feat(tracing): sync JS scope propagation context to native scope - #6686
feat(tracing): sync JS scope propagation context to native scope#6686alwx wants to merge 8 commits into
Conversation
When a new JS root span starts, push its traceId, spanId, sampled, and sampleRand to the native SDK scope via a new bridge method. Native HTTP instrumentation (OkHttp on Android, URLSession on iOS) then attaches the correct sentry-trace header, linking native spans to the JS trace. Fixes #6237 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Semver Impact of This PR⚪ None (no version bump detected) 📋 Changelog PreviewThis is how your changes will appear in the changelog.
🤖 This preview updates automatically when you update the PR. |
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ridge modules - Add missing @ReactMethod wrapper in oldarch RNSentryModule - Add missing @OverRide wrapper in newarch RNSentryModule - Guard against null ctx/traceId/spanId in RNSentryModuleImpl - Pass null instead of sampled boolean as sample rate; the Java SDK expects the actual configured rate (0.0–1.0), not a boolean flag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| @_spi(Private) @objc public static func setCurrentScopePropagationContext(traceId: String, spanId: String) { | ||
| let sentryTraceId = SentryId(uuidString: traceId) | ||
| let sentrySpanId = SpanId(value: spanId) | ||
| SentrySDK.internal.setTrace(sentryTraceId, spanId: sentrySpanId) | ||
| } |
There was a problem hiding this comment.
Bug: The iOS setCurrentScopePropagationContext implementation ignores sampled and sampleRand values, causing native spans to have different sampling decisions than the JavaScript trace.
Severity: MEDIUM
Suggested Fix
Update the setCurrentScopePropagationContext method in RNSentry.mm to extract sampled and sampleRand from the dictionary. Modify the setCurrentScopePropagationContext function in RNSentryInternal.swift to accept these new parameters and use them to create a PropagationContext, similar to the Android implementation, ensuring the sampling decision is correctly propagated.
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/ios/RNSentryInternal.swift#L252-L256
Potential issue: The iOS implementation of `setCurrentScopePropagationContext` only
accepts `traceId` and `spanId` parameters, silently ignoring the `sampled` and
`sampleRand` values passed from the JavaScript layer. This causes native iOS HTTP spans
to be sampled independently of the JavaScript trace's sampling decision. The
corresponding Swift function `setCurrentScopePropagationContext` only accepts `traceId`
and `spanId`, calling `SentrySDK.internal.setTrace` without any sampling information.
This undermines the feature's goal of unified trace sampling across platforms.
- Update native context for all root spans including non-recording (unsampled) ones to prevent stale traceId lingering from a previous navigation - Make setCurrentScopePropagationContext synchronous on iOS (RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD) and Android (boolean return) to eliminate race with native URLSession/OkHttp - Update NativeRNSentry spec return type void→boolean to match synchronous codegen path - Document that iOS setTrace does not accept sampled/sampleRand (Cocoa SDK limitation) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ae93384. Configure here.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|
||
| // 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. |
There was a problem hiding this comment.
Let's open an issue/pr for the Cocoa API change to keep track of this case an fix it.
…ive scope sync - RNSentryInternal: reformat 32-char hex traceId to hyphenated UUID before passing to SentryId(uuidString:); without hyphens it silently produces an empty SentryId and trace linking breaks on iOS. - syncPropagationContextToNative: skip root spans that are not the active span so inactive forceTransaction roots (app start, expo-updates) do not clobber the native propagation context of an in-flight navigation span. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| export function syncPropagationContextToNative(client: Client): void { | ||
| client.on('spanStart', (span: Span) => { | ||
| if (!isRootSpan(span)) return; | ||
| if (getActiveSpan() !== 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(), | ||
| }); | ||
| }); | ||
| } |
There was a problem hiding this comment.
spanStart active-span guard prevents native propagation sync
Drop the getActiveSpan() !== span check on spanStart (or sync after activation in startIdleSpan); spanStart fires from startInactiveSpan before the span is active, so this guard skips every real navigation/idle root and the feature never syncs.
Evidence
syncPropagationContextToNative()registers aspanStartlistener that returns early unlessgetActiveSpan() === span(span.ts).- Production roots go through
startIdleSpan()→coreStartIdleSpan()/startInactiveSpan(), which emitspanStartduring creation before the span is set active (idle navigation tests only assertgetActiveSpan()afterstartIdleNavigationSpanreturns). nativeScopeSync.test.tsnever exercises that path: positive cases manuallywithActiveSpan(span, () => client.emit('spanStart', span)), while plainstartInactiveSpan({ forceTransaction: true })is only used to assert the native call is skipped.- Result: navigation/user-interaction roots that should fix orphaned native HTTP spans never call
NATIVE.setCurrentScopePropagationContext.
Also found at 1 additional location
packages/core/test/tracing/nativeScopeSync.test.ts:41-45
Identified by Warden · find-bugs · XSA-86X
| // sampled is a boolean on the JS side; the Java SDK expects the actual sample rate (0.0–1.0), | ||
| // which we don't have here, so we pass null and let sampleRand carry the sampling context. | ||
| Double sampleRand = ctx.hasKey("sampleRand") ? ctx.getDouble("sampleRand") : null; | ||
|
|
||
| PropagationContext propagationContext = | ||
| PropagationContext.fromExistingTrace(traceId, spanId, null, sampleRand); |
There was a problem hiding this comment.
Android PropagationContext drops JS sampled decision
sampled is sent from JS but never applied; fromExistingTrace(..., null, sampleRand) leaves native isSampled unset, so OkHttp trace headers/spans can miss the JS sampling decision—sampleRand alone is not that decision. Read sampled and build a PropagationContext that sets isSampled (and keep sampleRand on baggage).
Evidence
- JS
syncPropagationContextToNative()callsNATIVE.setCurrentScopePropagationContext({ traceId, spanId, sampled: spanIsSampled(span), sampleRand })inspan.ts. - This Android method only validates/reads
traceId,spanId, and optionalsampleRand; it never readsctxkeysampled. - The call is
PropagationContext.fromExistingTrace(traceId, spanId, null, sampleRand)with an explicit null third argument (sample rate per the in-code comment), so the created context does not receive the JS boolean sampling decision. - Native HTTP instrumentation uses scope propagation context for
sentry-tracesampled state; withisSampledleft unset, Android cannot reliably mirror the active JS root span’s sampled/unsampled decision.
Identified by Warden · find-bugs · 4BQ-AGU

📢 Type of change
📜 Description
Adds a new
setCurrentScopePropagationContextbridge method that pushes the active JS root span'straceId,spanId,sampled, andsampleRandto the native SDK scope whenever a new root span starts. Wired up automatically via aspanStarthook inreactNativeTracingIntegration.💡 Motivation and Context
Fixes #6237.
Native HTTP spans (OkHttp on Android, URLSession on iOS) were always orphaned — they had no knowledge of the active JS trace and ended up as standalone transactions in Sentry. After this change, native spans automatically share the same
trace_idas the JS navigation transaction that triggered them.syncPropagationContextToNative(client)registers aspanStarthook (root spans only) and callsNATIVE.setCurrentScopePropagationContext({ traceId, spanId, sampled, sampleRand }).PropagationContext.fromExistingTrace(traceId, spanId, sampled, sampleRand)applied viaSentry.configureScope.SentrySDK.internal.setTrace(_:spanId:)called throughRNSentryInternal.swiftusing the existing@_spi(Private)bridge.💚 How did you test it?
test/tracing/nativeScopeSync.test.ts: root span sync, child span skip,SentryNonRecordingSpanskip, sampled/sampleRand propagation, per-span firing.📝 Checklist
sendDefaultPIIis enabled.🔮 Next steps
DSC/baggage propagation and iOS
sampledflag —SentrySDK.internal.setTracesets traceId+spanId only; follow up with sentry-cocoa once the API is extended.