From 914b82a96a08d1c858a11a34b9c7d1e6ca8a21e9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 17 Mar 2026 14:31:01 +0100 Subject: [PATCH 1/2] 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) --- .../java/io/sentry/react/RNSentryStart.java | 10 + packages/core/ios/RNSentryStart.m | 14 + packages/core/test/sdk.test.ts | 28 ++ .../core/test/strictTraceContinuation.test.ts | 261 ++++++++++++++++++ packages/core/test/wrapper.test.ts | 32 +++ 5 files changed, 345 insertions(+) create mode 100644 packages/core/test/strictTraceContinuation.test.ts diff --git a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java index b2709fb9f4..db9bfd9a31 100644 --- a/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java +++ b/packages/core/android/src/main/java/io/sentry/react/RNSentryStart.java @@ -142,6 +142,16 @@ static void getSentryAndroidOptions( if (rnOptions.hasKey("sendDefaultPii")) { options.setSendDefaultPii(rnOptions.getBoolean("sendDefaultPii")); } + if (rnOptions.hasKey("strictTraceContinuation")) { + options.setStrictTraceContinuation(rnOptions.getBoolean("strictTraceContinuation")); + } + if (rnOptions.hasKey("orgId")) { + if (rnOptions.getType("orgId") == ReadableType.String) { + options.setOrgId(rnOptions.getString("orgId")); + } else if (rnOptions.getType("orgId") == ReadableType.Number) { + options.setOrgId(String.valueOf((long) rnOptions.getDouble("orgId"))); + } + } if (rnOptions.hasKey("maxQueueSize")) { options.setMaxQueueSize(rnOptions.getInt("maxQueueSize")); } diff --git a/packages/core/ios/RNSentryStart.m b/packages/core/ios/RNSentryStart.m index db86096fe1..ef136d336d 100644 --- a/packages/core/ios/RNSentryStart.m +++ b/packages/core/ios/RNSentryStart.m @@ -97,6 +97,20 @@ + (SentryOptions *_Nullable)createOptionsWithDictionary:(NSDictionary *_Nonnull) } } + // Set strict trace continuation options + if ([mutableOptions valueForKey:@"strictTraceContinuation"] != nil) { + sentryOptions.strictTraceContinuation = + [mutableOptions[@"strictTraceContinuation"] boolValue]; + } + if ([mutableOptions valueForKey:@"orgId"] != nil) { + id orgIdValue = [mutableOptions valueForKey:@"orgId"]; + if ([orgIdValue isKindOfClass:[NSString class]]) { + sentryOptions.orgId = orgIdValue; + } else if ([orgIdValue isKindOfClass:[NSNumber class]]) { + sentryOptions.orgId = [orgIdValue stringValue]; + } + } + if (isSessionReplayEnabled) { [RNSentryExperimentalOptions setEnableSessionReplayInUnreliableEnvironment:YES sentryOptions:sentryOptions]; diff --git a/packages/core/test/sdk.test.ts b/packages/core/test/sdk.test.ts index 66cf4a927b..2143beb795 100644 --- a/packages/core/test/sdk.test.ts +++ b/packages/core/test/sdk.test.ts @@ -389,6 +389,34 @@ describe('Tests the SDK functionality', () => { }); }); + describe('strictTraceContinuation', () => { + it('passes strictTraceContinuation option through to client options', () => { + init({ + strictTraceContinuation: true, + }); + expect(usedOptions()?.strictTraceContinuation).toBe(true); + }); + + it('passes orgId option through to client options', () => { + init({ + orgId: '12345', + }); + expect(usedOptions()?.orgId).toBe('12345'); + }); + + it('passes numeric orgId option through to client options', () => { + init({ + orgId: 12345, + }); + expect(usedOptions()?.orgId).toBe(12345); + }); + + it('defaults strictTraceContinuation to undefined when not set', () => { + init({}); + expect(usedOptions()?.strictTraceContinuation).toBeUndefined(); + }); + }); + describe('beforeBreadcrumb', () => { it('should filters out dev server breadcrumbs', () => { const devServerUrl = 'http://localhost:8081'; diff --git a/packages/core/test/strictTraceContinuation.test.ts b/packages/core/test/strictTraceContinuation.test.ts new file mode 100644 index 0000000000..e50deaf885 --- /dev/null +++ b/packages/core/test/strictTraceContinuation.test.ts @@ -0,0 +1,261 @@ +import { continueTrace, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { getDefaultTestClientOptions, TestClient } from './mocks/client'; + +describe('strictTraceContinuation', () => { + let client: TestClient; + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with matching org IDs', () => { + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@o123.ingest.sentry.io/1234', + }), + ); + setCurrentClient(client); + client.init(); + }); + + it('continues trace when baggage org_id matches DSN org ID', () => { + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + }); + + describe('with mismatching org IDs', () => { + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@o123.ingest.sentry.io/1234', + }), + ); + setCurrentClient(client); + client.init(); + }); + + it('starts new trace when baggage org_id does not match DSN org ID', () => { + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=456', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + }); + + describe('with orgId option override', () => { + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@o123.ingest.sentry.io/1234', + orgId: '999', + }), + ); + setCurrentClient(client); + client.init(); + }); + + it('uses orgId option over DSN-extracted org ID', () => { + // baggage org_id=123 matches DSN but NOT the orgId option (999) + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + // Should start new trace because orgId option (999) != baggage org_id (123) + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + it('continues trace when baggage matches orgId option', () => { + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=999', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + }); + + describe('strictTraceContinuation=true', () => { + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@o123.ingest.sentry.io/1234', + strictTraceContinuation: true, + }), + ); + setCurrentClient(client); + client.init(); + }); + + it('starts new trace when baggage has no org_id', () => { + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + it('starts new trace when SDK has no org_id but baggage does', () => { + // Use a DSN without org ID in hostname + const clientWithoutOrgId = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@sentry.example.com/1234', + strictTraceContinuation: true, + }), + ); + setCurrentClient(clientWithoutOrgId); + clientWithoutOrgId.init(); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + + it('continues trace when both org IDs are missing', () => { + const clientWithoutOrgId = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@sentry.example.com/1234', + strictTraceContinuation: true, + }), + ); + setCurrentClient(clientWithoutOrgId); + clientWithoutOrgId.init(); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + }); + + describe('strictTraceContinuation=false (default)', () => { + beforeEach(() => { + client = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@o123.ingest.sentry.io/1234', + strictTraceContinuation: false, + }), + ); + setCurrentClient(client); + client.init(); + }); + + it('continues trace when baggage has no org_id', () => { + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-environment=production', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + + it('continues trace when SDK has no org_id but baggage does', () => { + const clientWithoutOrgId = new TestClient( + getDefaultTestClientOptions({ + tracesSampleRate: 1.0, + dsn: 'https://abc@sentry.example.com/1234', + strictTraceContinuation: false, + }), + ); + setCurrentClient(clientWithoutOrgId); + clientWithoutOrgId.init(); + + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=123', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBe('1121201211212012'); + }); + + it('still starts new trace when org IDs mismatch', () => { + const scope = continueTrace( + { + sentryTrace: '12312012123120121231201212312012-1121201211212012-1', + baggage: 'sentry-org_id=456', + }, + () => { + return getCurrentScope(); + }, + ); + + expect(scope.getPropagationContext().traceId).not.toBe('12312012123120121231201212312012'); + expect(scope.getPropagationContext().parentSpanId).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/wrapper.test.ts b/packages/core/test/wrapper.test.ts index cc7b79c0d9..65880888ff 100644 --- a/packages/core/test/wrapper.test.ts +++ b/packages/core/test/wrapper.test.ts @@ -377,6 +377,38 @@ describe('Tests Native Wrapper', () => { expect(initParameter.enableLogs).toBe(expectedEnableLogs); expect(initParameter.logsOrigin).toBeUndefined(); }); + + test('passes strictTraceContinuation option to native SDK', async () => { + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + autoInitializeNativeSdk: true, + strictTraceContinuation: true, + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalled(); + const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction).mock.calls[0][0]; + expect(initParameter.strictTraceContinuation).toBe(true); + }); + + test('passes orgId option to native SDK', async () => { + await NATIVE.initNativeSdk({ + dsn: 'test', + enableNative: true, + autoInitializeNativeSdk: true, + orgId: '12345', + devServerUrl: undefined, + defaultSidecarUrl: undefined, + mobileReplayOptions: undefined, + }); + + expect(RNSentry.initNativeSdk).toHaveBeenCalled(); + const initParameter = (RNSentry.initNativeSdk as jest.MockedFunction).mock.calls[0][0]; + expect(initParameter.orgId).toBe('12345'); + }); }); describe('sendEnvelope', () => { From d425efcf6adecc7acf344a6c485c0071930bbce9 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Tue, 17 Mar 2026 14:53:37 +0100 Subject: [PATCH 2/2] chore: Add changelog entry for strict trace continuation --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06273210b1..5e757e8516 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - Support `SENTRY_ENVIRONMENT` in bare React Native builds ([#5823](https://github.com/getsentry/sentry-react-native/pull/5823)) +- Add `strictTraceContinuation` and `orgId` options for trace continuation validation ([#5829](https://github.com/getsentry/sentry-react-native/pull/5829)) ### Fixes