Skip to content

fix(effect): Honor external parents and isolate root spans - #23900

Open
JPeer264 wants to merge 2 commits into
developfrom
jp/effect-sdk-trace-structure
Open

fix(effect): Honor external parents and isolate root spans#23900
JPeer264 wants to merge 2 commits into
developfrom
jp/effect-sdk-trace-structure

Conversation

@JPeer264

@JPeer264 JPeer264 commented Sep 2, 2026

Copy link
Copy Markdown
Member

@sentry/effect parented every Effect span on the active Sentry span. A Tracer.ExternalSpan parent was dropped, so a persisted trace continued with Tracer.externalSpan started a disconnected trace, and a root: true span or a span leaked from another fiber through the async context could become the parent of an unrelated span. On the server, every parentless span also shared the process-wide propagation context, so a long-lived process put all of its work into one trace.

  • A parentless span only nests under a foreign active Sentry span (an http.server span from the Node SDK, a pageload), never under a span this tracer created.
  • The server tracer starts a new trace for every parentless span, unless the user set up the current scope (continueTrace, withScope, an isolation scope). The client tracer keeps parentless spans in the page trace.
  • An external parent is ignored by default, and the span nests where Effect would have put it without the parent option: under the fiber's current span, or parentless.
  • The new SentryEffectExternalSpanLayer opts into continuing external parents: the span becomes a new root span with the external span as parent_span_id. No dynamic sampling context is frozen, so the SDK builds one from the client. Next to the tracer layer it applies to the whole runtime, with Effect.provide to a single effect only there.

Decisions

External parents are opt-in. A Tracer.externalSpan parent bridges a trace the SDK did not start, for example an OpenTelemetry span of another app in the same process, or trace state persisted with a queue message. Joining such a trace silently would re-parent spans into a trace the SDK cannot vouch for, so the user adds the layer to ask for it. Incoming trace headers on the Effect HTTP server are out of scope here and get their own PR.

The opt-in is a layer, not a second tracer. Two layers that set the tracer race inside Layer.mergeAll, and a wrapping tracer only sees the Sentry one when nested with Layer.provide. A flag in the fiber context composes in Layer.mergeAll like Layer.setTracer does, and also works per effect. It is a plain service in both Effect versions, because v4 has no FiberRef and v3 has no Context.Reference.

How the tracer reads the flag. Effect's span hook gets no fiber in either version, only the span's own context or annotations. The context hook does get the fiber and wraps every operation the fiber evaluates, and span runs synchronously inside it, so the hook keeps the fiber in a module variable for that extent, the same way it already sets the active Sentry span there. The flag is read only when an external parent shows up.

These fixes apply to both trace lifecycles. One related limitation stays and is specific to traceLifecycle: 'static': a child span that ends after its root span is dropped with the transaction. Long-running Effect fibers, such as a background agent that outlives the request that started it, lose those children unless traceLifecycle: 'stream' is used, which sends every span on its own end and is the default since v11.

The effect-3-node and effect-4-node e2e apps cover a Tracer.externalSpan parent continued through the layer and a root: true span inside a request.

🤖 Generated with Claude Code

@JPeer264 JPeer264 self-assigned this Sep 2, 2026
@JPeer264
JPeer264 requested a review from a team as a code owner September 2, 2026 07:09
@JPeer264
JPeer264 requested review from mydea and nicohrubec and removed request for a team September 2, 2026 07:09
@JPeer264
JPeer264 marked this pull request as draft September 2, 2026 07:09
@JPeer264
JPeer264 force-pushed the jp/effect-sdk-trace-structure branch from 07eefa2 to 61b80c5 Compare September 2, 2026 07:30
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size % Change Change
@sentry/browser 28.8 kB - -
@sentry/browser - with treeshaking flags 27.11 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27 kB - -
@sentry/browser (incl. Tracing) 49.22 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 49.22 kB - -
@sentry/browser (incl. Tracing, Profiling) 52.12 kB - -
@sentry/browser (incl. Tracing, Replay) 88.76 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 77.95 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 93.44 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 106.37 kB - -
@sentry/browser (incl. Feedback) 46.3 kB - -
@sentry/browser (incl. sendFeedback) 33.86 kB - -
@sentry/browser (incl. FeedbackAsync) 38.97 kB - -
@sentry/browser (incl. Metrics) 29.82 kB - -
@sentry/browser (incl. Logs) 30.09 kB - -
@sentry/browser (incl. Metrics & Logs) 30.75 kB - -
@sentry/react 30.55 kB - -
@sentry/react (incl. Tracing) 51.56 kB - -
@sentry/vue 36.05 kB - -
@sentry/vue (incl. Tracing) 51.48 kB - -
@sentry/svelte 28.83 kB - -
CDN Bundle 30.55 kB - -
CDN Bundle (incl. Tracing) 49.74 kB - -
CDN Bundle (incl. Logs, Metrics) 32.82 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 51.7 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 73.48 kB - -
CDN Bundle (incl. Tracing, Replay) 87.29 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 89.18 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 93.22 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 95.21 kB - -
CDN Bundle - uncompressed 90.46 kB - -
CDN Bundle (incl. Tracing) - uncompressed 148.17 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 97.03 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 154.14 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 226.29 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 267.76 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 273.71 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 281.46 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 287.4 kB - -
@sentry/nextjs (client) 54 kB -0.06% -32 B 🔽
@sentry/sveltekit (client) 49.65 kB - -
@sentry/core/server 36.99 kB - -
@sentry/core/browser 13.55 kB - -
@sentry/node 127.86 kB +0.2% +251 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 81.61 kB +0.04% +32 B 🔺
@sentry/node - without tracing 88.73 kB +0.05% +43 B 🔺
@sentry/node - without channel injection 107.11 kB +0.22% +227 B 🔺
@sentry/aws-serverless 97.12 kB +0.06% +54 B 🔺
@sentry/cloudflare (withSentry) - minified 201.98 kB +0.02% +24 B 🔺
@sentry/cloudflare (withSentry) 502.68 kB +0.01% +12 B 🔺

View base workflow run

@JPeer264
JPeer264 marked this pull request as ready for review September 2, 2026 07:43
@JPeer264
JPeer264 requested review from Lms24 and s1gr1d September 2, 2026 07:43
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

👋 @Lms24, @s1gr1d — Please review this PR when you get a chance!

@JPeer264
JPeer264 requested a review from nicohrubec September 7, 2026 15:25

@Lms24 Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry for blocking but IIUC this allows a Sentry-instrumented Effect app to continue a trace based on any incoming traceparent header, correct?

If so, let's not merge this. We explicitly don't continue traceparent-initiated traces. We only propagate our traces via traceparent (if propagateTraceparent: true) to allow downstream services to continue a sentry trace in OTel-instrumented services.

`@sentry/effect` ignored `Tracer.ExternalSpan` parents and fell back to the
active Sentry span. An incoming `traceparent` header on an Effect HTTP server
or a persisted trace continued with `Tracer.externalSpan` therefore started a
disconnected trace, and a `root: true` span or a span leaked from another
fiber through the async context could become the parent of an unrelated span.
On the server, every parentless span also shared the process-wide propagation
context, so a long-lived process put all of its work into one trace.

- An external parent now continues its trace as a new root span with the
  external span as `parent_span_id`. No dynamic sampling context is frozen, so
  the SDK builds one from the client.
- A parentless span only nests under a foreign active Sentry span (an
  `http.server` span from the Node SDK, a pageload), never under a span this
  tracer created.
- The server tracer starts a new trace for every parentless span, unless the
  user set up the current scope (`continueTrace`, `withScope`, an isolation
  scope). The client tracer keeps parentless spans in the page trace.

These fixes apply to both trace lifecycles. One related limitation stays and
is specific to `traceLifecycle: 'static'`: a child span that ends after its
root span is dropped with the transaction. Long-running Effect fibers, such as
a background agent that outlives the request that started it, lose those
children unless `traceLifecycle: 'stream'` is used, which sends every span on
its own end and is the default since v11.

The effect-3-node and effect-4-node e2e apps cover an incoming traceparent
header, a `Tracer.externalSpan` parent, and a `root: true` span inside a
request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JPeer264
JPeer264 force-pushed the jp/effect-sdk-trace-structure branch from 61b80c5 to 1f7229d Compare September 8, 2026 15:47
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@JPeer264
JPeer264 force-pushed the jp/effect-sdk-trace-structure branch from 8a32bfb to c900a24 Compare September 8, 2026 16:59
@JPeer264

JPeer264 commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

@Lms24 it is now changed to be opt-in. So users must explicitly add an extra layer to add this.

const SentryLive = Layer.mergeAll(
  Sentry.effectLayer({ dsn: '__DSN__', tracesSampleRate: 1.0 }),
  Layer.setTracer(Sentry.SentryEffectTracer),
  Sentry.SentryEffectExternalSpanLayer, // this must be added now
);

To clarify, we talked offline about it. It has nothing to do with external traces coming in with a traceparent it is coming from external from the same process. So basically an OpenTelemetry bridge from another app into the Effect runtime.

@JPeer264
JPeer264 requested a review from Lms24 September 8, 2026 17:03

@Lms24 Lms24 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for making the change! As discussed, we can go with this approach first, and if necessary revisit default behaviour.

For some more public context why I think opt-in behaviour is the way to go: If the Sentry SDK is used in a "sub app" and the surrounding SDK is instrumented with pure OTel, we still have the issue that it's not us making a sampling decision, but the OTel SDK (or it inheriting a trace and sampling decision from some other service via traceparent headers). So our span metrics extrapolation is potentially compromised, our own tracesSampleRate would always be overridden and it can also lead to cross-org/customer traces being continued. For these reasons, we so far stayed away from continuing traceparent-initiated traces. This is just a special case of that, where we have no service boundary crossing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants