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 9f689d35018b..4025bacdbfba 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 @@ -65,3 +65,41 @@ 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', + }); +}); 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 db5b1fae44eb..0bcc70088359 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 @@ -101,6 +101,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 2a468928c798..395f5263f286 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 @@ -65,3 +65,41 @@ 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', + }); +}); 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 db33a96cc97d..2e72e3c61fc0 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 { 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 | undefined { return undefined; } +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 whoever calls `Effect.withSpan`, so the name is the only signal * available. `@effect/platform` names its HTTP spans `http.server`/`http.client`, which map onto the @@ -173,42 +201,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 op = deriveOp(name); - const origin = deriveOrigin(name); - - const newSpan = startInactiveSpan({ - name, - startTime: nanosToHrTime(startTime), - // Setting these to `undefined` would strip the core defaults instead of leaving them in place. - attributes: { - ...(op && { [SENTRY_OP]: op }), - ...(origin && { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }), - }, - ...(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 @@ -226,7 +224,187 @@ const isEffectV4 = (() => { } })(); -const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +const EXTERNAL_SPAN_KEY = '@sentry/effect/ExternalSpan'; + +interface ContextExports { + Reference: typeof Context.Reference; + /** Only Effect v3 exports it. */ + GenericTag(key: string): Context.Key; +} + +// Effect v4 has no `Context.GenericTag` and Effect v3 before 3.11 has no `Context.Reference`. Both are read +// through a separate binding because bundlers fail the build on a namespace member the installed version lacks. +const contextExports = Context as unknown as ContextExports; + +/** + * Whether the tracer continues the trace of a `Tracer.externalSpan` parent. Effect v4 has no `FiberRef` + * and v3 before 3.11 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 + ? contextExports.Reference(EXTERNAL_SPAN_KEY, { defaultValue: () => false }) + : contextExports.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, 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 { + /** The span the fiber runs in. Only Effect v3 fibers have it. */ + readonly currentSpan?: EffectTracer.AnySpan | undefined; + /** Holds `span`, the span the fiber runs in. Only Effect v4 fibers have it. */ + readonly cache?: { readonly span?: 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 getFiberSpan(fiber: FiberLike): EffectTracer.AnySpan | undefined { + return isEffectV4 ? fiber.cache?.span : fiber.currentSpan; +} + +function withFiberContext(fiber: FiberLike, execution: () => X): X { + const previousFiber = currentFiber; + currentFiber = fiber; + try { + const currentSpan = getFiberSpan(fiber); + 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` bridging a trace this SDK did not start, such as an + * OpenTelemetry span of another app in the same process or persisted trace state) is ignored unless + * {@link SentryEffectExternalSpanLayer} is provided, so a span joins a foreign trace only when the user + * asked for it. 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 && getFiberSpan(currentFiber); + 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 op = deriveOp(name); + const origin = deriveOrigin(name); + + const newSpan = startSentrySpan( + startInactiveSpan, + { + name, + startTime: nanosToHrTime(startTime), + // Setting these to `undefined` would strip the core defaults instead of leaving them in place. + attributes: { + ...(op && { [SENTRY_OP]: op }), + ...(origin && { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }), + }, + }, + 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( @@ -237,25 +415,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, @@ -265,11 +443,7 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. ); }, context(primitive, fiber) { - const currentSpan = fiber.cache.span; - 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)); }, }); }; @@ -279,7 +453,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 90429b3a8bcd..a7aace022282 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(() => { @@ -229,4 +232,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); + }); + } + }); });