Skip to content

Commit f4a473e

Browse files
committed
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
1 parent fc52bb8 commit f4a473e

File tree

7 files changed

+249
-2
lines changed

7 files changed

+249
-2
lines changed

sentry/src/main/java/io/sentry/Baggage.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ public static Baggage fromEvent(
186186
baggage.setPublicKey(options.retrieveParsedDsn().getPublicKey());
187187
baggage.setRelease(event.getRelease());
188188
baggage.setEnvironment(event.getEnvironment());
189+
baggage.setOrgId(options.getEffectiveOrgId());
189190
baggage.setTransaction(transaction);
190191
// we don't persist sample rate
191192
baggage.setSampleRate(null);
@@ -450,6 +451,16 @@ public void setReplayId(final @Nullable String replayId) {
450451
set(DSCKeys.REPLAY_ID, replayId);
451452
}
452453

454+
@ApiStatus.Internal
455+
public @Nullable String getOrgId() {
456+
return get(DSCKeys.ORG_ID);
457+
}
458+
459+
@ApiStatus.Internal
460+
public void setOrgId(final @Nullable String orgId) {
461+
set(DSCKeys.ORG_ID, orgId);
462+
}
463+
453464
/**
454465
* Sets / updates a value, but only if the baggage is still mutable.
455466
*
@@ -501,6 +512,7 @@ public void setValuesFromTransaction(
501512
if (replayId != null && !SentryId.EMPTY_ID.equals(replayId)) {
502513
setReplayId(replayId.toString());
503514
}
515+
setOrgId(sentryOptions.getEffectiveOrgId());
504516
setSampleRate(sampleRate(samplingDecision));
505517
setSampled(StringUtils.toString(sampled(samplingDecision)));
506518
setSampleRand(sampleRand(samplingDecision));
@@ -536,6 +548,7 @@ public void setValuesFromScope(
536548
if (!SentryId.EMPTY_ID.equals(replayId)) {
537549
setReplayId(replayId.toString());
538550
}
551+
setOrgId(options.getEffectiveOrgId());
539552
setTransaction(null);
540553
setSampleRate(null);
541554
setSampled(null);
@@ -632,6 +645,7 @@ public static final class DSCKeys {
632645
public static final String SAMPLE_RAND = "sentry-sample_rand";
633646
public static final String SAMPLED = "sentry-sampled";
634647
public static final String REPLAY_ID = "sentry-replay_id";
648+
public static final String ORG_ID = "sentry-org_id";
635649

636650
public static final List<String> ALL =
637651
Arrays.asList(
@@ -644,6 +658,7 @@ public static final class DSCKeys {
644658
SAMPLE_RATE,
645659
SAMPLE_RAND,
646660
SAMPLED,
647-
REPLAY_ID);
661+
REPLAY_ID,
662+
ORG_ID);
648663
}
649664
}

sentry/src/main/java/io/sentry/Dsn.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22

33
import io.sentry.util.Objects;
44
import java.net.URI;
5+
import java.util.regex.Matcher;
6+
import java.util.regex.Pattern;
57
import org.jetbrains.annotations.NotNull;
68
import org.jetbrains.annotations.Nullable;
79

810
final class Dsn {
11+
private static final @NotNull Pattern ORG_ID_PATTERN = Pattern.compile("^o(\\d+)\\.");
12+
913
private final @NotNull String projectId;
1014
private final @Nullable String path;
1115
private final @Nullable String secretKey;
1216
private final @NotNull String publicKey;
1317
private final @NotNull URI sentryUri;
18+
private @Nullable String orgId;
1419

1520
/*
1621
/ The project ID which the authenticated user is bound to.
@@ -84,8 +89,25 @@ URI getSentryUri() {
8489
sentryUri =
8590
new URI(
8691
scheme, null, uri.getHost(), uri.getPort(), path + "api/" + projectId, null, null);
92+
93+
// Extract org ID from host (e.g., "o123.ingest.sentry.io" -> "123")
94+
final String host = uri.getHost();
95+
if (host != null) {
96+
final Matcher matcher = ORG_ID_PATTERN.matcher(host);
97+
if (matcher.find()) {
98+
orgId = matcher.group(1);
99+
}
100+
}
87101
} catch (Throwable e) {
88102
throw new IllegalArgumentException(e);
89103
}
90104
}
105+
106+
public @Nullable String getOrgId() {
107+
return orgId;
108+
}
109+
110+
void setOrgId(final @Nullable String orgId) {
111+
this.orgId = orgId;
112+
}
91113
}

sentry/src/main/java/io/sentry/PropagationContext.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,27 @@ public static PropagationContext fromHeaders(
2323
final @NotNull ILogger logger,
2424
final @Nullable String sentryTraceHeaderString,
2525
final @Nullable List<String> baggageHeaderStrings) {
26+
return fromHeaders(logger, sentryTraceHeaderString, baggageHeaderStrings, null);
27+
}
28+
29+
public static @NotNull PropagationContext fromHeaders(
30+
final @NotNull ILogger logger,
31+
final @Nullable String sentryTraceHeaderString,
32+
final @Nullable List<String> baggageHeaderStrings,
33+
final @Nullable SentryOptions options) {
2634
if (sentryTraceHeaderString == null) {
2735
return new PropagationContext();
2836
}
2937

3038
try {
3139
final @NotNull SentryTraceHeader traceHeader = new SentryTraceHeader(sentryTraceHeaderString);
3240
final @NotNull Baggage baggage = Baggage.fromHeader(baggageHeaderStrings, logger);
41+
42+
if (options != null && !shouldContinueTrace(options, baggage)) {
43+
logger.log(SentryLevel.DEBUG, "Not continuing trace due to org ID mismatch.");
44+
return new PropagationContext();
45+
}
46+
3347
return fromHeaders(traceHeader, baggage, null);
3448
} catch (InvalidSentryTraceHeaderException e) {
3549
logger.log(SentryLevel.DEBUG, e, "Failed to parse Sentry trace header: %s", e.getMessage());
@@ -149,4 +163,25 @@ public void setSampled(final @Nullable Boolean sampled) {
149163
// should never be null since we ensure it in ctor
150164
return sampleRand == null ? 0.0 : sampleRand;
151165
}
166+
167+
static boolean shouldContinueTrace(
168+
final @NotNull SentryOptions options, final @Nullable Baggage baggage) {
169+
final @Nullable String sdkOrgId = options.getEffectiveOrgId();
170+
final @Nullable String baggageOrgId = baggage != null ? baggage.getOrgId() : null;
171+
172+
// Mismatched org IDs always reject regardless of strict mode
173+
if (sdkOrgId != null && baggageOrgId != null && !sdkOrgId.equals(baggageOrgId)) {
174+
return false;
175+
}
176+
177+
// In strict mode, both must be present and match (unless both are missing)
178+
if (options.isStrictTraceContinuation()) {
179+
if (sdkOrgId == null && baggageOrgId == null) {
180+
return true;
181+
}
182+
return sdkOrgId != null && sdkOrgId.equals(baggageOrgId);
183+
}
184+
185+
return true;
186+
}
152187
}

sentry/src/main/java/io/sentry/Scopes.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1135,7 +1135,7 @@ public void reportFullyDisplayed() {
11351135
final @Nullable String sentryTrace, final @Nullable List<String> baggageHeaders) {
11361136
@NotNull
11371137
PropagationContext propagationContext =
1138-
PropagationContext.fromHeaders(getOptions().getLogger(), sentryTrace, baggageHeaders);
1138+
PropagationContext.fromHeaders(getOptions().getLogger(), sentryTrace, baggageHeaders, getOptions());
11391139
configureScope(
11401140
(scope) -> {
11411141
scope.withPropagationContext(

sentry/src/main/java/io/sentry/SentryOptions.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,21 @@ public class SentryOptions {
427427
/** Whether to propagate W3C traceparent HTTP header. */
428428
private boolean propagateTraceparent = false;
429429

430+
/**
431+
* Controls whether the SDK requires matching org IDs from incoming baggage to continue a trace.
432+
* When true, both the SDK's org ID and the incoming baggage org ID must be present and match.
433+
* When false, a mismatch between present org IDs will still start a new trace, but missing org
434+
* IDs on either side are tolerated.
435+
*/
436+
private boolean strictTraceContinuation = false;
437+
438+
/**
439+
* An optional organization ID. The SDK will try to extract it from the DSN in most cases but you
440+
* can provide it explicitly for self-hosted and Relay setups. This value is used for trace
441+
* propagation and for features like {@link #strictTraceContinuation}.
442+
*/
443+
private @Nullable String orgId;
444+
430445
/** Proguard UUID. */
431446
private @Nullable String proguardUuid;
432447

@@ -2287,6 +2302,37 @@ public void setPropagateTraceparent(final boolean propagateTraceparent) {
22872302
this.propagateTraceparent = propagateTraceparent;
22882303
}
22892304

2305+
public boolean isStrictTraceContinuation() {
2306+
return strictTraceContinuation;
2307+
}
2308+
2309+
public void setStrictTraceContinuation(final boolean strictTraceContinuation) {
2310+
this.strictTraceContinuation = strictTraceContinuation;
2311+
}
2312+
2313+
public @Nullable String getOrgId() {
2314+
return orgId;
2315+
}
2316+
2317+
public void setOrgId(final @Nullable String orgId) {
2318+
this.orgId = orgId;
2319+
}
2320+
2321+
/**
2322+
* Returns the effective org ID, preferring the explicit config option over the DSN-parsed value.
2323+
*/
2324+
public @Nullable String getEffectiveOrgId() {
2325+
if (orgId != null) {
2326+
return orgId;
2327+
}
2328+
try {
2329+
final @Nullable String dsnOrgId = retrieveParsedDsn().getOrgId();
2330+
return dsnOrgId;
2331+
} catch (Throwable e) {
2332+
return null;
2333+
}
2334+
}
2335+
22902336
/**
22912337
* Returns a Proguard UUID.
22922338
*

sentry/src/test/java/io/sentry/DsnTest.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,4 +103,36 @@ class DsnTest {
103103
Dsn("HTTP://publicKey:secretKey@host/path/id")
104104
Dsn("HTTPS://publicKey:secretKey@host/path/id")
105105
}
106+
107+
@Test
108+
fun `extracts org id from host`() {
109+
val dsn = Dsn("https://key@o123.ingest.sentry.io/456")
110+
assertEquals("123", dsn.orgId)
111+
}
112+
113+
@Test
114+
fun `extracts single digit org id from host`() {
115+
val dsn = Dsn("https://key@o1.ingest.us.sentry.io/456")
116+
assertEquals("1", dsn.orgId)
117+
}
118+
119+
@Test
120+
fun `returns null org id when host has no org prefix`() {
121+
val dsn = Dsn("https://key@sentry.io/456")
122+
assertNull(dsn.orgId)
123+
}
124+
125+
@Test
126+
fun `returns null org id for non-standard host`() {
127+
val dsn = Dsn("http://key@localhost:9000/456")
128+
assertNull(dsn.orgId)
129+
}
130+
131+
@Test
132+
fun `org id can be overridden via setter`() {
133+
val dsn = Dsn("https://key@o123.ingest.sentry.io/456")
134+
assertEquals("123", dsn.orgId)
135+
dsn.setOrgId("999")
136+
assertEquals("999", dsn.orgId)
137+
}
106138
}

sentry/src/test/java/io/sentry/PropagationContextTest.kt

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package io.sentry
22

33
import kotlin.test.Test
4+
import kotlin.test.assertEquals
45
import kotlin.test.assertFalse
6+
import kotlin.test.assertNotEquals
57
import kotlin.test.assertNotNull
68
import kotlin.test.assertTrue
79

@@ -42,4 +44,99 @@ class PropagationContextTest {
4244
assertTrue(propagationContext.baggage.isMutable)
4345
assertFalse(propagationContext.baggage.isShouldFreeze)
4446
}
47+
48+
// Decision matrix tests for shouldContinueTrace
49+
50+
private val incomingTraceId = "bc6d53f15eb88f4320054569b8c553d4"
51+
private val sentryTrace = "bc6d53f15eb88f4320054569b8c553d4-b72fa28504b07285-1"
52+
53+
private fun makeOptions(dsnOrgId: String?, explicitOrgId: String? = null, strict: Boolean = false): SentryOptions {
54+
val options = SentryOptions()
55+
if (dsnOrgId != null) {
56+
options.dsn = "https://key@o$dsnOrgId.ingest.sentry.io/123"
57+
} else {
58+
options.dsn = "https://key@sentry.io/123"
59+
}
60+
options.orgId = explicitOrgId
61+
options.isStrictTraceContinuation = strict
62+
return options
63+
}
64+
65+
private fun makeBaggage(orgId: String?): String {
66+
val parts = mutableListOf("sentry-trace_id=$incomingTraceId")
67+
if (orgId != null) {
68+
parts.add("sentry-org_id=$orgId")
69+
}
70+
return parts.joinToString(",")
71+
}
72+
73+
@Test
74+
fun `strict=false, matching orgs - continues trace`() {
75+
val options = makeOptions(dsnOrgId = "1", strict = false)
76+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage("1"), options)
77+
assertEquals(incomingTraceId, pc.traceId.toString())
78+
}
79+
80+
@Test
81+
fun `strict=false, baggage missing org - continues trace`() {
82+
val options = makeOptions(dsnOrgId = "1", strict = false)
83+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage(null), options)
84+
assertEquals(incomingTraceId, pc.traceId.toString())
85+
}
86+
87+
@Test
88+
fun `strict=false, sdk missing org - continues trace`() {
89+
val options = makeOptions(dsnOrgId = null, strict = false)
90+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage("1"), options)
91+
assertEquals(incomingTraceId, pc.traceId.toString())
92+
}
93+
94+
@Test
95+
fun `strict=false, both missing org - continues trace`() {
96+
val options = makeOptions(dsnOrgId = null, strict = false)
97+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage(null), options)
98+
assertEquals(incomingTraceId, pc.traceId.toString())
99+
}
100+
101+
@Test
102+
fun `strict=false, mismatched orgs - starts new trace`() {
103+
val options = makeOptions(dsnOrgId = "2", strict = false)
104+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage("1"), options)
105+
assertNotEquals(incomingTraceId, pc.traceId.toString())
106+
}
107+
108+
@Test
109+
fun `strict=true, matching orgs - continues trace`() {
110+
val options = makeOptions(dsnOrgId = "1", strict = true)
111+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage("1"), options)
112+
assertEquals(incomingTraceId, pc.traceId.toString())
113+
}
114+
115+
@Test
116+
fun `strict=true, baggage missing org - starts new trace`() {
117+
val options = makeOptions(dsnOrgId = "1", strict = true)
118+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage(null), options)
119+
assertNotEquals(incomingTraceId, pc.traceId.toString())
120+
}
121+
122+
@Test
123+
fun `strict=true, sdk missing org - starts new trace`() {
124+
val options = makeOptions(dsnOrgId = null, strict = true)
125+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage("1"), options)
126+
assertNotEquals(incomingTraceId, pc.traceId.toString())
127+
}
128+
129+
@Test
130+
fun `strict=true, both missing org - continues trace`() {
131+
val options = makeOptions(dsnOrgId = null, strict = true)
132+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage(null), options)
133+
assertEquals(incomingTraceId, pc.traceId.toString())
134+
}
135+
136+
@Test
137+
fun `strict=true, mismatched orgs - starts new trace`() {
138+
val options = makeOptions(dsnOrgId = "2", strict = true)
139+
val pc = PropagationContext.fromHeaders(NoOpLogger.getInstance(), sentryTrace, makeBaggage("1"), options)
140+
assertNotEquals(incomingTraceId, pc.traceId.toString())
141+
}
45142
}

0 commit comments

Comments
 (0)