Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {

Copy link
Copy Markdown
Member

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.

Suggested change
public final class ContextAttributes {
// TODO (jack-berg): delete after 2027-07-01 if progress hasn't been made on https://github.com/open-telemetry/opentelemetry-java/issues/8532
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?
Which instrumentations should skip an attribute?
Should we have a handler to decide which instrumentations opt to an attribute?
Are there attributes that make no sense across an entire context lifespan?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ public <T> T get(AttributeKey<T> key) {
return (T) entryValues[idx];
}

/**
* Returns whether an attribute with the given key name exists in this map, regardless of type.
*
* <p>Runs with expected/amortized O(1) complexity using the internal open-addressing table.
*
* @param key the attribute key name to look up. Must not be null.
*/
public boolean containsKey(String key) {
return hashTable[findSlot(key)] != EMPTY;
}

@Override
public int size() {
return size;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,26 @@ void get_returnsNullForAbsentKey() {
assertThat(map.get(stringKey("absent"))).isNull();
}

// ---- containsKey ----

@Test
void containsKey_returnsTrueForPresentKey() {
AttributesMap map = AttributesMap.create(10, Integer.MAX_VALUE);
map.put(stringKey("k"), "v");

assertThat(map.containsKey("k")).isTrue();
assertThat(map.containsKey("absent")).isFalse();
}

@Test
void containsKey_returnsTrueForSameNameDifferentType() {
AttributesMap map = AttributesMap.create(10, Integer.MAX_VALUE);
map.put(longKey("count"), 42L);

// containsKey() is name-based: true even if originally put as longKey("count")
assertThat(map.containsKey("count")).isTrue();
}

// ---- capacity ----

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

package io.opentelemetry.sdk.logs;

import io.opentelemetry.api.common.Attributes;
import io.opentelemetry.api.incubator.common.ContextAttributes;
import io.opentelemetry.context.Context;
import io.opentelemetry.sdk.common.InstrumentationScopeInfo;
import io.opentelemetry.sdk.logs.internal.LoggerConfig;

Expand All @@ -30,4 +33,8 @@ static SdkLogRecordBuilder createExtendedLogRecordBuilder(
SdkLogger logger) {
return new ExtendedSdkLogRecordBuilder(loggerSharedState, instrumentationScopeInfo, logger);
}

static Attributes getContextAttributes(Context context) {
return ContextAttributes.get(context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@

package io.opentelemetry.sdk.logs;

import io.opentelemetry.api.common.AttributeKey;
import io.opentelemetry.sdk.common.Clock;
import io.opentelemetry.sdk.common.CompletableResultCode;
import io.opentelemetry.sdk.common.InstrumentationScopeInfo;
import io.opentelemetry.sdk.common.internal.ExceptionAttributeResolver;
import io.opentelemetry.sdk.resources.Resource;
import io.opentelemetry.sdk.resources.internal.SdkResourceProvider;
import java.util.function.BiPredicate;
import java.util.function.Supplier;
import javax.annotation.Nullable;

Expand All @@ -25,6 +28,11 @@ final class LoggerSharedState {
private final Clock clock;
private final ExceptionAttributeResolver exceptionAttributeResolver;
private final SdkLoggerInstrumentation loggerInstrumentation;

@Nullable
private final BiPredicate<InstrumentationScopeInfo, AttributeKey<?>>
contextScopedAttributesFilter;

@Nullable private volatile CompletableResultCode shutdownResult = null;

LoggerSharedState(
Expand All @@ -33,13 +41,16 @@ final class LoggerSharedState {
LogRecordProcessor logRecordProcessor,
Clock clock,
ExceptionAttributeResolver exceptionAttributeResolver,
SdkLoggerInstrumentation loggerInstrumentation) {
SdkLoggerInstrumentation loggerInstrumentation,
@Nullable
BiPredicate<InstrumentationScopeInfo, AttributeKey<?>> contextScopedAttributesFilter) {
this.resourceProvider = resourceProvider;
this.logLimitsSupplier = logLimitsSupplier;
this.logRecordProcessor = logRecordProcessor;
this.clock = clock;
this.exceptionAttributeResolver = exceptionAttributeResolver;
this.loggerInstrumentation = loggerInstrumentation;
this.contextScopedAttributesFilter = contextScopedAttributesFilter;
}

Resource getResource() {
Expand All @@ -66,6 +77,11 @@ SdkLoggerInstrumentation getLoggerInstrumentation() {
return loggerInstrumentation;
}

@Nullable
BiPredicate<InstrumentationScopeInfo, AttributeKey<?>> getContextScopedAttributesFilter() {
return contextScopedAttributesFilter;
}

boolean hasBeenShutdown() {
return shutdownResult != null;
}
Expand Down
Loading
Loading