Skip to content

Sync JS scope propagation context to native scope (link native spans to JS trace) #6237

@alwx

Description

@alwx

Problem Statement

In a typical sentry-react-native app, the JS SDK owns the trace lifecycle: reactNavigationIntegration, manually started transactions, fetch / XHR instrumentation, etc. all create transactions and spans in JS. The native SDKs (sentry-android, sentry-cocoa) are initialized under the hood but are not informed about the active JS trace — there's no JS→native scope bridge.

Concretely, this means anything that emits spans on the native side cannot be linked to the JS trace:

  • SentryOkHttpInterceptor / SentryOkHttpEventListener for HTTP calls made from Kotlin or from third-party libraries that build their own OkHttpClient
  • HTTP calls from React Native's internal NetworkingModule (i.e. every fetch() from JS) when instrumented at the OkHttp layer via a custom OkHttpClientFactory
  • iOS URLSession auto-swizzled spans from sentry-cocoa
  • Spans emitted from any custom native module
  • Spans from third-party SDKs (e.g. Apollo, gRPC) that read the active Sentry scope on the native side

Today these all become orphaned standalone transactions in Sentry, sharing no trace_id with the JS transaction that triggered them.

A quick audit of the current native bridge confirms there is no API for this:

  • NativeRNSentry.ts exposes setActiveSpanId(spanId), but that is a red herring — it's only used by RNSentryTimeToDisplay for time-to-initial-display attribution and never touches Sentry's hub or scope.
  • None of the Sentry.configureScope { ... } call sites in RNSentryModuleImpl.java / RNSentry.mm set the propagation context or active transaction.

This is a real gap that customers hit anytime their app does meaningful native HTTP traffic.

Solution Brainstorm

Expose the scope propagation context across the bridge and have the JS SDK keep it in sync with the active JS root span automatically. The propagation context is already the canonical source of trace identity in modern Sentry SDKs (v8+), so this is a 1-1 mirror, not new semantics.

Rough shape

1. New TurboModule method (packages/core/src/js/NativeRNSentry.ts)

setCurrentScopePropagationContext(ctx: {
  traceId: string;
  parentSpanId?: string;
  sampled?: boolean;
  sampleRate?: number;
  dsc?: UnsafeObject;  // dynamic sampling context (baggage)
}): void;

2. Android impl (RNSentryModuleImpl.java)

Sentry.configureScope(scope -> {
  scope.setPropagationContext(new PropagationContext(
    new SentryId(traceId),
    new SpanId(parentSpanId),
    /* parent */ null,
    Baggage.fromHeader(dscHeader),
    sampled
  ));
});

3. iOS impl (RNSentry.mm)

[SentrySDK configureScope:^(SentryScope *scope) {
  scope.propagationContext = [[SentryPropagationContext alloc]
    initWithTraceId:traceId spanId:spanId sampled:sampled];
}];

4. JS auto-sync — hook into the JS client so this happens automatically without the user wiring anything:

client.on('spanStart', span => {
  if (getRootSpan(span) !== span) return;          // root spans only
  const ctx = span.spanContext();
  NATIVE.setCurrentScopePropagationContext({
    traceId: ctx.traceId,
    parentSpanId: ctx.spanId,
    sampled: spanIsSampled(span),
    dsc: getDynamicSamplingContextFromSpan(span),
  });
});

Expected behavior after this lands

  • Native HTTP spans (OkHttp on Android, URLSession on iOS) automatically share the same trace_id as the JS navigation transaction that triggered them.
  • In Sentry's Trace Explorer / waterfall, JS and native spans appear in the same trace — siblings under one trace, even if not nested under a single root span.
  • Customers who want true nesting (native spans as children of a JS-started transaction) can additionally start a native transaction bound to scope, but the trace-level linkage works out of the box.
  • iOS URLSession instrumentation, which already reads the active propagation context, is fixed essentially for free.

Things to get right

  • Bridge call frequency. Sync on root span start only — not every child span — to avoid bridge spam.
  • DSC / baggage propagation. Without baggage, downstream services may resample inconsistently.
  • Sampling decision propagation. sampled must flow so head-based sampling stays consistent across layers.
  • autoInitializeNativeSdk: false. Buffer or no-op if native isn't ready yet.
  • Tracing-disabled case. When tracesSampleRate is 0, the propagation context still needs to flow so trace headers and standalone transactions get the right trace_id.
  • Lifecycle. When the JS transaction ends, the propagation context can reasonably persist until the next root span starts — same as how scope-level propagation context already works in the JS SDK.

Context / repro

There is a minimal repro app demonstrating the current orphaning behavior here: https://github.com/thinkocapo/react-native-http-tracesNativeHttpRequest transactions show up in their own traces, not linked to the Route Change to /index JS trace.

Workarounds today require app developers to:

  1. Write a custom native module that wraps Sentry.configureScope { it.propagationContext = ... } (Android) and the equivalent on iOS.
  2. Plumb the active JS traceId / spanId over the bridge on every navigation.

This is boilerplate every non-trivial RN app ends up rewriting. It belongs in the SDK.

Metadata

Metadata

Assignees

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions