From 1f7229d7f720594c6e5f60b1d7cb4a75003f95ee Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 1 Sep 2026 15:54:35 +0200 Subject: [PATCH 1/2] fix(effect): Honor external parents and isolate root spans `@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 --- .../effect-3-node/src/app.ts | 26 ++++ .../effect-3-node/tests/spans.test.ts | 51 +++++++ .../effect-4-node/src/app.ts | 27 ++++ .../effect-4-node/tests/spans.test.ts | 51 +++++++ packages/effect/src/client/tracer.ts | 2 +- packages/effect/src/server/tracer.ts | 6 +- packages/effect/src/tracer.ts | 144 +++++++++++++++--- packages/effect/test/tracer.test.ts | 134 +++++++++++++++- 8 files changed, 416 insertions(+), 25 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts index 9de243f3cab3..69071df388a9 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts @@ -6,6 +6,7 @@ import * as Cause from 'effect/Cause'; import * as Layer from 'effect/Layer'; import * as Logger from 'effect/Logger'; import * as LogLevel from 'effect/LogLevel'; +import * as Tracer from 'effect/Tracer'; import { createServer } from 'http'; const SentryLive = Layer.mergeAll( @@ -42,6 +43,31 @@ const router = HttpRouter.empty.pipe( }), ), + HttpRouter.get( + '/test-root-span', + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker')); + yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true })); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + + HttpRouter.get( + '/test-external-parent', + Effect.gen(function* () { + yield* Effect.sleep('10 millis').pipe( + Effect.withSpan('continued-span', { + parent: Tracer.externalSpan({ + traceId: 'fedcba0987654321fedcba0987654321', + spanId: '0987654321fedcba', + sampled: true, + }), + }), + ); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + HttpRouter.get( '/test-error', Effect.gen(function* () { diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts index 3b04189aea2f..5933d8dae8ef 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts @@ -63,3 +63,54 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL expect(child.trace_id).toBe(segment.trace_id); } }); + +test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => { + const requestSpansPromise = collectStreamedSpans( + 'effect-3-node', + spans => + spans.some( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/test-root-span', + ) && spans.some(span => span.name === 'root-span-request-marker'), + ); + const detachedSpanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'detached-root-span'); + + await fetch(`${baseURL}/test-root-span`); + + const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); + const segment = requestSpans.find(span => span.is_segment)!; + expect(segment.name).toBe('http.server GET'); + expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); + + expect(detachedSpan.is_segment).toBe(true); + expect(detachedSpan.parent_span_id).toBeUndefined(); + expect(detachedSpan.trace_id).not.toBe(segment.trace_id); +}); + +test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'continued-span'); + + await fetch(`${baseURL}/test-external-parent`); + + const span = await spanPromise; + expect(span).toMatchObject({ + is_segment: true, + trace_id: 'fedcba0987654321fedcba0987654321', + parent_span_id: '0987654321fedcba', + }); +}); + +test('Continues the trace of an incoming traceparent header', async ({ baseURL }) => { + const traceId = '1234567890abcdef1234567890abcdef'; + const parentSpanId = 'abcdef1234567890'; + + const spanPromise = waitForStreamedSpan('effect-3-node', span => span.is_segment && span.trace_id === traceId); + + await fetch(`${baseURL}/test-success`, { headers: { traceparent: `00-${traceId}-${parentSpanId}-01` } }); + + const span = await spanPromise; + expect(span.name).toBe('http.server GET'); + expect(span.parent_span_id).toBe(parentSpanId); +}); diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts index 1109f1a412b9..2f94ee5fefd9 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts @@ -46,6 +46,33 @@ const Routes = Layer.mergeAll( }), ), + HttpRouter.add( + 'GET', + '/test-root-span', + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker')); + yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true })); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + + HttpRouter.add( + 'GET', + '/test-external-parent', + Effect.gen(function* () { + yield* Effect.sleep('10 millis').pipe( + Effect.withSpan('continued-span', { + parent: Tracer.externalSpan({ + traceId: 'fedcba0987654321fedcba0987654321', + spanId: '0987654321fedcba', + sampled: true, + }), + }), + ); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + HttpRouter.add( 'GET', '/test-error', diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts index 7477d91a04be..e7b159fa9b9b 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts @@ -63,3 +63,54 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL expect(child.trace_id).toBe(segment.trace_id); } }); + +test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => { + const requestSpansPromise = collectStreamedSpans( + 'effect-4-node', + spans => + spans.some( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/test-root-span', + ) && spans.some(span => span.name === 'root-span-request-marker'), + ); + const detachedSpanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'detached-root-span'); + + await fetch(`${baseURL}/test-root-span`); + + const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); + const segment = requestSpans.find(span => span.is_segment)!; + expect(segment.name).toBe('http.server GET'); + expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); + + expect(detachedSpan.is_segment).toBe(true); + expect(detachedSpan.parent_span_id).toBeUndefined(); + expect(detachedSpan.trace_id).not.toBe(segment.trace_id); +}); + +test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'continued-span'); + + await fetch(`${baseURL}/test-external-parent`); + + const span = await spanPromise; + expect(span).toMatchObject({ + is_segment: true, + trace_id: 'fedcba0987654321fedcba0987654321', + parent_span_id: '0987654321fedcba', + }); +}); + +test('Continues the trace of an incoming traceparent header', async ({ baseURL }) => { + const traceId = '1234567890abcdef1234567890abcdef'; + const parentSpanId = 'abcdef1234567890'; + + const spanPromise = waitForStreamedSpan('effect-4-node', span => span.is_segment && span.trace_id === traceId); + + await fetch(`${baseURL}/test-success`, { headers: { traceparent: `00-${traceId}-${parentSpanId}-01` } }); + + const span = await spanPromise; + expect(span.name).toBe('http.server GET'); + expect(span.parent_span_id).toBe(parentSpanId); +}); diff --git a/packages/effect/src/client/tracer.ts b/packages/effect/src/client/tracer.ts index e824286ddf60..e4313b7fa927 100644 --- a/packages/effect/src/client/tracer.ts +++ b/packages/effect/src/client/tracer.ts @@ -8,4 +8,4 @@ import { makeSentryTracer } from '../tracer'; * keeps the browser variant substitutable (mocks, bundler interop) exactly as it was when the tracer * called it directly. */ -export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options)); +export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), false); diff --git a/packages/effect/src/server/tracer.ts b/packages/effect/src/server/tracer.ts index d4b622c2ba5c..0814db123343 100644 --- a/packages/effect/src/server/tracer.ts +++ b/packages/effect/src/server/tracer.ts @@ -8,6 +8,10 @@ import { makeSentryTracer } from '../tracer'; * every span start with a `getClient()` lookup to lazily install `spanStreamingIntegration`, which on * the server is pure overhead — `ServerRuntimeClient` already installs it eagerly. * + * Every parentless Effect span starts a new trace. A server process forks no propagation context on + * its own, so without this every request, reactor and background job of the process would land in + * one trace. + * * See `./client/tracer.ts` for why the call is wrapped in an arrow. */ -export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options)); +export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), true); diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts index 31ac0be35c29..8682a364c7a6 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -1,7 +1,18 @@ import { SENTRY_OP } from '@sentry/conventions/attributes'; import { FUNCTION, HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import type { Span, StartSpanOptions } from '@sentry/core'; -import { isObjectLike, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan } from '@sentry/core'; +import { + _INTERNAL_safeMathRandom, + addNonEnumerableProperty, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startNewTrace, + withActiveSpan, + withScope, +} from '@sentry/core'; import type * as Context from 'effect/Context'; import * as Exit from 'effect/Exit'; import * as Option from 'effect/Option'; @@ -15,6 +26,20 @@ function deriveOrigin(name: string): string { return 'auto.function.effect'; } +const EFFECT_SPAN_SYMBOL = Symbol.for('@sentry/effect.EffectSpan'); + +function markEffectSpan(span: Span): void { + addNonEnumerableProperty(span, EFFECT_SPAN_SYMBOL, true); +} + +/** + * Whether this tracer created the span. A brand rather than an attribute check, because an unsampled + * span keeps no attributes. + */ +function isEffectSpan(span: Span): boolean { + return (span as { [EFFECT_SPAN_SYMBOL]?: boolean })[EFFECT_SPAN_SYMBOL] === true; +} + /** * Effect span names are chosen by user code, so the name is the only signal available. `@effect/platform` * names its HTTP spans `http.server`/`http.client`, which map onto the matching Sentry ops; everything @@ -171,15 +196,73 @@ class SentrySpanWrapper implements SentrySpanLike { } /** - * The client and the server entry differ only in which `startInactiveSpan` they hand to - * {@link makeSentryTracer}: the browser one from `@sentry/core`, which installs the span - * streaming integration on first use, and the plain one from `@sentry/core`, which does not. Nothing - * else about the tracer is platform-specific. + * The client and the server entry hand different `startInactiveSpan` functions to + * {@link makeSentryTracer}: the browser one from `@sentry/core/browser`, which installs the span + * streaming integration on first use, and the plain one from `@sentry/core`, which does not. */ export type StartInactiveSpan = (options: StartSpanOptions) => Span; +/** + * Starts the Sentry span for an Effect span, rooted or parented the way Effect asked for. + * + * - A parent this tracer created becomes the Sentry parent. + * - Any other parent (`Tracer.externalSpan` from incoming trace headers or persisted trace state, or a + * span from another Effect tracer) continues that trace: the new span is a root span whose + * `parent_span_id` is the external span. No dynamic sampling context is frozen, so the SDK builds + * one from the client the way it does for a head-of-trace span. + * - Without a parent, the span nests under a foreign active Sentry span (an `http.server` span from the + * Node SDK, a pageload in the browser) but never under a span this tracer created: Effect's own parent + * tracking is authoritative for those, so an active one is an enclosing `root: true` span or a span + * leaked from another fiber through the async context. Effect reports `root: true` for every + * parentless span, so the flag adds nothing and is not consulted. + * - A root span starts a new trace when `newTraceForRootSpans` is set, unless the user set up the + * current scope (`Sentry.continueTrace`, `Sentry.withScope`, an HTTP request's isolation scope). + */ +function startSentrySpan( + startInactiveSpan: StartInactiveSpan, + options: StartSpanOptions, + parent: Option.Option, + newTraceForRootSpans: boolean, +): Span { + if (Option.isSome(parent)) { + const parentSpan = parent.value; + + if (isSentrySpan(parentSpan)) { + return startInactiveSpan({ ...options, parentSpan: parentSpan.sentrySpan }); + } + + return withScope(scope => { + scope.setPropagationContext({ + traceId: parentSpan.traceId, + parentSpanId: parentSpan.spanId, + sampled: parentSpan.sampled, + sampleRand: _INTERNAL_safeMathRandom(), + }); + return withActiveSpan(null, () => startInactiveSpan(options)); + }); + } + + const activeSpan = getActiveSpan(); + if (activeSpan && !isEffectSpan(activeSpan)) { + return startInactiveSpan({ ...options, parentSpan: activeSpan }); + } + + // A scope the user forked (`continueTrace`, `withScope`, an isolation scope) carries its own trace id. + // The scopes this tracer forks in `context()` clone the propagation context they were forked from, so + // even when one leaks into another fiber through the async context it still carries the process-wide + // trace id of the default scope. + const isProcessTrace = + getCurrentScope().getPropagationContext().traceId === getDefaultCurrentScope().getPropagationContext().traceId; + if (newTraceForRootSpans && isProcessTrace) { + return startNewTrace(() => startInactiveSpan(options)); + } + + return withActiveSpan(null, () => startInactiveSpan(options)); +} + function createSentrySpan( startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, name: string, parent: Option.Option, context: Context.Context, @@ -187,18 +270,20 @@ function createSentrySpan( startTime: bigint, kind: EffectTracer.SpanKind, ): SentrySpanLike { - const parentSentrySpan = - Option.isSome(parent) && isSentrySpan(parent.value) ? parent.value.sentrySpan : (getActiveSpan() ?? null); - - const newSpan = startInactiveSpan({ - name, - startTime: nanosToHrTime(startTime), - attributes: { - [SENTRY_OP]: deriveOp(name), - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: deriveOrigin(name), + const newSpan = startSentrySpan( + startInactiveSpan, + { + name, + startTime: nanosToHrTime(startTime), + attributes: { + [SENTRY_OP]: deriveOp(name), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: deriveOrigin(name), + }, }, - ...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}), - }); + parent, + newTraceForRootSpans, + ); + markEffectSpan(newSpan); return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); } @@ -220,7 +305,10 @@ const isEffectV4 = (() => { } })(); -const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +const makeSentryTracerV3 = ( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer => { // Effect v3 API: span(name, parent, context, links, startTime, kind) return EffectTracer.make({ span( @@ -231,7 +319,7 @@ const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer. startTime: bigint, kind: EffectTracer.SpanKind, ) { - return createSentrySpan(startInactiveSpan, name, parent, context, links, startTime, kind); + return createSentrySpan(startInactiveSpan, newTraceForRootSpans, name, parent, context, links, startTime, kind); }, context(execution: () => unknown, fiber: { currentSpan?: EffectTracer.AnySpan }) { const currentSpan = fiber.currentSpan; @@ -243,13 +331,17 @@ const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer. } as unknown as EffectTracer.Tracer); }; -const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +const makeSentryTracerV4 = ( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer => { const EFFECT_EVALUATE = '~effect/Effect/evaluate' as const; return EffectTracer.make({ span(options) { return createSentrySpan( startInactiveSpan, + newTraceForRootSpans, options.name, options.parent, options.annotations, @@ -273,7 +365,17 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. * * Use the `SentryEffectTracer` exported from `@sentry/effect` rather than calling this directly — the * client and server entries each bind the right `startInactiveSpan` for their platform. + * + * `newTraceForRootSpans` is the one behavioural difference between the platforms: Effect gives every + * parentless span a fresh trace id, and on a long-lived server nothing else forks the propagation + * context, so the server tracer follows Effect and starts a new trace. In the browser the page trace is + * the intended home of every span, so the client tracer keeps parentless spans in it. */ -export function makeSentryTracer(startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer { - return isEffectV4 ? makeSentryTracerV4(startInactiveSpan) : makeSentryTracerV3(startInactiveSpan); +export function makeSentryTracer( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer { + return isEffectV4 + ? makeSentryTracerV4(startInactiveSpan, newTraceForRootSpans) + : makeSentryTracerV3(startInactiveSpan, newTraceForRootSpans); } diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts index a056227576e1..46d7057592b2 100644 --- a/packages/effect/test/tracer.test.ts +++ b/packages/effect/test/tracer.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from '@effect/vitest'; import * as sentryCore from '@sentry/core'; import * as sentryCoreBrowser from '@sentry/core/browser'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { ServerRuntimeClient } from '@sentry/core/server'; import { Effect } from 'effect'; -import { afterEach, vi } from 'vitest'; +import * as Tracer from 'effect/Tracer'; +import { afterEach, beforeEach, vi } from 'vitest'; import { SentryEffectTracer as clientTracer } from '../src/client/tracer'; import { SentryEffectTracer as serverTracer } from '../src/server/tracer'; @@ -31,7 +33,7 @@ function mockSpan(overrides: Record = {}): sentryCore.Span { } as unknown as sentryCore.Span; } -describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ tracer, spanApi }) => { +describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ variant, tracer, spanApi }) => { const withSentryTracer = (effect: Effect.Effect) => Effect.withTracer(effect, tracer); afterEach(() => { @@ -225,4 +227,132 @@ describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ tracer, spanApi }) = expect(result).toBe('with-tracer'); }).pipe(Effect.withTracer(tracer)), ); + + describe('trace structure', () => { + const traceId = 'a'.repeat(32); + const spanId = 'b'.repeat(16); + + beforeEach(() => { + const client = new ServerRuntimeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + transport: () => sentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => Promise.resolve({})), + stackParser: () => [], + tracesSampleRate: 1, + traceLifecycle: 'static', + }); + sentryCore.getCurrentScope().setClient(client); + client.init(); + }); + + afterEach(() => { + sentryCore.getCurrentScope().setClient(undefined); + }); + + const currentSentrySpan = Effect.map( + Effect.currentSpan, + span => (span as unknown as { sentrySpan: sentryCore.Span }).sentrySpan, + ); + + const run = (effect: Effect.Effect): A => Effect.runSync(Effect.withTracer(effect, tracer)); + + it('continues the trace of an external parent as a new root span', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const span = run(Effect.withSpan('reactor', { parent })(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span)).toMatchObject({ trace_id: traceId, parent_span_id: spanId }); + expect(sentryCore.spanIsSampled(span)).toBe(true); + expect(sentryCore.getDynamicSamplingContextFromSpan(span)).toMatchObject({ + trace_id: traceId, + public_key: 'public', + sampled: 'true', + }); + }); + + it('honors the sampling decision of an external parent', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: false }); + const span = run(Effect.withSpan('reactor', { parent })(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).trace_id).toBe(traceId); + expect(sentryCore.spanIsSampled(span)).toBe(false); + }); + + it('does not nest a root: true span under the enclosing Effect span', () => { + const [outer, inner] = run( + Effect.withSpan('outer')( + Effect.all([currentSentrySpan, Effect.withSpan('inner', { root: true })(currentSentrySpan)]), + ), + ); + + expect(sentryCore.spanToJSON(inner).parent_span_id).toBeUndefined(); + if (variant === 'server') { + expect(sentryCore.spanToJSON(inner).trace_id).not.toBe(sentryCore.spanToJSON(outer).trace_id); + } else { + expect(sentryCore.spanToJSON(inner).trace_id).toBe(sentryCore.spanToJSON(outer).trace_id); + } + }); + + it('nests a parentless Effect span under a foreign active Sentry span', () => { + sentryCore.startSpan({ name: 'http.server' }, request => { + const span = run(Effect.withSpan('handler')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).parent_span_id).toBe(request.spanContext().spanId); + expect(sentryCore.spanToJSON(span).trace_id).toBe(request.spanContext().traceId); + }); + }); + + it('does not parent a parentless Effect span on a span leaked from another fiber', () => { + const leaked = run(Effect.withSpan('other-fiber')(currentSentrySpan)); + + sentryCore.withActiveSpan(leaked, () => { + const span = run(Effect.withSpan('root')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + }); + + it('does not parent a parentless Effect span on an unsampled span leaked from another fiber', () => { + const leaked = sentryCore.withScope(scope => { + scope.setPropagationContext({ traceId, sampled: false, sampleRand: 0.5 }); + return run(Effect.withSpan('unsampled-fiber')(currentSentrySpan)); + }); + expect(sentryCore.spanIsSampled(leaked)).toBe(false); + + sentryCore.withActiveSpan(leaked, () => { + const span = run(Effect.withSpan('root')(currentSentrySpan)); + + expect(sentryCore.spanIsSampled(span)).toBe(true); + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + }); + + it('keeps a parentless Effect span in a trace the user continued', () => { + sentryCore.continueTrace({ sentryTrace: `${traceId}-${spanId}-1`, baggage: undefined }, () => { + const span = run(Effect.withSpan('handler')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span)).toMatchObject({ trace_id: traceId, parent_span_id: spanId }); + }); + }); + + if (variant === 'server') { + it('starts a new trace for every parentless Effect span', () => { + const processTraceId = sentryCore.getCurrentScope().getPropagationContext().traceId; + const first = run(Effect.withSpan('first')(currentSentrySpan)); + const second = run(Effect.withSpan('second')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(first).trace_id).not.toBe(processTraceId); + expect(sentryCore.spanToJSON(second).trace_id).not.toBe(processTraceId); + expect(sentryCore.spanToJSON(first).trace_id).not.toBe(sentryCore.spanToJSON(second).trace_id); + }); + } else { + it('keeps parentless Effect spans in the page trace', () => { + const pageTraceId = sentryCore.getCurrentScope().getPropagationContext().traceId; + const first = run(Effect.withSpan('first')(currentSentrySpan)); + const second = run(Effect.withSpan('second')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(first).trace_id).toBe(pageTraceId); + expect(sentryCore.spanToJSON(second).trace_id).toBe(pageTraceId); + }); + } + }); }); From c900a2462ac0f404f179844d5d56430c0528ed8c Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 8 Sep 2026 18:59:15 +0200 Subject: [PATCH 2/2] fixup! fix(effect): Honor external parents and isolate root spans Co-Authored-By: Claude Fable 5.1 --- .../effect-3-node/src/app.ts | 2 +- .../effect-3-node/tests/spans.test.ts | 15 +- .../effect-4-node/src/app.ts | 2 +- .../effect-4-node/tests/spans.test.ts | 15 +- packages/effect/README.md | 27 ++++ packages/effect/src/index.client.ts | 1 + packages/effect/src/index.server.ts | 1 + packages/effect/src/tracer.ts | 147 +++++++++++++----- packages/effect/test/tracer.test.ts | 31 +++- 9 files changed, 188 insertions(+), 53 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts index 69071df388a9..3d6739e329f1 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts @@ -65,7 +65,7 @@ const router = HttpRouter.empty.pipe( }), ); return yield* HttpServerResponse.json({ status: 'ok' }); - }), + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), ), HttpRouter.get( diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts index 5933d8dae8ef..f68dbeba37cf 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts @@ -80,7 +80,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU await fetch(`${baseURL}/test-root-span`); const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); - const segment = requestSpans.find(span => span.is_segment)!; + const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!; expect(segment.name).toBe('http.server GET'); expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); @@ -89,7 +89,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU expect(detachedSpan.trace_id).not.toBe(segment.trace_id); }); -test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) => { +test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => { const spanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'continued-span'); await fetch(`${baseURL}/test-external-parent`); @@ -102,15 +102,20 @@ test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) }); }); -test('Continues the trace of an incoming traceparent header', async ({ baseURL }) => { +test('Ignores an incoming traceparent header without the external span layer', async ({ baseURL }) => { const traceId = '1234567890abcdef1234567890abcdef'; const parentSpanId = 'abcdef1234567890'; - const spanPromise = waitForStreamedSpan('effect-3-node', span => span.is_segment && span.trace_id === traceId); + const spanPromise = waitForStreamedSpan( + 'effect-3-node', + span => + span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/test-success', + ); await fetch(`${baseURL}/test-success`, { headers: { traceparent: `00-${traceId}-${parentSpanId}-01` } }); const span = await spanPromise; expect(span.name).toBe('http.server GET'); - expect(span.parent_span_id).toBe(parentSpanId); + expect(span.trace_id).not.toBe(traceId); + expect(span.parent_span_id).toBeUndefined(); }); diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts index 2f94ee5fefd9..77c80cb7b60d 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts @@ -70,7 +70,7 @@ const Routes = Layer.mergeAll( }), ); return yield* HttpServerResponse.json({ status: 'ok' }); - }), + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), ), HttpRouter.add( diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts index e7b159fa9b9b..995c2d189298 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts @@ -80,7 +80,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU await fetch(`${baseURL}/test-root-span`); const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); - const segment = requestSpans.find(span => span.is_segment)!; + const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!; expect(segment.name).toBe('http.server GET'); expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); @@ -89,7 +89,7 @@ test('Sends a root: true span as its own segment in a new trace', async ({ baseU expect(detachedSpan.trace_id).not.toBe(segment.trace_id); }); -test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) => { +test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => { const spanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'continued-span'); await fetch(`${baseURL}/test-external-parent`); @@ -102,15 +102,20 @@ test('Continues the trace of a Tracer.externalSpan parent', async ({ baseURL }) }); }); -test('Continues the trace of an incoming traceparent header', async ({ baseURL }) => { +test('Ignores an incoming traceparent header without the external span layer', async ({ baseURL }) => { const traceId = '1234567890abcdef1234567890abcdef'; const parentSpanId = 'abcdef1234567890'; - const spanPromise = waitForStreamedSpan('effect-4-node', span => span.is_segment && span.trace_id === traceId); + const spanPromise = waitForStreamedSpan( + 'effect-4-node', + span => + span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/test-success', + ); await fetch(`${baseURL}/test-success`, { headers: { traceparent: `00-${traceId}-${parentSpanId}-01` } }); const span = await spanPromise; expect(span.name).toBe('http.server GET'); - expect(span.parent_span_id).toBe(parentSpanId); + expect(span.trace_id).not.toBe(traceId); + expect(span.parent_span_id).toBeUndefined(); }); diff --git a/packages/effect/README.md b/packages/effect/README.md index aa733a4e528e..2535dd327c2c 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -72,6 +72,33 @@ const HttpLive = HttpRouter.serve(Routes).pipe( NodeRuntime.runMain(Layer.launch(HttpLive)); ``` +## Continuing external traces + +The tracer ignores `Tracer.externalSpan` parents by default, so Sentry's own +trace continuation stays in charge. This includes the parent that +`@effect/platform` builds from an incoming `traceparent` or `b3` header. To +continue the trace of an external span instead, provide +`SentryEffectExternalSpanLayer`. Next to the tracer layer it applies to every +span in the runtime, including the HTTP server spans. On Effect v4, replace the +`Layer.setTracer` line with the `Layer.succeed` call from the example above. + +```typescript +const SentryLive = Layer.mergeAll( + Sentry.effectLayer({ dsn: '__DSN__', tracesSampleRate: 1.0 }), + Layer.setTracer(Sentry.SentryEffectTracer), + Sentry.SentryEffectExternalSpanLayer, +); +``` + +Provided to a single effect, it applies to that effect only: + +```typescript +const processJob = handleMessage(message).pipe( + Effect.withSpan('process-job', { parent: Tracer.externalSpan(message.trace) }), + Effect.provide(Sentry.SentryEffectExternalSpanLayer), +); +``` + ## Links - [Official SDK Docs](https://docs.sentry.io/platforms/javascript/guides/effect/) diff --git a/packages/effect/src/index.client.ts b/packages/effect/src/index.client.ts index 76aba91e5d04..14ad2597abdf 100644 --- a/packages/effect/src/index.client.ts +++ b/packages/effect/src/index.client.ts @@ -7,5 +7,6 @@ export { effectLayer, init } from './client/index'; export type { EffectClientLayerOptions } from './client/index'; export { SentryEffectTracer } from './client/tracer'; +export { SentryEffectExternalSpanLayer } from './tracer'; export { SentryEffectLogger } from './logger'; export { SentryEffectMetricsLayer } from './metrics'; diff --git a/packages/effect/src/index.server.ts b/packages/effect/src/index.server.ts index 01d9272ce6a9..6ceec3346390 100644 --- a/packages/effect/src/index.server.ts +++ b/packages/effect/src/index.server.ts @@ -4,5 +4,6 @@ export { effectLayer, init } from './server/index'; export type { EffectServerLayerOptions } from './server/index'; export { SentryEffectTracer } from './server/tracer'; +export { SentryEffectExternalSpanLayer } from './tracer'; export { SentryEffectLogger } from './logger'; export { SentryEffectMetricsLayer } from './metrics'; diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts index 8682a364c7a6..c5d6b31af1c0 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -1,3 +1,4 @@ +/* oxlint-disable max-lines */ import { SENTRY_OP } from '@sentry/conventions/attributes'; import { FUNCTION, HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import type { Span, StartSpanOptions } from '@sentry/core'; @@ -13,8 +14,10 @@ import { withActiveSpan, withScope, } from '@sentry/core'; -import type * as Context from 'effect/Context'; +import * as Context from 'effect/Context'; import * as Exit from 'effect/Exit'; +import type * as EffectLayer from 'effect/Layer'; +import { succeed as succeedLayer } from 'effect/Layer'; import * as Option from 'effect/Option'; import * as EffectTracer from 'effect/Tracer'; @@ -202,13 +205,97 @@ class SentrySpanWrapper implements SentrySpanLike { */ export type StartInactiveSpan = (options: StartSpanOptions) => Span; +// Check if we're running Effect v4 by checking the Exit/Cause structure +// In v4, causes have a 'reasons' array +// In v3, causes have '_tag' directly on the cause object +const isEffectV4 = (() => { + try { + const testExit = Exit.fail('test') as unknown as { cause?: unknown }; + const cause = testExit.cause; + // v4 causes have 'reasons' array, v3 causes have '_tag' directly + if (isObjectLike(cause) && 'reasons' in cause) { + return true; + } + return false; + } catch { + return false; + } +})(); + +const EXTERNAL_SPAN_KEY = '@sentry/effect/ExternalSpan'; + +interface EffectV3Context { + GenericTag(key: string): Context.Key; +} + +/** + * Whether the tracer continues the trace of a `Tracer.externalSpan` parent. Effect v4 has no `FiberRef` + * and v3 has no `Context.Reference`, so the flag is a plain service in both. The cast is safe: the tracer + * reads it with `getRef` only on a v4 fiber, where it is a reference, and with `Context.getOption` on v3. + */ +const ExternalSpanFlag = ( + isEffectV4 + ? Context.Reference(EXTERNAL_SPAN_KEY, { defaultValue: () => false }) + : (Context as unknown as EffectV3Context).GenericTag(EXTERNAL_SPAN_KEY) +) as Context.Reference; + +/** + * Makes the tracer continue the trace of a `Tracer.externalSpan` parent instead of ignoring it. Provide it + * next to the tracer layer to continue every external span in the runtime, which includes the parent + * `@effect/platform` builds from an incoming `traceparent` or `b3` header, or provide it to a single effect + * with `Effect.provide` to continue only that one. + */ +export const SentryEffectExternalSpanLayer: EffectLayer.Layer = succeedLayer(ExternalSpanFlag, true); + +interface FiberLike { + readonly currentSpan?: EffectTracer.AnySpan | undefined; + /** Reads a reference with its default. Only Effect v4 fibers have it. */ + readonly getRef?: (ref: Context.Reference) => A; + /** The services of the fiber. Only Effect v3 fibers have it. */ + readonly currentContext?: Context.Context; +} + +/** + * The fiber whose operation is being evaluated. Effect hands the fiber to the `context` hook but not to + * `span`, which runs synchronously inside it, so the hook keeps the fiber here for that extent. + */ +let currentFiber: FiberLike | undefined; + +function continuesExternalSpans(fiber: FiberLike): boolean { + if (fiber.getRef) { + return fiber.getRef(ExternalSpanFlag); + } + + return ( + fiber.currentContext !== undefined && + Option.getOrElse(Context.getOption(fiber.currentContext, ExternalSpanFlag), () => false) + ); +} + +function withFiberContext(fiber: FiberLike, execution: () => X): X { + const previousFiber = currentFiber; + currentFiber = fiber; + try { + const currentSpan = fiber.currentSpan; + if (currentSpan === undefined || !isSentrySpan(currentSpan)) { + return execution(); + } + return withActiveSpan(currentSpan.sentrySpan, execution); + } finally { + currentFiber = previousFiber; + } +} + /** * Starts the Sentry span for an Effect span, rooted or parented the way Effect asked for. * * - A parent this tracer created becomes the Sentry parent. * - Any other parent (`Tracer.externalSpan` from incoming trace headers or persisted trace state, or a - * span from another Effect tracer) continues that trace: the new span is a root span whose - * `parent_span_id` is the external span. No dynamic sampling context is frozen, so the SDK builds + * span from another Effect tracer) is ignored unless {@link SentryEffectExternalSpanLayer} is provided, + * so Sentry's own trace continuation with its org id and `strictTraceContinuation` checks stays in + * charge. The span then nests where Effect would have put it without the `parent` option: under the + * fiber's current span, or parentless. With the layer, the span continues that trace: it is a root span + * whose `parent_span_id` is the external span. No dynamic sampling context is frozen, so the SDK builds * one from the client the way it does for a head-of-trace span. * - Without a parent, the span nests under a foreign active Sentry span (an `http.server` span from the * Node SDK, a pageload in the browser) but never under a span this tracer created: Effect's own parent @@ -231,15 +318,22 @@ function startSentrySpan( return startInactiveSpan({ ...options, parentSpan: parentSpan.sentrySpan }); } - return withScope(scope => { - scope.setPropagationContext({ - traceId: parentSpan.traceId, - parentSpanId: parentSpan.spanId, - sampled: parentSpan.sampled, - sampleRand: _INTERNAL_safeMathRandom(), + if (currentFiber !== undefined && continuesExternalSpans(currentFiber)) { + return withScope(scope => { + scope.setPropagationContext({ + traceId: parentSpan.traceId, + parentSpanId: parentSpan.spanId, + sampled: parentSpan.sampled, + sampleRand: _INTERNAL_safeMathRandom(), + }); + return withActiveSpan(null, () => startInactiveSpan(options)); }); - return withActiveSpan(null, () => startInactiveSpan(options)); - }); + } + + const enclosingSpan = currentFiber?.currentSpan; + if (enclosingSpan !== undefined && isSentrySpan(enclosingSpan)) { + return startInactiveSpan({ ...options, parentSpan: enclosingSpan.sentrySpan }); + } } const activeSpan = getActiveSpan(); @@ -288,23 +382,6 @@ function createSentrySpan( return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); } -// Check if we're running Effect v4 by checking the Exit/Cause structure -// In v4, causes have a 'reasons' array -// In v3, causes have '_tag' directly on the cause object -const isEffectV4 = (() => { - try { - const testExit = Exit.fail('test') as unknown as { cause?: unknown }; - const cause = testExit.cause; - // v4 causes have 'reasons' array, v3 causes have '_tag' directly - if (isObjectLike(cause) && 'reasons' in cause) { - return true; - } - return false; - } catch { - return false; - } -})(); - const makeSentryTracerV3 = ( startInactiveSpan: StartInactiveSpan, newTraceForRootSpans: boolean, @@ -321,12 +398,8 @@ const makeSentryTracerV3 = ( ) { return createSentrySpan(startInactiveSpan, newTraceForRootSpans, name, parent, context, links, startTime, kind); }, - context(execution: () => unknown, fiber: { currentSpan?: EffectTracer.AnySpan }) { - const currentSpan = fiber.currentSpan; - if (currentSpan === undefined || !isSentrySpan(currentSpan)) { - return execution(); - } - return withActiveSpan(currentSpan.sentrySpan, execution); + context(execution: () => unknown, fiber: FiberLike) { + return withFiberContext(fiber, execution); }, } as unknown as EffectTracer.Tracer); }; @@ -351,11 +424,7 @@ const makeSentryTracerV4 = ( ); }, context(primitive, fiber) { - const currentSpan = fiber.currentSpan; - if (currentSpan === undefined || !isSentrySpan(currentSpan)) { - return primitive[EFFECT_EVALUATE](fiber); - } - return withActiveSpan(currentSpan.sentrySpan, () => primitive[EFFECT_EVALUATE](fiber)); + return withFiberContext(fiber, () => primitive[EFFECT_EVALUATE](fiber)); }, }); }; diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts index 46d7057592b2..268566757b78 100644 --- a/packages/effect/test/tracer.test.ts +++ b/packages/effect/test/tracer.test.ts @@ -8,6 +8,7 @@ import * as Tracer from 'effect/Tracer'; import { afterEach, beforeEach, vi } from 'vitest'; import { SentryEffectTracer as clientTracer } from '../src/client/tracer'; import { SentryEffectTracer as serverTracer } from '../src/server/tracer'; +import { SentryEffectExternalSpanLayer } from '../src/tracer'; // The two variants differ only in which module they start spans through, so spying on `spanApi` also // asserts that wiring: the client tracer must go through `@sentry/core/browser` (which installs @@ -256,10 +257,34 @@ describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ variant, tracer, spa const run = (effect: Effect.Effect): A => Effect.runSync(Effect.withTracer(effect, tracer)); - it('continues the trace of an external parent as a new root span', () => { + it('treats an external parent as no parent without the external span layer', () => { const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); const span = run(Effect.withSpan('reactor', { parent })(currentSentrySpan)); + expect(sentryCore.spanToJSON(span).trace_id).not.toBe(traceId); + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + + it('nests a span with an ignored external parent under the enclosing Effect span', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const [outer, inner] = run( + Effect.withSpan('outer')( + Effect.all([currentSentrySpan, Effect.withSpan('inner', { parent })(currentSentrySpan)]), + ), + ); + + expect(sentryCore.spanToJSON(inner)).toMatchObject({ + trace_id: sentryCore.spanToJSON(outer).trace_id, + parent_span_id: outer.spanContext().spanId, + }); + }); + + it('continues the trace of an external parent as a new root span with the external span layer', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const span = run( + Effect.withSpan('reactor', { parent })(currentSentrySpan).pipe(Effect.provide(SentryEffectExternalSpanLayer)), + ); + expect(sentryCore.spanToJSON(span)).toMatchObject({ trace_id: traceId, parent_span_id: spanId }); expect(sentryCore.spanIsSampled(span)).toBe(true); expect(sentryCore.getDynamicSamplingContextFromSpan(span)).toMatchObject({ @@ -271,7 +296,9 @@ describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ variant, tracer, spa it('honors the sampling decision of an external parent', () => { const parent = Tracer.externalSpan({ traceId, spanId, sampled: false }); - const span = run(Effect.withSpan('reactor', { parent })(currentSentrySpan)); + const span = run( + Effect.withSpan('reactor', { parent })(currentSentrySpan).pipe(Effect.provide(SentryEffectExternalSpanLayer)), + ); expect(sentryCore.spanToJSON(span).trace_id).toBe(traceId); expect(sentryCore.spanIsSampled(span)).toBe(false);