Repository navigation
feat(context): prototype context-scoped attributes (#8532) #8947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c431047
ef1468f
a9bcfb9
c845277
802432d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.api.incubator.common; | ||
|
|
||
| import io.opentelemetry.api.common.Attributes; | ||
| import io.opentelemetry.common.impl.ApiUsageLogger; | ||
| import io.opentelemetry.context.Context; | ||
| import io.opentelemetry.context.ContextKey; | ||
| import javax.annotation.Nullable; | ||
|
|
||
| // TODO (jack-berg): delete after 2027-07-01 if progress hasn't been made on | ||
| // https://github.com/open-telemetry/opentelemetry-specification/pull/4931 | ||
| /** | ||
| * Utility for associating {@link Attributes} with a {@link Context} for automatic propagation to | ||
| * telemetry signals produced within that context. | ||
| * | ||
| * <p>SDKs can be configured to stamp context-scoped attributes across telemetry signals, with the | ||
| * ability to filter based on signal, instrumentation scope, and attribute key. | ||
| * | ||
| * <h2>Cardinality Considerations</h2> | ||
| * | ||
| * <p>Attaching high-cardinality attributes to {@link Context} poses a critical risk to metrics. | ||
| * Synchronous metric recordings inherit these attributes into their metric point dimension sets, | ||
| * which can cause metric stream explosion and high memory consumption. Therefore, attributes | ||
| * associated with {@link Context} <b>must</b> be low-cardinality unless a filter is explicitly | ||
| * configured on the metric provider to exclude them. | ||
| * | ||
| * <h2>Bound Instruments</h2> | ||
| * | ||
| * <p>Bound instruments do not inherit dynamic context-scoped attributes. Their attribute sets are | ||
| * resolved once at bind time, and subsequent bound recording operations do not access or evaluate | ||
| * the active context. | ||
| */ | ||
| public final class ContextAttributes { | ||
|
|
||
| private static final ContextKey<Attributes> CONTEXT_ATTRIBUTES_KEY = | ||
| ContextKey.named("opentelemetry-context-attributes"); | ||
|
|
||
| private ContextAttributes() {} | ||
|
|
||
| /** | ||
| * Sets the {@link Attributes} on the given {@link Context}, completely replacing any previously | ||
| * associated context-scoped attributes. | ||
| * | ||
| * @param context the context to attach attributes to. If null, {@link Context#root()} is | ||
| * returned. | ||
| * @param attributes the authoritative attributes to associate. If null, {@code context} is | ||
| * returned unchanged. Pass {@link Attributes#empty()} to logically clear context-scoped | ||
| * attributes. | ||
| * @return a new context containing the specified attributes. | ||
| */ | ||
| public static Context set(@Nullable Context context, @Nullable Attributes attributes) { | ||
| if (context == null) { | ||
| ApiUsageLogger.logNullParam(ContextAttributes.class, "set", "context"); | ||
| return Context.root(); | ||
| } | ||
| if (attributes == null) { | ||
| ApiUsageLogger.logNullParam(ContextAttributes.class, "set", "attributes"); | ||
| return context; | ||
| } | ||
| if (attributes.isEmpty()) { | ||
| Attributes existing = context.get(CONTEXT_ATTRIBUTES_KEY); | ||
| if (existing == null || existing.isEmpty()) { | ||
| return context; | ||
| } | ||
| return context.with(CONTEXT_ATTRIBUTES_KEY, Attributes.empty()); | ||
| } | ||
| return context.with(CONTEXT_ATTRIBUTES_KEY, attributes); | ||
| } | ||
|
|
||
| /** | ||
| * Adds the given {@link Attributes} to the {@link Context}, merging them with any existing | ||
| * context-scoped attributes. Conflicting keys take the value from {@code attributes}. | ||
| * | ||
| * @param context the context to attach attributes to. If null, {@link Context#root()} is | ||
| * returned. | ||
| * @param attributes the attributes to add. If null or empty, {@code context} is returned | ||
| * unchanged. | ||
| * @return a new context containing the merged attributes. | ||
| */ | ||
| public static Context add(@Nullable Context context, @Nullable Attributes attributes) { | ||
| if (context == null) { | ||
| ApiUsageLogger.logNullParam(ContextAttributes.class, "add", "context"); | ||
| return Context.root(); | ||
| } | ||
| if (attributes == null) { | ||
| ApiUsageLogger.logNullParam(ContextAttributes.class, "add", "attributes"); | ||
| return context; | ||
| } | ||
| if (attributes.isEmpty()) { | ||
| return context; | ||
| } | ||
| Attributes existing = get(context); | ||
| if (existing.isEmpty()) { | ||
| return set(context, attributes); | ||
| } | ||
| return set(context, existing.toBuilder().putAll(attributes).build()); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the {@link Attributes} associated with the given {@code context}, or {@link | ||
| * Attributes#empty()} if none are present. | ||
| * | ||
| * <p>This method is intended primarily for telemetry pipeline components such as SDKs, samplers, | ||
| * processors, and exporters. Application code should treat context-scoped attributes as | ||
| * write-only via {@link #set(Context, Attributes)} or {@link #add(Context, Attributes)}. | ||
| */ | ||
| public static Attributes get(@Nullable Context context) { | ||
| if (context == null) { | ||
| return Attributes.empty(); | ||
| } | ||
| Attributes attributes = context.get(CONTEXT_ATTRIBUTES_KEY); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see usefulness in this feature but I find it hard to understand how to decide with instrumentations should include the attributes. How to assert or qualify the carnality of the attribute?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instrumentations are advised against including attributes (see javadoc at top). Think about the core use cases: tenant id / user id. An application would add these to context early in request lifecycle via interceptor or filter. They would also configure the SDK to include the attributes as needed based on signal, scope, and and attribute key. The OTEP left a lot of questions unanswered with the intent of discovering more via prototyping. If you have general feedback (i.e. not related to the java implementation), please add it to open-telemetry/opentelemetry-specification#5170 so all languages can benefit. |
||
| return attributes != null ? attributes : Attributes.empty(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| /* | ||
| * Copyright The OpenTelemetry Authors | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| package io.opentelemetry.api.incubator.common; | ||
|
|
||
| import static io.opentelemetry.api.common.AttributeKey.longKey; | ||
| import static io.opentelemetry.api.common.AttributeKey.stringKey; | ||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| import io.opentelemetry.api.common.Attributes; | ||
| import io.opentelemetry.context.Context; | ||
| import io.opentelemetry.context.Scope; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| class ContextAttributesTest { | ||
|
|
||
| @Test | ||
| void emptyContext_returnsEmptyAttributes() { | ||
| assertThat(ContextAttributes.get(Context.root())).isEqualTo(Attributes.empty()); | ||
| assertThat(ContextAttributes.get(null)).isEqualTo(Attributes.empty()); | ||
| } | ||
|
|
||
| @Test | ||
| void addNullValidation() { | ||
| Context root = Context.root(); | ||
| Attributes attrs = Attributes.of(stringKey("k"), "v"); | ||
| assertThat(ContextAttributes.add(null, attrs)).isEqualTo(Context.root()); | ||
| assertThat(ContextAttributes.add(root, null)).isSameAs(root); | ||
| } | ||
|
|
||
| @Test | ||
| void addEmptyAttributes_returnsSameContext() { | ||
| Context root = Context.root(); | ||
| assertThat(ContextAttributes.add(root, Attributes.empty())).isSameAs(root); | ||
| } | ||
|
|
||
| @Test | ||
| void setNullValidation() { | ||
| Context root = Context.root(); | ||
| Attributes attrs = Attributes.of(stringKey("k"), "v"); | ||
| assertThat(ContextAttributes.set(null, attrs)).isEqualTo(Context.root()); | ||
| assertThat(ContextAttributes.set(root, null)).isSameAs(root); | ||
| } | ||
|
|
||
| @Test | ||
| void setReplacesExistingAndClearsWithEmpty() { | ||
| Context ctx1 = | ||
| ContextAttributes.set( | ||
| Context.root(), Attributes.of(stringKey("k1"), "v1", longKey("k2"), 2L)); | ||
| assertThat(ContextAttributes.get(ctx1)) | ||
| .isEqualTo(Attributes.of(stringKey("k1"), "v1", longKey("k2"), 2L)); | ||
|
|
||
| // set() completely replaces previous attributes | ||
| Context ctx2 = ContextAttributes.set(ctx1, Attributes.of(stringKey("k3"), "v3")); | ||
| assertThat(ContextAttributes.get(ctx2)).isEqualTo(Attributes.of(stringKey("k3"), "v3")); | ||
|
|
||
| // set() with empty clears attributes | ||
| Context ctxCleared = ContextAttributes.set(ctx2, Attributes.empty()); | ||
| assertThat(ContextAttributes.get(ctxCleared)).isEqualTo(Attributes.empty()); | ||
|
|
||
| // set() with empty on already empty returns same context | ||
| assertThat(ContextAttributes.set(Context.root(), Attributes.empty())).isSameAs(Context.root()); | ||
| } | ||
|
|
||
| @Test | ||
| void addAndGet_singleLayer() { | ||
| Attributes attrs = Attributes.of(stringKey("user.id"), "u123", longKey("retry"), 1L); | ||
| Context ctx = ContextAttributes.add(Context.root(), attrs); | ||
|
|
||
| assertThat(ContextAttributes.get(ctx)).isEqualTo(attrs); | ||
| assertThat(ContextAttributes.get(Context.root())).isEqualTo(Attributes.empty()); | ||
| } | ||
|
|
||
| @Test | ||
| void addAndGet_nestedContextsMergeAndOverride() { | ||
| Context outer = | ||
| ContextAttributes.add( | ||
| Context.root(), Attributes.of(stringKey("k1"), "v1", stringKey("k2"), "v2")); | ||
| Context inner = | ||
| ContextAttributes.add( | ||
| outer, Attributes.of(stringKey("k2"), "v2-override", stringKey("k3"), "v3")); | ||
|
|
||
| assertThat(ContextAttributes.get(outer)) | ||
| .isEqualTo(Attributes.of(stringKey("k1"), "v1", stringKey("k2"), "v2")); | ||
| assertThat(ContextAttributes.get(inner)) | ||
| .isEqualTo( | ||
| Attributes.of( | ||
| stringKey("k1"), "v1", stringKey("k2"), "v2-override", stringKey("k3"), "v3")); | ||
| } | ||
|
|
||
| @Test | ||
| void addWithSameNameDifferentType_overwritesExisting() { | ||
| Context ctx1 = | ||
| ContextAttributes.add(Context.root(), Attributes.of(stringKey("foo"), "stringVal")); | ||
| Context ctx2 = ContextAttributes.add(ctx1, Attributes.of(longKey("foo"), 42L)); | ||
| assertThat(ContextAttributes.get(ctx2)).isEqualTo(Attributes.of(longKey("foo"), 42L)); | ||
| } | ||
|
|
||
| @Test | ||
| void scopeAttachment_restoresOuterContextAttributesOnClose() { | ||
| Context outer = ContextAttributes.add(Context.root(), Attributes.of(stringKey("env"), "prod")); | ||
| try (Scope ignoredOuter = outer.makeCurrent()) { | ||
| assertThat(ContextAttributes.get(Context.current())) | ||
| .isEqualTo(Attributes.of(stringKey("env"), "prod")); | ||
|
|
||
| Context inner = | ||
| ContextAttributes.add( | ||
| Context.current(), | ||
| Attributes.of(stringKey("env"), "staging", stringKey("req"), "r1")); | ||
| try (Scope ignoredInner = inner.makeCurrent()) { | ||
| assertThat(ContextAttributes.get(Context.current())) | ||
| .isEqualTo(Attributes.of(stringKey("env"), "staging", stringKey("req"), "r1")); | ||
| } | ||
|
|
||
| assertThat(ContextAttributes.get(Context.current())) | ||
| .isEqualTo(Attributes.of(stringKey("env"), "prod")); | ||
| } | ||
| assertThat(ContextAttributes.get(Context.current())).isEqualTo(Attributes.empty()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The otep was merged for this, but there has been no attempt yet to codify the otep in a spec PR. This repo is bound to content in the spec (otep != spec). I'm going to make a judgement call here and go ahead with this on the basis of the otep, but it can't live on indefinitely if nobody makes progress in the spec.
I want there to be a comment here reminding us of this.