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..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 @@ -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' }); + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), + ), + 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..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 @@ -63,3 +63,59 @@ 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 && 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']); + + 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 with the external span layer', 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('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 && 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.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 1109f1a412b9..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 @@ -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' }); + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), + ), + 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..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 @@ -63,3 +63,59 @@ 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 && 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']); + + 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 with the external span layer', 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('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 && 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.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/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/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/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..c5d6b31af1c0 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -1,9 +1,23 @@ +/* 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'; -import { isObjectLike, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan } from '@sentry/core'; -import type * as Context from 'effect/Context'; +import { + _INTERNAL_safeMathRandom, + addNonEnumerableProperty, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startNewTrace, + withActiveSpan, + withScope, +} from '@sentry/core'; +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'; @@ -15,6 +29,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,38 +199,12 @@ 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; -function createSentrySpan( - startInactiveSpan: StartInactiveSpan, - name: string, - parent: Option.Option, - context: Context.Context, - links: ReadonlyArray, - 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), - }, - ...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}), - }); - - 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 @@ -220,7 +222,170 @@ const isEffectV4 = (() => { } })(); -const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +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) 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 + * 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 }); + } + + 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)); + }); + } + + const enclosingSpan = currentFiber?.currentSpan; + if (enclosingSpan !== undefined && isSentrySpan(enclosingSpan)) { + return startInactiveSpan({ ...options, parentSpan: enclosingSpan.sentrySpan }); + } + } + + 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, + links: ReadonlyArray, + startTime: bigint, + kind: EffectTracer.SpanKind, +): SentrySpanLike { + const newSpan = startSentrySpan( + startInactiveSpan, + { + name, + startTime: nanosToHrTime(startTime), + attributes: { + [SENTRY_OP]: deriveOp(name), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: deriveOrigin(name), + }, + }, + parent, + newTraceForRootSpans, + ); + markEffectSpan(newSpan); + + return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); +} + +const makeSentryTracerV3 = ( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer => { // Effect v3 API: span(name, parent, context, links, startTime, kind) return EffectTracer.make({ span( @@ -231,25 +396,25 @@ 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; - 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); }; -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, @@ -259,11 +424,7 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. ); }, 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)); }, }); }; @@ -273,7 +434,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..268566757b78 100644 --- a/packages/effect/test/tracer.test.ts +++ b/packages/effect/test/tracer.test.ts @@ -2,10 +2,13 @@ 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'; +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 @@ -31,7 +34,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 +228,158 @@ 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('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({ + 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).pipe(Effect.provide(SentryEffectExternalSpanLayer)), + ); + + 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); + }); + } + }); });