From 0df122c4c4de3dfa4abd8436c9ed94193fab6fca Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 30 Jul 2026 14:45:10 +0200 Subject: [PATCH 01/11] feat(bun): Add `bunHttpServerIntegration` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun does not emit the `http.server.request.start` diagnostics channel that the Node SDK relies on to isolate each incoming request. As a result, servers built on `node:http` (such as Next.js running via `bun --bun`) do not get a fresh isolation scope and trace per request, so unrelated requests can share one trace. `bunHttpIntegration` closes this gap by patching `http.Server.prototype.emit` and, on each server's first `request` event, handing the server to the same core instrumentation the Node SDK uses (`getHttpServerSubscriptions` → `instrumentServer`). We patch the prototype rather than `createServer` because the server is typically created and `listen()`ed before Sentry initializes (e.g. Next.js creates its server before running the `instrumentation.ts` `register()` hook). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../nextjs-16-bun/package.json | 1 + .../nextjs-16-bun/sentry.server.config.ts | 5 + packages/bun/src/index.ts | 1 + .../bun/src/integrations/bunHttpServer.ts | 133 ++++++++++++++++++ .../test/integrations/bunHttpServer.test.ts | 66 +++++++++ 5 files changed, 206 insertions(+) create mode 100644 packages/bun/src/integrations/bunHttpServer.ts create mode 100644 packages/bun/test/integrations/bunHttpServer.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json index 509c2b2c3a9f..346e10ca7d9f 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@sentry/bun": "file:../../packed/sentry-bun-packed.tgz", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", "import-in-the-middle": "^2", "next": "16.2.3", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts index abf631fb0060..75e20e590771 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-bun/sentry.server.config.ts @@ -1,3 +1,4 @@ +import { bunHttpServerIntegration } from '@sentry/bun'; import * as Sentry from '@sentry/nextjs'; Sentry.init({ @@ -8,4 +9,8 @@ Sentry.init({ tracesSampleRate: 1.0, dataCollection: { userInfo: true }, tracePropagationTargets: ['http://localhost:3030/propagation/test-outgoing-fetch/check'], + // Bun does not emit the `node:http` diagnostics channel the Node SDK uses to isolate incoming + // requests, so each request would otherwise share one trace. Next.js emits its own server spans, + // hence `spans: false` — this only isolates the request and resets its trace. + integrations: [bunHttpServerIntegration({ spans: false })], }); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 4a6c25eed27d..a37fbf651231 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -199,5 +199,6 @@ export { initWithoutDefaultIntegrations, } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; +export { bunHttpServerIntegration } from './integrations/bunHttpServer'; export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts new file mode 100644 index 000000000000..cb5cea764500 --- /dev/null +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -0,0 +1,133 @@ +import http from 'node:http'; +import https from 'node:https'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core'; + +const INTEGRATION_NAME = 'BunHttpServer' as const; + +interface BunHttpServerOptions { + /** + * Whether to create `http.server` spans for incoming requests. + * + * Set this to `false` when another layer already emits incoming-request spans + * (e.g. Next.js running on Bun, which creates its own OpenTelemetry spans). + * The integration then only isolates each request and resets its trace, without + * creating duplicate transactions. + * + * @default true + */ + spans?: boolean; + + /** + * Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests. + * + * @default true + */ + sessions?: boolean; + + /** + * Number of milliseconds until sessions are flushed as a session aggregate. + * + * @default 60000 + */ + sessionFlushingDelayMS?: number; + + /** + * Do not capture the request body for incoming HTTP requests to URLs where the given callback returns `true`. + */ + ignoreRequestBody?: (url: string, request: http.RequestOptions) => boolean; + + /** + * Controls the maximum size of incoming HTTP request bodies attached to events. + * + * @default 'medium' + */ + maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; +} + +let hasPatched = false; + +const _bunHttpServerIntegration = ((options: BunHttpServerOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentBunHttpServer(options); + }, + }; +}) satisfies IntegrationFn; + +/** + * Instruments incoming `node:http`/`node:https` server requests under the Bun runtime. + * + * Unlike Node.js, Bun does not emit the `http.server.request.start` diagnostics channel that the + * Node SDK relies on to isolate each incoming request. As a result, servers built on `node:http` + * (such as Next.js running via `bun --bun`) do not get a fresh isolation scope and trace per request, + * so unrelated requests can end up sharing one trace. + * + * This closes that gap by patching `http.Server.prototype.emit` and, on the first `'request'` event + * of each server, handing that server to the same core instrumentation the Node SDK uses + * (`getHttpServerSubscriptions` → `instrumentServer`). We patch the prototype (not `createServer`) + * because the server is typically created before Sentry is initialized — e.g. Next.js creates and + * `listen()`s its server before running the `instrumentation.ts` `register()` hook that loads the + * Sentry config. + * + * This is intended for `node:http`-based servers. For `Bun.serve`, use {@link bunServerIntegration}. + * + * ```js + * Sentry.init({ + * integrations: [ + * Sentry.bunHttpServerIntegration(), + * ], + * }) + * ``` + */ +export const bunHttpServerIntegration = defineIntegration(_bunHttpServerIntegration); + +/** + * Patches `http.Server.prototype.emit` so each server's incoming requests are isolated using the + * same core instrumentation the Node SDK uses. + * + * Only exported for tests. + */ +export function instrumentBunHttpServer(options: BunHttpServerOptions = {}): void { + // This only makes sense under Bun; on Node the diagnostics channel already handles this. + if (!process.versions.bun || hasPatched) { + return; + } + + const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({ + spans: options.spans ?? true, + sessions: options.sessions ?? true, + sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60_000, + maxRequestBodySize: options.maxRequestBodySize ?? 'medium', + ignoreRequestBody: options.ignoreRequestBody, + }); + + // Track which servers we have already handed to core, so we instrument each server exactly once. + // After core instruments a server it installs its own `emit` on the instance, which shadows this + // prototype patch for all subsequent requests to that server. + const instrumented = new WeakSet(); + + const patchEmitOn = (ServerClass: typeof http.Server): void => { + // oxlint-disable-next-line typescript/unbound-method + const originalEmit = ServerClass.prototype.emit; + ServerClass.prototype.emit = function (this: http.Server, event: string, ...args: unknown[]): boolean { + if (event === 'request' && !instrumented.has(this)) { + instrumented.add(this); + // Hand the server to core, which patches this instance's `emit` to isolate requests. + onServerRequest({ server: this }, HTTP_ON_SERVER_REQUEST); + // Re-dispatch the in-flight request through the instance emit core just installed. + return this.emit(event, ...args); + } + return originalEmit.call(this, event, ...args) as boolean; + } as typeof originalEmit; + }; + + patchEmitOn(http.Server); + // In Bun `https.Server` reuses `http.Server`, but patch it explicitly in case that ever diverges. + if (https.Server !== http.Server) { + patchEmitOn(https.Server); + } + + hasPatched = true; +} diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts new file mode 100644 index 000000000000..9511c8eee836 --- /dev/null +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -0,0 +1,66 @@ +import http from 'node:http'; +import { getCurrentScope } from '@sentry/core'; +import { beforeAll, describe, expect, test } from 'bun:test'; +import { init } from '../../src'; +import { instrumentBunHttpServer } from '../../src/integrations/bunHttpServer'; + +async function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer(handler); + const port = await new Promise(resolve => { + server.listen(0, () => resolve((server.address() as { port: number }).port)); + }); + return { + port, + close: () => new Promise(resolve => server.close(() => resolve())), + }; +} + +describe('Bun HTTP Server Integration', () => { + beforeAll(() => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 0, + // Avoid sending anything to Sentry + transport: () => ({ send: async () => ({}), flush: async () => true }), + }); + // Only performs isolation + trace reset, no span creation (Next.js emits its own spans). + instrumentBunHttpServer({ spans: false }); + }); + + test('isolates each incoming request with a distinct trace id', async () => { + const traceIds: string[] = []; + + const { port, close } = await startServer((_req, res) => { + traceIds.push(getCurrentScope().getPropagationContext().traceId); + res.end('ok'); + }); + + await fetch(`http://localhost:${port}/a`).then(res => res.text()); + await fetch(`http://localhost:${port}/b`).then(res => res.text()); + + await close(); + + expect(traceIds).toHaveLength(2); + expect(traceIds[0]).toEqual(expect.any(String)); + expect(traceIds[1]).toEqual(expect.any(String)); + expect(traceIds[0]).not.toBe(traceIds[1]); + }); + + test('continues an incoming trace from headers', async () => { + const incomingTraceId = 'cafecafecafecafecafecafecafecafe'; + let observedTraceId: string | undefined; + + const { port, close } = await startServer((_req, res) => { + observedTraceId = getCurrentScope().getPropagationContext().traceId; + res.end('ok'); + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'sentry-trace': `${incomingTraceId}-1234567890abcdef-1` }, + }).then(res => res.text()); + + await close(); + + expect(observedTraceId).toBe(incomingTraceId); + }); +}); From 19d37d8890e81b753d6f66fcc625a236a0a87fbd Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 30 Jul 2026 14:53:12 +0200 Subject: [PATCH 02/11] feat(bun): Instrument http module on bun --- packages/bun/src/sdk.ts | 2 ++ .../test/integrations/bunHttpServer.test.ts | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 1d23447ed43d..9411e1ad47f0 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -26,6 +26,7 @@ import { channelIntegrations, isOrchestrionInjected } from '@sentry/server-utils import { bunServerIntegration } from './integrations/bunserver'; import { makeFetchTransport } from './transports'; import type { BunOptions } from './types'; +import { bunHttpServerIntegration } from './integrations/bunHttpServer'; /** * The orchestrion channel-subscriber integrations, listening on the diagnostics @@ -88,6 +89,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { processSessionIntegration(), // Bun Specific bunServerIntegration(), + bunHttpServerIntegration(), ]; } diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index 9511c8eee836..e15b2d485f18 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -1,8 +1,7 @@ import http from 'node:http'; -import { getCurrentScope } from '@sentry/core'; +import { getTraceData } from '@sentry/core'; import { beforeAll, describe, expect, test } from 'bun:test'; -import { init } from '../../src'; -import { instrumentBunHttpServer } from '../../src/integrations/bunHttpServer'; +import { bunHttpServerIntegration, init } from '../../src'; async function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise }> { const server = http.createServer(handler); @@ -15,6 +14,11 @@ async function startServer(handler: http.RequestListener): Promise<{ port: numbe }; } +/** Read the trace id the SDK would propagate for the current request. Works in both OTel and non-OTel modes. */ +function currentTraceId(): string | undefined { + return getTraceData()['sentry-trace']?.split('-')[0]; +} + describe('Bun HTTP Server Integration', () => { beforeAll(() => { init({ @@ -22,16 +26,15 @@ describe('Bun HTTP Server Integration', () => { tracesSampleRate: 0, // Avoid sending anything to Sentry transport: () => ({ send: async () => ({}), flush: async () => true }), + integrations: [bunHttpServerIntegration({ spans: false })], }); - // Only performs isolation + trace reset, no span creation (Next.js emits its own spans). - instrumentBunHttpServer({ spans: false }); }); test('isolates each incoming request with a distinct trace id', async () => { - const traceIds: string[] = []; + const traceIds: Array = []; const { port, close } = await startServer((_req, res) => { - traceIds.push(getCurrentScope().getPropagationContext().traceId); + traceIds.push(currentTraceId()); res.end('ok'); }); @@ -51,7 +54,7 @@ describe('Bun HTTP Server Integration', () => { let observedTraceId: string | undefined; const { port, close } = await startServer((_req, res) => { - observedTraceId = getCurrentScope().getPropagationContext().traceId; + observedTraceId = currentTraceId(); res.end('ok'); }); From aa90c89c7c1f9c6c202d1b1cd499ce818768260e Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Thu, 30 Jul 2026 15:02:02 +0200 Subject: [PATCH 03/11] test(bun): Cover span emission in bunHttpServerIntegration Adds an assertion that an `http.server` span is created for incoming requests when spans are enabled, alongside the existing isolation and trace-continuation coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/integrations/bunHttpServer.test.ts | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index e15b2d485f18..59b6ab1f56cc 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -1,5 +1,5 @@ import http from 'node:http'; -import { getTraceData } from '@sentry/core'; +import { getActiveSpan, getTraceData, spanToJSON } from '@sentry/core'; import { beforeAll, describe, expect, test } from 'bun:test'; import { bunHttpServerIntegration, init } from '../../src'; @@ -23,13 +23,32 @@ describe('Bun HTTP Server Integration', () => { beforeAll(() => { init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', - tracesSampleRate: 0, + tracesSampleRate: 1.0, // Avoid sending anything to Sentry transport: () => ({ send: async () => ({}), flush: async () => true }), - integrations: [bunHttpServerIntegration({ spans: false })], + integrations: [bunHttpServerIntegration()], }); }); + test('creates an http.server span for incoming requests', async () => { + let span: ReturnType | undefined; + + const { port, close } = await startServer((_req, res) => { + const activeSpan = getActiveSpan(); + span = activeSpan ? spanToJSON(activeSpan) : undefined; + res.end('ok'); + }); + + await fetch(`http://localhost:${port}/users?id=123`).then(res => res.text()); + + await close(); + + expect(span).toBeDefined(); + expect(span?.op).toBe('http.server'); + expect(span?.description).toBe('GET /users'); + expect(span?.data['sentry.origin']).toBe('auto.http.server'); + }); + test('isolates each incoming request with a distinct trace id', async () => { const traceIds: Array = []; From 0166d24454c684a30fe01ce24a49123890251bab Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 31 Jul 2026 08:36:17 +0200 Subject: [PATCH 04/11] Apply suggestions from code review Co-authored-by: isaacs --- packages/bun/test/integrations/bunHttpServer.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index 59b6ab1f56cc..83aaed4f4789 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -26,7 +26,6 @@ describe('Bun HTTP Server Integration', () => { tracesSampleRate: 1.0, // Avoid sending anything to Sentry transport: () => ({ send: async () => ({}), flush: async () => true }), - integrations: [bunHttpServerIntegration()], }); }); From d3b91f397245141812ee9d3ffe2f78fd1713a0cc Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 09:02:03 +0200 Subject: [PATCH 05/11] fix options --- packages/bun/src/integrations/bunHttpServer.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts index cb5cea764500..12647be55454 100644 --- a/packages/bun/src/integrations/bunHttpServer.ts +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -95,13 +95,7 @@ export function instrumentBunHttpServer(options: BunHttpServerOptions = {}): voi return; } - const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({ - spans: options.spans ?? true, - sessions: options.sessions ?? true, - sessionFlushingDelayMS: options.sessionFlushingDelayMS ?? 60_000, - maxRequestBodySize: options.maxRequestBodySize ?? 'medium', - ignoreRequestBody: options.ignoreRequestBody, - }); + const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions(options); // Track which servers we have already handed to core, so we instrument each server exactly once. // After core instruments a server it installs its own `emit` on the instance, which shadows this From 5c0580de2ce7d5b056741783a4da7ca1153321b5 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 09:07:05 +0200 Subject: [PATCH 06/11] fixes --- .../bun/src/integrations/bunHttpServer.ts | 35 +++++++++++++++++-- .../test/integrations/bunHttpServer.test.ts | 2 +- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/bun/src/integrations/bunHttpServer.ts b/packages/bun/src/integrations/bunHttpServer.ts index 12647be55454..6cf95b37ab35 100644 --- a/packages/bun/src/integrations/bunHttpServer.ts +++ b/packages/bun/src/integrations/bunHttpServer.ts @@ -1,6 +1,7 @@ +import { errorMonitor } from 'node:events'; import http from 'node:http'; import https from 'node:https'; -import type { IntegrationFn } from '@sentry/core'; +import type { HttpIncomingMessage, HttpServerResponse, IntegrationFn, Span } from '@sentry/core'; import { defineIntegration, getHttpServerSubscriptions, HTTP_ON_SERVER_REQUEST } from '@sentry/core'; const INTEGRATION_NAME = 'BunHttpServer' as const; @@ -43,6 +44,31 @@ interface BunHttpServerOptions { * @default 'medium' */ maxRequestBodySize?: 'none' | 'small' | 'medium' | 'always'; + + /** + * Do not capture spans for incoming HTTP requests to URLs where the given callback returns `true`. + * + * The `urlPath` param consists of the URL path and query string (if any) of the incoming request. + */ + ignoreIncomingRequests?: (urlPath: string, request: HttpIncomingMessage) => boolean; + + /** + * Whether to automatically ignore common static asset requests like favicon.ico, robots.txt, etc. + * + * @default true + */ + ignoreStaticAssets?: boolean; + + /** + * A hook that can be used to mutate the span for incoming requests. + * This is triggered after the span is created, but before it is recorded. + */ + onSpanCreated?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; + + /** + * A hook that can be used to mutate the span one last time when the response is finished. + */ + onSpanEnd?: (span: Span, request: HttpIncomingMessage, response: HttpServerResponse) => void; } let hasPatched = false; @@ -95,7 +121,12 @@ export function instrumentBunHttpServer(options: BunHttpServerOptions = {}): voi return; } - const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions(options); + const { [HTTP_ON_SERVER_REQUEST]: onServerRequest } = getHttpServerSubscriptions({ + ...options, + // Pass the real `errorMonitor` symbol so core observes `'error'` events without consuming + // them — otherwise it would swallow errors before they reach user-supplied `'error'` handlers. + errorMonitor, + }); // Track which servers we have already handed to core, so we instrument each server exactly once. // After core instruments a server it installs its own `emit` on the instance, which shadows this diff --git a/packages/bun/test/integrations/bunHttpServer.test.ts b/packages/bun/test/integrations/bunHttpServer.test.ts index 83aaed4f4789..1834f4eb9f15 100644 --- a/packages/bun/test/integrations/bunHttpServer.test.ts +++ b/packages/bun/test/integrations/bunHttpServer.test.ts @@ -1,7 +1,7 @@ import http from 'node:http'; import { getActiveSpan, getTraceData, spanToJSON } from '@sentry/core'; import { beforeAll, describe, expect, test } from 'bun:test'; -import { bunHttpServerIntegration, init } from '../../src'; +import { init } from '../../src'; async function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise }> { const server = http.createServer(handler); From 313d70f610b00c6e3e7a578d4a0f0d564e93ba23 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 09:13:41 +0200 Subject: [PATCH 07/11] feat(server-utils): Implement `setAsyncLocalStorageAsyncContextStrategy` --- .../tracing/durableobject-sync-kv/test.ts | 1 + packages/cloudflare/.oxlintrc.json | 2 +- packages/cloudflare/src/async.ts | 81 ------------------- packages/cloudflare/src/durableobject.ts | 2 +- packages/cloudflare/src/index.ts | 2 +- .../instrumentWorkerEntrypoint.ts | 2 +- packages/cloudflare/src/pages-plugin.ts | 2 +- packages/cloudflare/src/withSentry.ts | 2 +- packages/cloudflare/src/workflows.ts | 2 +- packages/cloudflare/test/client.test.ts | 2 +- .../test/instrumentCloudflareAgent.test.ts | 2 +- .../instrumentWorkerEntrypoint.test.ts | 2 +- packages/cloudflare/test/request.test.ts | 2 +- .../integrations}/redis/redis-common.test.ts | 2 +- packages/deno/src/client.ts | 2 +- .../opentelemetry/src/asyncContextStrategy.ts | 11 ++- .../test/asyncContextStrategy.test.ts | 25 ++++++ packages/server-utils/package.json | 5 ++ packages/server-utils/rollup.npm.config.mjs | 1 + .../src/async-context.ts} | 29 +++---- packages/server-utils/src/exports.ts | 3 + .../src/index.no-diagnostic-channels.ts | 1 + packages/server-utils/src/index.ts | 9 +-- .../test/async-context.test.ts} | 63 ++++++++++++++- 24 files changed, 132 insertions(+), 123 deletions(-) delete mode 100644 packages/cloudflare/src/async.ts rename packages/{node/test/integrations/tracing => core/test/integrations}/redis/redis-common.test.ts (95%) rename packages/{deno/src/async.ts => server-utils/src/async-context.ts} (75%) create mode 100644 packages/server-utils/src/exports.ts create mode 100644 packages/server-utils/src/index.no-diagnostic-channels.ts rename packages/{cloudflare/test/async.test.ts => server-utils/test/async-context.test.ts} (68%) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts index 25103e4e0ceb..4a3064871f93 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject-sync-kv/test.ts @@ -13,6 +13,7 @@ const flushMarkerMatcher = (envelope: Envelope): void => { it('instruments sync KV operations on Durable Object storage', async ({ signal }) => { const runner = createRunner(__dirname) + .unordered() .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent | undefined; const spans = transactionEvent?.spans ?? []; diff --git a/packages/cloudflare/.oxlintrc.json b/packages/cloudflare/.oxlintrc.json index 3ec49c6228c2..05cf6fc19fc6 100644 --- a/packages/cloudflare/.oxlintrc.json +++ b/packages/cloudflare/.oxlintrc.json @@ -34,7 +34,7 @@ "message": "Do not import from `@sentry/node` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points." }, { - "group": ["@sentry/server-utils/**"], + "group": ["@sentry/server-utils/**", "!@sentry/server-utils/no-diagnostic-channels"], "message": "Do not import from `@sentry/server-utils` in the Cloudflare SDK. It relies on Node.js APIs that are only available when the `nodejs_compat` flag is set. The only allowed importers are files in `src/nodejs_compat/`, which are exposed via the `@sentry/cloudflare/nodejs_compat/*` entry points." } ] diff --git a/packages/cloudflare/src/async.ts b/packages/cloudflare/src/async.ts deleted file mode 100644 index bde7bbfc8f47..000000000000 --- a/packages/cloudflare/src/async.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Need to use node: prefix for cloudflare workers compatibility -// Note: Because we are using node:async_hooks, we need to set `node_compat` in the wrangler.toml -import { AsyncLocalStorage } from 'node:async_hooks'; -import type { Scope } from '@sentry/core'; -import { - _INTERNAL_createTracingChannelBinding, - getDefaultCurrentScope, - getDefaultIsolationScope, - setAsyncContextStrategy, -} from '@sentry/core'; - -/** - * Sets the async context strategy to use AsyncLocalStorage. - * - * AsyncLocalStorage is only available in the cloudflare workers runtime if you set - * compatibility_flags = ["nodejs_compat"] - * - * @internal Only exported to be used in higher-level Sentry packages - * @hidden Only exported to be used in higher-level Sentry packages - */ -export function setAsyncLocalStorageAsyncContextStrategy(): void { - const asyncStorage = new AsyncLocalStorage<{ - scope: Scope; - isolationScope: Scope; - }>(); - - function getScopes(): { scope: Scope; isolationScope: Scope } { - const scopes = asyncStorage.getStore(); - - if (scopes) { - return scopes; - } - - // fallback behavior: - // if, for whatever reason, we can't find scopes on the context here, we have to fix this somehow - return { - scope: getDefaultCurrentScope(), - isolationScope: getDefaultIsolationScope(), - }; - } - - function withScope(callback: (scope: Scope) => T): T { - const scope = getScopes().scope.clone(); - const isolationScope = getScopes().isolationScope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); - } - - function withSetScope(scope: Scope, callback: (scope: Scope) => T): T { - const isolationScope = getScopes().isolationScope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(scope); - }); - } - - function withIsolationScope(callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope; - const isolationScope = getScopes().isolationScope.clone(); - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(isolationScope); - }); - } - - function withSetIsolationScope(isolationScope: Scope, callback: (isolationScope: Scope) => T): T { - const scope = getScopes().scope; - return asyncStorage.run({ scope, isolationScope }, () => { - return callback(isolationScope); - }); - } - - setAsyncContextStrategy({ - withScope, - withSetScope, - withIsolationScope, - withSetIsolationScope, - getCurrentScope: () => getScopes().scope, - getIsolationScope: () => getScopes().isolationScope, - getTracingChannelBinding: () => _INTERNAL_createTracingChannelBinding(asyncStorage, getScopes), - }); -} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 1e18e18ac059..ff4ae839f0a0 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { captureException } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { ensureInstrumented } from './instrument'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index de232a39751e..20124b3b8554 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -134,4 +134,4 @@ export { instrumentD1WithSentry } from './instrumentations/worker/instrumentD1'; export { instrumentWorkflowWithSentry } from './workflows'; -export { setAsyncLocalStorageAsyncContextStrategy } from './async'; +export { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index bb53e124568e..c0b46f027be8 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -1,5 +1,5 @@ import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from '../client'; import { getFinalOptions } from '../options'; import { instrumentContext } from '../utils/instrumentContext'; diff --git a/packages/cloudflare/src/pages-plugin.ts b/packages/cloudflare/src/pages-plugin.ts index de67eafd5506..2f9c966cb53b 100644 --- a/packages/cloudflare/src/pages-plugin.ts +++ b/packages/cloudflare/src/pages-plugin.ts @@ -1,4 +1,4 @@ -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import type { ExecutionContextCompat } from './executionContext'; import { wrapRequestHandler } from './request'; diff --git a/packages/cloudflare/src/withSentry.ts b/packages/cloudflare/src/withSentry.ts index b71fde33b30a..50acb67541dc 100644 --- a/packages/cloudflare/src/withSentry.ts +++ b/packages/cloudflare/src/withSentry.ts @@ -1,5 +1,5 @@ import type { env as cloudflareEnv } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { instrumentExportedHandlerEmail } from './instrumentations/worker/instrumentEmail'; import { instrumentExportedHandlerFetch } from './instrumentations/worker/instrumentFetch'; diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index c7c54e424d5c..721cb752d1ba 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -23,7 +23,7 @@ import type { WorkflowStepRollbackOptions, WorkflowTimeoutDuration, } from 'cloudflare:workers'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; import { flushAndDispose } from './flush'; import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; diff --git a/packages/cloudflare/test/client.test.ts b/packages/cloudflare/test/client.test.ts index 8a6ac65175a0..7e305f6aec65 100644 --- a/packages/cloudflare/test/client.test.ts +++ b/packages/cloudflare/test/client.test.ts @@ -1,5 +1,5 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; import { makeFlushLock } from '../src/flush'; diff --git a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts index 64bd417f5192..314efb3c1e3c 100644 --- a/packages/cloudflare/test/instrumentCloudflareAgent.test.ts +++ b/packages/cloudflare/test/instrumentCloudflareAgent.test.ts @@ -1,7 +1,7 @@ import type { Event } from '@sentry/core'; import { getCurrentScope, setCurrentClient } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; import { instrumentCloudflareAgent } from '../src/instrumentations/agents'; import { resetSdk } from './testUtils'; diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 353eabebe474..22e2c9eed139 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -176,7 +176,7 @@ describe('instrumentWorkerEntrypoint', () => { }); it('Calls setAsyncLocalStorageAsyncContextStrategy outside Proxy (at instrumentation time), not inside construct', async () => { - const asyncModule = await import('../../src/async'); + const asyncModule = await import('@sentry/server-utils/no-diagnostic-channels'); const setStrategy = vi.spyOn(asyncModule, 'setAsyncLocalStorageAsyncContextStrategy'); const mockContext = createMockExecutionContext(); const TestClass = class extends WorkerEntrypoint { diff --git a/packages/cloudflare/test/request.test.ts b/packages/cloudflare/test/request.test.ts index a517c5b9ef39..8dc80ecda3ae 100644 --- a/packages/cloudflare/test/request.test.ts +++ b/packages/cloudflare/test/request.test.ts @@ -5,7 +5,7 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { beforeAll, beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from '../src/client'; import { CloudflareClient } from '../src/client'; import { wrapRequestHandler } from '../src/request'; diff --git a/packages/node/test/integrations/tracing/redis/redis-common.test.ts b/packages/core/test/integrations/redis/redis-common.test.ts similarity index 95% rename from packages/node/test/integrations/tracing/redis/redis-common.test.ts rename to packages/core/test/integrations/redis/redis-common.test.ts index 4334e1838f78..f25c6fa6b2a1 100644 --- a/packages/node/test/integrations/tracing/redis/redis-common.test.ts +++ b/packages/core/test/integrations/redis/redis-common.test.ts @@ -4,7 +4,7 @@ * Licensed under the Apache License, Version 2.0 */ -import { defaultDbStatementSerializer } from '@sentry/server-utils'; +import { defaultDbStatementSerializer } from '../../../../server-utils/src/redis/redis-statement-serializer'; import { describe, expect, it } from 'vitest'; describe('defaultDbStatementSerializer()', () => { diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts index f403b9ba6f0d..b6ab892200db 100644 --- a/packages/deno/src/client.ts +++ b/packages/deno/src/client.ts @@ -1,6 +1,6 @@ import type { ServerRuntimeClientOptions } from '@sentry/core'; import { _INTERNAL_flushLogsBuffer, SDK_VERSION, ServerRuntimeClient } from '@sentry/core'; -import { setAsyncLocalStorageAsyncContextStrategy } from './async'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import type { DenoClientOptions } from './types'; function getHostName(): string | undefined { diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 41cddace030e..e13b62087aee 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -1,8 +1,10 @@ import * as api from '@opentelemetry/api'; import type { Scope, TracingChannelBinding } from '@sentry/core'; import { + getAsyncContextStrategy, getDefaultCurrentScope, getDefaultIsolationScope, + getMainCarrier, getRootSpan, setAsyncContextStrategy, spanIsIgnored, @@ -27,7 +29,14 @@ import { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContex * We handle forking a hub inside of our custom OTEL Context Manager (./otelContextManager.ts) */ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorageLookup { - const asyncLocalStorage = new AsyncLocalStorage(); + // Re-use the AsyncLocalStorage of an already-installed strategy, if any. Otherwise a repeated + // setup would swap in a new store while integrations that captured the previous one (via + // `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one, breaking scope + // propagation across async boundaries. + const existingAsyncLocalStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.() + ?.asyncLocalStorage as AsyncLocalStorage | undefined; + + const asyncLocalStorage = existingAsyncLocalStorage ?? new AsyncLocalStorage(); function getScopes(): CurrentScopes { const ctx = api.context.active(); diff --git a/packages/opentelemetry/test/asyncContextStrategy.test.ts b/packages/opentelemetry/test/asyncContextStrategy.test.ts index c95932c3ecf6..a36ae15180a7 100644 --- a/packages/opentelemetry/test/asyncContextStrategy.test.ts +++ b/packages/opentelemetry/test/asyncContextStrategy.test.ts @@ -362,6 +362,31 @@ describe('asyncContextStrategy', () => { }); }); + describe('AsyncLocalStorage re-use', () => { + it('re-uses the AsyncLocalStorage of an already-installed strategy on repeated setup', () => { + setOpenTelemetryContextAsyncContextStrategy(); + const firstStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + expect(firstStorage).toBeDefined(); + + setOpenTelemetryContextAsyncContextStrategy(); + const secondStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + expect(secondStorage).toBe(firstStorage); + }); + + it('returns a context manager backed by the re-used AsyncLocalStorage', () => { + const firstLookup = setOpenTelemetryContextAsyncContextStrategy(); + const secondLookup = setOpenTelemetryContextAsyncContextStrategy(); + + // The context manager returned on the second setup wraps the same AsyncLocalStorage instance, + // so consumers that captured the lookup from the first setup keep observing the active context. + expect(secondLookup.asyncLocalStorage).toBe(firstLookup.asyncLocalStorage); + expect(getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage).toBe( + firstLookup.asyncLocalStorage, + ); + }); + }); + describe('withScope()', () => { it('will make the passed scope the active scope within the callback', () => new Promise(done => { diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 9bef9afa58df..c327942b7bc6 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -22,6 +22,11 @@ "import": "./build/esm/index.js", "require": "./build/cjs/index.js" }, + "./no-diagnostic-channels": { + "types": "./build/types/index.no-diagnostic-channels.d.ts", + "import": "./build/esm/index.no-diagnostic-channels.js", + "require": "./build/cjs/index.no-diagnostic-channels.js" + }, "./orchestrion": { "types": "./build/types/orchestrion/index.d.ts", "import": "./build/esm/orchestrion/index.js", diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 1a7bf99b5d12..e79218e1cd83 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -104,6 +104,7 @@ export default [ // subpath export. entrypoints: [ 'src/index.ts', + 'src/index.no-diagnostic-channels.ts', 'src/orchestrion/index.ts', 'src/orchestrion/config/index.ts', // `src/orchestrion/runtime/register.ts` backs the `./orchestrion/register` diff --git a/packages/deno/src/async.ts b/packages/server-utils/src/async-context.ts similarity index 75% rename from packages/deno/src/async.ts rename to packages/server-utils/src/async-context.ts index 425905216162..76047929d2af 100644 --- a/packages/deno/src/async.ts +++ b/packages/server-utils/src/async-context.ts @@ -1,36 +1,28 @@ -// Need to use node: prefix for deno compatibility import { AsyncLocalStorage } from 'node:async_hooks'; import type { Scope } from '@sentry/core'; import { _INTERNAL_createTracingChannelBinding, + getAsyncContextStrategy, getDefaultCurrentScope, getDefaultIsolationScope, + getMainCarrier, setAsyncContextStrategy, } from '@sentry/core'; -let installed = false; +type ScopeStore = { scope: Scope; isolationScope: Scope }; /** * Sets the async context strategy to use AsyncLocalStorage. - * - * Idempotent: multiple integrations each call this from their `setupOnce`, - * but they must all share a single `AsyncLocalStorage` so context propagates - * between them. The first call wins, later calls are no-ops. This prevents - * orphaning an in-flight context if an integration is set up asynchronously. - * - * @internal Only exported to be used in higher-level Sentry packages - * @hidden Only exported to be used in higher-level Sentry packages */ export function setAsyncLocalStorageAsyncContextStrategy(): void { - if (installed) { - return; - } - installed = true; + // Re-use the AsyncLocalStorage of an already-installed strategy, if any. Otherwise a repeated + // setup (e.g. a second `Sentry.init()`) would swap in a new store while integrations that captured + // the previous one (via `getTracingChannelBinding().asyncLocalStorage`) keep reading the old one, + // breaking scope propagation across async boundaries. + const existingAsyncStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.() + ?.asyncLocalStorage as AsyncLocalStorage | undefined; - const asyncStorage = new AsyncLocalStorage<{ - scope: Scope; - isolationScope: Scope; - }>(); + const asyncStorage = existingAsyncStorage ?? new AsyncLocalStorage(); function getScopes(): { scope: Scope; isolationScope: Scope } { const scopes = asyncStorage.getStore(); @@ -65,6 +57,7 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { function withIsolationScope(callback: (isolationScope: Scope) => T): T { const scope = getScopes().scope; const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => { return callback(isolationScope); }); diff --git a/packages/server-utils/src/exports.ts b/packages/server-utils/src/exports.ts new file mode 100644 index 000000000000..95c01cc80606 --- /dev/null +++ b/packages/server-utils/src/exports.ts @@ -0,0 +1,3 @@ +// Shared exports not using diagnostics channels +export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; +export { setAsyncLocalStorageAsyncContextStrategy } from './async-context'; diff --git a/packages/server-utils/src/index.no-diagnostic-channels.ts b/packages/server-utils/src/index.no-diagnostic-channels.ts new file mode 100644 index 000000000000..8570a426a7e7 --- /dev/null +++ b/packages/server-utils/src/index.no-diagnostic-channels.ts @@ -0,0 +1 @@ +export * from './exports'; diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 06352b7a0601..fc44fe376eeb 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -1,9 +1,6 @@ -/** - * Server-only utilities shared across Sentry server SDKs. - * - * @module - */ +export * from './exports'; +// Exports using diagnostics channels export { graphqlIntegration } from './graphql'; export { mongooseIntegration, startMongooseLegacySpan } from './mongoose'; export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose'; @@ -19,7 +16,6 @@ export { instrumentPrisma, prismaIntegration } from './prisma'; export type { PrismaInstrumentationConfig, PrismaOptions } from './prisma'; export { redisIntegration, type RedisDiagnosticChannelsOptions } from './redis'; export type { RedisDiagnosticChannelResponseHook } from './redis/redis-dc-subscriber'; -export { defaultDbStatementSerializer } from './redis/redis-statement-serializer'; export { bindTracingChannelToSpan } from './tracing-channel'; export type { SentryTracingChannel, @@ -36,4 +32,3 @@ export { // oxlint-disable-next-line typescript/no-deprecated instrumentFastify, } from './integrations/tracing-channel/fastify'; -export { setHttpServerSpanRouteAttribute } from './utils/setHttpServerSpanRouteAttribute'; diff --git a/packages/cloudflare/test/async.test.ts b/packages/server-utils/test/async-context.test.ts similarity index 68% rename from packages/cloudflare/test/async.test.ts rename to packages/server-utils/test/async-context.test.ts index 64a8d06ba29f..40c5dbd95cc3 100644 --- a/packages/cloudflare/test/async.test.ts +++ b/packages/server-utils/test/async-context.test.ts @@ -1,6 +1,16 @@ -import { getCurrentScope, getGlobalScope, getIsolationScope, Scope, withIsolationScope, withScope } from '@sentry/core'; -import { beforeEach, describe, expect, it } from 'vitest'; -import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async'; +import { + getAsyncContextStrategy, + getCurrentScope, + getGlobalScope, + getIsolationScope, + getMainCarrier, + Scope, + setAsyncContextStrategy, + withIsolationScope, + withScope, +} from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../src/async-context'; describe('withScope()', () => { beforeEach(() => { @@ -157,3 +167,50 @@ describe('withIsolationScope()', () => { }); })); }); + +describe('AsyncLocalStorage re-use', () => { + afterEach(() => { + setAsyncContextStrategy(undefined); + }); + + it('re-uses the AsyncLocalStorage of an already-installed strategy on repeated setup', () => { + setAsyncLocalStorageAsyncContextStrategy(); + const firstStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + expect(firstStorage).toBeDefined(); + + setAsyncLocalStorageAsyncContextStrategy(); + const secondStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + expect(secondStorage).toBe(firstStorage); + }); + + it('keeps scope propagation working after a repeated setup', () => + new Promise(done => { + setAsyncLocalStorageAsyncContextStrategy(); + + // A consumer captures the store from the initial setup... + const capturedStorage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + // ...and then a second setup happens (e.g. a second `Sentry.init()`). + setAsyncLocalStorageAsyncContextStrategy(); + + withIsolationScope(isolationScope => { + // The store captured before the second setup still observes the active scopes, + // because the same AsyncLocalStorage instance was re-used. + expect((capturedStorage as { getStore: () => { isolationScope: Scope } }).getStore().isolationScope).toBe( + isolationScope, + ); + expect(getIsolationScope()).toBe(isolationScope); + done(); + }); + })); + + it('creates a new AsyncLocalStorage when no strategy is installed yet', () => { + setAsyncContextStrategy(undefined); + + setAsyncLocalStorageAsyncContextStrategy(); + const storage = getAsyncContextStrategy(getMainCarrier()).getTracingChannelBinding?.()?.asyncLocalStorage; + + expect(storage).toBeDefined(); + }); +}); From 2efb18461adc77c8547869b9852ce0833048a4e7 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 09:13:41 +0200 Subject: [PATCH 08/11] feat: Streamline isolation scope handling & reset in isolation scopes --- .../src/tracing/browserTracingIntegration.ts | 7 -- .../tracing/browserTracingIntegration.test.ts | 15 +-- packages/core/src/exports.ts | 77 +-------------- packages/core/src/monitor.ts | 98 +++++++++++++++++++ packages/core/src/shared-exports.ts | 3 +- packages/core/src/tracing/trace.ts | 2 +- packages/core/test/lib/sdk.test.ts | 2 +- .../opentelemetry/src/asyncContextStrategy.ts | 14 +++ .../test/asyncContextStrategy.test.ts | 27 +++-- packages/profiling-node/src/integration.ts | 3 +- packages/server-utils/src/async-context.ts | 14 +++ .../server-utils/test/async-context.test.ts | 28 ++++++ .../test/middlewareTraceIsolation.test.ts | 49 ++++++---- 13 files changed, 211 insertions(+), 128 deletions(-) create mode 100644 packages/core/src/monitor.ts diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index cb1be728f26f..b0c67a006185 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -19,7 +19,6 @@ import { getClient, getCurrentScope, getDynamicSamplingContextFromSpan, - getIsolationScope, getLocationHref, GLOBAL_OBJ, hasSpansEnabled, @@ -554,12 +553,6 @@ export const browserTracingIntegration = ((options: Partial { setCurrentClient(client); client.init(); - const oldIsolationScopePropCtx = getIsolationScope().getPropagationContext(); const oldCurrentScopePropCtx = getCurrentScope().getPropagationContext(); startBrowserTracingNavigationSpan(client, { name: 'test navigation span' }); - const newIsolationScopePropCtx = getIsolationScope().getPropagationContext(); const newCurrentScopePropCtx = getCurrentScope().getPropagationContext(); expect(oldCurrentScopePropCtx).toEqual({ @@ -950,24 +948,13 @@ describe('browserTracingIntegration', () => { propagationSpanId: expect.stringMatching(/[a-f0-9]{16}/), sampleRand: expect.any(Number), }); - expect(oldIsolationScopePropCtx).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - sampleRand: expect.any(Number), - }); + expect(newCurrentScopePropCtx).toEqual({ traceId: expect.stringMatching(/[a-f0-9]{32}/), propagationSpanId: expect.stringMatching(/[a-f0-9]{16}/), sampleRand: expect.any(Number), }); - expect(newIsolationScopePropCtx).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - propagationSpanId: expect.stringMatching(/[a-f0-9]{16}/), - sampleRand: expect.any(Number), - }); - - expect(newIsolationScopePropCtx.traceId).not.toEqual(oldIsolationScopePropCtx.traceId); expect(newCurrentScopePropCtx.traceId).not.toEqual(oldCurrentScopePropCtx.traceId); - expect(newIsolationScopePropCtx.propagationSpanId).not.toEqual(oldIsolationScopePropCtx.propagationSpanId); }); it("saves the span's positive sampling decision and its DSC on the propagationContext when the span finishes", () => { diff --git a/packages/core/src/exports.ts b/packages/core/src/exports.ts index e2322766287b..12623aa224d9 100644 --- a/packages/core/src/exports.ts +++ b/packages/core/src/exports.ts @@ -1,10 +1,8 @@ import type { AttributeObject, RawAttribute, RawAttributes } from './attributes'; -import { getClient, getCurrentScope, getIsolationScope, withIsolationScope } from './currentScopes'; +import { getClient, getCurrentScope, getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { CaptureContext } from './scope'; import { closeSession, makeSession, updateSession } from './session'; -import { startNewTrace } from './tracing/trace'; -import type { CheckIn, FinishedCheckIn, MonitorConfig } from './types/checkin'; import type { Event, EventHint } from './types/event'; import type { EventProcessor } from './types/eventprocessor'; import type { Extra, Extras } from './types/extra'; @@ -13,12 +11,9 @@ import type { Session, SessionContext } from './types/session'; import type { SeverityLevel } from './types/severity'; import type { User } from './types/user'; import { debug } from './utils/debug-logger'; -import { isThenable } from './utils/is'; -import { uuid4 } from './utils/misc'; import type { ExclusiveEventHintOrCaptureContext } from './utils/prepareEvent'; import { parseEventHintOrCaptureContext } from './utils/prepareEvent'; import { getCombinedScopeData } from './utils/scopeData'; -import { timestampInSeconds } from './utils/time'; import { GLOBAL_OBJ } from './utils/worldwide'; /** @@ -182,76 +177,6 @@ export function lastEventId(): string | undefined { return getIsolationScope().lastEventId(); } -/** - * Create a cron monitor check in and send it to Sentry. - * - * @param checkIn An object that describes a check in. - * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want - * to create a monitor automatically when sending a check in. - */ -export function captureCheckIn(checkIn: CheckIn, upsertMonitorConfig?: MonitorConfig): string { - const scope = getCurrentScope(); - const client = getClient(); - if (!client) { - DEBUG_BUILD && debug.warn('Cannot capture check-in. No client defined.'); - } else if (!client.captureCheckIn) { - DEBUG_BUILD && debug.warn('Cannot capture check-in. Client does not support sending check-ins.'); - } else { - return client.captureCheckIn(checkIn, upsertMonitorConfig, scope); - } - - return uuid4(); -} - -/** - * Wraps a callback with a cron monitor check in. The check in will be sent to Sentry when the callback finishes. - * - * @param monitorSlug The distinct slug of the monitor. - * @param callback Callback to be monitored - * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want - * to create a monitor automatically when sending a check in. - */ -export function withMonitor( - monitorSlug: CheckIn['monitorSlug'], - callback: () => T, - upsertMonitorConfig?: MonitorConfig, -): T { - function runCallback(): T { - const checkInId = captureCheckIn({ monitorSlug, status: 'in_progress' }, upsertMonitorConfig); - const now = timestampInSeconds(); - - function finishCheckIn(status: FinishedCheckIn['status']): void { - captureCheckIn({ monitorSlug, status, checkInId, duration: timestampInSeconds() - now }); - } - // Default behavior without isolateTrace - let maybePromiseResult: T; - try { - maybePromiseResult = callback(); - } catch (e) { - finishCheckIn('error'); - throw e; - } - - if (isThenable(maybePromiseResult)) { - return maybePromiseResult.then( - r => { - finishCheckIn('ok'); - return r; - }, - e => { - finishCheckIn('error'); - throw e; - }, - ) as T; - } - finishCheckIn('ok'); - - return maybePromiseResult; - } - - return withIsolationScope(() => (upsertMonitorConfig?.isolateTrace ? startNewTrace(runCallback) : runCallback())); -} - /** * Call `flush()` on the current client, if there is one. See {@link Client.flush}. * diff --git a/packages/core/src/monitor.ts b/packages/core/src/monitor.ts new file mode 100644 index 000000000000..64df5a2ee818 --- /dev/null +++ b/packages/core/src/monitor.ts @@ -0,0 +1,98 @@ +import { getClient, getCurrentScope, withIsolationScope } from './currentScopes'; +import { DEBUG_BUILD } from './debug-build'; +import { continueTrace, startNewTrace, withActiveSpan } from './tracing/trace'; +import type { CheckIn, FinishedCheckIn, MonitorConfig } from './types/checkin'; +import { debug } from './utils/debug-logger'; +import { isThenable } from './utils/is'; +import { uuid4 } from './utils/misc'; +import { getActiveSpan } from './utils/spanUtils'; +import { timestampInSeconds } from './utils/time'; +import { getTraceData } from './utils/traceData'; + +/** + * Wraps a callback with a cron monitor check in. The check in will be sent to Sentry when the callback finishes. + * + * @param monitorSlug The distinct slug of the monitor. + * @param callback Callback to be monitored + * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want + * to create a monitor automatically when sending a check in. + */ +export function withMonitor( + monitorSlug: CheckIn['monitorSlug'], + callback: () => T, + upsertMonitorConfig?: MonitorConfig, +): T { + function runCallback(): T { + const checkInId = captureCheckIn({ monitorSlug, status: 'in_progress' }, upsertMonitorConfig); + const now = timestampInSeconds(); + + function finishCheckIn(status: FinishedCheckIn['status']): void { + captureCheckIn({ monitorSlug, status, checkInId, duration: timestampInSeconds() - now }); + } + // Default behavior without isolateTrace + let maybePromiseResult: T; + try { + maybePromiseResult = callback(); + } catch (e) { + finishCheckIn('error'); + throw e; + } + + if (isThenable(maybePromiseResult)) { + return maybePromiseResult.then( + r => { + finishCheckIn('ok'); + return r; + }, + e => { + finishCheckIn('error'); + throw e; + }, + ) as T; + } + finishCheckIn('ok'); + + return maybePromiseResult; + } + + // With isolation scope resets the propagation context, so if we do not pass isolateTace, we want to manually continue the trace + const traceData = getTraceData(); + const activeSpan = getActiveSpan(); + + return withIsolationScope(() => { + if (upsertMonitorConfig?.isolateTrace) { + return startNewTrace(runCallback); + } + + // If we are not isolating the trace, we want to keep the same trace as the parent + // We also need to ensure to set the same span active as before, to ensure an eventually active span is set back in continueTrace + return continueTrace( + { + sentryTrace: traceData['sentry-trace'], + baggage: traceData['baggage'], + }, + () => withActiveSpan(activeSpan ?? null, runCallback), + ); + }); +} + +/** + * Create a cron monitor check in and send it to Sentry. + * + * @param checkIn An object that describes a check in. + * @param upsertMonitorConfig An optional object that describes a monitor config. Use this if you want + * to create a monitor automatically when sending a check in. + */ +export function captureCheckIn(checkIn: CheckIn, upsertMonitorConfig?: MonitorConfig): string { + const scope = getCurrentScope(); + const client = getClient(); + if (!client) { + DEBUG_BUILD && debug.warn('Cannot capture check-in. No client defined.'); + } else if (!client.captureCheckIn) { + DEBUG_BUILD && debug.warn('Cannot capture check-in. Client does not support sending check-ins.'); + } else { + return client.captureCheckIn(checkIn, upsertMonitorConfig, scope); + } + + return uuid4(); +} diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 700e65d2a974..88635882f473 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -12,8 +12,6 @@ export * from './tracing'; export * from './semanticAttributes'; export { createEventEnvelope, createSessionEnvelope } from './envelope'; export { - captureCheckIn, - withMonitor, captureException, captureEvent, captureMessage, @@ -36,6 +34,7 @@ export { captureSession, addEventProcessor, } from './exports'; +export { withMonitor, captureCheckIn } from './monitor'; export { getCurrentScope, getIsolationScope, diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 115f8097b190..22342cd1f513 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -367,7 +367,7 @@ function createChildOrRootSpan({ const isolationScope = getIsolationScope(); if (!hasSpansEnabled()) { - const scopePropagationContext = { ...isolationScope.getPropagationContext(), ...scope.getPropagationContext() }; + const scopePropagationContext = scope.getPropagationContext(); const traceId = parentSpan ? parentSpan.spanContext().traceId : scopePropagationContext.traceId; // The placeholder is a thin marker; it carries no sampling decision or DSC. Both are read from diff --git a/packages/core/test/lib/sdk.test.ts b/packages/core/test/lib/sdk.test.ts index 588585630621..8ca2f6cda39d 100644 --- a/packages/core/test/lib/sdk.test.ts +++ b/packages/core/test/lib/sdk.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, type Mock, test, vi } from 'vitest'; import type { Client } from '../../src/client'; import { getCurrentScope } from '../../src/currentScopes'; -import { captureCheckIn } from '../../src/exports'; +import { captureCheckIn } from '../../src/monitor'; import { installedIntegrations } from '../../src/integration'; import { initAndBind, setCurrentClient } from '../../src/sdk'; import type { Integration } from '../../src/types/integration'; diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index e13b62087aee..c7afdf06f805 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -2,6 +2,8 @@ import * as api from '@opentelemetry/api'; import type { Scope, TracingChannelBinding } from '@sentry/core'; import { getAsyncContextStrategy, + _INTERNAL_safeMathRandom, + generateTraceId, getDefaultCurrentScope, getDefaultIsolationScope, getMainCarrier, @@ -86,6 +88,18 @@ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorage // the OTEL context manager, which uses the presence of this key to determine if it should // fork the isolation scope, or not return api.context.with(ctx.setValue(SENTRY_FORK_ISOLATION_SCOPE_CONTEXT_KEY, true), () => { + // When forking an isolation scope, unless we are continuing an incoming trace (identified by a `parentSpanId`), + // we give the freshly forked scope its own trace. + // This way, new root spans in an isolation scope will get separate traces + const scope = getCurrentScope(); + const propagationContext = scope.getPropagationContext(); + if (!propagationContext.parentSpanId) { + scope.setPropagationContext({ + ...propagationContext, + traceId: generateTraceId(), + sampleRand: _INTERNAL_safeMathRandom(), + }); + } return callback(getIsolationScope()); }); } diff --git a/packages/opentelemetry/test/asyncContextStrategy.test.ts b/packages/opentelemetry/test/asyncContextStrategy.test.ts index a36ae15180a7..669b79dea560 100644 --- a/packages/opentelemetry/test/asyncContextStrategy.test.ts +++ b/packages/opentelemetry/test/asyncContextStrategy.test.ts @@ -19,6 +19,15 @@ import { TraceState } from '../src/utils/TraceState'; import { mockSdkInit } from './helpers/mockSdkInit'; describe('asyncContextStrategy', () => { + // `withIsolationScope` gives the forked current scope a fresh propagation context (unless it is + // continuing an incoming trace), so scope data is expected to match apart from that context. + function scopeDataWithoutPropagationContext( + scope: Scope, + ): Omit, 'propagationContext'> { + const { propagationContext: _propagationContext, ...rest } = scope.getScopeData(); + return rest; + } + beforeEach(() => { getCurrentScope().clear(); getIsolationScope().clear(); @@ -45,7 +54,8 @@ describe('asyncContextStrategy', () => { expect(scope1).not.toBe(initialScope); expect(isolationScope1).not.toBe(initialIsolationScope); - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope)); + expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId); expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); scope1.setExtra('b', 'b'); @@ -162,7 +172,8 @@ describe('asyncContextStrategy', () => { expect(scope1).not.toBe(initialScope); expect(isolationScope1).not.toBe(initialIsolationScope); - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope)); + expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId); expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); await asyncSetExtra(scope1, 'b', 'b'); @@ -207,7 +218,8 @@ describe('asyncContextStrategy', () => { expect(scope1).not.toBe(initialScope); expect(isolationScope1).not.toBe(initialIsolationScope); - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope)); + expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId); expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); scope1.setExtra('b', 'b'); @@ -244,7 +256,8 @@ describe('asyncContextStrategy', () => { expect(scope1).not.toBe(initialScope); expect(isolationScope1).not.toBe(initialIsolationScope); - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope)); + expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId); expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); scope1.setExtra('b2', 'b'); @@ -294,7 +307,8 @@ describe('asyncContextStrategy', () => { expect(scope1).not.toBe(initialScope); expect(isolationScope1).not.toBe(initialIsolationScope); - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope)); + expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId); expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); await asyncSetExtra(scope1, 'b', 'b'); @@ -331,7 +345,8 @@ describe('asyncContextStrategy', () => { expect(scope1).not.toBe(initialScope); expect(isolationScope1).not.toBe(initialIsolationScope); - expect(scope1.getScopeData()).toEqual(initialScope.getScopeData()); + expect(scopeDataWithoutPropagationContext(scope1)).toEqual(scopeDataWithoutPropagationContext(initialScope)); + expect(scope1.getPropagationContext().traceId).not.toBe(initialScope.getPropagationContext().traceId); expect(isolationScope1.getScopeData()).toEqual(initialIsolationScope.getScopeData()); scope1.setExtra('b2', 'b'); diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 943ae5e35c7b..3aff24aa4d74 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -541,8 +541,7 @@ class ContinuousProfiler { return; } - const traceId = - getCurrentScope().getPropagationContext().traceId || getIsolationScope().getPropagationContext().traceId; + const traceId = getCurrentScope().getPropagationContext().traceId; const chunk = this._initializeChunk(traceId); CpuProfilerBindings.startProfiling(chunk.id); diff --git a/packages/server-utils/src/async-context.ts b/packages/server-utils/src/async-context.ts index 76047929d2af..5a52d780b044 100644 --- a/packages/server-utils/src/async-context.ts +++ b/packages/server-utils/src/async-context.ts @@ -3,6 +3,8 @@ import type { Scope } from '@sentry/core'; import { _INTERNAL_createTracingChannelBinding, getAsyncContextStrategy, + _INTERNAL_safeMathRandom, + generateTraceId, getDefaultCurrentScope, getDefaultIsolationScope, getMainCarrier, @@ -58,6 +60,18 @@ export function setAsyncLocalStorageAsyncContextStrategy(): void { const scope = getScopes().scope; const isolationScope = getScopes().isolationScope.clone(); + // When forking an isolation scope, unless we are continuing an incoming trace (identified by a `parentSpanId`), + // we give the freshly forked scope its own trace. + // This way, new root spans in an isolation scope will get separate traces. + const propagationContext = scope.getPropagationContext(); + if (!propagationContext.parentSpanId) { + scope.setPropagationContext({ + ...propagationContext, + traceId: generateTraceId(), + sampleRand: _INTERNAL_safeMathRandom(), + }); + } + return asyncStorage.run({ scope, isolationScope }, () => { return callback(isolationScope); }); diff --git a/packages/server-utils/test/async-context.test.ts b/packages/server-utils/test/async-context.test.ts index 40c5dbd95cc3..82f66e4702ba 100644 --- a/packages/server-utils/test/async-context.test.ts +++ b/packages/server-utils/test/async-context.test.ts @@ -166,6 +166,34 @@ describe('withIsolationScope()', () => { done(); }); })); + + it('gives each forked isolation scope its own trace id when not continuing an incoming trace', () => { + const traceIds: string[] = []; + + withIsolationScope(() => { + traceIds.push(getCurrentScope().getPropagationContext().traceId); + }); + withIsolationScope(() => { + traceIds.push(getCurrentScope().getPropagationContext().traceId); + }); + + expect(traceIds[0]).toMatch(/^[a-f0-9]{32}$/); + expect(traceIds[1]).toMatch(/^[a-f0-9]{32}$/); + expect(traceIds[0]).not.toBe(traceIds[1]); + }); + + it('keeps the trace id when continuing an incoming trace (parentSpanId set)', () => { + const incomingTraceId = 'cafecafecafecafecafecafecafecafe'; + getCurrentScope().setPropagationContext({ + traceId: incomingTraceId, + parentSpanId: '1234567890abcdef', + sampleRand: 0.42, + }); + + withIsolationScope(() => { + expect(getCurrentScope().getPropagationContext().traceId).toBe(incomingTraceId); + }); + }); }); describe('AsyncLocalStorage re-use', () => { diff --git a/packages/vercel-edge/test/middlewareTraceIsolation.test.ts b/packages/vercel-edge/test/middlewareTraceIsolation.test.ts index 503c692a7a8d..0338de190a0a 100644 --- a/packages/vercel-edge/test/middlewareTraceIsolation.test.ts +++ b/packages/vercel-edge/test/middlewareTraceIsolation.test.ts @@ -1,5 +1,12 @@ import { context, propagation, ROOT_CONTEXT, trace } from '@opentelemetry/api'; -import { getCurrentScope, getGlobalScope, getIsolationScope, GLOBAL_OBJ, spanToJSON } from '@sentry/core'; +import { + getCurrentScope, + getGlobalScope, + getIsolationScope, + GLOBAL_OBJ, + spanToJSON, + withIsolationScope, +} from '@sentry/core'; import { setOpenTelemetryContextAsyncContextStrategy } from '@sentry/opentelemetry'; import { AsyncLocalStorage } from 'async_hooks'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -59,24 +66,28 @@ async function runMiddlewareRequest( delay = 0, ): Promise<{ traceId: string; childTraceId: string }> { const request = new Request(url, { method: 'GET', headers }); - return withPropagatedContext(request.headers, () => - nextMiddlewareTrace(new URL(url).pathname, async () => { - const rootSpan = trace.getActiveSpan()!; - const traceId = spanToJSON(rootSpan).trace_id!; - - await new Promise(resolve => setTimeout(resolve, delay)); - - // A child span started within the request must parent onto this request's root span (same trace id), not onto a - // concurrent request's span. - const childTracer = trace.getTracer('user'); - const childSpan = childTracer.startSpan('user-work'); - const childTraceId = spanToJSON(childSpan as unknown as Parameters[0]).trace_id!; - childSpan.end(); - - await new Promise(resolve => setTimeout(resolve, delay)); - - return { traceId, childTraceId }; - }), + // Mirror `wrapMiddlewareWithSentry`: fork an isolation scope per request, then run Next's + // `withPropagatedContext` + `Middleware.execute` trace inside it. + return withIsolationScope(() => + withPropagatedContext(request.headers, () => + nextMiddlewareTrace(new URL(url).pathname, async () => { + const rootSpan = trace.getActiveSpan()!; + const traceId = spanToJSON(rootSpan).trace_id!; + + await new Promise(resolve => setTimeout(resolve, delay)); + + // A child span started within the request must parent onto this request's root span (same trace id), not onto a + // concurrent request's span. + const childTracer = trace.getTracer('user'); + const childSpan = childTracer.startSpan('user-work'); + const childTraceId = spanToJSON(childSpan as unknown as Parameters[0]).trace_id!; + childSpan.end(); + + await new Promise(resolve => setTimeout(resolve, delay)); + + return { traceId, childTraceId }; + }), + ), ); } From 65c8a4c0fd388f3537146ee75192c32e51dc8f0d Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 13:19:32 +0200 Subject: [PATCH 09/11] monitor change --- packages/core/src/monitor.ts | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/core/src/monitor.ts b/packages/core/src/monitor.ts index 64df5a2ee818..59490ce90be6 100644 --- a/packages/core/src/monitor.ts +++ b/packages/core/src/monitor.ts @@ -1,13 +1,11 @@ import { getClient, getCurrentScope, withIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; -import { continueTrace, startNewTrace, withActiveSpan } from './tracing/trace'; +import { startNewTrace } from './tracing/trace'; import type { CheckIn, FinishedCheckIn, MonitorConfig } from './types/checkin'; import { debug } from './utils/debug-logger'; import { isThenable } from './utils/is'; import { uuid4 } from './utils/misc'; -import { getActiveSpan } from './utils/spanUtils'; import { timestampInSeconds } from './utils/time'; -import { getTraceData } from './utils/traceData'; /** * Wraps a callback with a cron monitor check in. The check in will be sent to Sentry when the callback finishes. @@ -56,23 +54,20 @@ export function withMonitor( } // With isolation scope resets the propagation context, so if we do not pass isolateTace, we want to manually continue the trace - const traceData = getTraceData(); - const activeSpan = getActiveSpan(); + const oldPropagationContext = getCurrentScope().getPropagationContext(); return withIsolationScope(() => { if (upsertMonitorConfig?.isolateTrace) { return startNewTrace(runCallback); } - // If we are not isolating the trace, we want to keep the same trace as the parent - // We also need to ensure to set the same span active as before, to ensure an eventually active span is set back in continueTrace - return continueTrace( - { - sentryTrace: traceData['sentry-trace'], - baggage: traceData['baggage'], - }, - () => withActiveSpan(activeSpan ?? null, runCallback), - ); + // If we are not isolating the trace, in this case we want to keep the same trace as the parent + const newPropagationContext = getCurrentScope().getPropagationContext(); + if (!newPropagationContext.parentSpanId) { + getCurrentScope().setPropagationContext(oldPropagationContext); + } + + return runCallback(); }); } From 5d24bc4c096bdb45bc93983fa0d492c01c93bc81 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 27 Jul 2026 14:48:31 +0200 Subject: [PATCH 10/11] feat(opentelemetry)!: Simplify `SentryPropagator` remove getTraceData & fixes fix traceparent propagation --- .../suites/pino/scenario.mjs | 5 +- .../public-api/bindScopeToEmitter/test.ts | 6 +- .../parallel-root-spans-streamed/test.ts | 21 +- .../startSpan/parallel-root-spans/test.ts | 15 +- .../parallel-spans-in-scope-streamed/test.ts | 17 +- .../test.ts | 19 +- .../test.ts | 13 +- .../startSpan/parallel-spans-in-scope/test.ts | 4 +- .../fetch-sampled-no-active-span/test.ts | 8 +- packages/core/src/tracing/trace.ts | 17 +- packages/node/src/sdk/client.ts | 19 +- packages/opentelemetry/README.md | 1 - .../opentelemetry/src/asyncContextStrategy.ts | 6 +- packages/opentelemetry/src/constants.ts | 1 - packages/opentelemetry/src/index.ts | 2 - packages/opentelemetry/src/propagator.ts | 263 ++---- packages/opentelemetry/src/trace.ts | 91 +- packages/opentelemetry/src/tracer.ts | 20 +- .../opentelemetry/src/utils/getTraceData.ts | 42 - .../test/integration/transactions.test.ts | 2 - .../opentelemetry/test/propagator.test.ts | 797 ++---------------- packages/opentelemetry/test/trace.test.ts | 337 +------- .../test/utils/getTraceData.test.ts | 119 --- packages/vercel-edge/src/types.ts | 4 +- 24 files changed, 215 insertions(+), 1614 deletions(-) delete mode 100644 packages/opentelemetry/src/utils/getTraceData.ts delete mode 100644 packages/opentelemetry/test/utils/getTraceData.test.ts diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index 55966552a07f..a1990b9b02f6 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -8,14 +8,15 @@ Sentry.pinoIntegration.untrackLogger(ignoredLogger); ignoredLogger.info('this will not be tracked'); -Sentry.withIsolationScope(() => { +// Each operation runs in its own trace, so logs emitted within them carry distinct trace ids. +Sentry.startNewTrace(() => { Sentry.startSpan({ name: 'startup' }, () => { logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); }); }); setTimeout(() => { - Sentry.withIsolationScope(() => { + Sentry.startNewTrace(() => { Sentry.startSpan({ name: 'later' }, () => { const child = logger.child({ module: 'authentication' }); child.error(new Error('oh no')); diff --git a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts index 1ce679449100..165eb6cc5fd8 100644 --- a/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/bindScopeToEmitter/test.ts @@ -34,7 +34,9 @@ test('bindScopeToEmitter preserves the active span for listeners firing in a dif expect(childBound?.parent_span_id).toBe(parentSpanId); expect(childBound?.trace_id).toBe(parentTraceId); - // The unbound emitter's listener ran without the parent active -> its own, separate trace. + // The unbound emitter's listener ran without the parent active -> its own root transaction, + // not nested under the parent span. It still shares the isolation scope's propagation context + // trace, matching the core SDK behavior for root spans. expect(childUnbound?.spans).toEqual([]); - expect(childUnbound?.contexts?.trace?.trace_id).not.toBe(parentTraceId); + expect(childUnbound?.contexts?.trace?.parent_span_id).toBeUndefined(); }); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts index 5324d891819d..5f45cccdbf8a 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans-streamed/test.ts @@ -6,25 +6,24 @@ afterAll(() => { }); test('sends manually started streamed parallel root spans in root context', async () => { - expect.assertions(7); + expect.assertions(6); await createRunner(__dirname, 'scenario.ts') - .expect({ span: { items: [{ name: 'test_span_1' }] } }) .expect({ span: spanContainer => { expect(spanContainer).toBeDefined(); - const traceId = spanContainer.items[0]!.trace_id; - expect(traceId).toMatch(/^[0-9a-f]{32}$/); - // It ignores propagation context of the root context - expect(traceId).not.toBe('12345678901234567890123456789012'); - expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); + const span1 = spanContainer.items.find(item => item.name === 'test_span_1'); + const span2 = spanContainer.items.find(item => item.name === 'test_span_2'); + expect(span1).toBeDefined(); + expect(span2).toBeDefined(); - // Different trace ID than the first span - const trace1Id = spanContainer.items[0]!.attributes.spanIdTraceId?.value; - expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + // Both root spans continue the scope's propagation context, matching the core SDK behavior. + expect(span1!.trace_id).toBe('12345678901234567890123456789012'); + expect(span1!.parent_span_id).toBe('1234567890123456'); - expect(trace1Id).not.toBe(traceId); + // Same trace ID for both spans + expect(span2!.trace_id).toBe(span1!.trace_id); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts index e1b8f793d9b6..674cafad1f70 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-root-spans/test.ts @@ -6,7 +6,7 @@ afterAll(() => { }); test('should send manually started parallel root spans in root context', async () => { - expect.assertions(7); + expect.assertions(6); await createRunner(__dirname, 'scenario.ts') .expect({ transaction: { transaction: 'test_span_1' } }) @@ -14,16 +14,15 @@ test('should send manually started parallel root spans in root context', async ( transaction: transaction => { expect(transaction).toBeDefined(); const traceId = transaction.contexts?.trace?.trace_id; - expect(traceId).toBeDefined(); - // It ignores propagation context of the root context - expect(traceId).not.toBe('12345678901234567890123456789012'); - expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); + // Both root spans continue the scope's propagation context, matching the core SDK behavior. + expect(traceId).toBe('12345678901234567890123456789012'); + expect(transaction.contexts?.trace?.parent_span_id).toBe('1234567890123456'); - // Different trace ID than the first span + // Same trace ID as the first span const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; - expect(trace1Id).toBeDefined(); - expect(trace1Id).not.toBe(traceId); + expect(trace1Id).toBe('12345678901234567890123456789012'); + expect(trace1Id).toBe(traceId); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts index a0c8ac343edb..2d5de99aae9e 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-streamed/test.ts @@ -9,19 +9,20 @@ test('sends manually started streamed parallel root spans outside of root contex expect.assertions(6); await createRunner(__dirname, 'scenario.ts') - .expect({ span: { items: [{ name: 'test_span_1' }] } }) .expect({ span: spanContainer => { expect(spanContainer).toBeDefined(); - const traceId = spanContainer.items[0]!.trace_id; - expect(traceId).toMatch(/^[0-9a-f]{32}$/); - expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); - const trace1Id = spanContainer.items[0]!.attributes.spanIdTraceId?.value; - expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + const span1 = spanContainer.items.find(item => item.name === 'test_span_1'); + const span2 = spanContainer.items.find(item => item.name === 'test_span_2'); + expect(span1).toBeDefined(); + expect(span2).toBeDefined(); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + expect(span1!.trace_id).toMatch(/^[0-9a-f]{32}$/); + expect(span1!.parent_span_id).toBeUndefined(); + + // Same trace ID for both spans - both root spans share the scope's propagation context + expect(span2!.trace_id).toBe(span1!.trace_id); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts index 33f4ed3b3f11..50666c267cdf 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId-streamed/test.ts @@ -9,19 +9,22 @@ test('sends manually started streamed parallel root spans outside of root contex expect.assertions(6); await createRunner(__dirname, 'scenario.ts') - .expect({ span: { items: [{ name: 'test_span_1' }] } }) .expect({ span: spanContainer => { expect(spanContainer).toBeDefined(); - const traceId = spanContainer.items[0]!.trace_id; - expect(traceId).toMatch(/^[0-9a-f]{32}$/); - expect(spanContainer.items[0]!.parent_span_id).toBeUndefined(); - const trace1Id = spanContainer.items[0]!.attributes.spanIdTraceId?.value; - expect(trace1Id).toMatch(/^[0-9a-f]{32}$/); + const span1 = spanContainer.items.find(item => item.name === 'test_span_1'); + const span2 = spanContainer.items.find(item => item.name === 'test_span_2'); + expect(span1).toBeDefined(); + expect(span2).toBeDefined(); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + // Both root spans continue the scope's propagation context, including the parentSpanId, + // matching the core SDK behavior. + expect(span1!.trace_id).toBe('12345678901234567890123456789012'); + expect(span1!.parent_span_id).toBe('1234567890123456'); + + // Same trace ID for both spans + expect(span2!.trace_id).toBe(span1!.trace_id); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts index e10a1210a0c9..509c01a41689 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope-with-parentSpanId/test.ts @@ -12,14 +12,15 @@ test('should send manually started parallel root spans outside of root context w transaction: transaction => { expect(transaction).toBeDefined(); const traceId = transaction.contexts?.trace?.trace_id; - expect(traceId).toBeDefined(); - expect(transaction.contexts?.trace?.parent_span_id).toBeUndefined(); - const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; - expect(trace1Id).toBeDefined(); + // Both root spans continue the scope's propagation context, including the parentSpanId, + // matching the core SDK behavior. + expect(traceId).toBe('12345678901234567890123456789012'); + expect(transaction.contexts?.trace?.parent_span_id).toBe('1234567890123456'); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + // Same trace ID as the first span + const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; + expect(trace1Id).toBe(traceId); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts index 69fc2bc2774a..2d125160dbc4 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/parallel-spans-in-scope/test.ts @@ -20,8 +20,8 @@ test('should send manually started parallel root spans outside of root context', const trace1Id = transaction.contexts?.trace?.data?.spanIdTraceId; expect(trace1Id).toBeDefined(); - // Different trace ID as the first span - expect(trace1Id).not.toBe(traceId); + // Same trace ID as the first span - both root spans share the scope's propagation context + expect(trace1Id).toBe(traceId); }, }) .start() diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts index 702a2febd61d..cd7f91dd97f2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled-no-active-span/test.ts @@ -10,13 +10,13 @@ describe('outgoing fetch', () => { const [SERVER_URL, closeTestServer] = await createTestServer() .get('/api/v0', headers => { expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); }) .get('/api/v1', headers => { expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f\d]{32})-([a-f\d]{16})-0$/)); + expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 22342cd1f513..4438389f20e0 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -257,8 +257,7 @@ export const continueTrace = ( return withScope(scope => { const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); scope.setPropagationContext(propagationContext); - _setSpanForScope(scope, undefined); - return callback(); + return withActiveSpan(null, callback); }); }; @@ -330,13 +329,15 @@ export function startNewTrace(callback: () => T): T { return acs.startNewTrace(callback); } - return withScope(scope => { - scope.setPropagationContext({ - traceId: generateTraceId(), - sampleRand: safeMathRandom(), + return withActiveSpan(null, () => { + return withScope(scope => { + scope.setPropagationContext({ + traceId: generateTraceId(), + sampleRand: safeMathRandom(), + }); + DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); + return callback(); }); - DEBUG_BUILD && debug.log(`Starting a new trace with id ${scope.getPropagationContext().traceId}`); - return withActiveSpan(null, callback); }); } diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts index 7968fb3595cc..a4dc8957bad2 100644 --- a/packages/node/src/sdk/client.ts +++ b/packages/node/src/sdk/client.ts @@ -1,7 +1,7 @@ import * as os from 'node:os'; import type { Tracer } from '@opentelemetry/api'; import { trace } from '@opentelemetry/api'; -import type { DynamicSamplingContext, Scope, ServerRuntimeClientOptions, TraceContext } from '@sentry/core'; +import type { ServerRuntimeClientOptions } from '@sentry/core'; import { _INTERNAL_clearAiProviderSkips, _INTERNAL_flushLogsBuffer, @@ -11,11 +11,7 @@ import { SDK_VERSION, ServerRuntimeClient, } from '@sentry/core'; -import { - type AsyncLocalStorageLookup, - getTraceContextForScope, - type SentryTracerProvider, -} from '@sentry/opentelemetry'; +import { type AsyncLocalStorageLookup, type SentryTracerProvider } from '@sentry/opentelemetry'; import { isMainThread, threadId } from 'worker_threads'; import { DEBUG_BUILD } from '../debug-build'; import type { NodeClientOptions } from '../types'; @@ -170,15 +166,4 @@ export class NodeClient extends ServerRuntimeClient { _INTERNAL_clearAiProviderSkips(); super._setupIntegrations(); } - - /** Custom implementation for OTEL, so we can handle scope-span linking. */ - protected _getTraceInfoFromScope( - scope: Scope | undefined, - ): [dynamicSamplingContext: Partial | undefined, traceContext: TraceContext | undefined] { - if (!scope) { - return [undefined, undefined]; - } - - return getTraceContextForScope(this, scope); - } } diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index eb9665d9d555..fd7008659ca0 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -74,7 +74,6 @@ function setupSentry() { // Initialize the provider trace.setGlobalTracerProvider(provider); - propagation.setGlobalPropagator(new SentryPropagator()); context.setGlobalContextManager(new SentryAsyncLocalStorageContextManager()); setOpenTelemetryContextAsyncContextStrategy(); diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index c7afdf06f805..e96aba8d7e23 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -17,11 +17,10 @@ import { SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, SENTRY_TRACE_STATE_CHILD_IGNORED, } from './constants'; -import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual, withActiveSpan } from './trace'; +import { startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './trace'; import type { CurrentScopes } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; -import { getTraceData } from './utils/getTraceData'; import { AsyncLocalStorage } from 'node:async_hooks'; import type { AsyncLocalStorageLookup } from './asyncLocalStorageContextManager'; import { SentryAsyncLocalStorageContextManager } from './asyncLocalStorageContextManager'; @@ -142,9 +141,6 @@ export function setOpenTelemetryContextAsyncContextStrategy(): AsyncLocalStorage startSpanManual, startInactiveSpan, getActiveSpan, - getTraceData, - continueTrace, - startNewTrace, // The types here don't fully align, because our own `Span` type is narrower // than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around withActiveSpan: withActiveSpan, diff --git a/packages/opentelemetry/src/constants.ts b/packages/opentelemetry/src/constants.ts index 5b2b6c4c5211..8e3f8520b5da 100644 --- a/packages/opentelemetry/src/constants.ts +++ b/packages/opentelemetry/src/constants.ts @@ -5,7 +5,6 @@ export const SENTRY_BAGGAGE_HEADER = 'baggage'; export const SENTRY_TRACE_STATE_DSC = 'sentry.dsc'; export const SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING = 'sentry.sampled_not_recording'; -export const SENTRY_TRACE_STATE_URL = 'sentry.url'; /** * A flag marking a context as ignored because the span associated with the context diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index 1ae4dff6fa7b..f17239a2e3b3 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -1,7 +1,5 @@ export { getScopesFromContext } from './utils/contextData'; -export { getTraceContextForScope } from './trace'; - export { setupEventContextTrace } from './setupEventContextTrace'; export { SentryPropagator } from './propagator'; diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 9db3fa6ea995..e33a6206f376 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -1,205 +1,78 @@ -import type { Baggage, Context, Span, SpanContext, TextMapGetter, TextMapSetter } from '@opentelemetry/api'; -import { context, INVALID_TRACEID, propagation, trace, TraceFlags } from '@opentelemetry/api'; -import { isTracingSuppressed, W3CBaggagePropagator } from '@opentelemetry/core'; -import { URL_FULL } from '@sentry/conventions/attributes'; -import type { Client, continueTrace, DynamicSamplingContext, Scope } from '@sentry/core'; +import type { Context, SpanContext, TextMapGetter, TextMapPropagator, TextMapSetter } from '@opentelemetry/api'; +import { context, trace, TraceFlags } from '@opentelemetry/api'; +import type { continueTrace, DynamicSamplingContext } from '@sentry/core'; import { baggageHeaderToDynamicSamplingContext, - debug, - generateSentryTraceHeader, - generateTraceparentHeader, + consoleSandbox, getClient, getCurrentScope, - getDynamicSamplingContextFromScope, - getDynamicSamplingContextFromSpan, getIsolationScope, - LRUMap, - parseBaggageHeader, + getTraceData, + isTracingSuppressed, propagationContextFromHeaders, - SENTRY_BAGGAGE_KEY_PREFIX, shouldContinueTrace, - shouldPropagateTraceForUrl, - spanToJSON, } from '@sentry/core'; -import { - SENTRY_BAGGAGE_HEADER, - SENTRY_TRACE_HEADER, - SENTRY_TRACE_STATE_DSC, - SENTRY_TRACE_STATE_URL, -} from './constants'; -import { DEBUG_BUILD } from './debug-build'; import { getScopesFromContext, setScopesOnContext } from './utils/contextData'; -import { getSampledForPropagation, getSamplingDecision } from './utils/getSamplingDecision'; import { makeTraceState } from './utils/makeTraceState'; -import { reconcileDscSampled } from './utils/reconcileDscSampled'; + +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const SENTRY_BAGGAGE_HEADER = 'baggage'; +const W3C_TRACEPARENT_HEADER = 'traceparent'; /** - * Injects and extracts `sentry-trace` and `baggage` headers from carriers. + * A minimal OpenTelemetry `TextMapPropagator` that injects and extracts Sentry trace data. + * + * This propagator only supports injecting/extracting from current context, for simplicity sake. + * It will bail and do nothing if using a different context. */ -export class SentryPropagator extends W3CBaggagePropagator { - /** A map of URLs that have already been checked for if they match tracePropagationTargets. */ - private _urlMatchesTargetsMap: LRUMap; - - public constructor() { - super(); - - // We're caching results so we don't have to recompute regexp every time we create a request. - this._urlMatchesTargetsMap = new LRUMap(100); - } - - /** - * @inheritDoc - */ - public inject(context: Context, carrier: unknown, setter: TextMapSetter): void { - if (isTracingSuppressed(context)) { - DEBUG_BUILD && debug.log('[Tracing] Not injecting trace data for url because tracing is suppressed.'); +export class SentryPropagator implements TextMapPropagator { + /** @inheritDoc */ + public inject(ctx: Context, carrier: unknown, setter: TextMapSetter): void { + if (ctx !== context.active()) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + 'SentryPropagator: Injecting with a different context than the active one - this is not supported. Skipping injection.', + ); + }); return; } - const activeSpan = trace.getSpan(context); - const url = activeSpan && getCurrentURL(activeSpan); - - const { tracePropagationTargets, propagateTraceparent } = getClient()?.getOptions() || {}; - if (!shouldPropagateTraceForUrl(url, tracePropagationTargets, this._urlMatchesTargetsMap)) { - DEBUG_BUILD && - debug.log('[Tracing] Not injecting trace data for url because it does not match tracePropagationTargets:', url); + if (isTracingSuppressed()) { return; } - const existingBaggageHeader = getExistingBaggage(carrier); - const existingSentryTraceHeader = getExistingSentryTrace(carrier); - - let baggage = propagation.getBaggage(context) || propagation.createBaggage({}); + const { propagateTraceparent } = getClient()?.getOptions() ?? {}; - const { dynamicSamplingContext, traceId, spanId, sampled } = getInjectionData(context); + // Pick trace data from the current scope + const { 'sentry-trace': sentryTrace, baggage, traceparent } = getTraceData({ propagateTraceparent }); - if (existingBaggageHeader) { - const baggageEntries = parseBaggageHeader(existingBaggageHeader); - - if (baggageEntries) { - Object.entries(baggageEntries).forEach(([key, value]) => { - if (!existingSentryTraceHeader && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { - // Edge case: A baggage header with sentry- keys was added previously but no - // sentry-trace header. In this case we remove the old sentry-keys and add new - // ones below. - return; - } - baggage = baggage.setEntry(key, { value }); - }); - } + if (sentryTrace) { + setter.set(carrier, SENTRY_TRACE_HEADER, sentryTrace); } - - if (!existingSentryTraceHeader && dynamicSamplingContext) { - baggage = Object.entries(dynamicSamplingContext).reduce((b, [dscKey, dscValue]) => { - if (dscValue) { - return b.setEntry(`${SENTRY_BAGGAGE_KEY_PREFIX}${dscKey}`, { value: dscValue }); - } - return b; - }, baggage); + if (baggage) { + setter.set(carrier, SENTRY_BAGGAGE_HEADER, baggage); } - - // We also want to avoid setting the default OTEL trace ID, if we get that for whatever reason - if (!existingSentryTraceHeader && traceId && traceId !== INVALID_TRACEID) { - setter.set(carrier, SENTRY_TRACE_HEADER, generateSentryTraceHeader(traceId, spanId, sampled)); - - if (propagateTraceparent) { - setter.set(carrier, 'traceparent', generateTraceparentHeader(traceId, spanId, sampled)); - } + if (traceparent) { + setter.set(carrier, W3C_TRACEPARENT_HEADER, traceparent); } - - super.inject(propagation.setBaggage(context, baggage), carrier, setter); } - /** - * @inheritDoc - */ - public extract(context: Context, carrier: unknown, getter: TextMapGetter): Context { + /** @inheritDoc */ + public extract(ctx: Context, carrier: unknown, getter: TextMapGetter): Context { const maybeSentryTraceHeader: string | string[] | undefined = getter.get(carrier, SENTRY_TRACE_HEADER); const baggage = getter.get(carrier, SENTRY_BAGGAGE_HEADER); - const sentryTrace = maybeSentryTraceHeader - ? Array.isArray(maybeSentryTraceHeader) - ? maybeSentryTraceHeader[0] - : maybeSentryTraceHeader - : undefined; + const sentryTrace = Array.isArray(maybeSentryTraceHeader) ? maybeSentryTraceHeader[0] : maybeSentryTraceHeader; - // Add remote parent span context - // If there is no incoming trace, this will return the context as-is - return ensureScopesOnContext(getContextWithRemoteActiveSpan(context, { sentryTrace, baggage })); + // Add remote parent span context. If there is no incoming trace, this returns the context as-is. + return getContextWithRemoteActiveSpanAndScopes(ctx, { sentryTrace, baggage }); } - /** - * @inheritDoc - */ + /** @inheritDoc */ public fields(): string[] { - return [SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER, 'traceparent']; - } -} - -/** - * Get propagation injection data for the given context. - * The additional options can be passed to override the scope and client that is otherwise derived from the context. - */ -export function getInjectionData( - context: Context, - options: { scope?: Scope; client?: Client } = {}, -): { - dynamicSamplingContext: Partial | undefined; - traceId: string | undefined; - spanId: string | undefined; - sampled: boolean | undefined; -} { - const span = trace.getSpan(context); - - // If we have a remote span, the spanId should be considered as the parentSpanId, not spanId itself - // Instead, we use a virtual (generated) spanId for propagation - if (span?.spanContext().isRemote) { - const spanContext = span.spanContext(); - const sampled = getSamplingDecision(spanContext); - const dsc = getDynamicSamplingContextFromSpan(span); - - // When the incoming trace froze its DSC on the trace state, `getDynamicSamplingContextFromSpan` - // returns that DSC verbatim; per the propagation spec it is immutable, so we must not rewrite - // `sampled` or strip `transaction` on it. We only reconcile the DSC that core freshly derives - // from the (binary) span trace flags, which is the sole case that can misrepresent a deferred - // decision as unsampled. - const hasIncomingFrozenDsc = !!spanContext.traceState?.get(SENTRY_TRACE_STATE_DSC); - const dynamicSamplingContext = hasIncomingFrozenDsc ? dsc : reconcileDscSampled(dsc, sampled); - - return { - dynamicSamplingContext, - traceId: spanContext.traceId, - spanId: undefined, - sampled, - }; - } - - // If we have a local span, we just use this - if (span) { - const spanContext = span.spanContext(); - const dynamicSamplingContext = getDynamicSamplingContextFromSpan(span); - - return { - dynamicSamplingContext, - traceId: spanContext.traceId, - spanId: spanContext.spanId, - sampled: getSampledForPropagation(span, options.client), - }; + return [SENTRY_TRACE_HEADER, SENTRY_BAGGAGE_HEADER, W3C_TRACEPARENT_HEADER]; } - - // Else we try to use the propagation context from the scope - // The only scenario where this should happen is when we neither have a span, nor an incoming trace - const scope = options.scope || getScopesFromContext(context)?.scope || getCurrentScope(); - const client = options.client || getClient(); - - const propagationContext = scope.getPropagationContext(); - const dynamicSamplingContext = client ? getDynamicSamplingContextFromScope(client, scope) : undefined; - return { - dynamicSamplingContext, - traceId: propagationContext.traceId, - spanId: propagationContext.propagationSpanId, - sampled: propagationContext.sampled, - }; } function getContextWithRemoteActiveSpan( @@ -238,11 +111,20 @@ export function continueTraceAsRemoteSpan( options: Parameters[0], callback: () => T, ): T { - const ctxWithSpanContext = ensureScopesOnContext(getContextWithRemoteActiveSpan(ctx, options)); + const ctxWithSpanContext = getContextWithRemoteActiveSpanAndScopes(ctx, options); return context.with(ctxWithSpanContext, callback); } +/** + * Build a context that continues an incoming trace as a remote active span, with scopes ensured. + * Unlike `continueTraceAsRemoteSpan`, this returns the context instead of running a callback within it, + * so it can be used to implement an OpenTelemetry propagator's `extract`. + */ +function getContextWithRemoteActiveSpanAndScopes(ctx: Context, options: Parameters[0]): Context { + return ensureScopesOnContext(getContextWithRemoteActiveSpan(ctx, options)); +} + function ensureScopesOnContext(ctx: Context): Context { // If there are no scopes yet on the context, ensure we have them const scopes = getScopesFromContext(ctx); @@ -256,49 +138,6 @@ function ensureScopesOnContext(ctx: Context): Context { return setScopesOnContext(ctx, newScopes); } -/** Try to get the existing baggage header so we can merge this in. */ -function getExistingBaggage(carrier: unknown): string | undefined { - try { - const baggage = (carrier as Record)[SENTRY_BAGGAGE_HEADER]; - return Array.isArray(baggage) ? baggage.join(',') : baggage; - } catch { - return undefined; - } -} - -function getExistingSentryTrace(carrier: unknown): string | string[] | undefined { - try { - return (carrier as Record)[SENTRY_TRACE_HEADER]; - } catch { - return undefined; - } -} - -/** - * It is pretty tricky to get access to the outgoing request URL of a request in the propagator. - * As we only have access to the context of the span to be sent and the carrier (=headers), - * but the span may be unsampled and thus have no attributes. - * - * So we use the following logic: - * 1. If we have an active span, we check if it has a URL attribute. - * 2. Else, if the active span has no URL attribute (e.g. it is unsampled), we check a special trace state (which we set in our sampler). - */ -function getCurrentURL(span: Span): string | undefined { - const spanData = spanToJSON(span).data; - const urlAttribute = spanData[URL_FULL]; - if (typeof urlAttribute === 'string') { - return urlAttribute; - } - - // Also look at the traceState, which we may set in the sampler even for unsampled spans - const urlTraceState = span.spanContext().traceState?.get(SENTRY_TRACE_STATE_URL); - if (urlTraceState) { - return urlTraceState; - } - - return undefined; -} - function generateRemoteSpanContext({ spanId, traceId, diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index 2239127b62fe..c78cf01d546c 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -1,38 +1,24 @@ import type { Context, Span, SpanContext, SpanOptions, TimeInput, Tracer } from '@opentelemetry/api'; import { context, SpanStatusCode, trace, TraceFlags } from '@opentelemetry/api'; import { isTracingSuppressed, suppressTracing } from '@opentelemetry/core'; -import type { - Client, - continueTrace as baseContinueTrace, - DynamicSamplingContext, - Scope, - Span as SentrySpan, - TraceContext, -} from '@sentry/core'; +import type { Client, Scope, Span as SentrySpan } from '@sentry/core'; import { - _INTERNAL_safeMathRandom, - generateSpanId, - generateTraceId, getClient, getCurrentScope, - getDynamicSamplingContextFromScope, getDynamicSamplingContextFromSpan, getRootSpan, - getTraceContextFromScope, handleCallbackErrors, hasSpansEnabled, SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, - spanToTraceContext, } from '@sentry/core'; -import { SENTRY_TRACE_STATE_DSC } from './constants'; -import { continueTraceAsRemoteSpan } from './propagator'; import type { OpenTelemetrySpanContext } from './types'; import { getContextFromScope } from './utils/contextData'; import { getSamplingDecision } from './utils/getSamplingDecision'; import { makeTraceState } from './utils/makeTraceState'; import { reconcileDscSampled } from './utils/reconcileDscSampled'; +import { SENTRY_TRACE_STATE_DSC } from './constants'; /** * Internal helper for starting spans and manual spans. See {@link startSpan} and {@link startSpanManual} for the public APIs. @@ -70,12 +56,15 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) return context.with(suppressedCtx, () => { return tracer.startActiveSpan(name, spanOptions, suppressedCtx, span => { patchSpanEnd(span); - // Restore the original unsuppressed context for the callback execution - // so that custom OpenTelemetry spans maintain the correct context. + // Run the callback under the original unsuppressed context (so custom OpenTelemetry spans + // created inside are not suppressed) but with our span set as active. Without setting the + // span here, `getActiveSpan()` inside the callback would resolve to a stale ancestor on + // `activeCtx` (e.g. an outer span outside a `startNewTrace`/`continueTrace` boundary), + // and event trace-context would attach to the wrong trace. // We use activeCtx (not ctx) because ctx may be suppressed when onlyIfParent is true // and no parent span exists. Using activeCtx ensures custom OTel spans are never // inadvertently suppressed. - return context.with(activeCtx, () => { + return context.with(trace.setSpan(activeCtx, span), () => { return handleCallbackErrors( () => callback(span), () => { @@ -298,70 +287,6 @@ function getContextForScope(scope?: Scope): Context { return context.active(); } -/** - * Continue a trace from `sentry-trace` and `baggage` values. - * These values can be obtained from incoming request headers, or in the browser from `` - * and `` HTML tags. - * - * Spans started with `startSpan`, `startSpanManual` and `startInactiveSpan`, within the callback will automatically - * be attached to the incoming trace. - * - * This is a custom version of `continueTrace` that is used in OTEL-powered environments. - * It propagates the trace as a remote span, in addition to setting it on the propagation context. - */ -export function continueTrace(options: Parameters[0], callback: () => T): T { - return continueTraceAsRemoteSpan(context.active(), options, callback); -} - -/** - * Start a new trace with a unique traceId, ensuring all spans created within the callback - * share the same traceId. - * - * This is a custom version of `startNewTrace` for OTEL-powered environments. - * It injects the new traceId as a remote span context into the OTEL context, so that - * `startInactiveSpan` and `startSpan` pick it up correctly. - */ -export function startNewTrace(callback: () => T): T { - const traceId = generateTraceId(); - const spanId = generateSpanId(); - - const spanContext: SpanContext = { - traceId, - spanId, - isRemote: true, - traceFlags: TraceFlags.NONE, - }; - - const ctxWithTrace = trace.setSpanContext(context.active(), spanContext); - - return context.with(ctxWithTrace, () => { - getCurrentScope().setPropagationContext({ - traceId, - sampleRand: _INTERNAL_safeMathRandom(), - }); - return callback(); - }); -} - -/** - * Get the trace context for a given scope. - * We have a custom implementation here because we need an OTEL-specific way to get the span from a scope. - */ -export function getTraceContextForScope( - client: Client, - scope: Scope, -): [dynamicSamplingContext: Partial, traceContext: TraceContext] { - const ctx = getContextFromScope(scope); - const span = ctx && trace.getSpan(ctx); - - const traceContext = span ? spanToTraceContext(span) : getTraceContextFromScope(scope); - - const dynamicSamplingContext = span - ? getDynamicSamplingContextFromSpan(span) - : getDynamicSamplingContextFromScope(client, scope); - return [dynamicSamplingContext, traceContext]; -} - function getActiveSpanWrapper(parentSpan: Span | SentrySpan | undefined | null): (callback: () => T) => T { return parentSpan !== undefined ? (callback: () => T) => { diff --git a/packages/opentelemetry/src/tracer.ts b/packages/opentelemetry/src/tracer.ts index e20b83a698d7..6ff1e85531bd 100644 --- a/packages/opentelemetry/src/tracer.ts +++ b/packages/opentelemetry/src/tracer.ts @@ -136,15 +136,11 @@ export class SentryTracer implements Tracer { return _INTERNAL_startInactiveSpan({ ...sentryOptions, parentSpan: parentSpan }); } - // No parent span and no remote parent: this is a fresh root span. Start a new trace instead of - // continuing the scope's (possibly auto-generated) propagation context, matching the OpenTelemetry - // SDK where each root span without an incoming trace gets its own trace id. - return startNewTrace(() => - _INTERNAL_startInactiveSpan({ - ...sentryOptions, - parentSpan: hasExplicitContext ? null : undefined, - }), - ); + // No parent span and no remote parent: this is a fresh root span. + return _INTERNAL_startInactiveSpan({ + ...sentryOptions, + parentSpan: hasExplicitContext ? null : undefined, + }); } private _startRootSpanWithRemoteParent( @@ -176,7 +172,11 @@ export class SentryTracer implements Tracer { } private _createNonRecordingSpan(parentSpan: OpenTelemetrySpan | undefined): OpenTelemetrySpan { - const span = new SentryNonRecordingSpan({ traceId: parentSpan?.spanContext().traceId }); + // Without a parent, fall back to the current scope's propagation context trace id, so that + // non-recording spans (TwP mode) stay on the trace set by `startNewTrace`/`continueTrace` + // instead of minting a fresh random trace id. Mirrors core's `createChildOrRootSpan` TwP branch. + const traceId = parentSpan?.spanContext().traceId ?? getCurrentScope().getPropagationContext().traceId; + const span = new SentryNonRecordingSpan({ traceId }); // Link to the parent (like core's `createChildOrRootSpan`) so `getRootSpan` and DSC // resolution reach the parent. Non-recording spans no longer carry a `parentSpanId`. if (parentSpan) { diff --git a/packages/opentelemetry/src/utils/getTraceData.ts b/packages/opentelemetry/src/utils/getTraceData.ts deleted file mode 100644 index cae4059cf9e1..000000000000 --- a/packages/opentelemetry/src/utils/getTraceData.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as api from '@opentelemetry/api'; -import type { Client, Scope, SerializedTraceData, Span } from '@sentry/core'; -import { - dynamicSamplingContextToSentryBaggageHeader, - generateSentryTraceHeader, - generateTraceparentHeader, - getCapturedScopesOnSpan, -} from '@sentry/core'; -import { getInjectionData } from '../propagator'; -import { getContextFromScope } from './contextData'; - -/** - * Otel-specific implementation of `getTraceData`. - * @see `@sentry/core` version of `getTraceData` for more information - */ -export function getTraceData({ - span, - scope, - client, - propagateTraceparent, -}: { span?: Span; scope?: Scope; client?: Client; propagateTraceparent?: boolean } = {}): SerializedTraceData { - let ctx = (scope && getContextFromScope(scope)) ?? api.context.active(); - - if (span) { - const { scope } = getCapturedScopesOnSpan(span); - // fall back to current context if for whatever reason we can't find the one of the span - ctx = (scope && getContextFromScope(scope)) || api.trace.setSpan(api.context.active(), span); - } - - const { traceId, spanId, sampled, dynamicSamplingContext } = getInjectionData(ctx, { scope, client }); - - const traceData: SerializedTraceData = { - 'sentry-trace': generateSentryTraceHeader(traceId, spanId, sampled), - baggage: dynamicSamplingContextToSentryBaggageHeader(dynamicSamplingContext), - }; - - if (propagateTraceparent) { - traceData.traceparent = generateTraceparentHeader(traceId, spanId, sampled); - } - - return traceData; -} diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index da4c67e4d5c8..7f7cb6eeb031 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -317,7 +317,6 @@ describe('Integration | Transactions', () => { const client = mockSdkInit({ tracesSampleRate: 1, beforeSendTransaction }); - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { startSpan( { @@ -432,7 +431,6 @@ describe('Integration | Transactions', () => { release: '7.0.0', }); - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { startSpan( { diff --git a/packages/opentelemetry/test/propagator.test.ts b/packages/opentelemetry/test/propagator.test.ts index 706396ddea53..330927e6ddd2 100644 --- a/packages/opentelemetry/test/propagator.test.ts +++ b/packages/opentelemetry/test/propagator.test.ts @@ -1,27 +1,17 @@ -import { - context, - defaultTextMapGetter, - defaultTextMapSetter, - propagation, - ROOT_CONTEXT, - trace, - TraceFlags, -} from '@opentelemetry/api'; -import { suppressTracing } from '@opentelemetry/core'; -import { getCurrentScope, withScope } from '@sentry/core'; +import { context, defaultTextMapGetter, defaultTextMapSetter, ROOT_CONTEXT, trace } from '@opentelemetry/api'; +import { suppressTracing, withScope } from '@sentry/core'; import { beforeEach, describe, expect, it } from 'vitest'; -import { SENTRY_BAGGAGE_HEADER, SENTRY_SCOPES_CONTEXT_KEY, SENTRY_TRACE_HEADER } from '../src/constants'; +import { SENTRY_BAGGAGE_HEADER, SENTRY_TRACE_HEADER } from '../src/constants'; import { SentryPropagator } from '../src/propagator'; -import { getSamplingDecision } from '../src/utils/getSamplingDecision'; -import { makeTraceState } from '../src/utils/makeTraceState'; import { mockSdkInit } from './helpers/mockSdkInit'; +const TRACE_ID = 'd4cda95b652f4a1592b449d5929fda1b'; +const PARENT_SPAN_ID = '6e0c63257de34c93'; + describe('SentryPropagator', () => { const propagator = new SentryPropagator(); - let carrier: { [key: string]: unknown }; beforeEach(() => { - carrier = {}; mockSdkInit({ environment: 'production', release: '1.0.0', @@ -35,754 +25,101 @@ describe('SentryPropagator', () => { }); describe('inject', () => { - describe('without active local span', () => { - it('uses scope propagation context without DSC if no span is found', () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - parentSpanId: '6e0c63257de34c93', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); + it('injects sentry-trace and baggage from the scope propagation context', () => { + const carrier: Record = {}; + + withScope(scope => { + scope.setPropagationContext({ + traceId: TRACE_ID, + parentSpanId: PARENT_SPAN_ID, + sampled: true, + sampleRand: 0.42, }); - }); - - it('uses scope propagation context with DSC if no span is found', () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - parentSpanId: '6e0c63257de34c93', - sampled: true, - sampleRand: Math.random(), - dsc: { - transaction: 'sampled-transaction', - sampled: 'false', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }); - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-1/); - }); + propagator.inject(context.active(), carrier, defaultTextMapSetter); }); - it('uses propagation data from current scope if no scope & span is found', () => { - const scope = getCurrentScope(); - const traceId = scope.getPropagationContext().traceId; - - const ctx = trace.deleteSpan(ROOT_CONTEXT).deleteValue(SENTRY_SCOPES_CONTEXT_KEY); - propagator.inject(ctx, carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual([ - 'sentry-environment=production', - 'sentry-public_key=abc', - 'sentry-release=1.0.0', - `sentry-trace_id=${traceId}`, - ]); - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(traceId); - }); + expect(carrier[SENTRY_TRACE_HEADER]).toMatch(new RegExp(`^${TRACE_ID}-[a-f0-9]{16}-1$`)); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toContain(`sentry-trace_id=${TRACE_ID}`); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toContain('sentry-environment=production'); }); - describe('with active span', () => { - it.each([ - [ - 'continues a remote trace without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - [ - 'continues a remote trace with dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - traceState: makeTraceState({ - dsc: { - transaction: 'sampled-transaction', - sampled: 'true', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=true', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - [ - 'continues an unsampled remote trace without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - undefined, - ], - [ - 'continues an unsampled remote trace with sampled trace state & without dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - sampled: false, - }), - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-sampled=false', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'continues an unsampled remote trace with dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - dsc: { - transaction: 'sampled-transaction', - sampled: 'false', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'continues an unsampled remote trace with dsc & sampled trace state', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ - sampled: false, - dsc: { - transaction: 'sampled-transaction', - trace_id: 'dsc_trace_id', - public_key: 'dsc_public_key', - environment: 'dsc_environment', - release: 'dsc_release', - sample_rate: '0.5', - replay_id: 'dsc_replay_id', - }, - }), - }, - [ - 'sentry-environment=dsc_environment', - 'sentry-release=dsc_release', - 'sentry-public_key=dsc_public_key', - 'sentry-trace_id=dsc_trace_id', - 'sentry-transaction=sampled-transaction', - 'sentry-sample_rate=0.5', - 'sentry-replay_id=dsc_replay_id', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-0', - false, - ], - [ - 'starts a new trace without existing dsc', - { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }, - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ], - 'd4cda95b652f4a1592b449d5929fda1b-{{spanId}}-1', - true, - ], - ])('%s', (_name, spanContext, baggage, sentryTrace, samplingDecision) => { - expect(getSamplingDecision(spanContext)).toBe(samplingDecision); + it('injects the trace data of an active span', () => { + const carrier: Record = {}; - context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { - trace.getTracer('test').startActiveSpan('test', span => { - propagator.inject(context.active(), carrier, defaultTextMapSetter); - baggage.forEach(baggageItem => { - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(baggageItem); - }); - expect(carrier[SENTRY_TRACE_HEADER]).toBe(sentryTrace.replace('{{spanId}}', span.spanContext().spanId)); - }); - }); - }); - - it('uses local span over propagation context', () => { - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - isRemote: true, - }), - () => { - trace.getTracer('test').startActiveSpan('test', span => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=true', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=test', - expect.stringMatching(/sentry-sample_rand=0\.[0-9]+/), - ].forEach(item => { - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toContainEqual(item); - }); - expect(carrier[SENTRY_TRACE_HEADER]).toBe( - `d4cda95b652f4a1592b449d5929fda1b-${span.spanContext().spanId}-1`, - ); - }); - }); - }, - ); - }); - - it('uses remote span with deferred sampling decision over propagation context', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - }), - () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - // Used spanId is a random ID, not from the remote span - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}/); - expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'); - }); - }, - ); - }); - - it('preserves a frozen incoming DSC on a directly-injected unsampled remote span', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - // A definitively-unsampled incoming trace that froze its own DSC, including a transaction name. - traceState: makeTraceState({ - sampled: false, - dsc: { - transaction: 'incoming-transaction', - sampled: 'false', - trace_id: 'd4cda95b652f4a1592b449d5929fda1b', - public_key: 'incoming_public_key', - environment: 'incoming_environment', - release: 'incoming_release', - sample_rate: '0.5', - }, - }), - }), - () => { - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - // The frozen incoming DSC is immutable, so its `transaction` must survive even though the - // trace is unsampled — we must not strip it the way we do for a freshly-derived DSC. - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=incoming_environment', - 'sentry-release=incoming_release', - 'sentry-public_key=incoming_public_key', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-transaction=incoming-transaction', - 'sentry-sampled=false', - 'sentry-sample_rate=0.5', - ].sort(), - ); - }, - ); - }); - - it('uses remote span over propagation context', () => { - const carrier: Record = {}; - context.with( - trace.setSpanContext(ROOT_CONTEXT, { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - isRemote: true, - traceState: makeTraceState({ sampled: false }), - }), - () => { - withScope(scope => { - scope.setPropagationContext({ - traceId: 'TRACE_ID', - parentSpanId: 'PARENT_SPAN_ID', - sampled: true, - sampleRand: Math.random(), - }); - - propagator.inject(context.active(), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-public_key=abc', - 'sentry-sampled=false', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - ].sort(), - ); - // Used spanId is a random ID, not from the remote span - expect(carrier[SENTRY_TRACE_HEADER]).toMatch(/d4cda95b652f4a1592b449d5929fda1b-[a-f0-9]{16}-0/); - expect(carrier[SENTRY_TRACE_HEADER]).not.toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'); - }); - }, - ); - }); - }); - - it('should include existing baggage', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should include existing baggage header', () => { - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const carrier = { - other: 'header', - baggage: 'foo=bar,other=yes', - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); - - it('should include existing baggage array header', () => { const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceId: TRACE_ID, spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - - const carrier = { - other: 'header', - baggage: ['foo=bar,other=yes', 'other2=no'], + traceFlags: 1, }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'other2=no', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - }); + const ctx = trace.setSpanContext(ROOT_CONTEXT, spanContext); - it('overwrites existing sentry baggage values and add sentry-trace header if sentry-trace is not set yet', () => { - // This is an edeg case where someone set a baggage header with existing sentry- values but no sentry-trace header. - // There's no evidence this occurs in real-life but if it does, we can assume that this must be some kind of error - // Hence, we overwrite the existing sentry- values with our new ones but keep all other non-sentry values. - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; + context.with(ctx, () => { + propagator.inject(context.active(), carrier, defaultTextMapSetter); + }); - const carrier: Record = { - baggage: 'foo=bar,other=yes,sentry-release=9.9.9,sentry-other=yes', - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage(); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'other=yes', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'); + expect(carrier[SENTRY_TRACE_HEADER]).toBe(`${TRACE_ID}-6e0c63257de34c92-1`); }); - it('should create baggage without propagation context', () => { - const scope = getCurrentScope(); - const traceId = scope.getPropagationContext().traceId; + it('does not inject anything when tracing is suppressed', () => { + const carrier: Record = {}; - const context = ROOT_CONTEXT; - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe( - `foo=bar,sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=${traceId}`, - ); - expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); // whenever we set baggage, we must also set sentry-trace - }); - - it('should NOT set baggage and sentry-trace header if instrumentation is suppressed', () => { const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', + traceId: TRACE_ID, spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, + traceFlags: 1, }; + const ctx = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const context = suppressTracing(trace.setSpanContext(ROOT_CONTEXT, spanContext)); - propagator.inject(context, carrier, defaultTextMapSetter); - expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); - }); - - it("doesn't set baggage header if sentry-trace header is already set", () => { - const carrier: Record = { - [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', - }; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); - }); - - describe('traceparent header', () => { - it("doesn't change baggage header if sentry-trace header is already set", () => { - const carrier: Record = { - [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', - [SENTRY_BAGGAGE_HEADER]: 'foo=bar,other=yes,sentry-release=9.9.9', - }; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe('foo=bar,other=yes,sentry-release=9.9.9'); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); - }); - - it("doesn't set traceparent header if sentry-trace header is already set", () => { - mockSdkInit({ propagateTraceparent: true }); - const carrier: Record = { - [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', - }; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier['traceparent']).toBe(undefined); - expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); - }); - - it('sets traceparent header if propagateTraceparent is true', () => { - mockSdkInit({ - environment: 'production', - release: '1.0.0', - tracesSampleRate: 1, - dsn: 'https://abc@domain/123', - propagateTraceparent: true, + suppressTracing(() => { + context.with(ctx, () => { + propagator.inject(context.active(), carrier, defaultTextMapSetter); }); - - const spanContext = { - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - }; - const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); - const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); - propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); - - expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( - [ - 'foo=bar', - 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', - 'sentry-public_key=abc', - 'sentry-environment=production', - 'sentry-release=1.0.0', - 'sentry-sampled=true', - ].sort(), - ); - expect(carrier['traceparent']).toBe('00-d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-01'); }); - it("doesn't set traceparent header if propagateTraceparent is false", () => { - mockSdkInit({ - environment: 'production', - release: '1.0.0', - tracesSampleRate: 1, - dsn: 'https://abc@domain/123', - propagateTraceparent: false, - }); - const carrier: Record = {}; - propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); - - expect(carrier['traceparent']).toBe(undefined); - expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); - }); + expect(carrier[SENTRY_TRACE_HEADER]).toBeUndefined(); + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBeUndefined(); }); }); describe('extract', () => { - it('sets data from sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); - }); + it('continues an incoming trace as a remote active span', () => { + const carrier = { + [SENTRY_TRACE_HEADER]: `${TRACE_ID}-${PARENT_SPAN_ID}-1`, + [SENTRY_BAGGAGE_HEADER]: 'sentry-environment=production,sentry-public_key=abc', + }; - it('sets data from negative sampled sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-0'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({ sampled: false }), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(false); - }); + const extractedContext = propagator.extract(context.active(), carrier, defaultTextMapGetter); - it('sets data from not sampled sentry trace header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.NONE, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(undefined); + const spanContext = trace.getSpanContext(extractedContext); + expect(spanContext?.traceId).toBe(TRACE_ID); + expect(spanContext?.spanId).toBe(PARENT_SPAN_ID); + expect(spanContext?.isRemote).toBe(true); }); - it('handles undefined sentry trace header', () => { - const sentryTraceHeader = undefined; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual(undefined); - expect(getCurrentScope().getPropagationContext()).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - sampleRand: expect.any(Number), - }); - }); + it('handles an array-valued sentry-trace header', () => { + const carrier = { + [SENTRY_TRACE_HEADER]: [`${TRACE_ID}-${PARENT_SPAN_ID}-1`], + }; - it('sets data from baggage header on span context', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - const baggage = - 'sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b,sentry-transaction=dsc-transaction,sentry-sample_rand=0.123'; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - carrier[SENTRY_BAGGAGE_HEADER] = baggage; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({ - dsc: { - environment: 'production', - release: '1.0.0', - public_key: 'abc', - trace_id: 'd4cda95b652f4a1592b449d5929fda1b', - transaction: 'dsc-transaction', - sample_rand: '0.123', - }, - }), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); - }); + const getter = { + keys: (c: Record) => Object.keys(c), + get: (c: Record, key: string) => c[key] as string | string[] | undefined, + }; - it('handles empty dsc baggage header', () => { - const sentryTraceHeader = 'd4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'; - const baggage = ''; - carrier[SENTRY_TRACE_HEADER] = sentryTraceHeader; - carrier[SENTRY_BAGGAGE_HEADER] = baggage; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual({ - isRemote: true, - spanId: '6e0c63257de34c92', - traceFlags: TraceFlags.SAMPLED, - traceId: 'd4cda95b652f4a1592b449d5929fda1b', - traceState: makeTraceState({}), - }); - expect(getSamplingDecision(trace.getSpanContext(context)!)).toBe(true); + const extractedContext = propagator.extract(context.active(), carrier, getter); + + const spanContext = trace.getSpanContext(extractedContext); + expect(spanContext?.traceId).toBe(TRACE_ID); + expect(spanContext?.spanId).toBe(PARENT_SPAN_ID); }); - it('handles when sentry-trace is an empty array', () => { - carrier[SENTRY_TRACE_HEADER] = []; - const context = propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter); - expect(trace.getSpanContext(context)).toEqual(undefined); - expect(getCurrentScope().getPropagationContext()).toEqual({ - traceId: expect.stringMatching(/[a-f0-9]{32}/), - sampleRand: expect.any(Number), - }); + it('returns the context unchanged when there is no incoming trace', () => { + const carrier = {}; + + const extractedContext = propagator.extract(context.active(), carrier, defaultTextMapGetter); + + expect(trace.getSpanContext(extractedContext)).toBeUndefined(); }); }); }); - -function baggageToArray(baggage: unknown): string[] { - return typeof baggage === 'string' ? baggage.split(',').sort() : []; -} diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 16ac9aebc40b..10c23386d5a3 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -9,20 +9,16 @@ import { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan, getRootSpan, - isTracingSuppressed, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - spanIsSampled, spanToJSON, - suppressTracing, withScope, } from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual } from '../src/trace'; +import { startInactiveSpan, startSpan, startSpanManual } from '../src/trace'; import { getActiveSpan } from '../src/utils/getActiveSpan'; -import { getSamplingDecision } from '../src/utils/getSamplingDecision'; import { makeTraceState } from '../src/utils/makeTraceState'; import { isSpan } from './helpers/isSpan'; import { mockSdkInit } from './helpers/mockSdkInit'; @@ -1292,7 +1288,9 @@ describe('trace', () => { const traceId = spanToJSON(span).trace_id; expect(traceId).toMatch(/[a-f0-9]{32}/); expect(spanToJSON(span).parent_span_id).toBe(undefined); - expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + // A root span without a parent continues the current scope's propagation context trace, + // matching the core SDK behavior. + expect(spanToJSON(span).trace_id).toEqual(propagationContext.traceId); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ trace_id: traceId, @@ -1306,8 +1304,7 @@ describe('trace', () => { }); }); - // Note: This _should_ never happen, when we have an incoming trace, we should always have a parent span - it('starts new trace, ignoring parentSpanId, if there is no parent', () => { + it('continues the scope propagation context, including parentSpanId, if there is no active span', () => { withScope(scope => { const propagationContext = scope.getPropagationContext(); propagationContext.parentSpanId = '1121201211212012'; @@ -1316,8 +1313,10 @@ describe('trace', () => { expect(span).toBeDefined(); const traceId = spanToJSON(span).trace_id; expect(traceId).toMatch(/[a-f0-9]{32}/); - expect(spanToJSON(span).parent_span_id).toBe(undefined); - expect(spanToJSON(span).trace_id).not.toEqual(propagationContext.traceId); + // The root span continues the scope's trace and inherits the propagation context's + // parentSpanId as its parent, matching the core SDK behavior. + expect(spanToJSON(span).parent_span_id).toBe('1121201211212012'); + expect(spanToJSON(span).trace_id).toEqual(propagationContext.traceId); expect(getDynamicSamplingContextFromSpan(span)).toEqual({ environment: 'production', @@ -1595,7 +1594,6 @@ describe('trace (sampling)', () => { traceFlags: TraceFlags.SAMPLED, }; - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { // This will def. be sampled because of the tracesSampleRate startSpan({ name: 'outer' }, outerSpan => { @@ -1622,7 +1620,6 @@ describe('trace (sampling)', () => { traceFlags: TraceFlags.NONE, }; - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { // This will def. be sampled because of the tracesSampleRate startSpan({ name: 'outer' }, outerSpan => { @@ -1780,7 +1777,6 @@ describe('trace (sampling)', () => { traceFlags: TraceFlags.SAMPLED, }; - // We simulate the correct context we'd normally get from the SentryPropagator context.with(trace.setSpanContext(ROOT_CONTEXT, spanContext), () => { // This will def. be sampled because of the tracesSampleRate startSpan({ name: 'outer' }, outerSpan => { @@ -1819,219 +1815,6 @@ describe('trace (sampling)', () => { }); }); -describe('continueTrace', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - it('works without trace & baggage data', () => { - const scope = continueTrace({ sentryTrace: undefined, baggage: undefined }, () => { - const span = getActiveSpan()!; - expect(span).toBeUndefined(); - return getCurrentScope(); - }); - - expect(scope.getPropagationContext()).toEqual({ - traceId: expect.any(String), - sampleRand: expect.any(Number), - }); - - expect(scope.getScopeData().sdkProcessingMetadata).toEqual({}); - }); - - it('works with trace data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-0', - baggage: undefined, - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - status: 'ok', - }); - expect(getSamplingDecision(span.spanContext())).toBe(false); - expect(spanIsSampled(span)).toBe(false); - }, - ); - }); - - it('works with trace & baggage data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-version=1.0,sentry-environment=production', - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - status: 'ok', - }); - expect(getSamplingDecision(span.spanContext())).toBe(true); - expect(spanIsSampled(span)).toBe(true); - }, - ); - }); - - it('works with trace & 3rd party baggage data', () => { - continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-1', - baggage: 'sentry-version=1.0,sentry-environment=production,dogs=great,cats=boring', - }, - () => { - const span = getActiveSpan()!; - expect(span).toBeDefined(); - expect(spanToJSON(span)).toEqual({ - span_id: '1121201211212012', - trace_id: '12312012123120121231201212312012', - data: {}, - start_timestamp: 0, - status: 'ok', - }); - expect(getSamplingDecision(span.spanContext())).toBe(true); - expect(spanIsSampled(span)).toBe(true); - }, - ); - }); - - it('returns response of callback', () => { - const result = continueTrace( - { - sentryTrace: '12312012123120121231201212312012-1121201211212012-0', - baggage: undefined, - }, - () => { - return 'aha'; - }, - ); - - expect(result).toEqual('aha'); - }); -}); - -describe('suppressTracing', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - it('works for a root span', () => { - const span = suppressTracing(() => { - return startInactiveSpan({ name: 'span' }); - }); - - expect(span.isRecording()).toBe(false); - expect(spanIsSampled(span)).toBe(false); - }); - - it('works for a child span', () => { - startSpan({ name: 'outer' }, span => { - expect(span.isRecording()).toBe(true); - expect(spanIsSampled(span)).toBe(true); - - const child1 = startInactiveSpan({ name: 'inner1' }); - - expect(child1.isRecording()).toBe(true); - expect(spanIsSampled(child1)).toBe(true); - - const child2 = suppressTracing(() => { - return startInactiveSpan({ name: 'span' }); - }); - - expect(child2.isRecording()).toBe(false); - expect(spanIsSampled(child2)).toBe(false); - }); - }); - - it('works for a child span with forceTransaction=true', () => { - startSpan({ name: 'outer' }, span => { - expect(span.isRecording()).toBe(true); - expect(spanIsSampled(span)).toBe(true); - - const child = suppressTracing(() => { - return startInactiveSpan({ name: 'span', forceTransaction: true }); - }); - - expect(child.isRecording()).toBe(false); - expect(spanIsSampled(child)).toBe(false); - }); - }); - - it('works with parallel processes', async () => { - const span = suppressTracing(() => { - return startInactiveSpan({ name: 'span' }); - }); - - const span2Promise = suppressTracing(async () => { - await new Promise(resolve => setTimeout(resolve, 100)); - return startInactiveSpan({ name: 'span2' }); - }); - - const span3Promise = suppressTracing(async () => { - const span = startInactiveSpan({ name: 'span3' }); - await new Promise(resolve => setTimeout(resolve, 100)); - return span; - }); - - const span4 = suppressTracing(() => { - return startInactiveSpan({ name: 'span' }); - }); - - const span5 = startInactiveSpan({ name: 'span5' }); - - const span2 = await span2Promise; - const span3 = await span3Promise; - - expect(spanIsSampled(span)).toBe(false); - expect(spanIsSampled(span2)).toBe(false); - expect(spanIsSampled(span3)).toBe(false); - expect(spanIsSampled(span4)).toBe(false); - expect(spanIsSampled(span5)).toBe(true); - }); -}); - -describe('isTracingSuppressed', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - it('returns false when tracing is not suppressed', () => { - expect(isTracingSuppressed()).toBe(false); - }); - - it('returns true while inside suppressTracing', () => { - const suppressed = suppressTracing(() => isTracingSuppressed()); - expect(suppressed).toBe(true); - }); - - it('returns false again after suppressTracing has finished', () => { - suppressTracing(() => { - expect(isTracingSuppressed()).toBe(true); - }); - - expect(isTracingSuppressed()).toBe(false); - }); - - it('stays suppressed across async boundaries within suppressTracing', async () => { - const suppressed = await suppressTracing(async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - return isTracingSuppressed(); - }); - - expect(suppressed).toBe(true); - }); -}); - describe('span.end() timestamp conversion', () => { beforeEach(() => { mockSdkInit({ tracesSampleRate: 1 }); @@ -2120,108 +1903,6 @@ describe('span.end() timestamp conversion', () => { }); }); -describe('startNewTrace', () => { - beforeEach(() => { - mockSdkInit({ tracesSampleRate: 1 }); - }); - - it('sequential startInactiveSpan calls share the same traceId', () => { - startNewTrace(() => { - const propagationContext = getCurrentScope().getPropagationContext(); - - const span1 = startInactiveSpan({ name: 'span-1' }); - const span2 = startInactiveSpan({ name: 'span-2' }); - const span3 = startInactiveSpan({ name: 'span-3' }); - - const traceId1 = span1.spanContext().traceId; - const traceId2 = span2.spanContext().traceId; - const traceId3 = span3.spanContext().traceId; - - expect(traceId1).toBe(propagationContext.traceId); - expect(traceId2).toBe(propagationContext.traceId); - expect(traceId3).toBe(propagationContext.traceId); - - span1.end(); - span2.end(); - span3.end(); - }); - }); - - it('startSpan inside startNewTrace uses the correct traceId', () => { - startNewTrace(() => { - const propagationContext = getCurrentScope().getPropagationContext(); - - startSpan({ name: 'parent-span' }, parentSpan => { - const parentTraceId = parentSpan.spanContext().traceId; - expect(parentTraceId).toBe(propagationContext.traceId); - - const child = startInactiveSpan({ name: 'child-span' }); - expect(child.spanContext().traceId).toBe(propagationContext.traceId); - child.end(); - }); - }); - }); - - it('generates a different traceId than the outer trace', () => { - startSpan({ name: 'outer-span' }, outerSpan => { - const outerTraceId = outerSpan.spanContext().traceId; - - startNewTrace(() => { - const innerSpan = startInactiveSpan({ name: 'inner-span' }); - const innerTraceId = innerSpan.spanContext().traceId; - - expect(innerTraceId).not.toBe(outerTraceId); - - const propagationContext = getCurrentScope().getPropagationContext(); - expect(innerTraceId).toBe(propagationContext.traceId); - - innerSpan.end(); - }); - }); - }); - - it('allows spans to be sampled based on tracesSampleRate', () => { - startNewTrace(() => { - const span = startInactiveSpan({ name: 'sampled-span' }); - // tracesSampleRate is 1 in mockSdkInit, so spans should be sampled - // This verifies that TraceFlags.NONE on the remote span context does not - // cause the sampler to inherit a "not sampled" decision from the parent - expect(spanIsSampled(span)).toBe(true); - span.end(); - }); - }); - - it('samples a forced transaction based on tracesSampleRate', () => { - // `startNewTrace` injects a remote parent with `traceFlags: NONE` and no trace state, i.e. a - // *deferred* decision. A forced transaction under it runs through `getContext`'s simulated-root - // branch, which derives a DSC from that parent. Core naively reads `sampled=false` off the binary - // trace flags; without reconciliation that gets baked into the trace state and the transaction - // wrongly inherits a negative decision despite `tracesSampleRate: 1`. - startNewTrace(() => { - const span = startInactiveSpan({ name: 'forced-transaction', forceTransaction: true }); - expect(spanIsSampled(span)).toBe(true); - span.end(); - }); - }); - - it('does not leak the new traceId to the outer scope', () => { - const outerScope = getCurrentScope(); - const outerTraceId = outerScope.getPropagationContext().traceId; - - startNewTrace(() => { - // Manually set a known traceId on the inner scope to verify it doesn't leak - getCurrentScope().setPropagationContext({ - traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - sampleRand: 0.5, - }); - }); - - const afterTraceId = outerScope.getPropagationContext().traceId; - expect(afterTraceId).toBe(outerTraceId); - expect(afterTraceId).not.toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); - }); -}); - function getSpanName(span: Span): string | undefined { return spanToJSON(span).description; } diff --git a/packages/opentelemetry/test/utils/getTraceData.test.ts b/packages/opentelemetry/test/utils/getTraceData.test.ts deleted file mode 100644 index ddba9c332b4a..000000000000 --- a/packages/opentelemetry/test/utils/getTraceData.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { context, trace } from '@opentelemetry/api'; -import { getCurrentScope, Scope } from '@sentry/core'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { getTraceData } from '../../src/utils/getTraceData'; -import { makeTraceState } from '../../src/utils/makeTraceState'; -import { mockSdkInit } from '../helpers/mockSdkInit'; -import { getDefaultTestClientOptions, TestClient } from '../helpers/TestClient'; - -describe('getTraceData', () => { - beforeEach(() => { - mockSdkInit(); - }); - - afterEach(async () => { - vi.clearAllMocks(); - }); - - it('returns the tracing data from the span, if a span is available', () => { - const ctx = trace.setSpanContext(context.active(), { - traceId: '12345678901234567890123456789012', - spanId: '1234567890123456', - traceFlags: 1, - }); - - context.with(ctx, () => { - const data = getTraceData(); - - expect(data).toEqual({ - 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', - baggage: - 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', - }); - }); - }); - - it('allows to pass a span directly', () => { - const ctx = trace.setSpanContext(context.active(), { - traceId: '12345678901234567890123456789012', - spanId: '1234567890123456', - traceFlags: 1, - }); - - const span = trace.getSpan(ctx)!; - - const data = getTraceData({ span }); - - expect(data).toEqual({ - 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', - baggage: - 'sentry-environment=production,sentry-public_key=username,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', - }); - }); - - it('allows to pass a scope & client directly', () => { - getCurrentScope().setPropagationContext({ - traceId: '12345678901234567890123456789099', - sampleRand: 0.44, - }); - - const customClient = new TestClient( - getDefaultTestClientOptions({ tracesSampleRate: 1, dsn: 'https://123@sentry.io/42' }), - ); - - // note: Right now, this only works properly if the scope is linked to a context - const scope = new Scope(); - scope.setPropagationContext({ - traceId: '12345678901234567890123456789012', - sampleRand: 0.42, - }); - scope.setClient(customClient); - - const traceData = getTraceData({ client: customClient, scope }); - - expect(traceData['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}$/); - expect(traceData.baggage).toEqual( - 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012', - ); - }); - - it('returns propagationContext DSC data if no span is available', () => { - getCurrentScope().setPropagationContext({ - traceId: '12345678901234567890123456789012', - sampleRand: Math.random(), - sampled: true, - dsc: { - environment: 'staging', - public_key: 'key', - trace_id: '12345678901234567890123456789012', - }, - }); - - const traceData = getTraceData(); - - expect(traceData['sentry-trace']).toMatch(/^12345678901234567890123456789012-[a-f0-9]{16}-1$/); - expect(traceData.baggage).toEqual( - 'sentry-environment=staging,sentry-public_key=key,sentry-trace_id=12345678901234567890123456789012', - ); - }); - - it('works with an span with frozen DSC in traceState', () => { - const ctx = trace.setSpanContext(context.active(), { - traceId: '12345678901234567890123456789012', - spanId: '1234567890123456', - traceFlags: 1, - traceState: makeTraceState({ - dsc: { environment: 'test-dev', public_key: '456', trace_id: '12345678901234567890123456789088' }, - }), - }); - - context.with(ctx, () => { - const data = getTraceData(); - - expect(data).toEqual({ - 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', - baggage: 'sentry-environment=test-dev,sentry-public_key=456,sentry-trace_id=12345678901234567890123456789088', - }); - }); - }); -}); diff --git a/packages/vercel-edge/src/types.ts b/packages/vercel-edge/src/types.ts index 8aaad22f4226..afb3243fa6f4 100644 --- a/packages/vercel-edge/src/types.ts +++ b/packages/vercel-edge/src/types.ts @@ -43,10 +43,8 @@ export interface BaseVercelEdgeOptions { /** * If this is set to true, the SDK will not set up OpenTelemetry automatically. * In this case, you _have_ to ensure to set it up correctly yourself, including: - * * The `SentrySpanProcessor` + * * The `SentryTracerProvider` * * The `SentryPropagator` - * * The `SentryContextManager` - * * The `SentrySampler` */ skipOpenTelemetrySetup?: boolean; From 302be747b3ac67c02fcd9c8d7d64db18f29a3127 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 31 Jul 2026 14:23:04 +0200 Subject: [PATCH 11/11] fixes for edge nextjs --- packages/opentelemetry/src/propagator.ts | 32 ++++++++++++++++--- .../test/middlewareTraceIsolation.test.ts | 12 ++++--- 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index e33a6206f376..111e1a998f8f 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -2,8 +2,10 @@ import type { Context, SpanContext, TextMapGetter, TextMapPropagator, TextMapSet import { context, trace, TraceFlags } from '@opentelemetry/api'; import type { continueTrace, DynamicSamplingContext } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, baggageHeaderToDynamicSamplingContext, consoleSandbox, + generateTraceId, getClient, getCurrentScope, getIsolationScope, @@ -122,16 +124,36 @@ export function continueTraceAsRemoteSpan( * so it can be used to implement an OpenTelemetry propagator's `extract`. */ function getContextWithRemoteActiveSpanAndScopes(ctx: Context, options: Parameters[0]): Context { - return ensureScopesOnContext(getContextWithRemoteActiveSpan(ctx, options)); + const ctxWithRemoteSpan = getContextWithRemoteActiveSpan(ctx, options); + // If a remote active span was set, we are continuing an incoming trace, so the trace id is fixed. + // Otherwise there was no (valid) incoming trace and we are the head of a new trace. + const isContinuingTrace = trace.getSpanContext(ctxWithRemoteSpan) !== undefined; + return ensureScopesOnContext(ctxWithRemoteSpan, isContinuingTrace); } -function ensureScopesOnContext(ctx: Context): Context { +function ensureScopesOnContext(ctx: Context, isContinuingTrace: boolean): Context { // If there are no scopes yet on the context, ensure we have them const scopes = getScopesFromContext(ctx); + + // If we have no scope here, this is most likely either the root context or a context manually derived from it + // In this case, we want to fork the current scope, to ensure we do not pollute the root scope + const scope = scopes ? scopes.scope : getCurrentScope().clone(); + + // When we forked a fresh scope and are not continuing an incoming trace, we give it its own trace. + // Without this, concurrent header-less requests (e.g. edge middleware, whose root span is created by + // upstream OTEL instrumentation rather than through `continueTrace`) would all inherit the forked + // scope's trace id and collapse into a single trace. Mirrors `continueTrace` in the core HTTP server. + if (!scopes && !isContinuingTrace) { + const propagationContext = scope.getPropagationContext(); + scope.setPropagationContext({ + ...propagationContext, + traceId: generateTraceId(), + sampleRand: _INTERNAL_safeMathRandom(), + }); + } + const newScopes = { - // If we have no scope here, this is most likely either the root context or a context manually derived from it - // In this case, we want to fork the current scope, to ensure we do not pollute the root scope - scope: scopes ? scopes.scope : getCurrentScope().clone(), + scope, isolationScope: scopes ? scopes.isolationScope : getIsolationScope(), }; diff --git a/packages/vercel-edge/test/middlewareTraceIsolation.test.ts b/packages/vercel-edge/test/middlewareTraceIsolation.test.ts index 0338de190a0a..f271d82669cc 100644 --- a/packages/vercel-edge/test/middlewareTraceIsolation.test.ts +++ b/packages/vercel-edge/test/middlewareTraceIsolation.test.ts @@ -66,11 +66,13 @@ async function runMiddlewareRequest( delay = 0, ): Promise<{ traceId: string; childTraceId: string }> { const request = new Request(url, { method: 'GET', headers }); - // Mirror `wrapMiddlewareWithSentry`: fork an isolation scope per request, then run Next's - // `withPropagatedContext` + `Middleware.execute` trace inside it. - return withIsolationScope(() => - withPropagatedContext(request.headers, () => - nextMiddlewareTrace(new URL(url).pathname, async () => { + // Faithfully mirror production ordering: Next's native OTEL creates the `Middleware.execute` root span + // (via `withPropagatedContext` -> `trace`) BEFORE `wrapMiddlewareWithSentry` forks its isolation scope. + // So the root span's trace id is fixed by `extract` (SentryPropagator), not by the later fork - the fork + // is nested *inside* the span callback, exactly like `wrapMiddlewareWithSentry`. + return withPropagatedContext(request.headers, () => + nextMiddlewareTrace(new URL(url).pathname, () => + withIsolationScope(async () => { const rootSpan = trace.getActiveSpan()!; const traceId = spanToJSON(rootSpan).trace_id!;