Skip to content

Commit 96c9c72

Browse files
giortzisggetsentry-botclaudeadinauer
authored
feat: Add strict trace continuation support (#5136)
* feat: Add strict trace continuation support Extract org ID from DSN host, add strictTraceContinuation and orgId options, propagate sentry-org_id in baggage, and validate incoming traces per the decision matrix. Closes #5128 * Format code * Add changelog entry * Update API surface file for strict trace continuation Add public API declarations for new org ID and strict trace continuation methods on Baggage, PropagationContext, and SentryOptions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review comments for strict trace continuation - Make Dsn.orgId final, remove unnecessary setter - Fix test signatures to use List<String> for baggage headers - Add strictTraceContinuation and orgId to ExternalOptions and merge() - Add options to ManifestMetadataReader for Android manifest config - Use Sentry.getCurrentScopes().getOptions() in legacy fromHeaders overload - Improve CHANGELOG description with details about new options - Update API surface file for ExternalOptions changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix compilation errors after rebase on main - Add comment to empty catch block in PropagationContext to satisfy -Werror - Add setOrgId setter to Dsn class (remove final modifier on orgId field) to support the existing test for org ID override Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Move changelog entry to Unreleased section * Format code * fix: Address review comments — pass options to PropagationContext, fix OTel overload, add option tests, make Dsn.orgId final - Remove PropagationContext.fromHeaders overload without SentryOptions; all callers now pass options (or null) explicitly instead of relying on Sentry.getCurrentScopes() - Add SentryOptions parameter to the OTel-facing fromHeaders(SentryTraceHeader, Baggage, SpanId) overload so OpenTelemetry integrations also check orgId - Make Dsn.orgId final and remove the setter — orgId is only set during DSN parsing in the constructor - Add tests for strictTraceContinuation and orgId options in ExternalOptionsTest, SentryOptionsTest, and ManifestMetadataReaderTest - Improve CHANGELOG entry with customer-facing description - Update API declarations (apiDump) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: Add missing 8.34.1 changelog section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Format code * fix: Address PR review comments for strict trace continuation - Remove duplicated org ID check in PropagationContext.fromHeaders, pass options through to the single-check overload instead - Add debug log when trace is not continued in the SentryTraceHeader overload - Handle empty/blank org ID strings in shouldContinueTrace to avoid silently breaking traces - Update OtelSentrySpanProcessor to use PropagationContext.fromHeaders with options for org_id validation - Rename ExternalOptions property key to enable-strict-trace-continuation (matching the enable- prefix convention for newer options) - Update ExternalOptionsTest to use the new property key - Add strict-trace-continuation and org-id properties to all 3 Spring Boot SentryAutoConfigurationTest modules - Improve CHANGELOG entry with detailed customer-facing descriptions and configuration examples for all options Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Format code * fix(tracing): Clarify strict org validation debug log Update the trace-continuation rejection log message to cover all strict org ID validation failures, including missing org IDs, not just mismatches. Co-Authored-By: Claude <noreply@anthropic.com> * fix(android): Use enabled suffix for strict trace manifest key Rename the Android manifest option to io.sentry.strict-trace-continuation.enabled to align with existing enabled-style manifest flags. Update changelog documentation to match the new Android key. Co-Authored-By: Claude <noreply@anthropic.com> * fix(api): Mark effective org ID helper as internal Annotate SentryOptions.getEffectiveOrgId with ApiStatus.Internal since it is used as an internal helper for trace propagation org ID resolution. Co-Authored-By: Claude <noreply@anthropic.com> * ref(tracing): Extract trace continuation decision into TracingUtils Move strict trace continuation org-id validation logic to TracingUtils so it can be reused by tracing entry points. Update PropagationContext to call the shared helper and add dedicated TracingUtils tests for strict/non-strict org-id continuation outcomes. Co-Authored-By: Claude <noreply@anthropic.com> * fix(opentelemetry): Enforce strict continuation in propagators Apply strict trace continuation checks in all OpenTelemetry propagator extract paths before creating remote parent span context. When org-id validation fails, return the original context and ignore incoming sentry-trace and baggage to keep propagation behavior aligned with strict continuation requirements. Add rejection tests for OtelSentryPropagator, deprecated SentryPropagator, and OpenTelemetryOtlpPropagator. Co-Authored-By: Claude <noreply@anthropic.com> * Format code * fix(tracing): Fix empty orgId bypassing DSN fallback The getEffectiveOrgId() method only checked orgId != null, allowing empty strings and whitespace-only values to bypass the DSN fallback mechanism. This caused empty org IDs to propagate to outgoing baggage headers as sentry-org_id=, silently breaking trace continuation in strict mode. Update getEffectiveOrgId() to trim the orgId value and check if it's empty after trimming. Empty or blank values now correctly fall back to the DSN org ID instead of propagating as empty strings. Add comprehensive test coverage for all edge cases including empty strings, whitespace-only values, and their impact on baggage propagation and strict trace continuation. Co-Authored-By: Claude <noreply@anthropic.com> * Format code * ref: Remove redundant trim of already-trimmed effective org ID getEffectiveOrgId() already guarantees it returns either null or a trimmed, non-empty string. The defensive trim and empty check in shouldContinueTrace was dead code that could never change the result. Co-Authored-By: Claude <noreply@anthropic.com> * ref(tracing): Revert shouldContinueTrace check in OtelSentrySpanProcessor The propagator is the correct enforcement point for org ID validation. By the time the span processor runs, OTel has already created the span with the remote parent's trace ID. If shouldContinueTrace rejects here, it creates a fresh PropagationContext with a mismatched trace ID rather than cleanly rejecting the trace. Co-Authored-By: Claude <noreply@anthropic.com> * fix(test): Clean up global Sentry state in SentryPropagatorTest SentryPropagatorTest calls Sentry.init with strict trace continuation but never closes Sentry afterward. This leaks global state into SentrySpanProcessorTest where SentryPropagator uses the global ScopesAdapter and rejects incoming sentry-trace headers under the leaked strict mode configuration. Add @AfterTest that calls Sentry.close() to prevent state leakage. Co-Authored-By: Claude <noreply@anthropic.com> * fix(test): Use mock scopes in propagator strict continuation tests The strict continuation tests called Sentry.init with strict mode and org ID configuration, which leaked global state into other tests. Sentry.close() does not fully reset the global scope options, so SentrySpanProcessorTest's SentryPropagator (using ScopesAdapter) would reject incoming sentry-trace headers. Use the package-private constructor with mock IScopes instead of relying on global Sentry state for strict continuation validation. Co-Authored-By: Claude <noreply@anthropic.com> * fix(test): Reset OTel context in propagator test teardown Sentry.init uses OtelContextScopesStorage which pushes scopes onto the OTel context via makeCurrent(). The returned pop-token is discarded by Sentry.init, and OtelContextScopesStorage.close() is a no-op, so Sentry.close() alone does not restore the OTel context. Since JUnit reuses the same thread across test classes, stale scopes from SentryPropagatorTest (with strict continuation enabled) leaked into SentrySpanProcessorTest via Context.current(). Add Context.root().makeCurrent() after Sentry.close() in all propagator test teardowns to reset the thread-local OTel context. Co-Authored-By: Claude <noreply@anthropic.com> * fix(test): Add back test for inject with invalid span The previous commit replaced the invalid-span inject test with a no-span-in-context test. These are distinct scenarios: an explicitly invalid span vs no span at all. Restore the invalid span test and keep both. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Sentry Github Bot <bot+github-bot@sentry.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Alexander Dinauer <alexander.dinauer@sentry.io> Co-authored-by: Alexander Dinauer <adinauer@users.noreply.github.com>
1 parent 22ff2c7 commit 96c9c72

File tree

35 files changed

+963
-70
lines changed

35 files changed

+963
-70
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### Features
66

7+
- Prevent cross-organization trace continuation ([#5136](https://github.com/getsentry/sentry-java/pull/5136))
8+
- By default, the SDK now extracts the organization ID from the DSN (e.g. `o123.ingest.sentry.io`) and compares it with the `sentry-org_id` value in incoming baggage headers. When the two differ, the SDK starts a fresh trace instead of continuing the foreign one. This guards against accidentally linking traces across organizations.
9+
- New option `enableStrictTraceContinuation` (default `false`): when enabled, both the SDK's org ID **and** the incoming baggage org ID must be present and match for a trace to be continued. Traces with a missing org ID on either side are rejected. Configurable via code (`setStrictTraceContinuation(true)`), `sentry.properties` (`enable-strict-trace-continuation=true`), Android manifest (`io.sentry.strict-trace-continuation.enabled`), or Spring Boot (`sentry.strict-trace-continuation=true`).
10+
- New option `orgId`: allows explicitly setting the organization ID for self-hosted and Relay setups where it cannot be extracted from the DSN. Configurable via code (`setOrgId("123")`), `sentry.properties` (`org-id=123`), Android manifest (`io.sentry.org-id`), or Spring Boot (`sentry.org-id=123`).
711
- Android: Attachments on the scope will now be synced to native ([#5211](https://github.com/getsentry/sentry-java/pull/5211))
812
- Add THIRD_PARTY_NOTICES.md for vendored third-party code, bundled as SENTRY_THIRD_PARTY_NOTICES.md in the sentry JAR under META-INF ([#5186](https://github.com/getsentry/sentry-java/pull/5186))
913

sentry-android-core/src/main/java/io/sentry/android/core/ManifestMetadataReader.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ final class ManifestMetadataReader {
167167

168168
static final String FEEDBACK_SHOW_BRANDING = "io.sentry.feedback.show-branding";
169169

170+
static final String STRICT_TRACE_CONTINUATION = "io.sentry.strict-trace-continuation.enabled";
171+
static final String ORG_ID = "io.sentry.org-id";
172+
170173
static final String FEEDBACK_USE_SHAKE_GESTURE = "io.sentry.feedback.use-shake-gesture";
171174

172175
static final String SPOTLIGHT_ENABLE = "io.sentry.spotlight.enable";
@@ -667,6 +670,15 @@ static void applyMetadata(
667670
readBool(
668671
metadata, logger, FEEDBACK_USE_SHAKE_GESTURE, feedbackOptions.isUseShakeGesture()));
669672

673+
options.setStrictTraceContinuation(
674+
readBool(
675+
metadata, logger, STRICT_TRACE_CONTINUATION, options.isStrictTraceContinuation()));
676+
677+
final @Nullable String orgId = readString(metadata, logger, ORG_ID, null);
678+
if (orgId != null) {
679+
options.setOrgId(orgId);
680+
}
681+
670682
options.setEnableSpotlight(
671683
readBool(metadata, logger, SPOTLIGHT_ENABLE, options.isEnableSpotlight()));
672684

sentry-android-core/src/test/java/io/sentry/android/core/ManifestMetadataReaderTest.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2461,4 +2461,54 @@ class ManifestMetadataReaderTest {
24612461
// maskAllImages should also add WebView
24622462
assertTrue(fixture.options.screenshot.maskViewClasses.contains("android.webkit.WebView"))
24632463
}
2464+
2465+
@Test
2466+
fun `applyMetadata reads strictTraceContinuation and keeps default value if not found`() {
2467+
// Arrange
2468+
val context = fixture.getContext()
2469+
2470+
// Act
2471+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2472+
2473+
// Assert
2474+
assertFalse(fixture.options.isStrictTraceContinuation)
2475+
}
2476+
2477+
@Test
2478+
fun `applyMetadata reads strictTraceContinuation to options`() {
2479+
// Arrange
2480+
val bundle = bundleOf(ManifestMetadataReader.STRICT_TRACE_CONTINUATION to true)
2481+
val context = fixture.getContext(metaData = bundle)
2482+
2483+
// Act
2484+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2485+
2486+
// Assert
2487+
assertTrue(fixture.options.isStrictTraceContinuation)
2488+
}
2489+
2490+
@Test
2491+
fun `applyMetadata reads orgId and keeps null if not found`() {
2492+
// Arrange
2493+
val context = fixture.getContext()
2494+
2495+
// Act
2496+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2497+
2498+
// Assert
2499+
assertNull(fixture.options.orgId)
2500+
}
2501+
2502+
@Test
2503+
fun `applyMetadata reads orgId to options`() {
2504+
// Arrange
2505+
val bundle = bundleOf(ManifestMetadataReader.ORG_ID to "12345")
2506+
val context = fixture.getContext(metaData = bundle)
2507+
2508+
// Act
2509+
ManifestMetadataReader.applyMetadata(context, fixture.options, fixture.buildInfoProvider)
2510+
2511+
// Assert
2512+
assertEquals("12345", fixture.options.orgId)
2513+
}
24642514
}

sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSentryPropagator.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ public <C> Context extract(
113113

114114
final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER);
115115
final Baggage baggage = Baggage.fromHeader(baggageString);
116+
if (!TracingUtils.shouldContinueTrace(scopes.getOptions(), baggage)) {
117+
scopes
118+
.getOptions()
119+
.getLogger()
120+
.log(
121+
SentryLevel.DEBUG, "Not continuing trace due to strict org ID validation failure.");
122+
return context;
123+
}
116124
final @NotNull TraceState traceState = TraceState.getDefault();
117125

118126
SpanContext otelSpanContext =

sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentryPropagator.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import io.sentry.SentryLevel;
1717
import io.sentry.SentryTraceHeader;
1818
import io.sentry.exception.InvalidSentryTraceHeaderException;
19+
import io.sentry.util.TracingUtils;
1920
import java.util.Arrays;
2021
import java.util.Collection;
2122
import java.util.Collections;
@@ -98,6 +99,17 @@ public <C> Context extract(
9899
try {
99100
SentryTraceHeader sentryTraceHeader = new SentryTraceHeader(sentryTraceString);
100101

102+
final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER);
103+
Baggage baggage = Baggage.fromHeader(baggageString);
104+
if (!TracingUtils.shouldContinueTrace(scopes.getOptions(), baggage)) {
105+
scopes
106+
.getOptions()
107+
.getLogger()
108+
.log(
109+
SentryLevel.DEBUG, "Not continuing trace due to strict org ID validation failure.");
110+
return context;
111+
}
112+
101113
SpanContext otelSpanContext =
102114
SpanContext.createFromRemoteParent(
103115
sentryTraceHeader.getTraceId().toString(),
@@ -107,9 +119,6 @@ public <C> Context extract(
107119

108120
@NotNull
109121
Context modifiedContext = context.with(SentryOtelKeys.SENTRY_TRACE_KEY, sentryTraceHeader);
110-
111-
final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER);
112-
Baggage baggage = Baggage.fromHeader(baggageString);
113122
modifiedContext = modifiedContext.with(SentryOtelKeys.SENTRY_BAGGAGE_KEY, baggage);
114123

115124
Span wrappedSpan = Span.wrap(otelSpanContext);

sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ public SamplingResult shouldSample(
9191
final @NotNull PropagationContext propagationContext =
9292
sentryTraceHeader == null
9393
? new PropagationContext(new SentryId(traceId), randomSpanId, null, baggage, null)
94-
: PropagationContext.fromHeaders(sentryTraceHeader, baggage, randomSpanId);
94+
: PropagationContext.fromHeaders(
95+
sentryTraceHeader, baggage, randomSpanId, scopes.getOptions());
9596

9697
final @NotNull TransactionContext transactionContext =
9798
TransactionContext.fromPropagationContext(propagationContext);

sentry-opentelemetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySpanProcessor.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,10 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri
127127
new SentryId(traceData.getTraceId()), spanId, null, null, null)
128128
: TransactionContext.fromPropagationContext(
129129
PropagationContext.fromHeaders(
130-
traceData.getSentryTraceHeader(), traceData.getBaggage(), spanId));
130+
traceData.getSentryTraceHeader(),
131+
traceData.getBaggage(),
132+
spanId,
133+
scopes.getOptions()));
131134
;
132135
transactionContext.setName(transactionName);
133136
transactionContext.setTransactionNameSource(transactionNameSource);

sentry-opentelemetry/sentry-opentelemetry-core/src/test/kotlin/OtelSentryPropagatorTest.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import kotlin.test.AfterTest
1919
import kotlin.test.BeforeTest
2020
import kotlin.test.Test
2121
import kotlin.test.assertEquals
22+
import kotlin.test.assertFalse
2223
import kotlin.test.assertNotNull
2324
import kotlin.test.assertNull
2425
import kotlin.test.assertSame
@@ -38,6 +39,8 @@ class OtelSentryPropagatorTest {
3839
@AfterTest
3940
fun cleanup() {
4041
spanStorage.clear()
42+
Sentry.close()
43+
Context.root().makeCurrent()
4144
}
4245

4346
@Test
@@ -69,6 +72,26 @@ class OtelSentryPropagatorTest {
6972
assertSame(scopeInContext, scopes)
7073
}
7174

75+
@Test
76+
fun `ignores incoming headers when strict continuation rejects org id`() {
77+
Sentry.init { options ->
78+
options.dsn = "https://key@o2.ingest.sentry.io/123"
79+
options.isStrictTraceContinuation = true
80+
}
81+
val propagator = OtelSentryPropagator()
82+
val carrier: Map<String, String> =
83+
mapOf(
84+
"sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1",
85+
"baggage" to "sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-org_id=1",
86+
)
87+
88+
val newContext = propagator.extract(Context.root(), carrier, MapGetter())
89+
90+
assertFalse(Span.fromContext(newContext).spanContext.isValid)
91+
assertNull(newContext.get(SENTRY_TRACE_KEY))
92+
assertNull(newContext.get(SENTRY_BAGGAGE_KEY))
93+
}
94+
7295
@Test
7396
fun `uses incoming headers`() {
7497
val propagator = OtelSentryPropagator()
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.sentry.opentelemetry
2+
3+
import io.opentelemetry.api.trace.Span
4+
import io.opentelemetry.context.Context
5+
import io.sentry.Sentry
6+
import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_BAGGAGE_KEY
7+
import io.sentry.opentelemetry.SentryOtelKeys.SENTRY_TRACE_KEY
8+
import kotlin.test.AfterTest
9+
import kotlin.test.BeforeTest
10+
import kotlin.test.Test
11+
import kotlin.test.assertFalse
12+
import kotlin.test.assertNull
13+
14+
class SentryPropagatorTest {
15+
16+
@BeforeTest
17+
fun setup() {
18+
Sentry.init("https://key@sentry.io/proj")
19+
}
20+
21+
@AfterTest
22+
fun teardown() {
23+
Sentry.close()
24+
Context.root().makeCurrent()
25+
}
26+
27+
@Suppress("DEPRECATION")
28+
@Test
29+
fun `ignores incoming headers when strict continuation rejects org id`() {
30+
Sentry.init { options ->
31+
options.dsn = "https://key@o2.ingest.sentry.io/123"
32+
options.isStrictTraceContinuation = true
33+
}
34+
35+
val propagator = SentryPropagator()
36+
val carrier: Map<String, String> =
37+
mapOf(
38+
"sentry-trace" to "f9118105af4a2d42b4124532cd1065ff-424cffc8f94feeee-1",
39+
"baggage" to "sentry-trace_id=f9118105af4a2d42b4124532cd1065ff,sentry-org_id=1",
40+
)
41+
42+
val newContext = propagator.extract(Context.root(), carrier, MapGetter())
43+
44+
assertFalse(Span.fromContext(newContext).spanContext.isValid)
45+
assertNull(newContext.get(SENTRY_TRACE_KEY))
46+
assertNull(newContext.get(SENTRY_BAGGAGE_KEY))
47+
}
48+
}

sentry-opentelemetry/sentry-opentelemetry-otlp/src/main/java/io/sentry/opentelemetry/otlp/OpenTelemetryOtlpPropagator.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import io.sentry.SentryLevel;
1919
import io.sentry.SentryTraceHeader;
2020
import io.sentry.exception.InvalidSentryTraceHeaderException;
21+
import io.sentry.util.TracingUtils;
2122
import java.util.Arrays;
2223
import java.util.Collection;
2324
import java.util.List;
@@ -87,6 +88,16 @@ public <C> Context extract(
8788
SentryTraceHeader sentryTraceHeader = new SentryTraceHeader(sentryTraceString);
8889

8990
final @Nullable String baggageString = getter.get(carrier, BaggageHeader.BAGGAGE_HEADER);
91+
final @Nullable Baggage baggage =
92+
baggageString == null ? null : Baggage.fromHeader(baggageString);
93+
if (!TracingUtils.shouldContinueTrace(scopes.getOptions(), baggage)) {
94+
scopes
95+
.getOptions()
96+
.getLogger()
97+
.log(
98+
SentryLevel.DEBUG, "Not continuing trace due to strict org ID validation failure.");
99+
return context;
100+
}
90101
final @NotNull TraceState traceState = TraceState.getDefault();
91102

92103
final @NotNull TraceFlags traceFlags =
@@ -104,9 +115,8 @@ public <C> Context extract(
104115
Span wrappedSpan = Span.wrap(otelSpanContext);
105116

106117
@NotNull Context modifiedContext = context.with(wrappedSpan);
107-
if (baggageString != null) {
108-
modifiedContext =
109-
modifiedContext.with(SENTRY_BAGGAGE_KEY, Baggage.fromHeader(baggageString));
118+
if (baggage != null) {
119+
modifiedContext = modifiedContext.with(SENTRY_BAGGAGE_KEY, baggage);
110120
}
111121

112122
scopes

0 commit comments

Comments
 (0)