Skip to content

Commit df2b796

Browse files
JPeer264claude
andauthored
feat(effect): Add opt-in for external span parents and isolate root spans (#23900)
`@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](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ca0837d commit df2b796

10 files changed

Lines changed: 531 additions & 55 deletions

File tree

‎dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as Cause from 'effect/Cause';
66
import * as Layer from 'effect/Layer';
77
import * as Logger from 'effect/Logger';
88
import * as LogLevel from 'effect/LogLevel';
9+
import * as Tracer from 'effect/Tracer';
910
import { createServer } from 'http';
1011

1112
const SentryLive = Layer.mergeAll(
@@ -42,6 +43,31 @@ const router = HttpRouter.empty.pipe(
4243
}),
4344
),
4445

46+
HttpRouter.get(
47+
'/test-root-span',
48+
Effect.gen(function* () {
49+
yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker'));
50+
yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true }));
51+
return yield* HttpServerResponse.json({ status: 'ok' });
52+
}),
53+
),
54+
55+
HttpRouter.get(
56+
'/test-external-parent',
57+
Effect.gen(function* () {
58+
yield* Effect.sleep('10 millis').pipe(
59+
Effect.withSpan('continued-span', {
60+
parent: Tracer.externalSpan({
61+
traceId: 'fedcba0987654321fedcba0987654321',
62+
spanId: '0987654321fedcba',
63+
sampled: true,
64+
}),
65+
}),
66+
);
67+
return yield* HttpServerResponse.json({ status: 'ok' });
68+
}).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)),
69+
),
70+
4571
HttpRouter.get(
4672
'/test-error',
4773
Effect.gen(function* () {

‎dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,41 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL
6565
expect(child.trace_id).toBe(segment.trace_id);
6666
}
6767
});
68+
69+
test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => {
70+
const requestSpansPromise = collectStreamedSpans(
71+
'effect-3-node',
72+
spans =>
73+
spans.some(
74+
span =>
75+
span.is_segment &&
76+
getSpanOp(span) === 'http.server' &&
77+
span.attributes['url.path']?.value === '/test-root-span',
78+
) && spans.some(span => span.name === 'root-span-request-marker'),
79+
);
80+
const detachedSpanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'detached-root-span');
81+
82+
await fetch(`${baseURL}/test-root-span`);
83+
84+
const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]);
85+
const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!;
86+
expect(segment.name).toBe('http.server GET');
87+
expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']);
88+
89+
expect(detachedSpan.is_segment).toBe(true);
90+
expect(detachedSpan.parent_span_id).toBeUndefined();
91+
expect(detachedSpan.trace_id).not.toBe(segment.trace_id);
92+
});
93+
94+
test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => {
95+
const spanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'continued-span');
96+
97+
await fetch(`${baseURL}/test-external-parent`);
98+
99+
const span = await spanPromise;
100+
expect(span).toMatchObject({
101+
is_segment: true,
102+
trace_id: 'fedcba0987654321fedcba0987654321',
103+
parent_span_id: '0987654321fedcba',
104+
});
105+
});

‎dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,33 @@ const Routes = Layer.mergeAll(
101101
}),
102102
),
103103

104+
HttpRouter.add(
105+
'GET',
106+
'/test-root-span',
107+
Effect.gen(function* () {
108+
yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker'));
109+
yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true }));
110+
return yield* HttpServerResponse.json({ status: 'ok' });
111+
}),
112+
),
113+
114+
HttpRouter.add(
115+
'GET',
116+
'/test-external-parent',
117+
Effect.gen(function* () {
118+
yield* Effect.sleep('10 millis').pipe(
119+
Effect.withSpan('continued-span', {
120+
parent: Tracer.externalSpan({
121+
traceId: 'fedcba0987654321fedcba0987654321',
122+
spanId: '0987654321fedcba',
123+
sampled: true,
124+
}),
125+
}),
126+
);
127+
return yield* HttpServerResponse.json({ status: 'ok' });
128+
}).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)),
129+
),
130+
104131
HttpRouter.add(
105132
'GET',
106133
'/test-error',

‎dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,41 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL
6565
expect(child.trace_id).toBe(segment.trace_id);
6666
}
6767
});
68+
69+
test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => {
70+
const requestSpansPromise = collectStreamedSpans(
71+
'effect-4-node',
72+
spans =>
73+
spans.some(
74+
span =>
75+
span.is_segment &&
76+
getSpanOp(span) === 'http.server' &&
77+
span.attributes['url.path']?.value === '/test-root-span',
78+
) && spans.some(span => span.name === 'root-span-request-marker'),
79+
);
80+
const detachedSpanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'detached-root-span');
81+
82+
await fetch(`${baseURL}/test-root-span`);
83+
84+
const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]);
85+
const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!;
86+
expect(segment.name).toBe('http.server GET');
87+
expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']);
88+
89+
expect(detachedSpan.is_segment).toBe(true);
90+
expect(detachedSpan.parent_span_id).toBeUndefined();
91+
expect(detachedSpan.trace_id).not.toBe(segment.trace_id);
92+
});
93+
94+
test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => {
95+
const spanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'continued-span');
96+
97+
await fetch(`${baseURL}/test-external-parent`);
98+
99+
const span = await spanPromise;
100+
expect(span).toMatchObject({
101+
is_segment: true,
102+
trace_id: 'fedcba0987654321fedcba0987654321',
103+
parent_span_id: '0987654321fedcba',
104+
});
105+
});

‎packages/effect/src/client/tracer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,4 @@ import { makeSentryTracer } from '../tracer';
88
* keeps the browser variant substitutable (mocks, bundler interop) exactly as it was when the tracer
99
* called it directly.
1010
*/
11-
export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options));
11+
export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), false);

‎packages/effect/src/index.client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export { effectLayer, init } from './client/index';
77
export type { EffectClientLayerOptions } from './client/index';
88

99
export { SentryEffectTracer } from './client/tracer';
10+
export { SentryEffectExternalSpanLayer } from './tracer';
1011
export { SentryEffectLogger } from './logger';
1112
export { SentryEffectMetricsLayer } from './metrics';

‎packages/effect/src/index.server.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export { effectLayer, init } from './server/index';
44
export type { EffectServerLayerOptions } from './server/index';
55

66
export { SentryEffectTracer } from './server/tracer';
7+
export { SentryEffectExternalSpanLayer } from './tracer';
78
export { SentryEffectLogger } from './logger';
89
export { SentryEffectMetricsLayer } from './metrics';

‎packages/effect/src/server/tracer.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { makeSentryTracer } from '../tracer';
88
* every span start with a `getClient()` lookup to lazily install `spanStreamingIntegration`, which on
99
* the server is pure overhead — `ServerRuntimeClient` already installs it eagerly.
1010
*
11+
* Every parentless Effect span starts a new trace. A server process forks no propagation context on
12+
* its own, so without this every request, reactor and background job of the process would land in
13+
* one trace.
14+
*
1115
* See `./client/tracer.ts` for why the call is wrapped in an arrow.
1216
*/
13-
export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options));
17+
export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), true);

0 commit comments

Comments
 (0)