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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

### Features

- Sync JS scope propagation context to native scope ([#6686](https://github.com/getsentry/sentry-react-native/pull/6686))
- Native HTTP spans (OkHttp on Android, URLSession on iOS) now automatically share the same `trace_id` as the active JS navigation transaction, linking them in the Sentry trace waterfall.
- Expose the iOS `enableMemoryIntrospection` option to omit memory contents from native crash reports ([#6547](https://github.com/getsentry/sentry-react-native/pull/6674))
- Add `anrProfilingSampleRate` option to profile ANRs on Android ([#6673](https://github.com/getsentry/sentry-react-native/pull/6673))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
import io.sentry.IScope;
import io.sentry.ISentryExecutorService;
import io.sentry.ISerializer;
import io.sentry.PropagationContext;
import io.sentry.ScopesAdapter;
import io.sentry.Sentry;
import io.sentry.SentryAttributes;
Expand Down Expand Up @@ -677,6 +678,25 @@ public boolean setActiveSpanId(@Nullable String spanId) {
return true; // The return ensure RN executes the code synchronously
}

public boolean setCurrentScopePropagationContext(@Nullable ReadableMap ctx) {
if (ctx == null || !ctx.hasKey("traceId") || !ctx.hasKey("spanId")) {
return false;
}
String traceId = ctx.getString("traceId");
String spanId = ctx.getString("spanId");
if (traceId == null || spanId == null) {
return false;
}
Double sampleRand = ctx.hasKey("sampleRand") ? ctx.getDouble("sampleRand") : null;
Boolean sampled = ctx.hasKey("sampled") ? ctx.getBoolean("sampled") : null;

PropagationContext propagationContext =
PropagationContext.fromExistingTrace(traceId, spanId, null, sampleRand);
propagationContext.setSampled(sampled);
Sentry.configureScope(scope -> scope.setPropagationContext(propagationContext));
return true; // The return ensures RN executes the method synchronously
}

public void setExtra(String key, String extra) {
if (key == null || extra == null) {
logger.log(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,11 @@ public boolean setActiveSpanId(String spanId) {
return this.impl.setActiveSpanId(spanId);
}

@Override
public boolean setCurrentScopePropagationContext(ReadableMap ctx) {
return this.impl.setCurrentScopePropagationContext(ctx);
}

@Override
public void enableShakeDetection() {
this.impl.enableShakeDetection();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,11 +229,16 @@ public void popTimeToDisplayFor(String key, Promise promise) {
this.impl.popTimeToDisplayFor(key, promise);
}

@ReactMethod
@ReactMethod(isBlockingSynchronousMethod = true)

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.

q: Why do we change this? I think it might degrade the performance

public boolean setActiveSpanId(String spanId) {
return this.impl.setActiveSpanId(spanId);
}

@ReactMethod(isBlockingSynchronousMethod = true)
public boolean setCurrentScopePropagationContext(ReadableMap ctx) {
return this.impl.setCurrentScopePropagationContext(ctx);
Comment thread
cursor[bot] marked this conversation as resolved.
}

@ReactMethod
public void enableShakeDetection() {
this.impl.enableShakeDetection();
Expand Down
2 changes: 1 addition & 1 deletion packages/core/etc/sentry-react-native.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -948,7 +948,7 @@ export function wrapTurboModule<T extends object>(name: string, module: T | null
//
// src/js/feedback/integration.ts:21:5 - (ae-forgotten-export) The symbol "ScreenshotButtonProps" needs to be exported by the entry point index.d.ts
// src/js/feedback/integration.ts:23:5 - (ae-forgotten-export) The symbol "FeedbackFormTheme" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnativetracing.ts:90:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnativetracing.ts:95:3 - (ae-forgotten-export) The symbol "ReactNativeTracingState" needs to be exported by the entry point index.d.ts
// src/js/tracing/reactnavigation.ts:228:3 - (ae-forgotten-export) The symbol "RouteOverrideProvider" needs to be exported by the entry point index.d.ts

// (No @packageDocumentation comment for this package)
Expand Down
11 changes: 11 additions & 0 deletions packages/core/ios/RNSentry.mm
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,17 @@ + (BOOL)isPathUnderAllowedRootsForTesting:(NSString *)path
return @YES; // The return ensures that the method is synchronous
}

RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(
NSNumber *, setCurrentScopePropagationContext : (NSDictionary *)ctx)
{
NSString *traceId = ctx[@"traceId"];
NSString *spanId = ctx[@"spanId"];
if (traceId && spanId) {
[RNSentryInternal setCurrentScopePropagationContextWithTraceId:traceId spanId:spanId];
}
return @YES;
}

RCT_EXPORT_METHOD(encodeToBase64 : (NSArray *)array resolver : (
RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject)
{
Expand Down
21 changes: 21 additions & 0 deletions packages/core/ios/RNSentryInternal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,25 @@
) -> [String: Any]? { nil }
@_spi(Private) @objc public static func discardProfiler(forTrace traceId: SentryId) {}
#endif

// MARK: - Scope propagation context

// Note: sampled and sampleRand from the JS propagation context are not applied here.
// SentrySDK.internal.setTrace only accepts traceId/spanId; wiring sampling fields
// through would require a sentry-cocoa API change.
Comment thread
antonis marked this conversation as resolved.
@_spi(Private) @objc public static func setCurrentScopePropagationContext(traceId: String, spanId: String) {
// JS traceId is a 32-char hex string without hyphens; SentryId(uuidString:) requires
// the standard hyphenated UUID format (8-4-4-4-12), otherwise it silently produces
// an empty SentryId and trace linking breaks.
let hyphenated: String
if traceId.count == 32 {
let s = traceId
hyphenated = "\(s.prefix(8))-\(s.dropFirst(8).prefix(4))-\(s.dropFirst(12).prefix(4))-\(s.dropFirst(16).prefix(4))-\(s.dropFirst(20))"
} else {
hyphenated = traceId
}
let sentryTraceId = SentryId(uuidString: hyphenated)
let sentrySpanId = SpanId(value: spanId)
SentrySDK.internal.setTrace(sentryTraceId, spanId: sentrySpanId)
}

Check warning on line 269 in packages/core/ios/RNSentryInternal.swift

View check run for this annotation

@sentry/warden / warden: code-review

iOS native scope does not receive JS sampling fields

The JS bridge sends sampled and sampleRand, but the iOS implementation drops them before calling setTrace, which only updates traceId and spanId. Consequently, iOS native HTTP instrumentation receives no JS sampling decision, so native-span sampling can diverge from the JS root span. This requires sentry-cocoa API support or equivalent native scope propagation.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
alwx marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
}
1 change: 1 addition & 0 deletions packages/core/src/js/NativeRNSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export interface Spec extends TurboModule {
getDataFromUri(uri: string): Promise<number[]>;
popTimeToDisplayFor(key: string): Promise<number | undefined | null>;
setActiveSpanId(spanId: string): boolean;
setCurrentScopePropagationContext(ctx: UnsafeObject): boolean;
encodeToBase64(data: number[]): Promise<string | undefined | null>;
enableShakeDetection(): void;
disableShakeDetection(): void;
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/js/tracing/reactnativetracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { debug, getClient } from '@sentry/core';
import { isExpoFetchEnabled, isWeb } from '../utils/environment';
import { getDevServer } from './../integrations/debugsymbolicatorutils';
import { getTransactionEventDiscardReason } from './onSpanEndUtils';
import { addDefaultOpForSpanFrom, addThreadInfoToSpan, defaultIdleOptions } from './span';
import {
addDefaultOpForSpanFrom,
addThreadInfoToSpan,
defaultIdleOptions,
syncPropagationContextToNative,
} from './span';

export const INTEGRATION_NAME = 'ReactNativeTracing';

Expand Down Expand Up @@ -131,6 +136,7 @@ export const reactNativeTracingIntegration = (
const setup = (client: Client): void => {
addDefaultOpForSpanFrom(client);
addThreadInfoToSpan(client);
syncPropagationContextToNative(client);
Comment thread
sentry-warden[bot] marked this conversation as resolved.

instrumentOutgoingRequests(client, {
traceFetch: finalOptions.traceFetch,
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/js/tracing/span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,21 @@
getActiveSpan,
getClient,
getCurrentScope,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SentryNonRecordingSpan,
spanIsSampled,
SPAN_STATUS_ERROR,
spanToJSON,
startIdleSpan as coreStartIdleSpan,
} from '@sentry/core';
import { AppState, Platform } from 'react-native';

import { isRootSpan } from '../utils/span';
import { NATIVE } from '../wrapper';
import { adjustTransactionDuration, cancelInBackground } from './onSpanEndUtils';
import {
SPAN_ORIGIN_AUTO_INTERACTION,

Check warning on line 23 in packages/core/src/js/tracing/span.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Inactive root spans can overwrite native context for an active navigation

The native propagation hook synchronizes every root span, including inactive `forceTransaction` roots such as Expo Updates and standalone app-start transactions. If one starts while a navigation root remains active, it replaces the native scope's trace context and native HTTP spans may be attributed to the inactive transaction. The native context is not restored when that transaction ends.
SPAN_ORIGIN_AUTO_NAVIGATION_CUSTOM,
SPAN_ORIGIN_MANUAL_INTERACTION,
} from './origin';
Expand Down Expand Up @@ -202,3 +204,29 @@
spanJSON.data[SPAN_THREAD_NAME] = SPAN_THREAD_NAME_MAIN;
return spanJSON;
}

/**
* Pushes the JS root span's propagation context to the native scope so that
* native HTTP instrumentation (OkHttp on Android, URLSession on iOS) attaches
* the correct traceId and appears in the same trace as the JS transaction.
*
* Fires for every root span so that the native scope is always up to date and
* doesn't retain a stale traceId from a previous span. Child spans are skipped
* to avoid bridge spam.
*
* Note: `spanStart` fires before idle spans are made active, so an active-span
* guard here would skip every navigation span. Sync happens for all root spans.
*/
export function syncPropagationContextToNative(client: Client): void {
client.on('spanStart', (span: Span) => {
if (!isRootSpan(span)) return;
const ctx = span.spanContext();
const propagationCtx = getCurrentScope().getPropagationContext();
NATIVE.setCurrentScopePropagationContext({
traceId: ctx.traceId,
spanId: ctx.spanId,
sampled: spanIsSampled(span),
sampleRand: propagationCtx.sampleRand ?? Math.random(),
Comment on lines +223 to +229

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Root spans created without startIdleSpan can cause a mismatched propagation context, combining a new traceId with a stale sampleRand, leading to incorrect native sampling.
Severity: MEDIUM

Suggested Fix

Ensure that code paths creating root spans, like startInactiveSpan({ forceTransaction: true }), explicitly update the propagation context with a new traceId and sampleRand before the span starts, similar to how startIdleSpan does. This will prevent syncPropagationContextToNative from using stale data.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/core/src/js/tracing/span.ts#L223-L229

Potential issue: For root spans not created via `startIdleSpan`, such as those for
app-start using `startInactiveSpan({ forceTransaction: true })`, the native scope
synchronization logic can create a mismatched propagation context. When
`syncPropagationContextToNative` is triggered, it uses the `traceId` from the new span
but may use a stale `sampleRand` value from the previous trace's scope. This sends a
mismatched context to the native layer, potentially causing incorrect sampling decisions
for downstream native HTTP calls.

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.

This looks valid ๐Ÿ‘
Also these background roots come from startInactiveSpan({ forceTransaction: true }) (appStart.ts:556/1019, expoupdateslistener.ts:207/224) and can start mid navigation, overwriting the native trace that in-flight native HTTP spans link to, with no restore when they end. Refreshing their propagation context (the suggested fix) keeps the pushed values consistent but still links native calls to the wrong trace.

});
Comment thread
sentry[bot] marked this conversation as resolved.
});

Check warning on line 231 in packages/core/src/js/tracing/span.ts

View check run for this annotation

@sentry/warden / warden: code-review

Inactive root spans overwrite native propagation context

Syncing on every root `spanStart` lets inactive `forceTransaction` roots (e.g. standalone app-start) replace the active navigation trace on the native scope; gate on the span that is or becomes active, or re-sync the active root after inactive roots end.

Check warning on line 231 in packages/core/src/js/tracing/span.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

[C9H-WHY] Inactive root spans can overwrite native context for an active navigation (additional location)

The native propagation hook synchronizes every root span, including inactive `forceTransaction` roots such as Expo Updates and standalone app-start transactions. If one starts while a navigation root remains active, it replaces the native scope's trace context and native HTTP spans may be attributed to the inactive transaction. The native context is not restored when that transaction ends.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
alwx marked this conversation as resolved.
19 changes: 19 additions & 0 deletions packages/core/src/js/wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,13 @@ interface SentryNativeWrapper {

setActiveSpanId(spanId: string): void;

setCurrentScopePropagationContext(ctx: {
traceId: string;
spanId: string;
sampled: boolean;
sampleRand: number;
}): boolean;

encodeToBase64(data: Uint8Array): Promise<string | null>;

primitiveProcessor(value: Primitive): string;
Expand Down Expand Up @@ -955,6 +962,18 @@ export const NATIVE: SentryNativeWrapper = {
}
},

setCurrentScopePropagationContext(ctx): boolean {
if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
return false;
}
try {
return !!RNSentry.setCurrentScopePropagationContext(ctx);
} catch (error) {
debug.error('Error:', error);
return false;
}
},

async encodeToBase64(data: Uint8Array): Promise<string | null> {
if (!this.enableNative || !this._isModuleLoaded(RNSentry)) {
return Promise.resolve(null);
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/mockWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const NATIVE: MockInterface<NativeType> = {
getDataFromUri: jest.fn(),
popTimeToDisplayFor: jest.fn(),
setActiveSpanId: jest.fn(),
setCurrentScopePropagationContext: jest.fn(),
encodeToBase64: jest.fn(),
primitiveProcessor: jest.fn(),
};
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/tracing/gesturetracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jest.mock('../../src/js/wrapper', () => {
fetchNativeAppStart: jest.fn(),
fetchNativeFrames: jest.fn(() => Promise.resolve()),
enableNativeFramesTracking: jest.fn(() => Promise.resolve()),
setCurrentScopePropagationContext: jest.fn(),
enableNative: true,
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ jest.mock('../../../src/js/wrapper', () => {
fetchNativeFrames: jest.fn(() => Promise.resolve()),
disableNativeFramesTracking: jest.fn(() => Promise.resolve()),
enableNativeFramesTracking: jest.fn(() => Promise.resolve()),
setCurrentScopePropagationContext: jest.fn(),
enableNative: true,
},
};
Expand Down
101 changes: 101 additions & 0 deletions packages/core/test/tracing/nativeScopeSync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { Client } from '@sentry/core';

import { getCurrentScope, SentryNonRecordingSpan, startInactiveSpan } from '@sentry/core';

jest.mock('../../src/js/wrapper', () => ({
NATIVE: {
enableNative: true,
setCurrentScopePropagationContext: jest.fn(),
},
}));

jest.mock('react-native', () => ({
AppState: {
currentState: 'active',
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
},
Platform: { OS: 'ios' },
NativeModules: { RNSentry: {} },
}));

import { syncPropagationContextToNative } from '../../src/js/tracing/span';
import { NATIVE } from '../../src/js/wrapper';
import { setupTestClient } from '../mocks/client';

const mockSetPropagationContext = NATIVE.setCurrentScopePropagationContext as jest.Mock;

describe('syncPropagationContextToNative', () => {
let client: Client;

beforeEach(() => {
jest.clearAllMocks();
client = setupTestClient({ tracesSampleRate: 1.0 });
syncPropagationContextToNative(client);
});

it('calls NATIVE.setCurrentScopePropagationContext when a root span starts', () => {
const span = startInactiveSpan({ name: 'root', forceTransaction: true });
const ctx = span.spanContext();

expect(mockSetPropagationContext).toHaveBeenCalledTimes(1);
expect(mockSetPropagationContext).toHaveBeenCalledWith(
expect.objectContaining({
traceId: ctx.traceId,
spanId: ctx.spanId,
}),
);

span.end();
});

it('does not call NATIVE.setCurrentScopePropagationContext for child spans', () => {
const root = startInactiveSpan({ name: 'root', forceTransaction: true });
mockSetPropagationContext.mockClear();

const child = startInactiveSpan({ name: 'child', parentSpan: root });
expect(mockSetPropagationContext).not.toHaveBeenCalled();

child.end();
root.end();
});

it('calls NATIVE.setCurrentScopePropagationContext for SentryNonRecordingSpan to prevent stale native context', () => {
const nonRecording = new SentryNonRecordingSpan();
const ctx = nonRecording.spanContext();
client.emit('spanStart', nonRecording);

expect(mockSetPropagationContext).toHaveBeenCalledTimes(1);
expect(mockSetPropagationContext).toHaveBeenCalledWith(
expect.objectContaining({
traceId: ctx.traceId,
spanId: ctx.spanId,
}),
);
});

it('passes sampled and sampleRand from the scope propagation context', () => {
const span = startInactiveSpan({ name: 'root', forceTransaction: true });
const propagationCtx = getCurrentScope().getPropagationContext();

expect(mockSetPropagationContext).toHaveBeenCalledWith(
expect.objectContaining({
sampled: expect.any(Boolean),
sampleRand: propagationCtx.sampleRand ?? expect.any(Number),
}),
);

span.end();
});

it('fires once per root span start', () => {
const span1 = startInactiveSpan({ name: 'first', forceTransaction: true });
span1.end();

const span2 = startInactiveSpan({ name: 'second', forceTransaction: true });
span2.end();

expect(mockSetPropagationContext).toHaveBeenCalledTimes(2);
expect(mockSetPropagationContext.mock.calls[0][0].spanId).toBe(span1.spanContext().spanId);
expect(mockSetPropagationContext.mock.calls[1][0].spanId).toBe(span2.spanContext().spanId);
});
});
Loading