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,113 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.api.incubator.common;

import static java.util.Objects.requireNonNull;

import io.opentelemetry.api.common.Attributes;
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-java/issues/8532

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.

Suggested change
// https://github.com/open-telemetry/opentelemetry-java/issues/8532
// https://github.com/open-telemetry/opentelemetry-specification/pull/4931

Its progress on the spec we want to gate on

/**
* Utility for associating {@link Attributes} with a {@link Context} for automatic propagation to
* telemetry signals produced within that context.
*
* <h2>Usage and Authoring Guidance</h2>

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.

Less is more here.

General library instrumentations should avoid populating context-scoped attributes to prevent injecting library-internal concerns into ambient application telemetry.

If this isn't a direct quote from the otep, omit it.

Context-scoped attributes are stamped across all active telemetry signals produced within the context.

Not by default. Should more simply indicate that SDKs can configure context scoped attributes to be stamped across telemetry signals, with the ability to configure based on signal, scope, and attribute key. The metric cardinality warning is good to keep.

*
* <p>Context-scoped attributes are designed for cross-cutting contextual identity attributes (such
* as {@code tenant.id}, {@code account.id}, or deployment environment metadata) established at
* request or execution boundaries (for example, in HTTP/RPC server interceptors or messaging
* consumers). General library instrumentations should avoid populating context-scoped attributes to
* prevent injecting library-internal concerns into ambient application telemetry.
*
* <h2>Cardinality Considerations</h2>
*
* <p>Context-scoped attributes are stamped across all active telemetry signals produced within the
* context. While high or unbounded cardinality attributes (such as user identifiers or transaction
* IDs) are acceptable on event-based signals like spans and log records, 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
* (via {@code SdkMeterProviderBuilder.setContextScopedAttributesFilter}) to exclude them.
*
* <h2>Bound Instruments</h2>
*
* <p>Bound instruments (such as {@code BoundLongCounter} or {@code BoundDoubleCounter}) do
* <b>not</b> inherit context-scoped attributes. Their attribute sets are resolved once at {@code
* 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. Must not be null.
* @param attributes the authoritative attributes to associate. Must not be null. Pass {@link
* Attributes#empty()} to logically clear context-scoped attributes.
* @return a new context containing the specified attributes.
*/
public static Context set(Context context, Attributes attributes) {
requireNonNull(context, "context");
requireNonNull(attributes, "attributes");
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. Must not be null.
* @param attributes the attributes to add. Must not be null. If empty, {@code context} is
* returned unchanged.
* @return a new context containing the merged attributes.
*/
public static Context add(Context context, Attributes attributes) {
Comment thread
jack-berg marked this conversation as resolved.
Resolved
requireNonNull(context, "context");
requireNonNull(attributes, "attributes");

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.

Should log and gracefully degrade if these are null, following advice in "docs/knowledge/api-design.md -> Null guards -> Runtime / instrumentation-time boundaries (Span methods, metric recordings, log builders)"

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,129 @@
/*
* 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 static org.assertj.core.api.Assertions.assertThatThrownBy;

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();
assertThatThrownBy(() -> ContextAttributes.add(null, Attributes.of(stringKey("k"), "v")))
.isInstanceOf(NullPointerException.class)
.hasMessage("context");
assertThatThrownBy(() -> ContextAttributes.add(root, null))
.isInstanceOf(NullPointerException.class)
.hasMessage("attributes");
}

@Test
void addEmptyAttributes_returnsSameContext() {
Context root = Context.root();
assertThat(ContextAttributes.add(root, Attributes.empty())).isSameAs(root);
}

@Test
void setNullValidation() {
Context root = Context.root();
assertThatThrownBy(() -> ContextAttributes.set(null, Attributes.of(stringKey("k"), "v")))
.isInstanceOf(NullPointerException.class)
.hasMessage("context");
assertThatThrownBy(() -> ContextAttributes.set(root, null))
.isInstanceOf(NullPointerException.class)
.hasMessage("attributes");
}

@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 whose name to look up. Must not be null.
*/
public boolean contains(AttributeKey<?> key) {

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.

If its checking containment regardless of type, let's have the method signature reflect this by accepting String. The current signature suggests this method is redundant to calling get(AttributeKey) != null.

return hashTable[findSlot(key.getKey())] != EMPTY;
}

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

// ---- contains ----

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

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

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

// contains() is name-based: true even if queried with stringKey("count")
assertThat(map.contains(longKey("count"))).isTrue();
assertThat(map.contains(stringKey("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,15 @@ SdkLoggerInstrumentation getLoggerInstrumentation() {
return loggerInstrumentation;
}

boolean isContextScopedAttributesEnabled() {
return contextScopedAttributesFilter != null;
}

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

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