Skip to content

Commit 93704e1

Browse files
antonisclaude
andauthored
feat(tracing): Implement strict trace continuation (#5829)
* feat(tracing): Implement strict trace continuation Expose strictTraceContinuation and orgId options for cross-org trace validation. The JS layer is handled by @sentry/core 10.43.0 which already implements shouldContinueTrace and org_id propagation in DSC. This commit adds native bridge support to pass these options through to the Android (SentryAndroidOptions) and iOS (SentryOptions) native SDKs during initialization, and adds comprehensive tests covering: - Option pass-through to native SDK initialization - Option pass-through via Sentry.init() - continueTrace behavior with matching/mismatching org IDs - strictTraceContinuation=true/false decision matrix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: Add changelog entry for strict trace continuation * style: Fix oxfmt import spacing --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5bf96de commit 93704e1

6 files changed

Lines changed: 347 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
- Enable "Open Sentry" button in Playground for Expo apps ([#5947](https://github.com/getsentry/sentry-react-native/pull/5947))
1414
- Add `attachAllThreads` option to attach full stack traces for all threads to captured events on iOS ([#5960](https://github.com/getsentry/sentry-react-native/issues/5960))
15+
- Add `strictTraceContinuation` and `orgId` options for trace continuation validation ([#5829](https://github.com/getsentry/sentry-react-native/pull/5829))
1516
- Add `deeplinkIntegration` for automatic deep link breadcrumbs ([#5983](https://github.com/getsentry/sentry-react-native/pull/5983))
1617
- Name navigation spans using dispatched action payload when `useDispatchedActionData` is enabled ([#5982](https://github.com/getsentry/sentry-react-native/pull/5982))
1718

packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,16 @@ static void getSentryAndroidOptions(
142142
if (rnOptions.hasKey("sendDefaultPii")) {
143143
options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii"));
144144
}
145+
if (rnOptions.hasKey("strictTraceContinuation")) {
146+
options.setStrictTraceContinuation(rnOptions.getBoolean("strictTraceContinuation"));
147+
}
148+
if (rnOptions.hasKey("orgId")) {
149+
if (rnOptions.getType("orgId") == ReadableType.String) {
150+
options.setOrgId(rnOptions.getString("orgId"));
151+
} else if (rnOptions.getType("orgId") == ReadableType.Number) {
152+
options.setOrgId(String.valueOf((long) rnOptions.getDouble("orgId")));
153+
}
154+
}
145155
if (rnOptions.hasKey("maxQueueSize")) {
146156
options.setMaxQueueSize(rnOptions.getInt("maxQueueSize"));
147157
}

packages/core/ios/RNSentryStart.m

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull)
100100
}
101101
}
102102

103+
// Set strict trace continuation options
104+
if ([mutableOptions valueForKey:@"strictTraceContinuation"] != nil) {
105+
sentryOptions.strictTraceContinuation =
106+
[mutableOptions[@"strictTraceContinuation"] boolValue];
107+
}
108+
if ([mutableOptions valueForKey:@"orgId"] != nil) {
109+
id orgIdValue = [mutableOptions valueForKey:@"orgId"];
110+
if ([orgIdValue isKindOfClass:[NSString class]]) {
111+
sentryOptions.orgId = orgIdValue;
112+
} else if ([orgIdValue isKindOfClass:[NSNumber class]]) {
113+
sentryOptions.orgId = [orgIdValue stringValue];
114+
}
115+
}
116+
103117
if (isSessionReplayEnabled) {
104118
[RNSentryExperimentalOptions setEnableSessionReplayInUnreliableEnvironment:YES
105119
sentryOptions:sentryOptions];

packages/core/test/sdk.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,34 @@ describe('Tests the SDK functionality', () => {
416416
});
417417
});
418418

419+
describe('strictTraceContinuation', () => {
420+
it('passes strictTraceContinuation option through to client options', () => {
421+
init({
422+
strictTraceContinuation: true,
423+
});
424+
expect(usedOptions()?.strictTraceContinuation).toBe(true);
425+
});
426+
427+
it('passes orgId option through to client options', () => {
428+
init({
429+
orgId: '12345',
430+
});
431+
expect(usedOptions()?.orgId).toBe('12345');
432+
});
433+
434+
it('passes numeric orgId option through to client options', () => {
435+
init({
436+
orgId: 12345,
437+
});
438+
expect(usedOptions()?.orgId).toBe(12345);
439+
});
440+
441+
it('defaults strictTraceContinuation to undefined when not set', () => {
442+
init({});
443+
expect(usedOptions()?.strictTraceContinuation).toBeUndefined();
444+
});
445+
});
446+
419447
describe('beforeBreadcrumb', () => {
420448
it('should filters out dev server breadcrumbs', () => {
421449
const devServerUrl = 'http://localhost:8081';
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { continueTrace, getCurrentScope, setCurrentClient } from '@sentry/core';
2+
3+
import { getDefaultTestClientOptions, TestClient } from './mocks/client';
4+
5+
describe('strictTraceContinuation', () => {
6+
let client: TestClient;
7+
8+
afterEach(() => {
9+
jest.clearAllMocks();
10+
});
11+
12+
describe('with matching org IDs', () => {
13+
beforeEach(() => {
14+
client = new TestClient(
15+
getDefaultTestClientOptions({
16+
tracesSampleRate: 1.0,
17+
dsn: 'https://abc@o123.ingest.sentry.io/1234',
18+
}),
19+
);
20+
setCurrentClient(client);
21+
client.init();
22+
});
23+
24+
it('continues trace when baggage org_id matches DSN org ID', () => {
25+
const scope = continueTrace(
26+
{
27+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
28+
baggage: 'sentry-org_id=123',
29+
},
30+
() => {
31+
return getCurrentScope();
32+
},
33+
);
34+
35+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
36+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
37+
});
38+
});
39+
40+
describe('with mismatching org IDs', () => {
41+
beforeEach(() => {
42+
client = new TestClient(
43+
getDefaultTestClientOptions({
44+
tracesSampleRate: 1.0,
45+
dsn: 'https://abc@o123.ingest.sentry.io/1234',
46+
}),
47+
);
48+
setCurrentClient(client);
49+
client.init();
50+
});
51+
52+
it('starts new trace when baggage org_id does not match DSN org ID', () => {
53+
const scope = continueTrace(
54+
{
55+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
56+
baggage: 'sentry-org_id=456',
57+
},
58+
() => {
59+
return getCurrentScope();
60+
},
61+
);
62+
63+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
64+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
65+
});
66+
});
67+
68+
describe('with orgId option override', () => {
69+
beforeEach(() => {
70+
client = new TestClient(
71+
getDefaultTestClientOptions({
72+
tracesSampleRate: 1.0,
73+
dsn: 'https://abc@o123.ingest.sentry.io/1234',
74+
orgId: '999',
75+
}),
76+
);
77+
setCurrentClient(client);
78+
client.init();
79+
});
80+
81+
it('uses orgId option over DSN-extracted org ID', () => {
82+
// baggage org_id=123 matches DSN but NOT the orgId option (999)
83+
const scope = continueTrace(
84+
{
85+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
86+
baggage: 'sentry-org_id=123',
87+
},
88+
() => {
89+
return getCurrentScope();
90+
},
91+
);
92+
93+
// Should start new trace because orgId option (999) != baggage org_id (123)
94+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
95+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
96+
});
97+
98+
it('continues trace when baggage matches orgId option', () => {
99+
const scope = continueTrace(
100+
{
101+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
102+
baggage: 'sentry-org_id=999',
103+
},
104+
() => {
105+
return getCurrentScope();
106+
},
107+
);
108+
109+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
110+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
111+
});
112+
});
113+
114+
describe('strictTraceContinuation=true', () => {
115+
beforeEach(() => {
116+
client = new TestClient(
117+
getDefaultTestClientOptions({
118+
tracesSampleRate: 1.0,
119+
dsn: 'https://abc@o123.ingest.sentry.io/1234',
120+
strictTraceContinuation: true,
121+
}),
122+
);
123+
setCurrentClient(client);
124+
client.init();
125+
});
126+
127+
it('starts new trace when baggage has no org_id', () => {
128+
const scope = continueTrace(
129+
{
130+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
131+
baggage: 'sentry-environment=production',
132+
},
133+
() => {
134+
return getCurrentScope();
135+
},
136+
);
137+
138+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
139+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
140+
});
141+
142+
it('starts new trace when SDK has no org_id but baggage does', () => {
143+
// Use a DSN without org ID in hostname
144+
const clientWithoutOrgId = new TestClient(
145+
getDefaultTestClientOptions({
146+
tracesSampleRate: 1.0,
147+
dsn: 'https://abc@sentry.example.com/1234',
148+
strictTraceContinuation: true,
149+
}),
150+
);
151+
setCurrentClient(clientWithoutOrgId);
152+
clientWithoutOrgId.init();
153+
154+
const scope = continueTrace(
155+
{
156+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
157+
baggage: 'sentry-org_id=123',
158+
},
159+
() => {
160+
return getCurrentScope();
161+
},
162+
);
163+
164+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
165+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
166+
});
167+
168+
it('continues trace when both org IDs are missing', () => {
169+
const clientWithoutOrgId = new TestClient(
170+
getDefaultTestClientOptions({
171+
tracesSampleRate: 1.0,
172+
dsn: 'https://abc@sentry.example.com/1234',
173+
strictTraceContinuation: true,
174+
}),
175+
);
176+
setCurrentClient(clientWithoutOrgId);
177+
clientWithoutOrgId.init();
178+
179+
const scope = continueTrace(
180+
{
181+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
182+
baggage: 'sentry-environment=production',
183+
},
184+
() => {
185+
return getCurrentScope();
186+
},
187+
);
188+
189+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
190+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
191+
});
192+
});
193+
194+
describe('strictTraceContinuation=false (default)', () => {
195+
beforeEach(() => {
196+
client = new TestClient(
197+
getDefaultTestClientOptions({
198+
tracesSampleRate: 1.0,
199+
dsn: 'https://abc@o123.ingest.sentry.io/1234',
200+
strictTraceContinuation: false,
201+
}),
202+
);
203+
setCurrentClient(client);
204+
client.init();
205+
});
206+
207+
it('continues trace when baggage has no org_id', () => {
208+
const scope = continueTrace(
209+
{
210+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
211+
baggage: 'sentry-environment=production',
212+
},
213+
() => {
214+
return getCurrentScope();
215+
},
216+
);
217+
218+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
219+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
220+
});
221+
222+
it('continues trace when SDK has no org_id but baggage does', () => {
223+
const clientWithoutOrgId = new TestClient(
224+
getDefaultTestClientOptions({
225+
tracesSampleRate: 1.0,
226+
dsn: 'https://abc@sentry.example.com/1234',
227+
strictTraceContinuation: false,
228+
}),
229+
);
230+
setCurrentClient(clientWithoutOrgId);
231+
clientWithoutOrgId.init();
232+
233+
const scope = continueTrace(
234+
{
235+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
236+
baggage: 'sentry-org_id=123',
237+
},
238+
() => {
239+
return getCurrentScope();
240+
},
241+
);
242+
243+
expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012');
244+
expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012');
245+
});
246+
247+
it('still starts new trace when org IDs mismatch', () => {
248+
const scope = continueTrace(
249+
{
250+
sentryTrace: '12312012123120121231201212312012-1121201211212012-1',
251+
baggage: 'sentry-org_id=456',
252+
},
253+
() => {
254+
return getCurrentScope();
255+
},
256+
);
257+
258+
expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012');
259+
expect(scope.getPropagationContext().parentSpanId).toBeUndefined();
260+
});
261+
});
262+
});

packages/core/test/wrapper.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,38 @@ describe('Tests Native Wrapper', () => {
397397
expect(initParameter.enableLogs).toBe(expectedEnableLogs);
398398
expect(initParameter.logsOrigin).toBeUndefined();
399399
});
400+
401+
test('passes strictTraceContinuation option to native SDK', async () => {
402+
await NATIVE.initNativeSdk({
403+
dsn: 'test',
404+
enableNative: true,
405+
autoInitializeNativeSdk: true,
406+
strictTraceContinuation: true,
407+
devServerUrl: undefined,
408+
defaultSidecarUrl: undefined,
409+
mobileReplayOptions: undefined,
410+
});
411+
412+
expect(RNSentry.initNativeSdk).toHaveBeenCalled();
413+
const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction<any>).mock.calls[0][0];
414+
expect(initParameter.strictTraceContinuation).toBe(true);
415+
});
416+
417+
test('passes orgId option to native SDK', async () => {
418+
await NATIVE.initNativeSdk({
419+
dsn: 'test',
420+
enableNative: true,
421+
autoInitializeNativeSdk: true,
422+
orgId: '12345',
423+
devServerUrl: undefined,
424+
defaultSidecarUrl: undefined,
425+
mobileReplayOptions: undefined,
426+
});
427+
428+
expect(RNSentry.initNativeSdk).toHaveBeenCalled();
429+
const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction<any>).mock.calls[0][0];
430+
expect(initParameter.orgId).toBe('12345');
431+
});
400432
});
401433

402434
describe('sendEnvelope', () => {

0 commit comments

Comments
 (0)