Skip to content

feat(effect): Add opt-in for external span parents and isolate root spans - #23900

Merged
JPeer264 merged 3 commits into
developfrom
jp/effect-sdk-trace-structure
Oct 1, 2026
Merged

JPeer264 merged 3 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 29.51 kB - -
@sentry/browser - with treeshaking flags 27.68 kB - -
@sentry/browser - with treeshaking flags tracing without tracing 27.58 kB - -
@sentry/browser (incl. Tracing) 51.46 kB - -
@sentry/browser (incl. Tracing + Span Streaming) 51.47 kB - -
@sentry/browser (incl. Tracing, Profiling) 54.47 kB - -
@sentry/browser (incl. Tracing, Replay) 91.05 kB - -
@sentry/browser (incl. Tracing, Replay) - with treeshaking flags 80.03 kB - -
@sentry/browser (incl. Tracing, Replay with Canvas) 95.73 kB - -
@sentry/browser (incl. Tracing, Replay, Feedback) 108.72 kB - -
@sentry/browser (incl. Feedback) 47.04 kB - -
@sentry/browser (incl. sendFeedback) 34.57 kB - -
@sentry/browser (incl. FeedbackAsync) 39.69 kB - -
@sentry/browser (incl. Metrics) 30.53 kB - -
@sentry/browser (incl. Logs) 30.82 kB - -
@sentry/browser (incl. Metrics & Logs) 31.48 kB - -
@sentry/react 31.36 kB - -
@sentry/react (incl. Tracing) 53.82 kB - -
@sentry/vue 37.51 kB - -
@sentry/vue (incl. Tracing) 54.36 kB - -
@sentry/svelte 29.54 kB - -
@sentry/remix (Remix 3 client bundle) 55.46 kB - -
CDN Bundle 31.22 kB - -
CDN Bundle (incl. Tracing) 51.99 kB - -
CDN Bundle (incl. Logs, Metrics) 33.46 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) 53.94 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) 74.2 kB - -
CDN Bundle (incl. Tracing, Replay) 89.56 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) 91.54 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) 95.73 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) 97.73 kB - -
CDN Bundle - uncompressed 92.14 kB - -
CDN Bundle (incl. Tracing) - uncompressed 154.53 kB - -
CDN Bundle (incl. Logs, Metrics) - uncompressed 98.71 kB - -
CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed 160.48 kB - -
CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed 228.28 kB - -
CDN Bundle (incl. Tracing, Replay) - uncompressed 274.26 kB - -
CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed 280.2 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback) - uncompressed 287.96 kB - -
CDN Bundle (incl. Tracing, Replay, Feedback, Logs, Metrics) - uncompressed 293.89 kB - -
@sentry/nextjs (client) 56.33 kB - -
@sentry/sveltekit (client) 51.88 kB - -
@sentry/core/server 39.99 kB - -
@sentry/core/browser 13.63 kB - -
@sentry/node 144.41 kB +0.01% +8 B 🔺
@sentry/node/import (ESM hook with diagnostics-channel injection) 83.01 kB - -
@sentry/node - without tracing 92.96 kB +0.01% +9 B 🔺
@sentry/node - without channel injection 122.76 kB +0.01% +11 B 🔺
@sentry/aws-serverless 101.26 kB +0.01% +7 B 🔺
@sentry/cloudflare (withSentry) - minified 207.1 kB - -
@sentry/cloudflare (withSentry) 515.31 kB - -

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.

@JPeer264
JPeer264 force-pushed the jp/effect-sdk-trace-structure branch 2 times, most recently 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.

@github-actions

Copy link
Copy Markdown
Contributor

This pull request has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you apply the label PR: no-auto-close I will leave it alone ... forever!

JPeer264 and others added 2 commits October 1, 2026 15:48
`@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>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@JPeer264
JPeer264 force-pushed the jp/effect-sdk-trace-structure branch from c900a24 to 4f9aa86 Compare October 1, 2026 14:37
@JPeer264 JPeer264 changed the title fix(effect): Honor external parents and isolate root spans feat(effect): Add opt-in for external span parents and isolate root spans Oct 1, 2026
@JPeer264
JPeer264 enabled auto-merge (squash) October 1, 2026 15:18
@JPeer264
JPeer264 merged commit df2b796 into develop Oct 1, 2026
268 checks passed
@JPeer264
JPeer264 deleted the jp/effect-sdk-trace-structure branch October 1, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants