From 9270e7321841169bc534cfcfd5d8307747f97b12 Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Thu, 16 Jul 2026 13:46:27 +0300 Subject: [PATCH 01/54] fix(core): Prevent infinite recursion when console is instrumented twice (#21959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevent infinite recursion when console is instrumented twice `instrumentConsole()` could run more than once against the same module state — e.g. a bundler-duplicated `@sentry/core` (common in React Native) or repeated instrumentation. Because the wrapper resolves the original method dynamically rather than from its closure, a second wrap made `originalConsoleMethods[level]` point at a Sentry wrapper. From then on every `console.*` call (and every `consoleSandbox` restore) re-entered the wrapper, recursing until the stack overflowed. --------- Co-authored-by: Claude Opus 4.8 --- packages/core/src/instrument/console.ts | 19 ++- .../core/test/lib/instrument/console.test.ts | 108 ++++++++++++------ 2 files changed, 92 insertions(+), 35 deletions(-) diff --git a/packages/core/src/instrument/console.ts b/packages/core/src/instrument/console.ts index 5cfef86732c4..3a797a25908a 100644 --- a/packages/core/src/instrument/console.ts +++ b/packages/core/src/instrument/console.ts @@ -51,16 +51,31 @@ export function _INTERNAL_resetConsoleInstrumentationOptions(): void { _filter.clear(); } -function instrumentConsole(): void { +// The console levels *this* copy of the SDK has already wrapped. A copy must wrap each level at +// most once: re-wrapping overwrites `originalConsoleMethods[level]` with a function that itself +// reads `originalConsoleMethods[level]`, so calling through to the "original" (here and in +// `consoleSandbox`) re-enters a wrapper indefinitely, ending in a `RangeError: Maximum call stack +// size exceeded`. This can happen e.g. in React Native when instrumentation state is re-initialized +// while the console stays wrapped. See getsentry/sentry-react-native SDK-CRASHES-REACT-NATIVE-5HZ. +// We intentionally track this per copy rather than checking for the shared `__sentry_original__` +// marker: a *different* SDK copy's wrapper (e.g. separate CDN bundles that each inline +// `@sentry/core`) is safe to wrap since its call chain resolves down to the native method, and +// skipping it would silently drop that copy's console handlers (breadcrumbs, `captureConsole`). +const instrumentedLevels = new Set(); + +/** Only exported for tests. */ +export function instrumentConsole(): void { if (!('console' in GLOBAL_OBJ)) { return; } CONSOLE_LEVELS.forEach(function (level: ConsoleLevel): void { - if (!(level in GLOBAL_OBJ.console)) { + if (instrumentedLevels.has(level) || !(level in GLOBAL_OBJ.console)) { return; } + instrumentedLevels.add(level); + fill(GLOBAL_OBJ.console, level, function (originalConsoleMethod: () => any): Function { originalConsoleMethods[level] = originalConsoleMethod; diff --git a/packages/core/test/lib/instrument/console.test.ts b/packages/core/test/lib/instrument/console.test.ts index f67f7da1a1a6..4b65af9c73f1 100644 --- a/packages/core/test/lib/instrument/console.test.ts +++ b/packages/core/test/lib/instrument/console.test.ts @@ -1,19 +1,45 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - _INTERNAL_resetConsoleInstrumentationOptions, - addConsoleInstrumentationFilter, - addConsoleInstrumentationHandler, -} from '../../../src/instrument/console'; +import { CONSOLE_LEVELS } from '../../../src/utils/debug-logger'; import { GLOBAL_OBJ } from '../../../src/utils/worldwide'; -import { debug, originalConsoleMethods } from '../../../src/utils/debug-logger'; -import { resetInstrumentationHandlers } from '../../../src/instrument/handlers'; + +// Capture the pristine console methods once, before any instrumentation wraps them. Each test gets a +// fresh copy of the instrumentation modules (and therefore fresh module-scoped state such as +// `instrumentedLevels`) via `vi.resetModules()`, but the global console object is shared across the +// module registry, so we must also restore it between tests - otherwise a fresh `instrumentConsole()` +// would wrap an already-wrapped method. +const nativeConsoleMethods = Object.fromEntries( + CONSOLE_LEVELS.map(level => [level, GLOBAL_OBJ.console[level]]), +) as Record; + +function restoreNativeConsole(): void { + for (const level of CONSOLE_LEVELS) { + GLOBAL_OBJ.console[level] = nativeConsoleMethods[level] as (typeof GLOBAL_OBJ.console)[typeof level]; + } +} + +// Re-import the instrumentation modules so each test gets fresh module-scoped state (e.g. +// `instrumentedLevels`). `vi.resetModules()` does not reset the shared global console, so we also +// restore the native methods first, ensuring a fresh `instrumentConsole()` never wraps an +// already-wrapped method. +async function loadInstrumentationModules() { + vi.resetModules(); + restoreNativeConsole(); + return { + consoleModule: await import('../../../src/instrument/console'), + debugLoggerModule: await import('../../../src/utils/debug-logger'), + }; +} describe('addConsoleInstrumentationHandler', () => { - let _originalConsoleMethods: typeof originalConsoleMethods = {}; + let consoleModule: Awaited>['consoleModule']; + let debugLoggerModule: Awaited>['debugLoggerModule']; + + beforeEach(async () => { + ({ consoleModule, debugLoggerModule } = await loadInstrumentationModules()); + }); afterEach(() => { - Object.assign(originalConsoleMethods, _originalConsoleMethods); - resetInstrumentationHandlers(); + restoreNativeConsole(); vi.restoreAllMocks(); }); @@ -21,11 +47,7 @@ describe('addConsoleInstrumentationHandler', () => { // Due to `fill` being called // So instead, we need to call this each time after calling `addConsoleInstrumentationHandler` function mockConsoleMethods() { - // Re-store this with the current implementation - Object.assign(_originalConsoleMethods, originalConsoleMethods); - - // Overwrite with mock console methods - Object.assign(originalConsoleMethods, { + Object.assign(debugLoggerModule.originalConsoleMethods, { log: vi.fn(), warn: vi.fn(), error: vi.fn(), @@ -38,72 +60,92 @@ describe('addConsoleInstrumentationHandler', () => { 'calls registered handler when console.%s is called', level => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationHandler(handler); mockConsoleMethods(); GLOBAL_OBJ.console[level]('test message'); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['test message'], level })); - expect(originalConsoleMethods[level]).toHaveBeenCalledWith('test message'); + expect(debugLoggerModule.originalConsoleMethods[level]).toHaveBeenCalledWith('test message'); }, ); it('calls through to the underlying console method without throwing', () => { - addConsoleInstrumentationHandler(vi.fn()); + consoleModule.addConsoleInstrumentationHandler(vi.fn()); mockConsoleMethods(); expect(() => GLOBAL_OBJ.console.log('hello')).not.toThrow(); }); - describe('filter', () => { - afterEach(() => { - _INTERNAL_resetConsoleInstrumentationOptions(); - }); + it('does not recurse infinitely when the same SDK copy re-instruments an already-wrapped console', () => { + // Re-running `instrumentConsole()` within the same copy of `@sentry/core` (e.g. instrumentation + // state re-initialized in React Native while the console stays wrapped) must not re-wrap our own + // wrapper. Otherwise the second pass stores that wrapper into `originalConsoleMethods[level]`, + // which the wrapper reads on every call, so calling through to the "original" re-enters the + // wrapper forever -> `RangeError: Maximum call stack size exceeded`. + const handler = vi.fn(); + consoleModule.addConsoleInstrumentationHandler(handler); + mockConsoleMethods(); + + const wrapperAfterFirstPass = GLOBAL_OBJ.console.error; + + // Force a second instrumentation pass on the same copy to mimic re-initialized state. + consoleModule.instrumentConsole(); + + // The second pass must be a no-op: our own wrapper is left in place (identity unchanged), + // so `originalConsoleMethods[level]` keeps pointing at the real method rather than a wrapper. + expect(GLOBAL_OBJ.console.error).toBe(wrapperAfterFirstPass); + expect(() => GLOBAL_OBJ.console.error('boom')).not.toThrow(); + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ args: ['boom'], level: 'error' })); + expect(debugLoggerModule.originalConsoleMethods.error).toHaveBeenCalledWith('boom'); + }); + + describe('filter', () => { describe('when debug is disabled', () => { beforeEach(() => { - vi.spyOn(debug, 'isEnabled').mockImplementation(() => false); + vi.spyOn(debugLoggerModule.debug, 'isEnabled').mockImplementation(() => false); }); it('filters out messages that match the filter', () => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); - addConsoleInstrumentationFilter(['test message']); + consoleModule.addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationFilter(['test message']); mockConsoleMethods(); GLOBAL_OBJ.console.log('test message'); - expect(originalConsoleMethods.log).not.toHaveBeenCalledWith('test message'); + expect(debugLoggerModule.originalConsoleMethods.log).not.toHaveBeenCalledWith('test message'); expect(handler).not.toHaveBeenCalled(); }); it('does not filter out messages that do not match the filter', () => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); - addConsoleInstrumentationFilter(['test message']); + consoleModule.addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationFilter(['test message']); mockConsoleMethods(); GLOBAL_OBJ.console.log('other message'); expect(handler).toHaveBeenCalled(); - expect(originalConsoleMethods.log).toHaveBeenCalledWith('other message'); + expect(debugLoggerModule.originalConsoleMethods.log).toHaveBeenCalledWith('other message'); }); }); describe('when debug is enabled', () => { beforeEach(() => { - vi.spyOn(debug, 'isEnabled').mockImplementation(() => true); + vi.spyOn(debugLoggerModule.debug, 'isEnabled').mockImplementation(() => true); }); it('logs filtered messages but does not call the handler for them', () => { const handler = vi.fn(); - addConsoleInstrumentationHandler(handler); - addConsoleInstrumentationFilter(['test message']); + consoleModule.addConsoleInstrumentationHandler(handler); + consoleModule.addConsoleInstrumentationFilter(['test message']); mockConsoleMethods(); GLOBAL_OBJ.console.log('test message'); expect(handler).not.toHaveBeenCalled(); - expect(originalConsoleMethods.log).toHaveBeenCalledWith('test message'); + expect(debugLoggerModule.originalConsoleMethods.log).toHaveBeenCalledWith('test message'); }); }); }); From c487109777d623725c0e86e3e94a7b3768a86b41 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Thu, 16 Jul 2026 12:47:06 +0200 Subject: [PATCH 02/54] test(cloudflare): Add tests for `ignoreSpans` with continued traces (#22316) Node and browser required some fixes that also trickled down to clouflare. But luckily no further fixes, so this PR only adds tests. closes #22262 --- .../continued-trace-child/index.ts | 25 +++++++++ .../continued-trace-child/test.ts | 51 ++++++++++++++++++ .../continued-trace-child/wrangler.jsonc | 6 +++ .../continued-trace-http-client/index.ts | 22 ++++++++ .../continued-trace-http-client/test.ts | 52 +++++++++++++++++++ .../wrangler.jsonc | 6 +++ .../continued-trace-segment/index.ts | 22 ++++++++ .../continued-trace-segment/test.ts | 32 ++++++++++++ .../continued-trace-segment/wrangler.jsonc | 6 +++ 9 files changed, 222 insertions(+) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts new file mode 100644 index 000000000000..89b990b60865 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/index.ts @@ -0,0 +1,25 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + traceLifecycle: 'stream', + ignoreSpans: ['ignored-child'], + tracePropagationTargets: [env.SERVER_URL], + }), + { + async fetch(_request, env, _ctx) { + await Sentry.startSpan({ name: 'ignored-child' }, async () => { + await fetch(`${env.SERVER_URL}/outgoing`); + }); + + return Response.json({ status: 'ok' }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts new file mode 100644 index 000000000000..d9f587a14ccb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/test.ts @@ -0,0 +1,51 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('preserves a positive sampling decision across an ignored child span', async ({ signal }) => { + const [serverUrl, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + expect(headers['sentry-trace']).toMatch(/^12345678901234567890123456789012-[\da-f]{16}-1$/); + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + + const runner = createRunner(__dirname) + .withServerUrl(serverUrl) + .expect(envelope => { + const container = getSpanContainer(envelope); + const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + const fetchSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.client'); + + expect(serverSpan?.is_segment).toBe(true); + expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); + expect(fetchSpan?.parent_span_id).toBe(serverSpan?.span_id); + expect(container.items.some(item => item.name === 'ignored-child')).toBe(false); + }) + .start(signal); + + try { + const response = await runner.makeRequest<{ status: string }>('get', '/', { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + await runner.completed(); + } finally { + closeTestServer(); + } +}); + +function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc new file mode 100644 index 000000000000..e0705205fde7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-child/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "ignore-spans-streamed-continued-trace-child", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts new file mode 100644 index 000000000000..eee940bc8f3a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/index.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + traceLifecycle: 'stream', + ignoreSpans: [{ attributes: { 'sentry.op': 'http.client' } }], + tracePropagationTargets: [env.SERVER_URL], + }), + { + async fetch(_request, env, _ctx) { + await fetch(`${env.SERVER_URL}/outgoing`); + return Response.json({ status: 'ok' }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts new file mode 100644 index 000000000000..d7dc66316b88 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/test.ts @@ -0,0 +1,52 @@ +import type { Envelope, SerializedStreamedSpanContainer } from '@sentry/core'; +import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('preserves a positive sampling decision when the outgoing fetch span is ignored', async ({ signal }) => { + let outgoingSentryTrace: string | string[] | undefined; + + const [serverUrl, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + outgoingSentryTrace = headers['sentry-trace']; + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + + const runner = createRunner(__dirname) + .withServerUrl(serverUrl) + .expect(envelope => { + const container = getSpanContainer(envelope); + const serverSpan = container.items.find(item => item.attributes[SENTRY_OP]?.value === 'http.server'); + + expect(serverSpan?.is_segment).toBe(true); + expect(serverSpan?.trace_id).toBe('12345678901234567890123456789012'); + expect(outgoingSentryTrace).toBe(`12345678901234567890123456789012-${serverSpan?.span_id}-1`); + expect(container.items.some(item => item.attributes[SENTRY_OP]?.value === 'http.client')).toBe(false); + }) + .start(signal); + + try { + const response = await runner.makeRequest<{ status: string }>('get', '/', { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + await runner.completed(); + } finally { + closeTestServer(); + } +}); + +function getSpanContainer(envelope: Envelope): SerializedStreamedSpanContainer { + const spanItem = envelope[1].find(item => item[0].type === 'span'); + expect(spanItem).toBeDefined(); + return spanItem![1] as SerializedStreamedSpanContainer; +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc new file mode 100644 index 000000000000..bb4a0574d8e4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-http-client/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "ignore-spans-streamed-continued-trace-http-client", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts new file mode 100644 index 000000000000..457c8dce5ed6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/index.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + SERVER_URL: string; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 0, + traceLifecycle: 'stream', + ignoreSpans: [{ op: 'http.server' }], + tracePropagationTargets: [env.SERVER_URL], + }), + { + async fetch(_request, env, _ctx) { + await fetch(`${env.SERVER_URL}/outgoing`); + return Response.json({ status: 'ok' }); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts new file mode 100644 index 000000000000..b912c1cef95a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/test.ts @@ -0,0 +1,32 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('propagates a negative sampling decision when the continued server segment is ignored', async ({ signal }) => { + expect.assertions(3); + + const [serverUrl, closeTestServer] = await createTestServer() + .get('/outgoing', headers => { + expect(headers['sentry-trace']).toMatch(/^12345678901234567890123456789012-[\da-f]{16}-0$/); + expect(headers['baggage']).toBe( + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=false,sentry-public_key=public,sentry-sample_rand=0.5', + ); + }) + .start(); + + const runner = createRunner(__dirname).withServerUrl(serverUrl).start(signal); + + try { + const response = await runner.makeRequest<{ status: string }>('get', '/', { + headers: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-trace_id=12345678901234567890123456789012,sentry-sample_rate=1,sentry-sampled=true,sentry-public_key=public,sentry-sample_rand=0.5', + }, + }); + + expect(response?.status).toBe('ok'); + } finally { + closeTestServer(); + } +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc new file mode 100644 index 000000000000..762349725a01 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/ignoreSpans-streamed/continued-trace-segment/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "ignore-spans-streamed-continued-trace-segment", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} From b9f91c06e327d15b4d7021034cf67ff0a8149655 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 16 Jul 2026 12:06:38 +0100 Subject: [PATCH 03/54] feat(server-utils): Allow passing custom transforms to orchestrion (#22319) --- .../astro-7-orchestrion/package.json | 2 +- .../nuxt-4-orchestrion/package.json | 2 +- .../sveltekit-2-orchestrion/package.json | 2 +- packages/bun/package.json | 2 +- packages/server-utils/package.json | 2 +- .../src/orchestrion/bundler/options.ts | 29 ++++++++++++++++--- .../src/orchestrion/bundler/webpack.ts | 10 +++---- yarn.lock | 8 ++--- 8 files changed, 39 insertions(+), 18 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json index aeb882821dd2..83c12f774095 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json @@ -24,7 +24,7 @@ "mysql": "^2.18.1" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0" + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0" }, "volta": { "node": "22.22.0", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json index fe2f8129165d..0ec5d68db235 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json @@ -27,7 +27,7 @@ "vue-router": "^5.1.0" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json index 63bdd148f9ac..a3cc62e226b4 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json @@ -24,7 +24,7 @@ "mysql": "^2.18.1" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sveltejs/adapter-auto": "^7.0.1", diff --git a/packages/bun/package.json b/packages/bun/package.json index 5a3d5dcacb28..3499021d83a2 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -49,7 +49,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", "@sentry/core": "10.66.0", "@sentry/node": "10.66.0", "@sentry/server-utils": "10.66.0" diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index b9cc17f4f7db..d56b6dd17d1e 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -116,7 +116,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.6.1", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", "@apm-js-collab/code-transformer": "^0.18.0", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index 2fa610d02b51..21257daa8aff 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,12 +1,22 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig, CustomTransform } from '@apm-js-collab/code-transformer'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -import type codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; +import type { CodeTransformerPluginOptions } from '@apm-js-collab/code-transformer-bundler-plugins/core'; export type PluginOptions = { /** * Additional instrumentations to include with the default instrumentation. */ instrumentations?: InstrumentationConfig[]; + /** + * Custom transforms that can be applied using the `transform` option in each `InstrumentationConfig`. + */ + customTransforms?: Record; + /** + * Whether to inject the global diagnostics. + * + * Defaults to `true`. + */ + shouldInjectDiagnostics?: boolean; }; /** @@ -18,9 +28,20 @@ export type PluginOptions = { * bundler path ran (rather than relying on a build-time flag that wouldn't be * visible to the runtime). */ -export function orchestrionTransformOptions(options: PluginOptions): Parameters[0] { +export function orchestrionTransformOptions(options: PluginOptions): CodeTransformerPluginOptions { + const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])]; + const customTransforms = options.customTransforms; + + if (options.shouldInjectDiagnostics === false) { + return { + instrumentations, + customTransforms, + }; + } + return { - instrumentations: [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])], + instrumentations, + customTransforms, injectDiagnostics: (diag: { transformedModules: string[]; failedModules: string[] }) => { return `(globalThis.__SENTRY_ORCHESTRION__=globalThis.__SENTRY_ORCHESTRION__||{}).bundler=${JSON.stringify(diag.transformedModules)};`; }, diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 04eb56a2c6b9..e6ea99b3045c 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -5,6 +5,8 @@ import { createRequire } from 'node:module'; import { dirname } from 'node:path'; import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import { SENTRY_INSTRUMENTATIONS } from '../config'; +import type { PluginOptions } from './options'; +import { orchestrionTransformOptions } from './options'; // Both branches use `createRequire` (never alias the CJS `require`) so bundlers consuming this // module don't emit a "Critical dependency" warning. @@ -42,14 +44,12 @@ export function getSentryInstrumentations(): InstrumentationConfig[] { } /** - * The code-transform webpack plugin, pre-fed the instrumentation config. Unlike the Vite plugin it - * does NOT inject the `__SENTRY_ORCHESTRION__.bundler` marker — that would disable the runtime - * module hook, which externalized packages still need (hybrid setup). + * The code-transform webpack plugin, pre-fed the instrumentation config */ -export function sentryOrchestrionWebpackPlugin(): unknown { +export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): unknown { const mod = getOrchestrionRequire()('@apm-js-collab/code-transformer-bundler-plugins/webpack') as { default?: (options: { instrumentations: InstrumentationConfig[] }) => unknown; }; const codeTransformerWebpack = mod.default ?? (mod as unknown as NonNullable); - return codeTransformerWebpack({ instrumentations: SENTRY_INSTRUMENTATIONS }); + return codeTransformerWebpack(orchestrionTransformOptions(options)); } diff --git a/yarn.lock b/yarn.lock index 7b70dded22ba..bb0c06041615 100644 --- a/yarn.lock +++ b/yarn.lock @@ -404,10 +404,10 @@ dependencies: json-schema-to-ts "^3.1.1" -"@apm-js-collab/code-transformer-bundler-plugins@^0.6.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.6.1.tgz#29bf79167411b8607f02db177250b8f7c16d93e1" - integrity sha512-TDvypsLUk/W202Y1JBE6I2enQalWjbYXKnxpELWEZn6GLoIcGFii69ljh948hdAzKZJ/wg0Uwivz72a/szoOBQ== +"@apm-js-collab/code-transformer-bundler-plugins@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.0.tgz#499cb4730570e4f4bc3a1e77739dc1fb17839a7a" + integrity sha512-huqdbQqE1lTUM2uud/qBY664o5qGPLrI/3ipGRNcTP37otpaVOb6omfIU/8QyR4aObhkpgnFl0ywtoAJZ+y2jw== dependencies: "@apm-js-collab/code-transformer" "^0.18.0" es-module-lexer "^2.1.0" From da1645c8207be30bab3fd48b8c512aacfc4d6dbd Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:26:27 +0200 Subject: [PATCH 04/54] feat(server-utils): Add @opentelemetry/instrumentation-koa orchestrion integration (#22146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `koaChannelIntegration` in `@sentry/server-utils` for injecting orchestrion channels into `koa`. A subscriber wraps each registered layer in a span-creating proxy. Span-helpers are ported from the vendored instrumentation, preserving span names (but adapting to [new conventions](https://github.com/getsentry/sentry-conventions/pull/472)). Also upgraded `@apm-js-collab/tracing-hooks` to get this: https://github.com/apm-js-collab/tracing-hooks/pull/45 to be released (lets us actually patch `koa` - see 1. iteration below).
1. iteration One thing to know for review: **we instrument `koa-compose`, not `use` from koa.** koa's `use` lives in koa's main entry (`lib/application.js`), and transforming a package's main entry forces its top-level `require` chain through Node's `require(esm)` bridge, which throws on Node < 24.13. 1. Orchestrion instruments by rewriting a module's source at load time (via the ESM load hook). 2. `use` lives in koa's main entry (`lib/application.js`), so to instrument it we transform that file. But transforming a main entry pulls its whole top-level `require` chain into the loader's handling --> and that changes how those `require`s are loaded (through the ESM→CommonJS translator, not the normal sync require path). 3. `koa` is CJS (but support ESM). When `import`ing koa, it loads a shim that loads the CJS code: `import 'koa'` → `dist/koa.mjs` (a ESM shim) → `import '../lib/application.js'` (CJS). That CJS entry has a top-level `require('is-generator-function')`. 4. `is-generator-function` → `require('generator-function')`, and `generator-function` points at an `.mjs` file, which in turn imports `./index.js`. --> So the top-level `require` in koa's `application.js` (CJS) becomes `require(esm)` of an ESM file importing a CJS file. 5. On Node < 24.13 (we pin 20.19.5), the loader can't pre-link this dual-package shape into the `require(esm)` cache, so it throws `request for './index.js' is not in cache`. The failing chain: ``` koa/lib/application.js (CJS) └─ require('is-generator-function') (CJS) └─ require('generator-function') → require(esm) → require.mjs (ESM) └─ import './index.js' (ESM importing CJS) ``` `koa-compose` is koa's zero-dependency dispatch engine, so it's safe to transform, and `compose(app.middleware)` sees the same layers `use` would. Since `@koa/router` also calls `compose` per request, the subscriber uses `getActiveSpan()` to only wrap at app startup (no active span) and skip the per-request router composition.
Closes https://github.com/getsentry/sentry-javascript/issues/20758 Linear: https://linear.app/getsentry/issue/JS-2409/rewrite-opentelemetryinstrumentation-koa-to-orchestrion --- .../node-koa/tests/transactions.test.ts | 2 + .../node-integration-tests/package.json | 2 + .../suites/tracing/koa/instrument.mjs | 9 + .../suites/tracing/koa/scenario.mjs | 36 +++ .../suites/tracing/koa/test.ts | 104 +++++++ packages/deno/src/index.ts | 1 + packages/deno/src/integrations/koa.ts | 31 +++ packages/deno/src/sdk.ts | 3 +- .../deno/test/__snapshots__/mod.test.ts.snap | 4 + .../tracing/koa/vendored/utils.ts | 7 +- .../src/integrations/tracing-channel/koa.ts | 262 ++++++++++++++++++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/index.ts | 2 + .../src/orchestrion/config/koa.ts | 13 + .../server-utils/src/orchestrion/index.ts | 4 + yarn.lock | 166 +++++++++-- 16 files changed, 621 insertions(+), 27 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/koa/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/koa/test.ts create mode 100644 packages/deno/src/integrations/koa.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/koa.ts create mode 100644 packages/server-utils/src/orchestrion/config/koa.ts diff --git a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts index 53803a8882e6..f86901e0dee4 100644 --- a/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-koa/tests/transactions.test.ts @@ -65,6 +65,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { { data: { 'koa.name': 'bodyParser', + 'code.function.name': 'bodyParser', 'koa.type': 'middleware', 'sentry.op': 'middleware.koa', 'sentry.origin': 'auto.http.otel.koa', @@ -82,6 +83,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { { data: { 'koa.name': '', + 'code.function.name': '', 'koa.type': 'middleware', 'sentry.origin': 'auto.http.otel.koa', 'sentry.op': 'middleware.koa', diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 83261f911b58..743afeeebce8 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -40,6 +40,7 @@ "@growthbook/growthbook": "^1.6.5", "@hapi/hapi": "^21.3.10", "@hono/node-server": "^1.19.13", + "@koa/router": "^12.0.1", "@langchain/anthropic": "^0.3.10", "@langchain/core": "^0.3.80", "@langchain/openai": "^0.5.0", @@ -76,6 +77,7 @@ "ioredis-5": "npm:ioredis@^5.11.0", "kafkajs": "2.2.4", "knex": "^2.5.1", + "koa": "^2.15.2", "lru-memoizer": "2.3.0", "mongodb": "^3.7.3", "mongodb-memory-server-global": "^11.0.1", diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/koa/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/koa/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs new file mode 100644 index 000000000000..c1c0abc772cb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/koa/scenario.mjs @@ -0,0 +1,36 @@ +import Router from '@koa/router'; +import * as Sentry from '@sentry/node'; +import { sendPortToRunner } from '@sentry-internal/node-integration-tests'; +import Koa from 'koa'; + +const port = 5698; + +const app = new Koa(); + +// Registered first so it wraps every downstream middleware/route in its try/catch. +Sentry.setupKoaErrorHandler(app); + +// Plain middleware -> produces a `middleware.koa` span named after the function. +app.use(async function simpleMiddleware(ctx, next) { + await next(); +}); + +const router = new Router(); + +router.get('/', ctx => { + ctx.body = 'Hello World!'; +}); + +router.get('/test-param/:id', ctx => { + ctx.body = { id: ctx.params.id }; +}); + +router.get('/error', () => { + throw new Error('Sentry Test Error'); +}); + +app.use(router.routes()).use(router.allowedMethods()); + +app.listen(port, () => { + sendPortToRunner(port); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts new file mode 100644 index 000000000000..e3754e233565 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts @@ -0,0 +1,104 @@ +import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('koa auto-instrumentation', () => { + afterAll(async () => { + cleanupChildProcesses(); + }); + + // `createEsmAndCjsTests` auto-runs this suite with orchestrion on CI. The + // orchestrion path keeps span ops/attributes identical to the OTel path; only + // the origin differs to signal the injection mechanism, so we branch on + // `isOrchestrionEnabled()`. + const origin = isOrchestrionEnabled() ? 'auto.http.orchestrion.koa' : 'auto.http.otel.koa'; + + const EXPECTED_ERROR_EVENT = { + exception: { + values: [ + { + type: 'Error', + value: 'Sentry Test Error', + }, + ], + }, + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('should auto-instrument `koa` router and middleware layers.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /', + spans: expect.arrayContaining([ + // Router layer span (from `@koa/router`), carrying the matched route. + expect.objectContaining({ + description: '/', + op: 'router.koa', + origin, + data: expect.objectContaining({ + 'http.route': '/', + 'koa.type': 'router', + 'koa.name': '/', + 'sentry.op': 'router.koa', + 'sentry.origin': origin, + }), + }), + // Plain middleware span. + expect.objectContaining({ + description: 'simpleMiddleware', + op: 'middleware.koa', + origin, + data: expect.objectContaining({ + 'koa.type': 'middleware', + 'koa.name': 'simpleMiddleware', + 'code.function.name': 'simpleMiddleware', + 'sentry.op': 'middleware.koa', + 'sentry.origin': origin, + }), + }), + ]), + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); + + test('should assign a parameterized transaction name.', async () => { + const runner = createRunner() + .expect({ + transaction: { + transaction: 'GET /test-param/:id', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: '/test-param/:id', + op: 'router.koa', + origin, + data: expect.objectContaining({ + 'http.route': '/test-param/:id', + 'koa.type': 'router', + 'koa.name': '/test-param/:id', + 'sentry.op': 'router.koa', + 'sentry.origin': origin, + }), + }), + ]), + }, + }) + .start(); + runner.makeRequest('get', '/test-param/123'); + await runner.completed(); + }); + + test('should capture errors thrown in routes via the koa error handler.', async () => { + const runner = createRunner() + .unordered() + .expect({ transaction: { transaction: 'GET /error' } }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); + }); + }); +}); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 44cfd21ebcb8..54ee7e07b7d9 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -116,6 +116,7 @@ export { denoPostgresIntegration } from './integrations/postgres'; export { denoAmqplibIntegration } from './integrations/amqplib'; export { denoDataloaderIntegration } from './integrations/dataloader'; export { denoKnexIntegration } from './integrations/knex'; +export { denoKoaIntegration } from './integrations/koa'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; export { normalizePathsIntegration } from './integrations/normalizepaths'; diff --git a/packages/deno/src/integrations/koa.ts b/packages/deno/src/integrations/koa.ts new file mode 100644 index 000000000000..7f41fd166c43 --- /dev/null +++ b/packages/deno/src/integrations/koa.ts @@ -0,0 +1,31 @@ +import { koaChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { KoaChannelIntegrationOptions } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoKoa' as const; + +/** + * Create spans for `koa` middleware/router layers under Deno. Requires the + * `@sentry/deno/import` loader. Delegates to the shared subscriber in + * `@sentry/server-utils`, adding Deno's `AsyncLocalStorage` context strategy so + * spans nest under the active HTTP server span. + */ +const _denoKoaIntegration = ((options: KoaChannelIntegrationOptions = {}) => { + const inner = koaChannelIntegration(options); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoKoaIntegration = defineIntegration(_denoKoaIntegration) as ( + options?: KoaChannelIntegrationOptions, +) => Integration & { + name: 'DenoKoa'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index f67b545966de..805b2ab077bf 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -24,6 +24,7 @@ import { import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; import { denoAmqplibIntegration } from './integrations/amqplib'; +import { denoKoaIntegration } from './integrations/koa'; import { denoMysqlIntegration } from './integrations/mysql'; import { denoPostgresIntegration } from './integrations/postgres'; import { denoRedisIntegration } from './integrations/redis'; @@ -62,7 +63,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { // (or in parallel to) loading the SDK, so we only gate on whether the // feature is possible. If they're never loaded, it'll just be a no-op. ...(MODULE_REGISTER_HOOKS_SUPPORTED - ? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration()] + ? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration(), denoKoaIntegration()] : []), contextLinesIntegration(), normalizePathsIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index 3b344fa68eff..d8bf3a760e8c 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -118,6 +118,7 @@ snapshot[`captureException 1`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoKoa", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -196,6 +197,7 @@ snapshot[`captureMessage 1`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoKoa", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -281,6 +283,7 @@ snapshot[`captureMessage twice 1`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoKoa", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -373,6 +376,7 @@ snapshot[`captureMessage twice 2`] = ` "DenoMysql", "DenoPostgres", "DenoAmqplib", + "DenoKoa", "ContextLines", "NormalizePaths", "GlobalHandlers", diff --git a/packages/node/src/integrations/tracing/koa/vendored/utils.ts b/packages/node/src/integrations/tracing/koa/vendored/utils.ts index 1c9d9a8067a9..070aa7eb965d 100644 --- a/packages/node/src/integrations/tracing/koa/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/koa/vendored/utils.ts @@ -11,7 +11,7 @@ import { KoaLayerType, type KoaInstrumentationConfig } from './types'; import type { KoaContext, KoaMiddleware } from './internal-types'; import { AttributeNames } from './enums/AttributeNames'; import type { Attributes } from '@opentelemetry/api'; -import { HTTP_ROUTE } from '@sentry/conventions/attributes'; +import { CODE_FUNCTION_NAME, HTTP_ROUTE } from '@sentry/conventions/attributes'; export const getMiddlewareMetadata = ( context: KoaContext, @@ -25,7 +25,7 @@ export const getMiddlewareMetadata = ( if (isRouter) { return { attributes: { - [AttributeNames.KOA_NAME]: layerPath?.toString(), + [AttributeNames.KOA_NAME]: layerPath?.toString(), // TODO(v11): remove, replaced by http.route [AttributeNames.KOA_TYPE]: KoaLayerType.ROUTER, [HTTP_ROUTE]: layerPath?.toString(), }, @@ -34,8 +34,9 @@ export const getMiddlewareMetadata = ( } else { return { attributes: { - [AttributeNames.KOA_NAME]: layer.name ?? 'middleware', + [AttributeNames.KOA_NAME]: layer.name ?? 'middleware', // TODO(v11): remove, replaced by code.function.name [AttributeNames.KOA_TYPE]: KoaLayerType.MIDDLEWARE, + [CODE_FUNCTION_NAME]: layer.name ?? 'middleware', }, name: `middleware - ${layer.name}`, }; diff --git a/packages/server-utils/src/integrations/tracing-channel/koa.ts b/packages/server-utils/src/integrations/tracing-channel/koa.ts new file mode 100644 index 000000000000..982faee9765e --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/koa.ts @@ -0,0 +1,262 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + getDefaultIsolationScope, + getIsolationScope, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanToJSON, + startSpan, +} from '@sentry/core'; +// oxlint-disable-next-line typescript/no-deprecated +import { CODE_FUNCTION_NAME, HTTP_ROUTE, KOA_NAME, KOA_TYPE } from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +// Same name as the OTel integration. When enabled, the OTel 'Koa' integration is omitted from the default set. +const INTEGRATION_NAME = 'Koa' as const; + +const ORIGIN = 'auto.http.orchestrion.koa'; + +const LAYER_TYPE = { + ROUTER: 'router', + MIDDLEWARE: 'middleware', +} as const; +type KoaLayerType = (typeof LAYER_TYPE)[keyof typeof LAYER_TYPE]; + +// Keeps wrapping idempotent — the same middleware instance can be registered on +// multiple routes (mirrors the vendored OTel instrumentation's symbol). +const kLayerPatched: unique symbol = Symbol('sentry.koa.layer-patched'); + +// Core dedupes `setupOnce` by integration name, but the Deno SDK also runs this +// under the name `DenoKoa` (via `extendIntegration`), so guard against a second +// subscription here. +let subscribed = false; + +type Next = () => Promise; + +interface KoaContext { + [key: string]: unknown; + _matchedRoute?: string | RegExp; + _matchedRouteName?: string; + request?: { method?: string }; +} + +interface Layer { + path: string | RegExp; + stack: KoaMiddleware[]; +} + +interface Router { + stack: Layer[]; +} + +type KoaMiddleware = ((context: KoaContext, next: Next) => unknown) & { + router?: Router; + [kLayerPatched]?: boolean; +}; + +type KoaSpanAttributes = Record; + +interface KoaMiddlewareMetadata { + attributes: KoaSpanAttributes; + name: string; +} + +interface KoaUseContext { + arguments: unknown[]; +} + +export interface KoaChannelIntegrationOptions { + /** Ignore layers of the specified types (`'middleware'` and/or `'router'`). */ + ignoreLayersType?: Array<'middleware' | 'router'>; +} + +const _koaChannelIntegration = ((options: KoaChannelIntegrationOptions = {}) => { + const ignoreLayersType = options.ignoreLayersType ?? []; + + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel || subscribed) { + return; + } + subscribed = true; + DEBUG_BUILD && debug.log(`[orchestrion:koa] subscribing to channel "${CHANNELS.KOA_USE}"`); + + diagnosticsChannel.tracingChannel(CHANNELS.KOA_USE).subscribe({ + start(rawCtx) { + handleUse(rawCtx as KoaUseContext, ignoreLayersType); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); + }, + }; +}) satisfies IntegrationFn; + +function handleUse(ctx: KoaUseContext, ignoreLayersType: KoaLayerType[]): void { + const middleware = ctx.arguments[0]; + if (typeof middleware === 'function') { + ctx.arguments[0] = patchUse(middleware as KoaMiddleware, ignoreLayersType); + } +} + +/** + * Wrap a registered koa middleware. A `@koa/router` dispatch layer + * (`router.routes()`) exposes a `.router`, in which case we patch each routed + * middleware in its stack (in place) and leave the dispatch itself unwrapped; + * everything else is a plain middleware. + */ +function patchUse(middleware: KoaMiddleware, ignoreLayersType: KoaLayerType[]): KoaMiddleware { + return middleware.router + ? patchRouterDispatch(middleware, ignoreLayersType) + : patchLayer(middleware, false, ignoreLayersType); +} + +/** + * Patches the dispatch function used by `@koa/router`, wrapping each routed + * middleware in the router's stack so routed spans carry their matched path. + */ +function patchRouterDispatch(dispatchLayer: KoaMiddleware, ignoreLayersType: KoaLayerType[]): KoaMiddleware { + const router = dispatchLayer.router; + const routesStack = router?.stack ?? []; + for (const pathLayer of routesStack) { + const path = pathLayer.path; + const pathStack = pathLayer.stack; + pathStack.forEach((routedMiddleware, j) => { + pathStack[j] = patchLayer(routedMiddleware, true, ignoreLayersType, path); + }); + } + return dispatchLayer; +} + +/** + * Wraps an individual middleware layer so it opens a span when invoked. No span + * is created when there is no active (parent) span, matching the vendored OTel + * instrumentation's `api.trace.getSpan(api.context.active())` guard. + */ +function patchLayer( + middlewareLayer: KoaMiddleware, + isRouter: boolean, + ignoreLayersType: KoaLayerType[], + layerPath?: string | RegExp, +): KoaMiddleware { + const layerType = isRouter ? LAYER_TYPE.ROUTER : LAYER_TYPE.MIDDLEWARE; + // Skip patching the layer if it's ignored by config or already wrapped. + if (middlewareLayer[kLayerPatched] === true || ignoreLayersType.includes(layerType)) { + return middlewareLayer; + } + + if ( + middlewareLayer.constructor.name === 'GeneratorFunction' || + middlewareLayer.constructor.name === 'AsyncGeneratorFunction' + ) { + return middlewareLayer; + } + + middlewareLayer[kLayerPatched] = true; + + return (context: KoaContext, next: Next) => { + if (!getActiveSpan()) { + return middlewareLayer(context, next); + } + const metadata = getMiddlewareMetadata(context, middlewareLayer, isRouter, layerPath); + + if (context._matchedRoute) { + setHttpServerSpanRouteAttribute(context._matchedRoute.toString()); + } + + // oxlint-disable-next-line typescript/no-deprecated + const koaName = metadata.attributes[KOA_NAME]; + // Somehow, name is sometimes `''` for middleware spans. + // See: https://github.com/open-telemetry/opentelemetry-js-contrib/issues/2220 + const name = typeof koaName === 'string' ? koaName || '< unknown >' : metadata.name; + + return startSpan( + { + name, + op: `${layerType}.koa`, + attributes: { + ...metadata.attributes, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }, + () => { + const route = metadata.attributes[HTTP_ROUTE]; + if (getIsolationScope() === getDefaultIsolationScope()) { + DEBUG_BUILD && debug.warn('Isolation scope is default isolation scope - skipping setting transactionName'); + } else if (route) { + const method = context.request?.method?.toUpperCase() || 'GET'; + getIsolationScope().setTransactionName(`${method} ${route}`); + } + return middlewareLayer(context, next); + }, + ); + }; +} + +function getMiddlewareMetadata( + context: KoaContext, + layer: KoaMiddleware, + isRouter: boolean, + layerPath?: string | RegExp, +): KoaMiddlewareMetadata { + if (isRouter) { + return { + attributes: { + // oxlint-disable-next-line typescript/no-deprecated + [KOA_NAME]: layerPath?.toString(), // TODO(v11): remove, replaced by http.route + [KOA_TYPE]: LAYER_TYPE.ROUTER, + [HTTP_ROUTE]: layerPath?.toString(), + }, + name: context._matchedRouteName || `router - ${layerPath}`, + }; + } + return { + attributes: { + // oxlint-disable-next-line typescript/no-deprecated + [KOA_NAME]: layer.name || 'middleware', // TODO(v11): remove, replaced by code.function.name + [KOA_TYPE]: LAYER_TYPE.MIDDLEWARE, + [CODE_FUNCTION_NAME]: layer.name || 'middleware', + }, + name: `middleware - ${layer.name}`, + }; +} + +/** + * Set the `http.route` attribute on the root HTTP server span for the current trace. + * + * No-op when there is no active span, no root span, or the root span is not an + * `http.server` span — so this can be called unconditionally without risking + * attribute pollution on non-HTTP root spans. + */ +function setHttpServerSpanRouteAttribute(route: string): void { + const activeSpan = getActiveSpan(); + if (!activeSpan) { + return; + } + const rootSpan = getRootSpan(activeSpan); + if (!rootSpan) { + return; + } + if (spanToJSON(rootSpan).data[SEMANTIC_ATTRIBUTE_SENTRY_OP] !== 'http.server') { + return; + } + rootSpan.setAttribute(HTTP_ROUTE, route); +} + +/** + * EXPERIMENTAL — orchestrion-driven koa integration. Subscribes to the + * `orchestrion:koa:use` channel injected into `Application.prototype.use` and + * wraps each registered middleware/router layer in a span-creating proxy. + * Requires the orchestrion runtime hook or bundler plugin. + */ +export const koaChannelIntegration = defineIntegration(_koaChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 83413cfa0d34..258b0fdc589d 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -10,6 +10,7 @@ import { hapiChannels } from './config/hapi'; import { ioredisChannels } from './config/ioredis'; import { kafkajsChannels } from './config/kafkajs'; import { knexChannels } from './config/knex'; +import { koaChannels } from './config/koa'; import { langchainChannels } from './config/langchain'; import { langgraphChannels } from './config/langgraph'; import { lruMemoizerChannels } from './config/lru-memoizer'; @@ -57,6 +58,7 @@ export const CHANNELS = { ...ioredisChannels, ...kafkajsChannels, ...knexChannels, + ...koaChannels, ...langchainChannels, ...langgraphChannels, ...lruMemoizerChannels, diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index d8f1c15bad6c..b507f6ffc180 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -13,6 +13,7 @@ import { hapiConfig } from './hapi'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; import { knexConfig } from './knex'; +import { koaConfig } from './koa'; import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; @@ -53,6 +54,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...ioredisConfig, ...kafkajsConfig, ...knexConfig, + ...koaConfig, ...langchainConfig, ...langgraphConfig, ...lruMemoizerConfig, diff --git a/packages/server-utils/src/orchestrion/config/koa.ts b/packages/server-utils/src/orchestrion/config/koa.ts new file mode 100644 index 000000000000..b0d8ad516a37 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/koa.ts @@ -0,0 +1,13 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +export const koaConfig = [ + { + channelName: 'use', + module: { name: 'koa', versionRange: '>=2.0.0 <4', filePath: 'lib/application.js' }, + functionQuery: { className: 'Application', methodName: 'use', kind: 'Sync' }, + }, +] satisfies InstrumentationConfig[]; + +export const koaChannels = { + KOA_USE: 'orchestrion:koa:use', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index cf0302556d6a..e36c9360dc51 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -8,6 +8,7 @@ import { graphqlDiagnosticsChannelIntegration, } from '../integrations/tracing-channel/graphql'; import { hapiChannelIntegration } from '../integrations/tracing-channel/hapi'; +import { koaChannelIntegration } from '../integrations/tracing-channel/koa'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; @@ -32,6 +33,7 @@ export { googleGenAIChannelIntegration, graphqlChannelIntegration, hapiChannelIntegration, + koaChannelIntegration, ioredisChannelIntegration, kafkajsChannelIntegration, knexChannelIntegration, @@ -44,6 +46,7 @@ export { vercelAiChannelIntegration, expressChannelIntegration, }; +export type { KoaChannelIntegrationOptions } from '../integrations/tracing-channel/koa'; export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../integrations/tracing-channel/ioredis'; export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; @@ -88,6 +91,7 @@ export const channelIntegrations = { vercelAiIntegration: vercelAiChannelIntegration, amqplibIntegration: amqplibChannelIntegration, hapiIntegration: hapiChannelIntegration, + koaIntegration: koaChannelIntegration, expressIntegration: expressChannelIntegration, graphqlIntegration: graphqlDiagnosticsChannelIntegration, kafkajsIntegration: kafkajsChannelIntegration, diff --git a/yarn.lock b/yarn.lock index bb0c06041615..34ff8cd92cec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5055,6 +5055,17 @@ resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-5.7.0.tgz#526d437b07cbb41e28df34d487cbfccbe730185b" integrity sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg== +"@koa/router@^12.0.1": + version "12.0.2" + resolved "https://registry.yarnpkg.com/@koa/router/-/router-12.0.2.tgz#286d51959ed611255faa944818a112e35567835a" + integrity sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA== + dependencies: + debug "^4.3.4" + http-errors "^2.0.0" + koa-compose "^4.1.0" + methods "^1.1.2" + path-to-regexp "^6.3.0" + "@kwsites/file-exists@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@kwsites/file-exists/-/file-exists-1.1.1.tgz#ad1efcac13e1987d8dbaf235ef3be5b0d96faa99" @@ -10440,6 +10451,14 @@ abstract-logging@^2.0.1: resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839" integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== +accepts@^1.3.5, accepts@~1.3.4, accepts@~1.3.8: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + accepts@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" @@ -10448,14 +10467,6 @@ accepts@^2.0.0: mime-types "^3.0.0" negotiator "^1.0.0" -accepts@~1.3.4, accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - acorn-globals@^7.0.0: version "7.0.1" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3" @@ -12575,6 +12586,14 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" +cache-content-type@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/cache-content-type/-/cache-content-type-1.0.1.tgz#035cde2b08ee2129f4a8315ea8f00a00dba1453c" + integrity sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA== + dependencies: + mime-types "^2.1.18" + ylru "^1.2.0" + calculate-cache-key-for-tree@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/calculate-cache-key-for-tree/-/calculate-cache-key-for-tree-2.0.0.tgz#7ac57f149a4188eacb0a45b210689215d3fef8d6" @@ -13020,6 +13039,11 @@ cluster-key-slot@1.1.2, cluster-key-slot@^1.1.0: resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + code-red@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/code-red/-/code-red-1.0.4.tgz#59ba5c9d1d320a4ef795bc10a28bd42bfebe3e35" @@ -13366,14 +13390,14 @@ content-disposition@^1.0.0: resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b" integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== -content-disposition@~0.5.4: +content-disposition@~0.5.2, content-disposition@~0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== dependencies: safe-buffer "5.2.1" -content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.4, content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -13440,6 +13464,14 @@ cookie@^1.0.1, cookie@^1.0.2, cookie@^1.1.1: resolved "https://registry.yarnpkg.com/cookie/-/cookie-1.1.1.tgz#3bb9bdfc82369db9c2f69c93c9c3ceb310c88b3c" integrity sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ== +cookies@~0.9.0: + version "0.9.1" + resolved "https://registry.yarnpkg.com/cookies/-/cookies-0.9.1.tgz#3ffed6f60bb4fb5f146feeedba50acc418af67e3" + integrity sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw== + dependencies: + depd "~2.0.0" + keygrip "~1.1.0" + copy-anything@^2.0.1: version "2.0.6" resolved "https://registry.yarnpkg.com/copy-anything/-/copy-anything-2.0.6.tgz#092454ea9584a7b7ad5573062b2a87f5900fc480" @@ -14001,6 +14033,11 @@ deep-equal@^2.0.5: which-collection "^1.0.1" which-typed-array "^1.1.13" +deep-equal@~1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" + integrity sha512-bHtC0iYvWhyaTzvV3CZgPeZQqCOBGyGsVV7v4eevpdkLHfiSrXUdBG+qAuSz4RI70sszvjQ1QSZ98An1yNwpSw== + deep-extend@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" @@ -14168,7 +14205,7 @@ destr@^2.0.3, destr@^2.0.5: resolved "https://registry.yarnpkg.com/destr/-/destr-2.0.5.tgz#7d112ff1b925fb8d2079fac5bdb4a90973b51fdb" integrity sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA== -destroy@1.2.0, destroy@~1.2.0: +destroy@1.2.0, destroy@^1.0.4, destroy@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== @@ -15171,16 +15208,16 @@ enabled@2.0.x: resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== +encodeurl@^1.0.2, encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= + encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= - encoding@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" @@ -18446,6 +18483,14 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" +http-assert@^1.3.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f" + integrity sha512-uPpH7OKX4H25hBmU6G1jWNaqJGpTXxey+YOUizJUAgu0AjLUeC8D73hTrhvDS5D+GJN1DN1+hhc/eF/wpxtp0w== + dependencies: + deep-equal "~1.0.1" + http-errors "~1.8.0" + http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" @@ -18456,6 +18501,17 @@ http-deceiver@^1.2.7: resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc= +http-errors@^1.6.3, http-errors@~1.8.0: + version "1.8.1" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" + integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== + dependencies: + depd "~1.1.2" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses ">= 1.5.0 < 2" + toidentifier "1.0.1" + http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.0, http-errors@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" @@ -19969,6 +20025,13 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" +keygrip@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226" + integrity sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ== + dependencies: + tsscmp "1.0.6" + kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -20033,6 +20096,48 @@ knitwork@^1.2.0, knitwork@^1.3.0: resolved "https://registry.yarnpkg.com/knitwork/-/knitwork-1.3.0.tgz#4a0d0b0d45378cac909ee1117481392522bd08a4" integrity sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw== +koa-compose@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/koa-compose/-/koa-compose-4.1.0.tgz#507306b9371901db41121c812e923d0d67d3e877" + integrity sha512-8ODW8TrDuMYvXRwra/Kh7/rJo9BtOfPc6qO8eAfC80CnCvSjSl0bkRM24X6/XBBEyj0v1nRUQ1LyOy3dbqOWXw== + +koa-convert@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/koa-convert/-/koa-convert-2.0.0.tgz#86a0c44d81d40551bae22fee6709904573eea4f5" + integrity sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA== + dependencies: + co "^4.6.0" + koa-compose "^4.1.0" + +koa@^2.15.2: + version "2.16.4" + resolved "https://registry.yarnpkg.com/koa/-/koa-2.16.4.tgz#303b996f5c3f2a3bb771c7db5e4303ee05f2265f" + integrity sha512-3An0GCLDSR34tsCO4H8Tef8Pp2ngtaZDAZnsWJYelqXUK5wyiHvGItgK/xcSkmHLSTn1Jcho1mRQs2ehRzvKKw== + dependencies: + accepts "^1.3.5" + cache-content-type "^1.0.0" + content-disposition "~0.5.2" + content-type "^1.0.4" + cookies "~0.9.0" + debug "^4.3.2" + delegates "^1.0.0" + depd "^2.0.0" + destroy "^1.0.4" + encodeurl "^1.0.2" + escape-html "^1.0.3" + fresh "~0.5.2" + http-assert "^1.3.0" + http-errors "^1.6.3" + is-generator-function "^1.0.7" + koa-compose "^4.1.0" + koa-convert "^2.0.0" + on-finished "^2.3.0" + only "~0.0.2" + parseurl "^1.3.2" + statuses "^1.5.0" + type-is "^1.6.16" + vary "^1.1.2" + kubernetes-types@^1.30.0: version "1.30.0" resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116" @@ -21110,7 +21215,7 @@ meriyah@^6.1.4: resolved "https://registry.yarnpkg.com/meriyah/-/meriyah-6.1.4.tgz#2d49a8934fbcd9205c20564579c3560d9b1e077b" integrity sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ== -methods@~1.1.2: +methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= @@ -23109,7 +23214,7 @@ on-exit-leak-free@^2.1.0: resolved "https://registry.yarnpkg.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz#fed195c9ebddb7d9e4c3842f93f281ac8dadd3b8" integrity sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA== -on-finished@^2.4.1, on-finished@~2.4.1: +on-finished@^2.3.0, on-finished@^2.4.1, on-finished@~2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== @@ -23177,6 +23282,11 @@ oniguruma-to-es@^2.2.0: regex "^5.1.1" regex-recursion "^5.1.1" +only@~0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" + integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== + open@8.4.0: version "8.4.0" resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8" @@ -23725,7 +23835,7 @@ parse5@^7.0.0, parse5@^7.1.2: dependencies: entities "^4.4.0" -parseurl@^1.3.3, parseurl@~1.3.2, parseurl@~1.3.3: +parseurl@^1.3.2, parseurl@^1.3.3, parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -23826,7 +23936,7 @@ path-to-regexp@3.3.0: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== -path-to-regexp@6.3.0, path-to-regexp@^6.2.1: +path-to-regexp@6.3.0, path-to-regexp@^6.2.1, path-to-regexp@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz#2b6a26a337737a8e1416f9272ed0766b1c0389f4" integrity sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ== @@ -27633,7 +27743,7 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"statuses@>= 1.4.0 < 2", statuses@~1.5.0: +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0, statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= @@ -28646,7 +28756,7 @@ toad-cache@^3.7.0: resolved "https://registry.yarnpkg.com/toad-cache/-/toad-cache-3.7.1.tgz#33441aab508e15a35fb5292c61ee3322c0853822" integrity sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ== -toidentifier@~1.0.1: +toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== @@ -28824,6 +28934,11 @@ tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -28911,7 +29026,7 @@ type-fest@^5.0.0: dependencies: tagged-tag "^1.0.0" -type-is@^1.6.18, type-is@~1.6.18: +type-is@^1.6.16, type-is@^1.6.18, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -30901,6 +31016,11 @@ yauzl@^3.2.0: buffer-crc32 "~0.2.3" pend "~1.2.0" +ylru@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.4.0.tgz#0cf0aa57e9c24f8a2cbde0cc1ca2c9592ac4e0f6" + integrity sha512-2OQsPNEmBCvXuFlIni/a+Rn+R2pHW9INm0BxXJ4hVDA8TirqMj+J/Rp9ItLatT/5pZqWwefVrTQcHpixsxnVlA== + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" From 9f594dd9cc41fef413703de202d5c8ed47be4469 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 16 Jul 2026 12:41:52 +0100 Subject: [PATCH 05/54] fix(node-native): Don't drop breadcrumbs from event loop block events (#22322) --- .../suites/thread-blocked-native/basic.mjs | 8 ++++++++ .../suites/thread-blocked-native/test.ts | 15 ++++++++++++++- .../src/event-loop-block-watchdog.ts | 19 ++++++++++++++++--- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs index 273760a6db39..49f30db2e59e 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic.mjs @@ -14,6 +14,14 @@ Sentry.init({ integrations: [eventLoopBlockIntegration()], }); +// Sentry.addBreadcrumb() writes to the isolation scope which is only captured via +// AsyncLocalStorage, so we add to the current scope to test the poll state route +Sentry.getCurrentScope().addBreadcrumb({ + category: 'test', + message: 'blocking event loop soon', + level: 'info', +}); + setTimeout(() => { longWork(); }, 2000); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index c6729e55c209..a07cc781b3d2 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -105,7 +105,20 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { test('ESM', async () => { await createRunner(__dirname, 'basic.mjs') .withMockSentryServer() - .expect({ event: ANR_EVENT_WITH_DEBUG_META('basic') }) + .expect({ + event: { + ...ANR_EVENT_WITH_DEBUG_META('basic'), + // Ensures breadcrumbs make it through the poll state rather than via AsyncLocalStorage + breadcrumbs: [ + { + timestamp: expect.any(Number), + category: 'test', + message: 'blocking event loop soon', + level: 'info', + }, + ], + }, + }) .start() .completed(); }); diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index e48c73835f6e..853bcbf35a6e 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -225,6 +225,21 @@ function getExceptionAndThreads( }; } +/** + * Rehydrates a Scope from serialized ScopeData that has been passed over a JSON serialization boundary. + * + * `Scope.update()` only handles `ScopeContext` fields, so breadcrumbs have to be carried over separately. + * Attachments are deliberately not carried over since their binary data does not survive JSON serialization. + */ +function hydrateScope(data: Partial | undefined): Scope { + const scope = new Scope(); + if (data) { + scope.update(data); + data.breadcrumbs?.forEach(breadcrumb => scope.addBreadcrumb(breadcrumb)); + } + return scope; +} + function applyScopeToEvent(event: Event, scope: ScopeData): void { applyScopeDataToEvent(event, scope); @@ -274,9 +289,7 @@ async function sendBlockEvent(crashedThreadId: string): Promise { ...getExceptionAndThreads(crashedThreadId, threads), }; - const scope = crashedThread.pollState?.scope - ? new Scope().update(crashedThread.pollState.scope).getScopeData() - : new Scope().getScopeData(); + const scope = hydrateScope(crashedThread.pollState?.scope).getScopeData(); if (crashedThread?.asyncState?.isolationScope) { // We need to rehydrate the scope from the serialized object with properties beginning with _user, etc From 0c87729b2297ac0b21d33fe1e619bbe395e2d14b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 16 Jul 2026 13:53:24 +0200 Subject: [PATCH 06/54] fix(sveltekit): Detect SvelteKit server build via Vite Environment API (#21629) Gets the environment using `this.environment.name` and falls back to `config.build.ssr` for older Vite versions. Un-skips the last remaining server-side error test in the Kit 3 e2e app. ref https://github.com/getsentry/sentry-javascript/issues/21502 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../sveltekit-3/tests/errors.server.test.ts | 7 +-- packages/sveltekit/src/vite/autoInstrument.ts | 8 +++- .../test/vite/autoInstrument.test.ts | 45 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts index ee17fde7c048..d660cb6198d1 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.server.test.ts @@ -2,12 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; test.describe('server-side errors', () => { - // FIXME(sveltekit-3): the universal load function's frame is reported as `load$1` (not `load`) - // because the SDK still wraps universal `+page.ts` load in the server build. Unlike server-only - // load, this isn't suppressed by native-tracing detection: the wrapper is skipped via - // `config.build.ssr`, which is unreliable under Vite 8's Environment API. Unskip once the SDK - // detects the server environment via the Vite Environment API. - test.skip('captures universal load error', async ({ page }) => { + test('captures universal load error', async ({ page }) => { const errorEventPromise = waitForError('sveltekit-3', errorEvent => { return errorEvent?.exception?.values?.[0]?.value === 'Universal Load Error (server)'; }); diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts index f97b923ef65d..07e71966beda 100644 --- a/packages/sveltekit/src/vite/autoInstrument.ts +++ b/packages/sveltekit/src/vite/autoInstrument.ts @@ -75,7 +75,13 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio }, async load(id) { - if (onlyInstrumentClient && isServerBuild) { + // On Vite 6+ `config.build.ssr` captured in `configResolved` no longer reliably reflects the per-environment build. + // Prefer the environment of the current build (`this.environment.name === 'ssr'`) and fall back to + // `isServerBuild` for older Vite versions that don't expose environments. + const environmentName = (this as { environment?: { name?: string } }).environment?.name; + const isServerEnvironment = environmentName != null ? environmentName === 'ssr' : !!isServerBuild; + + if (onlyInstrumentClient && isServerEnvironment) { return null; } diff --git a/packages/sveltekit/test/vite/autoInstrument.test.ts b/packages/sveltekit/test/vite/autoInstrument.test.ts index 46e71c207018..42d805565d4c 100644 --- a/packages/sveltekit/test/vite/autoInstrument.test.ts +++ b/packages/sveltekit/test/vite/autoInstrument.test.ts @@ -293,6 +293,51 @@ describe('makeAutoInstrumentationPlugin()', () => { }); }); }); + + describe('when the server build is detected via the Vite Environment API', () => { + // On Vite 6+ `config.build.ssr` no longer reliably reflects the per-environment + // build, so the plugin relies on the current environment (`this.environment.name`). When + // `onlyInstrumentClient` is `true`, universal load must not be wrapped in the `ssr` environment + // (but should still be wrapped in `client`), even when `config.build.ssr`/`configResolved` + // didn't flag a server build. + it.each(['path/to/+page.ts', 'path/to/+layout.js', 'path/to/+page.server.ts'])( + "doesn't wrap %s in the `ssr` environment", + async (path: string) => { + const plugin = makeAutoInstrumentationPlugin({ + debug: false, + load: true, + serverLoad: true, + onlyInstrumentClient: true, + }); + + // `configResolved` is intentionally not called - `isServerBuild` stays `undefined` + // @ts-expect-error this exists and is callable; bind `this.environment` like Vite does + const loadResult = await plugin.load.call({ environment: { name: 'ssr' } }, path); + + expect(loadResult).toEqual(null); + }, + ); + + it('still wraps universal load in the `client` environment', async () => { + const plugin = makeAutoInstrumentationPlugin({ + debug: false, + load: true, + serverLoad: true, + onlyInstrumentClient: true, + }); + + const path = 'path/to/+page.ts'; + // @ts-expect-error this exists and is callable; bind `this.environment` like Vite does + const loadResult = await plugin.load.call({ environment: { name: 'client' } }, path); + + expect(loadResult).toBe( + 'import { wrapLoadWithSentry } from "@sentry/sveltekit";' + + `import * as userModule from "${path}?sentry-auto-wrap";` + + 'export const load = userModule.load ? wrapLoadWithSentry(userModule.load) : undefined;' + + `export * from "${path}?sentry-auto-wrap";`, + ); + }); + }); }); describe('canWrapLoad', () => { From e75e28759ad6d4302519d631a6908763c8ff64d1 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:17:19 +0200 Subject: [PATCH 07/54] feat(core): Rename `queryParams` to `urlQueryParams` (#22217) Update name of `queryParams` to `urlQueryParams` to reduce ambiguity with other query types. We can just use `urlQueryParams` everywhere in the code as we resolve the option once and then use just the new value: ```ts urlQueryParams: dc.urlQueryParams ?? dc.queryParams ?? base.urlQueryParams, ``` Develop Docs PR: https://github.com/getsentry/sentry-docs/pull/18703 The PR is not merged (as of now) but this change was already discussed to be made. --- packages/astro/test/server/middleware.test.ts | 2 +- packages/core/src/integrations/requestdata.ts | 2 +- packages/core/src/types/datacollection.ts | 10 ++-- .../defaultPiiToCollectionOptions.ts | 4 +- .../resolveDataCollectionOptions.ts | 5 +- .../lib/integrations/mcp-server/testUtils.ts | 2 +- .../defaultPiiToCollectionOptions.test.ts | 8 ++-- .../resolveDataCollectionOptions.test.ts | 46 ++++++++++++++++--- packages/core/test/lib/utils/request.test.ts | 2 +- .../hooks/wrapMiddlewareHandler.test.ts | 2 +- packages/remix/test/server/errors.test.ts | 2 +- 11 files changed, 61 insertions(+), 24 deletions(-) diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index 6293bff213d7..205cfb7e757f 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -63,7 +63,7 @@ describe('sentryMiddleware', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: true, diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 5dc956c4dd73..27990ad2d6cc 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -56,7 +56,7 @@ const _requestDataIntegration = ((options: RequestDataIntegrationOptions = {}) = data: true, headers: dataCollection.httpHeaders.request !== false, ip: dataCollection.userInfo, - query_string: dataCollection.queryParams !== false, + query_string: dataCollection.urlQueryParams !== false, // No dataCollection equivalent — URL is always included url: true, ...options.include, diff --git a/packages/core/src/types/datacollection.ts b/packages/core/src/types/datacollection.ts index 88af5b354ebd..b160170761d7 100644 --- a/packages/core/src/types/datacollection.ts +++ b/packages/core/src/types/datacollection.ts @@ -44,11 +44,14 @@ export interface DataCollection { */ httpBodies?: HttpBodyCollectionTarget[]; + /** @deprecated Use `urlQueryParams` instead. */ + queryParams?: CollectBehavior; + /** - * Controls query parameter collection and sensitive value filtering. + * Controls URL query parameter collection and sensitive value filtering. * @default true */ - queryParams?: CollectBehavior; + urlQueryParams?: CollectBehavior; /** * Allow collection of GraphQL operation data when using the SDK's GraphQL integrations @@ -102,7 +105,8 @@ export interface DataCollection { /** * Fully resolved `DataCollection` with all defaults applied. */ -export type ResolvedDataCollection = Required & { +// todo(v11): change `Omit` to just `DataCollection` +export type ResolvedDataCollection = Required> & { httpHeaders: Required>; graphQL: Required>; genAI: Required>; diff --git a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts index b1338bf6a777..b70cddc267f4 100644 --- a/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/defaultPiiToCollectionOptions.ts @@ -15,7 +15,7 @@ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): Resolve cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -27,7 +27,7 @@ export function defaultPiiToCollectionOptions(sendDefaultPii?: boolean): Resolve cookies: { deny: PII_HEADER_SNIPPETS }, httpHeaders: { request: { deny: PII_HEADER_SNIPPETS }, response: { deny: PII_HEADER_SNIPPETS } }, httpBodies: [], - queryParams: { deny: PII_HEADER_SNIPPETS }, + urlQueryParams: { deny: PII_HEADER_SNIPPETS }, // The GraphQL document has literal values redacted at collection time, so it was historically // always attached regardless of `sendDefaultPii`; keep it on to preserve that behavior. graphQL: { document: true, variables: true }, diff --git a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts index 26da8ac0c1f5..4c558737903e 100644 --- a/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts +++ b/packages/core/src/utils/data-collection/resolveDataCollectionOptions.ts @@ -6,7 +6,7 @@ const DEFAULTS: ResolvedDataCollection = { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -44,7 +44,8 @@ export function resolveDataCollectionOptions(options: { response: dc.httpHeaders?.response ?? base.httpHeaders.response, }, httpBodies: dc.httpBodies ?? base.httpBodies, - queryParams: dc.queryParams ?? base.queryParams, + // oxlint-disable-next-line typescript/no-deprecated + urlQueryParams: dc.urlQueryParams ?? dc.queryParams ?? base.urlQueryParams, graphQL: { document: dc.graphQL?.document ?? base.graphQL.document, variables: dc.graphQL?.variables ?? base.graphQL.variables, diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts index d1b0c0aab237..2a34ee6eeb8a 100644 --- a/packages/core/test/lib/integrations/mcp-server/testUtils.ts +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -16,7 +16,7 @@ export function createMockClient(userInfo = true, genAI?: { inputs: boolean; out cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: genAIOptions, databaseQueryData: true, diff --git a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts index 55449201d664..c76dc1dccb4b 100644 --- a/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/defaultPiiToCollectionOptions.test.ts @@ -8,7 +8,7 @@ describe('defaultPiiToCollectionOptions', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -26,7 +26,7 @@ describe('defaultPiiToCollectionOptions', () => { response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, httpBodies: [], - queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: false, @@ -44,7 +44,7 @@ describe('defaultPiiToCollectionOptions', () => { response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, httpBodies: [], - queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: false, @@ -62,7 +62,7 @@ describe('defaultPiiToCollectionOptions', () => { response: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, }, httpBodies: [], - queryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, + urlQueryParams: { deny: ['forwarded', '-ip', 'remote-', 'via', '-user'] }, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: false, diff --git a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts index 0deee7654d09..9f554a1897e2 100644 --- a/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts +++ b/packages/core/test/lib/utils/data-collection/resolveDataCollectionOptions.test.ts @@ -7,7 +7,7 @@ describe('resolveDataCollectionOptions', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: ['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse'], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, @@ -49,7 +49,7 @@ describe('resolveDataCollectionOptions', () => { expect(result.cookies).toBe(true); expect(result.httpHeaders).toEqual({ request: true, response: true }); expect(result.httpBodies).toEqual(['incomingRequest', 'outgoingRequest', 'incomingResponse', 'outgoingResponse']); - expect(result.queryParams).toBe(true); + expect(result.urlQueryParams).toBe(true); expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.genAI).toEqual({ inputs: true, outputs: true }); expect(result.databaseQueryData).toBe(true); @@ -96,7 +96,7 @@ describe('resolveDataCollectionOptions', () => { // Everything else is spec default expect(result.cookies).toBe(true); expect(result.httpHeaders).toEqual({ request: true, response: true }); - expect(result.queryParams).toBe(true); + expect(result.urlQueryParams).toBe(true); expect(result.graphQL).toEqual({ document: true, variables: true }); expect(result.genAI).toEqual({ inputs: true, outputs: true }); expect(result.databaseQueryData).toBe(true); @@ -147,14 +147,14 @@ describe('resolveDataCollectionOptions', () => { expect(result.cookies).toEqual({ deny: ['x-custom'] }); }); - it('supports turning off query params', () => { + it('supports turning off URL query params', () => { const result = resolveDataCollectionOptions({ dataCollection: { - queryParams: false, + urlQueryParams: false, }, }); - expect(result.queryParams).toBe(false); + expect(result.urlQueryParams).toBe(false); }); it('supports turning off database query data', () => { @@ -179,7 +179,7 @@ describe('resolveDataCollectionOptions', () => { expect(result).toHaveProperty('httpHeaders.request'); expect(result).toHaveProperty('httpHeaders.response'); expect(result).toHaveProperty('httpBodies'); - expect(result).toHaveProperty('queryParams'); + expect(result).toHaveProperty('urlQueryParams'); expect(result).toHaveProperty('graphQL'); expect(result).toHaveProperty('graphQL.document'); expect(result).toHaveProperty('graphQL.variables'); @@ -191,4 +191,36 @@ describe('resolveDataCollectionOptions', () => { expect(result).toHaveProperty('frameContextLines'); }); }); + + describe('deprecated queryParams alias', () => { + it('honors deprecated queryParams when urlQueryParams is not set', () => { + expect(resolveDataCollectionOptions({ dataCollection: { queryParams: false } }).urlQueryParams).toBe(false); + + expect( + resolveDataCollectionOptions({ dataCollection: { queryParams: { deny: ['token'] } } }).urlQueryParams, + ).toEqual({ deny: ['token'] }); + }); + + it('prefers urlQueryParams over the deprecated queryParams when both are set', () => { + // new field wins, even when it is the "off" value + expect( + resolveDataCollectionOptions({ dataCollection: { urlQueryParams: false, queryParams: true } }).urlQueryParams, + ).toBe(false); + + expect( + resolveDataCollectionOptions({ dataCollection: { urlQueryParams: true, queryParams: false } }).urlQueryParams, + ).toBe(true); + }); + + it('falls back to the default when neither is set', () => { + // dataCollection provided → spec default (collect) + expect(resolveDataCollectionOptions({ dataCollection: {} }).urlQueryParams).toBe(true); + }); + + it('does not leak the deprecated queryParams key into the resolved output', () => { + const result = resolveDataCollectionOptions({ dataCollection: { queryParams: false } }); + expect(result).not.toHaveProperty('queryParams'); + expect(Object.keys(result)).toHaveLength(10); + }); + }); }); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index cf0ffe3ac231..4783f98be9f0 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -873,7 +873,7 @@ describe('request utils', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, diff --git a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts index c19bcc1ed099..e84b6aeb461b 100644 --- a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts +++ b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts @@ -46,7 +46,7 @@ describe('wrapMiddlewareHandlerWithSentry', () => { cookies: true, httpHeaders: { request: true, response: true }, httpBodies: [], - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: false, outputs: false }, databaseQueryData: true, diff --git a/packages/remix/test/server/errors.test.ts b/packages/remix/test/server/errors.test.ts index 4f58e280c3ee..6288d4df8238 100644 --- a/packages/remix/test/server/errors.test.ts +++ b/packages/remix/test/server/errors.test.ts @@ -16,7 +16,7 @@ function createMockClient(httpBodies: string[] = []): Client { cookies: true, httpHeaders: { request: true, response: true }, httpBodies, - queryParams: true, + urlQueryParams: true, graphQL: { document: true, variables: true }, genAI: { inputs: true, outputs: true }, databaseQueryData: true, From 159e40bcbbb50e72769626e5dbf9b6571d4ce528 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:30:46 +0200 Subject: [PATCH 08/54] fix(vue): Support pinia 4 in peer dependency range (#22324) Pinia 4 has no API-changes, so we only need to update peer-dependency range. Also updates Pinia in the E2E test. Pinia Release: https://github.com/vuejs/pinia/releases/tag/v4.0.0 Closes https://github.com/getsentry/sentry-javascript/issues/22320 Co-authored-by: Cursor --- dev-packages/e2e-tests/test-applications/vue-3/package.json | 2 +- packages/vue/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/vue-3/package.json b/dev-packages/e2e-tests/test-applications/vue-3/package.json index 243f4875add6..7367355d8f1e 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/package.json +++ b/dev-packages/e2e-tests/test-applications/vue-3/package.json @@ -20,7 +20,7 @@ }, "dependencies": { "@sentry/vue": "file:../../packed/sentry-vue-packed.tgz", - "pinia": "^3.0.0", + "pinia": "^4.0.0", "vue": "^3.4.15", "vue-router": "^4.2.5" }, diff --git a/packages/vue/package.json b/packages/vue/package.json index 4c255f292690..5a085347eba1 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -57,7 +57,7 @@ }, "peerDependencies": { "@tanstack/vue-router": "^1.64.0", - "pinia": "2.x || 3.x", + "pinia": "2.x || 3.x || 4.x", "vue": "2.x || 3.x" }, "peerDependenciesMeta": { From 6370c7dc76291925337a59986f2dac31c6b37962 Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 16 Jul 2026 06:59:21 -0700 Subject: [PATCH 09/54] fix(bundler): do not import type from optional peer dep (#22304) fix: #22296 fix: JS-3054 --- packages/bundler-plugins/src/rollup/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index c8371fe20a27..c53ce21245bd 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -20,10 +20,14 @@ import type { } from '../core/component-annotation-vite'; import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; -import type { TransformResult } from 'rollup'; import * as path from 'node:path'; import { createRequire } from 'node:module'; +// The subset of Rollup's `TransformResult` that this plugin's `transform` +// hook actually returns. Defined locally instead of imported from `rollup` +// because `rollup` is an optional dependency. +type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined; + type ViteModule = { parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise; }; From f22022b9e4903ac3b989273d8b7c304b4013b8f4 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:48:00 +0200 Subject: [PATCH 10/54] chore(skills): Add libraries to framework update watcher (#22326) Since we missed the Pinia major version (see issue https://github.com/getsentry/sentry-javascript/issues/22320), I added some libraries to our framework update watcher. --- .../assets/digest-schema.json | 2 +- .../assets/digest-template.md | 4 ++ .../track-framework-updates/sources.json | 66 +++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.agents/skills/track-framework-updates/assets/digest-schema.json b/.agents/skills/track-framework-updates/assets/digest-schema.json index 55b503ed7c10..843ba26acc72 100644 --- a/.agents/skills/track-framework-updates/assets/digest-schema.json +++ b/.agents/skills/track-framework-updates/assets/digest-schema.json @@ -14,7 +14,7 @@ { "name": "React", "sentryPackages": ["@sentry/react"], - "category": "client", + "category": "client|server|meta-framework|platform|library", "releases": [ { "tag": "v19.0.0", diff --git a/.agents/skills/track-framework-updates/assets/digest-template.md b/.agents/skills/track-framework-updates/assets/digest-template.md index 6d8f9a31f9fc..cbb17fa62e30 100644 --- a/.agents/skills/track-framework-updates/assets/digest-template.md +++ b/.agents/skills/track-framework-updates/assets/digest-template.md @@ -39,6 +39,10 @@ _Window: last days · generated _ +## Libraries + + + ## Source coverage ⚠ The following SDK packages are **not tracked** in `sources.json`. Add an upstream framework entry or exclude them in `scripts/check_sources.py`: diff --git a/.agents/skills/track-framework-updates/sources.json b/.agents/skills/track-framework-updates/sources.json index 1bab8000f8fe..76f8f18964a6 100644 --- a/.agents/skills/track-framework-updates/sources.json +++ b/.agents/skills/track-framework-updates/sources.json @@ -100,6 +100,17 @@ }, "rss": [] }, + { + "name": "Express", + "sentryPackages": ["@sentry/node"], + "category": "server", + "github": { + "repo": "expressjs/express", + "discussions": false, + "rfcsRepo": null + }, + "rss": ["https://expressjs.com/feed.xml"] + }, { "name": "Elysia", "sentryPackages": ["@sentry/elysia"], @@ -275,6 +286,61 @@ "rfcsRepo": null }, "rss": [] + }, + { + "name": "Pinia", + "sentryPackages": ["@sentry/vue", "@sentry/nuxt"], + "category": "library", + "github": { + "repo": "vuejs/pinia", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Pino", + "sentryPackages": ["@sentry/node"], + "category": "library", + "github": { + "repo": "pinojs/pino", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Winston", + "sentryPackages": ["@sentry/node"], + "category": "library", + "github": { + "repo": "winstonjs/winston", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Consola", + "sentryPackages": ["@sentry/core"], + "category": "library", + "github": { + "repo": "unjs/consola", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] + }, + { + "name": "Zod", + "sentryPackages": ["@sentry/core"], + "category": "library", + "github": { + "repo": "colinhacks/zod", + "discussions": false, + "rfcsRepo": null + }, + "rss": [] } ] } From 9e04d983379767141f33658337dc5f89892f8978 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:48:13 +0200 Subject: [PATCH 11/54] fix(skills): Make sure digest file is written/read correctly (#22330) Workflow file is not posted. See action run: https://github.com/getsentry/sentry-javascript/actions/runs/29232933372/job/86760964358 --- .../skills/track-framework-updates/SKILL.md | 18 +++++++++++++----- .../scripts/write_job_summary.py | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.agents/skills/track-framework-updates/SKILL.md b/.agents/skills/track-framework-updates/SKILL.md index 7ebd148d6c85..a3a14417619c 100644 --- a/.agents/skills/track-framework-updates/SKILL.md +++ b/.agents/skills/track-framework-updates/SKILL.md @@ -107,12 +107,20 @@ For each release or RFC that plausibly needs SDK work, draft one concrete, actio ### Step 6: Write output artifacts -Produce **three files** in the skill's `output/` directory: +The `collect_updates.py` script in Step 1 prints its output path, e.g.: -1. **`output/framework-updates-raw.json`** — already written by Step 1. -2. **`output/framework-updates-digest.json`** — structured, machine-readable digest. Follow the schema in `assets/digest-schema.json`. -3. **`output/framework-updates-digest.md`** — human-readable digest. Follow the structure in `assets/digest-template.md`: - - Group by Client-Side / Server-Side / Meta-Framework. +``` +Wrote /abs/path/to/.agents/skills/track-framework-updates/output/framework-updates-raw.json: ... +``` + +Use that printed path to derive the output directory. All three files must be written to the **same directory** as `framework-updates-raw.json` — never relative paths like `output/` from the workspace root. + +Produce **three files**: + +1. The raw JSON was already written by Step 1 — no action needed. +2. **`/framework-updates-digest.json`** — structured, machine-readable digest. Follow the schema in `assets/digest-schema.json`. +3. **`/framework-updates-digest.md`** — human-readable digest. Follow the structure in `assets/digest-template.md`: + - Group by Client-Side / Server-Side / Meta-Framework / Platform / Libraries. - Omit frameworks with no activity. - Include a "Run notes" section only if a fetcher reported errors. diff --git a/.agents/skills/track-framework-updates/scripts/write_job_summary.py b/.agents/skills/track-framework-updates/scripts/write_job_summary.py index 6ba3c406c524..595c9ec7b2ac 100644 --- a/.agents/skills/track-framework-updates/scripts/write_job_summary.py +++ b/.agents/skills/track-framework-updates/scripts/write_job_summary.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import os import sys @@ -85,8 +86,20 @@ def main() -> int: digest = f.read().strip() if digest: lines.append(digest) - except OSError: - lines.append("_Digest file not found._") + except OSError as e: + lines.append(f"_Digest file not found: `{digest_path}` ({e})_") + lines.append("") + # List what is actually in the output dir to help diagnose path mismatches + output_dir = os.path.dirname(os.path.abspath(digest_path)) + try: + found = os.listdir(output_dir) + if found: + lines.append(f"Files present in `{output_dir}`:") + lines.extend(f"- `{name}`" for name in sorted(found)) + else: + lines.append(f"`{output_dir}` exists but is empty.") + except OSError: + lines.append(f"Output directory not found: `{output_dir}`") # Run metrics at the bottom cost_str = ( From e0865f99fae51571389cf6cce92b8547a1c9e029 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 16 Jul 2026 16:26:34 +0100 Subject: [PATCH 12/54] fix(serve-rutils): Orchestrion diagnostics for async and require (#22327) This PR fixes some issues with capturing runtime orchestrion diagnostics: - Handles and logs errors! - Configures the hook for the async loader and require hook Notes: - This is all really nasty and can be reduced considerably when we drop support for Node without `require(esm)`. - We have to block `registerDiagnosticsChannelInjection` from running on the loader thread or it crashes! - I dropped all the usage of `DEBUG_BUILD` because this code is all server side and we want these to always be captured when `debug: true`. Probably worth adjusting the ai prompt to reflect this! --- .../src/orchestrion/runtime/register.ts | 81 +++++++++++++------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 1e63c4c6ddeb..a1f77e98bcbf 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -2,16 +2,21 @@ import { debug, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; -import { DEBUG_BUILD } from '../../debug-build'; +import { isMainThread, MessageChannel } from 'node:worker_threads'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import type { register } from 'node:module'; +type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; + type TracingHooksSync = { initialize: (opts: { instrumentations: InstrumentationConfig[] }) => void; resolve: Function; load: Function; - setDiagnosticsHook: (callback: (event: { url: string; moduleName: string; error: Error }) => void) => void; +}; + +type TracingHooksDiagnostics = { + setDiagnosticsHook: (callback: (event: DiagnosticsEvent) => void) => void; }; type NodeModule = { @@ -58,6 +63,12 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * the channel-based integrations subscribe to. */ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { + // We only want to register the hook once from the main thread. + // --import hooks are run in every worker thread! + if (!isMainThread) { + return; + } + if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) { return; } @@ -100,26 +111,42 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic // incompatibility) we warn (DEBUG only) and continue without channel // injection try { + // `lib/diagnostics.js` is plain CJS, so unlike the ESM hook entry points it can be + // require()d on every supported Node version. It holds the hook state shared by + // everything that can transform a module on this thread (the sync ESM hooks and the + // `_compile` patch), so setting the hook once here covers both branches below. + const { setDiagnosticsHook } = ( + requireFromHooksDir + ? requireFromHooksDir(`${tracingHooksDir}/lib/diagnostics.js`) + : nodeRequire('@apm-js-collab/tracing-hooks/lib/diagnostics.js') + ) as TracingHooksDiagnostics; + + const onDiagnostics = ({ moduleName, error }: DiagnosticsEvent): void => { + if (error) { + debug.warn(`[orchestrion] failed to inject diagnostics-channel into ${moduleName}:`, error); + } else { + GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; + GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(moduleName); + } + }; + + setDiagnosticsHook(onDiagnostics); + if (typeof mod.registerHooks === 'function' && stableSyncHooks) { // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. - // We require() the module here so that we can synchronously load it, - // including from a CommonJS Sentry build, without bundlers pulling in. - // All versions in stableSyncHooks support this. - const { initialize, resolve, load, setDiagnosticsHook } = ( + // We require() this ESM module so that we can synchronously load it, + // including from a CommonJS Sentry build; all versions in + // stableSyncHooks support require(esm). + const { initialize, resolve, load } = ( requireFromHooksDir ? requireFromHooksDir(`${tracingHooksDir}/hook-sync.mjs`) : nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') ) as TracingHooksSync; - setDiagnosticsHook(event => { - GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; - GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime.push(event.moduleName); - }); - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); mod.registerHooks({ resolve, load }); - DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.registerHooks()'); + debug.log('Registered diagnostics-channel injection via Module.registerHooks()'); } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 // path. Bun/Deno are excluded: they don't support this combination and @@ -130,9 +157,20 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic const hookSpecifier = tracingHooksDir ? pathToFileURL(`${tracingHooksDir}/hook.mjs`).href : '@apm-js-collab/tracing-hooks/hook.mjs'; + + // The `Module.register` hooks run on a loader thread with its own copy of + // `lib/diagnostics.js`, so the hook set above never fires there; the loader thread + // posts diagnostics back over a MessagePort instead. This replicates + // `createDiagnosticsPort` from hook.mjs, which is ESM and therefore not + // synchronously loadable on all Node versions that take this branch. + const { port1, port2 } = new MessageChannel(); + port1.on('message', onDiagnostics); + // The diagnostics channel must not keep the process alive. + port1.unref(); mod.register(hookSpecifier, { parentURL: thisModuleUrl, - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + data: { instrumentations: SENTRY_INSTRUMENTATIONS, diagnosticsPort: port2 }, + transferList: [port2], }); // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM @@ -148,19 +186,16 @@ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnostic patch: () => void; }; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); - DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.register()'); + debug.log('Registered diagnostics-channel injection via Module.register()'); } else { - DEBUG_BUILD && - debug.warn('[Sentry] No available Node API to register diagnostics-channel injection hooks; skipping.'); + debug.warn('No available Node API to register diagnostics-channel injection hooks; skipping.'); return; } } catch (error) { - DEBUG_BUILD && - debug.warn( - '[Sentry] Failed to register diagnostics-channel injection hooks; channel-based integrations ' + - 'will not record spans.', - error, - ); + debug.warn( + 'Failed to register diagnostics-channel injection hooks; channel-based integrations will not record spans.', + error, + ); return; } From abacdbf1dbe040d721eb5c6a08f89bc318501476 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Jul 2026 11:28:49 -0400 Subject: [PATCH 13/54] feat(server-utils): Rewrite `SentryLangGraphInstrumentation` to orchestrion (#22268) Rewrites the LangGraph integration to a `node:diagnostics_channel` listener instead of `InstrumentationBase`, with orchestrion injecting the channels. The vendored OTel path stays as the fallback when orchestrion isn't injected. closes getsentry/sentry-javascript#20916 --- packages/core/src/shared-exports.ts | 9 +- packages/core/src/tracing/langgraph/index.ts | 108 +++++++----- .../integrations/tracing-channel/langgraph.ts | 163 ++++++++++++++++++ .../src/orchestrion/config/langgraph.ts | 34 +++- .../server-utils/src/orchestrion/index.ts | 3 + 5 files changed, 268 insertions(+), 49 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/langgraph.ts diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 647886d41199..271cc7bbb004 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -219,7 +219,14 @@ export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from '. export { _INTERNAL_mergeLangChainCallbackHandler } from './tracing/langchain/utils'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; -export { instrumentStateGraphCompile, instrumentCreateReactAgent, instrumentLangGraph } from './tracing/langgraph'; +export { + instrumentStateGraphCompile, + instrumentCreateReactAgent, + instrumentLangGraph, + instrumentCompiledGraphInvoke, + _INTERNAL_getLangGraphCreateAgentSpanOptions, +} from './tracing/langgraph'; +export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; // eslint-disable-next-line typescript/no-deprecated diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index d4db0de3a6f3..daf2f55552ea 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -20,6 +20,7 @@ import { shouldEnableTruncation, } from '../ai/utils'; import { stringify } from '../../utils/string'; +import type { SpanAttributeValue } from '../../types/span'; import { createLangChainCallbackHandler } from '../langchain'; import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; @@ -39,6 +40,34 @@ let _insideCreateReactAgent = false; const SENTRY_PATCHED = '__sentry_patched__'; +/** + * Builds the span options for a LangGraph `create_agent` span. + * + * @internal Exported so the diagnostics-channel (orchestrion) instrumentation can open the same span + * as the prototype-patching path below without re-declaring the semantic attribute keys. + */ +export function _INTERNAL_getLangGraphCreateAgentSpanOptions(agentName?: string): { + op: string; + name: string; + attributes: Record; +} { + const attributes: Record = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent', + }; + + if (agentName) { + attributes[GEN_AI_AGENT_NAME_ATTRIBUTE] = agentName; + } + + return { + op: 'gen_ai.create_agent', + name: agentName ? `create_agent ${agentName}` : 'create_agent', + attributes, + }; +} + /** * Instruments StateGraph's compile method to create spans for agent creation and invocation * @@ -64,53 +93,42 @@ export function instrumentStateGraphCompile( return Reflect.apply(target, thisArg, args); } - return startSpan( - { - op: 'gen_ai.create_agent', - name: 'create_agent', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGGRAPH_ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.create_agent', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'create_agent', - }, - }, - span => { - try { - const compiledGraph = Reflect.apply(target, thisArg, args); - const compileOptions = args.length > 0 ? (args[0] as Record) : {}; - - // Extract graph name - if (compileOptions?.name && typeof compileOptions.name === 'string') { - span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name); - span.updateName(`create_agent ${compileOptions.name}`); - } + return startSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(), span => { + try { + const compiledGraph = Reflect.apply(target, thisArg, args); + const compileOptions = args.length > 0 ? (args[0] as Record) : {}; - // Instrument agent invoke method on the compiled graph - const originalInvoke = compiledGraph.invoke; - if (originalInvoke && typeof originalInvoke === 'function') { - compiledGraph.invoke = instrumentCompiledGraphInvoke( - originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, - compiledGraph, - compileOptions, - options, - undefined, - sentryHandler, - ) as typeof originalInvoke; - } + // Extract graph name + if (compileOptions?.name && typeof compileOptions.name === 'string') { + span.setAttribute(GEN_AI_AGENT_NAME_ATTRIBUTE, compileOptions.name); + span.updateName(`create_agent ${compileOptions.name}`); + } - return compiledGraph; - } catch (error) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); - captureException(error, { - mechanism: { - handled: false, - type: 'auto.ai.langgraph.error', - }, - }); - throw error; + // Instrument agent invoke method on the compiled graph + const originalInvoke = compiledGraph.invoke; + if (originalInvoke && typeof originalInvoke === 'function') { + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph) as (...args: unknown[]) => Promise, + compiledGraph, + compileOptions, + options, + undefined, + sentryHandler, + ) as typeof originalInvoke; } - }, - ); + + return compiledGraph; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.langgraph.error', + }, + }); + throw error; + } + }); }, }) as (...args: unknown[]) => CompiledGraph; @@ -123,7 +141,7 @@ export function instrumentStateGraphCompile( * * Creates a `gen_ai.invoke_agent` span when invoke() is called */ -function instrumentCompiledGraphInvoke( +export function instrumentCompiledGraphInvoke( originalInvoke: (...args: unknown[]) => Promise, graphInstance: CompiledGraph, compileOptions: Record, diff --git a/packages/server-utils/src/integrations/tracing-channel/langgraph.ts b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts new file mode 100644 index 000000000000..c5e601c61d9d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/langgraph.ts @@ -0,0 +1,163 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { CompiledGraph, IntegrationFn, LangGraphOptions } from '@sentry/core'; +import { + _INTERNAL_getLangGraphCreateAgentSpanOptions, + createLangChainCallbackHandler, + debug, + defineIntegration, + extractAgentNameFromParams, + extractLLMFromParams, + instrumentCompiledGraphInvoke, + LANGGRAPH_INTEGRATION_NAME, + resolveAIRecordingOptions, + startInactiveSpan, + waitForTracingChannelBinding, + wrapToolsWithSpans, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// Same name as the OTel integration by design: when enabled, the OTel 'LangGraph' integration is +// dropped from the default set (see the Node opt-in loader). +const INTEGRATION_NAME = LANGGRAPH_INTEGRATION_NAME; + +interface CompileChannelContext { + arguments: unknown[]; + result?: unknown; +} + +interface CreateReactAgentChannelContext { + arguments: unknown[]; + result?: unknown; +} + +let subscribed = false; + +// `createReactAgent` compiles a `StateGraph` internally; suppress the `create_agent` span for that +// nested compile so a react agent gets a single `invoke_agent` span, matching the OTel path. +let insideCreateReactAgent = false; + +const _langGraphChannelIntegration = ((options: LangGraphOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. + if (!diagnosticsChannel.tracingChannel || subscribed) { + return; + } + subscribed = true; + + const resolvedOptions = resolveAIRecordingOptions(options); + const sentryHandler = createLangChainCallbackHandler(resolvedOptions); + + // `bindTracingChannelToSpan` needs the async-context binding that `initOpenTelemetry()` registers + // after `setupOnce` runs, so wait for it before subscribing. + waitForTracingChannelBinding(() => { + // StateGraph.compile → `create_agent` span, then wrap the returned graph's `invoke`. + DEBUG_BUILD && + debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE}"`); + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.LANGGRAPH_STATE_GRAPH_COMPILE), + data => { + if (insideCreateReactAgent) { + return undefined; + } + const compileOptions = getFirstArgObject(data.arguments); + const name = typeof compileOptions?.name === 'string' ? compileOptions.name : undefined; + + return startInactiveSpan(_INTERNAL_getLangGraphCreateAgentSpanOptions(name)); + }, + { + beforeSpanEnd: (_span, data) => { + wrapCompiledGraphInvoke( + data.result, + getFirstArgObject(data.arguments) ?? {}, + resolvedOptions, + null, + sentryHandler, + ); + }, + }, + ); + + // createReactAgent has no `create_agent` span of its own; it only wraps tools and the returned + // graph's `invoke`. Tools are wrapped at `start` (before the agent runs), invoke at `end`. + DEBUG_BUILD && + debug.log(`[orchestrion:langgraph] subscribing to channel "${CHANNELS.LANGGRAPH_CREATE_REACT_AGENT}"`); + const reactAgentChannel = diagnosticsChannel.tracingChannel( + CHANNELS.LANGGRAPH_CREATE_REACT_AGENT, + ); + reactAgentChannel.start.subscribe(message => { + // `createReactAgent` runs synchronously and compiles a `StateGraph` internally, so the flag + // must be on for the duration and off by `end`. It's set here (never in a branch that can + // throw) and cleared in both `end` and `error`, so it can neither stick on across calls nor + // stay off during this call's nested compile. Tool wrapping is guarded for the same reason. + insideCreateReactAgent = true; + try { + const { arguments: args } = message as CreateReactAgentChannelContext; + const params = getFirstArgObject(args); + if (params && Array.isArray(params.tools) && params.tools.length > 0) { + wrapToolsWithSpans(params.tools, resolvedOptions, extractAgentNameFromParams(args) ?? undefined); + } + } catch (error) { + DEBUG_BUILD && debug.error('[orchestrion:langgraph] failed to wrap createReactAgent tools', error); + } + }); + reactAgentChannel.end.subscribe(message => { + insideCreateReactAgent = false; + const { arguments: args, result } = message as CreateReactAgentChannelContext; + const agentName = extractAgentNameFromParams(args) ?? undefined; + const compileOptions = agentName ? { name: agentName } : {}; + wrapCompiledGraphInvoke(result, compileOptions, resolvedOptions, extractLLMFromParams(args), sentryHandler); + }); + // Make sure a thrown `createReactAgent` doesn't leave the suppression flag stuck on. + reactAgentChannel.error.subscribe(() => { + insideCreateReactAgent = false; + }); + }); + }, + }; +}) satisfies IntegrationFn; + +function getFirstArgObject(args: unknown[] | undefined): Record | undefined { + const first = (args ?? [])[0]; + + return typeof first === 'object' && first !== null ? (first as Record) : undefined; +} + +/** + * Wrap the compiled graph's `invoke` with the shared `invoke_agent` instrumentation, exactly as the + * OTel path does on the returned graph. + */ +function wrapCompiledGraphInvoke( + graph: unknown, + compileOptions: Record, + options: LangGraphOptions, + llm: ReturnType, + sentryHandler: unknown, +): void { + if (!graph || typeof graph !== 'object') { + return; + } + + const compiledGraph = graph as CompiledGraph; + const originalInvoke = compiledGraph.invoke; + if (typeof originalInvoke === 'function') { + compiledGraph.invoke = instrumentCompiledGraphInvoke( + originalInvoke.bind(compiledGraph), + compiledGraph, + compileOptions, + options, + llm, + sentryHandler, + ); + } +} + +/** + * EXPERIMENTAL — orchestrion-driven LangGraph integration. Subscribes to the diagnostics_channels + * injected into `@langchain/langgraph`'s `StateGraph.compile` and `createReactAgent`, so it requires + * the orchestrion runtime hook or bundler plugin. + */ +export const langGraphChannelIntegration = defineIntegration(_langGraphChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/config/langgraph.ts b/packages/server-utils/src/orchestrion/config/langgraph.ts index ce93f1e993d5..8eab70919b70 100644 --- a/packages/server-utils/src/orchestrion/config/langgraph.ts +++ b/packages/server-utils/src/orchestrion/config/langgraph.ts @@ -1,6 +1,34 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -// TODO: Stub for the `langgraph` orchestrion integration (ports `SentryLangGraphInstrumentation`). -export const langgraphConfig: InstrumentationConfig[] = []; +// `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the +// matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile` +// and `createReactAgent` both return synchronously; the subscriber wraps the returned compiled graph's +// `invoke` (mirroring the vendored OTel instrumentation, which patched these on the module exports). +const module = (filePath: string): InstrumentationConfig['module'] => ({ + name: '@langchain/langgraph', + versionRange: '>=0.0.0 <2.0.0', + filePath, +}); -export const langgraphChannels = {} as const; +const compileConfig = ['dist/graph/state.cjs', 'dist/graph/state.js'].map(filePath => ({ + channelName: 'stateGraphCompile', + module: module(filePath), + functionQuery: { className: 'StateGraph', methodName: 'compile', kind: 'Sync' as const }, +})); + +// `createReactAgent` is a single function declaration re-exported from both `@langchain/langgraph` and +// `@langchain/langgraph/prebuilt`; hooking its definition file covers every import path. +const createReactAgentConfig = ['dist/prebuilt/react_agent_executor.cjs', 'dist/prebuilt/react_agent_executor.js'].map( + filePath => ({ + channelName: 'createReactAgent', + module: module(filePath), + functionQuery: { functionName: 'createReactAgent', kind: 'Sync' as const }, + }), +); + +export const langgraphConfig = [...compileConfig, ...createReactAgentConfig] satisfies InstrumentationConfig[]; + +export const langgraphChannels = { + LANGGRAPH_STATE_GRAPH_COMPILE: 'orchestrion:@langchain/langgraph:stateGraphCompile', + LANGGRAPH_CREATE_REACT_AGENT: 'orchestrion:@langchain/langgraph:createReactAgent', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index e36c9360dc51..2282477c32bb 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -12,6 +12,7 @@ import { koaChannelIntegration } from '../integrations/tracing-channel/koa'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; +import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2'; @@ -37,6 +38,7 @@ export { ioredisChannelIntegration, kafkajsChannelIntegration, knexChannelIntegration, + langGraphChannelIntegration, lruMemoizerChannelIntegration, mysqlChannelIntegration, mysql2ChannelIntegration, @@ -88,6 +90,7 @@ export const channelIntegrations = { openaiIntegration: openaiChannelIntegration, anthropicIntegration: anthropicChannelIntegration, googleGenAIIntegration: googleGenAIChannelIntegration, + langGraphIntegration: langGraphChannelIntegration, vercelAiIntegration: vercelAiChannelIntegration, amqplibIntegration: amqplibChannelIntegration, hapiIntegration: hapiChannelIntegration, From 27b350ffd7a579a3174bc487d505a984add6e734 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Jul 2026 12:19:26 -0400 Subject: [PATCH 14/54] feat(node): Rewrite tedious instrumentation to orchestrion tracing channels (#22238) Migrates the tedious integration off the vendored `InstrumentationBase` onto a `node:diagnostics_channel` subscriber whose channels orchestrion injects into the library. The OTel path stays as the fallback when orchestrion isn't injected. closes getsentry/sentry-javascript#20766 --- .../suites/tracing/tedious/test.ts | 9 +- .../integrations/tracing-channel/tedious.ts | 261 ++++++++++++++++++ .../src/orchestrion/config/tedious.ts | 31 ++- .../server-utils/src/orchestrion/index.ts | 3 + 4 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/tedious.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts index 9315adc85cf3..420a92a6caa7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/test.ts @@ -1,7 +1,10 @@ import { afterAll, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [__dirname] }, () => { + const ORIGIN = isOrchestrionEnabled() ? 'auto.db.orchestrion.tedious' : 'auto.db.otel.tedious'; + afterAll(() => { cleanupChildProcesses(); }); @@ -9,9 +12,9 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ const dbSpan = (overrides: Record) => expect.objectContaining({ op: 'db', - origin: 'auto.db.otel.tedious', + origin: ORIGIN, data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.tedious', + 'sentry.origin': ORIGIN, 'sentry.op': 'db', 'db.system': 'mssql', 'db.name': 'master', @@ -33,7 +36,7 @@ describeWithDockerCompose('tedious auto instrumentation', { workingDirectory: [_ expect.objectContaining({ description: 'execBulkLoad test_bulk master', op: 'db', - origin: 'auto.db.otel.tedious', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'db.sql.table': 'test_bulk' }), }), diff --git a/packages/server-utils/src/integrations/tracing-channel/tedious.ts b/packages/server-utils/src/integrations/tracing-channel/tedious.ts new file mode 100644 index 000000000000..99a927f5dd7e --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/tedious.ts @@ -0,0 +1,261 @@ +// The `@sentry/conventions` db/net attribute keys are deprecated (superseded by newer semconv), but we +// emit them deliberately to preserve parity with what `@opentelemetry/instrumentation-tedious` produced. +/* oxlint-disable typescript/no-deprecated */ + +import { EventEmitter } from 'node:events'; +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, SpanAttributes } from '@sentry/core'; +import { + debug, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + SPAN_STATUS_ERROR, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { + DB_NAME, + DB_STATEMENT, + DB_SYSTEM, + DB_USER, + NET_PEER_NAME, + NET_PEER_PORT, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +// NOTE: this uses the same name as the OTel integration by design. When orchestrion injection is active, +// `_init` swaps the OTel `Tedious` integration out of the defaults and appends this one (matched by name). +const INTEGRATION_NAME = 'Tedious' as const; +const ORIGIN = 'auto.db.orchestrion.tedious'; + +// OTel db/net semantic-convention values/keys not exported by `@sentry/conventions`, inlined to match +// what `@opentelemetry/instrumentation-tedious` emitted. +const DB_SYSTEM_VALUE_MSSQL = 'mssql'; +const ATTR_DB_SQL_TABLE = 'db.sql.table'; + +// Tracks the connection's active database (updated on `databaseChange`), read into `db.name` when a query +// runs. Mirrors the `CURRENT_DATABASE` symbol the vendored OTel instrumentation stashed on the connection. +const currentDatabaseSymbol = Symbol('sentry.orchestrion.tedious.current-database'); + +type UnknownFunction = (...args: unknown[]) => unknown; + +interface TediousConnectionConfig { + server?: string; + userName?: string; + authentication?: { options?: { userName?: string } }; + options?: { database?: string; port?: number }; +} + +interface TediousConnection extends EventEmitter { + config?: TediousConnectionConfig; + [currentDatabaseSymbol]?: string; +} + +interface TediousRequest extends EventEmitter { + sqlTextOrProcedure?: string; + callback?: UnknownFunction; + table?: string; + parametersByName?: Record; +} + +/** Context orchestrion attaches to the query channels (wrapping the `Connection` request methods). */ +interface TediousQueryChannelContext { + // `arguments[0]` is the `Request` (or `BulkLoad` for `execBulkLoad`), both `EventEmitter`s. + arguments: [TediousRequest?, ...unknown[]]; + self?: TediousConnection; + moduleVersion?: string; +} + +/** Context orchestrion attaches to the `Connection.connect` channel. */ +interface TediousConnectChannelContext { + arguments: unknown[]; + self?: TediousConnection; +} + +// Used both to seed the initial database and as the `databaseChange` listener, where `this` is the +// connection (a non-arrow listener). Keeping one shared reference lets `removeListener` find it again. +function setDatabase(this: TediousConnection, databaseName: string | undefined): void { + Object.defineProperty(this, currentDatabaseSymbol, { value: databaseName, writable: true, configurable: true }); +} + +// The `end` cleanup listener, where `this` is the connection (a non-arrow listener). Named (like +// `setDatabase`) so repeated `connect` calls can `removeListener` it rather than accumulate anonymous ones. +function removeDatabaseListener(this: TediousConnection): void { + this.removeListener('databaseChange', setDatabase); +} + +function subscribeConnect(): void { + diagnosticsChannel.tracingChannel(CHANNELS.TEDIOUS_CONNECT).start.subscribe(message => { + const connection = (message as TediousConnectChannelContext).self; + if (!connection) { + return; + } + + setDatabase.call(connection, connection.config?.options?.database); + + // Remove first in case `connect` runs more than once on the same connection, so neither listener + // accumulates across reconnects. + connection.removeListener('databaseChange', setDatabase); + connection.on('databaseChange', setDatabase); + connection.removeListener('end', removeDatabaseListener); + connection.once('end', removeDatabaseListener); + }); +} + +function subscribeQuery(channelName: string, operation: string): void { + diagnosticsChannel.tracingChannel(channelName).start.subscribe(message => { + const data = message as TediousQueryChannelContext; + const connection = data.self; + const request = data.arguments[0]; + + // The vendored instrumentation only traced when the first argument is an `EventEmitter` (a `Request` + // or `BulkLoad`); anything else is left untouched. + if (!connection || !(request instanceof EventEmitter)) { + return; + } + + let procCount = 0; + let statementCount = 0; + const incrementStatementCount = (): void => { + statementCount++; + }; + const incrementProcCount = (): void => { + procCount++; + }; + + const databaseName = connection[currentDatabaseSymbol]; + const sql = extractSql(request); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [DB_SYSTEM]: DB_SYSTEM_VALUE_MSSQL, + [DB_NAME]: databaseName, + // `>=4` uses the `authentication` object; older versions expose `userName` directly. + [DB_USER]: connection.config?.userName ?? connection.config?.authentication?.options?.userName, + [DB_STATEMENT]: sql, + [ATTR_DB_SQL_TABLE]: request.table, + [NET_PEER_NAME]: connection.config?.server, + [NET_PEER_PORT]: connection.config?.options?.port, + }; + + const span = startInactiveSpan({ + name: getSpanName(operation, databaseName, sql, request.table), + kind: SPAN_KIND.CLIENT, + op: 'db', + attributes, + }); + + const endSpan = once((err?: { message?: string }): void => { + request.removeListener('done', incrementStatementCount); + request.removeListener('doneInProc', incrementStatementCount); + request.removeListener('doneProc', incrementProcCount); + request.removeListener('error', endSpan); + connection.removeListener('end', endSpan); + + span.setAttribute('tedious.procedure_count', procCount); + span.setAttribute('tedious.statement_count', statementCount); + if (err) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: err.message }); + } + + span.end(); + }); + + request.on('done', incrementStatementCount); + request.on('doneInProc', incrementStatementCount); + request.on('doneProc', incrementProcCount); + request.once('error', endSpan); + connection.on('end', endSpan); + + // tedious invokes `request.callback` when the request settles (passing the error, if any). Wrapping it + // here (at `start`, before the method body dispatches) is the completion signal. A failed non-preparing + // request reports its error only through this callback, not via an `'error'` event. + if (typeof request.callback === 'function') { + const originalCallback = request.callback; + request.callback = function (this: unknown, ...args: unknown[]): unknown { + endSpan(args[0] as { message?: string } | undefined); + + return originalCallback.apply(this, args); + }; + } + }); +} + +function extractSql(request: TediousRequest): string | undefined { + // Required for <11.0.9: the SQL for a prepared statement is carried in the `stmt` parameter. + if (request.sqlTextOrProcedure === 'sp_prepare' && request.parametersByName?.stmt?.value != null) { + const value = request.parametersByName.stmt.value; + + return typeof value === 'string' ? value : undefined; + } + + return request.sqlTextOrProcedure; +} + +/** + * The span name is a low-cardinality label for the operation; the SDK's db-span inference later renames + * the span description off `db.statement` when present. Mirrors the vendored OTel `getSpanName`. + */ +function getSpanName( + operation: string, + db: string | undefined, + sql: string | undefined, + bulkLoadTable: string | undefined, +): string { + if (operation === 'execBulkLoad' && bulkLoadTable && db) { + return `${operation} ${bulkLoadTable} ${db}`; + } + if (operation === 'callProcedure') { + // `sql` refers to the procedure name for `callProcedure`. + return db ? `${operation} ${sql} ${db}` : `${operation} ${sql}`; + } + // Avoid `sql` in the general case because of its high cardinality. + return db ? `${operation} ${db}` : operation; +} + +function once(fn: (...args: Args) => void): (...args: Args) => void { + let called = false; + + return (...args: Args): void => { + if (called) { + return; + } + called = true; + fn(...args); + }; +} + +const _tediousChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + DEBUG_BUILD && debug.log(`[orchestrion:tedious] subscribing to channel "${CHANNELS.TEDIOUS_EXEC_SQL}"`); + + waitForTracingChannelBinding(() => { + subscribeConnect(); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL, 'execSql'); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_SQL_BATCH, 'execSqlBatch'); + subscribeQuery(CHANNELS.TEDIOUS_CALL_PROCEDURE, 'callProcedure'); + subscribeQuery(CHANNELS.TEDIOUS_EXEC_BULK_LOAD, 'execBulkLoad'); + subscribeQuery(CHANNELS.TEDIOUS_PREPARE, 'prepare'); + subscribeQuery(CHANNELS.TEDIOUS_EXECUTE, 'execute'); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL - orchestrion-driven tedious integration. + * + * Subscribes to the `orchestrion:tedious:*` diagnostics_channels that the orchestrion code transform + * injects into tedious's `Connection` request methods (each traced as one db span) and `Connection.connect` + * (active-database bookkeeping). Requires the orchestrion runtime hook or bundler plugin to be active. + */ +export const tediousChannelIntegration = defineIntegration(_tediousChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/config/tedious.ts b/packages/server-utils/src/orchestrion/config/tedious.ts index 8c9ee313d23c..9561d201f459 100644 --- a/packages/server-utils/src/orchestrion/config/tedious.ts +++ b/packages/server-utils/src/orchestrion/config/tedious.ts @@ -1,6 +1,31 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -// TODO: Stub for the `tedious` orchestrion integration (ports `@opentelemetry/instrumentation-tedious`). -export const tediousConfig: InstrumentationConfig[] = []; +const MODULE_NAME = 'tedious'; -export const tediousChannels = {} as const; +// `Connection` has lived in `lib/connection.js` across the whole supported range (matches the vendored +// OTel `supportedVersions`). Orchestrion never matches a file that doesn't exist, so a single entry is +// safe even for versions that shipped extra layouts. +const FILE_PATH = 'lib/connection.js'; +const VERSION_RANGE = '>=1.11.0 <20'; + +// `Connection` methods that dispatch a request (each traced as one db span) plus `connect`, which the +// subscriber wraps for bookkeeping only (tracking the connection's active database, read into `db.name`). +// All return synchronously; the request completes later via its callback/events, so the subscriber owns +// span-ending rather than the channel lifecycle. +const METHODS = ['connect', 'execSql', 'execSqlBatch', 'callProcedure', 'execBulkLoad', 'prepare', 'execute'] as const; + +export const tediousConfig: InstrumentationConfig[] = METHODS.map(methodName => ({ + channelName: methodName, + module: { name: MODULE_NAME, versionRange: VERSION_RANGE, filePath: FILE_PATH }, + functionQuery: { className: 'Connection', methodName, kind: 'Sync' }, +})); + +export const tediousChannels = { + TEDIOUS_CONNECT: 'orchestrion:tedious:connect', + TEDIOUS_EXEC_SQL: 'orchestrion:tedious:execSql', + TEDIOUS_EXEC_SQL_BATCH: 'orchestrion:tedious:execSqlBatch', + TEDIOUS_CALL_PROCEDURE: 'orchestrion:tedious:callProcedure', + TEDIOUS_EXEC_BULK_LOAD: 'orchestrion:tedious:execBulkLoad', + TEDIOUS_PREPARE: 'orchestrion:tedious:prepare', + TEDIOUS_EXECUTE: 'orchestrion:tedious:execute', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 2282477c32bb..be6e20672f98 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -19,6 +19,7 @@ import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2 import { openaiChannelIntegration } from '../integrations/tracing-channel/openai'; import { postgresChannelIntegration } from '../integrations/tracing-channel/postgres'; import { postgresJsChannelIntegration } from '../integrations/tracing-channel/postgres-js'; +import { tediousChannelIntegration } from '../integrations/tracing-channel/tedious'; import { vercelAiChannelIntegration } from '../integrations/tracing-channel/vercel-ai'; import { expressChannelIntegration } from '../integrations/tracing-channel/express'; @@ -45,6 +46,7 @@ export { openaiChannelIntegration, postgresChannelIntegration, postgresJsChannelIntegration, + tediousChannelIntegration, vercelAiChannelIntegration, expressChannelIntegration, }; @@ -98,4 +100,5 @@ export const channelIntegrations = { expressIntegration: expressChannelIntegration, graphqlIntegration: graphqlDiagnosticsChannelIntegration, kafkajsIntegration: kafkajsChannelIntegration, + tediousIntegration: tediousChannelIntegration, } as const; From 38d0d7bf04290d2a4cb9bedda080f6369537500b Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 16 Jul 2026 18:44:50 +0200 Subject: [PATCH 15/54] fix(vercelai): Avoid double-capturing v4 tool errors in orchestrion mode (#22293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In orchestrion mode, a v4 Vercel AI tool error was reported to Sentry **twice**: once by the channel subscriber (`captureToolError`, which captured the raw error mid-execution) and again when the SDK's wrapped `AI_ToolExecutionError` bubbled up to the app's error handling (express handler, or the global unhandled-rejection handler). The two events are different objects (raw error vs. wrapper), so Sentry's already-captured dedup didn't collapse them. This also caused a flaky test: `captures error in tool in express server` used `.unordered()` with a single `event` expect and matched whichever of the two error envelopes arrived first — when the express-handler envelope won the race, the tool-tag assertions failed. _Root cause_ The channel subscriber's tool-`execute` wrapper always captured the thrown error and re-threw. That capture is required on **v5**, where `executeTools` swallows the rejection into `tool-error` content so it never surfaces otherwise. On **v4** the rejection instead bubbles out of the `ai` call, so it is already captured by the app's error handling — making the channel capture a duplicate. The OTel integration never had this problem because it doesn't self-capture v4 thrown tool errors; it lets them bubble. Changes: - The orchestrion subscriber now only self-captures tool errors when the enclosing operation swallows them (v5+, detected via the `'v1'` model spec version already used elsewhere in the file). On v4 it marks the tool span as errored and lets the error bubble, matching the OTel path — so a single error event is reported. - To preserve trace correlation for bubbled errors, the subscriber stamps the operation's call-site span onto the error via `_sentry_active_span`, reusing the exact mechanism the OTel integration already uses (`onunhandledrejection` restores it at capture time). Without this, an unhandled rejection in a non-OTel (orchestrion) process would start its own trace instead of correlating to the transaction. - Both of the above are per-operation facts keyed by the operation span, so they're stored together in a single `WeakMap` (`{ callSiteSpan, toolErrorsBubbleToCaller }`) written once at operation start, rather than two parallel collections. - Tests updated: both v4 error-in-tool scenarios now assert a single `AI_ToolExecutionError` event correlated to the transaction, in both orchestrion and OTel modes. Fixes the flaky `suites/tracing/vercelai/test.ts > ... > captures error in tool in express server [cjs]`. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../suites/tracing/vercelai/test.ts | 68 ++++++++---------- .../vercel-ai-orchestrion-subscriber.ts | 69 +++++++++++++++---- 2 files changed, 84 insertions(+), 53 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 0b91ce62a02c..b759495545cc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -321,28 +321,22 @@ describe('Vercel AI integration (v4)', () => { expect(errorEvent).toBeDefined(); expect(errorEvent!.tags).toMatchObject({ 'test-tag': 'test-value' }); - // Trace id is shared between the transaction and the tool error. - expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); - if (orchestrion) { - // The channel subscriber captures the raw tool error and tags it with the tool identity. - expect(errorEvent!.level).toBe('error'); - expect(errorEvent!.tags).toMatchObject({ - 'vercel.ai.tool.name': 'getWeather', - 'vercel.ai.tool.callId': 'call-1', - }); - } else { - // The OTel processor surfaces the SDK's wrapped `AI_ToolExecutionError`. - expect(errorEvent!.exception?.values).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'AI_ToolExecutionError', - value: 'Error executing tool getWeather: Error in tool', - }), - ]), - ); - expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); - } + // The tool error bubbles out of the `ai` call as the SDK's wrapped `AI_ToolExecutionError`. The + // channel subscriber deliberately doesn't self-capture v4 tool errors (that would double-report + // alongside the bubbled error), so a single error event is produced in both modes. + expect(errorEvent!.exception?.values).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + ); + // Both paths stamp the operation's call-site span onto the bubbled error, so the global + // unhandled-rejection handler restores it and correlates the report to the transaction's root span. + expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); + expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); }); }); @@ -352,7 +346,7 @@ describe('Vercel AI integration (v4)', () => { let errorEvent: Event | undefined; const runner = createRunner() - // In orchestrion mode the tool error is captured mid-request, so envelopes can arrive in any order. + // The error and transaction/span envelopes can arrive in either order, so assert content, not order. .unordered() .expect({ transaction: transaction => { @@ -403,24 +397,18 @@ describe('Vercel AI integration (v4)', () => { expect(errorEvent!.tags).toMatchObject({ 'test-tag': 'test-value' }); expect(errorEvent!.contexts!.trace!.trace_id).toBe(transactionEvent!.contexts!.trace!.trace_id); - if (orchestrion) { - // The channel subscriber captures the raw tool error and tags it with the tool identity. - expect(errorEvent!.tags).toMatchObject({ - 'vercel.ai.tool.name': 'getWeather', - 'vercel.ai.tool.callId': 'call-1', - }); - } else { - // The OTel processor surfaces the SDK's wrapped `AI_ToolExecutionError`. - expect(errorEvent!.exception?.values).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'AI_ToolExecutionError', - value: 'Error executing tool getWeather: Error in tool', - }), - ]), - ); - expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); - } + // The tool error bubbles out of the `ai` call as the SDK's wrapped `AI_ToolExecutionError` and is + // captured once by the express error handler — the channel subscriber deliberately doesn't + // self-capture v4 tool errors, so orchestrion and OTel produce the same single error event. + expect(errorEvent!.exception?.values).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'AI_ToolExecutionError', + value: 'Error executing tool getWeather: Error in tool', + }), + ]), + ); + expect(errorEvent!.contexts!.trace!.span_id).toBe(transactionEvent!.contexts!.trace!.span_id); }); }); diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts index 4548ecc73b35..b41290b281e9 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-orchestrion-subscriber.ts @@ -1,6 +1,13 @@ /* eslint-disable max-lines */ import type { Span } from '@sentry/core'; -import { debug, getActiveSpan, isObjectLike, SPAN_STATUS_ERROR, withActiveSpan } from '@sentry/core'; +import { + addNonEnumerableProperty, + debug, + getActiveSpan, + isObjectLike, + SPAN_STATUS_ERROR, + withActiveSpan, +} from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { CHANNELS } from '../orchestrion/channels'; import { bindTracingChannelToSpan, type TracingChannelPayloadWithSpan } from '../tracing-channel'; @@ -100,6 +107,21 @@ const callIdBySpan = new WeakMap(); // child `generate_content` span (whose event would fall back to the global default). v7's channel forwards // these flags on every event, so this keeps v6 identical. const recordingBySpan = new WeakMap>(); +interface OperationErrorInfo { + // The span active when the operation was invoked (its call site, e.g. the enclosing request/`main` span). + // When an operation's error bubbles out unhandled it reaches the global handler outside any span, so — as + // the OTel path does — we stamp this span onto the error (in `beforeSpanEnd`) so the unhandled-rejection + // handler can restore it and correlate the captured error to the operation's trace. + callSiteSpan: Span | undefined; + // Whether a thrown tool error bubbles out of the `ai` call (v4). On v4 a tool-`execute` rejection is + // wrapped in `AI_ToolExecutionError` and re-thrown, so it reaches the user's own error handling (or our + // global handlers) — capturing it at the tool span too would double-report it, matching how the OTel path + // leaves v4 tool errors to bubble. On v5 `executeTools` swallows the rejection into `tool-error` content, + // so it never surfaces and we must capture it ourselves (see `failSpan`). + toolErrorsBubbleToCaller: boolean; +} +// Per-operation error-handling info, keyed by the operation span (see `OperationErrorInfo`). +const operationErrorInfoBySpan = new WeakMap(); // The `experimental_telemetry` objects we swapped in to suppress `ai`'s native OTel spans (see // `suppressNativeTelemetry`). Our skip logic treats `isEnabled === false` as "user disabled telemetry, @@ -220,10 +242,17 @@ function bindOperation( // from the channels, so the SDK's would be duplicates. Reads above have already captured everything // we need off `telemetry`. suppressNativeTelemetry(callOptions, telemetry); + // The span active here is the operation's call site — `bindTracingChannelToSpan` only makes the + // operation span active *after* this returns — so this is the span we later restore for a bubbled error. + const callSiteSpan = getActiveSpan(); const span = createSpanFromMessage(message, options); if (span) { messages.set(data, message); operationSpans.add(span); + // `'v1'` models are v4 (v5/v6 are `'v2'`), where a thrown tool error bubbles out of the call rather + // than being swallowed into `tool-error` content — so its tool spans must not self-capture the error. + const isV4 = isObjectLike(callOptions.model) && callOptions.model.specificationVersion === 'v1'; + operationErrorInfoBySpan.set(span, { callSiteSpan, toolErrorsBubbleToCaller: isV4 }); // v6's `executeToolCall` span: tracked so the v5 tool-`execute` patch can recognize (and skip) // tool calls it runs, since that patch sees this span as its active parent (see `patchToolExecute`). if (message.type === 'executeTool') { @@ -239,13 +268,12 @@ function bindOperation( if (isObjectLike(callOptions.tools)) { patchOperationTools(callOptions.tools, options); } - // v4 has no `resolveLanguageModel` chokepoint — the passed `LanguageModelV1` - // (`specificationVersion: 'v1'`) is called directly — so patch its `doGenerate`/`doStream` - // here, at the operation start, instead. v5/v6 models are `'v2'` and are patched via - // `resolveLanguageModel`, so restricting to `'v1'` keeps this strictly additive and avoids - // double-patching. Embedding models are also `'v1'` but expose no `doGenerate`/`doStream`, so - // the patch is a no-op for `embed`/`embedMany`. - if (isObjectLike(callOptions.model) && callOptions.model.specificationVersion === 'v1') { + // v4 has no `resolveLanguageModel` chokepoint — the passed `LanguageModelV1` is called directly — so + // patch its `doGenerate`/`doStream` here, at the operation start, instead. v5/v6 models are patched via + // `resolveLanguageModel`, so restricting to v4 keeps this strictly additive and avoids double-patching. + // Embedding models are also `'v1'` but expose no `doGenerate`/`doStream`, so the patch is a no-op for + // `embed`/`embedMany`. + if (isV4) { patchModelMethods(callOptions.model as PatchableModel, options); } } @@ -262,7 +290,16 @@ function bindOperation( return; } // The helper's `error` handler already set the span status; only enrich from a successful result. - if (!('error' in data)) { + if ('error' in data) { + // The error bubbles out of the `ai` call. If nothing handles it before our global handler, that + // handler runs outside any span — so stamp the operation's call-site span onto the error, letting + // the unhandled-rejection handler restore it and correlate the report to this trace. This mirrors + // the OTel path; it's a no-op when the error is handled earlier (e.g. a framework error handler). + const callSiteSpan = operationErrorInfoBySpan.get(span)?.callSiteSpan; + if (callSiteSpan && isObjectLike(data.error)) { + addNonEnumerableProperty(data.error, '_sentry_active_span', callSiteSpan); + } + } else { // v6's `executeToolCall` returns the tool result/error object directly, whereas the shared core // (matching v7) expects it nested under `output`; wrap it so tool-error detection works. message.result = message.type === 'executeTool' ? { output: data.result } : data.result; @@ -586,11 +623,17 @@ function patchToolExecute( return original.apply(this, [input, ...rest]); } - // v5's `executeTools` catches a thrown tool error and turns it into `tool-error` content rather - // than rejecting, so the user never sees a rejection — we must capture it here. Rethrow so - // `executeTools` still produces its `tool-error` result and the operation continues normally. + // v5's `executeTools` catches a thrown tool error and turns it into `tool-error` content rather than + // rejecting, so the user never sees a rejection — we must capture it here. On v4 the rejection bubbles + // out of the call and reaches the user's error handling (or our global handlers), so we only mark the + // span and leave the capture to them, avoiding a duplicate report. Rethrow either way so `executeTools` + // still produces its `tool-error` result (v5) and the rejection propagates (v4). const failSpan = (error: unknown): never => { - captureToolError(span, message, error); + if (operationErrorInfoBySpan.get(parent)?.toolErrorsBubbleToCaller) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: error instanceof Error ? error.message : 'tool_error' }); + } else { + captureToolError(span, message, error); + } span.end(); throw error; }; From 8fb6d5c36e1e0d6843ab48786ed318a154a67799 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 16 Jul 2026 18:10:08 +0100 Subject: [PATCH 16/54] fix(server-utils): Only skip loader thread (#22336) I missed this commit off of #22327. This ensure that we only skip registering the hooks on the loader thread which means Sentry in a worker thread can still hook loading. --- .../src/orchestrion/runtime/register.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index a1f77e98bcbf..18f9638175d3 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -2,7 +2,7 @@ import { debug, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; -import { isMainThread, MessageChannel } from 'node:worker_threads'; +import { isMainThread, MessageChannel, parentPort } from 'node:worker_threads'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import type { register } from 'node:module'; @@ -63,9 +63,15 @@ function hasStableSyncModuleHooks(denoVersionString: string | undefined): boolea * the channel-based integrations subscribe to. */ export function registerDiagnosticsChannelInjection(options?: RegisterDiagnosticsChannelInjectionOptions): void { - // We only want to register the hook once from the main thread. - // --import hooks are run in every worker thread! - if (!isMainThread) { + // Skip Node's internal loader (hooks) threads, recognizable as the only threads without a + // `parentPort`. Node re-runs `--require` preloads (though not `--import` ones) on the loader + // thread it spawns for `Module.register()`, so this function runs there too — but that thread + // never executes app code, and its `register()` implementation (the in-thread `Hooks` class) + // has no `transferList` parameter: our transfer array lands in its `isInternal` parameter and + // Node crashes trying to load the hook as an internal builtin. User-created workers always + // have a `parentPort` and register through `CustomizedModuleLoader`, which handles + // `transferList` correctly, so they proceed and get their own instrumented loader. + if (!isMainThread && !parentPort) { return; } From cf559f97e510e2739806af059f9a966c4a4c8f96 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 16 Jul 2026 19:39:19 +0200 Subject: [PATCH 17/54] ref(server-utils): Collapse express route/use into one orchestrion channel (#22331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges the express `route`/`use` registration channels into a single per-module `register` channel to reduce the number of tracing channels — the subscriber treats both identically, so there's no behavior change. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../tracing-channel/express/instrumentation.ts | 7 +------ .../src/orchestrion/config/express.ts | 17 +++++++++-------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts index d3cfe0dc13ec..19db7a815d14 100644 --- a/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts +++ b/packages/server-utils/src/integrations/tracing-channel/express/instrumentation.ts @@ -60,12 +60,7 @@ export function instrumentExpress( // matched route can be reconstructed with its parameters intact at request // time. Only the `end` event matters (the layer is on the router's stack by // then); the others are required by the subscriber type, so no-op them. - for (const channelName of [ - CHANNELS.EXPRESS_ROUTE, - CHANNELS.EXPRESS_USE, - CHANNELS.ROUTER_ROUTE, - CHANNELS.ROUTER_USE, - ]) { + for (const channelName of [CHANNELS.EXPRESS_REGISTER, CHANNELS.ROUTER_REGISTER]) { tracingChannel(channelName).subscribe({ start: NOOP, asyncStart: NOOP, diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index b7290dd2a7b4..e20a9d9d3094 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -32,25 +32,28 @@ export const expressConfig = [ // handler, `use`'s trailing function argument is a registration payload, not a // callback — so `Callback` would misclassify it and never fire `end`. // + // `route` and `use` share one `register` channel because the subscriber handles + // them identically, saving a channel per module. + // // Express v4 ships its own router in `express/lib/router/index.js`. { - channelName: 'route', + channelName: 'register', module: { name: 'express', versionRange: '>=4.0.0 <5', filePath: 'lib/router/index.js' }, functionQuery: { expressionName: 'route', kind: 'Sync' }, }, { - channelName: 'use', + channelName: 'register', module: { name: 'express', versionRange: '>=4.0.0 <5', filePath: 'lib/router/index.js' }, functionQuery: { expressionName: 'use', kind: 'Sync' }, }, // Express v5 delegates routing to the standalone `router` package. { - channelName: 'route', + channelName: 'register', module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, functionQuery: { expressionName: 'route', kind: 'Sync' }, }, { - channelName: 'use', + channelName: 'register', module: { name: 'router', versionRange: '>=2.0.0 <3', filePath: 'index.js' }, functionQuery: { expressionName: 'use', kind: 'Sync' }, }, @@ -66,8 +69,6 @@ export const expressChannels = { // Layer *registration* (`Router.prototype.route`/`.use`), used to capture each // layer's registered path pattern so the matched route can be reconstructed // with its parameters intact (`req.baseUrl` only exposes the resolved prefix). - EXPRESS_ROUTE: 'orchestrion:express:route', - EXPRESS_USE: 'orchestrion:express:use', - ROUTER_ROUTE: 'orchestrion:router:route', - ROUTER_USE: 'orchestrion:router:use', + EXPRESS_REGISTER: 'orchestrion:express:register', + ROUTER_REGISTER: 'orchestrion:router:register', } as const; From 2cfeb044b6629e770cd4dbe5de42636a900c4cdf Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 16 Jul 2026 20:01:09 +0200 Subject: [PATCH 18/54] ref(server-utils): Share one orchestrion channel across OpenAI chat/responses/conversations (#22332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the OpenAI `responses` and `conversations` APIs at the existing `chat` channel to reduce the number of tracing channels — all three already report the same `chat` operation with identical span handling, so there's no behavior change. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/integrations/tracing-channel/openai.ts | 2 -- packages/server-utils/src/orchestrion/config/openai.ts | 8 ++++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/src/integrations/tracing-channel/openai.ts b/packages/server-utils/src/integrations/tracing-channel/openai.ts index e1498db89e43..b172c6924300 100644 --- a/packages/server-utils/src/integrations/tracing-channel/openai.ts +++ b/packages/server-utils/src/integrations/tracing-channel/openai.ts @@ -29,9 +29,7 @@ const ORIGIN = 'auto.ai.orchestrion.openai'; // Each instrumented `create` method maps to the gen_ai operation its span reports. const INSTRUMENTED_CHANNELS = [ { channel: CHANNELS.OPENAI_CHAT, operation: 'chat' }, - { channel: CHANNELS.OPENAI_RESPONSES, operation: 'chat' }, { channel: CHANNELS.OPENAI_EMBEDDINGS, operation: 'embeddings' }, - { channel: CHANNELS.OPENAI_CONVERSATIONS, operation: 'chat' }, ] as const; /** diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index e8091003fc58..4d32e45cf2ca 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -11,7 +11,7 @@ export const openaiConfig = [ })), // OpenAI responses API — same `create(body, options)` shape as chat completions. ...['resources/responses/responses.js', 'resources/responses/responses.mjs'].map(filePath => ({ - channelName: 'responses', + channelName: 'chat', module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, functionQuery: { className: 'Responses', methodName: 'create', kind: 'Auto' as const }, })), @@ -23,15 +23,15 @@ export const openaiConfig = [ })), // OpenAI conversations API — same `create(body, options)` shape as chat completions. ...['resources/conversations/conversations.js', 'resources/conversations/conversations.mjs'].map(filePath => ({ - channelName: 'conversations', + channelName: 'chat', module: { name: 'openai', versionRange: '>=4.0.0 <7', filePath }, functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Auto' as const }, })), ] satisfies InstrumentationConfig[]; export const openaiChannels = { + // Chat completions, the responses API, and the conversations API all report a `chat` operation with + // identical span handling, so they share one channel. OPENAI_CHAT: 'orchestrion:openai:chat', - OPENAI_RESPONSES: 'orchestrion:openai:responses', OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', - OPENAI_CONVERSATIONS: 'orchestrion:openai:conversations', } as const; From ea5a462e6f2bc8fb6b31b74ca4211000904868cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 16 Jul 2026 22:26:12 +0300 Subject: [PATCH 19/54] test(cloudflare): Proof that AI providers work OOTB on Cloudflare (#22313) The Cloudflare integration tests were already testing all AI integrations, but used mocks. I changed the following in all tests: - Removed the `mocks.ts` and changed it to the actual package - Instead the `.fetch` is being mocked - Aligned the assertions --- .../cloudflare-integration-tests/package.json | 7 +- .../suites/tracing/anthropic-ai/index.ts | 25 ++- .../suites/tracing/anthropic-ai/mocks.ts | 68 ------ .../suites/tracing/anthropic-ai/test.ts | 56 ++--- .../tracing/anthropic-ai/wrangler.jsonc | 2 +- .../suites/tracing/google-genai/index.ts | 51 +++-- .../suites/tracing/google-genai/mocks.ts | 142 ------------- .../suites/tracing/google-genai/test.ts | 149 ++++++------- .../tracing/google-genai/wrangler.jsonc | 2 +- .../suites/tracing/langchain/index.ts | 52 ++--- .../suites/tracing/langchain/mocks.ts | 197 ------------------ .../suites/tracing/langchain/test.ts | 80 +++---- .../suites/tracing/langchain/wrangler.jsonc | 2 +- .../suites/tracing/openai/index.ts | 33 ++- .../suites/tracing/openai/mocks.ts | 50 ----- .../suites/tracing/openai/test.ts | 59 +++--- .../suites/tracing/openai/wrangler.jsonc | 2 +- 17 files changed, 272 insertions(+), 705 deletions(-) delete mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/mocks.ts delete mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts delete mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts delete mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/openai/mocks.ts diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 1a224e6d0071..c360d7449ed6 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -15,12 +15,17 @@ }, "dependencies": { "ai": "^6.0.0", + "@anthropic-ai/sdk": "0.63.0", + "@google/genai": "^1.20.0", + "@langchain/core": "^0.3.80", "@langchain/langgraph": "^1.0.1", + "@langchain/openai": "^0.5.0", "@prisma/adapter-d1": "6.15.0", "@prisma/client": "6.15.0", "@sentry/cloudflare": "10.66.0", "@sentry/hono": "10.66.0", - "hono": "^4.12.25" + "hono": "^4.12.25", + "openai": "5.18.1" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260426.0", diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts index 9ff6a9406258..384fba1965dd 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/index.ts @@ -1,16 +1,27 @@ +import Anthropic from '@anthropic-ai/sdk'; import * as Sentry from '@sentry/cloudflare'; -import type { AnthropicAiClient } from '@sentry/core'; -import { MockAnthropic } from './mocks'; interface Env { SENTRY_DSN: string; } -const mockClient = new MockAnthropic({ - apiKey: 'mock-api-key', -}); +// Return a canned response so the `@anthropic-ai/sdk` runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'msg_mock123', + type: 'message', + role: 'assistant', + model: 'claude-3-haiku-20240307', + content: [{ type: 'text', text: 'Hello from Anthropic!' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 15 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); -const client: AnthropicAiClient = Sentry.instrumentAnthropicAiClient(mockClient); +const client = Sentry.instrumentAnthropicAiClient(new Anthropic({ apiKey: 'mock-api-key', fetch: mockFetch })); export default Sentry.withSentry( (env: Env) => ({ @@ -20,7 +31,7 @@ export default Sentry.withSentry( }), { async fetch(_request, _env, _ctx) { - const response = await client.messages?.create({ + const response = await client.messages.create({ model: 'claude-3-haiku-20240307', messages: [{ role: 'user', content: 'What is the capital of France?' }], temperature: 0.7, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/mocks.ts deleted file mode 100644 index cff87ca84cbc..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/mocks.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { AnthropicAiClient, AnthropicAiResponse } from '@sentry/core'; - -export class MockAnthropic implements AnthropicAiClient { - public messages: { - create: (...args: unknown[]) => Promise; - countTokens: (...args: unknown[]) => Promise; - }; - public models: { - list: (...args: unknown[]) => Promise; - get: (...args: unknown[]) => Promise; - }; - public completions: { - create: (...args: unknown[]) => Promise; - }; - public apiKey: string; - - public constructor(config: { apiKey: string }) { - this.apiKey = config.apiKey; - - // Main focus: messages.create functionality - this.messages = { - create: async (...args: unknown[]) => { - const params = args[0] as { model: string; stream?: boolean }; - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - (error as unknown as { headers: Record }).headers = { 'x-request-id': 'mock-request-123' }; - throw error; - } - - return { - id: 'msg_mock123', - type: 'message', - role: 'assistant', - model: params.model, - content: [ - { - type: 'text', - text: 'Hello from Anthropic mock!', - }, - ], - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 15, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - }; - }, - countTokens: async (..._args: unknown[]) => ({ id: 'mock', type: 'model', model: 'mock', input_tokens: 0 }), - }; - - // Minimal implementations for required interface compliance - this.models = { - list: async (..._args: unknown[]) => ({ id: 'mock', type: 'model', model: 'mock' }), - get: async (..._args: unknown[]) => ({ id: 'mock', type: 'model', model: 'mock' }), - }; - - this.completions = { - create: async (..._args: unknown[]) => ({ id: 'mock', type: 'completion', model: 'mock' }), - }; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index 4f60868cddfb..6817843cde27 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -1,6 +1,7 @@ import { expect, it } from 'vitest'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, GEN_AI_RESPONSE_ID_ATTRIBUTE, @@ -8,48 +9,49 @@ import { GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `@anthropic-ai/sdk` on the Workers runtime (with a +// canned fetch) to verify the instrumentation works end-to-end on Cloudflare, +// not just against a hand-written mock client. -it('traces a basic message creation request', async ({ signal }) => { +it('traces a basic message creation request with the anthropic SDK', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - // [0] chat claude-3-haiku-20240307 - expect(firstSpan!.name).toBe('chat claude-3-haiku-20240307'); - expect(firstSpan!.status).toBe('ok'); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(firstSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(firstSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.anthropic' }); - expect(firstSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'anthropic' }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'claude-3-haiku-20240307', - }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ type: 'double', value: 0.7 }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'claude-3-haiku-20240307', + expect(container.items[0]).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat claude-3-haiku-20240307', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.anthropic', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'anthropic', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'claude-3-haiku-20240307', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: { value: 100, type: 'integer' }, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: { value: 'msg_mock123', type: 'string' }, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: { value: 'claude-3-haiku-20240307', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 10, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 15, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 25, type: 'integer' }, + }, }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]).toEqual({ type: 'string', value: 'msg_mock123' }); - expect(firstSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 10 }); - expect(firstSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 15 }); }) .start(signal); await runner.makeRequest('get', '/'); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc index 48aaa79409a3..d853a1899850 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "anthropic-ai-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts index ca6138ce17ef..635de9ebe95f 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts @@ -1,16 +1,45 @@ +import { GoogleGenAI } from '@google/genai'; import * as Sentry from '@sentry/cloudflare'; -import type { GoogleGenAIClient } from '@sentry/core'; -import { MockGoogleGenAI } from './mocks'; interface Env { SENTRY_DSN: string; } -const mockClient = new MockGoogleGenAI({ - apiKey: 'mock-api-key', -}); +// `@google/genai` has no per-client fetch option — it calls the global `fetch` +// directly. Override it (gated to the Gemini host) so the SDK runs on +// workerd without hitting the network, while Sentry envelope delivery still +// uses the original fetch. +const originalFetch = globalThis.fetch; +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; -const client: GoogleGenAIClient = Sentry.instrumentGoogleGenAIClient(mockClient); + if (!url.includes('generativelanguage.googleapis.com')) { + return originalFetch(input, init); + } + + if (url.includes(':embedContent') || url.includes(':batchEmbedContents')) { + return new Response(JSON.stringify({ embeddings: [{ values: [0.1, 0.2, 0.3, 0.4, 0.5] }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + + return new Response( + JSON.stringify({ + candidates: [ + { + content: { parts: [{ text: 'Hello from Google GenAI!' }], role: 'model' }, + finishReason: 'stop', + index: 0, + }, + ], + usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 12, totalTokenCount: 20 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); +}) as typeof fetch; + +const client = Sentry.instrumentGoogleGenAIClient(new GoogleGenAI({ apiKey: 'mock-api-key' })); export default Sentry.withSentry( (env: Env) => ({ @@ -36,12 +65,10 @@ export default Sentry.withSentry( ], }); - const chatResponse = await chat.sendMessage({ - message: 'Tell me a joke', - }); + await chat.sendMessage({ message: 'Tell me a joke' }); // Test 2: models.generateContent - const modelResponse = await client.models.generateContent({ + await client.models.generateContent({ model: 'gemini-1.5-flash', config: { temperature: 0.7, @@ -57,12 +84,12 @@ export default Sentry.withSentry( }); // Test 3: models.embedContent - const embedResponse = await client.models.embedContent({ + await client.models.embedContent({ model: 'text-embedding-004', contents: 'Hello world', }); - return new Response(JSON.stringify({ chatResponse, modelResponse, embedResponse })); + return new Response(JSON.stringify({ success: true })); }, }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts deleted file mode 100644 index c3eb23d7d5f5..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { GoogleGenAIChat, GoogleGenAIClient, GoogleGenAIResponse } from '@sentry/core'; - -export class MockGoogleGenAI implements GoogleGenAIClient { - public models: { - generateContent: (...args: unknown[]) => Promise; - generateContentStream: (...args: unknown[]) => Promise>; - embedContent: (...args: unknown[]) => Promise<{ embeddings: { values: number[] }[] }>; - }; - public chats: { - create: (...args: unknown[]) => GoogleGenAIChat; - }; - public apiKey: string; - - public constructor(config: { apiKey: string }) { - this.apiKey = config.apiKey; - - // models.generateContent functionality - this.models = { - generateContent: async (...args: unknown[]) => { - const params = args[0] as { model: string; contents?: unknown }; - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - (error as unknown as { headers: Record }).headers = { 'x-request-id': 'mock-request-123' }; - throw error; - } - - return { - candidates: [ - { - content: { - parts: [ - { - text: 'Hello from Google GenAI mock!', - }, - ], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - usageMetadata: { - promptTokenCount: 8, - candidatesTokenCount: 12, - totalTokenCount: 20, - }, - }; - }, - embedContent: async (...args: unknown[]) => { - const params = args[0] as { model: string; contents?: unknown }; - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - throw error; - } - - return { - embeddings: [{ values: [0.1, 0.2, 0.3, 0.4, 0.5] }], - }; - }, - generateContentStream: async () => { - // Return a promise that resolves to an async generator - return (async function* (): AsyncGenerator { - yield { - candidates: [ - { - content: { - parts: [{ text: 'Streaming response' }], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - }; - })(); - }, - }; - - // chats.create implementation - this.chats = { - create: (...args: unknown[]) => { - const params = args[0] as { model: string; config?: Record }; - const model = params.model; - - return { - modelVersion: model, - sendMessage: async (..._messageArgs: unknown[]) => { - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - return { - candidates: [ - { - content: { - parts: [ - { - text: 'This is a joke from the chat!', - }, - ], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - usageMetadata: { - promptTokenCount: 8, - candidatesTokenCount: 12, - totalTokenCount: 20, - }, - modelVersion: model, // Include model version in response - }; - }, - sendMessageStream: async () => { - // Return a promise that resolves to an async generator - return (async function* (): AsyncGenerator { - yield { - candidates: [ - { - content: { - parts: [{ text: 'Streaming chat response' }], - role: 'model', - }, - finishReason: 'stop', - index: 0, - }, - ], - }; - })(); - }, - }; - }, - }; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index 9dfa2b3d221d..30941df9b7ea 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -13,106 +13,85 @@ import { } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `@google/genai` SDK on the Workers runtime (with a +// canned global fetch) to verify the instrumentation works end-to-end on +// Cloudflare, not just against a hand-written mock client. -it('traces Google GenAI chat creation and message sending', async ({ signal }) => { +it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); expect(container.items).toHaveLength(3); - expect(container.items.map((span: SerializedStreamedSpan) => span.name).sort()).toEqual([ - 'chat gemini-1.5-pro', - 'embeddings text-embedding-004', - 'generate_content gemini-1.5-flash', - ]); - const chatSpan = container.items.find((span: SerializedStreamedSpan) => span.name === 'chat gemini-1.5-pro'); - expect(chatSpan).toBeDefined(); - expect(chatSpan!.status).toBe('ok'); - expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(chatSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(chatSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.google_genai' }); - expect(chatSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'google_genai' }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'gemini-1.5-pro', - }); - expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 8 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 12 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 20 }); + const byName = (name: string): SerializedStreamedSpan => + container.items.find((span: SerializedStreamedSpan) => span.name === name); - const generateContentSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'generate_content gemini-1.5-flash', - ); - expect(generateContentSpan).toBeDefined(); - expect(generateContentSpan!.status).toBe('ok'); - expect(generateContentSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'generate_content', - }); - expect(generateContentSpan!.attributes['sentry.op']).toEqual({ - type: 'string', - value: 'gen_ai.generate_content', - }); - expect(generateContentSpan!.attributes['sentry.origin']).toEqual({ - type: 'string', - value: 'auto.ai.google_genai', - }); - expect(generateContentSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'google_genai', - }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'gemini-1.5-flash', - }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ - type: 'double', - value: 0.7, - }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE]).toEqual({ type: 'double', value: 0.9 }); - expect(generateContentSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 100, - }); - expect(generateContentSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 8, - }); - expect(generateContentSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 12, - }); - expect(generateContentSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ - type: 'integer', - value: 20, + expect(byName('chat gemini-1.5-pro')).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat gemini-1.5-pro', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.google_genai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'google_genai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gemini-1.5-pro', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 8, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 12, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 20, type: 'integer' }, + }, }); - const embeddingsSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'embeddings text-embedding-004', - ); - expect(embeddingsSpan).toBeDefined(); - expect(embeddingsSpan!.status).toBe('ok'); - expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'embeddings', + expect(byName('generate_content gemini-1.5-flash')).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'generate_content gemini-1.5-flash', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.google_genai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.generate_content', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'google_genai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'generate_content', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gemini-1.5-flash', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_REQUEST_TOP_P_ATTRIBUTE]: { value: 0.9, type: 'double' }, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: { value: 100, type: 'integer' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 8, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 12, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 20, type: 'integer' }, + }, }); - expect(embeddingsSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.embeddings' }); - expect(embeddingsSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.google_genai' }); - expect(embeddingsSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'google_genai' }); - expect(embeddingsSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'text-embedding-004', + + expect(byName('embeddings text-embedding-004')).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'embeddings text-embedding-004', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.google_genai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.embeddings', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'google_genai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'embeddings', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'text-embedding-004', type: 'string' }, + }, }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc index 48aaa79409a3..089f1332bd7a 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "google-genai-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts index 7fac26ed8774..bae6245a01ad 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/index.ts @@ -1,10 +1,31 @@ +import { ChatOpenAI } from '@langchain/openai'; import * as Sentry from '@sentry/cloudflare'; -import { type CallbackHandler, MockChain, MockChatModel, MockTool } from './mocks'; interface Env { SENTRY_DSN: string; } +// Return a canned response so the `@langchain/openai` model (backed by the +// `openai` SDK) runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from LangChain!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, @@ -13,38 +34,21 @@ export default Sentry.withSentry( }), { async fetch(_request, _env, _ctx) { - // Create LangChain callback handler - // The mock models accept their own local `CallbackHandler` shape, which differs from the SDK's handler type. const callbackHandler = Sentry.createLangChainCallbackHandler({ recordInputs: false, recordOutputs: false, - }) as unknown as CallbackHandler; + }); - // Test 1: Chat model invocation - const chatModel = new MockChatModel({ - model: 'claude-3-5-sonnet-20241022', + const model = new ChatOpenAI({ + model: 'gpt-3.5-turbo', temperature: 0.7, maxTokens: 100, - }); - - await chatModel.invoke('Tell me a joke', { + apiKey: 'mock-api-key', + configuration: { fetch: mockFetch }, callbacks: [callbackHandler], }); - // Test 2: Chain invocation - const chain = new MockChain('my_test_chain'); - await chain.invoke( - { input: 'test input' }, - { - callbacks: [callbackHandler], - }, - ); - - // Test 3: Tool invocation - const tool = new MockTool('search_tool'); - await tool.call('search query', { - callbacks: [callbackHandler], - }); + await model.invoke('Tell me a joke'); return new Response(JSON.stringify({ success: true })); }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts deleted file mode 100644 index 946ae8252dbe..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/mocks.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Mock LangChain types and classes for testing the callback handler - -// Minimal callback handler interface to match LangChain's callback handler signature -export interface CallbackHandler { - handleChatModelStart?: ( - llm: unknown, - messages: unknown, - runId: string, - parentRunId?: string, - extraParams?: Record, - tags?: string[] | Record, - metadata?: Record, - runName?: string, - ) => unknown; - handleLLMEnd?: (output: unknown, runId: string) => unknown; - handleChainStart?: (chain: { name?: string }, inputs: Record, runId: string) => unknown; - handleChainEnd?: (outputs: unknown, runId: string) => unknown; - handleToolStart?: (tool: { name?: string }, input: string, runId: string) => unknown; - handleToolEnd?: (output: unknown, runId: string) => unknown; -} - -export interface LangChainMessage { - role: string; - content: string; -} - -export interface LangChainLLMResult { - generations: Array< - Array<{ - text: string; - generationInfo?: Record; - }> - >; - llmOutput?: { - tokenUsage?: { - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - }; - }; -} - -export interface InvocationParams { - model: string; - temperature?: number; - maxTokens?: number; -} - -// Mock LangChain Chat Model -export class MockChatModel { - private _model: string; - private _temperature?: number; - private _maxTokens?: number; - - public constructor(params: InvocationParams) { - this._model = params.model; - this._temperature = params.temperature; - this._maxTokens = params.maxTokens; - } - - public async invoke( - messages: LangChainMessage[] | string, - options?: { callbacks?: CallbackHandler[] }, - ): Promise { - const callbacks = options?.callbacks || []; - const runId = crypto.randomUUID(); - - // Get invocation params to match LangChain's signature - const invocationParams = { - model: this._model, - temperature: this._temperature, - max_tokens: this._maxTokens, - }; - - // Create serialized representation similar to LangChain - const serialized = { - lc: 1, - type: 'constructor', - id: ['langchain', 'anthropic', 'anthropic'], // Third element is used as system provider - kwargs: invocationParams, - }; - - // Call handleChatModelStart - // Pass tags as a record with invocation_params for proper extraction - // The callback handler's getInvocationParams utility accepts both string[] and Record - for (const callback of callbacks) { - if (callback.handleChatModelStart) { - await callback.handleChatModelStart( - serialized, - messages, - runId, - undefined, - undefined, - { invocation_params: invocationParams }, - { ls_model_name: this._model, ls_provider: 'anthropic' }, - ); - } - } - - // Create mock result - const result: LangChainLLMResult = { - generations: [ - [ - { - text: 'Mock response from LangChain!', - generationInfo: { - finish_reason: 'stop', - }, - }, - ], - ], - llmOutput: { - tokenUsage: { - promptTokens: 10, - completionTokens: 15, - totalTokens: 25, - }, - }, - }; - - // Call handleLLMEnd - for (const callback of callbacks) { - if (callback.handleLLMEnd) { - await callback.handleLLMEnd(result, runId); - } - } - - return result; - } -} - -// Mock LangChain Chain -export class MockChain { - private _name: string; - - public constructor(name: string) { - this._name = name; - } - - public async invoke( - inputs: Record, - options?: { callbacks?: CallbackHandler[] }, - ): Promise> { - const callbacks = options?.callbacks || []; - const runId = crypto.randomUUID(); - - // Call handleChainStart - for (const callback of callbacks) { - if (callback.handleChainStart) { - await callback.handleChainStart({ name: this._name }, inputs, runId); - } - } - - const outputs = { result: 'Chain execution completed!' }; - - // Call handleChainEnd - for (const callback of callbacks) { - if (callback.handleChainEnd) { - await callback.handleChainEnd(outputs, runId); - } - } - - return outputs; - } -} - -// Mock LangChain Tool -export class MockTool { - private _name: string; - - public constructor(name: string) { - this._name = name; - } - - public async call(input: string, options?: { callbacks?: CallbackHandler[] }): Promise { - const callbacks = options?.callbacks || []; - const runId = crypto.randomUUID(); - - // Call handleToolStart - for (const callback of callbacks) { - if (callback.handleToolStart) { - await callback.handleToolStart({ name: this._name }, input, runId); - } - } - - const output = `Tool ${this._name} executed with input: ${input}`; - - // Call handleToolEnd - for (const callback of callbacks) { - if (callback.handleToolEnd) { - await callback.handleToolEnd(output, runId); - } - } - - return output; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts index c663fc6b3289..927057035ccc 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -1,73 +1,61 @@ import { expect, it } from 'vitest'; -import type { SerializedStreamedSpan } from '@sentry/core'; import { GEN_AI_OPERATION_NAME_ATTRIBUTE, GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, GEN_AI_REQUEST_MODEL_ATTRIBUTE, GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, - GEN_AI_TOOL_NAME_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `@langchain/openai` model (backed by the +// `openai` SDK, with a canned fetch) on the Workers runtime to verify the +// LangChain callback instrumentation works end-to-end on Cloudflare. -it('traces langchain chat model, chain, and tool invocations', async ({ signal }) => { +it('traces a LangChain chat model invocation', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); - expect(container.items).toHaveLength(3); - expect(container.items.map((span: SerializedStreamedSpan) => span.name).sort()).toEqual([ - 'chain my_test_chain', - 'chat claude-3-5-sonnet-20241022', - 'execute_tool search_tool', - ]); + expect(container.items).toHaveLength(1); - const chatSpan = container.items.find( - (span: SerializedStreamedSpan) => span.name === 'chat claude-3-5-sonnet-20241022', - ); - expect(chatSpan).toBeDefined(); - expect(chatSpan!.status).toBe('ok'); - expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(chatSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(chatSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.langchain' }); - expect(chatSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'anthropic' }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'claude-3-5-sonnet-20241022', + expect(container.items[0]).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat gpt-3.5-turbo', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.langchain', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'openai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: { value: 100, type: 'integer' }, + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: { value: '["stop"]', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 10, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 15, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 25, type: 'integer' }, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: { value: 'chatcmpl-mock123', type: 'string' }, + [GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE]: { value: 'stop', type: 'string' }, + }, }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ type: 'double', value: 0.7 }); - expect(chatSpan!.attributes[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 100 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 10 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 15 }); - expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 25 }); - - const chainSpan = container.items.find((span: SerializedStreamedSpan) => span.name === 'chain my_test_chain'); - expect(chainSpan).toBeDefined(); - expect(chainSpan!.status).toBe('ok'); - expect(chainSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.langchain' }); - expect(chainSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.invoke_agent' }); - expect(chainSpan!.attributes['langchain.chain.name']).toEqual({ type: 'string', value: 'my_test_chain' }); - - const toolSpan = container.items.find((span: SerializedStreamedSpan) => span.name === 'execute_tool search_tool'); - expect(toolSpan).toBeDefined(); - expect(toolSpan!.status).toBe('ok'); - expect(toolSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.langchain' }); - expect(toolSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.execute_tool' }); - expect(toolSpan!.attributes[GEN_AI_TOOL_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'search_tool' }); }) .start(signal); await runner.makeRequest('get', '/'); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc index 48aaa79409a3..25dbb603b7a2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "langchain-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts index ba5ca07ce9e9..9ef8eabc831e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/index.ts @@ -1,15 +1,32 @@ import * as Sentry from '@sentry/cloudflare'; -import { MockOpenAi } from './mocks'; +import OpenAI from 'openai'; interface Env { SENTRY_DSN: string; } -const mockClient = new MockOpenAi({ - apiKey: 'mock-api-key', -}); +// Return a canned response so the `openai` SDK runs on workerd without hitting the network. +const mockFetch: typeof fetch = async () => + new Response( + JSON.stringify({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model: 'gpt-3.5-turbo', + system_fingerprint: 'fp_44709d6fcb', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello from OpenAI!' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); -const client = Sentry.instrumentOpenAiClient(mockClient); +const client = Sentry.instrumentOpenAiClient(new OpenAI({ apiKey: 'mock-api-key', fetch: mockFetch })); export default Sentry.withSentry( (env: Env) => ({ @@ -19,11 +36,7 @@ export default Sentry.withSentry( }), { async fetch(_request, _env, _ctx) { - // The mock client types `chat` loosely (`Record`), so narrow it to the shape used here. - const completions = ( - client.chat as { completions?: { create: (args: Record) => Promise } } - )?.completions; - const response = await completions?.create({ + const response = await client.chat.completions.create({ model: 'gpt-3.5-turbo', messages: [ { role: 'system', content: 'You are a helpful assistant.' }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/mocks.ts deleted file mode 100644 index cca72d5bd37d..000000000000 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/mocks.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { OpenAiClient } from '@sentry/core'; - -export class MockOpenAi implements OpenAiClient { - public chat?: Record; - public apiKey: string; - - public constructor(config: { apiKey: string }) { - this.apiKey = config.apiKey; - - this.chat = { - completions: { - create: async (...args: unknown[]) => { - const params = args[0] as { model: string; stream?: boolean }; - // Simulate processing time - await new Promise(resolve => setTimeout(resolve, 10)); - - if (params.model === 'error-model') { - const error = new Error('Model not found'); - (error as unknown as { status: number }).status = 404; - (error as unknown as { headers: Record }).headers = { 'x-request-id': 'mock-request-123' }; - throw error; - } - - return { - id: 'chatcmpl-mock123', - object: 'chat.completion', - created: 1677652288, - model: params.model, - system_fingerprint: 'fp_44709d6fcb', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content: 'Hello from OpenAI mock!', - }, - finish_reason: 'stop', - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 15, - total_tokens: 25, - }, - }; - }, - }, - }; - } -} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index 76288214e9f2..87e11a18e618 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -13,49 +13,44 @@ import { } from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; import { createRunner } from '../../../runner'; -// These tests are not exhaustive because the instrumentation is -// already tested in the node integration tests and we merely -// want to test that the instrumentation does not break in our -// cloudflare SDK. +// This test runs the `openai` SDK on the Workers runtime (with a canned +// fetch) to verify the instrumentation works end-to-end on Cloudflare, not just +// against a hand-written mock client. -it('traces a basic chat completion request', async ({ signal }) => { +it('traces a basic chat completion request with the openai SDK', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { - // Transaction item (first item in envelope) const transactionEvent = envelope[1]?.[0]?.[1] as any; expect(transactionEvent.transaction).toBe('GET /'); - // Span container item (second item in same envelope) const container = envelope[1]?.[1]?.[1] as any; expect(container).toBeDefined(); - expect(container.items).toHaveLength(1); - const [firstSpan] = container.items; - // [0] chat gpt-3.5-turbo - expect(firstSpan!.name).toBe('chat gpt-3.5-turbo'); - expect(firstSpan!.status).toBe('ok'); - expect(firstSpan!.attributes[GEN_AI_OPERATION_NAME_ATTRIBUTE]).toEqual({ type: 'string', value: 'chat' }); - expect(firstSpan!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); - expect(firstSpan!.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.ai.openai' }); - expect(firstSpan!.attributes[GEN_AI_SYSTEM_ATTRIBUTE]).toEqual({ type: 'string', value: 'openai' }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE]).toEqual({ type: 'string', value: 'gpt-3.5-turbo' }); - expect(firstSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]).toEqual({ type: 'double', value: 0.7 }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_MODEL_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'gpt-3.5-turbo', - }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_ID_ATTRIBUTE]).toEqual({ - type: 'string', - value: 'chatcmpl-mock123', - }); - expect(firstSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 10 }); - expect(firstSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 15 }); - expect(firstSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toEqual({ type: 'integer', value: 25 }); - expect(firstSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]).toEqual({ - type: 'string', - value: '["stop"]', + expect(container.items[0]).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + parent_span_id: expect.any(String), + name: 'chat gpt-3.5-turbo', + start_timestamp: expect.any(Number), + end_timestamp: expect.any(Number), + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.openai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.chat', type: 'string' }, + [GEN_AI_SYSTEM_ATTRIBUTE]: { value: 'openai', type: 'string' }, + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: { value: 'chat', type: 'string' }, + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: { value: 0.7, type: 'double' }, + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: { value: 'chatcmpl-mock123', type: 'string' }, + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: { value: 'gpt-3.5-turbo', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: { value: 10, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: { value: 15, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: { value: 25, type: 'integer' }, + [GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE]: { value: '["stop"]', type: 'string' }, + }, }); }) .start(signal); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc index 48aaa79409a3..409081a63462 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/wrangler.jsonc @@ -1,5 +1,5 @@ { - "name": "worker-name", + "name": "openai-worker", "compatibility_date": "2025-06-17", "main": "index.ts", "compatibility_flags": ["nodejs_als"], From 7bcd2014950cd6fbcffe2ffe9a949faef29b37fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:34:51 +0200 Subject: [PATCH 20/54] feat(deps): Bump @opentelemetry/semantic-conventions from 1.42.0 to 1.43.0 in the opentelemetry group (#22333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the opentelemetry group with 1 update: [@opentelemetry/semantic-conventions](https://github.com/open-telemetry/opentelemetry-js). Updates `@opentelemetry/semantic-conventions` from 1.42.0 to 1.43.0
Release notes

Sourced from @​opentelemetry/semantic-conventions's releases.

semconv/v1.43.0

1.43.0

:rocket: Features

  • feat: update semantic conventions to v1.43.0 #6883
    • Semantic Conventions v1.43.0: changelog | latest docs
    • @opentelemetry/semantic-conventions (stable) changes: 1 added export
    • @opentelemetry/semantic-conventions/incubating (unstable) changes: 1 added export

Stable changes in v1.43.0

TELEMETRY_SDK_LANGUAGE_VALUE_KOTLIN //
"kotlin"

Unstable changes in v1.43.0

ATTR_AZURE_RESOURCE_GROUP_NAME //
azure.resource_group.name
Commits
  • 9b05f66 chore: prepare next release (#6906)
  • 0f18798 feat(semantic-conventions): update semantic conventions to v1.43.0 (#6883)
  • bbcdfa8 chore: update workflow to the shared one (#6904)
  • e6a5776 chore(deps): update dependency typedoc to v0.28.20 (#6898)
  • 8bec662 feat(propagator-jaeger): deprecate JaegerPropagator (#6893)
  • 20e3abe chore: add workflow for first time contributor (#6896)
  • fda8f31 chore: bump to typescript@5.2.2 (#6888)
  • 3394c3e fix(sdk-trace): sample ratio 1 upper-bound trace IDs (#6890)
  • 2568ac1 chore: clean up some deps and devDeps in exporter packages (#6892)
  • 0fd7fac test(exporter-metrics-otlp-*): return response to allow process exit (#6889)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@opentelemetry/semantic-conventions&package-manager=npm_and_yarn&previous-version=1.42.0&new-version=1.43.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/node-core-integration-tests/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 332208a0e592..fe48425e07fb 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -32,7 +32,7 @@ "@opentelemetry/instrumentation-http": "0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-trace-base": "^2.9.0", - "@opentelemetry/semantic-conventions": "^1.42.0", + "@opentelemetry/semantic-conventions": "^1.43.0", "@sentry/core": "10.66.0", "@sentry/node-core": "10.66.0", "body-parser": "^2.2.2", diff --git a/yarn.lock b/yarn.lock index 34ff8cd92cec..b17fa3c0b4b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6112,10 +6112,10 @@ "@opentelemetry/resources" "2.9.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.42.0": - version "1.42.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.42.0.tgz#38f10edd26f02931bf3b7d664ba59bea6279a384" - integrity sha512-icc5xCzndZfhuJMy5oqk5AvloWquR7jtae74qzpkKkhGp8BivK+oCcEXgGnjCdTfp8hA44l+w8gE8yYJbocJJw== +"@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.43.0": + version "1.43.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz#f3f467e36c27332f0e735ec86cdcd78dd6f27865" + integrity sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg== "@oxc-parser/binding-android-arm64@0.76.0": version "0.76.0" From c406c07d6412aaca554162cb7a3c1f79bf999368 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:59:31 +0200 Subject: [PATCH 21/54] feat(server-utils): Add orchestrion aws-sdk channel integration core (#22142) The integration subscribes to the `orchestrion::send` channels the transform injects into the smithy `Client.prototype.send` (`@smithy/core`, `@smithy/smithy-client`, `@aws-sdk/smithy-client`) and emits a client rpc span per command (`rpc.system`/`rpc.method`/`rpc.service`, `cloud.region`, request id metadata), with a distinct `auto.aws.orchestrion.aws_sdk` origin. The per-service extension registry (span names, messaging/db/gen_ai attributes, trace propagation) starts empty and mirrors the OTel integration's `ServiceExtension` contract; the services are ported one group at a time in the follow-up PRs of this stack. The OTel instrumentation was only added to the `@sentry/aws-serverless` SDK, but it is useable in other SDKS so the orchestrion version lives in server-utils now. ## User-facing differences to the vendored OTel instrumentation - span origin is `auto.aws.orchestrion.aws_sdk` instead of `auto.otel.aws` - spans started after SQS `ReceiveMessage` parent to the surrounding span instead of the receive span; receive-to-consumer linkage uses span links (messaging PR) - outgoing SQS/SNS/Lambda trace propagation uses `sentry-trace`/`baggage` message attributes instead of W3C headers (messaging PR) - errored spans may carry `aws.request.id`/`aws.request.extended_id` where the OTel path missed them (`$metadata` fallback) Part of #20946 --- .oxlintrc.base.json | 6 + .size-limit.js | 2 +- .../aws-integration-streamed/test.ts | 5 +- .../aws-serverless/aws-integration/test.ts | 5 +- .../tracing-channel/aws-sdk/constants.ts | 8 + .../tracing-channel/aws-sdk/index.ts | 272 ++++++++++++++++++ .../aws-sdk/services/ServiceExtension.ts | 22 ++ .../aws-sdk/services/ServicesExtensions.ts | 27 ++ .../tracing-channel/aws-sdk/services/index.ts | 1 + .../tracing-channel/aws-sdk/types.ts | 36 +++ .../tracing-channel/aws-sdk/utils.ts | 31 ++ .../server-utils/src/orchestrion/channels.ts | 2 + .../src/orchestrion/config/aws-sdk.ts | 34 +++ .../src/orchestrion/config/index.ts | 2 + .../server-utils/src/orchestrion/index.ts | 3 + 15 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts create mode 100644 packages/server-utils/src/orchestrion/config/aws-sdk.ts diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index dd6ed529bc95..5e1a40beb267 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -147,6 +147,12 @@ "no-param-reassign": "off" } }, + { + "files": ["**/integrations/tracing-channel/aws-sdk/**/*.ts"], + "rules": { + "typescript/no-explicit-any": "off" + } + }, { "files": ["**/integrations/tracing/redis/vendored/**/*.ts"], "rules": { diff --git a/.size-limit.js b/.size-limit.js index 53bd15c11bf6..e9b00ebeb4e8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,7 +400,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '142 KB', + limit: '148 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts index 7491894494eb..48e81b01b9db 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts @@ -221,7 +221,10 @@ describe('awsIntegration (streamed)', () => { await createTestRunner().ignore('event').expect({ span: assertAwsServiceSpans }).start().completed(); }); }, - { additionalDependencies }, + // The orchestrion aws-sdk channel integration has no service extensions yet (empty registry), + // so it can't emit the service-specific attributes asserted here. Stay on the OTel path until + // the service extensions land in a follow-up. + { additionalDependencies, injectOrchestrion: false }, ); }); }); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts index a27b5c1a88ff..2da3a170a11c 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts @@ -216,7 +216,10 @@ describe('awsIntegration', () => { await createTestRunner().ignore('event').expect({ transaction: assertAwsServiceSpans }).start().completed(); }); }, - { additionalDependencies }, + // The orchestrion aws-sdk channel integration has no service extensions yet (empty registry), + // so it can't emit the service-specific attributes asserted here. Stay on the OTel path until + // the service extensions land in a follow-up. + { additionalDependencies, injectOrchestrion: false }, ); }); }); diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts new file mode 100644 index 000000000000..4d82a9c676c6 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -0,0 +1,8 @@ +/** + * AWS-specific span constants used by the aws-sdk channel integration that are NOT covered by + * `@sentry/conventions/attributes` (attribute names that exist there are imported from there + * directly). Per-service files append their own such constants below. + */ + +/** The span origin every aws-sdk channel span carries, mirroring the uniform OTel `auto.otel.aws`. */ +export const AWS_SDK_ORIGIN = 'auto.aws.orchestrion.aws_sdk'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts new file mode 100644 index 000000000000..4b3d85700653 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/index.ts @@ -0,0 +1,272 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + debug, + defineIntegration, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { + _AWS_REQUEST_ID as AWS_REQUEST_ID, + AWS_REQUEST_EXTENDED_ID, + CLOUD_REGION, + HTTP_STATUS_CODE, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../debug-build'; +import { CHANNELS } from '../../../orchestrion/channels'; +import type { TracingChannelLifeCycleOptions } from '../../../tracing-channel'; +import { bindTracingChannelToSpan } from '../../../tracing-channel'; +import { AWS_SDK_ORIGIN } from './constants'; +import { ServicesExtensions } from './services'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from './types'; +import { extractAttributesFromNormalizedRequest, normalizeV3Request, removeSuffixFromStringIfExists } from './utils'; + +// Same name as the OTel `Aws` integration by design, so enabling injection swaps this in for it. +const INTEGRATION_NAME = 'Aws' as const; + +// The context orchestrion's transform attaches to the channel: `arguments` is the live args of the +// wrapped `Client.prototype.send` call (`[command, ...]`), `self` the client, `result`/`error` the +// settled value. The `_sentry*` fields are stashed by us across the call's lifecycle. +interface AwsSendChannelContext { + arguments: unknown[]; + self?: { config?: AwsClientConfig; constructor?: { name?: string } }; + result?: unknown; + error?: unknown; + _sentryNormalizedRequest?: NormalizedRequest; + _sentryRequestMetadata?: RequestMetadata; + _sentryRegion?: { settled: boolean; promise: Promise }; +} + +interface AwsClientConfig { + serviceId?: string; + region?: () => string | Promise | undefined; +} + +interface AwsV3Command { + input?: Record; + constructor?: { name?: string }; +} + +/** Runs a span-building callback so a throw inside it can never break the user's aws-sdk call. */ +function safe(fn: () => T): T | undefined { + try { + return fn(); + } catch (error) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] error building span', error); + return undefined; + } +} + +// `metadata` is smithy's `ResponseMetadata`, read off the untyped channel result/error (`any` for the +// same reason as `CommandInput`, see types.ts). +function setMetadataAttributes(span: Span, metadata: Record | undefined): void { + if (!metadata) { + return; + } + if (metadata.requestId) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(AWS_REQUEST_ID, metadata.requestId); + } + if (metadata.httpStatusCode) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(HTTP_STATUS_CODE, metadata.httpStatusCode); + } + if (metadata.extendedRequestId) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(AWS_REQUEST_EXTENDED_ID, metadata.extendedRequestId); + } +} + +const _awsChannelIntegration = (() => { + const servicesExtensions = new ServicesExtensions(); + + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19 so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + const getSpan = (data: AwsSendChannelContext): Span | undefined => + safe(() => { + const command = data.arguments[0] as AwsV3Command | undefined; + const commandName = command?.constructor?.name; + if (!command || !commandName) { + // Not a recognizable v3 command call; leave the active context untouched. + return undefined; + } + + const clientConfig = data.self?.config; + const serviceName = + clientConfig?.serviceId ?? + // `clientName` isn't available at the `send` boundary; fall back to the client's + // constructor name (e.g. `S3Client` -> `S3`). `serviceId` is set for all AWS clients. + removeSuffixFromStringIfExists(data.self?.constructor?.name || 'AWS', 'Client'); + + // Commands with all-optional members can be constructed without an input (`new + // ListBucketsCommand()`); the OTel path traces those too, so default rather than bail. + // The default is assigned back onto the command (not kept detached) because service hooks + // mutate `commandInput` (trace-propagation headers, `MessageAttributeNames`) and those + // writes must reach the serialized request. Current smithy clients already default `input` + // to `{}` in the command constructor; this only affects older clients in our range. + if (!command.input) { + command.input = {}; + } + const normalizedRequest = normalizeV3Request(serviceName, commandName, command.input, undefined); + const requestMetadata = servicesExtensions.requestPreSpanHook(normalizedRequest); + + const span = startInactiveSpan({ + name: requestMetadata.spanName ?? `${normalizedRequest.serviceName}.${normalizedRequest.commandName}`, + kind: requestMetadata.spanKind ?? SPAN_KIND.CLIENT, + // `rpc` matches what the exporter infers from `rpc.service` for the OTel aws-sdk spans; + // service extensions override it where inference yields a different op (DynamoDB: `db`). + op: requestMetadata.spanOp || 'rpc', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: AWS_SDK_ORIGIN, + ...extractAttributesFromNormalizedRequest(normalizedRequest), + ...requestMetadata.spanAttributes, + }, + }); + + data._sentryNormalizedRequest = normalizedRequest; + data._sentryRequestMetadata = requestMetadata; + + // `region` resolves asynchronously while `send` proceeds (a channel subscriber cannot delay + // the traced call the way the OTel middleware does). Backfill it onto the span and the + // normalized request once available; `deferSpanEnd` holds the span open until this settles + // so `cloud.region` cannot be lost when `send` settles first (e.g. an early failure). + // + // The provider call is guarded separately: the span is already started, so a synchronous + // throw bubbling into the enclosing `safe` would discard it without ending it (a leaked + // open span). + let regionResult: string | Promise | undefined; + try { + regionResult = clientConfig?.region?.(); + } catch { + // Nothing to do; continue without a region. + } + // The `.finally` self-reference is safe: the callback only runs after initialization. + const regionHolder: { settled: boolean; promise: Promise } = { + settled: false, + promise: Promise.resolve(regionResult) + .then(region => { + if (region) { + normalizedRequest.region = region; + span.setAttribute(CLOUD_REGION, region); + } + }) + .catch(() => { + // Nothing to do; continue without a region. + }) + .finally(() => { + regionHolder.settled = true; + }), + }; + data._sentryRegion = regionHolder; + + // Inject trace-propagation headers into outgoing messages (SQS/SNS/Lambda). Runs before + // `send` proceeds, so the mutated `commandInput` is used to build the request. + safe(() => servicesExtensions.requestPostSpanHook(normalizedRequest, span)); + + return span; + }); + + const opts: TracingChannelLifeCycleOptions = { + deferSpanEnd({ span, data, end }) { + const normalizedRequest = data._sentryNormalizedRequest; + const requestMetadata = data._sentryRequestMetadata; + if (!normalizedRequest) { + return false; + } + + const failed = 'error' in data; + + // The channel `result`/`error` are untyped; the `$metadata` casts below name smithy's + // `ResponseMetadata` shape (`any`-valued, see `setMetadataAttributes`). + safe(() => { + if (failed) { + const err = data.error as + | { $metadata?: Record; RequestId?: string; extendedRequestId?: string } + | undefined; + const errMetadata = err?.$metadata; + // Like the OTel path, read RequestId/extendedRequestId off the error itself, with + // `$metadata` (which smithy service errors also carry) as the fallback. A spread won't + // do: `$metadata` includes these keys with `undefined` values, clobbering the fallback. + setMetadataAttributes(span, { + requestId: err?.RequestId ?? errMetadata?.requestId, + httpStatusCode: errMetadata?.httpStatusCode, + extendedRequestId: err?.extendedRequestId ?? errMetadata?.extendedRequestId, + }); + return; + } + + const output = data.result as { $metadata?: Record } | undefined; + setMetadataAttributes(span, output?.$metadata); + + const normalizedResponse: NormalizedResponse = { + data: output, + request: normalizedRequest, + requestId: output?.$metadata?.requestId, + }; + servicesExtensions.responseHook(normalizedResponse, span); + }); + + // Streaming responses end the span when their wrapped stream is consumed (see + // bedrock-runtime); the helper must not end it on `send` settling. Errors always end here. + if (requestMetadata?.isStream && !failed) { + return true; + } + + // Normally the region settles long before `send` does (the SDK awaits it internally to + // build the endpoint), but when `send` settles first (e.g. an early failure) hold the span + // open until the region backfill lands. The error status was already applied by the + // helper's `error` subscriber, so a plain `end()` suffices. + const region = data._sentryRegion; + if (region && !region.settled) { + void region.promise.then(() => end()); + return true; + } + + return false; + }, + }; + + // The AWS SDK's `Client.prototype.send` lives in different smithy packages across versions; the + // transform injects one channel per package. Only the package hosting the app's client fires, so + // subscribing to all of them is safe and never double-instruments a single call. + const awsSendChannels = [ + CHANNELS.AWS_SMITHY_CORE_SEND, + CHANNELS.AWS_SMITHY_CLIENT_SEND, + CHANNELS.AWS_SDK_SMITHY_CLIENT_SEND, + ] as const; + + DEBUG_BUILD && debug.log(`[orchestrion:aws-sdk] subscribing to channels "${awsSendChannels.join('", "')}"`); + + waitForTracingChannelBinding(() => { + for (const channelName of awsSendChannels) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + getSpan, + opts, + ); + } + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * EXPERIMENTAL — orchestrion-driven aws-sdk (v3) integration. + * + * Subscribes to the `orchestrion:@smithy/smithy-client:send` (and equivalent) diagnostics_channel + * the orchestrion code transform injects into the AWS SDK's smithy `Client.prototype.send`, emitting + * spans identical to the OTel `@opentelemetry/instrumentation-aws-sdk` integration (with a distinct + * `auto.aws.orchestrion.aws_sdk` origin). Requires the orchestrion runtime hook or bundler plugin — + * wire it up via `experimentalUseDiagnosticsChannelInjection()`. + * + * @experimental + */ +export const awsChannelIntegration = defineIntegration(_awsChannelIntegration); diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts new file mode 100644 index 000000000000..071f9005b8f0 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServiceExtension.ts @@ -0,0 +1,22 @@ +import type { Span } from '@sentry/core'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; + +export type { RequestMetadata }; + +export interface ServiceExtension { + // called before the request is sent, and before the span is started + requestPreSpanHook: (request: NormalizedRequest) => RequestMetadata; + + // called before the request is sent, and after the span is started. `span` is the started span, + // used to derive trace-propagation headers injected into outgoing messages. + requestPostSpanHook?: (request: NormalizedRequest, span: Span) => void; + + // Called after the response is received. Unlike the OTel middleware patch, a tracing-channel + // subscriber cannot replace the value the caller's promise resolves with: the injected settle + // handler returns the captured result, not `ctx.result`. It does however publish `asyncEnd` + // synchronously BEFORE the caller's continuations run, and `response.data` is the same object the + // caller receives, so extensions that need to alter the response (e.g. wrap a stream) must mutate + // `response.data` in place; the mutation is guaranteed to be visible to the caller. Same idiom as + // the vercel-ai subscribers' `result.stream` tap. + responseHook?: (response: NormalizedResponse, span: Span) => void; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts new file mode 100644 index 000000000000..83d0573b184e --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts @@ -0,0 +1,27 @@ +import type { Span } from '@sentry/core'; +import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; +import type { ServiceExtension } from './ServiceExtension'; + +export class ServicesExtensions implements ServiceExtension { + // Per-service extensions, keyed by the client's `serviceId` (e.g. `'S3'`). Services without a + // registered extension still get the base rpc span from the subscriber. + private _services: Map = new Map(); + + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const serviceExtension = this._services.get(request.serviceName); + if (!serviceExtension) { + return {}; + } + return serviceExtension.requestPreSpanHook(request); + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + const serviceExtension = this._services.get(request.serviceName); + serviceExtension?.requestPostSpanHook?.(request, span); + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const serviceExtension = this._services.get(response.request.serviceName); + serviceExtension?.responseHook?.(response, span); + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts new file mode 100644 index 000000000000..ef438e065534 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/index.ts @@ -0,0 +1 @@ +export { ServicesExtensions } from './ServicesExtensions'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts new file mode 100644 index 000000000000..93d1ebade30d --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/types.ts @@ -0,0 +1,36 @@ +import type { SpanKindValue } from '@sentry/core'; + +// Command inputs are service-specific shapes from hundreds of AWS APIs; typing them would require +// depending on the `@aws-sdk/*` client types. The per-service hooks read fields defensively instead. +export type CommandInput = Record; + +/** + * These are normalized request and response. They organize the relevant data in one interface which + * can be processed in a uniform manner in the per-service hooks. + */ +export interface NormalizedRequest { + serviceName: string; + commandName: string; + commandInput: CommandInput; + region?: string; +} + +export interface NormalizedResponse { + // The command output, shaped per service/command (see `CommandInput` on why this isn't typed). + data: any; + request: NormalizedRequest; + requestId?: string; +} + +/** Span metadata a per-service extension returns for the subscriber to build the span from. */ +export interface RequestMetadata { + // If true, then the response is a stream so the subscriber must not end the span when `send` settles. + // The service extension ends the span itself, generally by wrapping the stream and ending after it is + // consumed. + isStream?: boolean; + spanAttributes?: Record; + spanKind?: SpanKindValue; + spanName?: string; + // Overrides the default `rpc` span op (e.g. `db` for DynamoDB). + spanOp?: string; +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts new file mode 100644 index 000000000000..2186350d5aed --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/utils.ts @@ -0,0 +1,31 @@ +import { CLOUD_REGION, RPC_METHOD, RPC_SERVICE, RPC_SYSTEM } from '@sentry/conventions/attributes'; +import type { CommandInput, NormalizedRequest } from './types'; + +export function removeSuffixFromStringIfExists(str: string, suffixToRemove: string): string { + const suffixLength = suffixToRemove.length; + return str?.slice(-suffixLength) === suffixToRemove ? str.slice(0, -suffixLength) : str; +} + +export function normalizeV3Request( + serviceName: string, + commandNameWithSuffix: string, + commandInput: CommandInput, + region: string | undefined, +): NormalizedRequest { + return { + serviceName: serviceName?.replace(/\s+/g, ''), + commandName: removeSuffixFromStringIfExists(commandNameWithSuffix, 'Command'), + commandInput, + region, + }; +} + +export function extractAttributesFromNormalizedRequest(normalizedRequest: NormalizedRequest): Record { + return { + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv rpc.system, matched to the OTel aws-sdk integration + [RPC_SYSTEM]: 'aws-api', + [RPC_METHOD]: normalizedRequest.commandName, + [RPC_SERVICE]: normalizedRequest.serviceName, + [CLOUD_REGION]: normalizedRequest.region, + }; +} diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 258b0fdc589d..d51de03ceb95 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -1,5 +1,6 @@ import { amqplibChannels } from './config/amqplib'; import { anthropicAiChannels } from './config/anthropic-ai'; +import { awsSdkChannels } from './config/aws-sdk'; import { dataloaderChannels } from './config/dataloader'; import { expressChannels } from './config/express'; import { firebaseChannels } from './config/firebase'; @@ -48,6 +49,7 @@ import { vercelAiChannels } from './config/vercel-ai'; export const CHANNELS = { ...amqplibChannels, ...anthropicAiChannels, + ...awsSdkChannels, ...dataloaderChannels, ...expressChannels, ...firebaseChannels, diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts new file mode 100644 index 000000000000..fdcc4d9e5682 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -0,0 +1,34 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +// The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which +// package hosts that `Client` class changed across versions, so we target all of them; only the one +// the app's client actually extends is ever invoked. +// +// - `@smithy/core` >= 3.24.0: the `Client` class moved into the `client` submodule bundle. +// - `@smithy/smithy-client`: the `Client` class for aws-sdk v3.363.0+ (pre-`@smithy/core` stack). +// - `@aws-sdk/smithy-client`: the `Client` class for older aws-sdk v3 releases. +// +// `send` is `async send(command, options)` (returns a promise), so `kind: 'Async'`. +export const awsSdkConfig = [ + { + channelName: 'send', + module: { name: '@smithy/core', versionRange: '>=3.24.0 <4', filePath: 'dist-cjs/submodules/client/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, + { + channelName: 'send', + module: { name: '@smithy/smithy-client', versionRange: '>=1.0.3 <5', filePath: 'dist-cjs/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, + { + channelName: 'send', + module: { name: '@aws-sdk/smithy-client', versionRange: '^3.1.0', filePath: 'dist-cjs/index.js' }, + functionQuery: { className: 'Client', methodName: 'send', kind: 'Async' }, + }, +] satisfies InstrumentationConfig[]; + +export const awsSdkChannels = { + AWS_SMITHY_CORE_SEND: 'orchestrion:@smithy/core:send', + AWS_SMITHY_CLIENT_SEND: 'orchestrion:@smithy/smithy-client:send', + AWS_SDK_SMITHY_CLIENT_SEND: 'orchestrion:@aws-sdk/smithy-client:send', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index b507f6ffc180..60a78320fbff 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -3,6 +3,7 @@ import { uniq } from '@sentry/core'; import { amqplibConfig } from './amqplib'; import { anthropicAiConfig } from './anthropic-ai'; +import { awsSdkConfig } from './aws-sdk'; import { dataloaderConfig } from './dataloader'; import { expressConfig } from './express'; import { firebaseConfig } from './firebase'; @@ -44,6 +45,7 @@ import { vercelAiConfig } from './vercel-ai'; export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...amqplibConfig, ...anthropicAiConfig, + ...awsSdkConfig, ...dataloaderConfig, ...expressConfig, ...firebaseConfig, diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index be6e20672f98..3366e1c245ff 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,5 +1,6 @@ import { amqplibChannelIntegration } from '../integrations/tracing-channel/amqplib'; import { anthropicChannelIntegration } from '../integrations/tracing-channel/anthropic'; +import { awsChannelIntegration } from '../integrations/tracing-channel/aws-sdk'; import { dataloaderChannelIntegration } from '../integrations/tracing-channel/dataloader'; import { genericPoolChannelIntegration } from '../integrations/tracing-channel/generic-pool'; import { googleGenAIChannelIntegration } from '../integrations/tracing-channel/google-genai'; @@ -30,6 +31,7 @@ export { nestjsChannels } from './config/nestjs'; export { amqplibChannelIntegration, anthropicChannelIntegration, + awsChannelIntegration, dataloaderChannelIntegration, genericPoolChannelIntegration, googleGenAIChannelIntegration, @@ -101,4 +103,5 @@ export const channelIntegrations = { graphqlIntegration: graphqlDiagnosticsChannelIntegration, kafkajsIntegration: kafkajsChannelIntegration, tediousIntegration: tediousChannelIntegration, + awsIntegration: awsChannelIntegration, } as const; From c36ff0e46780504c29df5b8fec0f379f37ea09e4 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:59:31 +0200 Subject: [PATCH 22/54] feat(server-utils): Port S3, Kinesis, DynamoDB, SecretsManager and StepFunctions aws-sdk extensions (#22164) Ports the attribute-only service extensions from the OTel aws-sdk integration to the orchestrion channel integration and registers them in the service registry: * S3: `aws.s3.bucket` * Kinesis: `aws.kinesis.stream.name` * DynamoDB: `db.*` and `aws.dynamodb.*` request/response attributes * SecretsManager: `aws.secretsmanager.secret.arn` (request and response) * StepFunctions: state machine / activity ARNs Straight ports; none of these inject trace propagation or change span lifecycle. Part of getsentry/sentry-javascript#20946 --- .oxlintrc.base.json | 10 +- .../tracing-channel/aws-sdk/constants.ts | 3 + .../aws-sdk/services/ServicesExtensions.ts | 13 +- .../aws-sdk/services/dynamodb.ts | 188 ++++++++++++++++++ .../aws-sdk/services/kinesis.ts | 21 ++ .../tracing-channel/aws-sdk/services/s3.ts | 20 ++ .../aws-sdk/services/secretsmanager.ts | 27 +++ .../aws-sdk/services/stepfunctions.ts | 28 +++ 8 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts diff --git a/.oxlintrc.base.json b/.oxlintrc.base.json index 5e1a40beb267..57e39bcc20dd 100644 --- a/.oxlintrc.base.json +++ b/.oxlintrc.base.json @@ -150,7 +150,15 @@ { "files": ["**/integrations/tracing-channel/aws-sdk/**/*.ts"], "rules": { - "typescript/no-explicit-any": "off" + "typescript/no-explicit-any": "off", + "typescript/no-unsafe-member-access": "off" + } + }, + { + "files": ["**/integrations/tracing-channel/aws-sdk/services/**/*.ts"], + "rules": { + "max-lines": "off", + "complexity": "off" } }, { diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts index 4d82a9c676c6..320c24540f0a 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -6,3 +6,6 @@ /** The span origin every aws-sdk channel span carries, mirroring the uniform OTel `auto.otel.aws`. */ export const AWS_SDK_ORIGIN = 'auto.aws.orchestrion.aws_sdk'; + +/** DynamoDB `db.system` value (an attribute value, not a key, so not covered by conventions). */ +export const DB_SYSTEM_VALUE_DYNAMODB = 'dynamodb'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts index 83d0573b184e..b98d402c9078 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts @@ -1,11 +1,22 @@ import type { Span } from '@sentry/core'; import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; +import { DynamodbServiceExtension } from './dynamodb'; +import { KinesisServiceExtension } from './kinesis'; +import { S3ServiceExtension } from './s3'; +import { SecretsManagerServiceExtension } from './secretsmanager'; import type { ServiceExtension } from './ServiceExtension'; +import { StepFunctionsServiceExtension } from './stepfunctions'; export class ServicesExtensions implements ServiceExtension { // Per-service extensions, keyed by the client's `serviceId` (e.g. `'S3'`). Services without a // registered extension still get the base rpc span from the subscriber. - private _services: Map = new Map(); + private _services: Map = new Map([ + ['SecretsManager', new SecretsManagerServiceExtension()], + ['SFN', new StepFunctionsServiceExtension()], + ['DynamoDB', new DynamodbServiceExtension()], + ['S3', new S3ServiceExtension()], + ['Kinesis', new KinesisServiceExtension()], + ]); public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { const serviceExtension = this._services.get(request.serviceName); diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts new file mode 100644 index 000000000000..f9276fc20d96 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/dynamodb.ts @@ -0,0 +1,188 @@ +import type { Span } from '@sentry/core'; +import { SPAN_KIND } from '@sentry/core'; +import { + AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS as ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS, + AWS_DYNAMODB_CONSISTENT_READ as ATTR_AWS_DYNAMODB_CONSISTENT_READ, + AWS_DYNAMODB_CONSUMED_CAPACITY as ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY, + AWS_DYNAMODB_COUNT as ATTR_AWS_DYNAMODB_COUNT, + AWS_DYNAMODB_EXCLUSIVE_START_TABLE as ATTR_AWS_DYNAMODB_EXCLUSIVE_START_TABLE, + AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES as ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES, + AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES as ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES, + AWS_DYNAMODB_INDEX_NAME as ATTR_AWS_DYNAMODB_INDEX_NAME, + AWS_DYNAMODB_ITEM_COLLECTION_METRICS as ATTR_AWS_DYNAMODB_ITEM_COLLECTION_METRICS, + AWS_DYNAMODB_LIMIT as ATTR_AWS_DYNAMODB_LIMIT, + AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES as ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES, + AWS_DYNAMODB_PROJECTION as ATTR_AWS_DYNAMODB_PROJECTION, + AWS_DYNAMODB_PROVISIONED_READ_CAPACITY as ATTR_AWS_DYNAMODB_PROVISIONED_READ_CAPACITY, + AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY as ATTR_AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY, + AWS_DYNAMODB_SCAN_FORWARD as ATTR_AWS_DYNAMODB_SCAN_FORWARD, + AWS_DYNAMODB_SCANNED_COUNT as ATTR_AWS_DYNAMODB_SCANNED_COUNT, + AWS_DYNAMODB_SEGMENT as ATTR_AWS_DYNAMODB_SEGMENT, + AWS_DYNAMODB_SELECT as ATTR_AWS_DYNAMODB_SELECT, + AWS_DYNAMODB_TABLE_COUNT as ATTR_AWS_DYNAMODB_TABLE_COUNT, + AWS_DYNAMODB_TABLE_NAMES as ATTR_AWS_DYNAMODB_TABLE_NAMES, + AWS_DYNAMODB_TOTAL_SEGMENTS as ATTR_AWS_DYNAMODB_TOTAL_SEGMENTS, + DB_NAME, + DB_OPERATION, + DB_SYSTEM, +} from '@sentry/conventions/attributes'; +import { DB_SYSTEM_VALUE_DYNAMODB } from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +function toArray(values: T | T[]): T[] { + return Array.isArray(values) ? values : [values]; +} + +export class DynamodbServiceExtension implements ServiceExtension { + public requestPreSpanHook(normalizedRequest: NormalizedRequest): RequestMetadata { + const operation = normalizedRequest.commandName; + const tableName = normalizedRequest.commandInput?.TableName; + + const spanAttributes: Record = {}; + + /* oxlint-disable typescript/no-deprecated -- old-semconv db.* attributes, matched to the OTel aws-sdk integration */ + spanAttributes[DB_SYSTEM] = DB_SYSTEM_VALUE_DYNAMODB; + spanAttributes[DB_NAME] = tableName; + spanAttributes[DB_OPERATION] = operation; + /* oxlint-enable typescript/no-deprecated */ + + // `RequestItems` is undefined when no table names are returned; its keys are the table names. + if (normalizedRequest.commandInput?.TableName) { + // Necessary for commands with only 1 table name (e.g. CreateTable). Attribute is `TableName`, not keys of `RequestItems`. + spanAttributes[ATTR_AWS_DYNAMODB_TABLE_NAMES] = [normalizedRequest.commandInput.TableName]; + } else if (normalizedRequest.commandInput?.RequestItems) { + spanAttributes[ATTR_AWS_DYNAMODB_TABLE_NAMES] = Object.keys(normalizedRequest.commandInput.RequestItems); + } + + if (operation === 'CreateTable' || operation === 'UpdateTable') { + // only check for ProvisionedThroughput since ReadCapacityUnits and WriteCapacityUnits are required attributes + if (normalizedRequest.commandInput?.ProvisionedThroughput) { + spanAttributes[ATTR_AWS_DYNAMODB_PROVISIONED_READ_CAPACITY] = + normalizedRequest.commandInput.ProvisionedThroughput.ReadCapacityUnits; + spanAttributes[ATTR_AWS_DYNAMODB_PROVISIONED_WRITE_CAPACITY] = + normalizedRequest.commandInput.ProvisionedThroughput.WriteCapacityUnits; + } + } + + if (operation === 'GetItem' || operation === 'Scan' || operation === 'Query') { + if (normalizedRequest.commandInput?.ConsistentRead) { + spanAttributes[ATTR_AWS_DYNAMODB_CONSISTENT_READ] = normalizedRequest.commandInput.ConsistentRead; + } + } + + if (operation === 'Query' || operation === 'Scan') { + if (normalizedRequest.commandInput?.ProjectionExpression) { + spanAttributes[ATTR_AWS_DYNAMODB_PROJECTION] = normalizedRequest.commandInput.ProjectionExpression; + } + } + + if (operation === 'CreateTable') { + if (normalizedRequest.commandInput?.GlobalSecondaryIndexes) { + spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEXES] = toArray( + normalizedRequest.commandInput.GlobalSecondaryIndexes, + ).map((x: unknown) => JSON.stringify(x)); + } + + if (normalizedRequest.commandInput?.LocalSecondaryIndexes) { + spanAttributes[ATTR_AWS_DYNAMODB_LOCAL_SECONDARY_INDEXES] = toArray( + normalizedRequest.commandInput.LocalSecondaryIndexes, + ).map((x: unknown) => JSON.stringify(x)); + } + } + + if (operation === 'ListTables' || operation === 'Query' || operation === 'Scan') { + if (normalizedRequest.commandInput?.Limit) { + spanAttributes[ATTR_AWS_DYNAMODB_LIMIT] = normalizedRequest.commandInput.Limit; + } + } + + if (operation === 'ListTables') { + if (normalizedRequest.commandInput?.ExclusiveStartTableName) { + spanAttributes[ATTR_AWS_DYNAMODB_EXCLUSIVE_START_TABLE] = + normalizedRequest.commandInput.ExclusiveStartTableName; + } + } + + if (operation === 'Query') { + if (normalizedRequest.commandInput?.ScanIndexForward) { + spanAttributes[ATTR_AWS_DYNAMODB_SCAN_FORWARD] = normalizedRequest.commandInput.ScanIndexForward; + } + + if (normalizedRequest.commandInput?.IndexName) { + spanAttributes[ATTR_AWS_DYNAMODB_INDEX_NAME] = normalizedRequest.commandInput.IndexName; + } + + if (normalizedRequest.commandInput?.Select) { + spanAttributes[ATTR_AWS_DYNAMODB_SELECT] = normalizedRequest.commandInput.Select; + } + } + + if (operation === 'Scan') { + if (normalizedRequest.commandInput?.Segment) { + spanAttributes[ATTR_AWS_DYNAMODB_SEGMENT] = normalizedRequest.commandInput?.Segment; + } + + if (normalizedRequest.commandInput?.TotalSegments) { + spanAttributes[ATTR_AWS_DYNAMODB_TOTAL_SEGMENTS] = normalizedRequest.commandInput?.TotalSegments; + } + + if (normalizedRequest.commandInput?.IndexName) { + spanAttributes[ATTR_AWS_DYNAMODB_INDEX_NAME] = normalizedRequest.commandInput.IndexName; + } + + if (normalizedRequest.commandInput?.Select) { + spanAttributes[ATTR_AWS_DYNAMODB_SELECT] = normalizedRequest.commandInput.Select; + } + } + + if (operation === 'UpdateTable') { + if (normalizedRequest.commandInput?.AttributeDefinitions) { + spanAttributes[ATTR_AWS_DYNAMODB_ATTRIBUTE_DEFINITIONS] = toArray( + normalizedRequest.commandInput.AttributeDefinitions, + ).map((x: unknown) => JSON.stringify(x)); + } + + if (normalizedRequest.commandInput?.GlobalSecondaryIndexUpdates) { + spanAttributes[ATTR_AWS_DYNAMODB_GLOBAL_SECONDARY_INDEX_UPDATES] = toArray( + normalizedRequest.commandInput.GlobalSecondaryIndexUpdates, + ).map((x: unknown) => JSON.stringify(x)); + } + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + // Matches what the exporter infers from `db.system` for the OTel DynamoDB spans. + spanOp: 'db', + }; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + if (response.data?.ConsumedCapacity) { + span.setAttribute( + ATTR_AWS_DYNAMODB_CONSUMED_CAPACITY, + toArray(response.data.ConsumedCapacity).map((x: unknown) => JSON.stringify(x)), + ); + } + + if (response.data?.ItemCollectionMetrics) { + span.setAttribute( + ATTR_AWS_DYNAMODB_ITEM_COLLECTION_METRICS, + toArray(response.data.ItemCollectionMetrics).map((x: unknown) => JSON.stringify(x)), + ); + } + + if (response.data?.TableNames) { + span.setAttribute(ATTR_AWS_DYNAMODB_TABLE_COUNT, response.data?.TableNames.length); + } + + if (response.data?.Count) { + span.setAttribute(ATTR_AWS_DYNAMODB_COUNT, response.data?.Count); + } + + if (response.data?.ScannedCount) { + span.setAttribute(ATTR_AWS_DYNAMODB_SCANNED_COUNT, response.data?.ScannedCount); + } + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts new file mode 100644 index 000000000000..22599882d443 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/kinesis.ts @@ -0,0 +1,21 @@ +import { SPAN_KIND } from '@sentry/core'; +import { _AWS_KINESIS_STREAM_NAME as AWS_KINESIS_STREAM_NAME } from '@sentry/conventions/attributes'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class KinesisServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const streamName = request.commandInput?.StreamName; + const spanAttributes: Record = {}; + + if (streamName) { + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv aws.kinesis.stream.name, matched to the OTel aws-sdk integration + spanAttributes[AWS_KINESIS_STREAM_NAME] = streamName; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts new file mode 100644 index 000000000000..24062efe3cd8 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/s3.ts @@ -0,0 +1,20 @@ +import { SPAN_KIND } from '@sentry/core'; +import { AWS_S3_BUCKET } from '@sentry/conventions/attributes'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class S3ServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const bucketName = request.commandInput?.Bucket; + const spanAttributes: Record = {}; + + if (bucketName) { + spanAttributes[AWS_S3_BUCKET] = bucketName; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts new file mode 100644 index 000000000000..b73318ef23b8 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/secretsmanager.ts @@ -0,0 +1,27 @@ +import type { Span } from '@sentry/core'; +import { SPAN_KIND } from '@sentry/core'; +import { AWS_SECRETSMANAGER_SECRET_ARN as ATTR_AWS_SECRETSMANAGER_SECRET_ARN } from '@sentry/conventions/attributes'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SecretsManagerServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const secretId = request.commandInput?.SecretId; + const spanAttributes: Record = {}; + if (typeof secretId === 'string' && secretId.startsWith('arn:aws:secretsmanager:')) { + spanAttributes[ATTR_AWS_SECRETSMANAGER_SECRET_ARN] = secretId; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const secretArn = response.data?.ARN; + if (secretArn) { + span.setAttribute(ATTR_AWS_SECRETSMANAGER_SECRET_ARN, secretArn); + } + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts new file mode 100644 index 000000000000..05afa34d6a39 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/stepfunctions.ts @@ -0,0 +1,28 @@ +import { SPAN_KIND } from '@sentry/core'; +import { + AWS_STEP_FUNCTIONS_ACTIVITY_ARN as ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN, + AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN as ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN, +} from '@sentry/conventions/attributes'; +import type { NormalizedRequest } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class StepFunctionsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const stateMachineArn = request.commandInput?.stateMachineArn; + const activityArn = request.commandInput?.activityArn; + const spanAttributes: Record = {}; + + if (stateMachineArn) { + spanAttributes[ATTR_AWS_STEP_FUNCTIONS_STATE_MACHINE_ARN] = stateMachineArn; + } + + if (activityArn) { + spanAttributes[ATTR_AWS_STEP_FUNCTIONS_ACTIVITY_ARN] = activityArn; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + }; + } +} From 9646b109ec8d5a7109592b873ea1aa139420d6c3 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:59:32 +0200 Subject: [PATCH 23/54] feat(server-utils): Port SQS, SNS and Lambda aws-sdk extensions with trace propagation (#22165) Ports the messaging service extensions from the OTel aws-sdk integration to the orchestrion channel integration and registers them in the service registry: * SQS: producer/consumer span kinds and names, `messaging.*` attributes, trace propagation into outgoing `MessageAttributes` (single and batch), and span links from received messages via the propagated headers * SNS: producer span kind/name for `Publish`, `messaging.*` and topic ARN attributes, trace propagation into `MessageAttributes` * Lambda: `faas.*` attributes for `Invoke`, trace propagation into the base64 `ClientContext` (respecting the 3583 byte cap) Propagation writes Sentry-native `sentry-trace`/`baggage` headers derived from the request span instead of OTel `propagation.inject`, and the SQS receive side reads them back with `propagationContextFromHeaders`. Part of getsentry/sentry-javascript#20946 --- .../tracing-channel/aws-sdk/aws-sdk.types.ts | 59 +++++++ .../tracing-channel/aws-sdk/constants.ts | 9 +- .../aws-sdk/services/MessageAttributes.ts | 69 ++++++++ .../aws-sdk/services/ServicesExtensions.ts | 6 + .../aws-sdk/services/lambda.ts | 85 ++++++++++ .../tracing-channel/aws-sdk/services/sns.ts | 75 +++++++++ .../tracing-channel/aws-sdk/services/sqs.ts | 149 ++++++++++++++++++ 7 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts new file mode 100644 index 000000000000..bf290fc443b7 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/aws-sdk.types.ts @@ -0,0 +1,59 @@ +/* + * AWS SDK for JavaScript + * Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * This product includes software developed at + * Amazon Web Services, Inc. (http://aws.amazon.com/). + */ + +/* + These are slightly modified and simplified versions of the actual SQS/SNS types included + in the official distribution: + https://github.com/aws/aws-sdk-js/blob/master/clients/sqs.d.ts + These are brought here to avoid having users install the `aws-sdk` whenever they + require this instrumentation. +*/ + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface Blob {} +type Binary = Buffer | Uint8Array | Blob | string; + +// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location +export namespace SNS { + interface MessageAttributeValue { + DataType: string; + StringValue?: string; + BinaryValue?: Binary; + } + + export type MessageAttributeMap = { [key: string]: MessageAttributeValue }; +} + +// eslint-disable-next-line @typescript-eslint/no-namespace -- Prefer to contain the types copied over in one location +export namespace SQS { + type StringList = string[]; + type BinaryList = Binary[]; + interface MessageAttributeValue { + StringValue?: string; + BinaryValue?: Binary; + StringListValues?: StringList; + BinaryListValues?: BinaryList; + DataType: string; + } + + export type MessageBodyAttributeMap = { + [key: string]: MessageAttributeValue; + }; + + type MessageSystemAttributeMap = { [key: string]: string }; + + export interface Message { + MessageId?: string; + ReceiptHandle?: string; + MD5OfBody?: string; + Body?: string; + Attributes?: MessageSystemAttributeMap; + MD5OfMessageAttributes?: string; + MessageAttributes?: MessageBodyAttributeMap; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts index 320c24540f0a..dee0abe5ae09 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -1,7 +1,8 @@ /** * AWS-specific span constants used by the aws-sdk channel integration that are NOT covered by * `@sentry/conventions/attributes` (attribute names that exist there are imported from there - * directly). Per-service files append their own such constants below. + * directly). These are either Sentry-specific (the span origin), attribute *values* (not keys), or + * obsolete OTel conventions with no `@sentry/conventions` export. */ /** The span origin every aws-sdk channel span carries, mirroring the uniform OTel `auto.otel.aws`. */ @@ -9,3 +10,9 @@ export const AWS_SDK_ORIGIN = 'auto.aws.orchestrion.aws_sdk'; /** DynamoDB `db.system` value (an attribute value, not a key, so not covered by conventions). */ export const DB_SYSTEM_VALUE_DYNAMODB = 'dynamodb'; + +// Messaging (obsolete OTel convention with no `@sentry/conventions` export, kept for parity) +// TODO(v11): import from `@sentry/conventions` once a release including it ships (added in +// getsentry/sentry-conventions#509), and drop this local constant. +export const ATTR_MESSAGING_DESTINATION_KIND = 'messaging.destination_kind'; +export const MESSAGING_DESTINATION_KIND_VALUE_TOPIC = 'topic'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts new file mode 100644 index 000000000000..80258914f788 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/MessageAttributes.ts @@ -0,0 +1,69 @@ +import type { SerializedTraceData } from '@sentry/core'; +import { debug, uniq } from '@sentry/core'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import type { SNS, SQS } from '../aws-sdk.types'; + +// https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-quotas.html +export const MAX_MESSAGE_ATTRIBUTES = 10; + +// Sentry trace-propagation headers written into / read from AWS message attributes. +const SENTRY_TRACE_HEADER = 'sentry-trace'; +const BAGGAGE_HEADER = 'baggage'; +const PROPAGATION_FIELDS = [SENTRY_TRACE_HEADER, BAGGAGE_HEADER]; + +export interface AwsSdkContextObject { + [key: string]: { + StringValue?: string; + Value?: string; + }; +} + +/** + * Inject trace-propagation headers (from `getTraceData({ span })`) into an SQS/SNS message-attribute + * map, so the consumer can continue the trace. Respects the SQS 10-attribute quota. Mirrors the OTel + * integration's `injectPropagationContext`, but writes Sentry's `sentry-trace`/`baggage` instead of + * W3C headers. Callers pass the precomputed headers so batch sends serialize them only once. + */ +export function injectPropagationContext( + attributesMap: SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap | undefined, + traceData: SerializedTraceData, +): SQS.MessageBodyAttributeMap | SNS.MessageAttributeMap { + const attributes = attributesMap ?? {}; + const headerKeys = Object.keys(traceData) as (keyof SerializedTraceData)[]; + + if (Object.keys(attributes).length + headerKeys.length <= MAX_MESSAGE_ATTRIBUTES) { + for (const key of headerKeys) { + const value = traceData[key]; + if (value) { + // Index-assigning into the SQS/SNS map union needs one concrete map type; the written value + // shape is valid for both. + (attributes as SQS.MessageBodyAttributeMap)[key] = { DataType: 'String', StringValue: value }; + } + } + } else { + DEBUG_BUILD && + debug.warn( + '[orchestrion:aws-sdk] cannot set trace propagation on SQS/SNS message due to maximum amount of MessageAttributes', + ); + } + return attributes; +} + +/** Read the propagation headers back off a received SQS message, if present. */ +export function extractPropagationHeaders( + message: SQS.Message, +): { sentryTrace?: string; baggage?: string } | undefined { + const carrier = (message.MessageAttributes ?? {}) as AwsSdkContextObject; + const sentryTrace = carrier[SENTRY_TRACE_HEADER]?.StringValue ?? carrier[SENTRY_TRACE_HEADER]?.Value; + if (!sentryTrace) { + return undefined; + } + return { + sentryTrace, + baggage: carrier[BAGGAGE_HEADER]?.StringValue ?? carrier[BAGGAGE_HEADER]?.Value, + }; +} + +export function addPropagationFieldsToAttributeNames(messageAttributeNames: string[] = []): string[] { + return uniq([...messageAttributeNames, ...PROPAGATION_FIELDS]); +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts index b98d402c9078..b70b0f59bf6c 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts @@ -2,9 +2,12 @@ import type { Span } from '@sentry/core'; import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; import { DynamodbServiceExtension } from './dynamodb'; import { KinesisServiceExtension } from './kinesis'; +import { LambdaServiceExtension } from './lambda'; import { S3ServiceExtension } from './s3'; import { SecretsManagerServiceExtension } from './secretsmanager'; import type { ServiceExtension } from './ServiceExtension'; +import { SnsServiceExtension } from './sns'; +import { SqsServiceExtension } from './sqs'; import { StepFunctionsServiceExtension } from './stepfunctions'; export class ServicesExtensions implements ServiceExtension { @@ -13,7 +16,10 @@ export class ServicesExtensions implements ServiceExtension { private _services: Map = new Map([ ['SecretsManager', new SecretsManagerServiceExtension()], ['SFN', new StepFunctionsServiceExtension()], + ['SQS', new SqsServiceExtension()], + ['SNS', new SnsServiceExtension()], ['DynamoDB', new DynamodbServiceExtension()], + ['Lambda', new LambdaServiceExtension()], ['S3', new S3ServiceExtension()], ['Kinesis', new KinesisServiceExtension()], ]); diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts new file mode 100644 index 000000000000..e1a7cd1fce43 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/lambda.ts @@ -0,0 +1,85 @@ +import type { Span } from '@sentry/core'; +import { debug, getTraceData, SPAN_KIND } from '@sentry/core'; +import { + FAAS_EXECUTION as ATTR_FAAS_EXECUTION, + FAAS_INVOKED_NAME as ATTR_FAAS_INVOKED_NAME, + FAAS_INVOKED_PROVIDER as ATTR_FAAS_INVOKED_PROVIDER, + FAAS_INVOKED_REGION as ATTR_FAAS_INVOKED_REGION, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +const INVOKE_COMMAND = 'Invoke'; + +export class LambdaServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const functionName = request.commandInput?.FunctionName; + + const spanAttributes: Record = {}; + let spanName: string | undefined; + + if (request.commandName === INVOKE_COMMAND) { + spanAttributes[ATTR_FAAS_INVOKED_NAME] = functionName; + spanAttributes[ATTR_FAAS_INVOKED_PROVIDER] = 'aws'; + spanName = `${functionName} ${INVOKE_COMMAND}`; + } + + return { + spanAttributes, + spanKind: SPAN_KIND.CLIENT, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + if (request.commandName === INVOKE_COMMAND && request.commandInput) { + request.commandInput.ClientContext = injectLambdaPropagationContext(request.commandInput.ClientContext, span); + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + if (response.request.commandName === INVOKE_COMMAND) { + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv faas.execution, matched to the OTel aws-sdk integration + span.setAttribute(ATTR_FAAS_EXECUTION, response.requestId); + // Region resolves asynchronously after `requestPreSpanHook`, so it's backfilled onto the + // normalized request and read here (same timing as `cloud.region`). + if (response.request.region) { + span.setAttribute(ATTR_FAAS_INVOKED_REGION, response.request.region); + } + } + } +} + +function injectLambdaPropagationContext(clientContext: string | undefined, span: Span): string | undefined { + try { + const propagatedContext = getTraceData({ span }); + + const parsedClientContext = clientContext ? JSON.parse(Buffer.from(clientContext, 'base64').toString('utf8')) : {}; + + const updatedClientContext = { + ...parsedClientContext, + custom: { + ...parsedClientContext.custom, + ...propagatedContext, + }, + }; + + const encodedClientContext = Buffer.from(JSON.stringify(updatedClientContext)).toString('base64'); + + // The length of client context is capped at 3583 bytes of base64 encoded data + // (https://docs.aws.amazon.com/lambda/latest/dg/API_Invoke.html#API_Invoke_RequestSyntax) + if (encodedClientContext.length > 3583) { + DEBUG_BUILD && + debug.warn( + '[orchestrion:aws-sdk] cannot set trace propagation on lambda invoke parameters due to ClientContext length limitations.', + ); + return clientContext; + } + + return encodedClientContext; + } catch (e) { + DEBUG_BUILD && debug.log('[orchestrion:aws-sdk] failed to set trace propagation on lambda ClientContext', e); + return clientContext; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts new file mode 100644 index 000000000000..ca12a69c54aa --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sns.ts @@ -0,0 +1,75 @@ +import type { Span, SpanKindValue } from '@sentry/core'; +import { getTraceData, SPAN_KIND } from '@sentry/core'; +import { + AWS_SNS_TOPIC_ARN as ATTR_AWS_SNS_TOPIC_ARN, + MESSAGING_DESTINATION as ATTR_MESSAGING_DESTINATION, + MESSAGING_DESTINATION_NAME, + MESSAGING_SYSTEM, +} from '@sentry/conventions/attributes'; +import { ATTR_MESSAGING_DESTINATION_KIND, MESSAGING_DESTINATION_KIND_VALUE_TOPIC } from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import { injectPropagationContext } from './MessageAttributes'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SnsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + let spanKind: SpanKindValue = SPAN_KIND.CLIENT; + let spanName = `SNS ${request.commandName}`; + const spanAttributes: Record = { + [MESSAGING_SYSTEM]: 'aws.sns', + }; + + if (request.commandName === 'Publish') { + spanKind = SPAN_KIND.PRODUCER; + + spanAttributes[ATTR_MESSAGING_DESTINATION_KIND] = MESSAGING_DESTINATION_KIND_VALUE_TOPIC; + const { TopicArn, TargetArn, PhoneNumber } = request.commandInput; + const destinationName = extractDestinationName(TopicArn, TargetArn, PhoneNumber); + // oxlint-disable-next-line typescript/no-deprecated -- old-semconv messaging.destination, matched to the OTel aws-sdk integration + spanAttributes[ATTR_MESSAGING_DESTINATION] = destinationName; + spanAttributes[MESSAGING_DESTINATION_NAME] = TopicArn || TargetArn || PhoneNumber || 'unknown'; + + spanName = `${PhoneNumber ? 'phone_number' : destinationName} send`; + } + + const topicArn = request.commandInput?.TopicArn; + if (topicArn) { + spanAttributes[ATTR_AWS_SNS_TOPIC_ARN] = topicArn; + } + + return { + spanAttributes, + spanKind, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + if (request.commandName === 'Publish') { + const origMessageAttributes = request.commandInput.MessageAttributes ?? {}; + request.commandInput.MessageAttributes = injectPropagationContext(origMessageAttributes, getTraceData({ span })); + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const topicArn = response.data?.TopicArn; + if (topicArn) { + span.setAttribute(ATTR_AWS_SNS_TOPIC_ARN, topicArn); + } + } +} + +function extractDestinationName(topicArn: string, targetArn: string, phoneNumber: string): string { + if (topicArn || targetArn) { + const arn = topicArn ?? targetArn; + try { + return arn.substring(arn.lastIndexOf(':') + 1); + } catch { + return arn; + } + } else if (phoneNumber) { + return phoneNumber; + } else { + return 'unknown'; + } +} diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts new file mode 100644 index 000000000000..da1ec52ff013 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/sqs.ts @@ -0,0 +1,149 @@ +import type { Span, SpanKindValue } from '@sentry/core'; +import { getTraceData, propagationContextFromHeaders, SPAN_KIND } from '@sentry/core'; +import { + MESSAGING_BATCH_MESSAGE_COUNT, + MESSAGING_DESTINATION_NAME, + MESSAGING_MESSAGE_ID, + MESSAGING_OPERATION_TYPE, + MESSAGING_SYSTEM, + URL_FULL, +} from '@sentry/conventions/attributes'; +import type { SQS } from '../aws-sdk.types'; +import type { CommandInput, NormalizedRequest, NormalizedResponse } from '../types'; +import { + addPropagationFieldsToAttributeNames, + extractPropagationHeaders, + injectPropagationContext, +} from './MessageAttributes'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +export class SqsServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + const queueUrl = extractQueueUrl(request.commandInput); + const queueName = extractQueueNameFromUrl(queueUrl); + let spanKind: SpanKindValue = SPAN_KIND.CLIENT; + let spanName: string | undefined; + + const spanAttributes: Record = { + [MESSAGING_SYSTEM]: 'aws_sqs', + [MESSAGING_DESTINATION_NAME]: queueName, + [URL_FULL]: queueUrl, + }; + + switch (request.commandName) { + case 'ReceiveMessage': + { + spanKind = SPAN_KIND.CONSUMER; + spanName = `${queueName} receive`; + spanAttributes[MESSAGING_OPERATION_TYPE] = 'receive'; + + request.commandInput.MessageAttributeNames = addPropagationFieldsToAttributeNames( + request.commandInput.MessageAttributeNames, + ); + } + break; + + case 'SendMessage': + case 'SendMessageBatch': + spanKind = SPAN_KIND.PRODUCER; + spanName = `${queueName} send`; + break; + } + + return { + spanAttributes, + spanKind, + spanName, + }; + } + + public requestPostSpanHook(request: NormalizedRequest, span: Span): void { + switch (request.commandName) { + case 'SendMessage': + { + const origMessageAttributes = request.commandInput.MessageAttributes ?? {}; + request.commandInput.MessageAttributes = injectPropagationContext( + origMessageAttributes, + getTraceData({ span }), + ); + } + break; + + case 'SendMessageBatch': + { + const entries = request.commandInput?.Entries; + if (Array.isArray(entries)) { + // Serialized once; the headers are identical for every entry of the batch. + const traceData = getTraceData({ span }); + entries.forEach((messageParams: { MessageAttributes: SQS.MessageBodyAttributeMap }) => { + messageParams.MessageAttributes = injectPropagationContext( + messageParams.MessageAttributes ?? {}, + traceData, + ); + }); + } + } + break; + } + } + + public responseHook(response: NormalizedResponse, span: Span): void { + switch (response.request.commandName) { + case 'SendMessage': + span.setAttribute(MESSAGING_MESSAGE_ID, response?.data?.MessageId); + break; + + case 'SendMessageBatch': + break; + + case 'ReceiveMessage': { + const messages: SQS.Message[] = response?.data?.Messages || []; + + span.setAttribute(MESSAGING_BATCH_MESSAGE_COUNT, messages.length); + + for (const message of messages) { + linkReceivedMessageToProducer(span, message); + } + break; + } + } + } +} + +/** + * Link a received SQS message's span back to the producer trace carried in its propagation headers. + * No-op when the message has no (valid) `sentry-trace`/`baggage` attributes. + */ +function linkReceivedMessageToProducer(span: Span, message: SQS.Message): void { + const headers = extractPropagationHeaders(message); + if (!headers) { + return; + } + + const { parentSpanId, traceId, sampled } = propagationContextFromHeaders(headers.sentryTrace, headers.baggage); + if (traceId && parentSpanId) { + span.addLink({ + context: { + traceId, + spanId: parentSpanId, + traceFlags: sampled ? 1 : 0, + }, + attributes: { + [MESSAGING_MESSAGE_ID]: message.MessageId, + }, + }); + } +} + +function extractQueueUrl(commandInput: CommandInput): string { + return commandInput?.QueueUrl; +} + +function extractQueueNameFromUrl(queueUrl: string): string | undefined { + if (!queueUrl) return undefined; + + const segments = queueUrl.split('/'); + if (segments.length === 0) return undefined; + + return segments[segments.length - 1]; +} From 126ea96c07fc52ba260d1ef8ed981e45eaa6b125 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:59:32 +0200 Subject: [PATCH 24/54] feat(aws-serverless): Use orchestrion aws-sdk integration under diagnostics-channel opt-in (#22143) Wires the orchestrion aws-sdk channel integration into `@sentry/aws-serverless`, where the OTel `Aws` integration ships as a default today. `@sentry/node` exposes a reusable `applyDiagnosticsChannelInjectionIntegrations` helper (extracted from its `getDefaultIntegrations`), and `@sentry/aws-serverless` uses it to swap the OTel `Aws` integration for the channel version when the app opts in via `experimentalUseDiagnosticsChannelInjection()` (now re-exported from the aws-serverless SDK). No change when the opt-in isn't used. The existing `aws-integration` node-integration-tests assert the swapped origin via `isOrchestrionEnabled()`, so both the OTel and diagnostics-channel paths are covered by the same suites across both smithy stacks (latest + legacy `3.1041.0`) and ESM/CJS. Only the expected origin is parametrized. Part of getsentry/sentry-javascript#20946 --- .../scripts/consistentExports.ts | 3 ++ .../aws-integration-streamed/test.ts | 12 +++--- .../aws-serverless/aws-integration/test.ts | 37 ++++++++++--------- packages/aws-serverless/src/index.ts | 2 + packages/aws-serverless/src/init.ts | 15 ++++++-- packages/node/src/index.ts | 1 + packages/node/src/sdk/index.ts | 31 ++++++++++++++++ 7 files changed, 76 insertions(+), 25 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 14dedc28363d..7e0873355c65 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -31,6 +31,9 @@ const NODE_EXPORTS_IGNORE = [ 'diagnosticsChannelInjectionIntegrations', // Companion to the above two, same reasoning (Next.js re-exports it via `export * from '@sentry/node'`) 'isDiagnosticsChannelInjectionEnabled', + // Helper for SDKs that build their own default-integration set (e.g. aws-serverless) + // to apply the diagnostics-channel integration swap; not surfaced elsewhere. + 'applyDiagnosticsChannelInjectionIntegrations', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', ]; diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts index 48e81b01b9db..a5e4ea13011a 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration-streamed/test.ts @@ -1,7 +1,12 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +// See the non-streamed `aws-integration` suite: only the origin differs between the OTel and +// orchestrion diagnostics-channel runs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws_sdk' : 'auto.otel.aws'; + // The aws-sdk instrumentation creates spans by patching the underlying smithy middleware stack. The // patch target differs between aws-sdk versions, so we run the exact same assertions against both: // - the current aws-sdk (default, resolved from the workspace) which routes through `@smithy/core` >= 3.24.0 @@ -49,7 +54,7 @@ function assertAwsServiceSpans(spanCcontainer: SerializedStreamedSpanContainer): name: 'S3.PutObject', status: 'ok', attributes: expect.objectContaining({ - 'sentry.origin': { value: 'auto.otel.aws', type: 'string' }, + 'sentry.origin': { value: ORIGIN, type: 'string' }, 'sentry.op': { value: 'rpc', type: 'string' }, 'rpc.system': { value: 'aws-api', type: 'string' }, 'rpc.method': { value: 'PutObject', type: 'string' }, @@ -221,10 +226,7 @@ describe('awsIntegration (streamed)', () => { await createTestRunner().ignore('event').expect({ span: assertAwsServiceSpans }).start().completed(); }); }, - // The orchestrion aws-sdk channel integration has no service extensions yet (empty registry), - // so it can't emit the service-specific attributes asserted here. Stay on the OTel path until - // the service extensions land in a follow-up. - { additionalDependencies, injectOrchestrion: false }, + { additionalDependencies }, ); }); }); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts index 2da3a170a11c..22d84e5ffb3a 100644 --- a/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts +++ b/dev-packages/node-integration-tests/suites/aws-serverless/aws-integration/test.ts @@ -1,7 +1,13 @@ import type { TransactionEvent } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +// The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the +// orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the +// same spans; only the origin differs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws_sdk' : 'auto.otel.aws'; + // The aws-sdk instrumentation creates spans by patching the underlying smithy middleware stack. The // patch target differs between aws-sdk versions, so we run the exact same assertions against both: // - the current aws-sdk (default, resolved from the workspace) which routes through `@smithy/core` >= 3.24.0 @@ -40,10 +46,10 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.PutObject', { description: 'S3.PutObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ - 'sentry.origin': 'auto.otel.aws', + 'sentry.origin': ORIGIN, 'sentry.op': 'rpc', 'rpc.system': 'aws-api', 'rpc.method': 'PutObject', @@ -58,7 +64,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.GetObject (success)', { description: 'S3.GetObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3', 'aws.s3.bucket': 'ot-demo-test' }), }); @@ -67,7 +73,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('S3.GetObject (error)', { description: 'S3.GetObject', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'internal_error', data: expect.objectContaining({ 'rpc.method': 'GetObject', 'rpc.service': 'S3' }), }); @@ -76,7 +82,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('DynamoDB.PutItem', { description: 'DynamoDB.PutItem', op: 'db', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'sentry.op': 'db', 'rpc.method': 'PutItem', @@ -92,7 +98,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('DynamoDB.Query', { description: 'DynamoDB.Query', op: 'db', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Query', 'db.operation': 'Query', @@ -105,7 +111,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SQS SendMessage', { description: 'my-queue send', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'SendMessage', 'rpc.service': 'SQS', @@ -121,7 +127,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SQS ReceiveMessage', { description: 'my-queue receive', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'ReceiveMessage', 'messaging.system': 'aws_sqs', @@ -135,7 +141,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SNS Publish', { description: 'my-topic send', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Publish', 'rpc.service': 'SNS', @@ -150,7 +156,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('Lambda Invoke', { description: 'my-function Invoke', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'Invoke', 'rpc.service': 'Lambda', @@ -164,7 +170,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('Kinesis.PutRecord', { description: 'Kinesis.PutRecord', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, status: 'ok', data: expect.objectContaining({ 'rpc.method': 'PutRecord', @@ -177,7 +183,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('SecretsManager.GetSecretValue', { description: 'SecretsManager.GetSecretValue', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'GetSecretValue', 'rpc.service': 'SecretsManager', @@ -189,7 +195,7 @@ function assertAwsServiceSpans(transaction: TransactionEvent): void { expectSpan('StepFunctions.StartExecution', { description: 'SFN.StartExecution', op: 'rpc', - origin: 'auto.otel.aws', + origin: ORIGIN, data: expect.objectContaining({ 'rpc.method': 'StartExecution', 'rpc.service': 'SFN', @@ -216,10 +222,7 @@ describe('awsIntegration', () => { await createTestRunner().ignore('event').expect({ transaction: assertAwsServiceSpans }).start().completed(); }); }, - // The orchestrion aws-sdk channel integration has no service extensions yet (empty registry), - // so it can't emit the service-specific attributes asserted here. Stay on the OTel path until - // the service extensions land in a follow-up. - { additionalDependencies, injectOrchestrion: false }, + { additionalDependencies }, ); }); }); diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 71222eb0a6a6..2078044d64a8 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -167,6 +167,8 @@ export { metrics, spanStreamingIntegration, withStreamedSpan, + experimentalUseDiagnosticsChannelInjection, + diagnosticsChannelInjectionIntegrations, } from '@sentry/node'; export { diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index fe069d7ff6f5..9d815a304a22 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,7 +1,11 @@ import type { Integration, Options } from '@sentry/core'; import { applySdkMetadata, debug, getSDKSource } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; -import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; +import { + applyDiagnosticsChannelInjectionIntegrations, + getDefaultIntegrationsWithoutPerformance, + initWithoutDefaultIntegrations, +} from '@sentry/node'; import { envToBool } from '@sentry/node-core'; import { DEBUG_BUILD } from './debug-build'; import { awsIntegration } from './integration/aws'; @@ -49,8 +53,13 @@ function shouldDisableLayerExtensionForProxy(): boolean { */ // NOTE: in awslambda-auto.ts, we also call the original `getDefaultIntegrations` from `@sentry/node` to load performance integrations. // If at some point we need to filter a node integration out for good, we need to make sure to also filter it out there. -export function getDefaultIntegrations(_options: Options): Integration[] { - return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; +export function getDefaultIntegrations(options: Options): Integration[] { + const integrations = [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; + // If the app opted into diagnostics-channel injection, the OTel `Aws` integration is swapped for + // its channel-based equivalent AND the full channel-integration set is appended (mysql, postgres, + // express, ...), giving opted-in apps performance coverage this SDK's defaults otherwise omit. + // No-op otherwise. + return applyDiagnosticsChannelInjectionIntegrations(integrations, options); } export interface AwsServerlessOptions extends NodeOptions { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 61c80f44794f..1ad92fd21b8e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -46,6 +46,7 @@ export { getDefaultIntegrations, getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, + applyDiagnosticsChannelInjectionIntegrations, } from './sdk'; export { experimentalUseDiagnosticsChannelInjection, diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 77c3c3bc6fcc..760ae63b9696 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -40,6 +40,37 @@ export function getDefaultIntegrations(options: Options): Integration[] { ]; } +/** + * When the app opted into diagnostics-channel injection (via + * `experimentalUseDiagnosticsChannelInjection()`) AND span recording is enabled, drop the OTel + * integrations that have a channel-based replacement and append the FULL channel-integration set, + * so the two never both instrument the same library. Otherwise returns `integrations` unchanged. + * + * `_init` applies the same swap to `defaultIntegrations`, but SDKs that seed their integrations + * through the user `integrations` option instead (e.g. the `@sentry/aws-serverless` Lambda layer + * entry) never hit that path, so they call this directly from their own `getDefaultIntegrations`. + * + * Note the asymmetry: appended channel integrations are not limited to ones whose OTel counterpart + * was in `integrations`. For `@sentry/node` that makes no difference (the incoming list carries the + * whole OTel performance set), but a caller with a narrower list (e.g. `@sentry/aws-serverless`) + * gains channel coverage for libraries it never shipped OTel integrations for. Channel integrations + * produce nothing but spans, so this is gated on span recording. Exported so SDKs that build their + * own default-integration set can apply the same logic instead of duplicating it. + */ +export function applyDiagnosticsChannelInjectionIntegrations( + integrations: Integration[], + options: Options, +): Integration[] { + if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { + const diagnosticsChannelInjection = resolveDiagnosticsChannelInjection(); + if (diagnosticsChannelInjection) { + const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); + return [...integrations.filter(i => !replaced.has(i.name)), ...diagnosticsChannelInjection.integrations]; + } + } + return integrations; +} + /** * Initialize Sentry for Node. */ From d4098b3ef63d5030e07bb2729098149f3ed4fedf Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:59:33 +0200 Subject: [PATCH 25/54] feat(server-utils): Port Bedrock aws-sdk extension and add integration tests (#22166) Ports the BedrockRuntime gen_ai service extension from the OTel aws-sdk integration to the orchestrion channel integration: chat span name and request parameters for `Converse`/`ConverseStream`, per-model-family request body parsing for `InvokeModel`/`InvokeModelWithResponseStream` (titan, nova, claude, llama, cohere, mistral), token usage and finish reasons from responses, and deferred span end for the streaming commands (the span ends when the wrapped stream is consumed). Adds nock-mocked integration tests for the non-streaming `Converse` and `InvokeModel` commands, asserted against both the OTel and diagnostics-channel paths. The streaming commands use the AWS binary eventstream framing which nock cannot mock, so they stay untested here. This completes orchestrion parity with the OTel aws-sdk integration. ## User-facing differences to the vendored OTel instrumentation * `ConverseStream` callers receive the original response object with its `stream` wrapped in place, instead of a new object (a channel subscriber can't replace the resolved value); relevant only to identity-sensitive code Fixes getsentry/sentry-javascript#20946 --- .../aws-serverless/bedrock/instrument.mjs | 9 + .../aws-serverless/bedrock/scenario.mjs | 84 +++ .../suites/aws-serverless/bedrock/test.ts | 75 +++ .../tracing-channel/aws-sdk/constants.ts | 4 + .../aws-sdk/services/ServicesExtensions.ts | 2 + .../aws-sdk/services/bedrock-runtime.ts | 494 ++++++++++++++++++ 6 files changed, 668 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs new file mode 100644 index 000000000000..fe1c5c47983f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/aws-serverless'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs new file mode 100644 index 000000000000..c1705ff0d403 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/scenario.mjs @@ -0,0 +1,84 @@ +import * as Sentry from '@sentry/aws-serverless'; +import { BedrockRuntimeClient, ConverseCommand, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime'; +// Force the HTTP/1 handler so `nock` can intercept the request instead of hitting real AWS. +import { NodeHttpHandler } from '@smithy/node-http-handler'; +import nock from 'nock'; + +nock.disableNetConnect(); + +const region = 'us-east-1'; +const credentials = { accessKeyId: 'aws-test-key', secretAccessKey: 'aws-test-secret' }; +const host = `https://bedrock-runtime.${region}.amazonaws.com`; + +async function converse() { + const client = new BedrockRuntimeClient({ + region, + credentials, + maxAttempts: 1, + requestHandler: new NodeHttpHandler(), + }); + + nock(host) + .post(/\/model\/.*\/converse$/) + .reply( + 200, + JSON.stringify({ + output: { message: { role: 'assistant', content: [{ text: 'Hello from Bedrock' }] } }, + stopReason: 'end_turn', + usage: { inputTokens: 12, outputTokens: 8, totalTokens: 20 }, + }), + { 'content-type': 'application/json' }, + ); + + await client.send( + new ConverseCommand({ + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + inferenceConfig: { maxTokens: 100, temperature: 0.5, topP: 0.9 }, + }), + ); +} + +async function invokeModel() { + const client = new BedrockRuntimeClient({ + region, + credentials, + maxAttempts: 1, + requestHandler: new NodeHttpHandler(), + }); + + nock(host) + .post(/\/model\/.*\/invoke$/) + .reply( + 200, + JSON.stringify({ + content: [{ type: 'text', text: 'Hello from Bedrock' }], + stop_reason: 'end_turn', + usage: { input_tokens: 15, output_tokens: 9 }, + }), + { 'content-type': 'application/json' }, + ); + + await client.send( + new InvokeModelCommand({ + modelId: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + contentType: 'application/json', + body: JSON.stringify({ + anthropic_version: 'bedrock-2023-05-31', + max_tokens: 100, + temperature: 0.5, + top_p: 0.9, + messages: [{ role: 'user', content: 'Hello' }], + }), + }), + ); +} + +async function run() { + await Sentry.startSpan({ name: 'Test Transaction' }, async () => { + await converse(); + await invokeModel(); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts new file mode 100644 index 000000000000..d04c2cf1d7fa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-serverless/bedrock/test.ts @@ -0,0 +1,75 @@ +import type { TransactionEvent } from '@sentry/core'; +import { afterAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// The suite runs twice on CI: once with the OTel `Aws` integration (default) and once with the +// orchestrion diagnostics-channel integration auto-injected (`INJECT_ORCHESTRION`). Both emit the +// same gen_ai spans; only the origin differs. +const ORIGIN = isOrchestrionEnabled() ? 'auto.aws.orchestrion.aws_sdk' : 'auto.otel.aws'; + +const MODEL_ID = 'anthropic.claude-3-5-sonnet-20240620-v1:0'; + +function assertBedrockSpans(transaction: TransactionEvent): void { + const spans = transaction.spans ?? []; + + expect(transaction.transaction).toBe('Test Transaction'); + + // Converse (non-streaming) + expect(spans, 'expected a Bedrock Converse span').toContainEqual( + expect.objectContaining({ + description: `chat ${MODEL_ID}`, + origin: ORIGIN, + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': ORIGIN, + 'gen_ai.system': 'aws.bedrock', + 'gen_ai.operation.name': 'chat', + 'gen_ai.request.model': MODEL_ID, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.temperature': 0.5, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.usage.input_tokens': 12, + 'gen_ai.usage.output_tokens': 8, + 'gen_ai.response.finish_reasons': ['end_turn'], + }), + }), + ); + + // InvokeModel (non-streaming, anthropic.claude request/response body) + expect(spans, 'expected a Bedrock InvokeModel span').toContainEqual( + expect.objectContaining({ + origin: ORIGIN, + status: 'ok', + data: expect.objectContaining({ + 'sentry.origin': ORIGIN, + 'gen_ai.system': 'aws.bedrock', + 'gen_ai.request.model': MODEL_ID, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.temperature': 0.5, + 'gen_ai.request.top_p': 0.9, + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 9, + 'gen_ai.response.finish_reasons': ['end_turn'], + }), + }), + ); +} + +describe('awsIntegration - Bedrock', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments Bedrock Converse and InvokeModel', { timeout: 90_000 }, async () => { + await createTestRunner().ignore('event').expect({ transaction: assertBedrockSpans }).start().completed(); + }); + }, + { additionalDependencies: { '@aws-sdk/client-bedrock-runtime': '^3.1046.0' } }, + ); +}); diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts index dee0abe5ae09..1f6fc9b28031 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/constants.ts @@ -16,3 +16,7 @@ export const DB_SYSTEM_VALUE_DYNAMODB = 'dynamodb'; // getsentry/sentry-conventions#509), and drop this local constant. export const ATTR_MESSAGING_DESTINATION_KIND = 'messaging.destination_kind'; export const MESSAGING_DESTINATION_KIND_VALUE_TOPIC = 'topic'; + +// Bedrock (gen_ai) attribute values (not keys, so not covered by conventions) +export const GEN_AI_OPERATION_NAME_VALUE_CHAT = 'chat'; +export const GEN_AI_SYSTEM_VALUE_AWS_BEDROCK = 'aws.bedrock'; diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts index b70b0f59bf6c..36dfcc884892 100644 --- a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/ServicesExtensions.ts @@ -1,5 +1,6 @@ import type { Span } from '@sentry/core'; import type { NormalizedRequest, NormalizedResponse, RequestMetadata } from '../types'; +import { BedrockRuntimeServiceExtension } from './bedrock-runtime'; import { DynamodbServiceExtension } from './dynamodb'; import { KinesisServiceExtension } from './kinesis'; import { LambdaServiceExtension } from './lambda'; @@ -22,6 +23,7 @@ export class ServicesExtensions implements ServiceExtension { ['Lambda', new LambdaServiceExtension()], ['S3', new S3ServiceExtension()], ['Kinesis', new KinesisServiceExtension()], + ['BedrockRuntime', new BedrockRuntimeServiceExtension()], ]); public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { diff --git a/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts new file mode 100644 index 000000000000..94cab0159c18 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/aws-sdk/services/bedrock-runtime.ts @@ -0,0 +1,494 @@ +import type { Span } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_STOP_SEQUENCES as ATTR_GEN_AI_REQUEST_STOP_SEQUENCES, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_P, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_SYSTEM, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, +} from '@sentry/conventions/attributes'; +import { DEBUG_BUILD } from '../../../../debug-build'; +import { GEN_AI_OPERATION_NAME_VALUE_CHAT, GEN_AI_SYSTEM_VALUE_AWS_BEDROCK } from '../constants'; +import type { NormalizedRequest, NormalizedResponse } from '../types'; +import type { RequestMetadata, ServiceExtension } from './ServiceExtension'; + +// Simplified types inlined from @aws-sdk/client-bedrock-runtime +// Only the fields accessed by this instrumentation are included +interface TokenUsage { + inputTokens: number | undefined; + outputTokens: number | undefined; + totalTokens: number | undefined; +} + +interface ConverseStreamOutput { + messageStop?: { stopReason?: string }; + metadata?: { usage?: TokenUsage }; + [key: string]: unknown; +} + +// Streamed `InvokeModel` chunks and response bodies are model-family-specific JSON (titan, nova, +// claude, llama, cohere, mistral); the record helpers probe the shapes defensively, so `any` instead +// of one structural type per family. +type ParsedChunk = any; + +const textDecoder = new TextDecoder(); + +export class BedrockRuntimeServiceExtension implements ServiceExtension { + public requestPreSpanHook(request: NormalizedRequest): RequestMetadata { + switch (request.commandName) { + case 'Converse': + return this._requestPreSpanHookConverse(request, false); + case 'ConverseStream': + return this._requestPreSpanHookConverse(request, true); + case 'InvokeModel': + return this._requestPreSpanHookInvokeModel(request, false); + case 'InvokeModelWithResponseStream': + return this._requestPreSpanHookInvokeModel(request, true); + } + + return {}; + } + + public responseHook(response: NormalizedResponse, span: Span): void { + const commandName = response.request.commandName; + + if (!span.isRecording()) { + // Streaming spans are ended by the wrapped stream (the subscriber's `deferSpanEnd` suppresses + // the helper's own `end()`). When the span isn't recording we skip wrapping, so end it here to + // avoid leaking an open span. Non-streaming commands are ended by the helper, so leave them. + if (commandName === 'ConverseStream' || commandName === 'InvokeModelWithResponseStream') { + span.end(); + } + return; + } + + switch (commandName) { + case 'Converse': + return this._responseHookConverse(response, span); + case 'ConverseStream': + return this._responseHookConverseStream(response, span); + case 'InvokeModel': + return this._responseHookInvokeModel(response, span); + case 'InvokeModelWithResponseStream': + return this._responseHookInvokeModelWithResponseStream(response, span); + } + } + + private _requestPreSpanHookConverse(request: NormalizedRequest, isStream: boolean): RequestMetadata { + let spanName = GEN_AI_OPERATION_NAME_VALUE_CHAT; + const spanAttributes: Record = { + // oxlint-disable-next-line typescript/no-deprecated + [GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, + [GEN_AI_OPERATION_NAME]: GEN_AI_OPERATION_NAME_VALUE_CHAT, + }; + + const modelId = request.commandInput.modelId; + if (modelId) { + spanAttributes[GEN_AI_REQUEST_MODEL] = modelId; + if (spanName) { + spanName += ` ${modelId}`; + } + } + + const inferenceConfig = request.commandInput.inferenceConfig; + if (inferenceConfig) { + const { maxTokens, temperature, topP, stopSequences } = inferenceConfig; + if (maxTokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = maxTokens; + } + if (temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = temperature; + } + if (topP !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = topP; + } + if (stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = stopSequences; + } + } + + return { + spanName, + isStream, + spanAttributes, + }; + } + + private _requestPreSpanHookInvokeModel(request: NormalizedRequest, isStream: boolean): RequestMetadata { + const spanAttributes: Record = { + // oxlint-disable-next-line typescript/no-deprecated + [GEN_AI_SYSTEM]: GEN_AI_SYSTEM_VALUE_AWS_BEDROCK, + }; + + const modelId = request.commandInput?.modelId; + if (modelId) { + spanAttributes[GEN_AI_REQUEST_MODEL] = modelId; + } + + if (request.commandInput?.body) { + const requestBody = JSON.parse(request.commandInput.body); + if (modelId.includes('amazon.titan')) { + if (requestBody.textGenerationConfig?.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.textGenerationConfig.temperature; + } + if (requestBody.textGenerationConfig?.topP !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.textGenerationConfig.topP; + } + if (requestBody.textGenerationConfig?.maxTokenCount !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.textGenerationConfig.maxTokenCount; + } + if (requestBody.textGenerationConfig?.stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.textGenerationConfig.stopSequences; + } + } else if (modelId.includes('amazon.nova')) { + if (requestBody.inferenceConfig?.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.inferenceConfig.temperature; + } + if (requestBody.inferenceConfig?.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.inferenceConfig.top_p; + } + if (requestBody.inferenceConfig?.max_new_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.inferenceConfig.max_new_tokens; + } + if (requestBody.inferenceConfig?.stopSequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.inferenceConfig.stopSequences; + } + } else if (modelId.includes('anthropic.claude')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('meta.llama')) { + if (requestBody.max_gen_len !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_gen_len; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + // request for meta llama models does not contain stop_sequences field + } else if (modelId.includes('cohere.command-r')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.p; + } + if (requestBody.message !== undefined) { + // NOTE: We approximate the token count since this value is not directly available in the body. + // According to Bedrock docs they use (total_chars / 6) to approximate token count for pricing. + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.message.length / 6); + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('cohere.command')) { + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.p; + } + if (requestBody.prompt !== undefined) { + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.prompt.length / 6); + } + if (requestBody.stop_sequences !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop_sequences; + } + } else if (modelId.includes('mistral')) { + if (requestBody.prompt !== undefined) { + spanAttributes[GEN_AI_USAGE_INPUT_TOKENS] = Math.ceil(requestBody.prompt.length / 6); + } + if (requestBody.max_tokens !== undefined) { + spanAttributes[GEN_AI_REQUEST_MAX_TOKENS] = requestBody.max_tokens; + } + if (requestBody.temperature !== undefined) { + spanAttributes[GEN_AI_REQUEST_TEMPERATURE] = requestBody.temperature; + } + if (requestBody.top_p !== undefined) { + spanAttributes[GEN_AI_REQUEST_TOP_P] = requestBody.top_p; + } + if (requestBody.stop !== undefined) { + spanAttributes[ATTR_GEN_AI_REQUEST_STOP_SEQUENCES] = requestBody.stop; + } + } + } + + return { + isStream, + spanAttributes, + }; + } + + private _responseHookConverse(response: NormalizedResponse, span: Span): void { + const { stopReason, usage } = response.data; + + setStopReason(span, stopReason); + setUsage(span, usage); + } + + private _responseHookConverseStream(response: NormalizedResponse, span: Span): void { + // Wrap and replace the response stream in place to process events into telemetry + // before yielding to the user. + response.data.stream = wrapConverseStreamResponse(response.data.stream, span); + } + + private _responseHookInvokeModel(response: NormalizedResponse, span: Span): void { + const currentModelId = response.request.commandInput?.modelId; + if (response.data?.body) { + const decodedResponseBody = textDecoder.decode(response.data.body); + const responseBody = JSON.parse(decodedResponseBody); + if (currentModelId.includes('amazon.titan')) { + if (responseBody.inputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.inputTextTokenCount); + } + if (responseBody.results?.[0]?.tokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.results[0].tokenCount); + } + if (responseBody.results?.[0]?.completionReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.results[0].completionReason]); + } + } else if (currentModelId.includes('amazon.nova')) { + if (responseBody.usage !== undefined) { + if (responseBody.usage.inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.usage.inputTokens); + } + if (responseBody.usage.outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.usage.outputTokens); + } + } + if (responseBody.stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stopReason]); + } + } else if (currentModelId.includes('anthropic.claude')) { + if (responseBody.usage?.input_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.usage.input_tokens); + } + if (responseBody.usage?.output_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.usage.output_tokens); + } + if (responseBody.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stop_reason]); + } + } else if (currentModelId.includes('meta.llama')) { + if (responseBody.prompt_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, responseBody.prompt_token_count); + } + if (responseBody.generation_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, responseBody.generation_token_count); + } + if (responseBody.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.stop_reason]); + } + } else if (currentModelId.includes('cohere.command-r')) { + if (responseBody.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.text.length / 6)); + } + if (responseBody.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.finish_reason]); + } + } else if (currentModelId.includes('cohere.command')) { + if (responseBody.generations?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.generations[0].text.length / 6)); + } + if (responseBody.generations?.[0]?.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.generations[0].finish_reason]); + } + } else if (currentModelId.includes('mistral')) { + if (responseBody.outputs?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(responseBody.outputs[0].text.length / 6)); + } + if (responseBody.outputs?.[0]?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [responseBody.outputs[0].stop_reason]); + } + } + } + } + + private _responseHookInvokeModelWithResponseStream(response: NormalizedResponse, span: Span): void { + const stream = response.data?.body; + const modelId = response.request.commandInput?.modelId; + if (!stream || !modelId) { + return; + } + + // Resolved once: the model family is fixed for the whole stream, and for unrecognized families + // the chunks don't need to be parsed at all. + const recordAttributes = resolveStreamRecorder(modelId); + + // Replace the original response body with our instrumented stream, deferring span.end() until the + // entire stream is consumed. Downstream consumers still receive the full stream. + response.data.body = (async function* () { + try { + for await (const chunk of stream) { + if (recordAttributes) { + const parsedChunk = parseChunk(chunk?.chunk?.bytes); + if (parsedChunk) { + recordAttributes(parsedChunk, span); + } + } + yield chunk; + } + } finally { + span.end(); + } + })(); + } +} + +function resolveStreamRecorder(modelId: string): ((parsedChunk: ParsedChunk, span: Span) => void) | undefined { + if (modelId.includes('amazon.titan')) return recordTitanAttributes; + if (modelId.includes('anthropic.claude')) return recordClaudeAttributes; + if (modelId.includes('amazon.nova')) return recordNovaAttributes; + if (modelId.includes('meta.llama')) return recordLlamaAttributes; + if (modelId.includes('cohere.command-r')) return recordCohereRAttributes; + if (modelId.includes('cohere.command')) return recordCohereAttributes; + if (modelId.includes('mistral')) return recordMistralAttributes; + return undefined; +} + +async function* wrapConverseStreamResponse( + stream: AsyncIterable, + span: Span, +): AsyncGenerator { + try { + let usage: TokenUsage | undefined; + for await (const item of stream) { + setStopReason(span, item.messageStop?.stopReason); + usage = item.metadata?.usage; + yield item; + } + setUsage(span, usage); + } finally { + span.end(); + } +} + +function setStopReason(span: Span, stopReason: string | undefined): void { + if (stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [stopReason]); + } +} + +function setUsage(span: Span, usage: TokenUsage | undefined): void { + if (usage) { + const { inputTokens, outputTokens } = usage; + if (inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, inputTokens); + } + if (outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, outputTokens); + } + } +} + +function parseChunk(bytes?: Uint8Array): ParsedChunk { + if (!bytes || !(bytes instanceof Uint8Array)) { + return null; + } + try { + const str = Buffer.from(bytes).toString('utf-8'); + return JSON.parse(str); + } catch (err) { + DEBUG_BUILD && debug.warn('[orchestrion:aws-sdk] failed to parse streamed bedrock chunk', err); + return null; + } +} + +function recordNovaAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.metadata?.usage !== undefined) { + if (parsedChunk.metadata?.usage.inputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.metadata.usage.inputTokens); + } + if (parsedChunk.metadata?.usage.outputTokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.metadata.usage.outputTokens); + } + } + if (parsedChunk.messageStop?.stopReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.messageStop.stopReason]); + } +} + +function recordClaudeAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.message?.usage?.input_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.message.usage.input_tokens); + } + if (parsedChunk.message?.usage?.output_tokens !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.message.usage.output_tokens); + } + if (parsedChunk.delta?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.delta.stop_reason]); + } +} + +function recordTitanAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.inputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.inputTextTokenCount); + } + if (parsedChunk.totalOutputTextTokenCount !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.totalOutputTextTokenCount); + } + if (parsedChunk.completionReason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.completionReason]); + } +} + +function recordLlamaAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.prompt_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_INPUT_TOKENS, parsedChunk.prompt_token_count); + } + if (parsedChunk.generation_token_count !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, parsedChunk.generation_token_count); + } + if (parsedChunk.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.stop_reason]); + } +} + +function recordMistralAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.outputs?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.outputs[0].text.length / 6)); + } + if (parsedChunk.outputs?.[0]?.stop_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.outputs[0].stop_reason]); + } +} + +function recordCohereAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.generations?.[0]?.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.generations[0].text.length / 6)); + } + if (parsedChunk.generations?.[0]?.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.generations[0].finish_reason]); + } +} + +function recordCohereRAttributes(parsedChunk: ParsedChunk, span: Span): void { + if (parsedChunk.text !== undefined) { + span.setAttribute(GEN_AI_USAGE_OUTPUT_TOKENS, Math.ceil(parsedChunk.text.length / 6)); + } + if (parsedChunk.finish_reason !== undefined) { + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, [parsedChunk.finish_reason]); + } +} From 2014e5d74f63d2a72d60ba17a497d8387416bcad Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 16 Jul 2026 21:51:42 +0100 Subject: [PATCH 26/54] chore: Remove `@apm-js-collab/code-transformer` as a dependency (#22317) `@apm-js-collab/code-transformer` was only ever a dependency to access the `InstrumentationConfig` type. This is now re-exported from `@apm-js-collab/code-transformer-bundler-plugins/core` at the root of `@sentry/server-utils` so it can be used wherever we need it. --- .../test-applications/astro-7-orchestrion/package.json | 2 +- .../test-applications/nuxt-4-orchestrion/package.json | 2 +- .../sveltekit-2-orchestrion/package.json | 2 +- packages/bun/package.json | 2 +- .../node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts | 2 +- packages/server-utils/package.json | 3 +-- packages/server-utils/src/index.ts | 2 +- packages/server-utils/src/orchestrion/bundler/options.ts | 2 +- packages/server-utils/src/orchestrion/bundler/webpack.ts | 2 +- packages/server-utils/src/orchestrion/config/amqplib.ts | 2 +- .../server-utils/src/orchestrion/config/anthropic-ai.ts | 2 +- .../server-utils/src/orchestrion/config/dataloader.ts | 2 +- packages/server-utils/src/orchestrion/config/express.ts | 2 +- packages/server-utils/src/orchestrion/config/firebase.ts | 2 +- .../server-utils/src/orchestrion/config/generic-pool.ts | 2 +- .../server-utils/src/orchestrion/config/google-genai.ts | 2 +- packages/server-utils/src/orchestrion/config/graphql.ts | 2 +- packages/server-utils/src/orchestrion/config/hapi.ts | 2 +- packages/server-utils/src/orchestrion/config/index.ts | 2 +- packages/server-utils/src/orchestrion/config/ioredis.ts | 2 +- packages/server-utils/src/orchestrion/config/kafkajs.ts | 2 +- packages/server-utils/src/orchestrion/config/knex.ts | 2 +- packages/server-utils/src/orchestrion/config/langchain.ts | 2 +- packages/server-utils/src/orchestrion/config/langgraph.ts | 2 +- .../server-utils/src/orchestrion/config/lru-memoizer.ts | 2 +- packages/server-utils/src/orchestrion/config/mongodb.ts | 2 +- packages/server-utils/src/orchestrion/config/mongoose.ts | 2 +- packages/server-utils/src/orchestrion/config/mysql.ts | 2 +- packages/server-utils/src/orchestrion/config/mysql2.ts | 2 +- packages/server-utils/src/orchestrion/config/nestjs.ts | 4 ++-- packages/server-utils/src/orchestrion/config/openai.ts | 2 +- packages/server-utils/src/orchestrion/config/pg.ts | 2 +- packages/server-utils/src/orchestrion/config/postgres.ts | 2 +- packages/server-utils/src/orchestrion/config/prisma.ts | 2 +- .../server-utils/src/orchestrion/config/react-router.ts | 2 +- packages/server-utils/src/orchestrion/config/redis.ts | 2 +- packages/server-utils/src/orchestrion/config/remix.ts | 2 +- packages/server-utils/src/orchestrion/config/tedious.ts | 2 +- packages/server-utils/src/orchestrion/config/vercel-ai.ts | 2 +- packages/server-utils/src/orchestrion/index.ts | 1 + packages/server-utils/src/orchestrion/runtime/register.ts | 2 +- packages/server-utils/test/orchestrion/config.test.ts | 2 +- yarn.lock | 8 ++++---- 43 files changed, 47 insertions(+), 47 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json index 83c12f774095..5d39c4e4b7c8 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-orchestrion/package.json @@ -24,7 +24,7 @@ "mysql": "^2.18.1" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0" + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1" }, "volta": { "node": "22.22.0", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json index 0ec5d68db235..3f96e7bc8f6d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json @@ -27,7 +27,7 @@ "vue-router": "^5.1.0" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json index a3cc62e226b4..59069913902e 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-orchestrion/package.json @@ -24,7 +24,7 @@ "mysql": "^2.18.1" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sveltejs/adapter-auto": "^7.0.1", diff --git a/packages/bun/package.json b/packages/bun/package.json index 3499021d83a2..8e79385b3b50 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -49,7 +49,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@sentry/core": "10.66.0", "@sentry/node": "10.66.0", "@sentry/server-utils": "10.66.0" diff --git a/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts index c4ae4897678d..9b28ae3eccb5 100644 --- a/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts +++ b/packages/node-core/src/sdk/apm-js-collab-tracing-hooks.d.ts @@ -1,5 +1,5 @@ declare module '@apm-js-collab/tracing-hooks' { - import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + import type { InstrumentationConfig } from '@sentry/server-utils'; type PatchConfig = { instrumentations: InstrumentationConfig[] }; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index d56b6dd17d1e..278b74fa803e 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -116,8 +116,7 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.0", - "@apm-js-collab/code-transformer": "^0.18.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.66.0" diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index c7a1784f3e94..4c993f0a9570 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -20,7 +20,7 @@ export type { TracingChannelPayloadWithSpan, } from './tracing-channel'; export { vercelAiIntegration } from './vercel-ai'; - +export type { InstrumentationConfig } from './orchestrion'; export { fastifyIntegration, // oxlint-disable-next-line typescript/no-deprecated diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index 21257daa8aff..c536482df346 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig, CustomTransform } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig, CustomTransform } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { CodeTransformerPluginOptions } from '@apm-js-collab/code-transformer-bundler-plugins/core'; diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index e6ea99b3045c..5dce4de27d62 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -3,7 +3,7 @@ import { createRequire } from 'node:module'; import { dirname } from 'node:path'; -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; import type { PluginOptions } from './options'; import { orchestrionTransformOptions } from './options'; diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 75b5ced78cae..5f2082859347 100644 --- a/packages/server-utils/src/orchestrion/config/amqplib.ts +++ b/packages/server-utils/src/orchestrion/config/amqplib.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index e5032d88a209..0b5f0e0ecf10 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const anthropicAiConfig = [ // One entry each for CJS/ESM diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index 5c479f9e000d..cd1f0879bdc3 100644 --- a/packages/server-utils/src/orchestrion/config/dataloader.ts +++ b/packages/server-utils/src/orchestrion/config/dataloader.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as // `_proto. = function () {}` (named function *expressions*), so they match on diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index e20a9d9d3094..81fe89947e3d 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on diff --git a/packages/server-utils/src/orchestrion/config/firebase.ts b/packages/server-utils/src/orchestrion/config/firebase.ts index b2f809e4383c..fa8606eb1ff6 100644 --- a/packages/server-utils/src/orchestrion/config/firebase.ts +++ b/packages/server-utils/src/orchestrion/config/firebase.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `firebase` orchestrion integration (ports `FirebaseInstrumentation`). export const firebaseConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 83866505162f..724451207ea2 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // Two shapes of `acquire`, both publishing to the same `orchestrion:generic-pool:acquire` channel: // - v3+: `class Pool { acquire(priority) }` returns a promise, so `kind: 'Auto'` resolves to `wrapPromise`. diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index d46854b88c76..693e8ba039bc 100644 --- a/packages/server-utils/src/orchestrion/config/google-genai.ts +++ b/packages/server-utils/src/orchestrion/config/google-genai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, // so we list every file the `node` export condition resolves to across the supported range: `index.js` diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index be5a7809a0a5..13257755934c 100644 --- a/packages/server-utils/src/orchestrion/config/graphql.ts +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 755161717c63..7c11aac6911a 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 60a78320fbff..ccc361c12755 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; import { uniq } from '@sentry/core'; import { amqplibConfig } from './amqplib'; diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index f6d6392d72b8..35d2d44cd79b 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index e584ddd46299..0d653e0db0eb 100644 --- a/packages/server-utils/src/orchestrion/config/kafkajs.ts +++ b/packages/server-utils/src/orchestrion/config/kafkajs.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const kafkajsConfig = [ { diff --git a/packages/server-utils/src/orchestrion/config/knex.ts b/packages/server-utils/src/orchestrion/config/knex.ts index 5e9ef9d7c7c0..8c4c2d068563 100644 --- a/packages/server-utils/src/orchestrion/config/knex.ts +++ b/packages/server-utils/src/orchestrion/config/knex.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; const MODULE_NAME = 'knex'; diff --git a/packages/server-utils/src/orchestrion/config/langchain.ts b/packages/server-utils/src/orchestrion/config/langchain.ts index a115273b22aa..73f8298f0a29 100644 --- a/packages/server-utils/src/orchestrion/config/langchain.ts +++ b/packages/server-utils/src/orchestrion/config/langchain.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `langchain` orchestrion integration (ports `SentryLangChainInstrumentation`). export const langchainConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/langgraph.ts b/packages/server-utils/src/orchestrion/config/langgraph.ts index 8eab70919b70..666e4ac3ef12 100644 --- a/packages/server-utils/src/orchestrion/config/langgraph.ts +++ b/packages/server-utils/src/orchestrion/config/langgraph.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // `@langchain/langgraph` ships dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the // matcher compares `filePath` exactly, so each hook is declared once per built file. `StateGraph.compile` diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index 578317e612a8..e186136d05d1 100644 --- a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const lruMemoizerConfig = [ { diff --git a/packages/server-utils/src/orchestrion/config/mongodb.ts b/packages/server-utils/src/orchestrion/config/mongodb.ts index f99e5b70cea7..04c623f0ba30 100644 --- a/packages/server-utils/src/orchestrion/config/mongodb.ts +++ b/packages/server-utils/src/orchestrion/config/mongodb.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `mongodb` orchestrion integration (ports `@opentelemetry/instrumentation-mongodb`). export const mongodbConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index d3186a0aed57..0c46ff9060a7 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `mongoose` orchestrion integration (ports `@opentelemetry/instrumentation-mongoose`). export const mongooseConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index 589e384bd6a3..c686f435a9f7 100644 --- a/packages/server-utils/src/orchestrion/config/mysql.ts +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const mysqlConfig = [ { diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index 796361164756..bac26e323053 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. diff --git a/packages/server-utils/src/orchestrion/config/nestjs.ts b/packages/server-utils/src/orchestrion/config/nestjs.ts index 70525177e4d3..44900addc39e 100644 --- a/packages/server-utils/src/orchestrion/config/nestjs.ts +++ b/packages/server-utils/src/orchestrion/config/nestjs.ts @@ -1,4 +1,4 @@ -import type { FunctionKind, InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; /** * Wrap an instrumentation that targets nodes via a raw esquery selector @@ -12,7 +12,7 @@ function astQueryInstrumentation(config: { channelName: string; module: InstrumentationConfig['module']; astQuery: string; - functionQuery: { kind: FunctionKind }; + functionQuery: { kind: 'Sync' | 'Async' | 'Callback' | 'Auto' }; }): InstrumentationConfig { return config as unknown as InstrumentationConfig; } diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index 4d32e45cf2ca..d29c12623e24 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index 7bfa05a82661..d000e423068a 100644 --- a/packages/server-utils/src/orchestrion/config/pg.ts +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const pgConfig = [ // `pg` (node-postgres). diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 7710a7132c6a..245a939cd7e7 100644 --- a/packages/server-utils/src/orchestrion/config/postgres.ts +++ b/packages/server-utils/src/orchestrion/config/postgres.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // postgres.js (`postgres` npm package, v3.x). Named after the npm package; // `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`). diff --git a/packages/server-utils/src/orchestrion/config/prisma.ts b/packages/server-utils/src/orchestrion/config/prisma.ts index d2b3f35ebbce..eace86695028 100644 --- a/packages/server-utils/src/orchestrion/config/prisma.ts +++ b/packages/server-utils/src/orchestrion/config/prisma.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `prisma` orchestrion integration (ports `@prisma/instrumentation`). export const prismaConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/react-router.ts b/packages/server-utils/src/orchestrion/config/react-router.ts index 50f45bc837c5..f21348123ca0 100644 --- a/packages/server-utils/src/orchestrion/config/react-router.ts +++ b/packages/server-utils/src/orchestrion/config/react-router.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `react-router` orchestrion integration (ports `ReactRouterInstrumentation`). export const reactRouterConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index c704eb43d08f..55db5e45c4e8 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an diff --git a/packages/server-utils/src/orchestrion/config/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index eaac1a6e390b..a7e6cf115a75 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; // TODO: Stub for the `remix` orchestrion integration (ports `RemixInstrumentation`). export const remixConfig: InstrumentationConfig[] = []; diff --git a/packages/server-utils/src/orchestrion/config/tedious.ts b/packages/server-utils/src/orchestrion/config/tedious.ts index 9561d201f459..c8ac5129e807 100644 --- a/packages/server-utils/src/orchestrion/config/tedious.ts +++ b/packages/server-utils/src/orchestrion/config/tedious.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; const MODULE_NAME = 'tedious'; diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 082be1a135bc..3c7d15fa0be7 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '..'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 3366e1c245ff..37fc2e205bce 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -57,6 +57,7 @@ export type { IORedisChannelIntegrationOptions, IORedisResponseHook } from '../i export type { PostgresJsChannelIntegrationOptions } from '../integrations/tracing-channel/postgres-js'; export { redisChannelIntegration } from '../integrations/tracing-channel/redis'; export type { RedisChannelIntegrationOptions, RedisResponseHook } from '../integrations/tracing-channel/redis'; +export type { InstrumentationConfig, CustomTransform } from '@apm-js-collab/code-transformer-bundler-plugins/core'; // The structural `graphql` package types are the single source of truth shared with `@sentry/node`'s // vendored OTel graphql instrumentation (re-exported from here so the two can't drift). diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 18f9638175d3..bd559b2a6e48 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -4,8 +4,8 @@ import * as Module from 'node:module'; import { pathToFileURL } from 'node:url'; import { isMainThread, MessageChannel, parentPort } from 'node:worker_threads'; import { SENTRY_INSTRUMENTATIONS } from '../config'; -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; import type { register } from 'node:module'; +import type { InstrumentationConfig } from '..'; type DiagnosticsEvent = { url: string; moduleName: string; error?: Error }; diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 86e56ddd77a7..24a2accc036e 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -1,4 +1,4 @@ -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer-bundler-plugins/core'; import { describe, expect, it } from 'vitest'; import { INSTRUMENTED_MODULE_NAMES, diff --git a/yarn.lock b/yarn.lock index b17fa3c0b4b9..381e90c80f85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -404,10 +404,10 @@ dependencies: json-schema-to-ts "^3.1.1" -"@apm-js-collab/code-transformer-bundler-plugins@^0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.0.tgz#499cb4730570e4f4bc3a1e77739dc1fb17839a7a" - integrity sha512-huqdbQqE1lTUM2uud/qBY664o5qGPLrI/3ipGRNcTP37otpaVOb6omfIU/8QyR4aObhkpgnFl0ywtoAJZ+y2jw== +"@apm-js-collab/code-transformer-bundler-plugins@^0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.7.1.tgz#f9e7128b87cdabb50e91f2ce3a5c48505b511c41" + integrity sha512-Yidf5GOl60db80UxUtNdKK3pnY7obU/gs0xOfA0SCdnvVLMCvfYIer/egC3TqpPiT0Jg22eg3RlzcO+zKfPMcA== dependencies: "@apm-js-collab/code-transformer" "^0.18.0" es-module-lexer "^2.1.0" From e36268b292057fcf294fe4dba35f3f06e307b5eb Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 16 Jul 2026 16:44:23 -0700 Subject: [PATCH 27/54] feat(mongoose): implement orchestrion mongoose integration (#22202) Port the OTel mongoose intstrumentation to Orchestrion. Add Deno integration, and node integration tests for mongoose versions 5, 6, 7, 8, and 9. Native diagnostics channel used on Mongoose versions supporting them (ie, 9.7+). Fix: JS-2412 Fix: #20761 --- .size-limit.js | 2 +- .../suites/tracing/mongoose-v5/instrument.mjs | 9 + .../suites/tracing/mongoose-v5/scenario.mjs | 36 ++++ .../suites/tracing/mongoose-v5/test.ts | 59 +++++ .../suites/tracing/mongoose-v7/test.ts | 4 +- .../suites/tracing/mongoose-v8/test.ts | 8 +- .../suites/tracing/mongoose-v9/test.ts | 5 +- .../suites/tracing/mongoose/test.ts | 16 +- packages/deno/src/index.ts | 1 + packages/deno/src/integrations/mongoose.ts | 34 +++ packages/deno/src/sdk.ts | 9 +- .../deno/test/__snapshots__/mod.test.ts.snap | 20 +- .../tracing/mongoose/vendored/mongoose.ts | 70 +++--- .../tracing/mongoose/vendored/semconv.ts | 17 -- .../tracing/mongoose/vendored/utils.ts | 20 +- packages/server-utils/src/index.ts | 3 +- .../integrations/tracing-channel/mongoose.ts | 201 ++++++++++++++++++ packages/server-utils/src/mongoose/index.ts | 3 + .../src/mongoose/mongoose-legacy-span.ts | 64 ++++++ .../src/orchestrion/config/mongoose.ts | 126 ++++++++++- .../server-utils/src/orchestrion/index.ts | 3 + 21 files changed, 614 insertions(+), 96 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts create mode 100644 packages/deno/src/integrations/mongoose.ts delete mode 100644 packages/node/src/integrations/tracing/mongoose/vendored/semconv.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/mongoose.ts create mode 100644 packages/server-utils/src/mongoose/mongoose-legacy-span.ts diff --git a/.size-limit.js b/.size-limit.js index e9b00ebeb4e8..a2b9a8649be9 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -400,7 +400,7 @@ module.exports = [ import: createImport('init', 'experimentalUseDiagnosticsChannelInjection'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '148 KB', + limit: '150 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs new file mode 100644 index 000000000000..0463ed0b3680 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/scenario.mjs @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import mongoose from 'mongoose'; + +async function run() { + await mongoose.connect(process.env.MONGO_URL || ''); + + const BlogPostSchema = new mongoose.Schema({ + title: String, + body: String, + date: Date, + }); + + const BlogPost = mongoose.model('BlogPost', BlogPostSchema); + + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + const post = new BlogPost({ title: 'Test', body: 'Test body', date: new Date() }); + + await post.save(); + + await BlogPost.findOne({}); + + await BlogPost.aggregate([{ $match: {} }]); + + await BlogPost.insertMany([{ title: 'Insert', body: 'Insert body', date: new Date() }]); + + await BlogPost.bulkWrite([{ insertOne: { document: { title: 'Bulk', body: 'Bulk body', date: new Date() } } }]); + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts new file mode 100644 index 000000000000..9f62692c1b90 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v5/test.ts @@ -0,0 +1,59 @@ +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongoose 5.9.7 +// the bottom of the IITM patcher's `>=5.9.7 <9.7.0` range, so the oldest +// supported major is exercised against a real mongoose. +describe('Mongoose v5 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const expectedSpan = (operation: string) => + expect.objectContaining({ + data: expect.objectContaining({ + 'db.mongodb.collection': 'blogposts', + 'db.operation': operation, + 'db.system': 'mongoose', + }), + description: `mongoose.BlogPost.${operation}`, + op: 'db', + origin, + }); + + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expectedSpan('save'), + expectedSpan('findOne'), + expectedSpan('aggregate'), + expectedSpan('insertMany'), + expectedSpan('bulkWrite'), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongoose` v5.', async () => { + await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }, + { additionalDependencies: { mongoose: '^5.9.7' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts index dde237210761..ee9cec92cc46 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v7/test.ts @@ -1,9 +1,11 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins mongoose 7 so the `contextCaptureFunctions7` version branch is exercised against a real mongoose. describe('Mongoose v7 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -27,7 +29,7 @@ describe('Mongoose v7 Test', () => { }), description: `mongoose.BlogPost.${operation}`, op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }); const EXPECTED_TRANSACTION = { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts index 48e3faefbf0c..b13d715fc3bd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v8/test.ts @@ -1,10 +1,12 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins mongoose 8 (>= 8.21) so the document `updateOne`/`deleteOne` lazy-Query path is exercised // against a real mongoose, guarding the thenable trap that mongoose 6 (the workspace version) can't hit. describe('Mongoose v8 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -30,7 +32,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -40,7 +42,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.updateOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -50,7 +52,7 @@ describe('Mongoose v8 Test', () => { }), description: 'mongoose.BlogPost.deleteOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), ]), }; diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts index 85f5c432983e..cf027f31fc38 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-v9/test.ts @@ -1,6 +1,6 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // Pins the highest mongoose 9 below 9.7, the top of the IITM patcher's `>=5.9.7 <9.7.0` range, so the @@ -8,6 +8,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // diagnostics_channel and is covered by the `mongoose-tracing-channel` suite instead. // mongoose 9 requires Node >=20.19, so this suite is skipped on older Node. conditionalTest({ min: 20 })('Mongoose v9 Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -31,7 +32,7 @@ conditionalTest({ min: 20 })('Mongoose v9 Test', () => { }), description: `mongoose.BlogPost.${operation}`, op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }); const EXPECTED_TRANSACTION = { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts index ed20aff13496..b3a2b709c701 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -1,8 +1,10 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('Mongoose experimental Test', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -29,7 +31,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -40,7 +42,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.findOne', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -51,7 +53,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.aggregate', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -62,7 +64,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.insertMany', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), expect.objectContaining({ data: expect.objectContaining({ @@ -73,7 +75,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.bulkWrite', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), // `remove` is patched only on mongoose 5/6. expect.objectContaining({ @@ -85,7 +87,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.BlogPost.remove', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, }), // A failing operation still produces a span, marked with an error status. expect.objectContaining({ @@ -95,7 +97,7 @@ describe('Mongoose experimental Test', () => { }), description: 'mongoose.RequiredDoc.save', op: 'db', - origin: 'auto.db.otel.mongoose', + origin, status: 'internal_error', }), ]), diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 54ee7e07b7d9..4a33a171fcaf 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -117,6 +117,7 @@ export { denoAmqplibIntegration } from './integrations/amqplib'; export { denoDataloaderIntegration } from './integrations/dataloader'; export { denoKnexIntegration } from './integrations/knex'; export { denoKoaIntegration } from './integrations/koa'; +export { denoMongooseIntegration } from './integrations/mongoose'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; export { normalizePathsIntegration } from './integrations/normalizepaths'; diff --git a/packages/deno/src/integrations/mongoose.ts b/packages/deno/src/integrations/mongoose.ts new file mode 100644 index 000000000000..86e079cd37e6 --- /dev/null +++ b/packages/deno/src/integrations/mongoose.ts @@ -0,0 +1,34 @@ +import { mongooseChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoMongoose' as const; + +/** + * Create spans for `mongoose` queries under Deno. + * + * `mongoose` channels are injected by the orchestrion runtime hook at load + * time. The `@sentry/deno/import` loader must be active for this integration + * to record anything. + * + * The channel-subscription logic is shared with the other server runtimes in + * `@sentry/server-utils`. This just installs Deno's `AsyncLocalStorage` + * context strategy (so spans nest under the active span and survive mongoose's + * internal callback dispatch) before delegating. + */ +const _denoMongooseIntegration = (() => { + const inner = mongooseChannelIntegration(); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoMongooseIntegration = defineIntegration(_denoMongooseIntegration) as () => Integration & { + name: 'DenoMongoose'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 805b2ab077bf..ce743f713b59 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -25,6 +25,7 @@ import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; import { denoAmqplibIntegration } from './integrations/amqplib'; import { denoKoaIntegration } from './integrations/koa'; +import { denoMongooseIntegration } from './integrations/mongoose'; import { denoMysqlIntegration } from './integrations/mysql'; import { denoPostgresIntegration } from './integrations/postgres'; import { denoRedisIntegration } from './integrations/redis'; @@ -63,7 +64,13 @@ export function getDefaultIntegrations(_options: Options): Integration[] { // (or in parallel to) loading the SDK, so we only gate on whether the // feature is possible. If they're never loaded, it'll just be a no-op. ...(MODULE_REGISTER_HOOKS_SUPPORTED - ? [denoMysqlIntegration(), denoPostgresIntegration(), denoAmqplibIntegration(), denoKoaIntegration()] + ? [ + denoAmqplibIntegration(), + denoKoaIntegration(), + denoMongooseIntegration(), + denoMysqlIntegration(), + denoPostgresIntegration(), + ] : []), contextLinesIntegration(), normalizePathsIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index d8bf3a760e8c..fe5046625075 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -115,10 +115,11 @@ snapshot[`captureException 1`] = ` "DenoServe", "DenoHttp", "DenoRedis", - "DenoMysql", - "DenoPostgres", "DenoAmqplib", "DenoKoa", + "DenoMongoose", + "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -194,10 +195,11 @@ snapshot[`captureMessage 1`] = ` "DenoServe", "DenoHttp", "DenoRedis", - "DenoMysql", - "DenoPostgres", "DenoAmqplib", "DenoKoa", + "DenoMongoose", + "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -280,10 +282,11 @@ snapshot[`captureMessage twice 1`] = ` "DenoServe", "DenoHttp", "DenoRedis", - "DenoMysql", - "DenoPostgres", "DenoAmqplib", "DenoKoa", + "DenoMongoose", + "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", @@ -373,10 +376,11 @@ snapshot[`captureMessage twice 2`] = ` "DenoServe", "DenoHttp", "DenoRedis", - "DenoMysql", - "DenoPostgres", "DenoAmqplib", "DenoKoa", + "DenoMongoose", + "DenoMysql", + "DenoPostgres", "ContextLines", "NormalizePaths", "GlobalHandlers", diff --git a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts index 8144b38ef801..579408acf6c5 100644 --- a/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts +++ b/packages/node/src/integrations/tracing/mongoose/vendored/mongoose.ts @@ -16,18 +16,11 @@ import { type InstrumentationModuleDefinition, InstrumentationNodeModuleDefinition, } from '@opentelemetry/instrumentation'; -import { DB_OPERATION, DB_SYSTEM } from '@sentry/conventions/attributes'; -import type { Span, SpanAttributes } from '@sentry/core'; -import { - getActiveSpan, - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_KIND, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { getActiveSpan, SDK_VERSION, withActiveSpan } from '@sentry/core'; +import { startMongooseLegacySpan } from '@sentry/server-utils'; import type * as mongoose from './mongoose-types'; -import { getAttributesFromCollection, handleCallbackResponse, handlePromiseResponse } from './utils'; +import { handleCallbackResponse, handlePromiseResponse } from './utils'; const PACKAGE_NAME = '@sentry/instrumentation-mongoose'; const ORIGIN = 'auto.db.otel.mongoose'; @@ -190,7 +183,13 @@ export class MongooseInstrumentation extends InstrumentationBase { return function exec(this: any, callback?: Function) { const parentSpan = this[_STORED_PARENT_SPAN]; - const span = self._startSpan(this._model.collection, this._model?.modelName, 'aggregate', parentSpan); + const span = startMongooseLegacySpan({ + collection: this._model.collection, + modelName: this._model?.modelName, + operation: 'aggregate', + origin: ORIGIN, + parentSpan, + }); return self._handleResponse(span, originalAggregate, this, arguments, callback); }; @@ -207,7 +206,13 @@ export class MongooseInstrumentation extends InstrumentationBase { return function method(this: any, options?: any, callback?: Function) { - const span = self._startSpan(this.constructor.collection, this.constructor.modelName, op); + const span = startMongooseLegacySpan({ + collection: this.constructor.collection, + modelName: this.constructor.modelName, + operation: op, + origin: ORIGIN, + }); if (options instanceof Function) { // oxlint-disable-next-line no-param-reassign @@ -243,7 +253,12 @@ export class MongooseInstrumentation extends InstrumentationBase(); + +let orchestrionSubscribed = false; + +const _mongooseChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + // mongoose >= 9.7 publishes via its own diagnostics_channel; reuse + // the shared native subscriber so this single integration covers all + // versions after it replaces the OTel one. Idempotent and inert on + // < 9.7 (those channels just never get published to) + subscribeMongooseDiagnosticChannels(diagnosticsChannel.tracingChannel); + + // mongoose < 9.7 is covered by the orchestrion-injected channels. + subscribeOrchestrionMongooseChannels(); + }); + }, + }; +}) satisfies IntegrationFn; + +function subscribeOrchestrionMongooseChannels(): void { + if (orchestrionSubscribed) { + return; + } + orchestrionSubscribed = true; + + DEBUG_BUILD && debug.log('[orchestrion:mongoose] subscribing to injected channels'); + + // Context-capture builders: stash the active span at build time so `exec()` + // can parent to it. + for (const channelName of MONGOOSE_CONTEXT_CAPTURE_CHANNELS) { + channel(channelName).subscribe({ + start(message) { + stashParentSpan(message.self); + }, + }); + } + + // `Model.aggregate()` returns the aggregate it builds; stash the parent on + // the returned object. + channel(CHANNELS.MONGOOSE_MODEL_AGGREGATE).subscribe({ + end(message) { + const result = message.result; + if (result && typeof result === 'object') { + stashParentSpan(result); + } + }, + }); + + // Query execution. + bindExecSpan(CHANNELS.MONGOOSE_QUERY_EXEC, self => { + const query = self as MongooseQuery; + return startSpan( + query.mongooseCollection, + query.model?.modelName, + query.op ?? 'exec', + STORED_PARENT_SPAN.get(self), + ); + }); + + // Aggregation execution. + bindExecSpan(CHANNELS.MONGOOSE_AGGREGATE_EXEC, self => { + const model = (self as MongooseAggregate)._model; + return startSpan(model?.collection, model?.modelName, 'aggregate', STORED_PARENT_SPAN.get(self)); + }); + + // `doc.save()` (and the `$save` alias). + bindExecSpan(CHANNELS.MONGOOSE_MODEL_SAVE, self => { + const ctor = (self as MongooseDocument).constructor; + return startSpan(ctor.collection, ctor.modelName, 'save'); + }); + + // `doc.remove()` (mongoose 5/6). + bindExecSpan(CHANNELS.MONGOOSE_MODEL_REMOVE, self => { + const ctor = (self as MongooseDocument).constructor; + return startSpan(ctor.collection, ctor.modelName, 'remove'); + }); + + // Static batch operations. `self` is the Model. + bindExecSpan(CHANNELS.MONGOOSE_MODEL_INSERT_MANY, self => { + const model = self as MongooseModelStatic; + return startSpan(model.collection, model.modelName, 'insertMany'); + }); + bindExecSpan(CHANNELS.MONGOOSE_MODEL_BULK_WRITE, self => { + const model = self as MongooseModelStatic; + return startSpan(model.collection, model.modelName, 'bulkWrite'); + }); +} + +function startSpan( + collection: MongooseLegacyCollection | undefined, + modelName: string | undefined, + operation: string, + parentSpan?: Span, +): Span { + return startMongooseLegacySpan({ collection, modelName, operation, origin: ORIGIN, parentSpan }); +} + +// `SentryTracingChannel` relaxes Node's subscriber type to a `Partial`, so a +// `start`-only (or `end`-only) subscriber for the context-capture channels +// is accepted. +function channel(channelName: string): SentryTracingChannel { + return diagnosticsChannel.tracingChannel( + channelName, + ) as unknown as SentryTracingChannel; +} + +function bindExecSpan(channelName: string, getSpan: (self: object) => Span): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + const self = data.self; + if (!self) { + return undefined; + } + return getSpan(self); + }, + ); +} + +function stashParentSpan(self: object | undefined): void { + const active = getActiveSpan(); + if (self && active) { + STORED_PARENT_SPAN.set(self, active); + } +} + +/** + * EXPERIMENTAL: orchestrion-driven mongoose integration. + * + * Reproduces the vendored `@opentelemetry/instrumentation-mongoose` span + * shape (legacy db/net semantic conventions, `mongoose..` names, + * build-time span parenting) via the `orchestrion:mongoose:*` + * diagnostics_channels injected into mongoose `< 9.7` by the orchestrion + * code transform. For mongoose `>= 9.7` it also drives the native + * diagnostics_channel subscription, so this single integration covers every + * supported version once it replaces the OTel one. + */ +export const mongooseChannelIntegration = defineIntegration(_mongooseChannelIntegration); diff --git a/packages/server-utils/src/mongoose/index.ts b/packages/server-utils/src/mongoose/index.ts index ace42bb7a122..c16ee5811751 100644 --- a/packages/server-utils/src/mongoose/index.ts +++ b/packages/server-utils/src/mongoose/index.ts @@ -2,6 +2,9 @@ import { defineIntegration, type IntegrationFn, waitForTracingChannelBinding } f import * as dc from 'node:diagnostics_channel'; import { subscribeMongooseDiagnosticChannels } from './mongoose-dc-subscriber'; +export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose-legacy-span'; +export { startMongooseLegacySpan } from './mongoose-legacy-span'; + const _mongooseIntegration = (() => { return { name: 'Mongoose', diff --git a/packages/server-utils/src/mongoose/mongoose-legacy-span.ts b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts new file mode 100644 index 000000000000..301b9be210d7 --- /dev/null +++ b/packages/server-utils/src/mongoose/mongoose-legacy-span.ts @@ -0,0 +1,64 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core'; + +// OTel "OLD" db/net semantic-conventions, reproduced from the vendored +// `@opentelemetry/instrumentation-mongoose` span shape. Inlined as literals to +// avoid importing the deprecated convention constants. +const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_USER = 'db.user'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const ATTR_DB_OPERATION = 'db.operation'; +const ATTR_DB_SYSTEM = 'db.system'; + +/** The subset of mongoose's `Collection` that the legacy span shape reads. */ +export interface MongooseLegacyCollection { + name?: string; + conn?: { name?: string; user?: string; host?: string; port?: number }; +} + +export interface StartMongooseLegacySpanOptions { + collection: MongooseLegacyCollection | undefined; + modelName: string | undefined; + operation: string; + /** Span origin: distinguishes the OTel/IITM caller from orchestrion */ + origin: string; + parentSpan?: Span; +} + +/** + * Start a mongoose client span with the legacy (pre-stable) db/net semantic + * conventions. + * + * Shared by the vendored OTel/IITM instrumentation (`@sentry/node`) and the + * orchestrion channel subscriber so the two emit an identical span shape, + * differing only by `origin`. + */ +export function startMongooseLegacySpan({ + collection, + modelName, + operation, + origin, + parentSpan, +}: StartMongooseLegacySpanOptions): Span { + const attributes: SpanAttributes = { + [ATTR_DB_MONGODB_COLLECTION]: collection?.name, + [ATTR_DB_NAME]: collection?.conn?.name, + [ATTR_DB_USER]: collection?.conn?.user, + [ATTR_NET_PEER_NAME]: collection?.conn?.host, + [ATTR_NET_PEER_PORT]: collection?.conn?.port, + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_SYSTEM]: 'mongoose', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + }; + + return startInactiveSpan({ + name: `mongoose.${modelName}.${operation}`, + // Set this explicitly, for platforms lacking `inferDbSpanData` + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes, + parentSpan, + }); +} diff --git a/packages/server-utils/src/orchestrion/config/mongoose.ts b/packages/server-utils/src/orchestrion/config/mongoose.ts index 0c46ff9060a7..efc952184ba1 100644 --- a/packages/server-utils/src/orchestrion/config/mongoose.ts +++ b/packages/server-utils/src/orchestrion/config/mongoose.ts @@ -1,6 +1,126 @@ import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `mongoose` orchestrion integration (ports `@opentelemetry/instrumentation-mongoose`). -export const mongooseConfig: InstrumentationConfig[] = []; +// mongoose >= 9.7.0 publishes via its own `node:diagnostics_channel` tracing channels (handled by +// `subscribeMongooseDiagnosticChannels`), so this transform is gated to `< 9.7.0` to avoid emitting +// two spans per operation. The lower bound mirrors the vendored OTel IITM patcher's supported range. +const module = { name: 'mongoose', versionRange: '>=5.9.7 <9.7.0' } as const; -export const mongooseChannels = {} as const; +// mongoose defines its prototype/static methods as assignments +// (`Query.prototype.find = function(...)`, most of them anonymous), so the +// transform matches the assigned property name via `expressionName` +// rather than the function name. Entries whose method does not exist in a +// given major are simply inert. + +// Builder methods that run synchronously and return a `Query`/`Aggregate`. +// They don't get their own span; the subscriber only reads the active span +// at build time and stashes it on the query so the later `exec()` can parent +// to where the query was *built*, not where it is awaited. `kind: 'Sync'` +// so the returned thenable isn't mistaken for the traced operation's result. +const CONTEXT_CAPTURE_QUERY_METHODS = [ + 'find', + 'findOne', + 'deleteOne', + 'deleteMany', + 'estimatedDocumentCount', + 'countDocuments', + 'distinct', + 'where', + '$where', + 'findOneAndUpdate', + 'findOneAndDelete', + 'findOneAndReplace', + // 5/6/7 only (removed in 8), inert in recent versions + 'remove', + 'count', + 'findOneAndRemove', +] as const; + +export const mongooseConfig = [ + // Query execution + // the span for most read/write operations. `op`, collection and model are + // read off the `Query` at exec time. + { + channelName: 'query_exec', + module: { ...module, filePath: 'lib/query.js' }, + functionQuery: { expressionName: 'exec', kind: 'Auto' }, + }, + // Aggregation pipeline execution. + { + channelName: 'aggregate_exec', + module: { ...module, filePath: 'lib/aggregate.js' }, + functionQuery: { expressionName: 'exec', kind: 'Auto' }, + }, + // `doc.save()` (and its `$save` alias, which mongoose points at `save` on + // require. the alias picks up the transformed body automatically, so no + // separate entry is needed). + { + channelName: 'model_save', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'save', kind: 'Auto' }, + }, + // Static batch operations. + { + channelName: 'model_insert_many', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'insertMany', kind: 'Auto' }, + }, + { + channelName: 'model_bulk_write', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'bulkWrite', kind: 'Auto' }, + }, + // `doc.remove()` (a document method, deprecated in 6 and removed in 7) + // `expressionName: 'remove'` also matches the sibling `Model.remove` + // *static* in this file, which no matcher can tell apart from the prototype + // method; that static is deprecated and would just produce a redundant span + // so the collision is accepted rather than dropping the doc-method span. + { + channelName: 'model_remove', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'remove', kind: 'Auto' }, + }, + // NOTE: document `updateOne`/`deleteOne` (mongoose 8.21+) are deliberately + // NOT hooked here. The vendored OTel/IITM patcher wraps + // `Model.prototype.updateOne`/`deleteOne`, but those delegate to + // `Query.exec`, which the `query_exec` channel above already instruments + // (its `this.op` is the right operation). Verified by the `mongoose-v8` + // suite against a real mongoose 8.21+ under orchestrion. A dedicated hook + // is also not possible cleanly: `expressionName: 'updateOne'` in + // `lib/model.js` can't be told apart from the same-named `Model.updateOne` + // *static* (the common query-builder form), so hooking it would double-span + // every `Model.updateOne(...)` call. + // + // `Model.aggregate()` builds an `Aggregate` with no method to hook for + // context capture, so hook the static itself and stash the active span + // on the returned aggregate. `Sync`: it returns the aggregate. + { + channelName: 'model_aggregate', + module: { ...module, filePath: 'lib/model.js' }, + functionQuery: { expressionName: 'aggregate', kind: 'Sync' }, + }, + ...CONTEXT_CAPTURE_QUERY_METHODS.map(methodName => ({ + channelName: `ctx_${methodName}`, + module: { ...module, filePath: 'lib/query.js' }, + functionQuery: { expressionName: methodName, kind: 'Sync' as const }, + })), +] satisfies InstrumentationConfig[]; + +export const mongooseChannels = { + MONGOOSE_QUERY_EXEC: 'orchestrion:mongoose:query_exec', + MONGOOSE_AGGREGATE_EXEC: 'orchestrion:mongoose:aggregate_exec', + MONGOOSE_MODEL_SAVE: 'orchestrion:mongoose:model_save', + MONGOOSE_MODEL_INSERT_MANY: 'orchestrion:mongoose:model_insert_many', + MONGOOSE_MODEL_BULK_WRITE: 'orchestrion:mongoose:model_bulk_write', + MONGOOSE_MODEL_REMOVE: 'orchestrion:mongoose:model_remove', + MONGOOSE_MODEL_AGGREGATE: 'orchestrion:mongoose:model_aggregate', +} as const; + +/** + * Fully-qualified names of the context-capture channels, derived from the + * same method list the transform config uses so the two can't drift. The + * subscriber subscribes to all of them uniformly to stash the build-time + * parent span (see `mongooseChannels` for the span-creating channels). + */ +export const MONGOOSE_CONTEXT_CAPTURE_CHANNELS: string[] = CONTEXT_CAPTURE_QUERY_METHODS.map( + methodName => `orchestrion:mongoose:ctx_${methodName}`, +); diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index 37fc2e205bce..f0d6a7c3351b 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -15,6 +15,7 @@ import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafka import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +import { mongooseChannelIntegration } from '../integrations/tracing-channel/mongoose'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2'; import { openaiChannelIntegration } from '../integrations/tracing-channel/openai'; @@ -43,6 +44,7 @@ export { knexChannelIntegration, langGraphChannelIntegration, lruMemoizerChannelIntegration, + mongooseChannelIntegration, mysqlChannelIntegration, mysql2ChannelIntegration, openaiChannelIntegration, @@ -91,6 +93,7 @@ export const channelIntegrations = { mysqlIntegration: mysqlChannelIntegration, mysql2Integration: mysql2ChannelIntegration, genericPoolIntegration: genericPoolChannelIntegration, + mongooseIntegration: mongooseChannelIntegration, lruMemoizerIntegration: lruMemoizerChannelIntegration, openaiIntegration: openaiChannelIntegration, anthropicIntegration: anthropicChannelIntegration, From a3b61d96603aae7db0eec003af528770e1f61d6a Mon Sep 17 00:00:00 2001 From: Denys Galkin Date: Fri, 17 Jul 2026 05:40:08 +0200 Subject: [PATCH 28/54] feat(replay): Allow skipping the final flush on stop() via { flush: false } (#22300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes getsentry/sentry-javascript#22220 ## Summary `ReplayIntegration.stop()` always force-flushes the pending segment when `recordingMode === 'session'`, with no public way to opt out. Because Replay envelopes bypass `beforeSend`, consent-gated setups (CookieYes / OneTrust / Usercentrics / custom CMPs) that call `stop()` on consent withdrawal end up sending the buffered segment **after** the user revoked consent. The only workaround today is reaching into the private `_replay` field to call the internal `stop({ forceFlush: false })`, which isn't something we want to depend on in production code. This exposes the already-existing internal `forceFlush` control on the public integration API. ## Changes * `stop()` now accepts an optional `{ flush?: boolean }`: ```ts public stop(options?: { flush?: boolean }): Promise { if (!this._replay) { return Promise.resolve(); } return this._replay.stop({ forceFlush: options?.flush ?? this._replay.recordingMode === 'session', reason: 'manual', }); } ``` * **Defaults to the current behavior** (`flush` unset → flush when `recordingMode === 'session'`), so it's fully backwards compatible and safe to land in v10. * `flush: false` stops recording without sending the pending segment. * Added a doc comment noting the caveat (per @isaacs' comment on the issue): `flush: false` only suppresses the *pending/final* segment — it does **not** retract segments already sent earlier during `session` recording. * Added tests for both `flush: false` (does not send) and `flush: true` (sends) in `session` mode. ## API naming The issue proposed matching the internal `forceFlush` name, and @isaacs flagged that `forceFlush` leaks an internal term into the public surface. I went with the public-facing `flush` (`stop({ flush: false })`) since it reads better as public API, while mapping to the internal `forceFlush`. Happy to switch to `forceFlush` (matching the internal name, least surprising for anyone who already knows it) or a dedicated `discard`-style option instead — whichever the team prefers. ## Verification * `packages/replay-internal` test suite: **476 passed** (incl. 2 new `stop` tests). * Changed files are `oxfmt`-clean and typecheck cleanly. - [X] Added tests for the new behavior. - [X] Test suite passes for the affected package. - [X] Linked the related issue (getsentry/sentry-javascript#22220). --- CHANGELOG.md | 2 + packages/replay-internal/src/integration.ts | 16 +++++++- .../test/integration/stop.test.ts | 39 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d116d36ed648..c52cc18cbfaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) + ## 10.66.0 - chore(node-core): Deprecate `@sentry/node-core` package ([#22285](https://github.com/getsentry/sentry-javascript/pull/22285)) diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 44efb1f6f9c9..ed5c062f21c7 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -298,13 +298,25 @@ export class Replay implements Integration { /** * Currently, this needs to be manually called (e.g. for tests). Sentry SDK * does not support a teardown + * + * @param options.flush - Whether to flush the pending replay segment when stopping. + * When recording in `session` mode, `stop()` flushes the buffered-but-unsent segment by + * default (`flush: true`), matching the previous behavior. Pass `flush: false` to stop + * recording without sending that pending segment — useful when a user withdraws consent + * and no further data should leave the browser. + * + * Note: `flush: false` only prevents the *pending* (final) segment from being sent. It does + * **not** retract segments that were already sent earlier during `session` recording. */ - public stop(): Promise { + public stop(options?: { flush?: boolean }): Promise { if (!this._replay) { return Promise.resolve(); } - return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session', reason: 'manual' }); + return this._replay.stop({ + forceFlush: options?.flush ?? this._replay.recordingMode === 'session', + reason: 'manual', + }); } /** diff --git a/packages/replay-internal/test/integration/stop.test.ts b/packages/replay-internal/test/integration/stop.test.ts index 43c08857a5fd..5f3058b64c5f 100644 --- a/packages/replay-internal/test/integration/stop.test.ts +++ b/packages/replay-internal/test/integration/stop.test.ts @@ -153,6 +153,45 @@ describe('Integration | stop', () => { }); }); + it('does not flush the pending segment when stopped with `{ flush: false }` in session mode', async function () { + // Default mock SDK records in `session` mode (replaysSessionSampleRate: 1.0) + expect(replay.recordingMode).toBe('session'); + + const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); + addEvent(replay, TEST_EVENT, true); + expect(replay.eventBuffer?.hasEvents).toBe(true); + expect(mockRunFlush).toHaveBeenCalledTimes(0); + + // stop without force-flushing the pending segment (e.g. on consent withdrawal) + await integration.stop({ flush: false }); + + // the buffered segment must not have been flushed/sent + expect(mockRunFlush).toHaveBeenCalledTimes(0); + expect(replay).not.toHaveLastSentReplay(); + + // recording is still torn down as usual + expect(replay.eventBuffer).toBe(null); + expect(replay.session).toEqual(undefined); + }); + + it('flushes the pending segment when stopped with `{ flush: true }` in session mode', async function () { + expect(replay.recordingMode).toBe('session'); + + const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); + addEvent(replay, TEST_EVENT, true); + expect(replay.eventBuffer?.hasEvents).toBe(true); + expect(mockRunFlush).toHaveBeenCalledTimes(0); + + // explicitly force-flush the pending segment + await integration.stop({ flush: true }); + + expect(mockRunFlush).toHaveBeenCalledTimes(1); + expect(replay.eventBuffer).toBe(null); + expect(replay).toHaveLastSentReplay({ + recordingData: JSON.stringify([TEST_EVENT]), + }); + }); + it('does not call core SDK `addClickKeypressInstrumentationHandler` after initial setup', async function () { // NOTE: We clear mockAddDomInstrumentationHandler after every test await integration.stop(); From 352266199adb82af024b489483d37e94a2a17e81 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 17 Jul 2026 09:55:32 +0200 Subject: [PATCH 29/54] fix(core): Prevent functionToStringIntegration from throwing on cross-origin realms (#22273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default `functionToStringIntegration` patches `Function.prototype.toString`. The patched function reads the Sentry carrier off `getClient()` (→ `getMainCarrier()` → `getSentryCarrier(GLOBAL_OBJ)` → `GLOBAL_OBJ.__SENTRY__`) on **every** `.toString()` invocation. `GLOBAL_OBJ` is a `WindowProxy` in browser realms. When its browsing context is later navigated cross-origin while code from the old realm can still be invoked (e.g. a parent window holding a reference into a same-origin child iframe whose `src` changed to a cross-origin URL), the `__SENTRY__` read throws a `SecurityError`. Because the patch lives on `Function.prototype`, the throw is triggered by *any* third-party `.toString()` call, converting harmless introspection into uncatchable `SecurityError` noise that Sentry then reports as unhandled-rejection events. ## Root cause The native `Function.prototype.toString` never throws for these calls — the throw is introduced solely by the integration's internal carrier access. The fix wraps the patch body in a `try/catch` and falls back to `originalFunctionToString.apply(this, args)`, mirroring the defensive style already used around the patch installation. This keeps the unwrap behavior intact for live realms and degrades to exact native semantics when the carrier read throws. Fixes getsentry/sentry-javascript#21965 🤖 Generated with [Claude Code]() --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../core/src/integrations/functiontostring.ts | 29 ++++++++++------ .../lib/integrations/functiontostring.test.ts | 33 ++++++++++++++++++- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/packages/core/src/integrations/functiontostring.ts b/packages/core/src/integrations/functiontostring.ts index f8768037db6b..86844843664f 100644 --- a/packages/core/src/integrations/functiontostring.ts +++ b/packages/core/src/integrations/functiontostring.ts @@ -2,11 +2,8 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { defineIntegration } from '../integration'; import type { IntegrationFn } from '../types/integration'; -import type { WrappedFunction } from '../types/wrappedfunction'; import { getOriginalFunction } from '../utils/object'; -let originalFunctionToString: () => void; - const INTEGRATION_NAME = 'FunctionToString' as const; const SETUP_CLIENTS = new WeakMap(); @@ -16,17 +13,29 @@ const _functionToStringIntegration = (() => { name: INTEGRATION_NAME, setupOnce() { // eslint-disable-next-line @typescript-eslint/unbound-method - originalFunctionToString = Function.prototype.toString; + const originalFunctionToString = Function.prototype.toString; // intrinsics (like Function.prototype) might be immutable in some environments // e.g. Node with --frozen-intrinsics, XS (an embedded JavaScript engine) or SES (a JavaScript proposal) try { - Function.prototype.toString = function (this: WrappedFunction, ...args: unknown[]): string { - const originalFunction = getOriginalFunction(this); - const context = - SETUP_CLIENTS.has(getClient() as Client) && originalFunction !== undefined ? originalFunction : this; - return originalFunctionToString.apply(context, args); - }; + Function.prototype.toString = new Proxy(originalFunctionToString, { + apply(target, thisArg, args) { + const originalFunction = getOriginalFunction(thisArg); + let context = thisArg; + + try { + if (SETUP_CLIENTS.has(getClient()!) && originalFunction) { + context = originalFunction; + } + } catch { + // Reading the Sentry carrier off `getClient()` can throw a `SecurityError` when `this` (or the global + // object) is a `WindowProxy` whose browsing context was navigated cross-origin. The native + // `toString` never throws here, so fall back to it to avoid turning harmless introspection into noise. + } + + return Reflect.apply(target, context, args); + }, + }); } catch { // ignore errors here, just don't patch this } diff --git a/packages/core/test/lib/integrations/functiontostring.test.ts b/packages/core/test/lib/integrations/functiontostring.test.ts index 7c40de8b06f2..1e992cbd93cb 100644 --- a/packages/core/test/lib/integrations/functiontostring.test.ts +++ b/packages/core/test/lib/integrations/functiontostring.test.ts @@ -1,16 +1,27 @@ -import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../src/currentScopes'; import { fill, getClient, getCurrentScope, setCurrentClient } from '../../../src'; import { functionToStringIntegration } from '../../../src/integrations/functiontostring'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; +vi.mock('../../../src/currentScopes', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, getClient: vi.fn(actual.getClient) }; +}); + describe('FunctionToString', () => { beforeEach(() => { const testClient = new TestClient(getDefaultTestClientOptions({})); setCurrentClient(testClient); }); + afterEach(() => { + vi.mocked(currentScopes.getClient).mockClear(); + }); + afterAll(() => { getCurrentScope().setClient(undefined); + vi.restoreAllMocks(); }); it('it works as expected', () => { @@ -55,4 +66,24 @@ describe('FunctionToString', () => { expect(foo.bar.toString()).not.toBe(originalFunction); }); + + it('falls back to native toString and does not throw when the carrier read throws', () => { + const foo = { + bar(wat: boolean): boolean { + return wat; + }, + }; + const nativeString = foo.bar.toString(); + + const fts = functionToStringIntegration(); + getClient()?.addIntegration(fts); + + // Simulate a `SecurityError` thrown while reading the Sentry carrier off a cross-origin `WindowProxy`. + vi.mocked(currentScopes.getClient).mockImplementation(() => { + throw new Error('SecurityError: Blocked a frame from accessing a cross-origin frame.'); + }); + + expect(() => foo.bar.toString()).not.toThrow(); + expect(foo.bar.toString()).toBe(nativeString); + }); }); From 1c9dabffa7a42e9f35032d76ad71ba4ec4e26e6c Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 17 Jul 2026 09:13:55 +0100 Subject: [PATCH 30/54] refactor(nextjs): Change ESM interop. to remove lazy loading (#22193) Code checking `default` like this always points to an ESM/CJS interop issue: https://github.com/getsentry/sentry-javascript/blob/8c6358a31de99cdb697509e4e2b1fcc38efba401/packages/server-utils/src/orchestrion/bundler/webpack.ts#L53 This is caused by our repo-wide Rollup setting of `interop: 'esModule'`. This is required for us to be able to monkey patch Node built-ins but causes the above issues with CJS dependencies that use the classic CJS default export (`module.exports = fn`). This PR overrides the `interop` setting for the suspect package and removes the lazy dependency loading. --- dev-packages/rollup-utils/npmHelpers.mjs | 5 +++++ packages/nextjs/src/config/webpack.ts | 2 +- packages/server-utils/rollup.npm.config.mjs | 8 ++++++++ packages/server-utils/src/orchestrion/bundler/webpack.ts | 7 ++----- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 0014616f8071..43e11e36f19c 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -94,6 +94,11 @@ export function makeBaseNPMConfig(options = {}) { // (We don't need it, so why waste the bytes?) freeze: false, + // Assume externals are ESM-shaped (`__esModule` + `.default`), which our own `@sentry/*` + // packages satisfy via `esModule: 'if-default-prop'`. This keeps `import * as x` a live + // reference to the real module rather than an `_interopNamespace` copy — instrumentation code + // relies on that to monkey-patch modules like `fs` in place. Packages that pull in bare-CJS + // third-party deps (no `.default`) override this per-module (see server-utils). interop: 'esModule', }, diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 94db1cca621d..30532b354360 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -433,7 +433,7 @@ export function constructWebpackConfigFunction({ // Orchestrion code-transform loader — Node server runtime only, never the edge compilation if (runtime === 'server' && userSentryOptions._experimental?.useDiagnosticsChannelInjection) { - newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as WebpackPluginInstance); + newConfig.plugins.push(sentryOrchestrionWebpackPlugin() as unknown as WebpackPluginInstance); } return newConfig; diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 0b6412cee679..f1b3a19655a7 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -44,6 +44,14 @@ export default [ exports: 'named', // set preserveModules to true because we don't want to bundle everything into one file. preserveModules: true, + // `@apm-js-collab/code-transformer-bundler-plugins` ships CJS entries as bare + // `module.exports = fn` with no `__esModule`/`.default`. The repo default + // `interop: 'esModule'` assumes ESM-shaped externals and would dereference a nonexistent + // `.default`, so a default import compiles to `codeTransformer.default(...)` → "not a + // function". Use 'auto' for just these so Rollup emits its interop helper. Scoped here (not + // repo-wide) because 'auto' also turns `import * as x` into a copy, which breaks in-place + // monkey-patching that other packages (e.g. the OTel fs instrumentation) depend on. + interop: id => (id?.startsWith('@apm-js-collab/code-transformer-bundler-plugins') ? 'auto' : 'esModule'), }, }, }), diff --git a/packages/server-utils/src/orchestrion/bundler/webpack.ts b/packages/server-utils/src/orchestrion/bundler/webpack.ts index 5dce4de27d62..7e2f584b1819 100644 --- a/packages/server-utils/src/orchestrion/bundler/webpack.ts +++ b/packages/server-utils/src/orchestrion/bundler/webpack.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module'; import { dirname } from 'node:path'; import type { InstrumentationConfig } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; +import codeTransformerWebpack from '@apm-js-collab/code-transformer-bundler-plugins/webpack'; import type { PluginOptions } from './options'; import { orchestrionTransformOptions } from './options'; @@ -46,10 +47,6 @@ export function getSentryInstrumentations(): InstrumentationConfig[] { /** * The code-transform webpack plugin, pre-fed the instrumentation config */ -export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): unknown { - const mod = getOrchestrionRequire()('@apm-js-collab/code-transformer-bundler-plugins/webpack') as { - default?: (options: { instrumentations: InstrumentationConfig[] }) => unknown; - }; - const codeTransformerWebpack = mod.default ?? (mod as unknown as NonNullable); +export function sentryOrchestrionWebpackPlugin(options: PluginOptions = {}): ReturnType { return codeTransformerWebpack(orchestrionTransformOptions(options)); } From ea3764e1c32ea6b06d3573a26f90bd77b4619f18 Mon Sep 17 00:00:00 2001 From: Peter Wadie Date: Fri, 17 Jul 2026 06:25:57 -0400 Subject: [PATCH 31/54] feat(cloudflare): Instrument Cloudflare rate limiter bindings (#22035) Adds automatic tracing for Cloudflare Workers rate limiter bindings, mirroring the existing R2/Queue/D1 binding instrumentation. When a `RateLimit` binding is accessed on `env`, its `limit()` calls are wrapped in a span. ### Details - New `instrumentRateLimit` wraps the binding in a `Proxy` and starts a span named `rate_limit ` around `limit()`, with the standard `auto.faas.cloudflare.rate_limit` origin. - Detection uses a `limit` duck-type in `isBinding`, wired into `instrumentEnv` after the more specific Queue/R2/D1 checks so those win when a binding also happens to expose `limit`. - The rate limit `key` is intentionally not recorded, since it commonly contains user-identifying data (e.g. an IP address or user id). - Cloudflare does not emit a native span for the rate limiter binding, so no `op` or custom `cloudflare.rate_limit.*` attributes are set for now. These can be added later if/when they land in Sentry's semantic conventions. - Includes unit tests plus an integration suite covering both an allowed call and a rate-limited (`success: false`) call. Fixes #20871 --- .../suites/ratelimit/index.ts | 37 +++++++++ .../suites/ratelimit/test.ts | 63 +++++++++++++++ .../suites/ratelimit/wrangler.jsonc | 13 ++++ .../instrumentations/worker/instrumentEnv.ts | 18 ++++- .../worker/instrumentRateLimit.ts | 31 ++++++++ packages/cloudflare/src/utils/isBinding.ts | 13 +++- .../instrumentations/instrumentEnv.test.ts | 28 +++++++ .../worker/instrumentRateLimit.test.ts | 76 +++++++++++++++++++ .../cloudflare/test/utils/isBinding.test.ts | 33 +++++++- 9 files changed, 309 insertions(+), 3 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc create mode 100644 packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts create mode 100644 packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts b/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts new file mode 100644 index 000000000000..89f4810b26dd --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts @@ -0,0 +1,37 @@ +import type { RateLimit } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + MY_RATE_LIMITER: RateLimit; +} + +function json(data: unknown): Response { + return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/ratelimit/allowed') { + const outcome = await env.MY_RATE_LIMITER.limit({ key: 'allowed-key' }); + return json(outcome); + } + + if (url.pathname === '/ratelimit/blocked') { + // The binding's limit is 1, so the second call within the period is rate limited. + await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' }); + const outcome = await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' }); + return json(outcome); + } + + return new Response('not found', { status: 404 }); + }, + } as ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts b/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts new file mode 100644 index 000000000000..30653c6e943d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts @@ -0,0 +1,63 @@ +import type { Envelope } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function envelopeItemType(envelope: Envelope): string | undefined { + return envelope[1][0]?.[0]?.type as string | undefined; +} + +function envelopeItem(envelope: Envelope): Record { + return envelope[1][0]![1] as Record; +} + +function findRateLimitSpans(envelope: Envelope): Array> { + if (envelopeItemType(envelope) !== 'transaction') return []; + const spans = (envelopeItem(envelope).spans as Array>) || []; + return spans.filter( + s => (s.data as Record | undefined)?.['sentry.origin'] === 'auto.faas.cloudflare.rate_limit', + ); +} + +it('instruments an allowed rate limiter call automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('transaction'); + const event = envelopeItem(envelope); + + expect(event.spans).toEqual([ + { + data: { + 'sentry.origin': 'auto.faas.cloudflare.rate_limit', + }, + description: 'rate_limit MY_RATE_LIMITER', + origin: 'auto.faas.cloudflare.rate_limit', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ]); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/ratelimit/allowed'); + expect(response).toEqual({ success: true }); + await runner.completed(); +}); + +it('instruments a rate-limited call automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('transaction'); + // Both `limit()` calls on the blocked endpoint are instrumented. + expect(findRateLimitSpans(envelope)).toHaveLength(2); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/ratelimit/blocked'); + expect(response).toEqual({ success: false }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc new file mode 100644 index 000000000000..97ed092cdc9b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], + "ratelimits": [ + { + "name": "MY_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 1, "period": 60 }, + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 5899ee66ada5..4f8eb0aa3142 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,6 +1,13 @@ import { isObjectLike } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isR2Bucket } from '../../utils/isBinding'; +import { + isD1Database, + isDurableObjectNamespace, + isJSRPC, + isQueue, + isR2Bucket, + isRateLimit, +} from '../../utils/isBinding'; import { instrumentD1 } from './instrumentD1'; import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; @@ -8,6 +15,7 @@ import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instr import { instrumentFetcher } from './instrumentFetcher'; import { instrumentQueueProducer } from './instrumentQueueProducer'; import { instrumentR2Bucket } from './instrumentR2'; +import { instrumentRateLimit } from './instrumentRateLimit'; function isProxyable(item: unknown): item is object { return isObjectLike(item) || typeof item === 'function'; @@ -24,6 +32,7 @@ const instrumentedBindings = new WeakMap(); * - Service bindings / JSRPC proxies * - Queue producers (via `send` + `sendBatch` duck-typing) * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) + * - Rate limiters (via `limit` duck-typing) * * @param env - The Cloudflare env object to instrument * @param options - Optional CloudflareOptions to control RPC trace propagation @@ -69,6 +78,13 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isRateLimit(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentRateLimit(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (!rpcPropagation) { return item; } diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts b/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts new file mode 100644 index 000000000000..0b05a99f3518 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts @@ -0,0 +1,31 @@ +import type { RateLimit, RateLimitOptions, RateLimitOutcome } from '@cloudflare/workers-types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.faas.cloudflare.rate_limit'; + +/** + * Wraps a Cloudflare rate limiter binding to create a span on each `limit()` call. + */ +export function instrumentRateLimit(rateLimit: T, bindingName: string): T { + return new Proxy(rateLimit, { + get(target, prop, receiver) { + if (prop !== 'limit') { + return Reflect.get(target, prop, receiver); + } + + const original = Reflect.get(target, prop, receiver) as RateLimit['limit']; + + return function (this: unknown, options: RateLimitOptions): Promise { + return startSpan( + { + name: `rate_limit ${bindingName}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }, + () => Reflect.apply(original, target, [options]), + ); + }; + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 88832f375f21..c8f6387f3c07 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; +import type { D1Database, DurableObjectNamespace, Queue, R2Bucket, RateLimit } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -95,3 +95,14 @@ export function isR2Bucket(item: unknown): item is R2Bucket { typeof item.createMultipartUpload === 'function' ); } + +/** + * Duck-type check for RateLimit bindings. + * RateLimit only exposes a single `limit` method. Because that is a fairly + * common method name, this check is intentionally run after the more specific + * binding checks (Queue, R2, D1) in `instrumentEnv`, so those win when a binding + * also happens to expose `limit`. + */ +export function isRateLimit(item: unknown): item is RateLimit { + return item != null && isNotJSRPC(item) && typeof item.limit === 'function'; +} diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 9309284abb41..40c575898de0 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -256,6 +256,34 @@ describe('instrumentEnv', () => { expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); }); + it('wraps RateLimit bindings in a proxy and forwards calls', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const limit = vi.fn().mockResolvedValue({ success: true }); + const rateLimiter = { limit }; + const env = { MY_RATE_LIMITER: rateLimiter }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_RATE_LIMITER as typeof rateLimiter; + // Wrapped binding is a Proxy, not the original reference + expect(wrapped).not.toBe(rateLimiter); + + const outcome = await wrapped.limit({ key: 'user-123' }); + expect(outcome).toEqual({ success: true }); + expect(limit).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'rate_limit MY_RATE_LIMITER' }), + expect.any(Function), + ); + }); + + it('caches the wrapped RateLimit binding across repeated access', () => { + const rateLimiter = { limit: vi.fn() }; + const env = { MY_RATE_LIMITER: rateLimiter }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER); + }); + describe('mTLS Fetcher bindings', () => { function createMtlsFetcherProxy(mockFetch: ReturnType) { return new Proxy( diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts new file mode 100644 index 000000000000..61062355da77 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts @@ -0,0 +1,76 @@ +import type { RateLimit } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import type { MockInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentRateLimit } from '../../../src/instrumentations/worker/instrumentRateLimit'; + +function createMockRateLimit(success = true): RateLimit { + return { + limit: vi.fn().mockResolvedValue({ success }), + } as unknown as RateLimit; +} + +describe('instrumentRateLimit', () => { + let startSpanSpy: MockInstance; + + beforeEach(() => { + startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('limit', () => { + test('forwards the call and returns the outcome', async () => { + const rateLimit = createMockRateLimit(true); + const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER'); + + const outcome = await wrapped.limit({ key: 'user-123' }); + + expect(outcome).toEqual({ success: true }); + expect(rateLimit.limit).toHaveBeenCalledTimes(1); + expect(rateLimit.limit).toHaveBeenCalledWith({ key: 'user-123' }); + }); + + test('returns an unsuccessful (rate-limited) outcome unchanged', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(false), 'MY_RATE_LIMITER'); + + const outcome = await wrapped.limit({ key: 'user-123' }); + + expect(outcome).toEqual({ success: false }); + }); + + test('starts a span with the binding name and origin', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER'); + await wrapped.limit({ key: 'user-123' }); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: 'rate_limit MY_RATE_LIMITER', + attributes: { + 'sentry.origin': 'auto.faas.cloudflare.rate_limit', + }, + }, + expect.any(Function), + ); + }); + + test('does not record the rate limit key (avoids leaking PII)', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER'); + await wrapped.limit({ key: 'super-secret-user-id' }); + + expect(JSON.stringify(startSpanSpy.mock.calls[0]![0])).not.toContain('super-secret-user-id'); + }); + }); + + test('forwards unknown property accesses transparently', () => { + const rateLimit = Object.assign(createMockRateLimit(), { + customMethod: vi.fn().mockReturnValue('hi'), + }) as unknown as RateLimit & { customMethod: () => string }; + const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER') as RateLimit & { customMethod: () => string }; + + expect(wrapped.customMethod()).toBe('hi'); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 28d44fd9936d..3bfba571363b 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isRateLimit } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -210,3 +210,34 @@ describe('isD1Database', () => { expect(isD1Database(jsrpcProxy)).toBe(false); }); }); + +describe('isRateLimit', () => { + it('returns true for an object with a limit method', () => { + expect(isRateLimit({ limit: async () => ({ success: true }) })).toBe(true); + }); + + it('returns false when limit is missing', () => { + expect(isRateLimit({ foo: 'bar' })).toBe(false); + }); + + it('returns false when limit is not a function', () => { + expect(isRateLimit({ limit: 'nope' })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isRateLimit(null)).toBe(false); + expect(isRateLimit(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it returns a function for limit', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isRateLimit(jsrpcProxy)).toBe(false); + }); +}); From 95854988a755391739ed83d786ab3cd6abc754f3 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:30:10 +0000 Subject: [PATCH 32/54] chore: Add external contributor to CHANGELOG.md (#22357) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #22035 Co-authored-by: JPeer264 <10677263+JPeer264@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52cc18cbfaa..3f6ad5de939a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @PeterWadie. Thank you for your contribution! + - feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) ## 10.66.0 From 5c6574e96c876d254b4777c08b29b29362d25de3 Mon Sep 17 00:00:00 2001 From: Akshit Sinha Date: Fri, 17 Jul 2026 16:54:29 +0530 Subject: [PATCH 33/54] fix(cloudflare): Instrument custom WorkerEntrypoint RPC methods (#22310) `withSentry()` only wraps the lifecycle handlers on WorkerEntrypoint (fetch, scheduled, queue, tail). Custom RPC methods never get wrapped, so errors thrown in them are not captured and trace propagation does not work for them. This mirrors what we already do for Durable Objects in [durableobject.ts](https://github.com/getsentry/sentry-javascript/blob/develop/packages/cloudflare/src/durableobject.ts): the constructor proxy returns an instance proxy that catches RPC methods as workerd accesses them, and wraps them using the same `wrapMethodWithSentry` helper. The generic typing change on `instrumentWorkerEntrypoint` / `WorkerEntrypointConstructor` also fixes #22233, where the `Env` generic was ignored and the constructor parameter was always typed as the global `cloudflareEnv`. Before submitting a pull request, please take a look at our [Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). - [x] Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked. Also fixes #22233 --- .../index-sub-worker.ts | 40 +++- .../worker-workerentrypoint-rpc/index.ts | 52 ++++- .../worker-workerentrypoint-rpc/test.ts | 172 ++++++++++++++ .../wrangler.jsonc | 8 +- packages/cloudflare/src/client.ts | 10 +- packages/cloudflare/src/durableobject.ts | 20 +- .../instrumentWorkerEntrypoint.ts | 153 ++++++++++-- .../cloudflare/src/wrapMethodWithSentry.ts | 11 +- .../instrumentWorkerEntrypoint.test.ts | 220 +++++++++++++++--- .../test/wrapMethodWithSentry.test.ts | 28 +++ 10 files changed, 655 insertions(+), 59 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts index f37d654d9831..77218af60fa1 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts @@ -5,7 +5,17 @@ interface Env { SENTRY_DSN: string; } -class MySubWorkerEntrypointBase extends WorkerEntrypoint { +interface Props { + accountId: string; +} + +class BaseEntrypoint extends WorkerEntrypoint { + inherited(value: string): string { + return value; + } +} + +class MySubWorkerEntrypointBase extends BaseEntrypoint { async fetch(request: Request): Promise { const url = new URL(request.url); @@ -20,13 +30,39 @@ class MySubWorkerEntrypointBase extends WorkerEntrypoint { return new Response('Not found', { status: 404 }); } + + get(key: string): { argumentCount: number; key: string } { + Sentry.setTag('key', key); + return { argumentCount: arguments.length, key }; + } + + throwError(): never { + throw new Error('custom RPC receiver failed'); + } } -export default Sentry.withSentry( +export const BindingEntrypoint = Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, enableRpcTracePropagation: true, + initialScope: { tags: { initial_scope: 'applied' } }, + beforeSend(event) { + event.tags = { ...event.tags, before_send: 'applied' }; + return event; + }, + transportOptions: { fetch: fetch.bind(globalThis) }, }), MySubWorkerEntrypointBase, ); + +export const NoPropagationEntrypoint = Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + transportOptions: { fetch: fetch.bind(globalThis) }, + }), + MySubWorkerEntrypointBase, +); + +export default BindingEntrypoint; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts index e46d7ffd4daf..b37c2ab1ffe5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts @@ -1,10 +1,29 @@ import * as Sentry from '@sentry/cloudflare'; +import { WorkerEntrypoint } from 'cloudflare:workers'; interface Env { SENTRY_DSN: string; - SUB_WORKER: Fetcher; + SUB_WORKER: Fetcher & { + get(key: string): Promise<{ argumentCount: number; key: string }>; + inherited(value: string): Promise; + throwError(): Promise; + }; + SUB_WORKER_NO_PROPAGATION: Fetcher & { + get(key: string): Promise<{ argumentCount: number; key: string }>; + }; } +class LoopbackEntrypointBase extends WorkerEntrypoint { + throwError(): never { + throw new Error('loopback RPC receiver failed'); + } +} + +export const LoopbackEntrypoint = Sentry.withSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 0 }), + LoopbackEntrypointBase, +); + export default Sentry.withSentry( (env: Env) => ({ dsn: env.SENTRY_DSN, @@ -12,7 +31,7 @@ export default Sentry.withSentry( enableRpcTracePropagation: true, }), { - async fetch(request, env) { + async fetch(request, env, ctx) { const url = new URL(request.url); if (url.pathname === '/call-entrypoint') { @@ -27,6 +46,35 @@ export default Sentry.withSentry( return new Response(text); } + if (url.pathname === '/call-entrypoint-rpc') { + const result = await env.SUB_WORKER.get('feature-key'); + const inherited = await env.SUB_WORKER.inherited('base-value'); + return Response.json({ inherited, ...result }); + } + + if (url.pathname === '/call-entrypoint-rpc-error') { + try { + await env.SUB_WORKER.throwError(); + } catch { + return new Response('fallback'); + } + } + + if (url.pathname === '/call-entrypoint-rpc-no-propagation') { + const result = await env.SUB_WORKER_NO_PROPAGATION.get('no-prop-key'); + return Response.json(result); + } + + if (url.pathname === '/call-loopback-rpc-error') { + try { + await ( + ctx as unknown as { exports: { LoopbackEntrypoint: { throwError(): Promise } } } + ).exports.LoopbackEntrypoint.throwError(); + } catch { + return new Response('fallback'); + } + } + return new Response('Not found', { status: 404 }); }, } satisfies ExportedHandler, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts index 3b76e28e9e88..9309d0f4f97b 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts @@ -127,3 +127,175 @@ it('propagates trace for request with query params from Worker to WorkerEntrypoi expect(entrypointParentSpanId).toBeDefined(); expect(entrypointParentSpanId).toBe(workerSpanId); }); + +it('instruments inherited custom WorkerEntrypoint RPC methods and strips metadata', async ({ signal }) => { + let callerTraceId: string | undefined; + let callerSpanId: string | undefined; + let receiverGetTraceId: string | undefined; + let receiverGetParentSpanId: string | undefined; + + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + }), + }), + transaction: 'GET /call-entrypoint-rpc', + }), + ); + callerTraceId = transactionEvent.contexts?.trace?.trace_id as string; + callerSpanId = transactionEvent.contexts?.trace?.span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + origin: 'auto.faas.cloudflare.worker_entrypoint', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', + }), + }), + }), + transaction: 'get', + }), + ); + receiverGetTraceId = transactionEvent.contexts?.trace?.trace_id as string; + receiverGetParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'rpc', + origin: 'auto.faas.cloudflare.worker_entrypoint', + data: expect.objectContaining({ + 'sentry.op': 'rpc', + 'sentry.origin': 'auto.faas.cloudflare.worker_entrypoint', + }), + }), + }), + transaction: 'inherited', + }), + ); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest<{ argumentCount: number; inherited: string; key: string }>( + 'get', + '/call-entrypoint-rpc', + ); + expect(response).toEqual({ argumentCount: 1, inherited: 'base-value', key: 'feature-key' }); + + await runner.completed(); + + expect(receiverGetTraceId).toBeDefined(); + expect(callerTraceId).toBeDefined(); + expect(receiverGetTraceId).toBe(callerTraceId); + + expect(receiverGetParentSpanId).toBeDefined(); + expect(callerSpanId).toBeDefined(); + expect(receiverGetParentSpanId).toBe(callerSpanId); +}); + +it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('custom RPC receiver failed'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.faas.cloudflare.worker_entrypoint', + }); + expect(event.tags?.initial_scope).toBe('applied'); + expect(event.tags?.before_send).toBe('applied'); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('throwError'); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /call-entrypoint-rpc-error'); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-entrypoint-rpc-error'); + expect(response).toBe('fallback'); + + await runner.completed(); +}); + +it('does not inject RPC trace metadata into receiver calls when enableRpcTracePropagation is disabled', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + data: expect.objectContaining({ + 'sentry.origin': 'auto.http.cloudflare', + }), + origin: 'auto.http.cloudflare', + }), + }), + transaction: 'GET /call-entrypoint-rpc-no-propagation', + }), + ); + }) + .start(signal); + + const response = await runner.makeRequest<{ argumentCount: number; key: string }>( + 'get', + '/call-entrypoint-rpc-no-propagation', + ); + expect(response).toEqual({ argumentCount: 1, key: 'no-prop-key' }); + + await runner.completed(); +}); + +it('captures errors from loopback WorkerEntrypoint RPC without trace propagation', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('loopback RPC receiver failed'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.faas.cloudflare.worker_entrypoint', + }); + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.transaction).toBe('GET /call-loopback-rpc-error'); + }) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/call-loopback-rpc-error'); + expect(response).toBe('fallback'); + + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc index 1638b8a00a18..bd19f421dcff 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc @@ -2,11 +2,17 @@ "name": "cloudflare-worker-workerentrypoint-rpc", "main": "index.ts", "compatibility_date": "2025-06-17", - "compatibility_flags": ["nodejs_als"], + "compatibility_flags": ["nodejs_als", "enable_ctx_exports"], "services": [ { "binding": "SUB_WORKER", "service": "cloudflare-worker-workerentrypoint-rpc-sub", + "entrypoint": "BindingEntrypoint", + }, + { + "binding": "SUB_WORKER_NO_PROPAGATION", + "service": "cloudflare-worker-workerentrypoint-rpc-sub", + "entrypoint": "NoPropagationEntrypoint", }, ], } diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index 21be86bf4c59..b9a2d2614ebf 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -177,9 +177,9 @@ interface BaseCloudflareOptions { * When enabled, trace context (sentry-trace + baggage) is propagated across: * - `stub.fetch()` calls to Durable Objects (via HTTP headers) * - Service binding `fetch()` calls (via HTTP headers) - * - RPC method calls to Durable Objects (via trailing argument) + * - RPC method calls to Durable Objects and WorkerEntrypoints (via trailing argument) * - * When enabled on the **receiver side** (DurableObject), the SDK will also: + * When enabled on the **receiver side** (DurableObject or WorkerEntrypoint), the SDK will also: * - Extract and continue traces from incoming RPC calls * - Create spans for each RPC method invocation * - Capture errors thrown by RPC methods @@ -206,6 +206,12 @@ interface BaseCloudflareOptions { * }), * MyDOBase, * ); + * + * // WorkerEntrypoint side (receiver) + * export const MyEntrypoint = Sentry.withSentry( + * env => ({ dsn: env.SENTRY_DSN, enableRpcTracePropagation: true }), + * MyEntrypointBase, + * ); * ``` */ enableRpcTracePropagation?: boolean; diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index e312c31b59ad..0c0b1262156c 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -89,25 +89,35 @@ export function instrumentDurableObjectWithSentry< if (obj.alarm && typeof obj.alarm === 'function') { // Alarms are independent invocations, so we start a new trace and link to the previous alarm obj.alarm = wrapMethodWithSentry( - { options, context, spanName: 'alarm', spanOp: 'function', startNewTrace: true }, + { + options, + context, + spanName: 'alarm', + spanOp: 'function', + startNewTrace: true, + origin: 'auto.faas.cloudflare.durable_object', + }, obj.alarm, ); } if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { obj.webSocketMessage = wrapMethodWithSentry( - { options, context, spanName: 'webSocketMessage' }, + { options, context, spanName: 'webSocketMessage', origin: 'auto.faas.cloudflare.durable_object' }, obj.webSocketMessage, ); } if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { - obj.webSocketClose = wrapMethodWithSentry({ options, context, spanName: 'webSocketClose' }, obj.webSocketClose); + obj.webSocketClose = wrapMethodWithSentry( + { options, context, spanName: 'webSocketClose', origin: 'auto.faas.cloudflare.durable_object' }, + obj.webSocketClose, + ); } if (obj.webSocketError && typeof obj.webSocketError === 'function') { obj.webSocketError = wrapMethodWithSentry( - { options, context, spanName: 'webSocketError' }, + { options, context, spanName: 'webSocketError', origin: 'auto.faas.cloudflare.durable_object' }, obj.webSocketError, (_, error) => captureException(error, { @@ -174,7 +184,7 @@ export function instrumentDurableObjectWithSentry< // Pre-create the traced version const tracedMethod = wrapMethodWithSentry( - { options, context, spanName: prop, spanOp: 'rpc' }, + { options, context, spanName: prop, spanOp: 'rpc', origin: 'auto.faas.cloudflare.durable_object' }, boundMethod, undefined, true, diff --git a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts index e8b2466da821..bb53e124568e 100644 --- a/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts +++ b/packages/cloudflare/src/instrumentations/instrumentWorkerEntrypoint.ts @@ -1,19 +1,115 @@ +import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; -import type { env as cloudflareEnv, WorkerEntrypoint } from 'cloudflare:workers'; import type { CloudflareOptions } from '../client'; +import { getFinalOptions } from '../options'; +import { instrumentContext } from '../utils/instrumentContext'; +import { extractRpcMeta } from '../utils/rpcMeta'; +import { type UncheckedMethod, wrapMethodWithSentry } from '../wrapMethodWithSentry'; +import { instrumentEnv } from './worker/instrumentEnv'; import { instrumentWorkerEntrypointFetch } from './worker/instrumentFetch'; import { instrumentWorkerEntrypointQueue } from './worker/instrumentQueue'; import { instrumentWorkerEntrypointScheduled } from './worker/instrumentScheduled'; import { instrumentWorkerEntrypointTail } from './worker/instrumentTail'; -import { getFinalOptions } from '../options'; -import { instrumentContext } from '../utils/instrumentContext'; -import { instrumentEnv } from './worker/instrumentEnv'; -export type WorkerEntrypointConstructor = new ( +const WORKER_ENTRYPOINT_ORIGIN = 'auto.faas.cloudflare.worker_entrypoint'; + +type ReservedMethod = + | Exclude, '__WORKER_ENTRYPOINT_BRAND'> + | Extract + | Exclude unknown>, string>, '__RPC_STUB_BRAND'> + | 'constructor'; + +const RESERVED_METHODS = new Set([ + 'connect', + 'constructor', + 'dup', + 'email', + 'fetch', + 'queue', + 'scheduled', + 'tail', + 'tailStream', + 'test', + 'trace', +] satisfies ReservedMethod[]); + +interface CachedMethod { + source: UncheckedMethod; + wrapped: UncheckedMethod; +} + +export type WorkerEntrypointConstructor = new ( ctx: ExecutionContext, - env: typeof cloudflareEnv, + // WorkerEntrypoint subclasses can define different `env` shapes, so this constructor must accept all of them. + // oxlint-disable-next-line typescript/no-explicit-any + env: any, ) => InstanceType>; +function isPrototypeMethod(instance: object, prop: string, method: UncheckedMethod): boolean { + let prototype = Object.getPrototypeOf(instance); + + while (prototype && prototype !== Object.prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, prop); + if (descriptor) { + return descriptor.value === method; + } + prototype = Object.getPrototypeOf(prototype); + } + + return false; +} + +function bindToInstance(instance: object, proxy: object, method: UncheckedMethod): UncheckedMethod { + return new Proxy(method, { + apply(target, thisArg, args) { + return Reflect.apply(target, thisArg === proxy ? instance : thisArg, args); + }, + }); +} + +function instrumentMethod( + instance: object, + proxy: object, + prop: PropertyKey, + method: UncheckedMethod, + options: CloudflareOptions, + context: ExecutionContext, +): UncheckedMethod { + const ownDescriptor = Object.getOwnPropertyDescriptor(instance, prop); + if (ownDescriptor && 'value' in ownDescriptor && !ownDescriptor.configurable && !ownDescriptor.writable) { + return method; + } + + const boundMethod = bindToInstance(instance, proxy, method); + + if (typeof prop !== 'string' || RESERVED_METHODS.has(prop) || !isPrototypeMethod(instance, prop, method)) { + return boundMethod; + } + + const captureMethod = wrapMethodWithSentry( + { options, context, spanOp: 'rpc', origin: WORKER_ENTRYPOINT_ORIGIN }, + boundMethod, + undefined, + true, + ); + + if (!options.enableRpcTracePropagation) { + return captureMethod; + } + + const tracedMethod = wrapMethodWithSentry( + { options, context, spanName: prop, spanOp: 'rpc', origin: WORKER_ENTRYPOINT_ORIGIN }, + boundMethod, + undefined, + true, + ); + + return (...args: unknown[]) => { + const { rpcMeta } = extractRpcMeta(args); + return rpcMeta ? tracedMethod.call(proxy, ...args) : captureMethod.call(proxy, ...args); + }; +} + /** * Instruments a WorkerEntrypoint class to capture errors and performance data. * @@ -51,10 +147,12 @@ export type WorkerEntrypointConstructor * ); * ``` */ -export function instrumentWorkerEntrypoint( - optionsCallback: (env: typeof cloudflareEnv) => CloudflareOptions | undefined, - WorkerEntrypointClass: T, -): T { +export function instrumentWorkerEntrypoint< + Env, + Props, + T extends InstanceType>, + C extends new (ctx: ExecutionContext, env: Env) => T, +>(optionsCallback: (env: Env) => CloudflareOptions | undefined, WorkerEntrypointClass: C): C { // Set up AsyncLocalStorage strategy ONCE at instrumentation time, not per-request // This is critical - calling this per-request would create a new AsyncLocalStorage // each time, breaking scope isolation for concurrent requests @@ -103,12 +201,37 @@ export function instrumentWorkerEntrypoint(); + + const proxy: typeof obj = new Proxy(obj, { + get(instance, prop) { + const value = Reflect.get(instance, prop, instance); + + if (typeof value !== 'function') { + return value; + } + + const method = value as UncheckedMethod; + + const cached = methodCache.get(prop); + if (cached?.source === method) { + return cached.wrapped; + } + + const wrapped = instrumentMethod(instance, proxy, prop, method, options, context); + methodCache.set(prop, { source: method, wrapped }); + return wrapped; + }, + }); - return obj; + return proxy; }, }); } diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index 1479278446fa..dd2b5079c83f 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -65,6 +65,11 @@ type MethodWrapperOptions = { * @default false */ startNewTrace?: boolean; + /** + * The trace origin identifying which instrumentation created the span, e.g. `auto.faas.cloudflare.durable_object`. + * Used both as the span's `sentry.origin` attribute and as the `mechanism.type` for captured exceptions. + */ + origin: string; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -92,7 +97,7 @@ export function wrapMethodWithSentry( original => new Proxy(original, { apply(target, thisArg, rawArgs: Parameters) { - const { startNewTrace } = wrapperOptions; + const { startNewTrace, origin } = wrapperOptions; // For RPC methods, extract Sentry trace context from the trailing argument. // The caller side (instrumentDurableObjectStub / JSRPC proxy) appends it; @@ -152,7 +157,7 @@ export function wrapMethodWithSentry( const onRejected = (e: unknown) => { captureException(e, { mechanism: { - type: 'auto.faas.cloudflare.durable_object', + type: origin, handled: false, }, }); @@ -181,7 +186,7 @@ export function wrapMethodWithSentry( const attributes = wrapperOptions.spanOp ? { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: wrapperOptions.spanOp, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.faas.cloudflare.durable_object', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, } : {}; diff --git a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts index 54069e14c251..353eabebe474 100644 --- a/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentWorkerEntrypoint.test.ts @@ -1,4 +1,5 @@ import type { ExecutionContext } from '@cloudflare/workers-types'; +import type { Event } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getInstrumented } from '../../src/instrument'; @@ -6,6 +7,7 @@ import { instrumentWorkerEntrypoint, type WorkerEntrypointConstructor, } from '../../src/instrumentations/instrumentWorkerEntrypoint'; +import { resetSdk } from '../testUtils'; function createMockExecutionContext(): ExecutionContext { return { @@ -20,6 +22,7 @@ class WorkerEntrypoint {} describe('instrumentWorkerEntrypoint', () => { afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); it('Generic functionality', () => { @@ -235,50 +238,209 @@ describe('instrumentWorkerEntrypoint', () => { expect(testClientFlushCount).toBe(1); }); - describe('instrumentPrototypeMethods option', () => { - it('does not instrument prototype methods when option is not set', () => { - const TestClass = class Hello extends WorkerEntrypoint { - prototypeMethod() { - return 'prototype-result'; + describe('custom RPC methods', () => { + it('binds non-RPC methods and getters to the original instance', () => { + class TestClass extends WorkerEntrypoint { + #value = 'value'; + + ownMethod = function ownMethod(this: TestClass) { + return this.#value; + }; + + get value() { + return this.#value; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + + expect(obj.ownMethod).toBe(obj.ownMethod); + expect(obj.ownMethod()).toBe('value'); + expect(obj.value).toBe('value'); + expect(obj.toString()).toBe('[object Object]'); + expect(obj.ownMethod.name).toBe('ownMethod'); + + const other = { value: true }; + expect(obj.hasOwnProperty.call(other, 'value')).toBe(true); + }); + + it('handles method replacement and frozen own methods', () => { + const frozenMethod = () => 'frozen'; + class TestClass extends WorkerEntrypoint { + method() { + return 'first'; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + Object.defineProperty(obj, 'frozenMethod', { + configurable: false, + value: frozenMethod, + writable: false, + }); + + expect(obj.method()).toBe('first'); + TestClass.prototype.method = () => 'second'; + expect(obj.method()).toBe('second'); + expect(obj.frozenMethod).toBe(frozenMethod); + }); + + it('preserves `this` for custom RPC methods when RPC trace propagation is enabled', () => { + class TestClass extends WorkerEntrypoint { + #value = 'secret'; + + readValue() { + return this.#value; + } + } + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: true }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [createMockExecutionContext(), {}], + ); + + // No propagated trace metadata takes the error-capture-only path. + expect(obj.readValue()).toBe('secret'); + + // Propagated trace metadata takes the traced (span-creating) path. + const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; + expect(obj.readValue(rpcMeta)).toBe('secret'); + }); + + it('strips RPC metadata even when trace propagation is disabled', () => { + const rpcMeta = { __sentry_rpc_meta__: { 'sentry-trace': 'trace-data' } }; + const TestClass = class extends WorkerEntrypoint { + inspect(...args: unknown[]) { + return args; } }; - const options = vi.fn().mockReturnValue({}); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, []); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ enableRpcTracePropagation: false }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [createMockExecutionContext(), {}], + ); - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); + expect(obj.inspect).toBe(obj.inspect); + expect(obj.inspect(rpcMeta)).toEqual([]); }); - it('does not instrument prototype methods when option is false', () => { + it('flushes repeated calls on the same instance', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); const TestClass = class extends WorkerEntrypoint { - prototypeMethod() { - return 'prototype-result'; + async get(value: string) { + SentryCore.captureMessage(value); + return value; } }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: false }); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, []); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); - expect(getInstrumented(obj.prototypeMethod)).toBeFalsy(); + await expect(obj.get('first call')).resolves.toBe('first call'); + await Promise.all(waits.splice(0)); + await expect(obj.get('second call')).resolves.toBe('second call'); + await Promise.all(waits); + + expect(events.map(event => event.message)).toEqual(['first call', 'second call']); }); - it('does not instrument prototype methods when option is true (instrumentWorkerEntrypoint does not support instrumentPrototypeMethods)', () => { + it('does not create a second invocation for direct calls from RPC or lifecycle methods', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const context = createMockExecutionContext(); + context.waitUntil = vi.fn(promise => { + waits.push(promise); + }); const TestClass = class extends WorkerEntrypoint { - methodOne() { - return 'one'; + async outer() { + return this.inner(); } - methodTwo() { - return 'two'; + + async fetch() { + return this.inner(); + } + + async inner(): Promise { + throw new Error('inner failure'); } }; - const options = vi.fn().mockReturnValue({ instrumentPrototypeMethods: true }); - const instrumented = instrumentWorkerEntrypoint(options, TestClass as unknown as WorkerEntrypointConstructor); - const obj = Reflect.construct(instrumented, [createMockExecutionContext(), {}]); - - expect(getInstrumented(obj.methodOne)).toBeFalsy(); - expect(getInstrumented(obj.methodTwo)).toBeFalsy(); - expect(obj.methodOne()).toBe('one'); - expect(obj.methodTwo()).toBe('two'); + const obj = Reflect.construct( + instrumentWorkerEntrypoint( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event) { + events.push(event); + return null; + }, + }), + TestClass as unknown as WorkerEntrypointConstructor, + ), + [context, {}], + ); + + await expect(obj.outer()).rejects.toThrow('inner failure'); + await Promise.all(waits.splice(0)); + expect(events).toHaveLength(1); + + await expect(obj.fetch(new Request('https://example.com'))).rejects.toThrow('inner failure'); + await Promise.all(waits); + expect(events).toHaveLength(2); + }); + + it('only excludes WorkerEntrypoint lifecycle methods from RPC instrumentation', async () => { + const initAndBind = vi.spyOn(SentryCore, 'initAndBind'); + const TestClass = class extends WorkerEntrypoint { + alarm() {} + + fetch() { + return new Response('ok'); + } + + tailStream() {} + + test() {} + + trace() {} + + webSocketMessage() {} + }; + const obj = Reflect.construct( + instrumentWorkerEntrypoint(() => ({}), TestClass as unknown as WorkerEntrypointConstructor), + [createMockExecutionContext(), {}], + ); + + const response = await obj.fetch(new Request('https://example.com')); + await response.text(); + obj.tailStream(); + obj.test(); + obj.trace(); + await obj.alarm(); + await obj.webSocketMessage(); + + expect(initAndBind).toHaveBeenCalledTimes(3); }); }); diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index ae2e376cc555..5e609f8dcace 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -93,6 +93,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method and returns its result synchronously (not a Promise)', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -108,6 +109,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method with spanName and preserves sync behavior', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), spanName: 'test-span', @@ -124,6 +126,7 @@ describe('wrapMethodWithSentry', () => { it('wraps a sync method with startNewTrace and preserves sync behavior (no storage)', () => { const handler = vi.fn().mockReturnValue('sync-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext({ hasStorage: false }), spanName: 'test-span', @@ -142,6 +145,7 @@ describe('wrapMethodWithSentry', () => { it('wraps an async method and returns a promise', async () => { const handler = vi.fn().mockResolvedValue('async-result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -159,6 +163,7 @@ describe('wrapMethodWithSentry', () => { const context = createMockContext({ hasStorage: true, hasWaitUntil: true }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, spanName: 'alarm', @@ -185,6 +190,7 @@ describe('wrapMethodWithSentry', () => { } as any; const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, spanName: 'alarm', @@ -202,6 +208,7 @@ describe('wrapMethodWithSentry', () => { it('marks handler as instrumented', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -216,6 +223,7 @@ describe('wrapMethodWithSentry', () => { it('does not re-wrap already instrumented handler', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -231,6 +239,7 @@ describe('wrapMethodWithSentry', () => { it('does not mark handler when noMark is true', () => { const handler = vi.fn(); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -245,6 +254,7 @@ describe('wrapMethodWithSentry', () => { it('creates span with spanName when provided', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), spanName: 'test-span', @@ -265,6 +275,7 @@ describe('wrapMethodWithSentry', () => { it('does not create span when spanName is not provided', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -284,6 +295,7 @@ describe('wrapMethodWithSentry', () => { throw error; }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -303,6 +315,7 @@ describe('wrapMethodWithSentry', () => { const error = new Error('Test async error'); const handler = vi.fn().mockRejectedValue(error); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -323,6 +336,7 @@ describe('wrapMethodWithSentry', () => { it('uses withIsolationScope when startNewTrace is true', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: true, @@ -338,6 +352,7 @@ describe('wrapMethodWithSentry', () => { it('uses startNewTrace when startNewTrace is true and spanName is set', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: true, @@ -353,6 +368,7 @@ describe('wrapMethodWithSentry', () => { it('does not use startNewTrace when startNewTrace is false', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), startNewTrace: false, @@ -384,6 +400,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -418,6 +435,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -461,6 +479,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: true, @@ -496,6 +515,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, startNewTrace: false, @@ -524,6 +544,7 @@ describe('wrapMethodWithSentry', () => { callOrder.push('callback'); }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -546,6 +567,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, }; @@ -564,6 +586,7 @@ describe('wrapMethodWithSentry', () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context, }; @@ -579,6 +602,7 @@ describe('wrapMethodWithSentry', () => { it('passes arguments to handler', async () => { const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -595,6 +619,7 @@ describe('wrapMethodWithSentry', () => { return this.name; }); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: {}, context: createMockContext(), }; @@ -615,6 +640,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; @@ -648,6 +674,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; @@ -681,6 +708,7 @@ describe('wrapMethodWithSentry', () => { const spyClient = vi.spyOn(scope, 'setClient'); const handler = vi.fn().mockResolvedValue('result'); const options = { + origin: 'auto.faas.cloudflare.durable_object', options: { dsn: 'https://test@sentry.io/123' }, context: createMockContext(), }; From a1ab90b8869419bb036c77944490bc86c6b616cd Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:28:44 +0000 Subject: [PATCH 34/54] chore: Add external contributor to CHANGELOG.md (#22359) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #22310 Co-authored-by: JPeer264 <10677263+JPeer264@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f6ad5de939a..c9d8fcde58b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @PeterWadie. Thank you for your contribution! +Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! - feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) From 6ff1b4d2237d1197f130f1cc36f23c0824bb2037 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 17 Jul 2026 13:52:18 +0200 Subject: [PATCH 35/54] ref: Use base keys from conventions for dynamic suffix attribute keys (#22356) Sentry conventions now exports `_BASE` keys for attribute keys that have a dynamic suffix. We can now use these keys rather than the full key to avoid the `.replace` call. --- packages/react/src/tanstackrouter.ts | 12 +++++++++--- packages/solid/src/solidrouter.ts | 12 +++++++++--- packages/solid/src/tanstackrouter.ts | 12 +++++++++--- packages/vue/src/router.ts | 6 +++--- packages/vue/src/tanstackrouter.ts | 12 +++++++++--- 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/packages/react/src/tanstackrouter.ts b/packages/react/src/tanstackrouter.ts index 42993182cd55..ea6e9a3ea1f2 100644 --- a/packages/react/src/tanstackrouter.ts +++ b/packages/react/src/tanstackrouter.ts @@ -12,7 +12,13 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core/browser'; import type { VendoredTanstackRouter, VendoredTanstackRouterRouteMatch } from './vendor/tanstackrouter-types'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; interface TanstackRouterLocation { pathname: string; @@ -188,8 +194,8 @@ function routeMatchToParamSpanAttributes(match: VendoredTanstackRouterRouteMatch const paramAttributes: Record = {}; Object.entries(match.params).forEach(([key, value]) => { paramAttributes[`url.path.params.${key}`] = value; // TODO(v11): remove attribute which does not adhere to Sentry's semantic convention - paramAttributes[URL_PATH_PARAMETER_KEY.replace('', key)] = value; - paramAttributes[PARAMS_KEY.replace('', key)] = value; // params.[key] is an alias + paramAttributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + paramAttributes[`${PARAMS_KEY_BASE}.${key}`] = value; // params.[key] is an alias }); return paramAttributes; diff --git a/packages/solid/src/solidrouter.ts b/packages/solid/src/solidrouter.ts index 5b609af79ed5..6041253a2d28 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -6,7 +6,13 @@ import { spanToJSON, startBrowserTracingNavigationSpan, } from '@sentry/browser'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Client, Integration, Span } from '@sentry/core'; import { getClient, @@ -132,8 +138,8 @@ function withSentryRouterRoot(Root: Component): Component', key), value); - rootSpan.setAttribute(PARAMS_KEY.replace('', key), value); + rootSpan.setAttribute(`${URL_PATH_PARAMETER_KEY_BASE}.${key}`, value); + rootSpan.setAttribute(`${PARAMS_KEY_BASE}.${key}`, value); } } } else { diff --git a/packages/solid/src/tanstackrouter.ts b/packages/solid/src/tanstackrouter.ts index c02addfcfbaf..bc03ba841ff6 100644 --- a/packages/solid/src/tanstackrouter.ts +++ b/packages/solid/src/tanstackrouter.ts @@ -5,7 +5,13 @@ import { startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Integration } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -182,8 +188,8 @@ function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record< const paramAttributes: Record = {}; Object.entries(match.params as Record).forEach(([key, value]) => { - paramAttributes[URL_PATH_PARAMETER_KEY.replace('', key)] = value; - paramAttributes[PARAMS_KEY.replace('', key)] = value; // params.[key] is an alias + paramAttributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + paramAttributes[`${PARAMS_KEY_BASE}.${key}`] = value; // params.[key] is an alias }); return paramAttributes; diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index 91d4a5b6c4f8..fefd1274ca0d 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -1,5 +1,5 @@ import { captureException, getAbsoluteUrl } from '@sentry/browser'; -import { PARAMS_KEY, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { PARAMS_KEY_BASE, URL_PATH_PARAMETER_KEY_BASE, URL_TEMPLATE } from '@sentry/conventions/attributes'; import type { Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core'; import { getActiveSpan, @@ -74,8 +74,8 @@ export function instrumentVueRouter( const attributes: SpanAttributes = {}; for (const key of Object.keys(to.params)) { - attributes[URL_PATH_PARAMETER_KEY.replace('', key)] = to.params[key]; - attributes[PARAMS_KEY.replace('', key)] = to.params[key]; // params.[key] is an alias + attributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = to.params[key]; + attributes[`${PARAMS_KEY_BASE}.${key}`] = to.params[key]; // params.[key] is an alias } for (const key of Object.keys(to.query)) { const value = to.query[key]; diff --git a/packages/vue/src/tanstackrouter.ts b/packages/vue/src/tanstackrouter.ts index 6521bbe201a5..e2508f2754df 100644 --- a/packages/vue/src/tanstackrouter.ts +++ b/packages/vue/src/tanstackrouter.ts @@ -5,7 +5,13 @@ import { startBrowserTracingPageLoadSpan, WINDOW, } from '@sentry/browser'; -import { PARAMS_KEY, URL_FULL, URL_PATH, URL_PATH_PARAMETER_KEY, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + PARAMS_KEY_BASE, + URL_FULL, + URL_PATH, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Integration } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -191,8 +197,8 @@ function routeMatchToParamSpanAttributes(match: RouteMatch | undefined): Record< const paramAttributes: Record = {}; Object.entries(match.params as Record).forEach(([key, value]) => { - paramAttributes[URL_PATH_PARAMETER_KEY.replace('', key)] = value; - paramAttributes[PARAMS_KEY.replace('', key)] = value; // params.[key] is an alias + paramAttributes[`${URL_PATH_PARAMETER_KEY_BASE}.${key}`] = value; + paramAttributes[`${PARAMS_KEY_BASE}.${key}`] = value; // params.[key] is an alias }); return paramAttributes; From 465229c6c6b84deb04118ac67f4cdbacca5fae62 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:31:38 +0200 Subject: [PATCH 36/54] feat(nuxt): Add `_experimental.useDiagnosticsChannelInjection` (#22323) Adds the `useDiagnosticsChannelInjection` option and updates the E2E test. When using `_experimental.useDiagnosticsChannelInjection`, users also need to do those steps: 1. Call `Sentry.experimentalUseDiagnosticsChannelInjection()` just before `Sentry.init()` 2. Remove `--import ./.output/server/sentry.server.config.mjs` from your `start`script 3. Add `sentry.autoInjectServerSentry: 'top-level-import'` in `nuxt.config.ts` so Sentry's server configuration is automatically imported closes https://github.com/getsentry/sentry-javascript/issues/22348 --------- Co-authored-by: Charly Gomez --- .../modules/sentry-server-init.ts | 35 -------- .../nuxt-4-orchestrion/nuxt.config.ts | 46 ++-------- .../nuxt-4-orchestrion/package.json | 3 - .../sentry.server.config.ts | 7 +- .../nuxt-4-orchestrion/server/api/db-mysql.ts | 16 +++- .../tests/build-injection.test.ts | 42 ++++++++++ .../nuxt-4-orchestrion/tests/db.test.ts | 8 +- packages/nuxt/package.json | 1 + packages/nuxt/src/common/types.ts | 18 ++++ packages/nuxt/src/module.ts | 5 ++ packages/nuxt/src/vite/orchestrion.ts | 35 ++++++++ packages/nuxt/src/vite/sourceMaps.ts | 4 +- .../nuxt/test/vite/buildOptions.test-d.ts | 3 + packages/nuxt/test/vite/orchestrion.test.ts | 84 +++++++++++++++++++ 14 files changed, 219 insertions(+), 88 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts create mode 100644 packages/nuxt/src/vite/orchestrion.ts create mode 100644 packages/nuxt/test/vite/orchestrion.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts deleted file mode 100644 index 4cd518b88974..000000000000 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/modules/sentry-server-init.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { createResolver, defineNuxtModule } from '@nuxt/kit'; - -const SERVER_CONFIG_FILENAME = 'sentry.server.config'; - -/** - * Local demo module — NOT part of `@sentry/nuxt`. - * - * The orchestrion bundler approach needs the Sentry init to run inside the - * server build (so the diagnostics-channel subscribers are registered before - * the first request) WITHOUT relying on `node --import`. - */ -export default defineNuxtModule({ - meta: { name: 'sentry-server-init' }, - setup(_options, nuxt) { - nuxt.hooks.hook('nitro:init', nitro => { - nitro.hooks.hook('close', () => { - const entryFilePath = createResolver(nitro.options.output.serverDir).resolve('index.mjs'); - - if (!existsSync(entryFilePath)) { - return; - } - - const topImport = `import './${SERVER_CONFIG_FILENAME}.mjs';\n`; - const data = readFileSync(entryFilePath, 'utf8'); - - if (data.startsWith(topImport)) { - return; - } - - writeFileSync(entryFilePath, topImport + data, 'utf8'); - }); - }); - }, -}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts index 4f663643f739..8e7b7a15eedb 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/nuxt.config.ts @@ -1,21 +1,16 @@ -import codeTransformerRollup from '@apm-js-collab/code-transformer-bundler-plugins/rollup'; -import { INSTRUMENTED_MODULE_NAMES, SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; - -// Tells the SDK the orchestrion bundler transform ran, so `detectOrchestrionSetup()` -// no-ops the runtime diagnostics-channel hook. Injected via `codeTransformerRollup`'s -// `injectDiagnostics` option (sourcemap-safe) instead of a hand-rolled plugin. -const orchestrionBundlerMarker = [ - 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', - '', -].join('\n'); - // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: true }, - modules: ['@sentry/nuxt/module', './modules/sentry-server-init'], + modules: ['@sentry/nuxt/module'], + + sentry: { + _experimental: { + useDiagnosticsChannelInjection: true, + }, + autoInjectServerSentry: 'top-level-import', + }, runtimeConfig: { public: { @@ -24,29 +19,4 @@ export default defineNuxtConfig({ }, }, }, - - nitro: { - // Nuxt's server is built by Nitro (Rollup), not Vite — so the orchestrion - // code transform has to run as a Nitro Rollup plugin to reach `server/api/*` - // routes. Force-bundle the instrumented deps via `externals.inline`; - // externalized deps are `require()`d from `node_modules` at runtime and never - // pass through the transform. - // - // `standard-as-callback` is ioredis' CJS `export default` helper used by - // `connect()`. Left external, Rollup's interop resolves its `.default` to a - // non-function in the bundle; inlining it alongside ioredis links the - // interop consistently. - externals: { - inline: [...INSTRUMENTED_MODULE_NAMES, 'standard-as-callback'], - }, - rollupConfig: { - plugins: [ - codeTransformerRollup({ - instrumentations: SENTRY_INSTRUMENTATIONS, - injectDiagnostics: () => orchestrionBundlerMarker, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, - ], - }, - }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json index 3f96e7bc8f6d..ca4c3a887d9b 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/package.json @@ -8,7 +8,6 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", - "start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs", "clean": "npx nuxi cleanup", "test": "playwright test", "test:prod": "TEST_ENV=production playwright test", @@ -19,7 +18,6 @@ "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", "dependencies": { "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", "ioredis": "5.10.1", "mysql": "^2.18.1", "nuxt": "^4.4.8", @@ -27,7 +25,6 @@ "vue-router": "^5.1.0" }, "devDependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils" }, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts index 712b60d404b0..b58f42b3aec1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/sentry.server.config.ts @@ -1,9 +1,8 @@ import * as Sentry from '@sentry/nuxt'; -// Opt into diagnostics-channel-based auto-instrumentation. This registers the -// channel subscribers (e.g. for `mysql`) that turn the diagnostics-channel -// events — injected at build time by the orchestrion Rollup plugin (see -// `nuxt.config.ts`) — into Sentry spans. Must run before `Sentry.init()`. +// The Nuxt module transforms supported Nitro dependencies when enabled in +// `nuxt.config.ts`. In v10, register Node's matching channel subscribers before +// initializing the SDK so those events become spans. Sentry.experimentalUseDiagnosticsChannelInjection(); Sentry.init({ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts index 5fbe4ba8f934..5c05d2062105 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/server/api/db-mysql.ts @@ -7,9 +7,19 @@ const connection = mysql.createConnection({ }); export default defineEventHandler(() => { - return new Promise(resolve => { - connection.query('SELECT 1 + 1 AS solution', () => { - connection.query('SELECT NOW()', ['1', '2'], () => { + return new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', error => { + if (error) { + reject(error); + return; + } + + connection.query('SELECT NOW()', ['1', '2'], nestedError => { + if (nestedError) { + reject(nestedError); + return; + } + resolve({ status: 'ok' }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts new file mode 100644 index 000000000000..3cc7b960feee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/build-injection.test.ts @@ -0,0 +1,42 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +function readServerBundle(): string { + const serverDir = path.join(process.cwd(), '.output/server'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.mjs')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +function readClientBundle(): string { + const serverDir = path.join(process.cwd(), '.output/public'); + return readdirSync(serverDir, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.js')) + .map(file => readFileSync(path.join(serverDir, file), 'utf8')) + .join('\n'); +} + +test.describe('Orchestrion build-time injection', () => { + const serverBundle = readServerBundle(); + const clientBundle = readClientBundle(); + + test('force-bundles instrumented dependencies', () => { + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']standard-as-callback["']|require\(["']standard-as-callback["']\))/); + }); + + test('injects diagnostics-channel publishers into the server build', () => { + expect(serverBundle).toContain('__SENTRY_ORCHESTRION__'); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('does not inject diagnostics-channel publishers into the client build', () => { + expect(clientBundle).not.toContain('__SENTRY_ORCHESTRION__'); + expect(clientBundle).not.toMatch(/orchestrion:/); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts index 4aefc07f7c59..2ac8601b0303 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-orchestrion/tests/db.test.ts @@ -8,7 +8,9 @@ test('Instruments ioredis automatically', async ({ baseURL }) => { ); }); - await fetch(`${baseURL}/api/db-ioredis`); + const response = await fetch(`${baseURL}/api/db-ioredis`); + expect(response.status).toBe(200); + expect(await response.text()).toBe('test-value'); const transactionEvent = await transactionEventPromise; @@ -50,7 +52,9 @@ test('Instruments mysql automatically', async ({ baseURL }) => { ); }); - await fetch(`${baseURL}/api/db-mysql`); + const response = await fetch(`${baseURL}/api/db-mysql`); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ status: 'ok' }); const transactionEvent = await transactionEventPromise; diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index afad87708909..9f855cc0cb13 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -60,6 +60,7 @@ "@sentry/node": "10.66.0", "@sentry/node-core": "10.66.0", "@sentry/rollup-plugin": "^5.3.0", + "@sentry/server-utils": "10.66.0", "@sentry/vite-plugin": "^5.3.0", "@sentry/vue": "10.66.0", "local-pkg": "^1.1.2" diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 7c84775e6820..fbc9604cb310 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -183,6 +183,24 @@ export type SentryNuxtModuleOptions = BuildTimeOptionsBase & { */ enabled?: boolean; + // todo(v11): Update this JSDoc (and remove from experimental) + /** + * Experimental build-time options that may change or be removed without notice. + */ + _experimental?: { + /** + * Enables build-time diagnostics-channel instrumentation for supported dependencies bundled into the Nitro server. + * + * 1. Call `Sentry.experimentalUseDiagnosticsChannelInjection()` just before `Sentry.init()` + * 2. Remove `--import ./.output/server/sentry.server.config.mjs` from your `start`script + * 3. Add `sentry.autoInjectServerSentry: 'top-level-import'` in `nuxt.config.ts` so Sentry's server configuration is automatically imported + * + * @default false + * @experimental May change or be removed in any release. + */ + useDiagnosticsChannelInjection?: boolean; + }; + /** * Options for the Sentry Vite plugin to customize the source maps upload process. * diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 72ab409ba384..4cf39d16cdbc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -13,6 +13,7 @@ import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; +import { setupOrchestrion } from './vite/orchestrion'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; @@ -84,6 +85,10 @@ export default defineNuxtModule({ const isMinNuxtV4 = nuxtMajor >= 4; if (serverConfigFile) { + if (moduleOptions._experimental?.useDiagnosticsChannelInjection) { + setupOrchestrion(nuxt); + } + if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); diff --git a/packages/nuxt/src/vite/orchestrion.ts b/packages/nuxt/src/vite/orchestrion.ts new file mode 100644 index 000000000000..5bc3d3be1a12 --- /dev/null +++ b/packages/nuxt/src/vite/orchestrion.ts @@ -0,0 +1,35 @@ +import type { Nuxt } from '@nuxt/schema'; +import { INSTRUMENTED_MODULE_NAMES } from '@sentry/server-utils/orchestrion/config'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/rollup'; +import type { NitroConfig } from 'nitropack'; + +// ioredis requires this CommonJS helper to be bundled with it. Leaving it +// external makes Nitro resolve the default export as a namespace object. +const IORedisDependencies = ['standard-as-callback']; + +/** + * Configures Nitro to bundle and transform dependencies that publish tracing + * events through diagnostics channels. + */ +export function setupOrchestrion(nuxt: Nuxt): void { + nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { + if (nuxt.options?._prepare) { + return; + } + + nitroConfig.rollupConfig ??= {}; + + if (nitroConfig.rollupConfig.plugins === null || nitroConfig.rollupConfig.plugins === undefined) { + nitroConfig.rollupConfig.plugins = []; + } else if (!Array.isArray(nitroConfig.rollupConfig.plugins)) { + nitroConfig.rollupConfig.plugins = [nitroConfig.rollupConfig.plugins]; + } + + nitroConfig.rollupConfig.plugins.push(sentryOrchestrionPlugin()); + + const externals = (nitroConfig.externals ||= {}); + const inline = externals.inline; + const existingInline = Array.isArray(inline) ? inline : inline ? [inline] : []; + externals.inline = [...new Set([...existingInline, ...INSTRUMENTED_MODULE_NAMES, ...IORedisDependencies])]; + }); +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index b249b29f4cd3..bba2e6440c46 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -94,9 +94,7 @@ export function setupSourceMaps( nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { if (sourceMapsEnabled && !nitroConfig.dev && !nuxt.options?._prepare) { - if (!nitroConfig.rollupConfig) { - nitroConfig.rollupConfig = {}; - } + nitroConfig.rollupConfig ??= {}; if (nitroConfig.rollupConfig.plugins === null || nitroConfig.rollupConfig.plugins === undefined) { nitroConfig.rollupConfig.plugins = []; diff --git a/packages/nuxt/test/vite/buildOptions.test-d.ts b/packages/nuxt/test/vite/buildOptions.test-d.ts index 1ad1560e54ec..0eca417d46f0 100644 --- a/packages/nuxt/test/vite/buildOptions.test-d.ts +++ b/packages/nuxt/test/vite/buildOptions.test-d.ts @@ -54,6 +54,9 @@ describe('Sentry Nuxt build-time options type', () => { // --- SentryNuxtModuleOptions specific options --- enabled: true, + _experimental: { + useDiagnosticsChannelInjection: true, + }, autoInjectServerSentry: 'experimental_dynamic-import', configDir: '~/custom-config', experimental_entrypointWrappedFunctions: ['default', 'handler', 'server', 'customExport'], diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts new file mode 100644 index 000000000000..1d0f8d105627 --- /dev/null +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -0,0 +1,84 @@ +import type { Nuxt } from '@nuxt/schema'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSentryOrchestrionPlugin = vi.fn(() => ({ name: 'sentry-orchestrion-plugin' })); + +function createMockNuxt(options: { _prepare?: boolean } = {}) { + const hooks: Record void | Promise>> = {}; + + return { + options: { _prepare: options._prepare ?? false }, + hook: (name: string, callback: (...args: any[]) => void | Promise) => { + hooks[name] = hooks[name] || []; + hooks[name].push(callback); + }, + triggerHook: async (name: string, ...args: any[]) => { + for (const callback of hooks[name] || []) { + await callback(...args); + } + }, + }; +} + +describe('setupOrchestrion', () => { + beforeAll(() => { + vi.doMock('@sentry/server-utils/orchestrion/config', () => ({ + INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'], + })); + vi.doMock('@sentry/server-utils/orchestrion/rollup', () => ({ + sentryOrchestrionPlugin: mockSentryOrchestrionPlugin, + })); + }); + + afterAll(() => { + vi.doUnmock('@sentry/server-utils/orchestrion/config'); + vi.doUnmock('@sentry/server-utils/orchestrion/rollup'); + }); + + beforeEach(() => { + mockSentryOrchestrionPlugin.mockClear(); + }); + + it('adds the transformer and preserves existing inline dependencies', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const existingPlugin = { name: 'existing-plugin' }; + const nitroConfig = { + rollupConfig: { plugins: existingPlugin }, + externals: { inline: ['ioredis', 'custom-dependency'] }, + }; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).toHaveBeenCalledOnce(); + expect(nitroConfig.rollupConfig.plugins).toEqual([existingPlugin, { name: 'sentry-orchestrion-plugin' }]); + expect(nitroConfig.externals.inline).toEqual(['ioredis', 'custom-dependency', 'mysql', 'standard-as-callback']); + }); + + it('initializes absent Nitro configuration', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt(); + const nitroConfig = {}; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(nitroConfig).toEqual({ + rollupConfig: { plugins: [{ name: 'sentry-orchestrion-plugin' }] }, + externals: { inline: ['mysql', 'ioredis', 'standard-as-callback'] }, + }); + }); + + it('does not change Nitro configuration in prepare mode', async () => { + const { setupOrchestrion } = await import('../../src/vite/orchestrion'); + const mockNuxt = createMockNuxt({ _prepare: true }); + const nitroConfig = { rollupConfig: { plugins: [] } }; + + setupOrchestrion(mockNuxt as unknown as Nuxt); + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(mockSentryOrchestrionPlugin).not.toHaveBeenCalled(); + expect(nitroConfig).toEqual({ rollupConfig: { plugins: [] } }); + }); +}); From 46e7bc8afd260f46803737051e4966fee64630f6 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 17 Jul 2026 06:55:22 -0700 Subject: [PATCH 37/54] fix(cloudflare): Route DO teardown through original `waitUntil` (#22339) Avoid a flush-lock deadlock that prevents WebSocket hibernation. fix: #22328 fix: JS-3067 fix: #21153 fix: JS-2607 --- CHANGELOG.md | 1 + .../cloudflare/src/wrapMethodWithSentry.ts | 8 ++- .../test/wrapMethodWithSentry.test.ts | 51 +++++++++++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d8fcde58b1..a20a5cddfd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- fix(cloudflare): Route Durable Object teardown through the original `waitUntil` to avoid a flush-lock deadlock that prevented WebSocket hibernation ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! diff --git a/packages/cloudflare/src/wrapMethodWithSentry.ts b/packages/cloudflare/src/wrapMethodWithSentry.ts index dd2b5079c83f..4723887dbace 100644 --- a/packages/cloudflare/src/wrapMethodWithSentry.ts +++ b/packages/cloudflare/src/wrapMethodWithSentry.ts @@ -15,7 +15,8 @@ import { withScope, } from '@sentry/core'; import type { CloudflareOptions } from './client'; -import { flushAndDispose } from './flush'; +import type { ExecutionContextCompat } from './executionContext'; +import { flushAndDispose, getOriginalWaitUntil } from './flush'; import { ensureInstrumented } from './instrument'; import { init } from './sdk'; import { extractRpcMeta } from './utils/rpcMeta'; @@ -122,7 +123,10 @@ export function wrapMethodWithSentry( // see: https://github.com/getsentry/sentry-javascript/issues/13217 const context: typeof wrapperOptions.context | undefined = wrapperOptions.context; - const waitUntil = context?.waitUntil?.bind?.(context); + // see: https://github.com/getsentry/sentry-javascript/issues/22328 + const waitUntil = context + ? getOriginalWaitUntil(context as ExecutionContextCompat)?.bind(context) + : undefined; const storage = resolveOriginalStorage(context, thisArg); let scopeClient = scope.getClient(); diff --git a/packages/cloudflare/test/wrapMethodWithSentry.test.ts b/packages/cloudflare/test/wrapMethodWithSentry.test.ts index 5e609f8dcace..ea154816da09 100644 --- a/packages/cloudflare/test/wrapMethodWithSentry.test.ts +++ b/packages/cloudflare/test/wrapMethodWithSentry.test.ts @@ -1,5 +1,6 @@ import * as sentryCore from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeFlushLock } from '../src/flush'; import { getInstrumented } from '../src/instrument'; import * as sdk from '../src/sdk'; import { wrapMethodWithSentry } from '../src/wrapMethodWithSentry'; @@ -722,3 +723,53 @@ describe('wrapMethodWithSentry', () => { }); }); }); + +describe('wrapMethodWithSentry waitUntil teardown (hibernation regression)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // Regression for #22328 + it('does not deadlock teardown against a concurrent waitUntil task', async () => { + const waitUntilPromises: Array> = []; + const context = { + waitUntil: vi.fn((promise: Promise) => { + waitUntilPromises.push(promise); + }), + } as unknown as ExecutionContext; + + // A prior invocation instrumented context.waitUntil + // (installs the flush lock) + const lock = makeFlushLock(context); + + // A concurrent, in-flight waitUntil task holds the flush lock + let resolveUserTask!: () => void; + const userTask = new Promise(resolve => { + resolveUserTask = resolve; + }); + context.waitUntil(userTask); + + // flush waits for the flush lock to drain. + mocks.flush.mockImplementationOnce(async () => { + await lock.finalize(); + return true; + }); + + const wrapped = wrapMethodWithSentry( + { options: { dsn: 'https://test@sentry.io/123' }, context, spanName: 'webSocketMessage' }, + vi.fn().mockResolvedValue('ok'), + ); + + await wrapped(); + + // Releasing the concurrent task drains the lock + resolveUserTask(); + + await expect(Promise.all(waitUntilPromises)).resolves.toBeDefined(); + expect(mocks.flush).toHaveBeenCalled(); + }); +}); From a117a8728c123b5eac7c392ca977d356be7c4fb2 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 17 Jul 2026 16:19:45 +0200 Subject: [PATCH 38/54] fix(nextjs): Mark internal chunk frames as not `in_app` (#22354) The client stack frame normalization anchored its internal-chunk regex with `.js$`, so frames carrying a trailing `:line` or `:line:col` suffix (e.g. `app:///_next/static/chunks/webpack-.js:1`) never matched and stayed marked as `in_app`. The regex now tolerates the optional suffix, and the duplicated pattern is collapsed into a single shared constant. Adds a unit test for the normalization iteratee and an e2e test in the nextjs-15 app verifying internal frames are marked `in_app: false`. Unfortunately this only targets pages router as app router moves this code into regular hashed chunks. Co-authored-by: Claude Opus 4.8 (1M context) --- .../tests/client/click-error.test.ts | 4 +- .../nextjs-15/pages/[locale]/click-error.tsx | 12 +++ .../nextjs-15/tests/click-error.test.ts | 29 +++++++ .../client/clientNormalizationIntegration.ts | 30 +++---- .../clientNormalizationIntegration.test.ts | 79 +++++++++++++++++++ 5 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/pages/[locale]/click-error.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/click-error.test.ts create mode 100644 packages/nextjs/test/clientNormalizationIntegration.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts index 481e7264c62d..082c46c467a7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/click-error.test.ts @@ -39,7 +39,7 @@ test('should send error for faulty click handlers', async ({ page }) => { expect(frames).toContainEqual( expect.objectContaining({ filename: expect.stringMatching( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/, ), in_app: false, }), @@ -48,7 +48,7 @@ test('should send error for faulty click handlers', async ({ page }) => { expect(frames).not.toContainEqual( expect.objectContaining({ filename: expect.stringMatching( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/, ), in_app: true, }), diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/pages/[locale]/click-error.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/pages/[locale]/click-error.tsx new file mode 100644 index 000000000000..c0b3dc70edec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/pages/[locale]/click-error.tsx @@ -0,0 +1,12 @@ +export default function ClickErrorPage() { + return ( + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/click-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/click-error.test.ts new file mode 100644 index 000000000000..af0e1af509d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/click-error.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should mark nextjs internal frames as not in_app for faulty click handlers', async ({ page }) => { + // The internal-chunk detection matches webpack chunk names (e.g. `webpack-.js`), which only exist in + // production webpack builds. Turbopack and dev builds emit differently-named chunks, so this is only relevant there. + test.skip(process.env.TEST_ENV !== 'production', 'Only relevant for production webpack builds'); + + const errorPromise = waitForError('nextjs-15', async errorEvent => { + return errorEvent.exception?.values?.[0].value === 'click error'; + }); + + await page.goto('/42/click-error'); + await page.click('#error-button'); + + const errorEvent = await errorPromise; + + expect(errorEvent).toBeDefined(); + + const frames = errorEvent?.exception?.values?.[0]?.stacktrace?.frames; + + const internalChunkRegex = + /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/; + + const internalFrames = frames?.filter(frame => frame.filename && internalChunkRegex.test(frame.filename)) ?? []; + + expect(internalFrames.length).toBeGreaterThan(0); + expect(internalFrames.filter(frame => frame.in_app !== false)).toEqual([]); +}); diff --git a/packages/nextjs/src/client/clientNormalizationIntegration.ts b/packages/nextjs/src/client/clientNormalizationIntegration.ts index 30c57ee8fc02..56fbf252d4cc 100644 --- a/packages/nextjs/src/client/clientNormalizationIntegration.ts +++ b/packages/nextjs/src/client/clientNormalizationIntegration.ts @@ -1,6 +1,9 @@ import { defineIntegration } from '@sentry/core'; import { rewriteFramesIntegration } from '@sentry/react'; +const NEXTJS_INTERNAL_CHUNK_REGEX = + /\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js(:\d+)*$/; + export const nextjsClientStackFrameNormalizationIntegration = defineIntegration( ({ assetPrefix, @@ -58,28 +61,13 @@ export const nextjsClientStackFrameNormalizationIntegration = defineIntegration( if (frame.filename?.includes('/_next')) { frame.filename = decodeURI(frame.filename); } + } else if (frame.filename?.startsWith('app:///_next')) { + frame.filename = decodeURI(frame.filename); + } - if ( - frame.filename?.match( - /\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, - ) - ) { - // We don't care about these frames. It's Next.js internal code. - frame.in_app = false; - } - } else { - if (frame.filename?.startsWith('app:///_next')) { - frame.filename = decodeURI(frame.filename); - } - - if ( - frame.filename?.match( - /^app:\/\/\/_next\/static\/chunks\/(main-|main-app-|polyfills-|webpack-|framework-|framework\.)[0-9a-f]+\.js$/, - ) - ) { - // We don't care about these frames. It's Next.js internal code. - frame.in_app = false; - } + if (frame.filename?.match(NEXTJS_INTERNAL_CHUNK_REGEX)) { + // We don't care about these frames. It's Next.js internal code. + frame.in_app = false; } return frame; diff --git a/packages/nextjs/test/clientNormalizationIntegration.test.ts b/packages/nextjs/test/clientNormalizationIntegration.test.ts new file mode 100644 index 000000000000..55336b8611fc --- /dev/null +++ b/packages/nextjs/test/clientNormalizationIntegration.test.ts @@ -0,0 +1,79 @@ +import type { StackFrame } from '@sentry/core'; +import * as SentryReact from '@sentry/react'; +import { JSDOM } from 'jsdom'; +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; +import { nextjsClientStackFrameNormalizationIntegration } from '../src/client/clientNormalizationIntegration'; + +const dom = new JSDOM(undefined, { url: 'https://example.com/' }); +const originalWindow = (global as { window?: unknown }).window; +Object.defineProperty(global, 'window', { value: dom.window, writable: true, configurable: true }); + +afterAll(() => { + Object.defineProperty(global, 'window', { value: originalWindow, writable: true, configurable: true }); +}); + +type Iteratee = (frame: StackFrame) => StackFrame; + +function captureIteratee(options: Parameters[0]): Iteratee { + let iteratee: Iteratee | undefined; + const spy = vi.spyOn(SentryReact, 'rewriteFramesIntegration').mockImplementation(rewriteFramesOptions => { + iteratee = rewriteFramesOptions?.iteratee as Iteratee; + return { name: 'RewriteFrames', processEvent: e => e }; + }); + + nextjsClientStackFrameNormalizationIntegration(options); + spy.mockRestore(); + + if (!iteratee) { + throw new Error('rewriteFramesIntegration was not called with an iteratee'); + } + return iteratee; +} + +describe('nextjsClientStackFrameNormalizationIntegration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('marks Next.js internal chunks as not in_app', () => { + const iteratee = () => + captureIteratee({ + rewriteFramesAssetPrefixPath: '', + experimentalThirdPartyOriginStackFrames: false, + }); + + it.each([ + 'app:///_next/static/chunks/webpack-e0a29dd514f2abf8.js', + 'app:///_next/static/chunks/webpack-e0a29dd514f2abf8.js:1', + 'app:///_next/static/chunks/webpack-e0a29dd514f2abf8.js:1:234', + 'app:///_next/static/chunks/main-app-abc123.js:10:5', + 'app:///_next/static/chunks/framework.deadbeef.js:2', + ])('sets in_app=false for %s', filename => { + const frame = iteratee()({ filename }); + expect(frame.in_app).toBe(false); + }); + + it('leaves app chunks as in_app', () => { + const frame = iteratee()({ filename: 'app:///_next/static/chunks/pages/index-deadbeef.js:1:2' }); + expect(frame.in_app).toBeUndefined(); + }); + }); + + describe('experimentalThirdPartyOriginStackFrames', () => { + const iteratee = () => + captureIteratee({ + rewriteFramesAssetPrefixPath: '', + experimentalThirdPartyOriginStackFrames: true, + assetPrefix: 'https://cdn.example.com', + }); + + it.each([ + 'https://cdn.example.com/_next/static/chunks/webpack-e0a29dd514f2abf8.js', + 'https://cdn.example.com/_next/static/chunks/webpack-e0a29dd514f2abf8.js:1', + 'https://cdn.example.com/_next/static/chunks/webpack-e0a29dd514f2abf8.js:1:234', + ])('sets in_app=false for %s', filename => { + const frame = iteratee()({ filename }); + expect(frame.in_app).toBe(false); + }); + }); +}); From c34a414d594d40f49ec7e709495d6ccafae0ee49 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 17 Jul 2026 16:26:17 +0200 Subject: [PATCH 39/54] ref(core): Replace `sentry.span.source` attribute with `sentry.segment.name.source` (#22358) We decided to use a different attribute to set the `transaction_info.source` field on streamed segment spans. Previously we used `sentry.span.source` which has now been deprecated in favour of `sentry.segment.name.source`. This PR double-writes `sentry.segment.name.source` from `sentry.source` (the attribute our SDK always set since v8) and completely drops `sentry.span.source`. --- .../suites/public-api/startSpan/streamed/test.ts | 2 +- .../interactions-streamed/test.ts | 2 +- .../navigation-streamed/test.ts | 2 +- .../pageload-streamed/test.ts | 2 +- .../suites/public-api/startSpan-streamed/test.ts | 2 +- .../deno-streamed/tests/spans.test.ts | 2 +- .../public-api/startSpan/basic-usage-streamed/test.ts | 5 +---- .../public-api/startSpan/basic-usage-streamed/test.ts | 5 +---- .../suites/tracing/httpIntegration-streamed/test.ts | 2 +- .../suites/tracing/mysql/test.ts | 4 ---- .../suites/tracing/postgres-streamed/test.ts | 4 ---- packages/core/src/tracing/dynamicSamplingContext.ts | 6 +++--- packages/core/src/tracing/spans/captureSpan.ts | 10 +++++----- .../core/test/lib/tracing/spans/captureSpan.test.ts | 9 ++++----- 14 files changed, 21 insertions(+), 36 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index 6498acb5109b..8aacd9528d57 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -259,7 +259,7 @@ sentryTest( type: 'string', value: 'custom', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'custom', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts index 6426afe12a9c..aad52b5b78c8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions-streamed/test.ts @@ -105,7 +105,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts index 221e5409dd70..19cb5c08d97d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -158,7 +158,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts index feb12b9b5a51..50722ad418a8 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -165,7 +165,7 @@ sentryTest( type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts index 84d66bf88f0b..cd900f6b2a43 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan-streamed/test.ts @@ -201,7 +201,7 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'route' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, - 'sentry.span.source': { type: 'string', value: 'route' }, + 'sentry.segment.name.source': { type: 'string', value: 'route' }, 'server.address': { type: 'string', value: 'localhost', diff --git a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts index f6d3029db39a..e79589dcac76 100644 --- a/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno-streamed/tests/spans.test.ts @@ -115,7 +115,7 @@ const SEGMENT_SPAN = { type: 'string', value: 'url', }, - 'sentry.span.source': { + 'sentry.segment.name.source': { type: 'string', value: 'url', }, diff --git a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index b3e84ce4fed3..21a6d3f458fb 100644 --- a/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-core-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -70,7 +70,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-child-span', is_segment: false, @@ -95,7 +94,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, links: [ { @@ -133,7 +131,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-manual-span', is_segment: false, @@ -161,7 +158,7 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, 'process.runtime.engine.name': { type: 'string', value: 'v8' }, 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, 'app.start_time': { type: 'string', value: expect.any(String) }, diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index d64fe0becf9c..83d8ec942c08 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -70,7 +70,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-child-span', is_segment: false, @@ -95,7 +94,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, links: [ { @@ -133,7 +131,6 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, }, name: 'test-manual-span', is_segment: false, @@ -161,7 +158,7 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { type: 'string', value: 'custom' }, - 'sentry.span.source': { type: 'string', value: 'custom' }, + 'sentry.segment.name.source': { type: 'string', value: 'custom' }, 'process.runtime.engine.name': { type: 'string', value: 'v8' }, 'process.runtime.engine.version': { type: 'string', value: expect.any(String) }, 'app.start_time': { type: 'string', value: expect.any(String) }, diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts index 2a3d21716a89..6e692993c9d0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration-streamed/test.ts @@ -20,7 +20,7 @@ describe('httpIntegration-streamed', () => { expect(serverSpan?.is_segment).toBe(true); expect(serverSpan?.name).toBe('GET /test'); expect(serverSpan?.attributes['sentry.source']).toEqual({ type: 'string', value: 'route' }); - expect(serverSpan?.attributes['sentry.span.source']).toEqual({ type: 'string', value: 'route' }); + expect(serverSpan?.attributes['sentry.segment.name.source']).toEqual({ type: 'string', value: 'route' }); }, }) .start(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 6b4807caec50..63fe04d744b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -263,10 +263,6 @@ describe('mysql auto instrumentation', () => { type: 'string', value: 'task', }, - 'sentry.span.source': { - type: 'string', - value: 'task', - }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream', diff --git a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts index 6db43ab26971..a0d7686e912b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/postgres-streamed/test.ts @@ -72,10 +72,6 @@ const COMMON_DB_ATTRIBUTES = { type: 'string', value: 'task', }, - 'sentry.span.source': { - type: 'string', - value: 'task', - }, [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream', diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index 192459811798..8d428ddaa1d4 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -1,4 +1,3 @@ -import { SENTRY_SPAN_SOURCE } from '@sentry/conventions/attributes'; import type { Client } from '../client'; import { DEFAULT_ENVIRONMENT } from '../constants'; import { getClient } from '../currentScopes'; @@ -144,8 +143,9 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly { value: span.spanContext().spanId, type: 'string', }, - [SENTRY_SPAN_SOURCE]: { + ['sentry.segment.name.source']: { value: 'custom', type: 'string', }, @@ -195,7 +194,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SENTRY_SPAN_SOURCE]: { + ['sentry.segment.name.source']: { value: 'custom', type: 'string', }, @@ -295,7 +294,7 @@ describe('captureSpan', () => { value: span.spanContext().spanId, type: 'string', }, - [SENTRY_SPAN_SOURCE]: { + ['sentry.segment.name.source']: { value: 'custom', type: 'string', }, @@ -365,7 +364,7 @@ describe('captureSpan', () => { [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SENTRY_SEGMENT_NAME]: { value: 'my-span', type: 'string' }, [SENTRY_SEGMENT_ID]: { value: span.spanContext().spanId, type: 'string' }, - [SENTRY_SPAN_SOURCE]: { value: 'custom', type: 'string' }, + ['sentry.segment.name.source']: { value: 'custom', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: { value: 'custom', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string' }, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string' }, From fa0e44fadbdc23b2c4b89a9e4ad4e9c7030f6f82 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Jul 2026 10:53:08 -0400 Subject: [PATCH 40/54] feat(server-utils): Rewrite `SentryLangChainInstrumentation` to orchestrion (#22266) Rewrites the LangChain integration to a `node:diagnostics_channel` listener instead of `InstrumentationBase`, with orchestrion injecting the channels. Follows the OpenAI/Anthropic/dataloader pattern; the vendored OTel path stays as the fallback when orchestrion isn't injected. Some decisions due to some differences with other integrations: * Origin is `auto.ai.langchain` (spans come from the shared core handler/builder, not the subscriber). The dual-mode test still catches broken selectors: under orchestrion the OTel integration is filtered out, so wrong selectors yield zero spans. * Provider-skip (avoid double-instrumenting the underlying SDK) fires lazily on first langchain call, matching OTel timing; embeddings need it too since `embedQuery`/`embedDocuments` call the SDK internally. * `@langchain/core` range `>=0.1.0 <2.0.0` covers langchain v0.3 and v1. closes getsentry/sentry-javascript#20915 --- packages/core/src/shared-exports.ts | 1 + .../core/src/tracing/langchain/embeddings.ts | 59 +++++---- .../integrations/tracing-channel/langchain.ts | 121 ++++++++++++++++++ .../src/orchestrion/config/langchain.ts | 73 ++++++++++- .../server-utils/src/orchestrion/index.ts | 3 + 5 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 packages/server-utils/src/integrations/tracing-channel/langchain.ts diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 271cc7bbb004..a7b3f070c2c4 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -216,6 +216,7 @@ export { instrumentStream as instrumentGoogleGenAIStream } from './tracing/googl export { GOOGLE_GENAI_INTEGRATION_NAME } from './tracing/google-genai/constants'; export type { GoogleGenAIResponse } from './tracing/google-genai/types'; export { createLangChainCallbackHandler, instrumentLangChainEmbeddings } from './tracing/langchain'; +export { _INTERNAL_getLangChainEmbeddingsSpanOptions } from './tracing/langchain/embeddings'; export { _INTERNAL_mergeLangChainCallbackHandler } from './tracing/langchain/utils'; export { LANGCHAIN_INTEGRATION_NAME } from './tracing/langchain/constants'; export type { LangChainOptions, LangChainIntegration } from './tracing/langchain/types'; diff --git a/packages/core/src/tracing/langchain/embeddings.ts b/packages/core/src/tracing/langchain/embeddings.ts index 64cda4e413c3..f6f70280e2ac 100644 --- a/packages/core/src/tracing/langchain/embeddings.ts +++ b/packages/core/src/tracing/langchain/embeddings.ts @@ -55,6 +55,32 @@ function extractEmbeddingAttributes(instance: unknown): Record return attributes; } +/** + * Builds the span options for a LangChain embedding call from the embeddings instance and input. + * + * @internal Exported so the diagnostics-channel (orchestrion) instrumentation can build the same + * span as the prototype-patching path below. + */ +export function _INTERNAL_getLangChainEmbeddingsSpanOptions( + instance: unknown, + input: unknown, + options: LangChainOptions = {}, +): { name: string; op: string; attributes: Record } { + const { recordInputs } = resolveAIRecordingOptions(options); + const attributes = extractEmbeddingAttributes(instance); + const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] || 'unknown'; + + if (recordInputs && input != null) { + attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE] = typeof input === 'string' ? input : JSON.stringify(input); + } + + return { + name: `embeddings ${modelName}`, + op: GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE, + attributes: attributes as Record, + }; +} + /** * Wraps a LangChain embedding method (embedQuery or embedDocuments) to create Sentry spans. * @@ -64,35 +90,16 @@ export function instrumentEmbeddingMethod( originalMethod: (...args: unknown[]) => Promise, options: LangChainOptions = {}, ): (...args: unknown[]) => Promise { - const { recordInputs } = resolveAIRecordingOptions(options); - return new Proxy(originalMethod, { apply(target, thisArg, args: unknown[]): Promise { - const attributes = extractEmbeddingAttributes(thisArg); - const modelName = attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] || 'unknown'; - - if (recordInputs) { - const input = args[0]; - if (input != null) { - attributes[GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE] = typeof input === 'string' ? input : JSON.stringify(input); - } - } - - return startSpan( - { - name: `embeddings ${modelName}`, - op: GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE, - attributes: attributes as Record, - }, - () => { - return Reflect.apply(target, thisArg, args).then(undefined, error => { - captureException(error, { - mechanism: { handled: false, type: 'auto.ai.langchain' }, - }); - throw error; + return startSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(thisArg, args[0], options), () => { + return Reflect.apply(target, thisArg, args).then(undefined, error => { + captureException(error, { + mechanism: { handled: false, type: 'auto.ai.langchain' }, }); - }, - ); + throw error; + }); + }); }, }) as (...args: unknown[]) => Promise; } diff --git a/packages/server-utils/src/integrations/tracing-channel/langchain.ts b/packages/server-utils/src/integrations/tracing-channel/langchain.ts new file mode 100644 index 000000000000..008d52638241 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/langchain.ts @@ -0,0 +1,121 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn, LangChainOptions, Span } from '@sentry/core'; +import { + _INTERNAL_getLangChainEmbeddingsSpanOptions, + _INTERNAL_mergeLangChainCallbackHandler, + _INTERNAL_skipAiProviderWrapping, + ANTHROPIC_AI_INTEGRATION_NAME, + createLangChainCallbackHandler, + debug, + defineIntegration, + GOOGLE_GENAI_INTEGRATION_NAME, + LANGCHAIN_INTEGRATION_NAME, + OPENAI_INTEGRATION_NAME, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; +import { langchainEmbeddingsChannels } from '../../orchestrion/config/langchain'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +// Same name as the OTel integration by design: when enabled, the OTel 'LangChain' integration is +// dropped from the default set (see the Node opt-in loader). +const INTEGRATION_NAME = LANGCHAIN_INTEGRATION_NAME; + +// LangChain drives the underlying AI provider SDKs itself, so while it's active those providers must +// not also instrument, or every call would produce two spans (mirrors the OTel path's skip list). +const SKIPPED_PROVIDERS = [OPENAI_INTEGRATION_NAME, ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME]; + +// The chat-model channels carry the live args array of `invoke(input, options)` / `_streamIterator(input, options)`. +interface RunnableChannelContext { + arguments: unknown[]; +} + +// The embeddings channels carry the instance (`self`) and the `embedQuery(text)` / `embedDocuments(texts)` args. +interface EmbeddingsChannelContext { + self?: unknown; + arguments: unknown[]; +} + +let subscribed = false; + +// Registered lazily on the first LangChain call (not at `setupOnce`) so a direct provider call made +// before any LangChain call still gets its own span — matches the OTel patch-on-import timing. It +// also stops the underlying SDK from double-instrumenting embeddings, whose `embedQuery`/ +// `embedDocuments` call the provider SDK (e.g. `openai`) internally. +function markProvidersSkipped(): void { + _INTERNAL_skipAiProviderWrapping(SKIPPED_PROVIDERS); +} + +const _langChainChannelIntegration = ((options: LangChainOptions = {}) => { + return { + name: INTEGRATION_NAME, + setupOnce() { + // `tracingChannel` is unavailable before Node 18.19, and a second `init()` would double-subscribe. + if (!diagnosticsChannel.tracingChannel || subscribed) { + return; + } + subscribed = true; + + // One stateful handler tracks spans across the whole run tree, just like the OTel path. + const sentryHandler = createLangChainCallbackHandler(options); + + // Chat models: inject the Sentry callback handler into the call options (arg 1). LangChain's own + // callback dispatch then creates the spans, exactly as in the OTel path, so no span is opened + // here — a `start` subscriber (which also makes orchestrion wrap the function) is enough. + const injectHandler = (message: unknown): void => { + markProvidersSkipped(); + + const args = (message as RunnableChannelContext).arguments; + if (!Array.isArray(args)) { + return; + } + + let callOptions = args[1] as Record | undefined; + if (!callOptions || typeof callOptions !== 'object' || Array.isArray(callOptions)) { + callOptions = {}; + args[1] = callOptions; + } + + callOptions.callbacks = _INTERNAL_mergeLangChainCallbackHandler(callOptions.callbacks, sentryHandler); + }; + + for (const channelName of [CHANNELS.LANGCHAIN_CHAT_MODEL_INVOKE, CHANNELS.LANGCHAIN_CHAT_MODEL_STREAM]) { + DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`); + diagnosticsChannel.tracingChannel(channelName).start.subscribe(injectHandler); + } + + // Embeddings don't use the callback system — the OTel path wraps the method in its own span, so + // do the same here. `bindTracingChannelToSpan` needs the async-context binding that + // `initOpenTelemetry()` registers after `setupOnce`, so wait for it before subscribing. + waitForTracingChannelBinding(() => { + for (const channelName of langchainEmbeddingsChannels) { + DEBUG_BUILD && debug.log(`[orchestrion:langchain] subscribing to channel "${channelName}"`); + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => createEmbeddingsSpan(data, options), + { captureError: () => ({ mechanism: { handled: false, type: 'auto.ai.langchain' } }) }, + ); + } + }); + }, + }; +}) satisfies IntegrationFn; + +function createEmbeddingsSpan(data: EmbeddingsChannelContext, options: LangChainOptions): Span { + // `embedQuery`/`embedDocuments` call the provider SDK internally, so skip that SDK's own + // instrumentation before its channel fires (the producer runs at the embeddings channel's `start`). + markProvidersSkipped(); + + const input = (data.arguments ?? [])[0]; + + return startInactiveSpan(_INTERNAL_getLangChainEmbeddingsSpanOptions(data.self, input, options)); +} + +/** + * EXPERIMENTAL — orchestrion-driven LangChain integration. Subscribes to the diagnostics_channels + * injected into `@langchain/core`'s `BaseChatModel` (to inject the Sentry callback handler) and into + * `@langchain/openai`'s embedding methods, so it requires the orchestrion runtime hook or bundler plugin. + */ +export const langChainChannelIntegration = defineIntegration(_langChainChannelIntegration); diff --git a/packages/server-utils/src/orchestrion/config/langchain.ts b/packages/server-utils/src/orchestrion/config/langchain.ts index 73f8298f0a29..df6247c91d1c 100644 --- a/packages/server-utils/src/orchestrion/config/langchain.ts +++ b/packages/server-utils/src/orchestrion/config/langchain.ts @@ -1,6 +1,73 @@ import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `langchain` orchestrion integration (ports `SentryLangChainInstrumentation`). -export const langchainConfig: InstrumentationConfig[] = []; +// `@langchain/*` packages ship dual CJS/ESM builds (`.cjs` for `require`, `.js` for `import`) and the +// matcher compares `filePath` exactly, so each hook is declared once per built file. -export const langchainChannels = {} as const; +// LangChain's chat model methods live on `BaseChatModel` in `@langchain/core` and are inherited by +// every provider class (`ChatAnthropic`, `ChatOpenAI`, …), so a single hook there covers all +// providers. `invoke` also backs `.batch()` (which calls `invoke` per item); `_streamIterator` +// backs `.stream()`. The vendored OTel instrumentation instead patched each provider package to +// dodge `@langchain/core` being bundled, but orchestrion transforms its source directly regardless +// of bundling. +const chatModelConfig = ['dist/language_models/chat_models.cjs', 'dist/language_models/chat_models.js'].flatMap( + filePath => { + const module = { name: '@langchain/core', versionRange: '>=0.1.0 <2.0.0', filePath }; + + return [ + { + channelName: 'chatModelInvoke', + module, + functionQuery: { className: 'BaseChatModel', methodName: 'invoke', kind: 'Async' as const }, + }, + { + channelName: 'chatModelStream', + module, + functionQuery: { className: 'BaseChatModel', methodName: '_streamIterator', kind: 'Async' as const }, + }, + ]; + }, +); + +// Embeddings have no shared concrete method on the base class (each provider implements +// `embedQuery`/`embedDocuments`), so they're hooked per package. These are the provider packages the +// vendored OTel instrumentation covered that actually ship an `Embeddings` subclass: `@langchain/openai`, +// `@langchain/google-genai` and `@langchain/mistralai` define the two methods on their own class, while +// `@langchain/google-vertexai` inherits them from the shared `@langchain/google-common` base, so that base +// is the module hooked for it. (anthropic and groq are chat-only — they ship no embeddings class.) The +// `embedQuery`/`embedDocuments` channel names are per-method; orchestrion prefixes them with the module +// name, so the full channel strings stay distinct across packages. +const EMBED_QUERY = 'embedQuery'; +const EMBED_DOCUMENTS = 'embedDocuments'; + +const EMBEDDINGS_PROVIDERS = [ + { name: '@langchain/openai', versionRange: '>=0.1.0 <2.0.0', methods: [EMBED_QUERY, EMBED_DOCUMENTS] }, + { name: '@langchain/google-genai', versionRange: '>=0.1.0 <3.0.0', methods: [EMBED_QUERY, EMBED_DOCUMENTS] }, + { name: '@langchain/mistralai', versionRange: '>=0.1.0 <2.0.0', methods: [EMBED_QUERY, EMBED_DOCUMENTS] }, + // `@langchain/google-vertexai` inherits its embed methods from this shared base. The base's + // `embedQuery` delegates to `embedDocuments`, so hooking only `embedDocuments` still traces both + // entry points as a single span each, instead of emitting a nested duplicate for `embedQuery`. + { name: '@langchain/google-common', versionRange: '>=0.1.0 <3.0.0', methods: [EMBED_DOCUMENTS] }, +]; + +const embeddingsConfig = EMBEDDINGS_PROVIDERS.flatMap(({ name, versionRange, methods }) => + ['dist/embeddings.cjs', 'dist/embeddings.js'].flatMap(filePath => + methods.map(method => ({ + channelName: method, + module: { name, versionRange, filePath }, + functionQuery: { methodName: method, kind: 'Async' as const }, + })), + ), +); + +export const langchainConfig = [...chatModelConfig, ...embeddingsConfig] satisfies InstrumentationConfig[]; + +// The embeddings channel strings the subscriber binds to, derived from the provider list above so that +// adding a provider is a single edit that both instruments it and subscribes the listener to it. +export const langchainEmbeddingsChannels = EMBEDDINGS_PROVIDERS.flatMap(({ name, methods }) => + methods.map(method => `orchestrion:${name}:${method}`), +); + +export const langchainChannels = { + LANGCHAIN_CHAT_MODEL_INVOKE: 'orchestrion:@langchain/core:chatModelInvoke', + LANGCHAIN_CHAT_MODEL_STREAM: 'orchestrion:@langchain/core:chatModelStream', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index f0d6a7c3351b..eac0c5956915 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -13,6 +13,7 @@ import { koaChannelIntegration } from '../integrations/tracing-channel/koa'; import { ioredisChannelIntegration } from '../integrations/tracing-channel/ioredis'; import { kafkajsChannelIntegration } from '../integrations/tracing-channel/kafkajs'; import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; +import { langChainChannelIntegration } from '../integrations/tracing-channel/langchain'; import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; import { mongooseChannelIntegration } from '../integrations/tracing-channel/mongoose'; @@ -42,6 +43,7 @@ export { ioredisChannelIntegration, kafkajsChannelIntegration, knexChannelIntegration, + langChainChannelIntegration, langGraphChannelIntegration, lruMemoizerChannelIntegration, mongooseChannelIntegration, @@ -98,6 +100,7 @@ export const channelIntegrations = { openaiIntegration: openaiChannelIntegration, anthropicIntegration: anthropicChannelIntegration, googleGenAIIntegration: googleGenAIChannelIntegration, + langChainIntegration: langChainChannelIntegration, langGraphIntegration: langGraphChannelIntegration, vercelAiIntegration: vercelAiChannelIntegration, amqplibIntegration: amqplibChannelIntegration, From 483816f338e69f6c2301c96731792683e23e2aea Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 17 Jul 2026 16:56:14 +0200 Subject: [PATCH 41/54] feat(sveltekit): Support browser tracing with SvelteKit 3 (#22264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds browser tracing (pageload + navigation) for SvelteKit 3 / Svelte 5, while keeping SvelteKit 2 / Svelte 4 working from a single published version with no user setup changes. Peer range widened to `2.x || ^3.0.0-0`. Svelte 5 replaced store-based `$app/stores` with rune-based `$app/state`, and they're mutually exclusive at build time (`$app/stores` throws on Kit 3; `$app/state` is absent on Kit 2). So the `sentrySvelteKit()` Vite plugin resolves a package subpath (`@sentry/sveltekit/browser-tracing-variant`) to the matching variant at build time — only the right one is bundled, and instrumentation runs eagerly (no dynamic import delay, lean bundle size). - Version detection reads `@sveltejs/kit` via the bundler; if unavailable, falls back to the Svelte major and warns (never throws). - Kit 3 fires its `__data.json` request synchronously at navigation start — before an async rune `$effect` runs — so the variant wraps fetch to read navigating synchronously and start the nav span first, keeping the client/server trace connected. Fixes getsentry/sentry-javascript#22263 --------- Co-authored-by: Charly Gomez Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Cursor --- .../sveltekit-3/src/routes/+layout.svelte | 2 - .../src/routes/users/[id]/+page.svelte | 4 +- .../sveltekit-3/tests/errors.client.test.ts | 3 +- .../sveltekit-3/tests/tracing.client.test.ts | 3 +- .../sveltekit-3/tests/tracing.test.ts | 11 +- packages/sveltekit/package.json | 7 +- packages/sveltekit/rollup.npm.config.mjs | 8 +- .../src/client/browserTracingIntegration.ts | 140 +------------- .../src/client/navigationState.svelte.ts | 52 +++++ .../src/client/svelte4BrowserTracing.ts | 137 ++++++++++++++ .../src/client/svelte5BrowserTracing.ts | 143 ++++++++++++++ .../sveltekit/src/vite/sentryVitePlugins.ts | 92 ++++++++- .../client/browserTracingIntegration.test.ts | 24 ++- .../test/client/svelte5BrowserTracing.test.ts | 178 ++++++++++++++++++ .../test/vite/sentrySvelteKitPlugins.test.ts | 17 +- packages/sveltekit/tsconfig.json | 6 +- packages/sveltekit/vite.config.ts | 9 + 17 files changed, 669 insertions(+), 167 deletions(-) create mode 100644 packages/sveltekit/src/client/navigationState.svelte.ts create mode 100644 packages/sveltekit/src/client/svelte4BrowserTracing.ts create mode 100644 packages/sveltekit/src/client/svelte5BrowserTracing.ts create mode 100644 packages/sveltekit/test/client/svelte5BrowserTracing.test.ts diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte index 8eb402c9eda6..d1fadd2ea5a3 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/src/routes/+layout.svelte @@ -1,8 +1,6 @@

Route with dynamic params

- User id: {$page.params.id} + User id: {page.params.id}

diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts index 5ee8818ed485..77d185bfdef5 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/errors.client.test.ts @@ -2,8 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; -// TODO(sveltekit-3): Unskip once SvelteKit 3 browser support lands (#22264). -test.describe.skip('client-side errors', () => { +test.describe('client-side errors', () => { test('captures error thrown on click', async ({ page }) => { await waitForInitialPageload(page, { route: '/client-error' }); diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts index 1b504ba34bdf..d47359818c27 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.client.test.ts @@ -2,8 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; -// TODO(sveltekit-3): Unskip once SvelteKit 3 browser support lands (#22264). -test.describe.skip('client-specific performance events', () => { +test.describe('client-specific performance events', () => { test('multiple navigations have distinct traces', async ({ page }) => { const navigationTxn1EventPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.transaction === '/nav1' && txnEvent.contexts?.trace?.op === 'navigation'; diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts index 96705ead8ff0..25e2901eb21f 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.test.ts @@ -2,8 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; import { waitForInitialPageload } from './utils'; -// TODO(sveltekit-3): Unskip once SvelteKit 3 browser support lands (#22264). -test.skip('capture a distributed pageload trace', async ({ page }) => { +test('capture a distributed pageload trace', async ({ page }) => { const clientTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.transaction === '/users/[id]'; }); @@ -60,7 +59,7 @@ test.skip('capture a distributed pageload trace', async ({ page }) => { expect(clientTxnEvent.contexts?.trace?.parent_span_id).toBe(serverKitResolveSpan?.span_id); }); -test.skip('capture a distributed navigation trace', async ({ page }) => { +test('capture a distributed navigation trace', async ({ page }) => { const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.transaction === '/users' && txnEvent.contexts?.trace?.op === 'navigation'; }); @@ -109,7 +108,7 @@ test.skip('capture a distributed navigation trace', async ({ page }) => { expect(clientTxnEvent.contexts?.trace?.trace_id).toBe(serverTxnEvent.contexts?.trace?.trace_id); }); -test.skip('record client-side universal load fetch span and trace', async ({ page }) => { +test('record client-side universal load fetch span and trace', async ({ page }) => { await waitForInitialPageload(page); const clientNavigationTxnEventPromise = waitForTransaction('sveltekit-3', txnEvent => { @@ -186,7 +185,7 @@ test.skip('record client-side universal load fetch span and trace', async ({ pag }); }); -test.skip('captures a navigation transaction directly after pageload', async ({ page }) => { +test('captures a navigation transaction directly after pageload', async ({ page }) => { const clientPageloadTxnPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.contexts?.trace?.op === 'pageload'; }); @@ -251,7 +250,7 @@ test.skip('captures a navigation transaction directly after pageload', async ({ }); }); -test.skip('captures one navigation transaction per redirect', async ({ page }) => { +test('captures one navigation transaction per redirect', async ({ page }) => { const clientNavigationRedirect1TxnPromise = waitForTransaction('sveltekit-3', txnEvent => { return txnEvent?.contexts?.trace?.op === 'navigation' && txnEvent?.transaction === '/redirect1'; }); diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index de32230cf81f..20e53135df9b 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -18,6 +18,11 @@ "types": "build/types/index.types.d.ts", "exports": { "./package.json": "./package.json", + "./browser-tracing-variant": { + "types": "./build/types/client/svelte4BrowserTracing.d.ts", + "import": "./build/esm/client/svelte4BrowserTracing.js", + "require": "./build/cjs/client/svelte4BrowserTracing.js" + }, ".": { "types": "./build/types/index.types.d.ts", "worker": { @@ -42,7 +47,7 @@ "access": "public" }, "peerDependencies": { - "@sveltejs/kit": "2.x", + "@sveltejs/kit": "2.x || ^3.0.0-0", "vite": "*" }, "peerDependenciesMeta": { diff --git a/packages/sveltekit/rollup.npm.config.mjs b/packages/sveltekit/rollup.npm.config.mjs index ca0792cb4868..dc6003ab7698 100644 --- a/packages/sveltekit/rollup.npm.config.mjs +++ b/packages/sveltekit/rollup.npm.config.mjs @@ -7,11 +7,17 @@ export default makeNPMConfigVariants( 'src/index.client.ts', 'src/index.worker.ts', 'src/client/index.ts', + // Browser-tracing variants, kept as standalone entrypoints so the `sentrySvelteKit()` plugin + // (or the `exports` fallback) can select one per SvelteKit version. + 'src/client/svelte4BrowserTracing.ts', + 'src/client/svelte5BrowserTracing.ts', 'src/server/index.ts', 'src/worker/index.ts', ], packageSpecificConfig: { - external: ['$app/stores'], + // Keep the variant subpath external so the transpiled output preserves the import for the + // consumer to resolve (via `exports` or the `sentrySvelteKit()` plugin). + external: ['$app/state', '$app/stores', '@sentry/sveltekit/browser-tracing-variant'], output: { dynamicImportInCjs: true, }, diff --git a/packages/sveltekit/src/client/browserTracingIntegration.ts b/packages/sveltekit/src/client/browserTracingIntegration.ts index e33ddd6daa66..3fb9b65bd1a7 100644 --- a/packages/sveltekit/src/client/browserTracingIntegration.ts +++ b/packages/sveltekit/src/client/browserTracingIntegration.ts @@ -1,15 +1,8 @@ -import type { Client, Integration, Span } from '@sentry/core'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { - browserTracingIntegration as originalBrowserTracingIntegration, - getCurrentScope, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, - startInactiveSpan, - WINDOW, -} from '@sentry/svelte'; -import { navigating, page } from '$app/stores'; -import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import type { Integration } from '@sentry/core'; +import { browserTracingIntegration as originalBrowserTracingIntegration } from '@sentry/svelte'; +// The `sentrySvelteKit()` Vite plugin redirects this to the Svelte 4 or Svelte 5 variant per Kit +// version; without the plugin it resolves via `exports` to the Svelte 4 variant, so builds don't break. +import { instrumentSvelteKitTracing } from '@sentry/sveltekit/browser-tracing-variant'; /** * A custom `BrowserTracing` integration for SvelteKit. @@ -29,128 +22,7 @@ export function browserTracingIntegration( ...integration, afterAllSetup: client => { integration.afterAllSetup(client); - - if (options.instrumentPageLoad !== false) { - _instrumentPageload(client); - } - - if (options.instrumentNavigation !== false) { - _instrumentNavigations(client); - } + instrumentSvelteKitTracing(client, options); }, }; } - -function _instrumentPageload(client: Client): void { - const initialPath = WINDOW.location?.pathname; - - const pageloadSpan = startBrowserTracingPageLoadSpan(client, { - name: initialPath, - op: 'pageload', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - }, - }); - if (!pageloadSpan) { - return; - } - - // TODO(v11): require svelte 5 or newer to switch to `page` from `$app/state` - // eslint-disable-next-line typescript/no-deprecated - page.subscribe(page => { - if (!page) { - return; - } - - const routeId = page.route?.id; - - if (routeId) { - pageloadSpan.updateName(routeId); - pageloadSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: routeId }); - getCurrentScope().setTransactionName(routeId); - } - }); -} - -/** - * Use the `navigating` store to start a transaction on navigations. - */ -function _instrumentNavigations(client: Client): void { - let routingSpan: Span | undefined; - - // TODO(v11): require svelte 5 or newer to switch to `navigating` from `$app/state` - // eslint-disable-next-line typescript/no-deprecated - navigating.subscribe(navigation => { - if (!navigation) { - // `navigating` emits a 'null' value when the navigation is completed. - // So in this case, we can finish the routing span. If the span was an idle span, - // it will finish automatically and if it was user-created users also need to finish it. - if (routingSpan) { - routingSpan.end(); - routingSpan = undefined; - } - return; - } - - const from = navigation.from; - const to = navigation.to; - - // for the origin we can fall back to window.location.pathname because in this emission, it still is set to the origin path - const rawRouteOrigin = from?.url.pathname || WINDOW.location?.pathname; - - const rawRouteDestination = to?.url.pathname; - - // We don't want to create transactions for navigations of same origin and destination. - // We need to look at the raw URL here because parameterized routes can still differ in their raw parameters. - if (rawRouteOrigin === rawRouteDestination) { - return; - } - - const parameterizedRouteOrigin = from?.route.id; - const parameterizedRouteDestination = to?.route.id; - - if (routingSpan) { - // If a routing span is still open from a previous navigation, we finish it. - // This is important for e.g. redirects when a new navigation root span finishes - // the first root span. If we don't `.end()` the previous span, it will get - // status 'cancelled' which isn't entirely correct. - routingSpan.end(); - } - - const navigationInfo = { - // `navigation.type` denotes the origin of the navigation. e.g.: - // - link (clicking on a link) - // - goto (programmatic via goto() or redirect()) - // - popstate (back/forward navigation) - 'sentry.sveltekit.navigation.type': navigation.type, - 'sentry.sveltekit.navigation.from': parameterizedRouteOrigin || undefined, - 'sentry.sveltekit.navigation.to': parameterizedRouteDestination || undefined, - }; - - startBrowserTracingNavigationSpan( - client, - { - name: parameterizedRouteDestination || rawRouteDestination || 'unknown', - op: 'navigation', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedRouteDestination ? 'route' : 'url', - ...(parameterizedRouteDestination && { [URL_TEMPLATE]: parameterizedRouteDestination }), - ...navigationInfo, - }, - }, - { url: to?.url.href }, - ); - - routingSpan = startInactiveSpan({ - op: 'ui.sveltekit.routing', - name: 'SvelteKit Route Change', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', - ...navigationInfo, - }, - onlyIfParent: true, - }); - }); -} diff --git a/packages/sveltekit/src/client/navigationState.svelte.ts b/packages/sveltekit/src/client/navigationState.svelte.ts new file mode 100644 index 000000000000..0273dee73bf7 --- /dev/null +++ b/packages/sveltekit/src/client/navigationState.svelte.ts @@ -0,0 +1,52 @@ +import type { Navigation } from '@sveltejs/kit'; +import { navigating, page } from '$app/state'; + +declare const $effect: { + (callback: () => void): void; + root(callback: () => void): () => void; +}; + +/** + * Observes the current page's parameterized route id (`$app/state`'s `page.route.id`). Unlike Kit 2's + * `page` store, the rune isn't populated synchronously when the SDK sets up during `Sentry.init`, so + * we react to it instead of reading it once. Used to upgrade the pageload span from `url` to `route`. + */ +export function onPageRouteChange(callback: (routeId: string | null) => void): () => void { + return $effect.root(() => { + $effect(() => { + callback(page.route.id); + }); + }); +} + +export function onNavigationChange(callback: (navigation: Navigation | null) => void): () => void { + return $effect.root(() => { + $effect(() => { + callback(_snapshot()); + }); + }); +} + +/** + * Reads the current navigation synchronously. `$effect`s are always batched to a microtask, which is + * too late to catch SvelteKit's synchronous data-request `fetch` at navigation start; a synchronous + * rune read lets us start the navigation span before that request so it propagates the right trace. + */ +export function getCurrentNavigation(): Navigation | null { + return _snapshot(); +} + +function _snapshot(): Navigation | null { + if (navigating.type === null) { + return null; + } + + return { + complete: navigating.complete, + delta: navigating.delta, + from: navigating.from, + to: navigating.to, + type: navigating.type, + willUnload: navigating.willUnload, + } as Navigation; +} diff --git a/packages/sveltekit/src/client/svelte4BrowserTracing.ts b/packages/sveltekit/src/client/svelte4BrowserTracing.ts new file mode 100644 index 000000000000..a90de820b6bd --- /dev/null +++ b/packages/sveltekit/src/client/svelte4BrowserTracing.ts @@ -0,0 +1,137 @@ +import type { Client, Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + getCurrentScope, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + startInactiveSpan, + WINDOW, +} from '@sentry/svelte'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import type { Navigation, Page } from '@sveltejs/kit'; +// eslint-disable-next-line typescript/no-deprecated +import { navigating, page } from '$app/stores'; +import type { Readable } from 'svelte/store'; + +/** + * SvelteKit 2 / Svelte 4 browser tracing (`$app/stores`). Selected at build time, so it's only + * bundled on Kit 2 (where `$app/state` may not exist). + * @internal + */ +export function instrumentSvelteKitTracing( + client: Client, + options: { instrumentPageLoad?: boolean; instrumentNavigation?: boolean }, +): void { + if (options.instrumentPageLoad !== false) { + // eslint-disable-next-line typescript/no-deprecated + _instrumentPageload(client, page); + } + + if (options.instrumentNavigation !== false) { + // eslint-disable-next-line typescript/no-deprecated + _instrumentNavigations(client, navigating); + } +} + +function _instrumentPageload(client: Client, pageStore: Readable): void { + const initialPath = WINDOW.location?.pathname; + + const pageloadSpan = startBrowserTracingPageLoadSpan(client, { + name: initialPath, + op: 'pageload', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + if (!pageloadSpan) { + return; + } + + pageStore.subscribe(pageState => { + if (!pageState) { + return; + } + + const routeId = pageState.route?.id; + + if (routeId) { + pageloadSpan.updateName(routeId); + pageloadSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: routeId }); + getCurrentScope().setTransactionName(routeId); + } + }); +} + +/** + * Use the `navigating` store to start a transaction on navigations. + */ +function _instrumentNavigations(client: Client, navigatingStore: Readable): void { + let routingSpan: Span | undefined; + + navigatingStore.subscribe(navigation => { + if (!navigation) { + // `navigating` emits a 'null' value when the navigation is completed. + // So in this case, we can finish the routing span. If the span was an idle span, + // it will finish automatically and if it was user-created users also need to finish it. + if (routingSpan) { + routingSpan.end(); + routingSpan = undefined; + } + return; + } + + const from = navigation.from; + const to = navigation.to; + + // for the origin we can fall back to window.location.pathname because in this emission, it still is set to the origin path + const rawRouteOrigin = from?.url.pathname || WINDOW.location?.pathname; + + const rawRouteDestination = to?.url.pathname; + + // We don't want to create transactions for navigations of same origin and destination. + // We need to look at the raw URL here because parameterized routes can still differ in their raw parameters. + if (rawRouteOrigin === rawRouteDestination) { + return; + } + + const parameterizedRouteOrigin = from?.route.id; + const parameterizedRouteDestination = to?.route.id; + + if (routingSpan) { + // If a routing span is still open from a previous navigation, we finish it. + routingSpan.end(); + } + + const navigationInfo = { + 'sentry.sveltekit.navigation.type': navigation.type, + 'sentry.sveltekit.navigation.from': parameterizedRouteOrigin || undefined, + 'sentry.sveltekit.navigation.to': parameterizedRouteDestination || undefined, + }; + + startBrowserTracingNavigationSpan( + client, + { + name: parameterizedRouteDestination || rawRouteDestination || 'unknown', + op: 'navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedRouteDestination ? 'route' : 'url', + ...(parameterizedRouteDestination && { [URL_TEMPLATE]: parameterizedRouteDestination }), + ...navigationInfo, + }, + }, + { url: to?.url.href }, + ); + + routingSpan = startInactiveSpan({ + op: 'ui.sveltekit.routing', + name: 'SvelteKit Route Change', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', + ...navigationInfo, + }, + onlyIfParent: true, + }); + }); +} diff --git a/packages/sveltekit/src/client/svelte5BrowserTracing.ts b/packages/sveltekit/src/client/svelte5BrowserTracing.ts new file mode 100644 index 000000000000..166417dd9d64 --- /dev/null +++ b/packages/sveltekit/src/client/svelte5BrowserTracing.ts @@ -0,0 +1,143 @@ +import type { Client, Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { + getCurrentScope, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, + startInactiveSpan, + WINDOW, +} from '@sentry/svelte'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import type { Navigation } from '@sveltejs/kit'; +import { getCurrentNavigation, onNavigationChange, onPageRouteChange } from './navigationState.svelte'; + +/** + * SvelteKit 3 / Svelte 5 browser tracing (`$app/state` runes). Selected at build time, so it's only + * bundled on Kit 3. + * @internal + */ +export function instrumentSvelteKitTracing( + client: Client, + options: { instrumentPageLoad?: boolean; instrumentNavigation?: boolean }, +): void { + if (options.instrumentPageLoad !== false) { + _instrumentPageLoad(client); + } + + if (options.instrumentNavigation !== false) { + _instrumentNavigations(client); + } +} + +function _instrumentPageLoad(client: Client): void { + const initialPath = WINDOW.location?.pathname; + + const pageLoadSpan = startBrowserTracingPageLoadSpan(client, { + name: initialPath, + op: 'pageload', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + + if (!pageLoadSpan) { + return; + } + + // `page.route.id` isn't available synchronously when we set up (during `Sentry.init`), so we react + // to it and upgrade the pageload span from `url` to the parameterized `route` once it resolves. + onPageRouteChange(routeId => { + if (routeId) { + pageLoadSpan.updateName(routeId); + pageLoadSpan.setAttributes({ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [URL_TEMPLATE]: routeId }); + getCurrentScope().setTransactionName(routeId); + } + }); +} + +function _instrumentNavigations(client: Client): void { + let routingSpan: Span | undefined; + // Deduplicates the two triggers below (the `fetch` wrapper and the `$effect`) so a single + // navigation starts exactly one span, regardless of which fires first. + let activeNavigationId: string | undefined; + + function _startNavigation(navigation: Navigation): void { + const from = navigation.from; + const to = navigation.to; + const rawRouteOrigin = from?.url.pathname || WINDOW.location?.pathname; + const rawRouteDestination = to?.url.pathname; + + if (rawRouteOrigin === rawRouteDestination) { + return; + } + + const navigationId = to?.url.href; + if (navigationId && navigationId === activeNavigationId) { + return; + } + activeNavigationId = navigationId; + + const parameterizedRouteOrigin = from?.route.id; + const parameterizedRouteDestination = to?.route.id; + + routingSpan?.end(); + + const navigationInfo = { + 'sentry.sveltekit.navigation.type': navigation.type, + 'sentry.sveltekit.navigation.from': parameterizedRouteOrigin || undefined, + 'sentry.sveltekit.navigation.to': parameterizedRouteDestination || undefined, + }; + + startBrowserTracingNavigationSpan( + client, + { + name: parameterizedRouteDestination || rawRouteDestination || 'unknown', + op: 'navigation', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedRouteDestination ? 'route' : 'url', + ...(parameterizedRouteDestination && { [URL_TEMPLATE]: parameterizedRouteDestination }), + ...navigationInfo, + }, + }, + { url: to?.url.href }, + ); + + routingSpan = startInactiveSpan({ + op: 'ui.sveltekit.routing', + name: 'SvelteKit Route Change', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.sveltekit', + ...navigationInfo, + }, + onlyIfParent: true, + }); + } + + // SvelteKit fires its data request (e.g. `__data.json`) synchronously at navigation start, before + // a `$effect` on `navigating` runs (microtask), so the request would carry the previous trace. We + // wrap `fetch` to read `navigating` synchronously and start the nav span before the request goes. + const originalFetch = WINDOW.fetch?.bind(WINDOW); + if (originalFetch) { + WINDOW.fetch = (...args: Parameters): ReturnType => { + const navigation = getCurrentNavigation(); + if (navigation) { + _startNavigation(navigation); + } + return originalFetch(...args); + }; + } + + onNavigationChange(navigation => { + if (!navigation) { + routingSpan?.end(); + routingSpan = undefined; + activeNavigationId = undefined; + return; + } + + // Fallback for navigations that don't issue an outgoing request (the `fetch` wrapper never fires). + _startNavigation(navigation); + }); +} diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index 3d6be5319bed..2377c1e7f65c 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -1,3 +1,6 @@ +import { consoleSandbox } from '@sentry/core'; +import * as fs from 'fs'; +import * as path from 'path'; import type { Plugin } from 'vite'; import type { AutoInstrumentSelection } from './autoInstrument'; import { makeAutoInstrumentationPlugin } from './autoInstrument'; @@ -29,7 +32,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} adapter: options.adapter || (await detectAdapter(svelteConfig, options.debug)), }; - const sentryPlugins: Plugin[] = []; + const sentryPlugins: Plugin[] = [makeBrowserTracingVariantResolverPlugin()]; if (mergedOptions.autoInstrument) { // SvelteKit 3 (>= next.8) promoted `tracing` out of `experimental`; older versions nest it there. @@ -71,6 +74,93 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} return sentryPlugins; } +// A bare subpath (not a relative import) so this plugin's `resolveId` can intercept it. +const BROWSER_TRACING_VARIANT_ID = '@sentry/sveltekit/browser-tracing-variant'; + +/** + * Redirects the browser-tracing variant import to the Svelte 4 (`$app/stores`) or Svelte 5 + * (`$app/state`) variant per installed SvelteKit version, so only the matching one is bundled and + * instrumentation runs eagerly (no dynamic import). + */ +function makeBrowserTracingVariantResolverPlugin(): Plugin { + return { + name: 'sentry-sveltekit-browser-tracing-variant', + enforce: 'pre', + // Dev only: esbuild pre-bundles deps before `resolveId` runs, so exclude the SDK to let us + // redirect the import (not needed for build). + config(_config, { command }) { + if (command === 'serve') { + return { optimizeDeps: { exclude: ['@sentry/sveltekit'] } }; + } + return undefined; + }, + async resolveId(id) { + if (id !== BROWSER_TRACING_VARIANT_ID) { + return null; + } + + const variantModule = (await isSvelteKit3(id => this.resolve(id, undefined, { skipSelf: true }))) + ? 'svelte5BrowserTracing' + : 'svelte4BrowserTracing'; + + // Point at the variant file next to the SDK's resolved entry (absolute path, so it stays internal). + const sdkEntry = await this.resolve('@sentry/sveltekit', undefined, { skipSelf: true }); + if (!sdkEntry) { + return null; + } + + return path.join(path.dirname(sdkEntry.id), 'client', `${variantModule}.js`); + }, + }; +} + +/** + * Whether to use the SvelteKit 3 (`$app/state`) variant, from the installed `@sveltejs/kit` version + * (resolved via the bundler, not `process.cwd()`). + * + * If the Kit version can't be read, fall back to the Svelte major and warn (never throw): Svelte < 5 + * can't be Kit 3 (use `$app/stores`); on Svelte 5 use `$app/state`, which works on both Kit 2.12+ and + * Kit 3 — the safe direction, since `$app/stores` hard-breaks on Kit 3. + */ +async function isSvelteKit3(resolve: (id: string) => Promise<{ id: string } | null>): Promise { + const kitMajor = await readPackageMajor(resolve, '@sveltejs/kit/package.json'); + if (kitMajor !== undefined) { + return kitMajor >= 3; + } + + const svelteMajor = await readPackageMajor(resolve, 'svelte/package.json'); + const useKit3Variant = svelteMajor === undefined || svelteMajor >= 5; + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn( + "[@sentry/sveltekit] Couldn't read the installed `@sveltejs/kit` version to set up browser " + + `tracing; falling back to the Svelte ${svelteMajor ?? '?'} based variant ` + + `(${useKit3Variant ? '`$app/state`' : '`$app/stores`'}). ` + + 'If browser tracing misbehaves, please report this to the Sentry SDK team.', + ); + }); + return useKit3Variant; +} + +async function readPackageMajor( + resolve: (id: string) => Promise<{ id: string } | null>, + packageJsonId: string, +): Promise { + try { + const resolved = await resolve(packageJsonId); + if (resolved) { + const { version } = JSON.parse(fs.readFileSync(resolved.id, 'utf8')) as { version: string }; + const major = parseInt(version.split('.')[0] || '', 10); + if (!Number.isNaN(major)) { + return major; + } + } + } catch { + // ignore — caller handles the `undefined` case + } + return undefined; +} + /** * This function creates the options for the custom Sentry Vite plugin. * The options are derived from the Sentry SvelteKit plugin options, where the `_unstable` options take precedence. diff --git a/packages/sveltekit/test/client/browserTracingIntegration.test.ts b/packages/sveltekit/test/client/browserTracingIntegration.test.ts index ab3a1f7a0f3a..c091c2b20f1e 100644 --- a/packages/sveltekit/test/client/browserTracingIntegration.test.ts +++ b/packages/sveltekit/test/client/browserTracingIntegration.test.ts @@ -104,10 +104,11 @@ describe('browserTracingIntegration', () => { }); }); - it("starts a pageload span when it's called with default params", () => { + it("starts a pageload span when it's called with default params", async () => { const integration = browserTracingIntegration(); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledTimes(1); expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledWith(fakeClient, { @@ -134,17 +135,18 @@ describe('browserTracingIntegration', () => { }); }); - it("doesn't start a pageload span if `instrumentPageLoad` is false", () => { + it("doesn't start a pageload span if `instrumentPageLoad` is false", async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); expect(startBrowserTracingPageLoadSpanSpy).toHaveBeenCalledTimes(0); }); - it("updates the current scope's transactionName once it's resolved during pageload", () => { + it("updates the current scope's transactionName once it's resolved during pageload", async () => { const scopeSetTransactionNameSpy = vi.fn(); // @ts-expect-error - only returning a partial scope here, that's fine @@ -157,6 +159,7 @@ describe('browserTracingIntegration', () => { const integration = browserTracingIntegration(); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `page` store to simulate the SvelteKit router lifecycle // TODO(v11): switch to `page` from `$app/state` @@ -169,12 +172,13 @@ describe('browserTracingIntegration', () => { expect(scopeSetTransactionNameSpy).toHaveBeenLastCalledWith('testRoute/:id'); }); - it("doesn't start a navigation span when `instrumentNavigation` is false", () => { + it("doesn't start a navigation span when `instrumentNavigation` is false", async () => { const integration = browserTracingIntegration({ instrumentNavigation: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle // TODO(v11): switch to `navigating` from `$app/state` @@ -189,12 +193,13 @@ describe('browserTracingIntegration', () => { expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(0); }); - it('starts a navigation span when `startTransactionOnLocationChange` is true', () => { + it('starts a navigation span when `startTransactionOnLocationChange` is true', async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle // TODO(v11): switch to `navigating` from `$app/state` @@ -247,12 +252,13 @@ describe('browserTracingIntegration', () => { }); describe('handling same origin and destination navigations', () => { - it("doesn't start a navigation span if the raw navigation origin and destination are equal", () => { + it("doesn't start a navigation span if the raw navigation origin and destination are equal", async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // We emit an update to the `navigating` store to simulate the SvelteKit navigation lifecycle // TODO(v11): switch to `navigating` from `$app/state` @@ -266,12 +272,13 @@ describe('browserTracingIntegration', () => { expect(startBrowserTracingNavigationSpanSpy).toHaveBeenCalledTimes(0); }); - it('starts a navigation transaction if the raw navigation origin and destination are not equal', () => { + it('starts a navigation transaction if the raw navigation origin and destination are not equal', async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // TODO(v11): switch to `navigating` from `$app/state` // @ts-expect-error - navigating is a writable but the types say it's just readable @@ -313,12 +320,13 @@ describe('browserTracingIntegration', () => { }); }); - it('falls back to `window.location.pathname` to determine the raw origin', () => { + it('falls back to `window.location.pathname` to determine the raw origin', async () => { const integration = browserTracingIntegration({ instrumentPageLoad: false, }); // @ts-expect-error - the fakeClient doesn't satisfy Client but that's fine integration.afterAllSetup(fakeClient); + await vi.dynamicImportSettled(); // window.location.pathname is "/" in tests diff --git a/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts new file mode 100644 index 000000000000..ec11e78ba70a --- /dev/null +++ b/packages/sveltekit/test/client/svelte5BrowserTracing.test.ts @@ -0,0 +1,178 @@ +/** + * @vitest-environment jsdom + */ + +/* eslint-disable @typescript-eslint/unbound-method */ +import type { Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import * as SentrySvelte from '@sentry/svelte'; +import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentSvelteKitTracing } from '../../src/client/svelte5BrowserTracing'; + +// The Svelte 5 variant reads navigation state from rune-backed helpers (`navigationState.svelte.ts`), +// which need the Svelte compiler. We mock them here so the variant's orchestration logic (fetch +// wrapper, dedup, pageload upgrade) can be unit-tested; the rune integration itself is covered by the +// `sveltekit-3` e2e app. +let currentNavigation: unknown = null; +let navigationChangeCb: ((navigation: unknown) => void) | undefined; +let pageRouteChangeCb: ((routeId: string | null) => void) | undefined; + +vi.mock('../../src/client/navigationState.svelte', () => ({ + getCurrentNavigation: () => currentNavigation, + onNavigationChange: (cb: (navigation: unknown) => void) => { + navigationChangeCb = cb; + return () => {}; + }, + onPageRouteChange: (cb: (routeId: string | null) => void) => { + pageRouteChangeCb = cb; + return () => {}; + }, +})); + +const navigationTo = (fromId: string, toId: string, toPath: string, href: string): unknown => ({ + from: { route: { id: fromId }, url: { pathname: `/${fromId}` } }, + to: { route: { id: toId }, url: { pathname: toPath, href } }, + type: 'link', +}); + +describe('svelte5 browser tracing', () => { + let createdRootSpan: Partial | undefined; + + const startPageLoadSpanSpy = vi + .spyOn(SentrySvelte, 'startBrowserTracingPageLoadSpan') + .mockImplementation((_client, ctx) => { + createdRootSpan = { ...ctx, updateName: vi.fn(), setAttributes: vi.fn() }; + return createdRootSpan as Span; + }); + + const startNavigationSpanSpy = vi + .spyOn(SentrySvelte, 'startBrowserTracingNavigationSpan') + .mockImplementation((_client, ctx) => { + createdRootSpan = { ...ctx, updateName: vi.fn(), setAttributes: vi.fn() }; + return createdRootSpan as Span; + }); + + const routingSpan = { end: vi.fn() }; + const startInactiveSpanSpy = vi + .spyOn(SentrySvelte, 'startInactiveSpan') + .mockImplementation(() => routingSpan as unknown as Span); + + const setTransactionNameSpy = vi.fn(); + vi.spyOn(SentrySvelte, 'getCurrentScope').mockImplementation( + () => ({ setTransactionName: setTransactionNameSpy }) as unknown as ReturnType, + ); + + const client = {} as Parameters[0]; + + beforeEach(() => { + vi.clearAllMocks(); + currentNavigation = null; + navigationChangeCb = undefined; + pageRouteChangeCb = undefined; + createdRootSpan = undefined; + // The variant wraps `window.fetch`; give it a resolvable fetch to wrap and reset each test. + window.fetch = vi.fn().mockResolvedValue(undefined); + }); + + describe('pageload', () => { + it('starts a `url`-sourced pageload span and upgrades to `route` once the route id resolves', () => { + instrumentSvelteKitTracing(client, {}); + + expect(startPageLoadSpanSpy).toHaveBeenCalledWith(client, { + name: '/', + op: 'pageload', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); + + pageRouteChangeCb?.('/users/[id]'); + + expect(createdRootSpan?.updateName).toHaveBeenCalledWith('/users/[id]'); + expect(createdRootSpan?.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/users/[id]', + }); + expect(setTransactionNameSpy).toHaveBeenCalledWith('/users/[id]'); + }); + + it("doesn't upgrade the pageload span while the route id is still null", () => { + instrumentSvelteKitTracing(client, {}); + pageRouteChangeCb?.(null); + expect(createdRootSpan?.updateName).not.toHaveBeenCalled(); + }); + + it('respects `instrumentPageLoad: false`', () => { + instrumentSvelteKitTracing(client, { instrumentPageLoad: false }); + expect(startPageLoadSpanSpy).not.toHaveBeenCalled(); + }); + }); + + describe('navigation', () => { + it('starts the navigation span from the outgoing fetch (before the request), sourced by route', async () => { + instrumentSvelteKitTracing(client, {}); + currentNavigation = navigationTo('/', '/users/[id]', '/users/7', 'https://sentry-test.io/users/7'); + + await window.fetch('https://sentry-test.io/users/7/__data.json'); + + expect(startNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(startNavigationSpanSpy).toHaveBeenCalledWith( + client, + expect.objectContaining({ + name: '/users/[id]', + op: 'navigation', + attributes: expect.objectContaining({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.sveltekit', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [URL_TEMPLATE]: '/users/[id]', + }), + }), + { url: 'https://sentry-test.io/users/7' }, + ); + }); + + it('deduplicates: several fetches within one navigation start exactly one span', async () => { + instrumentSvelteKitTracing(client, {}); + currentNavigation = navigationTo('/', '/a', '/a', 'https://sentry-test.io/a'); + + await window.fetch('https://sentry-test.io/a/__data.json'); + await window.fetch('https://sentry-test.io/a/sub-request'); + + expect(startNavigationSpanSpy).toHaveBeenCalledTimes(1); + }); + + it('falls back to the `onNavigationChange` effect for navigations without a fetch, and ends the routing span on completion', () => { + instrumentSvelteKitTracing(client, {}); + + navigationChangeCb?.(navigationTo('/', '/a', '/a', 'https://sentry-test.io/a')); + expect(startNavigationSpanSpy).toHaveBeenCalledTimes(1); + expect(startInactiveSpanSpy).toHaveBeenCalledTimes(1); + + // `navigating` emits null when navigation completes + navigationChangeCb?.(null); + expect(routingSpan.end).toHaveBeenCalledTimes(1); + }); + + it("doesn't start a navigation span when origin and destination raw paths are equal", async () => { + instrumentSvelteKitTracing(client, {}); + currentNavigation = navigationTo('/a', '/a', '/a', 'https://sentry-test.io/a'); + // origin pathname is derived from `from.url.pathname` -> '//a'; make them equal explicitly + (currentNavigation as { from: { url: { pathname: string } } }).from.url.pathname = '/a'; + + await window.fetch('https://sentry-test.io/a'); + + expect(startNavigationSpanSpy).not.toHaveBeenCalled(); + }); + + it('respects `instrumentNavigation: false` (no fetch wrapper span)', async () => { + instrumentSvelteKitTracing(client, { instrumentNavigation: false }); + currentNavigation = navigationTo('/', '/a', '/a', 'https://sentry-test.io/a'); + + await window.fetch('https://sentry-test.io/a'); + + expect(startNavigationSpanSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 4e8a73e71003..eeb72b111ee5 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -42,14 +42,17 @@ describe('sentrySvelteKit()', () => { const plugins = await getSentrySvelteKitPlugins(); expect(plugins).toBeInstanceOf(Array); - // 1 auto instrument plugin + 1 global values injection plugin + 1 modified main plugin + 3 custom plugins - expect(plugins).toHaveLength(6); + // 1 browser-tracing variant resolver + 1 auto instrument plugin + 1 global values injection plugin + // + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(7); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { const plugins = await getSentrySvelteKitPlugins(); const pluginNames = plugins.map(plugin => plugin.name); expect(pluginNames).toEqual([ + // browser-tracing variant resolver: + 'sentry-sveltekit-browser-tracing-variant', // auto instrument plugin: 'sentry-auto-instrumentation', // global values injection plugin: @@ -65,7 +68,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(1); // auto instrument + expect(plugins).toHaveLength(2); // browser-tracing variant resolver + auto instrument }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -73,9 +76,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[0]; + const instrumentPlugin = plugins[1]; - expect(plugins).toHaveLength(2); // auto instrument + global values injection + expect(plugins).toHaveLength(3); // browser-tracing variant resolver + auto instrument + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -84,7 +87,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(5); // global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(6); // browser-tracing variant resolver + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -188,7 +191,7 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[0]!; + const plugin = plugins[1]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ diff --git a/packages/sveltekit/tsconfig.json b/packages/sveltekit/tsconfig.json index bf45a09f2d71..5de06be82324 100644 --- a/packages/sveltekit/tsconfig.json +++ b/packages/sveltekit/tsconfig.json @@ -4,6 +4,10 @@ "include": ["src/**/*"], "compilerOptions": { - // package-specific options + // Resolve the self-referenced variant subpath to source for type-checking, so tsc doesn't resolve + // it via `exports` to a `.d.ts` it's emitting (TS5055/TS2209). Emitted types keep the subpath. + "paths": { + "@sentry/sveltekit/browser-tracing-variant": ["./src/client/svelte4BrowserTracing.ts"] + } } } diff --git a/packages/sveltekit/vite.config.ts b/packages/sveltekit/vite.config.ts index 2b4e927808e7..8cadb91fb423 100644 --- a/packages/sveltekit/vite.config.ts +++ b/packages/sveltekit/vite.config.ts @@ -12,6 +12,15 @@ export default { find: '$app/stores', replacement: resolve(fileURLToPath(dirname(import.meta.url)), '/.empty.js'), }, + { + find: '$app/state', + replacement: resolve(fileURLToPath(dirname(import.meta.url)), '/.empty.js'), + }, + { + // Unit tests target the Svelte 4 variant; the Svelte 5 rune variant is covered by e2e. + find: '@sentry/sveltekit/browser-tracing-variant', + replacement: resolve(dirname(fileURLToPath(import.meta.url)), 'src/client/svelte4BrowserTracing.ts'), + }, ], }, }; From 0b3ad6d66d1412845a50dc00d0d756c5ab70a8e8 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 17 Jul 2026 07:56:22 -0700 Subject: [PATCH 42/54] fix(cloudflare): Bind Durable Object built-in handlers (#22340) Bind handlers per instance so a second instance in the same isolate no longer reuses the first instance's captured context and options fix: #22328 fix: JS-3067 fix: #21153 fix: JS-2607 --- CHANGELOG.md | 3 +- packages/cloudflare/src/durableobject.ts | 12 ++++--- .../cloudflare/test/durableobject.test.ts | 32 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a20a5cddfd5f..5d4da4d662db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,12 @@ ## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -- fix(cloudflare): Route Durable Object teardown through the original `waitUntil` to avoid a flush-lock deadlock that prevented WebSocket hibernation ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! - feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) +- fix(cloudflare): Route Durable Object teardown through the original `waitUntil` to avoid a flush-lock deadlock that prevented WebSocket hibernation ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) +- fix(cloudflare): Bind Durable Object built-in handlers (`fetch`/`alarm`/`webSocket*`) per instance so a second instance in the same isolate no longer reuses the first instance's captured context and options ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) ## 10.66.0 diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 0c0b1262156c..42bfcab834cf 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -72,9 +72,11 @@ export function instrumentDurableObjectWithSentry< // Any other public methods on the Durable Object instance are RPC calls. + // Bind each built-in handler to this instance before wrapping. + // See https://github.com/getsentry/sentry-javascript/issues/22328 if (obj.fetch && typeof obj.fetch === 'function') { obj.fetch = ensureInstrumented( - obj.fetch, + obj.fetch.bind(obj), original => new Proxy(original, { apply(target, thisArg, args) { @@ -97,28 +99,28 @@ export function instrumentDurableObjectWithSentry< startNewTrace: true, origin: 'auto.faas.cloudflare.durable_object', }, - obj.alarm, + obj.alarm.bind(obj), ); } if (obj.webSocketMessage && typeof obj.webSocketMessage === 'function') { obj.webSocketMessage = wrapMethodWithSentry( { options, context, spanName: 'webSocketMessage', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketMessage, + obj.webSocketMessage.bind(obj), ); } if (obj.webSocketClose && typeof obj.webSocketClose === 'function') { obj.webSocketClose = wrapMethodWithSentry( { options, context, spanName: 'webSocketClose', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketClose, + obj.webSocketClose.bind(obj), ); } if (obj.webSocketError && typeof obj.webSocketError === 'function') { obj.webSocketError = wrapMethodWithSentry( { options, context, spanName: 'webSocketError', origin: 'auto.faas.cloudflare.durable_object' }, - obj.webSocketError, + obj.webSocketError.bind(obj), (_, error) => captureException(error, { mechanism: { diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index 83b473fabe4d..ec0c9e8ec708 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -81,6 +81,38 @@ describe('instrumentDurableObjectWithSentry', () => { expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); }); + // Regression for #22328 + // built-in handlers live on the class prototype. + // ensureInstrumented keys its global cache on the original function + // reference, so without per-instance binding a second instance in the + // same isolate reuses the first instance's wrapper. + it('Built-in handlers do not stick to the first instance options across a shared isolate', async () => { + const mockContext = { + waitUntil: vi.fn(), + } as any; + const mockEnv = {} as any; + const initCore = vi.spyOn(SentryCore, 'initAndBind'); + vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); + const options = vi.fn().mockReturnValueOnce({ orgId: 1 }).mockReturnValueOnce({ orgId: 2 }); + + const testClass = class { + webSocketMessage() {} + }; + const Instrumented = instrumentDurableObjectWithSentry(options, testClass as any); + + const instance1 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + const instance2 = Reflect.construct(Instrumented, [mockContext, mockEnv]); + + // Each instance must get its own wrapper, not the first instance's cached proxy. + expect(instance2.webSocketMessage).not.toBe(instance1.webSocketMessage); + + await instance1.webSocketMessage(); + await instance2.webSocketMessage(); + + expect(initCore).nthCalledWith(1, expect.any(Function), expect.objectContaining({ orgId: 1 })); + expect(initCore).nthCalledWith(2, expect.any(Function), expect.objectContaining({ orgId: 2 })); + }); + it('does not create RPC spans without metadata when both RPC options are set', () => { const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); vi.spyOn(SentryCore, 'getClient').mockReturnValue(undefined); From f3b1dff670d2d056813b69168a32800ad7704fa3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Jul 2026 11:02:17 -0400 Subject: [PATCH 43/54] ref: Omit redundant `captureError: false` from tracing-channel callsites (#22368) `captureError` defaults to `false` in `bindTracingChannelToSpan`, so passing it explicitly at the redis and nestjs subscriber callsites is redundant. --- .../integrations/orchestrion-subscriber.ts | 15 ++--- .../src/integrations/tracing-channel/redis.ts | 59 +++++++---------- .../src/redis/redis-dc-subscriber.ts | 65 ++++++++----------- 3 files changed, 56 insertions(+), 83 deletions(-) diff --git a/packages/nestjs/src/integrations/orchestrion-subscriber.ts b/packages/nestjs/src/integrations/orchestrion-subscriber.ts index 063c564f7267..2d72eb28e5e8 100644 --- a/packages/nestjs/src/integrations/orchestrion-subscriber.ts +++ b/packages/nestjs/src/integrations/orchestrion-subscriber.ts @@ -128,9 +128,6 @@ export function subscribeToNestChannels(): void { // `start`, makes it the active context for the bootstrap, and ends it // on `asyncEnd` (or `end` if `create` throws synchronously). // - // `captureError: false`: a failed bootstrap surfaces to the caller. - // We just annotate the span. - // // `bindTracingChannelToSpan` uses `bindStore`, which needs the // async-context binding registered after integration `setupOnce`; defer // until it's available. Only this bind is deferred (it fires at @@ -138,14 +135,10 @@ export function subscribeToNestChannels(): void { // calls below stay synchronous because the decorator channels fire at // module-load time, which a deferred subscription could miss. waitForTracingChannelBinding(() => { - bindTracingChannelToSpan( - diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), - data => { - const moduleCls = data.arguments?.[0] as { name?: string } | undefined; - return startInactiveSpan(getAppCreationSpanOptions(data.moduleVersion, moduleCls?.name)); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.NESTJS_APP_CREATION), data => { + const moduleCls = data.arguments?.[0] as { name?: string } | undefined; + return startInactiveSpan(getAppCreationSpanOptions(data.moduleVersion, moduleCls?.name)); + }); }); // request_context + request_handler. `RouterExecutionContext.create` diff --git a/packages/server-utils/src/integrations/tracing-channel/redis.ts b/packages/server-utils/src/integrations/tracing-channel/redis.ts index 6a1d5e4d779e..6dedde81bafb 100644 --- a/packages/server-utils/src/integrations/tracing-channel/redis.ts +++ b/packages/server-utils/src/integrations/tracing-channel/redis.ts @@ -232,7 +232,6 @@ function bindNodeRedisCommandChannel( return startCommandSpan(commandName, wireArgs.slice(1), nodeRedisAttributes(options)); }, { - captureError: false, beforeSpanEnd(span, data) { if ('error' in data || !responseHook) { return; @@ -269,18 +268,14 @@ function getExecutorArgs(data: CommandContext): Array | undefin function bindNodeRedisConnectChannel(): void { const channel = diagnosticsChannel.tracingChannel(CHANNELS.NODE_REDIS_CONNECT); - bindTracingChannelToSpan( - channel, - data => { - const options = (data.self as NodeRedisClient | undefined)?.options; - return startInactiveSpan({ - name: 'redis-connect', - kind: SPAN_KIND.CLIENT, - attributes: { ...nodeRedisAttributes(options), [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(channel, data => { + const options = (data.self as NodeRedisClient | undefined)?.options; + return startInactiveSpan({ + name: 'redis-connect', + kind: SPAN_KIND.CLIENT, + attributes: { ...nodeRedisAttributes(options), [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db' }, + }); + }); } // Batch (multi/pipeline): one span per `exec`. Batched commands bypass `sendCommand`, so @@ -288,27 +283,23 @@ function bindNodeRedisConnectChannel(): void { // mirrors the native `node-redis:batch` span (see `redis-dc-subscriber.ts`). function bindNodeRedisBatchChannel(channelName: string, getOperation: (data: CommandContext) => string): void { const channel = diagnosticsChannel.tracingChannel(channelName); - bindTracingChannelToSpan( - channel, - data => { - const commands = data.arguments?.[0]; - const size = Array.isArray(commands) ? commands.length : undefined; - const socket = (data.self as NodeRedisClient | undefined)?.options?.socket; - return startInactiveSpan({ - name: getOperation(data), - kind: SPAN_KIND.CLIENT, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', - [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, - ...(size && size > 1 ? { [DB_OPERATION_BATCH_SIZE]: size } : {}), - ...(socket?.host != null ? { [SERVER_ADDRESS]: socket.host } : {}), - ...(socket?.port != null ? { [SERVER_PORT]: socket.port } : {}), - }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(channel, data => { + const commands = data.arguments?.[0]; + const size = Array.isArray(commands) ? commands.length : undefined; + const socket = (data.self as NodeRedisClient | undefined)?.options?.socket; + return startInactiveSpan({ + name: getOperation(data), + kind: SPAN_KIND.CLIENT, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [DB_SYSTEM_NAME]: DB_SYSTEM_VALUE_REDIS, + ...(size && size > 1 ? { [DB_OPERATION_BATCH_SIZE]: size } : {}), + ...(socket?.host != null ? { [SERVER_ADDRESS]: socket.host } : {}), + ...(socket?.port != null ? { [SERVER_PORT]: socket.port } : {}), + }, + }); + }); } const _redisChannelIntegration = ((options: RedisChannelIntegrationOptions = {}) => { diff --git a/packages/server-utils/src/redis/redis-dc-subscriber.ts b/packages/server-utils/src/redis/redis-dc-subscriber.ts index b1736cd74242..860f61138ae8 100644 --- a/packages/server-utils/src/redis/redis-dc-subscriber.ts +++ b/packages/server-utils/src/redis/redis-dc-subscriber.ts @@ -164,9 +164,6 @@ function setupCommandChannel( }); }, { - // Command failures are surfaced to (and usually handled by) the caller; only annotate the - // span so we don't emit a duplicate error event for every failed command. - captureError: false, beforeSpanEnd(span, data) { if ('error' in data) return; runResponseHook(responseHook, span, data.command, getCommandArgs(data), data.result); @@ -180,44 +177,36 @@ function setupBatchChannel( channelName: string, getOperationName: (data: RedisBatchData) => string, ): void { - bindTracingChannelToSpan( - tracingChannel(channelName), - data => { - return startInactiveSpan({ - name: getOperationName(data), - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', - [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, - // should only include batch size greater than 1, - // or else it isn't properly considered a "batch" - ...(Number(data.batchSize) > 1 ? { [DB_OPERATION_BATCH_SIZE]: data.batchSize } : {}), - ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), - ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), - }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(tracingChannel(channelName), data => { + return startInactiveSpan({ + name: getOperationName(data), + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + // should only include batch size greater than 1, + // or else it isn't properly considered a "batch" + ...(Number(data.batchSize) > 1 ? { [DB_OPERATION_BATCH_SIZE]: data.batchSize } : {}), + ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + }, + }); + }); } function setupConnectChannel(tracingChannel: RedisTracingChannelFactory, channelName: string): void { - bindTracingChannelToSpan( - tracingChannel(channelName), - data => { - return startInactiveSpan({ - name: 'redis-connect', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect', - [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, - ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), - ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), - }, - }); - }, - { captureError: false }, - ); + bindTracingChannelToSpan(tracingChannel(channelName), data => { + return startInactiveSpan({ + name: 'redis-connect', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.redis.connect', + [DB_SYSTEM_NAME]: DB_SYSTEM_NAME_VALUE_REDIS, + ...(data.serverAddress != null ? { [SERVER_ADDRESS]: data.serverAddress } : {}), + ...(data.serverPort != null ? { [SERVER_PORT]: data.serverPort } : {}), + }, + }); + }); } function runResponseHook( From 5fb95e1dac0f45b1de0b6eaad4dc02c12397f878 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 17 Jul 2026 17:16:19 +0200 Subject: [PATCH 44/54] chore(changelog): Add callout for SvelteKit 3 support (#22371) - adds a section for sveltekit 3 support (several prs contributed to this but I just linked the last one) - cleans up the changelog as there were some dangling entries in there that will be included anyway in the next release --------- Co-authored-by: Lukas Stracke --- CHANGELOG.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4da4d662db..b588cf99ab66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,11 @@ Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! -- feat(replay): Allow skipping the final flush when stopping recording via `stop({ flush: false })` ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) -- fix(cloudflare): Route Durable Object teardown through the original `waitUntil` to avoid a flush-lock deadlock that prevented WebSocket hibernation ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) -- fix(cloudflare): Bind Durable Object built-in handlers (`fetch`/`alarm`/`webSocket*`) per instance so a second instance in the same isolate no longer reuses the first instance's captured context and options ([#22328](https://github.com/getsentry/sentry-javascript/issues/22328)) +### Important Changes + +- **feat(sveltekit): Add support for SvelteKit 3 ([#22264](https://github.com/getsentry/sentry-javascript/pull/22264))** + + The SvelteKit SDK now supports SvelteKit 3, including client-side pageload and navigation tracing and server-side native tracing, alongside continued SvelteKit 2 support. No Sentry-specific setup changes are required. The SDK detects your SvelteKit version and picks the right implementation automatically. ## 10.66.0 From 237956ec5f1c29b7f5d5421ba7f9003f5ea12c1e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Jul 2026 11:20:50 -0400 Subject: [PATCH 45/54] docs(agents): Prefer reusing existing utils before adding new ones (#22370) Adds a item to `AGENTS.md` (`CLAUDE.md` symlinks to it) telling agents to search for an existing util before writing a new one, pointing at where shared helpers actually live (`packages/core/src/utils/`, `packages/browser-utils/`). Motivation: LLM-assisted changes have a habit of introducing near-duplicate helpers instead of reusing what's already there. This is a passive nudge rather than an enforced check. I did a quick check spawned a couple of agents on bogus tasks that needed a couple of utils, and watched its reasoning and it picked up on this, so while it doesn't enforce it, it may help us curb the util soup we get. This is a follow up to my previous util cleanup PRs #22155 #22163 #22154 --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 9c00f0403fb9..c39555fc1d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ Uses **Git Flow** (see `docs/gitflow.md`). ## Coding Standards - Follow existing conventions — check neighboring files +- Reach for existing utils before writing a new one. Most shared helpers live in `@sentry/core` (`packages/core/src/utils/`), with browser helpers in `packages/browser-utils/`. Search first (LSP `workspaceSymbol` or grep) for common needs (type guards in `is.ts`, object/array helpers, `normalize`, `dsn`, `merge`, string/url helpers). Reuse or extend the existing util rather than adding a near-duplicate; only introduce a new util when nothing fits. - Only use libraries already in the codebase - Never expose secrets or keys - When modifying files, cover all occurrences (including `src/` and `test/`) From 264b4f78cf9427a7aef390aaed0d4f8602fd040d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:16:34 +0200 Subject: [PATCH 46/54] fix(node): Register tracer provider when OTel API global pre-exists with a different version (#22374) ## What When the OTel API global registry (`Symbol.for('opentelemetry.js.api.1')`) pre-exists with a different `@opentelemetry/api` version, the SDK now replaces it and registers its tracer provider instead of silently disabling tracing. - Retry registration after replacing the registry, only when its `trace` slot is empty - Debug warning when the registry is replaced, pointing to `skipOpenTelemetrySetup` - Applies to both the default `SentryTracerProvider` and the `BasicTracerProvider` path ## Why Host runtimes like Neon Functions pre-create the registry with their own api version. OTel's `registerGlobal` requires an exact version match, so every registration is rejected and all spans (including `Sentry.startSpan()`) are non-recording, with no signal outside debug builds. Node flavor of the Deno issue fixed in #19723. The empty-`trace`-slot condition keeps existing behavior when a real provider is already registered (second `Sentry.init()`, user-managed OTel): those still back off as before, so no working setup gets clobbered. Closes: #22338 --------- Co-authored-by: Claude Fable 5 --- packages/node/src/sdk/initOtel.ts | 60 ++++++++++++++++++++++++- packages/node/test/sdk/init.test.ts | 69 +++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index b6936ab1e269..203d1c9568cb 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -1,3 +1,4 @@ +import type { TracerProvider } from '@opentelemetry/api'; import { context, propagation, trace } from '@opentelemetry/api'; import type { SpanProcessor } from '@opentelemetry/sdk-trace-base'; import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'; @@ -26,6 +27,61 @@ import { getOpenTelemetryInstrumentationToPreload } from '../integrations/tracin // About 277h - this must fit into new Array(len)! const MAX_MAX_SPAN_WAIT_DURATION = 1_000_000; +// The global registry of @opentelemetry/api 1.x, shared across all copies of the package +const OTEL_API_GLOBAL_KEY = Symbol.for('opentelemetry.js.api.1'); + +/** + * Registers the given tracer provider as the global tracer provider, recreating the OpenTelemetry + * API registry when it pre-exists with a different `@opentelemetry/api` version. + * + * Some host runtimes (e.g. Neon Functions) pre-create the registry with their own API version. + * `registerGlobal` requires an exact version match, so every registration through the SDK's copy + * of `@opentelemetry/api` is rejected and tracing is silently disabled + * (https://github.com/getsentry/sentry-javascript/issues/22338). This case is identified by a + * failed registration with an empty `trace` slot: `registerGlobal` checks the slot before the + * version, so an empty slot means the version gate rejected us and recreating the registry + * clobbers no other tracer provider. If the slot is occupied (another provider registered first, + * e.g. a second `Sentry.init()` call), the registry is left untouched and registration fails. + * + * Slots Sentry does not claim itself (e.g. `diag`, `metrics`) are carried over into the recreated + * registry: reads resolve via a semver-compatibility check rather than the exact-match write gate, + * so they keep working for the copy that registered them. `propagation` and `context` are not + * carried over because Sentry registers its own right after this. + */ +function registerGlobalTracerProvider(provider: TracerProvider): boolean { + if (trace.setGlobalTracerProvider(provider)) { + return true; + } + + // @opentelemetry/api stores the registry under a `Symbol.for` key that no public type + // describes, so `typeof globalThis` can only be narrowed to it by casting through `unknown`. + const otelGlobal = globalThis as unknown as Record | undefined>; + const registry = otelGlobal[OTEL_API_GLOBAL_KEY]; + if (registry && !registry.trace) { + DEBUG_BUILD && + coreDebug.warn( + 'Replaced a pre-existing OpenTelemetry API registry that was created by a different @opentelemetry/api version and would have blocked tracing. If you want to manage OpenTelemetry yourself, set `skipOpenTelemetrySetup: true` in `Sentry.init()`.', + ); + otelGlobal[OTEL_API_GLOBAL_KEY] = undefined; + + if (!trace.setGlobalTracerProvider(provider)) { + return false; + } + + // The cast is needed because TS still has the slot narrowed to `undefined` from the reset + // above and cannot know the registration call just recreated the registry. + const recreatedRegistry = otelGlobal[OTEL_API_GLOBAL_KEY] as Record | undefined; + if (recreatedRegistry) { + const { propagation: _propagation, context: _context, ...carriedOverSlots } = registry; + otelGlobal[OTEL_API_GLOBAL_KEY] = { ...carriedOverSlots, ...recreatedRegistry }; + } + + return true; + } + + return false; +} + interface AdditionalOpenTelemetryOptions { /** Additional SpanProcessor instances that should be used. */ spanProcessors?: SpanProcessor[]; @@ -118,7 +174,7 @@ export function setupOtel( }); // Register as globals - trace.setGlobalTracerProvider(provider); + registerGlobalTracerProvider(provider); propagation.setGlobalPropagator(new SentryPropagator()); const ctxManager = new SentryContextManager(); @@ -132,7 +188,7 @@ function setupSentryTracerProvider( ): [SentryTracerProvider | undefined, AsyncLocalStorageLookup | undefined] { const provider = new SentryTracerProvider({ resource: getSentryResource('node') }); - if (!trace.setGlobalTracerProvider(provider)) { + if (!registerGlobalTracerProvider(provider)) { DEBUG_BUILD && coreDebug.warn( 'Could not register SentryTracerProvider because another OpenTelemetry tracer provider is already registered.', diff --git a/packages/node/test/sdk/init.test.ts b/packages/node/test/sdk/init.test.ts index 1dd01361a2ab..95d9b9db1de9 100644 --- a/packages/node/test/sdk/init.test.ts +++ b/packages/node/test/sdk/init.test.ts @@ -14,6 +14,8 @@ declare var global: any; const PUBLIC_DSN = 'https://username@domain/123'; +const OTEL_API_GLOBAL_KEY = Symbol.for('opentelemetry.js.api.1'); + class MockIntegration implements Integration { public name: string; public setupOnce: Mock = vi.fn(); @@ -231,6 +233,73 @@ describe('init()', () => { expect(client?.traceProvider).toBeInstanceOf(BasicTracerProvider); }); + it('recreates the OTel API registry when it pre-exists with a different @opentelemetry/api version', () => { + // Simulate a host runtime (e.g. Neon Functions) pre-creating the registry with its own api version + global[OTEL_API_GLOBAL_KEY] = { version: '0.0.1' }; + + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + const registry = global[OTEL_API_GLOBAL_KEY]; + + expect(client?.traceProvider).toBeInstanceOf(SentryOpentelemetry.SentryTracerProvider); + expect(registry?.version).not.toBe('0.0.1'); + expect(registry?.trace).toBeDefined(); + }); + + it('recreates a version-mismatched OTel API registry also for the OpenTelemetry SDK tracer provider', () => { + global[OTEL_API_GLOBAL_KEY] = { version: '0.0.1' }; + + init({ dsn: PUBLIC_DSN, openTelemetryBasicTracerProvider: true }); + + const client = getClient(); + const registry = global[OTEL_API_GLOBAL_KEY]; + + expect(client?.traceProvider).toBeInstanceOf(BasicTracerProvider); + expect(registry?.version).not.toBe('0.0.1'); + expect(registry?.trace).toBeDefined(); + }); + + it('carries non-Sentry slots of a version-mismatched OTel API registry over into the recreated one', () => { + // Must be a complete DiagLogger: once carried over, the SDK's api copy resolves it and + // calls it for its own diag output. + const diagLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn(), verbose: vi.fn() }; + const meterProvider = { getMeter: vi.fn() }; + const propagator = { inject: vi.fn() }; + global[OTEL_API_GLOBAL_KEY] = { + version: '0.0.1', + diag: diagLogger, + metrics: meterProvider, + propagation: propagator, + }; + + init({ dsn: PUBLIC_DSN }); + + const registry = global[OTEL_API_GLOBAL_KEY]; + + expect(registry?.trace).toBeDefined(); + expect(registry?.diag).toBe(diagLogger); + expect(registry?.metrics).toBe(meterProvider); + // propagation is claimed by Sentry's own propagator, not carried over + expect(registry?.propagation).not.toBe(propagator); + }); + + it('does not recreate the OTel API registry when another tracer provider is already registered', () => { + const existingProvider = { getTracer: vi.fn() }; + const existingRegistry = { version: '0.0.1', trace: existingProvider }; + global[OTEL_API_GLOBAL_KEY] = existingRegistry; + + init({ dsn: PUBLIC_DSN }); + + const client = getClient(); + + expect(client?.traceProvider).not.toBeDefined(); + expect(global[OTEL_API_GLOBAL_KEY]).toBe(existingRegistry); + expect(existingRegistry.trace).toBe(existingProvider); + + global[OTEL_API_GLOBAL_KEY] = undefined; + }); + it('does not mark SentryTracerProvider as set up when global registration fails', () => { // Simulate another OpenTelemetry tracer provider already being registered. const setGlobalSpy = vi.spyOn(trace, 'setGlobalTracerProvider').mockReturnValue(false); From 1e26f76ade9091694a77ea93340ee738d71dd094 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 17 Jul 2026 11:55:30 -0700 Subject: [PATCH 47/54] feat(mongodb): implement orchestrion mongodb integration (#22308) Fix: JS-2411 Fix: #20760 --- .../suites/tracing/mongodb-v4/instrument.mjs | 9 + .../suites/tracing/mongodb-v4/scenario.mjs | 49 ++++ .../suites/tracing/mongodb-v4/test.ts | 76 ++++++ .../suites/tracing/mongodb-v5/instrument.mjs | 9 + .../suites/tracing/mongodb-v5/scenario.mjs | 49 ++++ .../suites/tracing/mongodb-v5/test.ts | 76 ++++++ .../suites/tracing/mongodb-v6/instrument.mjs | 9 + .../suites/tracing/mongodb-v6/scenario.mjs | 37 +++ .../suites/tracing/mongodb-v6/test.ts | 73 ++++++ .../suites/tracing/mongodb-v7/instrument.mjs | 9 + .../suites/tracing/mongodb-v7/scenario.mjs | 37 +++ .../suites/tracing/mongodb-v7/test.ts | 74 ++++++ .../suites/tracing/mongodb/test.ts | 26 +- .../tracing/mongoose-tracing-channel/test.ts | 5 +- .../suites/tracing/mongoose/test.ts | 3 +- packages/deno/src/index.ts | 1 + packages/deno/src/integrations/mongo.ts | 34 +++ packages/deno/src/sdk.ts | 2 + .../deno/test/__snapshots__/mod.test.ts.snap | 4 + .../tracing/mongo/vendored/patches.ts | 6 +- .../tracing/mongo/vendored/semconv.ts | 33 --- .../tracing/mongo/vendored/utils.ts | 206 ++-------------- packages/server-utils/src/index.ts | 7 + .../integrations/tracing-channel/mongodb.ts | 183 ++++++++++++++ .../server-utils/src/mongodb/mongodb-span.ts | 229 ++++++++++++++++++ .../src/orchestrion/config/mongodb.ts | 72 +++++- .../server-utils/src/orchestrion/index.ts | 3 + 27 files changed, 1077 insertions(+), 244 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts create mode 100644 packages/deno/src/integrations/mongo.ts delete mode 100644 packages/node/src/integrations/tracing/mongo/vendored/semconv.ts create mode 100644 packages/server-utils/src/integrations/tracing-channel/mongodb.ts create mode 100644 packages/server-utils/src/mongodb/mongodb-span.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs new file mode 100644 index 000000000000..70a62c9b9295 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/scenario.mjs @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +// maxPoolSize: 1 so the concurrent block below contends for the single +// pooled connection. the queued checkouts resolve from the pool's detached +// context, exercising the checkout context patch. +const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + + // Pool-contention: each op runs under its own span. If the pooled + // checkout loses the caller's context, a queued op's command span + // would parent to a sibling op instead of its own span. + await Promise.all( + ['a', 'b', 'c'].map(marker => + Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })), + ), + ); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts new file mode 100644 index 000000000000..28f5d5abb64b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v4/test.ts @@ -0,0 +1,76 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 4 so the = 4.0 <6.4 callback-based command band and the +// pool-checkout context propagation are exercised against a real mongodb. +describe('MongoDB v4 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + + // Checkout context propagation: each `op-*` span must be the + // parent of its own find command. A lost context would collapse + // them onto one parent (or the transaction). + const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id)); + expect(opIds.size).toBe(3); + const pooledFinds = spans.filter( + s => + s.origin === origin && + (s.data as Record)?.['db.operation'] === 'find' && + opIds.has(s.parent_span_id as string), + ); + expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '4.17.2' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs new file mode 100644 index 000000000000..9244d97eba47 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/scenario.mjs @@ -0,0 +1,49 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +// maxPoolSize: 1 so the concurrent block below contends for the single +// pooled connection. The queued checkouts resolve from the pool's detached +// context, exercising the checkout context patch. +const client = new MongoClient(process.env.MONGO_URL || '', { maxPoolSize: 1 }); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + + // Pool-contention: each op runs under its own span. If the pooled + // checkout loses the caller's context, a queued op's command span + // would parent to a sibling op instead of its own span. + await Promise.all( + ['a', 'b', 'c'].map(marker => + Sentry.startSpan({ name: `op-${marker}` }, () => collection.findOne({ marker })), + ), + ); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts new file mode 100644 index 000000000000..6b0c67965da9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v5/test.ts @@ -0,0 +1,76 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 5 so the >= 4.0 < 6.4 callback-based command band and the +// pool-checkout context propagation are exercised against a real mongodb. +describe('MongoDB v5 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments `mongodb` (>= 4.0 < 6.4 callback command) and parents pooled ops correctly.', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + + // Checkout context propagation: each `op-*` span must be the + // parent of its own find command. A lost context would collapse + // them onto one parent (or the transaction). + const opIds = new Set(spans.filter(s => /^op-[abc]$/.test(s.description ?? '')).map(s => s.span_id)); + expect(opIds.size).toBe(3); + const pooledFinds = spans.filter( + s => + s.origin === origin && + (s.data as Record)?.['db.operation'] === 'find' && + opIds.has(s.parent_span_id as string), + ); + expect(new Set(pooledFinds.map(s => s.parent_span_id)).size).toBe(3); + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '5.9.2' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs new file mode 100644 index 000000000000..dad734bdbe5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/scenario.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +const client = new MongoClient(process.env.MONGO_URL || ''); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts new file mode 100644 index 000000000000..9ad2263efc0f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v6/test.ts @@ -0,0 +1,73 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, describe, expect } from 'vitest'; +import { isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 6 so the >= 6.4 promise-based `Connection.prototype.command` +// band is exercised against a real mongodb. +describe('MongoDB v6 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + // `db.statement` (scrubbed full command doc) and `db.connection_string` vary + // by driver version, so assert their presence rather than exact content; + // the operation-identifying attributes are exact. + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments modern `mongodb` (>= 6.4 promise command).', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + // No orphaned handshake/heartbeat spans! + // every command span has a parent. + const mongoSpans = spans.filter(s => s.origin === origin); + expect(mongoSpans.length).toBeGreaterThan(0); + for (const s of mongoSpans) { + expect(s.parent_span_id).toBeTruthy(); + } + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '6.21.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs new file mode 100644 index 000000000000..dad734bdbe5b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/scenario.mjs @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/node'; +import mongodb from 'mongodb'; + +const { MongoClient } = mongodb; + +const client = new MongoClient(process.env.MONGO_URL || ''); + +async function run() { + await Sentry.startSpan( + { + name: 'Test Transaction', + op: 'transaction', + }, + async () => { + try { + await client.connect(); + + const collection = client.db('admin').collection('movies'); + + await collection.insertOne({ title: 'Rick and Morty' }); + await collection.findOne({ title: 'Back to the Future' }); + await collection.updateOne({ title: 'Back to the Future' }, { $set: { title: 'South Park' } }); + await collection.find({ title: 'South Park' }).toArray(); + + // A query the server rejects, to exercise the error-status span path. + await collection + .find({ $thisOperatorDoesNotExist: 1 }) + .toArray() + .catch(() => {}); + } finally { + await client.close(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts new file mode 100644 index 000000000000..f6c47b9a4f67 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb-v7/test.ts @@ -0,0 +1,74 @@ +import type { TransactionEvent } from '@sentry/core'; +import { MongoMemoryServer } from 'mongodb-memory-server-global'; +import { afterAll, beforeAll, expect } from 'vitest'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// Pins mongodb 7 so the >= 6.4 promise-based `Connection.prototype.command` +// band is exercised against a real mongodb. mongodb 7 requires Node >= 20.19, so this suite is +// skipped on older Node (on Node 18 the driver throws `ReferenceError: crypto is not defined`). +conditionalTest({ min: 20 })('MongoDB v7 auto-instrumentation', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URL = mongoServer.getUri(); + }, 30000); + + afterAll(async () => { + if (mongoServer) { + await mongoServer.stop(); + } + cleanupChildProcesses(); + }); + + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; + + // `db.statement` (scrubbed full command doc) and `db.connection_string` vary + // by driver version, so assert their presence rather than exact content; + // the operation-identifying attributes are exact. + const spanFor = (operation: string): unknown => + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.origin': origin, + 'sentry.op': 'db', + 'db.system': 'mongodb', + 'db.name': 'admin', + 'db.mongodb.collection': 'movies', + 'db.operation': operation, + 'db.connection_string': expect.any(String), + 'db.statement': expect.any(String), + }), + op: 'db', + origin, + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('auto-instruments modern `mongodb` (>= 6.4 promise command).', async () => { + await createTestRunner() + .expect({ + transaction: (event: TransactionEvent) => { + const spans = event.spans || []; + expect(spans).toContainEqual(spanFor('insert')); + expect(spans).toContainEqual(spanFor('find')); + expect(spans).toContainEqual(spanFor('update')); + // No orphaned handshake/heartbeat spans! + // every command span has a parent. + const mongoSpans = spans.filter(s => s.origin === origin); + expect(mongoSpans.length).toBeGreaterThan(0); + for (const s of mongoSpans) { + expect(s.parent_span_id).toBeTruthy(); + } + }, + }) + .start() + .completed(); + }); + }, + { additionalDependencies: { mongodb: '7.5.0' } }, + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts index 68a32df6181b..5c88a8de18da 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongodb/test.ts @@ -2,9 +2,11 @@ import type { TransactionEvent } from '@sentry/core'; import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, describe, expect } from 'vitest'; import { assertSentryTransaction } from '../../../utils/assertions'; +import { isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('MongoDB auto-instrumentation', () => { + const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -21,7 +23,7 @@ describe('MongoDB auto-instrumentation', () => { const SPAN_FIND_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -35,12 +37,12 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_INSERT_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -54,12 +56,12 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?","_id":{"_bsontype":"?","id":"?"}}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_ISMASTER_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -75,12 +77,12 @@ describe('MongoDB auto-instrumentation', () => { description: '{"ismaster":"?","client":{"driver":{"name":"?","version":"?"},"os":{"type":"?","name":"?","architecture":"?","version":"?"},"platform":"?"},"compression":[],"helloOk":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); const SPAN_UPDATE_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -94,13 +96,13 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"title":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); // A query the server rejects: same attributes as a successful find, but with an error status. const SPAN_FIND_ERROR_MATCHER = expect.objectContaining({ data: expect.objectContaining({ - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.operation': 'find', @@ -109,13 +111,13 @@ describe('MongoDB auto-instrumentation', () => { }), description: '{"$thisOperatorDoesNotExist":"?"}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, status: 'internal_error', }); const SPAN_ENDSESSIONS_MATCHER = expect.objectContaining({ data: { - 'sentry.origin': 'auto.db.otel.mongo', + 'sentry.origin': origin, 'sentry.op': 'db', 'db.system': 'mongodb', 'db.name': 'admin', @@ -128,7 +130,7 @@ describe('MongoDB auto-instrumentation', () => { }, description: '{"endSessions":[{"id":{"_bsontype":"?","sub_type":"?","position":"?","buffer":"?"}}]}', op: 'db', - origin: 'auto.db.otel.mongo', + origin, }); createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => { diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts index dbacf38050e4..c5adf265bf52 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose-tracing-channel/test.ts @@ -1,6 +1,6 @@ import { MongoMemoryServer } from 'mongodb-memory-server-global'; import { afterAll, beforeAll, expect } from 'vitest'; -import { conditionalTest } from '../../../utils'; +import { conditionalTest, isOrchestrionEnabled } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; // mongoose >= 9.7.0 publishes its operations via `node:diagnostics_channel`, so the SDK subscribes @@ -9,6 +9,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // query text, span relationships, and that the legacy IITM patcher does NOT also fire (no double // instrumentation). mongoose 9 requires Node >=20.19, so this suite is skipped on older Node. conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => { + const driverOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -104,7 +105,7 @@ conditionalTest({ min: 20 })('Mongoose tracing channel Test', () => { // the underlying mongodb driver span must parent to the mongoose channel span, // proving the channel span is the active async context for the traced operation const driverChild = spans.find( - span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo', + span => span.parent_span_id === mongooseSave?.span_id && span.origin === driverOrigin, ); expect(driverChild).toBeDefined(); }, diff --git a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts index b3a2b709c701..2a1bf262ab02 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mongoose/test.ts @@ -5,6 +5,7 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn describe('Mongoose experimental Test', () => { const origin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongoose' : 'auto.db.otel.mongoose'; + const driverOrigin = isOrchestrionEnabled() ? 'auto.db.orchestrion.mongo' : 'auto.db.otel.mongo'; let mongoServer: MongoMemoryServer; beforeAll(async () => { @@ -117,7 +118,7 @@ describe('Mongoose experimental Test', () => { expect(mongooseSave).toBeDefined(); // the underlying mongodb driver span must be parented to the mongoose span const driverChild = spans.find( - span => span.parent_span_id === mongooseSave?.span_id && span.origin === 'auto.db.otel.mongo', + span => span.parent_span_id === mongooseSave?.span_id && span.origin === driverOrigin, ); expect(driverChild).toBeDefined(); }, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 4a33a171fcaf..58611921021d 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -117,6 +117,7 @@ export { denoAmqplibIntegration } from './integrations/amqplib'; export { denoDataloaderIntegration } from './integrations/dataloader'; export { denoKnexIntegration } from './integrations/knex'; export { denoKoaIntegration } from './integrations/koa'; +export { denoMongoIntegration } from './integrations/mongo'; export { denoMongooseIntegration } from './integrations/mongoose'; export { denoContextIntegration } from './integrations/context'; export { globalHandlersIntegration } from './integrations/globalhandlers'; diff --git a/packages/deno/src/integrations/mongo.ts b/packages/deno/src/integrations/mongo.ts new file mode 100644 index 000000000000..15d6d1e9e028 --- /dev/null +++ b/packages/deno/src/integrations/mongo.ts @@ -0,0 +1,34 @@ +import { mongodbChannelIntegration } from '@sentry/server-utils/orchestrion'; +import type { Integration, IntegrationFn } from '@sentry/core'; +import { defineIntegration, extendIntegration } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '../async'; + +const INTEGRATION_NAME = 'DenoMongo' as const; + +/** + * Create spans for `mongodb` queries under Deno. + * + * `mongodb` channels are injected by the orchestrion runtime hook at load time. + * The `@sentry/deno/import` loader must be active for this integration to + * record anything. + * + * The channel-subscription logic is shared with the other server runtimes in + * `@sentry/server-utils`. This just installs Deno's + * `AsyncLocalStorage` context strategy (so spans nest under the active + * span and survive mongodb's internal callback dispatch) before delegating. + */ +const _denoMongoIntegration = (() => { + const inner = mongodbChannelIntegration(); + + return extendIntegration(inner, { + name: INTEGRATION_NAME, + setupOnce() { + setAsyncLocalStorageAsyncContextStrategy(); + }, + }); +}) satisfies IntegrationFn; + +export const denoMongoIntegration = defineIntegration(_denoMongoIntegration) as () => Integration & { + name: 'DenoMongo'; + setupOnce: () => void; +}; diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index ce743f713b59..d9a75982b667 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -25,6 +25,7 @@ import { denoServeIntegration } from './integrations/deno-serve'; import { denoHttpIntegration } from './integrations/http'; import { denoAmqplibIntegration } from './integrations/amqplib'; import { denoKoaIntegration } from './integrations/koa'; +import { denoMongoIntegration } from './integrations/mongo'; import { denoMongooseIntegration } from './integrations/mongoose'; import { denoMysqlIntegration } from './integrations/mysql'; import { denoPostgresIntegration } from './integrations/postgres'; @@ -67,6 +68,7 @@ export function getDefaultIntegrations(_options: Options): Integration[] { ? [ denoAmqplibIntegration(), denoKoaIntegration(), + denoMongoIntegration(), denoMongooseIntegration(), denoMysqlIntegration(), denoPostgresIntegration(), diff --git a/packages/deno/test/__snapshots__/mod.test.ts.snap b/packages/deno/test/__snapshots__/mod.test.ts.snap index fe5046625075..7da66392eb30 100644 --- a/packages/deno/test/__snapshots__/mod.test.ts.snap +++ b/packages/deno/test/__snapshots__/mod.test.ts.snap @@ -117,6 +117,7 @@ snapshot[`captureException 1`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", @@ -197,6 +198,7 @@ snapshot[`captureMessage 1`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", @@ -284,6 +286,7 @@ snapshot[`captureMessage twice 1`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", @@ -378,6 +381,7 @@ snapshot[`captureMessage twice 2`] = ` "DenoRedis", "DenoAmqplib", "DenoKoa", + "DenoMongo", "DenoMongoose", "DenoMysql", "DenoPostgres", diff --git a/packages/node/src/integrations/tracing/mongo/vendored/patches.ts b/packages/node/src/integrations/tracing/mongo/vendored/patches.ts index 1d1e7ef20cc7..ea29755c32d5 100644 --- a/packages/node/src/integrations/tracing/mongo/vendored/patches.ts +++ b/packages/node/src/integrations/tracing/mongo/vendored/patches.ts @@ -18,9 +18,8 @@ import type { V4ConnectionPool, WireProtocolInternal, } from './internal-types'; -import { MongodbCommandType } from './internal-types'; import { - getCommandType, + getV3CommandOperation, getV3SpanAttributes, getV4SpanAttributes, patchEnd, @@ -82,8 +81,7 @@ export function getV3PatchCommand() { } } - const commandType = getCommandType(cmd); - const operationName = commandType === MongodbCommandType.UNKNOWN ? undefined : commandType; + const operationName = getV3CommandOperation(cmd as unknown as Record); const span = startMongoSpan(getV3SpanAttributes(ns, server, cmd, operationName)); const patchedCallback = patchEnd(span, resultHandler); diff --git a/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts b/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts deleted file mode 100644 index 1a4c1680e84f..000000000000 --- a/packages/node/src/integrations/tracing/mongo/vendored/semconv.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - * - * NOTICE from the Sentry authors: - * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongodb - * - Upstream version: @opentelemetry/instrumentation-mongodb@0.71.0 - */ - -/* - * This file contains a copy of unstable semantic convention definitions - * used by this package. - * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv - */ - -/** - * Deprecated, use `server.address`, `server.port` attributes instead. - * - * @deprecated Replaced by `server.address` and `server.port`. - */ -export const ATTR_DB_CONNECTION_STRING = 'db.connection_string' as const; - -/** - * Deprecated, use `db.collection.name` instead. - * - * @deprecated Replaced by `db.collection.name`. - */ -export const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection' as const; - -/** - * Enum value "mongodb" for attribute `db.system`. - */ -export const DB_SYSTEM_VALUE_MONGODB = 'mongodb' as const; diff --git a/packages/node/src/integrations/tracing/mongo/vendored/utils.ts b/packages/node/src/integrations/tracing/mongo/vendored/utils.ts index 6c1430c0ce16..22876deae063 100644 --- a/packages/node/src/integrations/tracing/mongo/vendored/utils.ts +++ b/packages/node/src/integrations/tracing/mongo/vendored/utils.ts @@ -6,205 +6,33 @@ * - Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/tree/15ef7506553f631ea4181391e0c5725a56f0d082/packages/instrumentation-mongodb * - Upstream version: @opentelemetry/instrumentation-mongodb@0.71.0 * - Refactored to use Sentry's span APIs instead of OpenTelemetry tracing APIs + * - The db/net attribute extraction, `db.statement` scrubbing and span + * builder are shared with the orchestrion mongodb integration in + * `@sentry/server-utils` so the two emit an identical span shape. + * Only the OTel-specific callback/context helpers below remain here. */ import type { Span, SpanAttributes } from '@sentry/core'; +import { getActiveSpan, SPAN_STATUS_ERROR, withActiveSpan } from '@sentry/core'; import { - getActiveSpan, - isObjectLike, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_KIND, - SPAN_STATUS_ERROR, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; -import { - DB_NAME, - DB_OPERATION, - DB_STATEMENT, - DB_SYSTEM, - NET_PEER_NAME, - NET_PEER_PORT, -} from '@sentry/conventions/attributes'; -import { ATTR_DB_CONNECTION_STRING, ATTR_DB_MONGODB_COLLECTION, DB_SYSTEM_VALUE_MONGODB } from './semconv'; -import type { MongodbNamespace, MongoInternalCommand, MongoInternalTopology } from './internal-types'; -import { MongodbCommandType } from './internal-types'; + getV3CommandOperation, + getV3SpanAttributes as sharedGetV3SpanAttributes, + getV4SpanAttributes as sharedGetV4SpanAttributes, + startMongoSpan, +} from '@sentry/server-utils'; const ORIGIN = 'auto.db.otel.mongo'; -/** - * Replaces values in the command object with '?', hiding PII and helping grouping. - */ -function serializeDbStatement(commandObj: Record): string { - return JSON.stringify(scrubStatement(commandObj)); -} - -function scrubStatement(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(element => scrubStatement(element)); - } - - if (isCommandObj(value)) { - const initial: Record = {}; - return Object.entries(value) - .map(([key, element]) => [key, scrubStatement(element)]) - .reduce((prev, current) => { - if (isCommandEntry(current)) { - prev[current[0]] = current[1]; - } - return prev; - }, initial); - } - - // A value like string or number, possibly contains PII, scrub it - return '?'; -} - -function isCommandObj(value: Record | unknown): value is Record { - return isObjectLike(value) && !isBuffer(value); -} - -function isBuffer(value: unknown): boolean { - return typeof Buffer !== 'undefined' && Buffer.isBuffer(value); -} - -function isCommandEntry(value: [string, unknown] | unknown): value is [string, unknown] { - return Array.isArray(value); -} - -/** - * Get the mongodb command type from the object. - */ -export function getCommandType(command: MongoInternalCommand): MongodbCommandType { - if (command.createIndexes !== undefined) { - return MongodbCommandType.CREATE_INDEXES; - } else if (command.findandmodify !== undefined) { - return MongodbCommandType.FIND_AND_MODIFY; - } else if (command.ismaster !== undefined) { - return MongodbCommandType.IS_MASTER; - } else if (command.count !== undefined) { - return MongodbCommandType.COUNT; - } else if (command.aggregate !== undefined) { - return MongodbCommandType.AGGREGATE; - } else { - return MongodbCommandType.UNKNOWN; - } -} - -/** - * Determine a span's attributes by fetching related metadata from the v4 connection context. - */ -export function getV4SpanAttributes( - connectionCtx: any, - ns: MongodbNamespace, - command?: any, - operation?: string, -): SpanAttributes { - let host, port: undefined | string; - if (connectionCtx) { - const hostParts = typeof connectionCtx.address === 'string' ? connectionCtx.address.split(':') : ''; - if (hostParts.length === 2) { - host = hostParts[0]; - port = hostParts[1]; - } - } - let commandObj: Record; - if (command?.documents && command.documents[0]) { - commandObj = command.documents[0]; - } else if (command?.cursors) { - commandObj = command.cursors; - } else { - commandObj = command; - } - - return getSpanAttributes(ns.db, ns.collection, host, port, commandObj, operation); -} - -/** - * Determine a span's attributes by fetching related metadata from the v3 topology. - */ -export function getV3SpanAttributes( - ns: string, - topology: MongoInternalTopology, - command?: MongoInternalCommand, - operation?: string | undefined, -): SpanAttributes { - let host: undefined | string; - let port: undefined | string; - if (topology?.s) { - host = topology.s.options?.host ?? topology.s.host; - port = (topology.s.options?.port ?? topology.s.port)?.toString(); - if (host == null || port == null) { - const address = topology.description?.address; - if (address) { - const addressSegments = address.split(':'); - host = addressSegments[0]; - port = addressSegments[1]; - } - } - } - - // The namespace is a combination of the database name and the name of the - // collection or index, like so: [database-name].[collection-or-index-name]. - // It could be a string or an instance of MongoDBNamespace, as such we - // always coerce to a string to extract db and collection. - const [dbName, dbCollection] = ns.toString().split('.'); - const commandObj = command?.query ?? command?.q ?? command; - - return getSpanAttributes(dbName, dbCollection, host, port, commandObj, operation); -} - -function getSpanAttributes( - dbName?: string, - dbCollection?: string, - host?: undefined | string, - port?: undefined | string, - commandObj?: any, - operation?: string | undefined, -): SpanAttributes { - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - // eslint-disable-next-line typescript/no-deprecated - [DB_SYSTEM]: DB_SYSTEM_VALUE_MONGODB, - // eslint-disable-next-line typescript/no-deprecated - [DB_NAME]: dbName, - // eslint-disable-next-line typescript/no-deprecated - [ATTR_DB_MONGODB_COLLECTION]: dbCollection, - // eslint-disable-next-line typescript/no-deprecated - [DB_OPERATION]: operation, - // eslint-disable-next-line typescript/no-deprecated - [ATTR_DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`, - }; - - if (host && port) { - // eslint-disable-next-line typescript/no-deprecated - attributes[NET_PEER_NAME] = host; - const portNumber = parseInt(port, 10); - if (!isNaN(portNumber)) { - // eslint-disable-next-line typescript/no-deprecated - attributes[NET_PEER_PORT] = portNumber; - } - } - - if (commandObj) { - try { - // eslint-disable-next-line typescript/no-deprecated - attributes[DB_STATEMENT] = serializeDbStatement(commandObj); - } catch { - // ignore serialization errors — the statement is best-effort metadata - } - } +export { getV3CommandOperation, startMongoSpan }; - return attributes; +/** Determine a span's attributes from the v4 connection context (OTel origin). */ +export function getV4SpanAttributes(connectionCtx: any, ns: any, command?: any, operation?: string): SpanAttributes { + return sharedGetV4SpanAttributes(connectionCtx, ns, command, operation, ORIGIN); } -export function startMongoSpan(attributes: SpanAttributes): Span { - return startInactiveSpan({ - // eslint-disable-next-line typescript/no-deprecated - name: `mongodb.${attributes[DB_OPERATION] || 'command'}`, - kind: SPAN_KIND.CLIENT, - attributes, - }); +/** Determine a span's attributes from the v3 topology (OTel origin). */ +export function getV3SpanAttributes(ns: string, topology: any, command?: any, operation?: string): SpanAttributes { + return sharedGetV3SpanAttributes(ns, topology, command, operation, ORIGIN); } /** diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 2e7cd578ee94..7030a0cca557 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -7,6 +7,13 @@ export { graphqlIntegration } from './graphql'; export { mongooseIntegration, startMongooseLegacySpan } from './mongoose'; export type { MongooseLegacyCollection, StartMongooseLegacySpanOptions } from './mongoose'; +export { + getV3CommandOperation, + getV3SpanAttributes, + getV4SpanAttributes, + startMongoSpan, +} from './mongodb/mongodb-span'; +export type { MongodbNamespace, MongoV3Topology } from './mongodb/mongodb-span'; export { mysql2Integration } from './mysql2'; export { instrumentPrisma, prismaIntegration } from './prisma'; export type { PrismaInstrumentationConfig, PrismaOptions } from './prisma'; diff --git a/packages/server-utils/src/integrations/tracing-channel/mongodb.ts b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts new file mode 100644 index 000000000000..85899d910792 --- /dev/null +++ b/packages/server-utils/src/integrations/tracing-channel/mongodb.ts @@ -0,0 +1,183 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, waitForTracingChannelBinding } from '@sentry/core'; +import type { MongodbNamespace, MongoV3Topology } from '../../mongodb/mongodb-span'; +import { + getV3CommandOperation, + getV3SpanAttributes, + getV4SpanAttributes, + startMongoSpan, +} from '../../mongodb/mongodb-span'; +import { CHANNELS } from '../../orchestrion/channels'; +import { bindTracingChannelToSpan } from '../../tracing-channel'; + +const INTEGRATION_NAME = 'Mongo' as const; + +const ORIGIN = 'auto.db.orchestrion.mongo'; + +/** + * what orchestrion's transform attaches to a channel context: + * `self` is the `this`, plus args. + */ +interface MongoChannelContext { + self?: { address?: string }; + arguments?: unknown[]; +} + +// Details extracted from a v3 wireprotocol call's arguments to build its span. +interface V3CallInfo { + topology: MongoV3Topology | undefined; + ns: string; + command: Record | undefined; + operation: string | undefined; +} + +// Command doc first-keys whose span the `v3_command` channel must suppress. +// The `insert`/`update`/`remove`/`query`/`getMore` functions call the shared +// `command` function internally. Orchestrion transforms the `command` *source* +// (unlike the vendored OTel patch, which wraps the module's exported reference +// and misses internal calls), so `v3_command` would double-span the ones that +// already have their own dedicated channel (`v3_insert`/`update`/`remove`/ +// `query`/`get_more`) without this guard. +// +// `killCursors` has no dedicated channel, but is suppressed too for OTel +// parity: OTel wraps `wireprotocol/index.js`'s `command` property, which never +// intercepts `kill_cursors.js`'s direct `require('./command')` call — so OTel +// emits no killCursors span, and neither should we. +const V3_DEDICATED_COMMANDS = new Set(['insert', 'update', 'delete', 'find', 'getMore', 'killCursors']); + +const _mongodbChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + subscribeV4Command(); + subscribeV4Checkout(); + subscribeV3Wireprotocol(); + }); + }, + }; +}) satisfies IntegrationFn; + +/** + * `Connection.prototype.command` (mongodb >=4.0) one span per command. + * Handles both the >=6.4 promise form and the >=4.0 <6.4 callback form + * (both publish to this channel). + */ +function subscribeV4Command(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_COMMAND), + data => { + const args = data.arguments ?? []; + const ns = args[0] as MongodbNamespace | undefined; + const cmd = args[1] as Record | undefined; + // Skip handshake/heartbeat commands (matches otel). + if (!ns || !cmd || typeof cmd !== 'object' || cmd.ismaster || cmd.hello) { + return undefined; + } + const operation = Object.keys(cmd)[0]; + return startMongoSpan(getV4SpanAttributes(data.self, ns, cmd, operation, ORIGIN)); + }, + // Matches otel's `shouldSkipInstrumentation`: only trace when there is + // an active parent span, to avoid emitting orphaned mongodb spans. + { requiresParentSpan: true }, + ); +} + +/** + * `ConnectionPool.prototype.checkOut` (mongodb 4.0 - 6.3, callback form). + * Creates no span (`getSpan` returns `undefined`) but the binding re-runs + * the checkout callback under the caller's captured context, so the pooled + * `command()` invoked inside it re-inherits the active span. + */ +function subscribeV4Checkout(): void { + bindTracingChannelToSpan(diagnosticsChannel.tracingChannel(CHANNELS.MONGODB_CHECKOUT), () => undefined); +} + +/** + * mongodb >=3.3 <4 had no unified `command`; each operation is a separate + * `lib/core/wireprotocol` function with its own argument layout, so each + * channel extracts topology/namespace/command/op differently before building + * the span. + */ +function subscribeV3Wireprotocol(): void { + for (const operation of ['insert', 'update', 'remove'] as const) { + const channel = + operation === 'insert' + ? CHANNELS.MONGODB_V3_INSERT + : operation === 'update' + ? CHANNELS.MONGODB_V3_UPDATE + : CHANNELS.MONGODB_V3_REMOVE; + bindV3(channel, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: (args[2] as Record[] | undefined)?.[0], + operation, + })); + } + + // `command`(server, ns, cmd, options, callback) operation derived from the + // command doc. Skips commands that have a dedicated channel. See set above. + bindV3(CHANNELS.MONGODB_V3_COMMAND, args => { + const command = args[2] as Record | undefined; + const type = command ? Object.keys(command)[0] : undefined; + if (type && V3_DEDICATED_COMMANDS.has(type)) { + return undefined; + } + return { + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command, + operation: command ? getV3CommandOperation(command) : undefined, + }; + }); + + // `query`(server, ns, cmd, cursorState, options, callback). a find. + bindV3(CHANNELS.MONGODB_V3_QUERY, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: args[2] as Record | undefined, + operation: 'find', + })); + + // `getMore`(server, ns, cursorState, batchSize, options, callback) + // command doc is `cursorState.cmd`. + bindV3(CHANNELS.MONGODB_V3_GET_MORE, args => ({ + topology: args[0] as MongoV3Topology | undefined, + ns: args[1] as string, + command: (args[2] as { cmd?: Record } | undefined)?.cmd, + operation: 'getMore', + })); +} + +function bindV3(channelName: string, extract: (args: unknown[]) => V3CallInfo | undefined): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channelName), + data => { + const args = data.arguments; + if (!args) { + return undefined; + } + const info = extract(args); + if (!info || typeof info.ns !== 'string') { + return undefined; + } + return startMongoSpan(getV3SpanAttributes(info.ns, info.topology, info.command, info.operation, ORIGIN)); + }, + { requiresParentSpan: true }, + ); +} + +/** + * EXPERIMENTAL: orchestrion-driven mongodb integration. + * + * Reproduces the vendored `@opentelemetry/instrumentation-mongodb` span shape + * (legacy db/net semantic conventions, `mongodb.` names, scrubbed + * `db.statement`) via the `orchestrion:mongodb:*` diagnostics_channels + * injected by the orchestrion code transform. + */ +export const mongodbChannelIntegration = defineIntegration(_mongodbChannelIntegration); diff --git a/packages/server-utils/src/mongodb/mongodb-span.ts b/packages/server-utils/src/mongodb/mongodb-span.ts new file mode 100644 index 000000000000..3904274a99f3 --- /dev/null +++ b/packages/server-utils/src/mongodb/mongodb-span.ts @@ -0,0 +1,229 @@ +import type { Span, SpanAttributes } from '@sentry/core'; +import { isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND, startInactiveSpan } from '@sentry/core'; + +// OTel "OLD" db/net semantic-conventions, reproduced from the vendored +// `@opentelemetry/instrumentation-mongodb` span shape so the orchestrion +// spans match the OTel ones byte-for-byte. Inlined as literals to avoid +// importing the deprecated convention constants. +const ATTR_DB_SYSTEM = 'db.system'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_OPERATION = 'db.operation'; +const ATTR_DB_STATEMENT = 'db.statement'; +const ATTR_DB_MONGODB_COLLECTION = 'db.mongodb.collection'; +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; +const DB_SYSTEM_VALUE_MONGODB = 'mongodb'; + +/** + * The db/collection namespace mongodb (v4+) passes to `Connection.command` + */ +export interface MongodbNamespace { + db: string; + collection?: string; +} + +interface V4Command { + documents?: unknown[]; + cursors?: unknown; + [key: string]: unknown; +} + +/** + * Replaces every leaf value in the command object with '?', keeping keys + * and Mongo operators. hides PII and improves grouping. Reproduced from + * the OTel instrumentation. + */ +export function serializeDbStatement(commandObj: Record): string { + return JSON.stringify(scrubStatement(commandObj)); +} + +function scrubStatement(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(element => scrubStatement(element)); + } + + if (isCommandObj(value)) { + const initial: Record = {}; + return Object.entries(value) + .map(([key, element]) => [key, scrubStatement(element)]) + .reduce((prev, current) => { + if (isCommandEntry(current)) { + prev[current[0]] = current[1]; + } + return prev; + }, initial); + } + + // A value like string or number, possibly contains PII, scrub it. + return '?'; +} + +function isCommandObj(value: unknown): value is Record { + return isObjectLike(value) && !isBuffer(value); +} + +function isBuffer(value: unknown): boolean { + return typeof Buffer !== 'undefined' && Buffer.isBuffer(value); +} + +function isCommandEntry(value: unknown): value is [string, unknown] { + return Array.isArray(value); +} + +/** + * Build span attributes for a mongodb v4+ command from the connection + * context, namespace and command. + */ +export function getV4SpanAttributes( + connectionCtx: { address?: string } | undefined, + ns: MongodbNamespace, + command: V4Command | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + let host: string | undefined; + let port: string | undefined; + if (connectionCtx) { + const hostParts = typeof connectionCtx.address === 'string' ? connectionCtx.address.split(':') : ''; + if (hostParts.length === 2) { + host = hostParts[0]; + port = hostParts[1]; + } + } + + let commandObj: Record | undefined; + if (command?.documents?.[0]) { + commandObj = command.documents[0] as Record; + } else if (command?.cursors) { + commandObj = command.cursors as Record; + } else { + commandObj = command; + } + + return getSpanAttributes(ns.db, ns.collection, host, port, commandObj, operation, origin); +} + +/** + * Shared attribute builder used by both the v3 topology path and the v4+ + * connection path. + */ +export function getSpanAttributes( + dbName: string | undefined, + dbCollection: string | undefined, + host: string | undefined, + port: string | undefined, + commandObj: Record | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, + [ATTR_DB_SYSTEM]: DB_SYSTEM_VALUE_MONGODB, + [ATTR_DB_NAME]: dbName, + [ATTR_DB_MONGODB_COLLECTION]: dbCollection, + [ATTR_DB_OPERATION]: operation, + [ATTR_DB_CONNECTION_STRING]: `mongodb://${host}:${port}/${dbName}`, + }; + + if (host && port) { + attributes[ATTR_NET_PEER_NAME] = host; + const portNumber = parseInt(port, 10); + if (!isNaN(portNumber)) { + attributes[ATTR_NET_PEER_PORT] = portNumber; + } + } + + if (commandObj) { + try { + attributes[ATTR_DB_STATEMENT] = serializeDbStatement(commandObj); + } catch { + // ignore serialization errors — the statement is best-effort metadata + } + } + + return attributes; +} + +/** The v3 driver topology, from which host/port are read (mongodb 3.x). */ +export interface MongoV3Topology { + s?: { + options?: { host?: string; port?: number }; + host?: string; + port?: number; + }; + description?: { address?: string }; +} + +/** + * Determine the operation name for a mongodb v3 `command` from the command + * document, mirroring the vendored instrumentation. Returns `undefined` for + * commands it doesn't classify (e.g. `endSessions`). + */ +export function getV3CommandOperation(command: Record): string | undefined { + if (command.createIndexes !== undefined) { + return 'createIndexes'; + } else if (command.findandmodify !== undefined) { + return 'findAndModify'; + } else if (command.ismaster !== undefined) { + return 'isMaster'; + } else if (command.count !== undefined) { + return 'count'; + } else if (command.aggregate !== undefined) { + return 'aggregate'; + } + return undefined; +} + +/** + * Build span attributes for a mongodb v3 operation from the topology, + * namespace string and command. + */ +export function getV3SpanAttributes( + ns: string, + topology: MongoV3Topology | undefined, + command: Record | undefined, + operation: string | undefined, + origin: string, +): SpanAttributes { + let host: string | undefined; + let port: string | undefined; + if (topology?.s) { + host = topology.s.options?.host ?? topology.s.host; + port = (topology.s.options?.port ?? topology.s.port)?.toString(); + if (host == null || port == null) { + const address = topology.description?.address; + if (address) { + const segments = address.split(':'); + host = segments[0]; + port = segments[1]; + } + } + } + + // The namespace is `[db].[collection-or-index]`; coerce to string + // (might be a MongoDBNamespace) + const [dbName, dbCollection] = ns.toString().split('.'); + const commandObj = + (command?.query as Record | undefined) ?? + (command?.q as Record | undefined) ?? + command; + + return getSpanAttributes(dbName, dbCollection, host, port, commandObj, operation, origin); +} + +/** + * Start a mongodb client span with the legacy (pre-stable) db/net semantic + * conventions. + * + * `op: 'db'` is set explicitly rather than relying on `inferDbSpanData`, + * to support platforms that lack it (ie, Deno). + */ +export function startMongoSpan(attributes: SpanAttributes): Span { + return startInactiveSpan({ + name: `mongodb.${attributes[ATTR_DB_OPERATION] || 'command'}`, + op: 'db', + kind: SPAN_KIND.CLIENT, + attributes, + }); +} diff --git a/packages/server-utils/src/orchestrion/config/mongodb.ts b/packages/server-utils/src/orchestrion/config/mongodb.ts index 04c623f0ba30..ee44b79b0bba 100644 --- a/packages/server-utils/src/orchestrion/config/mongodb.ts +++ b/packages/server-utils/src/orchestrion/config/mongodb.ts @@ -1,6 +1,72 @@ import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `mongodb` orchestrion integration (ports `@opentelemetry/instrumentation-mongodb`). -export const mongodbConfig: InstrumentationConfig[] = []; +// The mongodb driver's command architecture changed across majors, mirrored in the vendored OTel +// instrumentation's version bands: +// - >= 6.4: promise-based `Connection.prototype.command` +// - >= 4.0 < 6.4: callback-based `Connection.prototype.command` + `ConnectionPool.checkOut` +// (the pool runs the checkout callback in a detached context, so the channel re-propagates the +// caller's async context to it — see the subscriber) +// - >= 3.3 < 4: the `lib/core/wireprotocol` module functions +const module = { name: 'mongodb' } as const; -export const mongodbChannels = {} as const; +export const mongodbConfig = [ + // Band 1: mongodb >= 6.4 — promise-based command. + // `methodName`-only (no `className`): the code-transformer's `className` matcher throws on classes + // containing ES2022 `static {}` blocks (mongodb 7.x's `Connection`/`ConnectionPool` have them — see + // `transformer-bug.md`), and `methodName` alone matches exactly the base method across all versions. + { + channelName: 'command', + module: { ...module, versionRange: '>=6.4.0 <8', filePath: 'lib/cmap/connection.js' }, + functionQuery: { methodName: 'command', kind: 'Async' }, + }, + // Band 2: mongodb >= 4.0 < 6.4 — callback-based command (same `command` channel, different kind). + { + channelName: 'command', + module: { ...module, versionRange: '>=4.0.0 <6.4', filePath: 'lib/cmap/connection.js' }, + functionQuery: { methodName: 'command', kind: 'Callback' }, + }, + // Band 2: the pool runs the checkout callback in a detached async context, so the operation's + // `command()` (invoked inside it) loses the caller's active span. Hooking `checkOut` re-propagates + // that context to the callback (the subscriber creates no span — see `getSpan` returning undefined). + // Only needed < 6.4; from 6.4 `checkOut` is promise-based and the context survives natively. + { + channelName: 'checkout', + module: { ...module, versionRange: '>=4.0.0 <6.4', filePath: 'lib/cmap/connection_pool.js' }, + functionQuery: { methodName: 'checkOut', kind: 'Callback' }, + }, + // Band 3: mongodb >= 3.3 < 4 — the driver had no unified `command`; each operation is a separate + // `lib/core/wireprotocol` function, all callback-style. `insert`/`update`/`remove` are named + // function expressions in the `index.js` `module.exports` object (matched by `expressionName`); + // `command`/`query`/`getMore` are single-function modules (matched by `functionName`). + ...(['insert', 'update', 'remove'] as const).map(op => ({ + channelName: `v3_${op}`, + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/index.js' }, + functionQuery: { expressionName: op, kind: 'Callback' as const }, + })), + { + channelName: 'v3_command', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/command.js' }, + functionQuery: { functionName: 'command', kind: 'Callback' }, + }, + { + channelName: 'v3_query', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/query.js' }, + functionQuery: { functionName: 'query', kind: 'Callback' }, + }, + { + channelName: 'v3_get_more', + module: { ...module, versionRange: '>=3.3.0 <4', filePath: 'lib/core/wireprotocol/get_more.js' }, + functionQuery: { functionName: 'getMore', kind: 'Callback' }, + }, +] satisfies InstrumentationConfig[]; + +export const mongodbChannels = { + MONGODB_COMMAND: 'orchestrion:mongodb:command', + MONGODB_CHECKOUT: 'orchestrion:mongodb:checkout', + MONGODB_V3_INSERT: 'orchestrion:mongodb:v3_insert', + MONGODB_V3_UPDATE: 'orchestrion:mongodb:v3_update', + MONGODB_V3_REMOVE: 'orchestrion:mongodb:v3_remove', + MONGODB_V3_COMMAND: 'orchestrion:mongodb:v3_command', + MONGODB_V3_QUERY: 'orchestrion:mongodb:v3_query', + MONGODB_V3_GET_MORE: 'orchestrion:mongodb:v3_get_more', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index eac0c5956915..d82fa7c8ccbd 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -16,6 +16,7 @@ import { knexChannelIntegration } from '../integrations/tracing-channel/knex'; import { langChainChannelIntegration } from '../integrations/tracing-channel/langchain'; import { langGraphChannelIntegration } from '../integrations/tracing-channel/langgraph'; import { lruMemoizerChannelIntegration } from '../integrations/tracing-channel/lru-memoizer'; +import { mongodbChannelIntegration } from '../integrations/tracing-channel/mongodb'; import { mongooseChannelIntegration } from '../integrations/tracing-channel/mongoose'; import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; import { mysql2ChannelIntegration } from '../integrations/tracing-channel/mysql2'; @@ -46,6 +47,7 @@ export { langChainChannelIntegration, langGraphChannelIntegration, lruMemoizerChannelIntegration, + mongodbChannelIntegration, mongooseChannelIntegration, mysqlChannelIntegration, mysql2ChannelIntegration, @@ -92,6 +94,7 @@ export type * from '../integrations/tracing-channel/graphql/graphql-types'; export const channelIntegrations = { postgresIntegration: postgresChannelIntegration, postgresJsIntegration: postgresJsChannelIntegration, + mongoIntegration: mongodbChannelIntegration, mysqlIntegration: mysqlChannelIntegration, mysql2Integration: mysql2ChannelIntegration, genericPoolIntegration: genericPoolChannelIntegration, From e47e5d865b425e98b6a3300e51609084c5b81ff6 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 20 Jul 2026 09:27:40 +0200 Subject: [PATCH 48/54] feat(remix): Add orchestrion-based remix instrumentation (#22244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the vendored `RemixInstrumentation` (an OpenTelemetry `InstrumentationBase` that patched `@remix-run/server-runtime`) to a diagnostics-channel listener, with orchestrion injecting the channels into the instrumented module at build time. ### SDK changes * `@sentry/server-utils` — populated the orchestrion config for `@remix-run/server-runtime` (`orchestrion/config/remix.ts`) * `@sentry/remix` — adjusted the `remixIntegration` so it runs either OTEL-based instrumentation, or prchestrion-based instrumentation if opted in. ### E2E changes * Folded the standalone `remix-orchestrion` test app into `create-remix-app-v2` as an `INJECT_ORCHESTRION`-gated `sentryTest` variant, so one codebase runs both the OpenTelemetry and orchestrion paths (build-time injection via the orchestrion Vite plugin, DB routes + docker for mysql/ioredis, and a build-injection assertion). The base transaction tests run in both variants as a parity check. * To make `@remix-run/server-runtime` reachable by the build-time transform, `@remix-run/node` is force-bundled (it re-exports server-runtime, which otherwise stays external and is loaded by `remix-serve` at runtime, outside the Vite bundle). Fixes getsentry/sentry-javascript#20910 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../app/routes/db-ioredis.tsx | 0 .../app/routes/db-mysql.tsx | 0 .../docker-compose.yml | 4 +- .../global-setup.mjs | 2 +- .../global-teardown.mjs | 0 .../create-remix-app-v2/instrument.server.cjs | 10 + .../create-remix-app-v2/package.json | 18 +- .../create-remix-app-v2/playwright.config.mjs | 19 +- .../create-remix-app-v2/remix.config.js | 9 - .../tests/build-injection.test.ts | 50 ++++ .../create-remix-app-v2/tests/db.test.ts | 94 +++++++ .../create-remix-app-v2/vite.config.ts | 9 +- .../remix-orchestrion/.gitignore | 10 - .../remix-orchestrion/app/entry.client.tsx | 27 -- .../remix-orchestrion/app/entry.server.tsx | 110 -------- .../remix-orchestrion/app/root.tsx | 34 --- .../remix-orchestrion/app/routes/_index.tsx | 3 - .../remix-orchestrion/instrument.server.cjs | 14 - .../remix-orchestrion/package.json | 45 ---- .../remix-orchestrion/playwright.config.mjs | 15 -- .../remix-orchestrion/remix.env.d.ts | 2 - .../remix-orchestrion/start-event-proxy.mjs | 6 - .../tests/build-injection.test.ts | 32 --- .../remix-orchestrion/tests/db.test.ts | 88 ------ .../remix-orchestrion/tsconfig.json | 25 -- .../remix-orchestrion/vite.config.ts | 19 -- packages/remix/package.json | 1 + packages/remix/src/server/index.ts | 1 + .../server/integrations/RemixIntegration.ts | 39 +++ .../src/server/integrations/opentelemetry.ts | 47 +--- .../server/integrations/tracing-channel.ts | 255 ++++++++++++++++++ packages/remix/src/server/sdk.ts | 2 +- .../server/remix-integration-otel.test.ts | 59 ++++ .../tracing-channel-no-form-data.test.ts | 68 +++++ .../test/server/tracing-channel-test-utils.ts | 110 ++++++++ .../remix/test/server/tracing-channel.test.ts | 152 +++++++++++ .../src/orchestrion/config/index.ts | 7 +- .../src/orchestrion/config/remix.ts | 59 +++- .../server-utils/src/orchestrion/index.ts | 3 + 39 files changed, 960 insertions(+), 488 deletions(-) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/app/routes/db-ioredis.tsx (100%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/app/routes/db-mysql.tsx (100%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/docker-compose.yml (86%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/global-setup.mjs (90%) rename dev-packages/e2e-tests/test-applications/{remix-orchestrion => create-remix-app-v2}/global-teardown.mjs (100%) delete mode 100644 dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js create mode 100644 dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json delete mode 100644 dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts create mode 100644 packages/remix/src/server/integrations/RemixIntegration.ts create mode 100644 packages/remix/src/server/integrations/tracing-channel.ts create mode 100644 packages/remix/test/server/remix-integration-otel.test.ts create mode 100644 packages/remix/test/server/tracing-channel-no-form-data.test.ts create mode 100644 packages/remix/test/server/tracing-channel-test-utils.ts create mode 100644 packages/remix/test/server/tracing-channel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-ioredis.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-ioredis.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-ioredis.tsx rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-ioredis.tsx diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-mysql.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-mysql.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/db-mysql.tsx rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/app/routes/db-mysql.tsx diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/docker-compose.yml b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/docker-compose.yml similarity index 86% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/docker-compose.yml rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/docker-compose.yml index b7d5ec8898f0..6875b0a63363 100644 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/docker-compose.yml +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/docker-compose.yml @@ -2,7 +2,7 @@ services: db: image: mysql:8.0 restart: always - container_name: e2e-tests-remix-orchestrion-mysql + container_name: e2e-tests-create-remix-app-v2-mysql # The `mysql` 2.x driver doesn't speak MySQL 8's default # `caching_sha2_password` auth, so force the legacy plugin. command: ['--default-authentication-plugin=mysql_native_password'] @@ -20,7 +20,7 @@ services: redis: image: redis:7 restart: always - container_name: e2e-tests-remix-orchestrion-redis + container_name: e2e-tests-create-remix-app-v2-redis ports: - '6379:6379' healthcheck: diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/global-setup.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-setup.mjs similarity index 90% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/global-setup.mjs rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-setup.mjs index f944033877f3..148cb4782e4c 100644 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-setup.mjs @@ -13,7 +13,7 @@ export default async function globalSetup() { // recognize a leftover container from a previous (e.g. interrupted) run as // part of the same project - but the container names are fixed, so the daemon // still refuses to create new ones. Force-remove any stale leftovers first. - for (const container of ['e2e-tests-remix-orchestrion-mysql', 'e2e-tests-remix-orchestrion-redis']) { + for (const container of ['e2e-tests-create-remix-app-v2-mysql', 'e2e-tests-create-remix-app-v2-redis']) { try { execSync(`docker rm -f ${container}`, { stdio: 'ignore' }); } catch { diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-teardown.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/remix-orchestrion/global-teardown.mjs rename to dev-packages/e2e-tests/test-applications/create-remix-app-v2/global-teardown.mjs diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs index 6d211cac4592..f0e22b321751 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/instrument.server.cjs @@ -1,5 +1,15 @@ const Sentry = require('@sentry/remix'); +const injectOrchestrion = process.env.INJECT_ORCHESTRION === 'true'; + +if (injectOrchestrion) { + // Opt into diagnostics-channel-based auto-instrumentation. This registers the + // channel subscribers (e.g. for mysql and ioredis) that turn the + // diagnostics-channel events - injected at build time by the orchestrion Vite + // plugin (see vite.config.ts) - into Sentry spans. Must run before Sentry.init(). + Sentry.experimentalUseDiagnosticsChannelInjection(); +} + Sentry.init({ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! environment: 'qa', // dynamic sampling bias to keep transactions diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json index fd57d2920d5a..978f2abbd4d7 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/package.json @@ -1,6 +1,7 @@ { "private": true, "sideEffects": false, + "type": "module", "scripts": { "build": "remix vite:build && pnpm typecheck", "dev": "remix vite:dev", @@ -8,15 +9,20 @@ "typecheck": "tsc", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm playwright test" + "test:assert": "pnpm playwright test", + "test:build:orchestrion": "INJECT_ORCHESTRION=true pnpm test:build", + "test:assert:orchestrion": "INJECT_ORCHESTRION=true pnpm test:assert" }, "dependencies": { "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", + "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", "@remix-run/css-bundle": "2.17.4", "@remix-run/node": "2.17.4", "@remix-run/react": "2.17.4", "@remix-run/serve": "2.17.4", "isbot": "^3.6.8", + "ioredis": "5.10.1", + "mysql": "^2.18.1", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -25,6 +31,7 @@ "@sentry-internal/test-utils": "link:../../../test-utils", "@remix-run/dev": "2.17.4", "@remix-run/eslint-config": "2.17.4", + "@types/mysql": "^2.15.26", "@types/react": "^18.2.64", "@types/react-dom": "^18.2.34", "@types/prop-types": "15.7.7", @@ -36,6 +43,15 @@ "resolutions": { "@types/react": "18.2.22" }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:orchestrion", + "assert-command": "pnpm test:assert:orchestrion", + "label": "create-remix-app-v2 (orchestrion)" + } + ] + }, "volta": { "extends": "../../package.json" } diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs index 31f2b913b58b..8ab515926536 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/playwright.config.mjs @@ -1,7 +1,20 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { fileURLToPath } from 'url'; -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, -}); +const injectOrchestrion = process.env.INJECT_ORCHESTRION === 'true'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + // The orchestrion variant exercises real MySQL/Redis. Boot them before the tests run, + // outside the webServer startup-timeout window. In the default variant no DB is needed. + injectOrchestrion + ? { + globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)), + globalTeardown: fileURLToPath(new URL('./global-teardown.mjs', import.meta.url)), + } + : {}, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js deleted file mode 100644 index cb3c8c7a9fb7..000000000000 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/remix.config.js +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - ignoredRouteFiles: ['**/.*'], - // appDirectory: 'app', - // assetsBuildDirectory: 'public/build', - // serverBuildPath: 'build/index.js', - // publicPath: '/build/', - serverModuleFormat: 'cjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts new file mode 100644 index 000000000000..cfa27164a765 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/build-injection.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { expect, test } from '@playwright/test'; + +// The `db.test.ts` runtime assertions prove orchestrion spans appear, but spans alone +// don't prove they came from the BUILD-time transform: if the Vite plugin silently +// failed to load, the deps would stay external and the runtime `--require` hook would +// inject the channels at runtime instead - the span tests would still pass. These +// assertions inspect the built server bundle directly so a broken plugin can't hide +// behind that runtime fallback. Only relevant in the orchestrion variant. +test.describe('orchestrion build-time injection', () => { + test.skip(process.env.INJECT_ORCHESTRION !== 'true', 'Only runs in the orchestrion variant'); + + const serverBundle = readFileSync(path.join(process.cwd(), 'build/server/index.js'), 'utf8'); + + test('force-bundles the instrumented deps instead of externalizing them', () => { + // The plugin adds mysql/ioredis to `ssr.noExternal` so the transform sees their + // source. Without it they'd be left as bare imports or `require(...)` calls resolved + // from node_modules at runtime - untouched, with no channels injected. + expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); + expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); + }); + + test('injects the diagnostics-channel publishers into the bundled deps', () => { + // The transform wraps each instrumented function with a `tracingChannel("")` + // publisher whose channel name is a string literal. The subscriber side passes the + // channel name as a variable, so a literal-arg match is unique to the injected + // publisher and proves the build-time transform ran. + expect(serverBundle).toMatch(/tracingChannel(\$?\d)?\(["']orchestrion:mysql:query["']\)/); + expect(serverBundle).toMatch(/tracingChannel(\$?\d)?\(["']orchestrion:ioredis:command["']\)/); + expect(serverBundle).toMatch(/tracingChannel(\$?\d)?\(["']orchestrion:ioredis:connect["']\)/); + }); + + test('injects the diagnostics-channel publishers into @remix-run/server-runtime', () => { + // Remix's own instrumentation is orchestrion-based too: the transform force-bundles + // and injects channels into `@remix-run/server-runtime` + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:requestHandler["']\)/, + ); + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:matchServerRoutes["']\)/, + ); + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:callRouteLoader["']\)/, + ); + expect(serverBundle).toMatch( + /tracingChannel(\$?\d)?\(["']orchestrion:@remix-run\/server-runtime:callRouteAction["']\)/, + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts new file mode 100644 index 000000000000..48b21d38b30f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/tests/db.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// These assertions only hold in the orchestrion variant (INJECT_ORCHESTRION=true), which +// force-bundles + transforms mysql/ioredis and boots the databases via docker-compose. +test.describe('orchestrion DB instrumentation', () => { + test.skip(process.env.INJECT_ORCHESTRION !== 'true', 'Only runs in the orchestrion variant'); + + test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-ioredis') + ); + }); + + await fetch(`${baseURL}/db-ioredis`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'set test-key [1 other arguments]', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'set test-key [1 other arguments]', + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.redis', + description: 'get test-key', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'redis', + 'db.statement': 'get test-key', + }), + }), + ); + }); + + test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('create-remix-app-v2', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-mysql') + ); + }); + + await fetch(`${baseURL}/db-mysql`); + + const transactionEvent = await transactionEventPromise; + + const spans = transactionEvent.spans || []; + + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT 1 + 1 AS solution', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + status: 'ok', + data: expect.objectContaining({ + 'db.system': 'mysql', + 'db.statement': 'SELECT NOW()', + 'db.user': 'root', + 'db.connection_string': expect.any(String), + 'net.peer.name': expect.any(String), + 'net.peer.port': 3306, + }), + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts index d4d7f23895c1..a0a15e923f76 100644 --- a/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-v2/vite.config.ts @@ -1,15 +1,22 @@ import { vitePlugin as remix } from '@remix-run/dev'; import { sentryRemixVitePlugin } from '@sentry/remix'; +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; +const injectOrchestrion = process.env.INJECT_ORCHESTRION === 'true'; + export default defineConfig({ plugins: [ remix({ ignoredRouteFiles: ['**/.*'], - serverModuleFormat: 'cjs', }), sentryRemixVitePlugin(), + // In the orchestrion variant, run the orchestrion code transform over the SSR + // server bundle and force-bundle the instrumented deps (mysql, ioredis, + // @remix-run/server-runtime, …) so their diagnostics-channel calls are injected + // at build time. + ...(injectOrchestrion ? [sentryOrchestrionPlugin()] : []), tsconfigPaths(), ], }); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore deleted file mode 100644 index 18268b83a7ef..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules - -/.cache -/build -/public/build -.env - -/test-results/ -/playwright-report/ -/playwright/.cache/ diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx deleted file mode 100644 index 259f7a0ab4b5..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.client.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { RemixBrowser, useLocation, useMatches } from '@remix-run/react'; -import * as Sentry from '@sentry/remix'; -import { StrictMode, startTransition, useEffect } from 'react'; -import { hydrateRoot } from 'react-dom/client'; - -Sentry.init({ - dsn: 'https://username@domain/123', - environment: 'qa', // dynamic sampling bias to keep transactions - integrations: [ - Sentry.browserTracingIntegration({ - useEffect, - useLocation, - useMatches, - }), - ], - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server -}); - -startTransition(() => { - hydrateRoot( - document, - - - , - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx deleted file mode 100644 index 1435c570796d..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/entry.server.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { PassThrough } from 'node:stream'; - -import type { AppLoadContext, EntryContext } from '@remix-run/node'; -import { createReadableStreamFromReadable } from '@remix-run/node'; -import { RemixServer } from '@remix-run/react'; -import * as Sentry from '@sentry/remix'; -import isbot from 'isbot'; -import { renderToPipeableStream } from 'react-dom/server'; - -const ABORT_DELAY = 5_000; - -export const handleError = Sentry.wrapHandleErrorWithSentry(() => {}); - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, - _loadContext: AppLoadContext, -) { - return isbot(request.headers.get('user-agent')) - ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) - : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext); -} - -function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onAllReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - if (shellRendered) { - console.error(error); - } - }, - }, - ); - - setTimeout(abort, ABORT_DELAY); - }); -} - -function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { pipe, abort } = renderToPipeableStream( - , - { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - if (shellRendered) { - console.error(error); - } - }, - }, - ); - - setTimeout(abort, ABORT_DELAY); - }); -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx deleted file mode 100644 index 763c7baaf7ed..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/root.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react'; -import { captureRemixErrorBoundaryError, withSentry } from '@sentry/remix'; - -export function ErrorBoundary() { - const error = useRouteError(); - const eventId = captureRemixErrorBoundaryError(error); - - return ( -

- ErrorBoundary Error - {eventId} -
- ); -} - -function App() { - return ( - - - - - - - - - - - - - - ); -} - -export default withSentry(App); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx deleted file mode 100644 index 16dec6f9d34d..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/app/routes/_index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function Index() { - return
home
; -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs b/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs deleted file mode 100644 index dd3bb7e4c222..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/instrument.server.cjs +++ /dev/null @@ -1,14 +0,0 @@ -const Sentry = require('@sentry/remix'); - -// Opt into diagnostics-channel-based auto-instrumentation. This registers the -// channel subscribers (e.g. for mysql and ioredis) that turn the -// diagnostics-channel events - injected at build time by the orchestrion Vite -// plugin (see vite.config.ts) - into Sentry spans. Must run before Sentry.init(). -Sentry.experimentalUseDiagnosticsChannelInjection(); - -Sentry.init({ - dsn: 'https://username@domain/123', - environment: 'qa', // dynamic sampling bias to keep transactions - tracesSampleRate: 1.0, - tunnel: 'http://localhost:3031/', // proxy server -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json deleted file mode 100644 index 9951d296954d..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "remix-orchestrion", - "private": true, - "sideEffects": false, - "type": "module", - "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", - "scripts": { - "build": "remix vite:build", - "dev": "remix vite:dev", - "start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve ./build/server/index.js", - "proxy": "node start-event-proxy.mjs", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test", - "test": "playwright test" - }, - "dependencies": { - "@remix-run/node": "2.17.4", - "@remix-run/react": "2.17.4", - "@remix-run/serve": "2.17.4", - "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", - "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz", - "ioredis": "5.10.1", - "isbot": "^3.6.8", - "mysql": "^2.18.1", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@playwright/test": "~1.58.0", - "@remix-run/dev": "2.17.4", - "@sentry-internal/test-utils": "link:../../../test-utils", - "@types/react": "^18.2.64", - "@types/react-dom": "^18.2.34", - "typescript": "^5.6.3", - "vite": "^5.4.11", - "vite-tsconfig-paths": "^4.2.1" - }, - "resolutions": { - "@types/react": "18.2.22" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs deleted file mode 100644 index 96503f44f0df..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/playwright.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -import { fileURLToPath } from 'url'; - -const config = getPlaywrightConfig( - { - startCommand: `pnpm start`, - }, - // Boot MySQL and Redis before the tests run, outside the webServer startup-timeout window. - { - globalSetup: fileURLToPath(new URL('./global-setup.mjs', import.meta.url)), - globalTeardown: fileURLToPath(new URL('./global-teardown.mjs', import.meta.url)), - }, -); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts deleted file mode 100644 index dcf8c45e1d4c..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/remix.env.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -/// -/// diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs deleted file mode 100644 index fc353ca35ee4..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'remix-orchestrion', -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts deleted file mode 100644 index 4beb26f45415..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/build-injection.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from 'node:fs'; -import path from 'node:path'; -import { expect, test } from '@playwright/test'; - -// The `db.test.ts` runtime assertions prove orchestrion spans appear, but spans alone -// don't prove they came from the BUILD-time transform: if the Vite plugin silently -// failed to load, the deps would stay external and the runtime `--import` hook would -// inject the channels at runtime instead - the span tests would still pass. These -// assertions inspect the built server bundle directly so a broken plugin can't hide -// behind that runtime fallback. -test.describe('orchestrion build-time injection', () => { - const serverBundle = readFileSync(path.join(process.cwd(), 'build/server/index.js'), 'utf8'); - - test('force-bundles the instrumented deps instead of externalizing them', () => { - // The plugin adds mysql/ioredis to `ssr.noExternal` so the transform sees their - // source. Without it they'd be left as bare imports (this is an ESM server build) - // or `require(...)` calls resolved from node_modules at runtime - untouched, with - // no channels injected. - expect(serverBundle).not.toMatch(/(from\s*["']mysql["']|require\(["']mysql["']\))/); - expect(serverBundle).not.toMatch(/(from\s*["']ioredis["']|require\(["']ioredis["']\))/); - }); - - test('injects the diagnostics-channel publishers into the bundled deps', () => { - // The transform wraps each instrumented function with a `tracingChannel("")` - // publisher whose channel name is a string literal. The subscriber side passes the - // channel name as a variable, so a literal-arg match is unique to the injected - // publisher and proves the build-time transform ran. - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:mysql:query["']\)/); - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:command["']\)/); - expect(serverBundle).toMatch(/tracingChannel\(["']orchestrion:ioredis:connect["']\)/); - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts deleted file mode 100644 index c912f73f88cb..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tests/db.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Instruments ioredis automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('remix-orchestrion', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-ioredis') - ); - }); - - await fetch(`${baseURL}/db-ioredis`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'set test-key [1 other arguments]', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'set test-key [1 other arguments]', - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.redis', - description: 'get test-key', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'redis', - 'db.statement': 'get test-key', - }), - }), - ); -}); - -test('Instruments mysql automatically via orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('remix-orchestrion', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && !!transactionEvent.transaction?.includes('db-mysql') - ); - }); - - await fetch(`${baseURL}/db-mysql`); - - const transactionEvent = await transactionEventPromise; - - const spans = transactionEvent.spans || []; - - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT 1 + 1 AS solution', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT 1 + 1 AS solution', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT NOW()', - status: 'ok', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'db.statement': 'SELECT NOW()', - 'db.user': 'root', - 'db.connection_string': expect.any(String), - 'net.peer.name': expect.any(String), - 'net.peer.port': 3306, - }), - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json deleted file mode 100644 index cb057906bc27..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "include": ["remix.env.d.ts", "./app/**/*.ts", "./app/**/*.tsx"], - "exclude": ["node_modules", "build"], - "compilerOptions": { - "lib": ["DOM", "DOM.Iterable", "ES2019"], - "isolatedModules": true, - "esModuleInterop": true, - "jsx": "react-jsx", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "target": "ES2019", - "strict": true, - "allowJs": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "baseUrl": ".", - "paths": { - "~/*": ["./app/*"] - }, - "types": ["@remix-run/node", "vite/client"], - - // Remix takes care of building everything in `remix vite:build`. - "noEmit": true - } -} diff --git a/dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts deleted file mode 100644 index 6cb8b3c54c26..000000000000 --- a/dev-packages/e2e-tests/test-applications/remix-orchestrion/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { vitePlugin as remix } from '@remix-run/dev'; -import { sentryRemixVitePlugin } from '@sentry/remix'; -import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; -import { defineConfig } from 'vite'; -import tsconfigPaths from 'vite-tsconfig-paths'; - -export default defineConfig({ - plugins: [ - remix({ - ignoredRouteFiles: ['**/.*'], - }), - sentryRemixVitePlugin(), - // Runs the orchestrion code transform over the SSR server bundle and - // force-bundles the instrumented deps (mysql, ioredis, …) so the - // diagnostics-channel calls are actually injected at build time. - sentryOrchestrionPlugin(), - tsconfigPaths(), - ], -}); diff --git a/packages/remix/package.json b/packages/remix/package.json index 891507dc35cd..6db2393669e6 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -72,6 +72,7 @@ "@sentry/core": "10.66.0", "@sentry/node": "10.66.0", "@sentry/react": "10.66.0", + "@sentry/server-utils": "10.66.0", "yargs": "^17.6.0" }, "devDependencies": { diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index 7650cdc6e7ea..3ce2aa4a2caf 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -145,3 +145,4 @@ export { init, getRemixDefaultIntegrations } from './sdk'; export { captureRemixServerException } from './errors'; export { sentryHandleError, wrapHandleErrorWithSentry, instrumentBuild } from './instrumentServer'; export { generateSentryServerTimingHeader } from './serverTimingTracePropagation'; +export { remixIntegration } from './integrations/RemixIntegration'; diff --git a/packages/remix/src/server/integrations/RemixIntegration.ts b/packages/remix/src/server/integrations/RemixIntegration.ts new file mode 100644 index 000000000000..f70926fa9add --- /dev/null +++ b/packages/remix/src/server/integrations/RemixIntegration.ts @@ -0,0 +1,39 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration, getClient } from '@sentry/core'; +import { isOrchestrionInjected } from '@sentry/server-utils/orchestrion'; +import { instrumentRemix } from './tracing-channel'; +import { addRemixSpanAttributes, instrumentRemixWithOpenTelemetry } from './opentelemetry'; +import type { RemixOptions } from '../../utils/remixOptions'; + +const INTEGRATION_NAME = 'Remix' as const; + +const _remixIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + const client = getClient(); + const options = client?.getOptions() as RemixOptions | undefined; + const actionFormDataAttributes = client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') + ? options?.captureActionFormDataKeys + : undefined; + + if (isOrchestrionInjected()) { + instrumentRemix(actionFormDataAttributes); + } else { + instrumentRemixWithOpenTelemetry({ actionFormDataAttributes }); + } + }, + setup(client) { + if (!isOrchestrionInjected()) { + client.on('spanStart', span => { + addRemixSpanAttributes(span); + }); + } + }, + }; +}) satisfies IntegrationFn; + +/** + * Instrument server-side Remix requests to emit spans. + */ +export const remixIntegration = defineIntegration(_remixIntegration); diff --git a/packages/remix/src/server/integrations/opentelemetry.ts b/packages/remix/src/server/integrations/opentelemetry.ts index b143edd473a8..ceaa9349c706 100644 --- a/packages/remix/src/server/integrations/opentelemetry.ts +++ b/packages/remix/src/server/integrations/opentelemetry.ts @@ -1,7 +1,6 @@ -import type { Client, IntegrationFn, Span } from '@sentry/core'; -import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { generateInstrumentOnce, getClient, spanToJSON } from '@sentry/node'; -import type { RemixOptions } from '../../utils/remixOptions'; +import type { Span } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { generateInstrumentOnce, spanToJSON } from '@sentry/node'; import { RemixInstrumentation } from '../../vendor/instrumentation'; const INTEGRATION_NAME = 'Remix'; @@ -10,33 +9,14 @@ interface RemixInstrumentationOptions { actionFormDataAttributes?: Record; } -const instrumentRemix = generateInstrumentOnce(INTEGRATION_NAME, (options?: RemixInstrumentationOptions) => { - return new RemixInstrumentation(options); -}); +export const instrumentRemixWithOpenTelemetry = generateInstrumentOnce( + INTEGRATION_NAME, + (options?: RemixInstrumentationOptions) => { + return new RemixInstrumentation(options); + }, +); -const _remixIntegration = (() => { - return { - name: 'Remix' as const, - setupOnce() { - const client = getClient(); - const options = client?.getOptions() as RemixOptions | undefined; - - instrumentRemix({ - actionFormDataAttributes: client?.getDataCollectionOptions().httpBodies.includes('incomingRequest') - ? options?.captureActionFormDataKeys - : undefined, - }); - }, - - setup(client: Client) { - client.on('spanStart', span => { - addRemixSpanAttributes(span); - }); - }, - }; -}) satisfies IntegrationFn; - -const addRemixSpanAttributes = (span: Span): void => { +export function addRemixSpanAttributes(span: Span): void { const attributes = spanToJSON(span).data; // this is one of: loader, action, requestHandler @@ -57,9 +37,4 @@ const addRemixSpanAttributes = (span: Span): void => { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.remix', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, }); -}; - -/** - * Instrumentation for aws-sdk package - */ -export const remixIntegration = defineIntegration(_remixIntegration); +} diff --git a/packages/remix/src/server/integrations/tracing-channel.ts b/packages/remix/src/server/integrations/tracing-channel.ts new file mode 100644 index 000000000000..624ab8dad39c --- /dev/null +++ b/packages/remix/src/server/integrations/tracing-channel.ts @@ -0,0 +1,255 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Span, SpanAttributes } from '@sentry/core'; +import { + getActiveSpan, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_KIND, + startInactiveSpan, + waitForTracingChannelBinding, +} from '@sentry/core'; +import { bindTracingChannelToSpan } from '@sentry/server-utils'; +import { CODE_FUNCTION, HTTP_METHOD, HTTP_ROUTE, HTTP_STATUS_CODE, HTTP_URL } from '@sentry/conventions/attributes'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; + +const ORIGIN = 'auto.http.orchestrion.remix'; + +const NOOP = (): void => {}; + +// `match.route.id` / `match.params.*` mirror `RemixSemanticAttributes` from the vendored +// `RemixInstrumentation` this integration replaces. +const MATCH_ROUTE_ID = 'match.route.id'; +const MATCH_PARAMS = 'match.params'; + +/** + * The shape orchestrion's transform attaches to a tracing-channel `context` object. Documented here + * rather than imported because orchestrion's runtime doesn't export it. + */ +interface ChannelContext { + // The live `arguments` of the wrapped call. + arguments: unknown[]; + result?: unknown; + error?: unknown; +} + +// `callRouteLoader`/`callRouteAction` receive a single options object as `arguments[0]`. +interface RouteCallParams { + request?: Request; + params?: Record; + routeId?: string; +} + +// The in-flight form-data read, started at span start (before the action consumes the body) so it +// overlaps the action's execution rather than starting after it settles. +interface ActionChannelContext extends ChannelContext { + _sentryFormData?: Promise; +} + +// Minimal shape of a `matchServerRoutes` entry we read. +interface RouteMatch { + route?: { path?: string; id?: string }; +} + +function getRequestAttributes(request: unknown): SpanAttributes { + if (!isObjectLike(request)) { + return {}; + } + const { method, url } = request as Partial; + const attributes: SpanAttributes = {}; + if (typeof method === 'string') { + // oxlint-disable-next-line typescript/no-deprecated + attributes[HTTP_METHOD] = method; + } + if (typeof url === 'string') { + // oxlint-disable-next-line typescript/no-deprecated + attributes[HTTP_URL] = url; + } + return attributes; +} + +function getMatchAttributes(params: RouteCallParams): SpanAttributes { + const attributes: SpanAttributes = {}; + if (params.routeId) { + attributes[MATCH_ROUTE_ID] = params.routeId; + } + for (const [name, value] of Object.entries(params.params ?? {})) { + attributes[`${MATCH_PARAMS}.${name}`] = value || '(undefined)'; + } + return attributes; +} + +// The route handlers return a `Response` (or, with single-fetch, a naked object without `status`). +function setResponseStatus(span: Span, result: unknown): void { + if (!isObjectLike(result)) { + return; + } + const status = (result as { status?: unknown }).status; + if (typeof status === 'number') { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(HTTP_STATUS_CODE, status); + } +} + +/** + * `matchServerRoutes` opens no span of its own; it enriches the enclosing request span with the + * matched route (used to derive the `http.server` transaction name), mirroring the vendored + * instrumentation's patch. + */ +function enrichActiveSpanWithRoute(result: unknown): void { + const span = getActiveSpan(); + if (!span) { + return; + } + + const matches = Array.isArray(result) ? (result as RouteMatch[]) : []; + const route = matches[matches.length - 1]?.route; + + if (route?.path) { + // oxlint-disable-next-line typescript/no-deprecated + span.setAttribute(HTTP_ROUTE, route.path); + span.updateName(`remix.request ${route.path}`); + } + if (route?.id) { + span.setAttribute(MATCH_ROUTE_ID, route.id); + } +} + +function subscribeRequestHandler(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(remixChannels.REMIX_REQUEST_HANDLER), + data => + startInactiveSpan({ + name: 'remix.request', + kind: SPAN_KIND.SERVER, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'http.server', + [CODE_FUNCTION]: 'requestHandler', + ...getRequestAttributes(data.arguments[0]), + }, + }), + { + beforeSpanEnd: (span, data) => setResponseStatus(span, data.result), + }, + ); +} + +function subscribeMatchServerRoutes(): void { + // `matchServerRoutes` is synchronous, so only the `end` event carries a result; the rest are + // no-ops. `subscribe` types demand a handler for each channel. + diagnosticsChannel.tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).subscribe({ + start: NOOP, + end(data) { + enrichActiveSpanWithRoute(data.result); + }, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +function subscribeCallRouteLoader(): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER), + data => { + const params = (data.arguments[0] ?? {}) as RouteCallParams; + return startInactiveSpan({ + name: `LOADER ${params.routeId}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'loader.remix', + [CODE_FUNCTION]: 'loader', + ...getRequestAttributes(params.request), + ...getMatchAttributes(params), + }, + }); + }, + { + requiresParentSpan: true, + beforeSpanEnd: (span, data) => setResponseStatus(span, data.result), + }, + ); +} + +function subscribeCallRouteAction(actionFormDataAttributes: Record | undefined): void { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION), + data => { + const params = (data.arguments[0] ?? {}) as RouteCallParams; + // Start reading the form data now (from a clone taken before the action consumes the body), so + // it overlaps the action's execution. Unlike the patched instrumentation, a channel can't + // delay the action promise, so reading only after it settles would race the parent + // `requestHandler` span flushing the transaction. Reading here means the promise is (virtually + // always) already resolved by `asyncEnd`, so ending the span costs a single microtask. + if (actionFormDataAttributes && params.request) { + const formData = params.request.clone().formData(); + // Attach a handler so an unconsumed rejection (e.g. the action errored) isn't unhandled. + formData.catch(() => undefined); + data._sentryFormData = formData; + } + return startInactiveSpan({ + name: `ACTION ${params.routeId}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'action.remix', + [CODE_FUNCTION]: 'action', + ...getRequestAttributes(params.request), + ...getMatchAttributes(params), + }, + }); + }, + { + requiresParentSpan: true, + beforeSpanEnd: (span, data) => setResponseStatus(span, data.result), + // Hold the span end until the (already in-flight) form-data read resolves, then apply the + // attributes and end (which sets the response status via `beforeSpanEnd`). On error, or when + // capture isn't configured, let the helper end the span normally. + deferSpanEnd: ({ span, data, end }) => { + const formData = data._sentryFormData; + if (!actionFormDataAttributes || !formData || 'error' in data) { + return false; + } + + formData + .then(resolved => applyFormDataAttributes(span, resolved, actionFormDataAttributes)) + // Silently continue on any error. Typically happens because the action body cannot be + // processed into FormData, in which case we should just continue. + .catch(() => undefined) + .finally(() => end()); + + return true; + }, + }, + ); +} + +function applyFormDataAttributes( + span: Span, + formData: FormData, + actionFormDataAttributes: Record, +): void { + formData.forEach((value, key) => { + const mapped = actionFormDataAttributes[key]; + if (mapped && typeof value === 'string') { + const keyName = mapped === true ? key : mapped; + span.setAttribute(`formData.${keyName}`, value); + } + }); +} + +export function instrumentRemix(actionFormDataAttributes: Record | undefined): void { + // `tracingChannel` is unavailable before Node 18.19, so do nothing in that case. + if (!diagnosticsChannel.tracingChannel) { + return; + } + + waitForTracingChannelBinding(() => { + subscribeRequestHandler(); + subscribeMatchServerRoutes(); + subscribeCallRouteLoader(); + // Always instrument actions; `actionFormDataAttributes` only gates the optional form-data + // attribute extraction, not whether ACTION spans are created. + subscribeCallRouteAction(actionFormDataAttributes); + }); +} diff --git a/packages/remix/src/server/sdk.ts b/packages/remix/src/server/sdk.ts index f191a336cfbb..d20727caa0a8 100644 --- a/packages/remix/src/server/sdk.ts +++ b/packages/remix/src/server/sdk.ts @@ -6,7 +6,7 @@ import { DEBUG_BUILD } from '../utils/debug-build'; import type { RemixOptions } from '../utils/remixOptions'; import { instrumentServer } from './instrumentServer'; import { httpIntegration } from './integrations/http'; -import { remixIntegration } from './integrations/opentelemetry'; +import { remixIntegration } from './integrations/RemixIntegration'; /** * Returns the default Remix integrations. diff --git a/packages/remix/test/server/remix-integration-otel.test.ts b/packages/remix/test/server/remix-integration-otel.test.ts new file mode 100644 index 000000000000..c2ef63d0a678 --- /dev/null +++ b/packages/remix/test/server/remix-integration-otel.test.ts @@ -0,0 +1,59 @@ +import * as SentryCore from '@sentry/core'; +import type { NodeClient } from '@sentry/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// Force the OpenTelemetry branch of `remixIntegration`. `isOrchestrionInjected` is the only export +// the loaded graph needs (the tracing-channel module is mocked below), so a minimal mock suffices. +vi.mock('@sentry/server-utils/orchestrion', () => ({ + isOrchestrionInjected: () => false, +})); + +// Replace both instrument helpers so we can assert the call shape without OTel/channel side effects. +vi.mock('../../src/server/integrations/opentelemetry', () => ({ + instrumentRemixWithOpenTelemetry: vi.fn(), + addRemixSpanAttributes: vi.fn(), +})); +vi.mock('../../src/server/integrations/tracing-channel', () => ({ + instrumentRemix: vi.fn(), +})); + +import { remixIntegration } from '../../src/server/integrations/RemixIntegration'; +import { instrumentRemixWithOpenTelemetry } from '../../src/server/integrations/opentelemetry'; +import { instrumentRemix } from '../../src/server/integrations/tracing-channel'; + +function mockClient( + captureActionFormDataKeys: Record | undefined, + httpBodies: string[], +): void { + vi.spyOn(SentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ captureActionFormDataKeys }), + getDataCollectionOptions: () => ({ httpBodies }), + } as unknown as NodeClient); +} + +describe('remixIntegration (OpenTelemetry-based)', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + }); + + it('wraps the opted-in form-data keys in the RemixInstrumentation options object', () => { + mockClient({ username: true }, ['incomingRequest']); + + remixIntegration().setupOnce?.(); + + // Must be wrapped as `{ actionFormDataAttributes }` — passing the bare map would leave + // RemixInstrumentation's default `{ _action: 'actionType' }` mapping in place. + expect(instrumentRemixWithOpenTelemetry).toHaveBeenCalledWith({ actionFormDataAttributes: { username: true } }); + expect(instrumentRemix).not.toHaveBeenCalled(); + }); + + it('passes undefined attributes when form-data capture is not opted into', () => { + // `httpBodies` without `incomingRequest` means capture is off, regardless of the configured keys. + mockClient({ username: true }, []); + + remixIntegration().setupOnce?.(); + + expect(instrumentRemixWithOpenTelemetry).toHaveBeenCalledWith({ actionFormDataAttributes: undefined }); + }); +}); diff --git a/packages/remix/test/server/tracing-channel-no-form-data.test.ts b/packages/remix/test/server/tracing-channel-no-form-data.test.ts new file mode 100644 index 000000000000..a8dbd64bfd1f --- /dev/null +++ b/packages/remix/test/server/tracing-channel-no-form-data.test.ts @@ -0,0 +1,68 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { + makeRequest, + makeSpan, + setupRemixInstrumentation, + teardownTestAsyncContextStrategy, +} from './tracing-channel-test-utils'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; + +// Runs in its own file so the channel subscriptions register with NO form-data capture configured - +// the default for most apps. `captureActionFormDataKeys` gates only the optional attribute +// extraction, so ACTION spans must still be created. +describe('remixIntegration with orchestrion (no form-data capture configured)', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + setupRemixInstrumentation(undefined); + }); + + afterAll(() => { + teardownTestAsyncContextStrategy(); + }); + + beforeEach(() => { + span = makeSpan(); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + getActiveSpanSpy = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as Span); + }); + + afterEach(() => { + startInactiveSpanSpy.mockRestore(); + getActiveSpanSpy.mockRestore(); + }); + + it('callRouteAction: still builds an ACTION span and sets the response status', async () => { + const ctx = { + arguments: [ + { + routeId: 'routes/submit', + request: makeRequest({ method: 'POST', url: 'http://localhost/submit', formEntries: { _action: 'create' } }), + params: {}, + }, + ], + }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'ACTION routes/submit', + attributes: expect.objectContaining({ + 'sentry.op': 'action.remix', + 'code.function': 'action', + 'http.method': 'POST', + }), + }), + ); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 201); + // No form-data capture configured, so no `formData.*` attribute is set. + expect(span.setAttribute).not.toHaveBeenCalledWith('formData.actionType', expect.anything()); + expect(span.end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/remix/test/server/tracing-channel-test-utils.ts b/packages/remix/test/server/tracing-channel-test-utils.ts new file mode 100644 index 000000000000..37d409a25841 --- /dev/null +++ b/packages/remix/test/server/tracing-channel-test-utils.ts @@ -0,0 +1,110 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Scope, Span } from '@sentry/core'; +import { + _INTERNAL_setSpanForScope, + getDefaultCurrentScope, + getDefaultIsolationScope, + setAsyncContextStrategy, +} from '@sentry/core'; +import * as SentryNode from '@sentry/node'; +import type { NodeClient } from '@sentry/node'; +import { vi } from 'vitest'; +import { instrumentRemix } from '../../src/server/integrations/tracing-channel'; + +interface TestStore { + scope: Scope; + isolationScope: Scope; +} + +// `bindTracingChannelToSpan` only binds (and `setupOnce` only subscribes via +// `waitForTracingChannelBinding`) when an async-context strategy exposes a +// `getTracingChannelBinding`. Install a minimal one so the channel subscriptions +// actually register in this unit-test context (no SDK `init`). +function installTestAsyncContextStrategy(): void { + const asyncStorage = new AsyncLocalStorage(); + + function getScopes(): TestStore { + return asyncStorage.getStore() || { scope: getDefaultCurrentScope(), isolationScope: getDefaultIsolationScope() }; + } + + setAsyncContextStrategy({ + withScope: callback => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withSetScope: (scope, callback) => { + const isolationScope = getScopes().isolationScope; + return asyncStorage.run({ scope, isolationScope }, () => callback(scope)); + }, + withIsolationScope: callback => { + const scope = getScopes().scope; + const isolationScope = getScopes().isolationScope.clone(); + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + withSetIsolationScope: (isolationScope, callback) => { + const scope = getScopes().scope; + return asyncStorage.run({ scope, isolationScope }, () => callback(isolationScope)); + }, + getCurrentScope: () => getScopes().scope, + getIsolationScope: () => getScopes().isolationScope, + getTracingChannelBinding: () => ({ + asyncLocalStorage: asyncStorage, + getStoreWithActiveSpan: span => { + const scope = getScopes().scope.clone(); + const isolationScope = getScopes().isolationScope; + _INTERNAL_setSpanForScope(scope, span); + return { scope, isolationScope }; + }, + }), + }); +} + +export function teardownTestAsyncContextStrategy(): void { + setAsyncContextStrategy(undefined); + vi.restoreAllMocks(); +} + +export function makeSpan(): Span { + return { + end: vi.fn(), + setStatus: vi.fn(), + setAttributes: vi.fn(), + setAttribute: vi.fn(), + updateName: vi.fn(), + } as unknown as Span; +} + +export function makeRequest( + overrides: { method?: string; url?: string; formEntries?: Record } = {}, +): Request { + const { method = 'GET', url = 'http://localhost/test', formEntries } = overrides; + return { + method, + url, + clone: () => ({ + formData: async () => { + const fd = new FormData(); + for (const [key, value] of Object.entries(formEntries ?? {})) { + fd.append(key, value); + } + return fd; + }, + }), + } as unknown as Request; +} + +/** + * Install the async-context strategy, mock the client with the given form-data config, and orchestrion-based remix instrumentation + * so the channel subscriptions register. + * `captureActionFormDataKeys` left undefined mimics an app that hasn't opted into form-data capture. + */ +export function setupRemixInstrumentation(captureActionFormDataKeys?: Record): void { + installTestAsyncContextStrategy(); + vi.spyOn(SentryNode, 'getClient').mockReturnValue({ + getOptions: () => ({ captureActionFormDataKeys }), + getDataCollectionOptions: () => ({ httpBodies: captureActionFormDataKeys ? ['incomingRequest'] : [] }), + } as unknown as NodeClient); + + instrumentRemix(captureActionFormDataKeys); +} diff --git a/packages/remix/test/server/tracing-channel.test.ts b/packages/remix/test/server/tracing-channel.test.ts new file mode 100644 index 000000000000..ae675a15f1a6 --- /dev/null +++ b/packages/remix/test/server/tracing-channel.test.ts @@ -0,0 +1,152 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { Span } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; +import { + makeRequest, + makeSpan, + setupRemixInstrumentation, + teardownTestAsyncContextStrategy, +} from './tracing-channel-test-utils'; +import { remixChannels } from '@sentry/server-utils/orchestrion'; + +describe('remixIntegration (Orchestrion-based)', () => { + let startInactiveSpanSpy: MockInstance; + let getActiveSpanSpy: MockInstance; + let span: Span; + + beforeAll(() => { + // Configure form-data capture so the ACTION span also extracts the mapped keys. + setupRemixInstrumentation({ _action: 'actionType' }); + }); + + afterAll(() => { + teardownTestAsyncContextStrategy(); + }); + + beforeEach(() => { + span = makeSpan(); + startInactiveSpanSpy = vi.spyOn(SentryCore, 'startInactiveSpan').mockReturnValue(span); + // A truthy active span by default, so the `requiresParentSpan` gate passes. + getActiveSpanSpy = vi.spyOn(SentryCore, 'getActiveSpan').mockReturnValue({} as Span); + }); + + afterEach(() => { + startInactiveSpanSpy.mockRestore(); + getActiveSpanSpy.mockRestore(); + }); + + it('requestHandler: builds the http.server span and sets the response status', async () => { + const ctx = { arguments: [makeRequest({ method: 'GET', url: 'http://localhost/users' })] }; + + await tracingChannel(remixChannels.REMIX_REQUEST_HANDLER).tracePromise(async () => ({ status: 200 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'remix.request', + kind: SentryCore.SPAN_KIND.SERVER, + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.orchestrion.remix', + 'sentry.op': 'http.server', + 'code.function': 'requestHandler', + 'http.method': 'GET', + 'http.url': 'http://localhost/users', + }), + }), + ); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 200); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('matchServerRoutes: enriches the active request span with the matched route', () => { + getActiveSpanSpy.mockReturnValue(span); + const ctx = { + arguments: [[], '/users/123'], + result: [{ route: { path: 'users/:userId', id: 'routes/users.$userId' } }], + }; + + tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).traceSync(() => ctx.result, ctx); + + expect(span.setAttribute).toHaveBeenCalledWith('http.route', 'users/:userId'); + expect(span.setAttribute).toHaveBeenCalledWith('match.route.id', 'routes/users.$userId'); + expect(span.updateName).toHaveBeenCalledWith('remix.request users/:userId'); + }); + + it('matchServerRoutes: does nothing when there is no active span', () => { + getActiveSpanSpy.mockReturnValue(undefined); + const ctx = { arguments: [], result: [{ route: { path: 'users/:userId', id: 'x' } }] }; + + tracingChannel(remixChannels.REMIX_MATCH_SERVER_ROUTES).traceSync(() => ctx.result, ctx); + + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + + it('callRouteLoader: builds a LOADER span with request + match attributes', async () => { + const ctx = { + arguments: [ + { + routeId: 'routes/users.$userId', + request: makeRequest({ method: 'GET', url: 'http://localhost/users/123' }), + params: { userId: '123' }, + }, + ], + }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'LOADER routes/users.$userId', + attributes: expect.objectContaining({ + 'sentry.origin': 'auto.http.orchestrion.remix', + 'sentry.op': 'loader.remix', + 'code.function': 'loader', + 'http.method': 'GET', + 'http.url': 'http://localhost/users/123', + 'match.route.id': 'routes/users.$userId', + 'match.params.userId': '123', + }), + }), + ); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 200); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('callRouteLoader: does not create a span without an active parent span', async () => { + getActiveSpanSpy.mockReturnValue(undefined); + const ctx = { arguments: [{ routeId: 'x', request: makeRequest(), params: {} }] }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_LOADER).tracePromise(async () => ({ status: 200 }), ctx); + + expect(startInactiveSpanSpy).not.toHaveBeenCalled(); + }); + + it('callRouteAction: builds an ACTION span and captures the configured form-data keys', async () => { + const ctx = { + arguments: [ + { + routeId: 'routes/submit', + request: makeRequest({ method: 'POST', url: 'http://localhost/submit', formEntries: { _action: 'create' } }), + params: {}, + }, + ], + }; + + await tracingChannel(remixChannels.REMIX_CALL_ROUTE_ACTION).tracePromise(async () => ({ status: 201 }), ctx); + + expect(startInactiveSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'ACTION routes/submit', + attributes: expect.objectContaining({ + 'sentry.op': 'action.remix', + 'code.function': 'action', + 'http.method': 'POST', + }), + }), + ); + // The span ends only after the async form-data read resolves. + await vi.waitFor(() => expect(span.end).toHaveBeenCalledTimes(1)); + expect(span.setAttribute).toHaveBeenCalledWith('http.status_code', 201); + expect(span.setAttribute).toHaveBeenCalledWith('formData.actionType', 'create'); + }); +}); diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index ccc361c12755..aee58e185b71 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -88,7 +88,12 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ * externalized and their transform never runs. */ export function instrumentedModuleNames(instrumentations: InstrumentationConfig[] = []): string[] { - return uniq([...SENTRY_INSTRUMENTATIONS, ...instrumentations].map(i => i.module.name)); + return [ + ...uniq([...SENTRY_INSTRUMENTATIONS, ...instrumentations].map(i => i.module.name)), + // Additional things that need to be bundled but are not covered by the above + // Remix needs to bundle this so @remix-run/server-runtime is _also_ bundled + '@remix-run/node', + ]; } /** The instrumented module names from the default Sentry config, with no custom additions. */ diff --git a/packages/server-utils/src/orchestrion/config/remix.ts b/packages/server-utils/src/orchestrion/config/remix.ts index a7e6cf115a75..893efa781e5a 100644 --- a/packages/server-utils/src/orchestrion/config/remix.ts +++ b/packages/server-utils/src/orchestrion/config/remix.ts @@ -1,6 +1,59 @@ import type { InstrumentationConfig } from '..'; -// TODO: Stub for the `remix` orchestrion integration (ports `RemixInstrumentation`). -export const remixConfig: InstrumentationConfig[] = []; +// Four concepts, one channel each: +// - `requestHandler` → the async handler returned by `createRequestHandler` (the server span) +// - `matchServerRoutes` → sync route match; enriches the active span (creates no span of its own) +// - `callRouteLoader` → LOADER span. Remix 2.0–2.8 named it `callRouteLoaderRR` +// - `callRouteAction` → ACTION span. Remix 2.0–2.8 named it `callRouteActionRR` +// +// Emitted for both the CJS (`dist/*`) and ESM (`dist/esm/*`) builds, which share function shapes. +const remixInstrumentationConfig = (dir: string): InstrumentationConfig[] => [ + // `createRequestHandler` returns `async function requestHandler(request, loadContext)` — the main + // server span. We target the returned handler (so the span wraps each request, not the one-time + // handler construction). It's a *named function expression*, which name-based `functionQuery` + // can't match (that only sees declarations), so we select it with `astQuery`; `functionQuery` + // then just carries the behaviour (`kind: 'Async'`). + { + channelName: 'requestHandler', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <3', filePath: `${dir}/server.js` }, + astQuery: 'FunctionExpression[id.name="requestHandler"]', + functionQuery: { kind: 'Async' }, + }, + // Sync; the subscriber reads its result to set `http.route` on the active request span. + { + channelName: 'matchServerRoutes', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <3', filePath: `${dir}/routeMatching.js` }, + functionQuery: { functionName: 'matchServerRoutes', kind: 'Sync' }, + }, + // Remix >= 2.9.0 + { + channelName: 'callRouteLoader', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.9.0 <3', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteLoader', kind: 'Async' }, + }, + { + channelName: 'callRouteAction', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.9.0 <3', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteAction', kind: 'Async' }, + }, + // Remix 2.0.0 – 2.8.x: the same functions were suffixed `…RR`. Same channels as above. + { + channelName: 'callRouteLoader', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <2.9.0', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteLoaderRR', kind: 'Async' }, + }, + { + channelName: 'callRouteAction', + module: { name: '@remix-run/server-runtime', versionRange: '>=2.0.0 <2.9.0', filePath: `${dir}/data.js` }, + functionQuery: { functionName: 'callRouteActionRR', kind: 'Async' }, + }, +]; -export const remixChannels = {} as const; +export const remixConfig = ['dist', 'dist/esm'].flatMap(remixInstrumentationConfig); + +export const remixChannels = { + REMIX_REQUEST_HANDLER: 'orchestrion:@remix-run/server-runtime:requestHandler', + REMIX_MATCH_SERVER_ROUTES: 'orchestrion:@remix-run/server-runtime:matchServerRoutes', + REMIX_CALL_ROUTE_LOADER: 'orchestrion:@remix-run/server-runtime:callRouteLoader', + REMIX_CALL_ROUTE_ACTION: 'orchestrion:@remix-run/server-runtime:callRouteAction', +} as const; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index d82fa7c8ccbd..1e2f15b1a422 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -31,6 +31,9 @@ export { detectOrchestrionSetup, isOrchestrionInjected } from './detect'; // The `@nestjs/*` channel names live here alongside their transform config; the // listener that subscribes to them lives in `@sentry/nestjs`, which imports this. export { nestjsChannels } from './config/nestjs'; +// The remix channel names live here alongside their transform config; the +// listener that subscribes to them lives in `@sentry/remix`, which imports this. +export { remixChannels } from './config/remix'; export { amqplibChannelIntegration, anthropicChannelIntegration, From 8e20a26f22a2e061684eb1f792c03dc9ca2b999a Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Mon, 20 Jul 2026 10:01:13 +0200 Subject: [PATCH 49/54] feat(vue): Set `navigation.route.id` from the route name (#22372) Sets the `navigation.route.id` attribute on pageload and navigation spans, sourced from the Vue Router route name (`to.name`). ref getsentry/sentry-javascript#22069 Co-authored-by: Claude Opus 4.8 (1M context) --- .../test-applications/vue-3/tests/performance.test.ts | 1 + packages/vue/src/router.ts | 11 ++++++++++- packages/vue/test/router.test.ts | 6 +++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts index 291c611d4ca4..a1a9626d01cf 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/performance.test.ts @@ -120,6 +120,7 @@ test('sends a pageload transaction with a route name as transaction name if avai 'sentry.source': 'custom', 'sentry.origin': 'auto.pageload.vue', 'sentry.op': 'pageload', + 'navigation.route.id': 'AboutView', 'url.path': '/about', 'url.full': expect.stringMatching(/^https?:\/\/localhost:\d+\/about$/), }, diff --git a/packages/vue/src/router.ts b/packages/vue/src/router.ts index fefd1274ca0d..9a1c5335d81e 100644 --- a/packages/vue/src/router.ts +++ b/packages/vue/src/router.ts @@ -1,5 +1,10 @@ import { captureException, getAbsoluteUrl } from '@sentry/browser'; -import { PARAMS_KEY_BASE, URL_PATH_PARAMETER_KEY_BASE, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { + NAVIGATION_ROUTE_ID, + PARAMS_KEY_BASE, + URL_PATH_PARAMETER_KEY_BASE, + URL_TEMPLATE, +} from '@sentry/conventions/attributes'; import type { Span, SpanAttributes, StartSpanOptions, TransactionSource } from '@sentry/core'; import { getActiveSpan, @@ -101,6 +106,10 @@ export function instrumentVueRouter( attributes[URL_TEMPLATE] = spanName; } + if (to.name) { + attributes[NAVIGATION_ROUTE_ID] = to.name.toString(); + } + getCurrentScope().setTransactionName(spanName); // Update the existing page load span with parametrized route information diff --git a/packages/vue/test/router.test.ts b/packages/vue/test/router.test.ts index 498c41f31ed3..2bd2fe2b5ace 100644 --- a/packages/vue/test/router.test.ts +++ b/packages/vue/test/router.test.ts @@ -2,7 +2,7 @@ import * as SentryBrowser from '@sentry/browser'; import type { Span, SpanAttributes } from '@sentry/core'; import * as SentryCore from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; -import { URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { NAVIGATION_ROUTE_ID, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Route } from '../src/router'; import { instrumentVueRouter } from '../src/router'; @@ -465,5 +465,9 @@ function getAttributesForRoute(route: Route, urlTemplate?: string): SpanAttribut attributes[URL_TEMPLATE] = urlTemplate; } + if (route.name) { + attributes[NAVIGATION_ROUTE_ID] = route.name.toString(); + } + return attributes; } From 326fbc9b422807896cca49a7bf0ea1ca4d0afbca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 20 Jul 2026 11:41:35 +0300 Subject: [PATCH 50/54] fix(cloudflare): Skip spans for Cloudflare-internal Durable Object SQL queries (#22376) `agents` generates a lot of internal spans, which are mainly noise. The `workers-sdk` actually proofs that the summary with a `cf_` prefix are internal. So these are now filtered by default. I thought about having [an option](https://github.com/getsentry/sentry-javascript/commit/689e90cde44437891188f2e5c4016c286a829c29) to enable them, but I don't think this adds a lot of value. Screenshot 2026-07-17 at 22 38 30 In case users have `cf_` prefixed spans in their app they can use: `durableObjectSqlSpanAllowlist: []` to allow them, this would also be helpful if you want to get cf internal spans activated. --- .size-limit.js | 2 +- .../cloudflare-agent/tests/callable.test.ts | 25 +++-- packages/cloudflare/src/client.ts | 24 ++++ .../instrumentations/instrumentSqlStorage.ts | 11 ++ .../cloudflare/src/utils/internalSqlQuery.ts | 38 +++++++ .../test/instrumentSqlStorage.test.ts | 39 +++++++ .../test/utils/internalSqlQuery.test.ts | 106 ++++++++++++++++++ 7 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 packages/cloudflare/src/utils/internalSqlQuery.ts create mode 100644 packages/cloudflare/test/utils/internalSqlQuery.test.ts diff --git a/.size-limit.js b/.size-limit.js index a2b9a8649be9..2dac16a7922c 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -480,7 +480,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '445 KiB', + limit: '448 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts index 45ea0ec5ac96..bdd5bd22b8c0 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/tests/callable.test.ts @@ -34,17 +34,20 @@ test('@callable() methods work correctly with Sentry instrumentDurableObjectWith }, spans: expect.arrayContaining([ expect.objectContaining({ - op: 'db.query', - origin: 'auto.db.cloudflare.durable_object.sql', - description: expect.stringMatching(/^SELECT /), - data: expect.objectContaining({ - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', - 'db.query.summary': expect.any(String), - 'db.query.text': expect.any(String), - 'sentry.op': 'db.query', - 'sentry.origin': 'auto.db.cloudflare.durable_object.sql', - }), + data: { + 'db.operation.name': 'get', + 'db.system.name': 'cloudflare.durable_object.storage', + 'sentry.op': 'db', + 'sentry.origin': 'auto.db.cloudflare.durable_object', + }, + description: 'durable_object_storage_get', + op: 'db', + origin: 'auto.db.cloudflare.durable_object', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), }), ]), start_timestamp: expect.any(Number), diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index b9a2d2614ebf..087c1ad720d9 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -216,6 +216,30 @@ interface BaseCloudflareOptions { */ enableRpcTracePropagation?: boolean; + /** + * Table names that should stay instrumented even though they match the reserved `cf_` prefix used + * by Durable Object frameworks (`agents`, `partyserver`, ...) for their internal SQLite tables. + * + * By default, `exec` queries against `cf_`-prefixed tables are treated as framework noise and no + * `db.query` span is created for them. If one of your own tables happens to use this prefix, add it + * here to opt it back into instrumentation. Entries are matched against each table name in the + * query summary — strings must match exactly, while regular expressions give you prefix/pattern + * matching. + * + * @default [] + * @example + * ```ts + * export default Sentry.withSentry( + * (env) => ({ + * dsn: env.SENTRY_DSN, + * durableObjectSqlSpanAllowlist: ['cf_my_table', /^cf_reports_/], + * }), + * handler, + * ); + * ``` + */ + durableObjectSqlSpanAllowlist?: Array; + /** * @deprecated Use `enableRpcTracePropagation` instead. This option will be removed in a future major version. * diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index 96950dbb4f9e..739a0a7ef14a 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -2,9 +2,12 @@ import type { SqlStorage } from '@cloudflare/workers-types'; import { _INTERNAL_getSqlQuerySummary, _INTERNAL_sanitizeSqlQuery, + getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, } from '@sentry/core'; +import type { CloudflareClientOptions } from '../client'; +import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; /** * Instruments the Durable Object SqlStorage `exec` method with Sentry spans. @@ -23,9 +26,17 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { return function (this: unknown, ...args: unknown[]) { const [query, ...bindings] = args as [string, ...unknown[]]; + const sanitizedQuery = _INTERNAL_sanitizeSqlQuery(query); const querySummary = _INTERNAL_getSqlQuerySummary(sanitizedQuery); + const allowlist = (getClient()?.getOptions() as CloudflareClientOptions | undefined) + ?.durableObjectSqlSpanAllowlist; + + if (targetsCloudflareInternalTable(querySummary, allowlist)) { + return (original as (...a: unknown[]) => ReturnType).apply(target, args); + } + return startSpan( { op: 'db.query', diff --git a/packages/cloudflare/src/utils/internalSqlQuery.ts b/packages/cloudflare/src/utils/internalSqlQuery.ts new file mode 100644 index 000000000000..977a5a9c05a1 --- /dev/null +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -0,0 +1,38 @@ +import { stringMatchesSomePattern } from '@sentry/core'; + +/** + * Cloudflare frameworks that build on Durable Objects (`agents`, `partyserver`, ...) manage their + * own internal SQLite tables, all namespaced with a `cf_` prefix — e.g. `cf_agents_schedules`, + * `cf_agent_state`, `cf_ai_chat_stream_chunks`. Queries against them (schedule polling, chat-stream + * persistence, state bookkeeping) are framework implementation details that otherwise flood traces + * with dozens of zero-signal `db.query` spans per request. The exact set of tables even varies + * between framework versions, so we match the reserved prefix rather than an enumerated list. + * + * The `cf_` prefix is a reserved convention for framework-managed tables, so user tables should not + * use it. In case a user table does collide with the prefix, the `durableObjectSqlSpanAllowlist` + * option lets them opt those tables back into instrumentation. + * + * The check operates on the query summary produced by `getSqlQuerySummary` (`{operation} {table} ...`, + * the same value used as the span name), so table targets are already isolated from the rest of the + * query. + */ +export function targetsCloudflareInternalTable( + querySummary: string | undefined, + allowlist?: Array, +): boolean { + if (!querySummary) { + return false; + } + + const [, ...tables] = querySummary.split(' '); + + return tables.some(table => { + if (!table.toLowerCase().startsWith('cf_')) { + return false; + } + + // A table on the allowlist is treated as a user table and stays instrumented, even though it + // matches the reserved prefix. + return !allowlist?.length || !stringMatchesSomePattern(table, allowlist, true); + }); +} diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index de5ba69f79b0..e9fdb9f5d2ff 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -144,6 +144,45 @@ describe('instrumentSqlStorage', () => { expect(startSpanSpy).toHaveBeenCalledTimes(2); expect(mockSql.exec).toHaveBeenCalledTimes(2); }); + + describe('internal storage queries', () => { + it('does not create a span for Cloudflare-internal queries', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockCursor = createMockCursor(); + const mockSql = createMockSqlStorage(mockCursor); + const instrumented = instrumentSqlStorage(mockSql); + + const result = instrumented.exec('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + expect(result).toBe(mockCursor); + }); + + it('still creates a span for user queries', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); + + it('creates a span for a cf_ table on the durableObjectSqlSpanAllowlist', () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + vi.spyOn(sentryCore, 'getClient').mockReturnValue({ + getOptions: () => ({ durableObjectSqlSpanAllowlist: ['cf_my_table'] }), + } as unknown as ReturnType); + + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM cf_my_table WHERE id = ?', 1); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + }); + }); }); function createMockCursor() { diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts new file mode 100644 index 000000000000..7d119eb8a98f --- /dev/null +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -0,0 +1,106 @@ +import { _INTERNAL_getSqlQuerySummary } from '@sentry/core'; +import { describe, expect, it } from 'vitest'; +import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; + +// Builds the summary the same way `instrumentSqlStorage` does, so the test exercises the real +// operation -> summary -> detection path rather than hand-written summaries. +const summarize = (query: string): string | undefined => _INTERNAL_getSqlQuerySummary(query); + +describe('targetsCloudflareInternalTable', () => { + describe('internal queries (cf_ tables)', () => { + it.each([ + ['SELECT', 'SELECT * FROM cf_agents_state WHERE id = ?'], + ['INSERT', 'INSERT INTO cf_agents_fibers (id, callback) VALUES (?, ?)'], + ['DELETE', 'DELETE FROM cf_agents_schedules WHERE id = ?'], + ['UPDATE', 'UPDATE cf_agent_tool_runs SET output_json = ? WHERE id = ?'], + ['CREATE TABLE', 'CREATE TABLE IF NOT EXISTS cf_agents_workflows (id TEXT PRIMARY KEY NOT NULL)'], + ['ALTER TABLE', 'ALTER TABLE cf_agents_queues ADD COLUMN retry_options TEXT'], + ['DROP TABLE', 'DROP TABLE cf_agents_state'], + ['cf_agent_ prefix', 'SELECT * FROM cf_agent_identity'], + ['cf_ai_ prefix', 'INSERT INTO cf_ai_chat_stream_chunks (id) VALUES (?)'], + ['cf_mcp_ prefix', 'SELECT * FROM cf_mcp_agent_event'], + ['schema version', 'SELECT version FROM cf_schema_version'], + ])('returns true for %s on internal tables', (_label, query) => { + expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); + }); + + it('returns true for an internal JOIN', () => { + const query = ` + SELECT f.fiber_id, f.status + FROM cf_agents_fibers f + LEFT JOIN cf_agents_runs r ON r.id = f.fiber_id + WHERE f.status IN ('pending', 'running') + `; + expect(targetsCloudflareInternalTable(summarize(query))).toBe(true); + }); + + it('returns true when an internal table is joined with a user table', () => { + // `.some()` — any internal table present means the query is framework-driven noise. + expect( + targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state s JOIN users u ON u.id = s.id')), + ).toBe(true); + }); + + it('handles case-insensitive keywords and prefixes', () => { + expect(targetsCloudflareInternalTable(summarize('select * from CF_AGENTS_STATE'))).toBe(true); + }); + }); + + describe('user queries (must be instrumented)', () => { + it.each([ + ['SELECT', 'SELECT * FROM users WHERE id = ?'], + ['INSERT', 'INSERT INTO orders (id, total) VALUES (?, ?)'], + ['UPDATE', 'UPDATE products SET price = ? WHERE id = ?'], + ['DELETE', 'DELETE FROM sessions WHERE expired = 1'], + ['CREATE TABLE', 'CREATE TABLE users (id TEXT PRIMARY KEY)'], + ['table with cf in the middle', 'SELECT * FROM my_cf_table'], + ['table starting with cfg', 'SELECT * FROM cfg_settings'], + ])('returns false for %s on user tables', (_label, query) => { + expect(targetsCloudflareInternalTable(summarize(query))).toBe(false); + }); + }); + + describe('allowlist (opt a cf_ table back into instrumentation)', () => { + it('returns false for an allowlisted table matched by exact string', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table'), ['cf_my_table'])).toBe(false); + }); + + it('returns false for an allowlisted table matched by regex', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_reports_daily'), [/^cf_reports_/])).toBe(false); + }); + + it('requires an exact match for string entries', () => { + // Substring matches must not opt a table back in, otherwise `cf_` would allowlist everything. + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_agents'])).toBe(true); + }); + + it('still skips genuine internal tables that are not allowlisted', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), ['cf_my_table'])).toBe(true); + }); + + it('still skips when an internal table is joined with an allowlisted table', () => { + expect( + targetsCloudflareInternalTable(summarize('SELECT * FROM cf_my_table t JOIN cf_agents_state s ON s.id = t.id'), [ + 'cf_my_table', + ]), + ).toBe(true); + }); + + it('ignores an empty allowlist', () => { + expect(targetsCloudflareInternalTable(summarize('SELECT * FROM cf_agents_state'), [])).toBe(true); + }); + }); + + describe('summaries without a resolvable table target (safe default: instrument)', () => { + it.each([ + ['undefined', undefined], + ['empty', ''], + ['no-table SELECT', 'SELECT 1'], + ['PRAGMA', 'PRAGMA foreign_keys = ON'], + ['bare operation', 'BEGIN'], + ])('returns false for %s', (_label, value) => { + const summary = typeof value === 'string' ? summarize(value) : value; + expect(targetsCloudflareInternalTable(summary)).toBe(false); + }); + }); +}); From c47b4491c8ad2c49252c3a1a15bff1006eab5f96 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 20 Jul 2026 11:08:51 +0200 Subject: [PATCH 51/54] chore(ci): Use openrouter keys for claude in ci (#22381) Switches our ci workflows to use openrouter. Key is already added. --- .github/workflows/auto-fix-issue.yml | 4 +++- .github/workflows/dependabot-auto-triage.yml | 8 ++++++-- .github/workflows/track-framework-updates.yml | 4 +++- .github/workflows/triage-issue.yml | 4 +++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/auto-fix-issue.yml b/.github/workflows/auto-fix-issue.yml index a03007fce8a3..87fede1ea9be 100644 --- a/.github/workflows/auto-fix-issue.yml +++ b/.github/workflows/auto-fix-issue.yml @@ -78,8 +78,10 @@ jobs: - name: Try to fix the issue with Claude id: triage uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' show_full_output: ${{ github.event.inputs.show_full_output || 'false' }} diff --git a/.github/workflows/dependabot-auto-triage.yml b/.github/workflows/dependabot-auto-triage.yml index 7b888c51433f..1589dbaf0a3f 100644 --- a/.github/workflows/dependabot-auto-triage.yml +++ b/.github/workflows/dependabot-auto-triage.yml @@ -159,8 +159,10 @@ jobs: - name: Open batched runtime fix PR via skill uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ steps.app-token.outputs.token }} settings: | { @@ -234,8 +236,10 @@ jobs: - name: Open batched dev fix PR via skill uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ steps.app-token.outputs.token }} settings: | { diff --git a/.github/workflows/track-framework-updates.yml b/.github/workflows/track-framework-updates.yml index 6bdb4bdaee15..1fa3fce76a32 100644 --- a/.github/workflows/track-framework-updates.yml +++ b/.github/workflows/track-framework-updates.yml @@ -44,8 +44,10 @@ jobs: - name: Run Claude digest id: digest uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' prompt: | diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml index e4431205647b..3546a77e1c0e 100644 --- a/.github/workflows/triage-issue.yml +++ b/.github/workflows/triage-issue.yml @@ -55,8 +55,10 @@ jobs: - name: Run Claude triage id: triage uses: anthropics/claude-code-action@fad22eb3fa582b7357fc0ea48af6645851b884fd # v1 + env: + ANTHROPIC_BASE_URL: https://openrouter.ai/api with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + anthropic_api_key: ${{ secrets.OPENROUTER_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} allowed_non_write_users: '*' settings: | From 55bd48ae129f4a28b84ecb2325ca63b5c782a8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 20 Jul 2026 12:42:31 +0300 Subject: [PATCH 52/54] feat(core): Instrument workers-ai-provider (#22119) closes getsentry/sentry-javascript#20839
closes getsentry/sentry-javascript#20839 This is instrumenting `workers-ai`, but it needs to be wrapped, as in Cloudflare there is no monkey patching for bindings. In a follow-up PR this will be included in the `instrumentEnv` within Cloudflare. Tbh I would have added this into `packages/cloudflare`, but all AI integrations do live in core, and there are also some utils which are only there and would have needed to export, such as `endStreamSpan` and all the gen-ai-attributes. The downside of this is that the workers-ai types needed to be vendored in (in the `packages/cloudflare` we could have taken them from the `@cloudflare/workers-types` package. --------- Co-authored-by: Claude Opus 4.8 --- packages/core/src/shared-exports.ts | 2 + .../core/src/tracing/workers-ai/constants.ts | 10 + packages/core/src/tracing/workers-ai/index.ts | 133 ++++++++ .../core/src/tracing/workers-ai/streaming.ts | 229 +++++++++++++ packages/core/src/tracing/workers-ai/types.ts | 58 ++++ packages/core/src/tracing/workers-ai/utils.ts | 225 +++++++++++++ .../lib/tracing/workers-ai-streaming.test.ts | 224 +++++++++++++ .../core/test/lib/tracing/workers-ai.test.ts | 31 ++ .../test/lib/utils/workers-ai-utils.test.ts | 311 ++++++++++++++++++ 9 files changed, 1223 insertions(+) create mode 100644 packages/core/src/tracing/workers-ai/constants.ts create mode 100644 packages/core/src/tracing/workers-ai/index.ts create mode 100644 packages/core/src/tracing/workers-ai/streaming.ts create mode 100644 packages/core/src/tracing/workers-ai/types.ts create mode 100644 packages/core/src/tracing/workers-ai/utils.ts create mode 100644 packages/core/test/lib/tracing/workers-ai-streaming.test.ts create mode 100644 packages/core/test/lib/tracing/workers-ai.test.ts create mode 100644 packages/core/test/lib/utils/workers-ai-utils.test.ts diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index a7b3f070c2c4..983367c882f9 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -230,6 +230,8 @@ export { export { wrapToolsWithSpans, extractLLMFromParams, extractAgentNameFromParams } from './tracing/langgraph/utils'; export { LANGGRAPH_INTEGRATION_NAME } from './tracing/langgraph/constants'; export type { LangGraphOptions, LangGraphIntegration, CompiledGraph } from './tracing/langgraph/types'; +export { instrumentWorkersAiClient } from './tracing/workers-ai'; +export type { WorkersAiClient, WorkersAiOptions } from './tracing/workers-ai/types'; // eslint-disable-next-line typescript/no-deprecated export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './tracing/openai/types'; export type { diff --git a/packages/core/src/tracing/workers-ai/constants.ts b/packages/core/src/tracing/workers-ai/constants.ts new file mode 100644 index 000000000000..8f532a0a0372 --- /dev/null +++ b/packages/core/src/tracing/workers-ai/constants.ts @@ -0,0 +1,10 @@ +/** + * The provider value for the `gen_ai.provider.name` attribute. + * @see https://developers.cloudflare.com/workers-ai/ + */ +export const WORKERS_AI_PROVIDER_NAME = 'cloudflare.workers_ai'; + +/** + * The Sentry origin for spans created by the Workers AI instrumentation. + */ +export const WORKERS_AI_ORIGIN = 'auto.ai.cloudflare.workers_ai'; diff --git a/packages/core/src/tracing/workers-ai/index.ts b/packages/core/src/tracing/workers-ai/index.ts new file mode 100644 index 000000000000..c6be4824305a --- /dev/null +++ b/packages/core/src/tracing/workers-ai/index.ts @@ -0,0 +1,133 @@ +import { SPAN_STATUS_ERROR } from '../../tracing'; +import { startSpan, startSpanManual } from '../../tracing/trace'; +import type { Span } from '../../types/span'; +import { isObjectLike } from '../../utils/is'; +import { resolveAIRecordingOptions, shouldEnableTruncation } from '../ai/utils'; +import { instrumentWorkersAiStream } from './streaming'; +import type { WorkersAiOptions } from './types'; +import { addRequestAttributes, addResponseAttributes, extractRequestAttributes, getOperationName } from './utils'; + +// Adapted from /server-utils/src/vercel-ai/util.ts +// TODO(v11): Reuse this function once this gets moved to @sentry/server-utils +// Workers AI streaming responses are SSE byte streams, so we narrow to `Uint8Array`. +function isReadableStream(value: unknown): value is ReadableStream { + return ( + isObjectLike(value) && + typeof (value as { pipeThrough?: unknown }).pipeThrough === 'function' && + typeof (value as { getReader?: unknown }).getReader === 'function' + ); +} + +/** + * Wrap the `run` method of the Workers AI binding with Sentry tracing. + */ +function instrumentRun( + originalRun: (...args: unknown[]) => Promise, + context: unknown, + options: WorkersAiOptions & Required>, +): (...args: unknown[]) => Promise { + return function instrumentedRun(...args: unknown[]): Promise { + const [model, inputs, runOptions] = args as [unknown, unknown, Record | undefined]; + + const operationName = getOperationName(inputs); + const requestAttributes = extractRequestAttributes(model, inputs, operationName); + const modelName = typeof model === 'string' ? model : 'unknown'; + + const isStreamRequested = + !!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true; + const returnsRawResponse = + !!runOptions && + typeof runOptions === 'object' && + (runOptions.returnRawResponse === true || runOptions.websocket === true); + + const spanConfig = { + name: `${operationName} ${modelName}`, + op: `gen_ai.${operationName}`, + attributes: requestAttributes, + }; + + if (isStreamRequested && !returnsRawResponse) { + return startSpanManual(spanConfig, (span: Span) => { + // `startSpanManual` does not auto-end the span, so we must end it on every exit path, + // including a synchronous throw from `run`. + const handleError = (error: unknown): never => { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + span.end(); + throw error; + }; + + let originalResult: Promise; + + try { + originalResult = originalRun.apply(context, args) as Promise; + } catch (error) { + return handleError(error); + } + + if (options.recordInputs) { + addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation)); + } + + return originalResult.then(result => { + if (isReadableStream(result)) { + return instrumentWorkersAiStream(result, span, options.recordOutputs); + } + + // The model did not actually return a stream — finalize the span eagerly. + addResponseAttributes(span, result, options.recordOutputs); + span.end(); + return result; + }, handleError); + }); + } + + return startSpan(spanConfig, (span: Span) => { + const originalResult = originalRun.apply(context, args) as Promise; + + if (options.recordInputs) { + addRequestAttributes(span, inputs, operationName, shouldEnableTruncation(options.enableTruncation)); + } + + return originalResult.then(result => { + if (!returnsRawResponse) { + addResponseAttributes(span, result, options.recordOutputs); + } + return result; + }); + }); + }; +} + +/** + * Instrument a Cloudflare Workers AI binding (`env.AI`) with Sentry tracing. + * + * This wraps the binding's `run` method to create `gen_ai` spans following the + * Sentry AI Agents conventions. All other methods are passed through untouched. + * + * In `@sentry/cloudflare`, the `env.AI` binding is instrumented automatically — + * wrapping manually is only needed to pass custom options. + * + * @example + * ```javascript + * const ai = Sentry.instrumentWorkersAiClient(env.AI, { recordInputs: true, recordOutputs: true }); + * const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + * ``` + */ +export function instrumentWorkersAiClient(client: T, options?: WorkersAiOptions): T { + const resolvedOptions = resolveAIRecordingOptions(options); + + const instrumented = new Proxy(client, { + get(target: object, prop: string | symbol, receiver: unknown): unknown { + const value = Reflect.get(target, prop, receiver); + + if (prop === 'run' && typeof value === 'function') { + return instrumentRun(value as (...args: unknown[]) => Promise, target, resolvedOptions); + } + + // Bind passed-through functions to the original target to preserve `this` (e.g. private fields). + return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(target) : value; + }, + }) as T; + + return instrumented; +} diff --git a/packages/core/src/tracing/workers-ai/streaming.ts b/packages/core/src/tracing/workers-ai/streaming.ts new file mode 100644 index 000000000000..5a36d5eff2dc --- /dev/null +++ b/packages/core/src/tracing/workers-ai/streaming.ts @@ -0,0 +1,229 @@ +import { SPAN_STATUS_ERROR } from '../../tracing'; +import type { Span } from '../../types/span'; +import { endStreamSpan, type StreamResponseState } from '../ai/utils'; +import type { WorkersAiUsage } from './types'; +import { setOutputMessagesAttribute } from './utils'; + +interface WorkersAiStreamingToolCall { + index?: number; + id?: string; + type?: string; + function?: { name?: string; arguments?: string }; + // Some Workers AI models stream tool calls with the name/arguments at the top + // level of the tool-call object instead of nested under `function`. + name?: string; + arguments?: string; +} + +interface WorkersAiStreamChunk { + // Native Workers AI streaming shape (`env.AI.run` with `stream: true`). + response?: unknown; + tool_calls?: unknown[]; + // OpenAI-compatible streaming shape emitted for models routed through the + // OpenAI-compatible endpoint (e.g. via `workers-ai-provider`). + choices?: Array<{ + delta?: { content?: unknown; tool_calls?: WorkersAiStreamingToolCall[] }; + finish_reason?: unknown; + }>; + usage?: WorkersAiUsage & { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; +} + +/** + * Accumulate a fragmented OpenAI-compatible tool call (delivered across multiple + * `choices[].delta.tool_calls` chunks) into the index-keyed accumulator. + */ +function accumulateStreamingToolCalls( + toolCalls: WorkersAiStreamingToolCall[], + accumulator: Record, +): void { + for (const toolCall of toolCalls) { + // Normalize both shapes: name/arguments nested under `function`, or at the top level. + const name = toolCall.function?.name ?? toolCall.name; + const args = toolCall.function?.arguments ?? toolCall.arguments; + + // A tool call must carry at least a name or argument fragment to be meaningful. + if (name == null && args == null) { + continue; + } + + const index = toolCall.index ?? 0; + const existing = accumulator[index]; + + if (!existing) { + accumulator[index] = { + index, + id: toolCall.id, + type: toolCall.type, + function: { + name, + arguments: args ?? '', + }, + }; + } else if (existing.function) { + if (name && !existing.function.name) { + existing.function.name = name; + } + if (args) { + existing.function.arguments = `${existing.function.arguments ?? ''}${args}`; + } + } + } +} + +/** + * Parse a single SSE line (`data: {...}`) and accumulate its data into the streaming state. + * + * Handles both the native Workers AI shape (top-level `response`/`tool_calls`) and the + * OpenAI-compatible shape (`choices[].delta.content`/`choices[].delta.tool_calls`), because + * the same `run()` call transparently yields either format depending on the model. + */ +function processLine( + line: string, + state: StreamResponseState, + recordOutputs: boolean, + toolCallAccumulator: Record, +): void { + const trimmed = line.trim(); + if (!trimmed.startsWith('data:')) { + return; + } + + const data = trimmed.slice('data:'.length).trim(); + if (!data || data === '[DONE]') { + return; + } + + let parsed: WorkersAiStreamChunk; + try { + parsed = JSON.parse(data) as WorkersAiStreamChunk; + } catch { + return; + } + + if (parsed.usage) { + if (typeof parsed.usage.prompt_tokens === 'number') { + state.promptTokens = parsed.usage.prompt_tokens; + } + if (typeof parsed.usage.completion_tokens === 'number') { + state.completionTokens = parsed.usage.completion_tokens; + } + if (typeof parsed.usage.total_tokens === 'number') { + state.totalTokens = parsed.usage.total_tokens; + } + } + + if (recordOutputs && typeof parsed.response === 'string') { + state.responseTexts.push(parsed.response); + } + + if (recordOutputs && Array.isArray(parsed.tool_calls) && parsed.tool_calls.length > 0) { + state.toolCalls.push(...parsed.tool_calls); + } + + if (Array.isArray(parsed.choices)) { + for (const choice of parsed.choices) { + if (recordOutputs && typeof choice.delta?.content === 'string' && choice.delta.content) { + state.responseTexts.push(choice.delta.content); + } + if (recordOutputs && Array.isArray(choice.delta?.tool_calls)) { + accumulateStreamingToolCalls(choice.delta.tool_calls, toolCallAccumulator); + } + if (typeof choice.finish_reason === 'string') { + state.finishReasons.push(choice.finish_reason); + } + } + } +} + +/** + * Wrap a Workers AI streaming response (a server-sent-events `ReadableStream`) so we can + * accumulate the response text and token usage while passing the original bytes through untouched. + * + * The span is ended once the consumer finishes reading (or cancels) the stream. + */ +export function instrumentWorkersAiStream( + stream: ReadableStream, + span: Span, + recordOutputs: boolean, +): ReadableStream { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + + const state: StreamResponseState = { + responseId: '', + responseModel: '', + finishReasons: [], + responseTexts: [], + toolCalls: [], + promptTokens: undefined, + completionTokens: undefined, + totalTokens: undefined, + }; + + // OpenAI-compatible tool calls arrive fragmented across chunks and are keyed by index; + // accumulate them here and flatten into `state.toolCalls` once the stream ends. + const toolCallAccumulator: Record = {}; + + let buffer = ''; + let spanEnded = false; + + const finish = (): void => { + if (spanEnded) { + return; + } + spanEnded = true; + + if (recordOutputs) { + const accumulatedToolCalls = Object.values(toolCallAccumulator); + if (accumulatedToolCalls.length > 0) { + state.toolCalls.push(...accumulatedToolCalls); + } + + // Set the authoritative `gen_ai.output.messages` alongside the deprecated response + // attributes `endStreamSpan` writes, so tool calls survive Relay's lossy migration. + setOutputMessagesAttribute(span, { + responseText: state.responseTexts.join(''), + toolCalls: state.toolCalls, + }); + } + + endStreamSpan(span, state, recordOutputs); + }; + + const flushBuffer = (isDone: boolean): void => { + const lines = buffer.split('\n'); + // Keep the last (potentially incomplete) line in the buffer unless the stream is done. + buffer = isDone ? '' : (lines.pop() ?? ''); + for (const line of lines) { + processLine(line, state, recordOutputs, toolCallAccumulator); + } + }; + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await reader.read(); + + if (done) { + buffer += decoder.decode(); + flushBuffer(true); + finish(); + controller.close(); + return; + } + + buffer += decoder.decode(value, { stream: true }); + flushBuffer(false); + controller.enqueue(value); + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + finish(); + controller.error(error); + } + }, + async cancel(reason) { + finish(); + await reader.cancel(reason); + }, + }); +} diff --git a/packages/core/src/tracing/workers-ai/types.ts b/packages/core/src/tracing/workers-ai/types.ts new file mode 100644 index 000000000000..33334a6ed1bf --- /dev/null +++ b/packages/core/src/tracing/workers-ai/types.ts @@ -0,0 +1,58 @@ +import type { AIRecordingOptions } from '../ai/utils'; + +export interface WorkersAiOptions extends AIRecordingOptions { + /** + * Enable or disable truncation of recorded input messages. + * Defaults to `true`. + */ + enableTruncation?: boolean; +} + +/** + * Minimal shape of the Cloudflare Workers AI binding (`env.AI`). + * We only rely on the `run` method, everything else is passed through untouched. + * @see https://developers.cloudflare.com/workers-ai/configuration/bindings/ + */ +export interface WorkersAiClient { + run: (model: string, inputs: Record, options?: Record) => Promise; + [key: string]: unknown; +} + +/** + * The token usage reported by Workers AI text generation models. + */ +export interface WorkersAiUsage { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; +} + +/** + * The (subset of) inputs accepted by Workers AI `run` calls that we read from. + * @see https://developers.cloudflare.com/workers-ai/models/ + */ +export interface WorkersAiInput { + // Text generation + prompt?: string; + messages?: Array<{ role?: string; content?: unknown }>; + stream?: boolean; + max_tokens?: number; + temperature?: number; + top_p?: number; + top_k?: number; + frequency_penalty?: number; + presence_penalty?: number; + tools?: unknown; + functions?: unknown; + // Text embeddings + text?: string | string[]; +} + +/** + * The (subset of) outputs returned by Workers AI text generation models that we read from. + */ +export interface WorkersAiOutput { + response?: unknown; + tool_calls?: unknown[]; + usage?: WorkersAiUsage; +} diff --git a/packages/core/src/tracing/workers-ai/utils.ts b/packages/core/src/tracing/workers-ai/utils.ts new file mode 100644 index 000000000000..23eac0eaa6fc --- /dev/null +++ b/packages/core/src/tracing/workers-ai/utils.ts @@ -0,0 +1,225 @@ +import { + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_FREQUENCY_PENALTY, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_PRESENCE_PENALTY, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_K, + GEN_AI_REQUEST_TOP_P, + GEN_AI_PROVIDER_NAME, + GEN_AI_SYSTEM_INSTRUCTIONS, +} from '@sentry/conventions/attributes'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { Span, SpanAttributeValue } from '../../types/span'; +import { + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { extractSystemInstructions, getTruncatedJsonString, setTokenUsageAttributes } from '../ai/utils'; +import { stringify } from '../../utils/string'; +import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; +import type { WorkersAiInput, WorkersAiOutput } from './types'; + +/** + * Determine the gen_ai operation name from the inputs passed to `AI.run`. + * Workers AI exposes a single `run` method, so we infer the operation from the input shape. + */ +export function getOperationName(inputs: unknown): string { + if (inputs && typeof inputs === 'object') { + if ('messages' in inputs || 'prompt' in inputs) { + return 'chat'; + } + if ('text' in inputs) { + return 'embeddings'; + } + } + return 'chat'; +} + +/** + * Extract the request attributes (model, request parameters, system, origin) from a `run` call. + */ +export function extractRequestAttributes( + model: unknown, + inputs: unknown, + operationName: string, +): Record { + const attributes: Record = { + [GEN_AI_PROVIDER_NAME]: WORKERS_AI_PROVIDER_NAME, + [GEN_AI_OPERATION_NAME]: operationName, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: WORKERS_AI_ORIGIN, + [GEN_AI_REQUEST_MODEL]: typeof model === 'string' ? model : 'unknown', + }; + + if (inputs && typeof inputs === 'object') { + const params = inputs as WorkersAiInput; + + if (typeof params.temperature === 'number') { + attributes[GEN_AI_REQUEST_TEMPERATURE] = params.temperature; + } + if (typeof params.max_tokens === 'number') { + attributes[GEN_AI_REQUEST_MAX_TOKENS] = params.max_tokens; + } + if (typeof params.top_p === 'number') { + attributes[GEN_AI_REQUEST_TOP_P] = params.top_p; + } + if (typeof params.top_k === 'number') { + attributes[GEN_AI_REQUEST_TOP_K] = params.top_k; + } + if (typeof params.frequency_penalty === 'number') { + attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY] = params.frequency_penalty; + } + if (typeof params.presence_penalty === 'number') { + attributes[GEN_AI_REQUEST_PRESENCE_PENALTY] = params.presence_penalty; + } + if (params.stream === true) { + attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = true; + } + } + + return attributes; +} + +/** + * Record the request inputs (messages/prompt/embeddings input) on the span. + * Only called when `recordInputs` is enabled. + */ +export function addRequestAttributes( + span: Span, + inputs: unknown, + operationName: string, + enableTruncation: boolean, +): void { + if (!inputs || typeof inputs !== 'object') { + return; + } + const params = inputs as WorkersAiInput; + + // Store embeddings input on a separate attribute and do not truncate it + if (operationName === 'embeddings') { + const text = params.text; + + if (text == null || (typeof text === 'string' && text.length === 0) || (Array.isArray(text) && text.length === 0)) { + return; + } + + span.setAttribute(GEN_AI_EMBEDDINGS_INPUT, typeof text === 'string' ? text : JSON.stringify(text)); + return; + } + + const src = params.messages ?? params.prompt; + + if (src == null || (Array.isArray(src) && src.length === 0)) { + return; + } + + const { systemInstructions, filteredMessages } = extractSystemInstructions(src); + + if (systemInstructions) { + span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); + } + + span.setAttribute( + GEN_AI_INPUT_MESSAGES, + enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), + ); + + span.setAttribute( + GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, + Array.isArray(filteredMessages) ? filteredMessages.length : 1, + ); +} + +/** + * Build the `gen_ai.output.messages` value (a single assistant message with text and/or + * tool-call parts) from the response text and tool calls. + * + * We set this in addition to the deprecated `gen_ai.response.text` / `gen_ai.response.tool_calls` + * attributes because Sentry's product reads the model output from `gen_ai.output.messages` first. + * Relay migrates `gen_ai.response.text` into `gen_ai.output.messages`, but the tool-calls half of + * that migration is lossy — so tool-call turns would otherwise render an empty Output. Emitting the + * normalized message here (mirroring the Vercel AI integration) keeps tool calls visible. + */ +export function setOutputMessagesAttribute( + span: Span, + { responseText, toolCalls }: { responseText?: string; toolCalls?: unknown[] }, +): void { + const parts: Array> = []; + + if (typeof responseText === 'string' && responseText.length > 0) { + parts.push({ type: 'text', content: responseText }); + } + + if (Array.isArray(toolCalls)) { + for (const toolCall of toolCalls) { + if (!toolCall || typeof toolCall !== 'object') { + continue; + } + const call = toolCall as { + id?: unknown; + function?: { name?: unknown; arguments?: unknown }; + name?: unknown; + arguments?: unknown; + }; + // Normalize both the OpenAI-compatible shape (name/arguments nested under `function`) + // and the native Workers AI shape (name/arguments at the top level). + const name = call.function?.name ?? call.name; + const args = call.function?.arguments ?? call.arguments; + parts.push({ + type: 'tool_call', + id: call.id, + name, + arguments: typeof args === 'string' ? args : JSON.stringify(args ?? {}), + }); + } + } + + if (parts.length > 0) { + span.setAttribute(GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, JSON.stringify([{ role: 'assistant', parts }])); + } +} + +/** + * Record the response attributes (token usage, response text, tool calls) on the span. + */ +export function addResponseAttributes(span: Span, result: unknown, recordOutputs: boolean): void { + if ( + !result || + typeof result !== 'object' || + // Raw `Response` objects (from `returnRawResponse`/`websocket`) cannot be introspected without consuming them. + (typeof Response !== 'undefined' && result instanceof Response) + ) { + return; + } + + const response = result as WorkersAiOutput; + + if (response.usage) { + setTokenUsageAttributes(span, response.usage.prompt_tokens, response.usage.completion_tokens); + } + + if (recordOutputs) { + let responseText: string | undefined; + if (typeof response.response === 'string') { + responseText = response.response; + span.setAttribute(GEN_AI_RESPONSE_TEXT_ATTRIBUTE, response.response); + } else if (response.response != null) { + responseText = JSON.stringify(response.response); + span.setAttribute(GEN_AI_RESPONSE_TEXT_ATTRIBUTE, responseText); + } + + const toolCalls = + Array.isArray(response.tool_calls) && response.tool_calls.length > 0 ? response.tool_calls : undefined; + if (toolCalls) { + span.setAttribute(GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, JSON.stringify(toolCalls)); + } + + setOutputMessagesAttribute(span, { responseText, toolCalls }); + } +} diff --git a/packages/core/test/lib/tracing/workers-ai-streaming.test.ts b/packages/core/test/lib/tracing/workers-ai-streaming.test.ts new file mode 100644 index 000000000000..8de48172d6d8 --- /dev/null +++ b/packages/core/test/lib/tracing/workers-ai-streaming.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import type { Span } from '../../../src'; +import { + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../src/tracing/ai/gen-ai-attributes'; +import { instrumentWorkersAiStream } from '../../../src/tracing/workers-ai/streaming'; + +function createMockSpan(): { span: Span; attributes: Record; ended: () => boolean } { + const attributes: Record = {}; + let isEnded = false; + const span = { + isRecording: () => !isEnded, + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + setAttributes: (attrs: Record) => { + Object.assign(attributes, attrs); + }, + setStatus: () => {}, + end: () => { + isEnded = true; + }, + } as unknown as Span; + return { span, attributes, ended: () => isEnded }; +} + +function streamFromChunks(chunks: string[]): ReadableStream { + const encoder = new TextEncoder(); + const queued = [...chunks]; + return new ReadableStream({ + pull(controller) { + const next = queued.shift(); + if (next === undefined) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(next)); + }, + }); +} + +describe('instrumentWorkersAiStream', () => { + it('passes the original bytes through untouched', async () => { + const { span } = createMockSpan(); + const raw = 'data: {"response":"Hello"}\n\ndata: [DONE]\n\n'; + + const instrumented = instrumentWorkersAiStream(streamFromChunks([raw]), span, true); + const passedThrough = await new Response(instrumented).text(); + + expect(passedThrough).toBe(raw); + }); + + it('accumulates response text and usage across SSE events split across read boundaries', async () => { + const { span, attributes, ended } = createMockSpan(); + // The second event is deliberately split mid-line across two reads. + const chunks = [ + 'data: {"response":"The capital "}\n\ndata: {"respo', + 'nse":"of France "}\n\ndata: {"response":"is Paris."', + ',"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toBe(true); + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('The capital of France is Paris.'); + expect(attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toBe(12); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(7); + expect(attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toBe(19); + expect(ended()).toBe(true); + }); + + it('records usage but not response text when recordOutputs is false', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"response":"secret"}\n\n', + 'data: {"response":"","usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, false); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(1); + }); + + it('ignores malformed SSE payloads without throwing', async () => { + const { span, attributes, ended } = createMockSpan(); + const chunks = ['data: not-json\n\n', 'data: {"response":"ok"}\n\ndata: [DONE]\n\n']; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('ok'); + expect(ended()).toBe(true); + }); + + // Models routed through the OpenAI-compatible endpoint (e.g. via `workers-ai-provider`, + // which the Cloudflare Agents SDK uses) stream `choices[].delta.content` instead of the + // native top-level `response` field. Usage still arrives top-level, which is why the + // pre-fix parser captured tokens but dropped the response text entirely. + it('accumulates response text from OpenAI-compatible choices[].delta.content chunks', async () => { + const { span, attributes, ended } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"content":"The capital "},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"delta":{"content":"of France "},"finish_reason":null}]}\n\n', + 'data: {"choices":[{"delta":{"content":"is Paris."},"finish_reason":"stop"}]}\n\n', + 'data: {"usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toBe(true); + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('The capital of France is Paris.'); + expect(attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]).toBe(12); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(7); + expect(attributes[GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]).toBe(19); + expect(ended()).toBe(true); + }); + + it('assembles fragmented OpenAI-compatible tool calls from choices[].delta.tool_calls', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"getRepoInfo","arguments":"{\\"owner\\":"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"cloudflare\\",\\"name\\":\\"agents\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + const toolCalls = JSON.parse(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] as string); + expect(toolCalls).toEqual([ + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: 'getRepoInfo', arguments: '{"owner":"cloudflare","name":"agents"}' }, + }, + ]); + + // The product reads model output from `gen_ai.output.messages`; tool calls must appear there too. + expect(JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE] as string)).toEqual([ + { + role: 'assistant', + parts: [ + { + type: 'tool_call', + id: 'call_1', + name: 'getRepoInfo', + arguments: '{"owner":"cloudflare","name":"agents"}', + }, + ], + }, + ]); + }); + + // Some Workers AI models (e.g. `@cf/moonshotai/kimi-k2.6`) stream tool calls with the + // name/arguments at the top level of the tool-call object rather than nested under `function`. + it('assembles tool calls whose name/arguments are at the top level of the delta', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","name":"getRepoInfo","arguments":"{\\"owner\\":"}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"arguments":"\\"cloudflare\\"}"}]}}]}\n\n', + 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, true); + await new Response(instrumented).text(); + + const toolCalls = JSON.parse(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] as string); + expect(toolCalls).toEqual([ + { + index: 0, + id: 'call_1', + type: undefined, + function: { name: 'getRepoInfo', arguments: '{"owner":"cloudflare"}' }, + }, + ]); + + expect(JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE] as string)).toEqual([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'getRepoInfo', arguments: '{"owner":"cloudflare"}' }], + }, + ]); + }); + + it('does not record OpenAI-compatible output when recordOutputs is false', async () => { + const { span, attributes } = createMockSpan(); + const chunks = [ + 'data: {"choices":[{"delta":{"content":"secret"}}]}\n\n', + 'data: {"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}\n\ndata: [DONE]\n\n', + ]; + + const instrumented = instrumentWorkersAiStream(streamFromChunks(chunks), span, false); + await new Response(instrumented).text(); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBeUndefined(); + expect(attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]).toBe(1); + }); + + it('ends the span when the consumer cancels the stream', async () => { + const { span, attributes, ended } = createMockSpan(); + + const instrumented = instrumentWorkersAiStream(streamFromChunks(['data: {"response":"partial"}\n\n']), span, true); + + const reader = instrumented.getReader(); + await reader.read(); + await reader.cancel('no longer needed'); + + expect(ended()).toBe(true); + expect(attributes[GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]).toBe(true); + }); +}); diff --git a/packages/core/test/lib/tracing/workers-ai.test.ts b/packages/core/test/lib/tracing/workers-ai.test.ts new file mode 100644 index 000000000000..d9c563b59962 --- /dev/null +++ b/packages/core/test/lib/tracing/workers-ai.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { instrumentWorkersAiClient } from '../../../src/tracing/workers-ai'; + +describe('instrumentWorkersAiClient', () => { + it('passes through non-run methods bound to the original client', () => { + class FakeAi { + #internal = 'secret'; + + public run = vi.fn().mockResolvedValue({ response: 'ok' }); + + public readInternal(): string { + return this.#internal; + } + } + + const client = new FakeAi(); + const instrumented = instrumentWorkersAiClient(client); + + expect(instrumented.readInternal()).toBe('secret'); + }); + + it('creates a span and forwards arguments for run calls', async () => { + const client = { run: vi.fn().mockResolvedValue({ response: 'Paris' }) }; + const instrumented = instrumentWorkersAiClient(client); + + const result = await instrumented.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + + expect(client.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + expect(result).toEqual({ response: 'Paris' }); + }); +}); diff --git a/packages/core/test/lib/utils/workers-ai-utils.test.ts b/packages/core/test/lib/utils/workers-ai-utils.test.ts new file mode 100644 index 000000000000..3eefe0bf79bc --- /dev/null +++ b/packages/core/test/lib/utils/workers-ai-utils.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../../src'; +import type { Span } from '../../../src'; +import { + GEN_AI_PROVIDER_NAME, + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_REQUEST_FREQUENCY_PENALTY, + GEN_AI_REQUEST_MAX_TOKENS, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_PRESENCE_PENALTY, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_REQUEST_TOP_K, + GEN_AI_REQUEST_TOP_P, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { + GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, +} from '../../../src/tracing/ai/gen-ai-attributes'; +import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from '../../../src/tracing/workers-ai/constants'; +import { + addRequestAttributes, + addResponseAttributes, + extractRequestAttributes, + getOperationName, +} from '../../../src/tracing/workers-ai/utils'; + +const MODEL = '@cf/meta/llama-3.1-8b-instruct'; + +function createMockSpan(): { span: Span; attributes: Record } { + const attributes: Record = {}; + const span = { + setAttribute: (key: string, value: unknown) => { + attributes[key] = value; + }, + setAttributes: (attrs: Record) => { + Object.assign(attributes, attrs); + }, + } as unknown as Span; + return { span, attributes }; +} + +describe('workers-ai utils', () => { + describe('getOperationName', () => { + it('returns "chat" for prompt inputs', () => { + expect(getOperationName({ prompt: 'Hello' })).toBe('chat'); + }); + + it('returns "chat" for messages inputs', () => { + expect(getOperationName({ messages: [{ role: 'user', content: 'Hi' }] })).toBe('chat'); + }); + + it('returns "embeddings" for text inputs', () => { + expect(getOperationName({ text: 'embed me' })).toBe('embeddings'); + }); + + it('prefers "chat" when both messages and text are present', () => { + expect(getOperationName({ messages: [{ role: 'user', content: 'Hi' }], text: 'embed me' })).toBe('chat'); + }); + + it('falls back to "chat" for null, undefined and empty inputs', () => { + expect(getOperationName(null)).toBe('chat'); + expect(getOperationName(undefined)).toBe('chat'); + expect(getOperationName({})).toBe('chat'); + }); + }); + + describe('extractRequestAttributes', () => { + it('sets exactly the base attributes for a minimal request', () => { + expect(extractRequestAttributes(MODEL, { prompt: 'Hello' }, 'chat')).toEqual({ + [GEN_AI_PROVIDER_NAME]: WORKERS_AI_PROVIDER_NAME, + [GEN_AI_OPERATION_NAME]: 'chat', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: WORKERS_AI_ORIGIN, + [GEN_AI_REQUEST_MODEL]: MODEL, + }); + }); + + it('maps all supported request parameters', () => { + expect( + extractRequestAttributes( + MODEL, + { + prompt: 'Hello', + temperature: 0.5, + max_tokens: 100, + top_p: 0.9, + top_k: 40, + frequency_penalty: 0.1, + presence_penalty: 0.2, + stream: true, + }, + 'chat', + ), + ).toEqual({ + [GEN_AI_PROVIDER_NAME]: WORKERS_AI_PROVIDER_NAME, + [GEN_AI_OPERATION_NAME]: 'chat', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: WORKERS_AI_ORIGIN, + [GEN_AI_REQUEST_MODEL]: MODEL, + [GEN_AI_REQUEST_TEMPERATURE]: 0.5, + [GEN_AI_REQUEST_MAX_TOKENS]: 100, + [GEN_AI_REQUEST_TOP_P]: 0.9, + [GEN_AI_REQUEST_TOP_K]: 40, + [GEN_AI_REQUEST_FREQUENCY_PENALTY]: 0.1, + [GEN_AI_REQUEST_PRESENCE_PENALTY]: 0.2, + [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: true, + }); + }); + + it('does not set the stream attribute when stream is false', () => { + const attrs = extractRequestAttributes(MODEL, { prompt: 'Hello', stream: false }, 'chat'); + expect(attrs[GEN_AI_REQUEST_STREAM_ATTRIBUTE]).toBeUndefined(); + }); + + it('falls back to "unknown" model when model is not a string', () => { + const attrs = extractRequestAttributes(undefined, { prompt: 'Hello' }, 'chat'); + expect(attrs[GEN_AI_REQUEST_MODEL]).toBe('unknown'); + }); + }); + + describe('addRequestAttributes', () => { + it('records messages and extracts system instructions', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes( + span, + { + messages: [ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Hi' }, + ], + }, + 'chat', + false, + ); + + expect(attributes[GEN_AI_SYSTEM_INSTRUCTIONS]).toBe( + JSON.stringify([{ type: 'text', content: 'You are helpful.' }]), + ); + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBe(JSON.stringify([{ role: 'user', content: 'Hi' }])); + }); + + it('records the prompt string directly', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { prompt: 'Hello world' }, 'chat', false); + + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBe('Hello world'); + }); + + it('records embeddings input on a dedicated attribute', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { text: ['embed a', 'embed b'] }, 'embeddings', false); + + expect(attributes).toEqual({ [GEN_AI_EMBEDDINGS_INPUT]: JSON.stringify(['embed a', 'embed b']) }); + }); + + it('records nothing for an empty messages array', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { messages: [] }, 'chat', false); + + expect(attributes).toEqual({}); + }); + + it('records nothing for empty embeddings input', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, { text: '' }, 'embeddings', false); + + expect(attributes).toEqual({}); + }); + + it('records nothing when inputs are missing', () => { + const { span, attributes } = createMockSpan(); + + addRequestAttributes(span, undefined, 'chat', false); + + expect(attributes).toEqual({}); + }); + }); + + describe('addResponseAttributes', () => { + it('sets token usage and computes the total from input and output tokens', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris', usage: { prompt_tokens: 12, completion_tokens: 7 } }, false); + + expect(attributes).toEqual({ + [GEN_AI_USAGE_INPUT_TOKENS]: 12, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 7, + [GEN_AI_USAGE_TOTAL_TOKENS]: 19, + }); + }); + + it('does not record response text when recordOutputs is false', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris' }, false); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBeUndefined(); + }); + + it('records response text when recordOutputs is true', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris' }, true); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('Paris'); + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([{ role: 'assistant', parts: [{ type: 'text', content: 'Paris' }] }]), + ); + }); + + it('records tool calls when recordOutputs is true', () => { + const { span, attributes } = createMockSpan(); + const toolCalls = [{ id: 'call_1', name: 'lookup', arguments: { city: 'Paris' } }]; + + addResponseAttributes(span, { tool_calls: toolCalls }, true); + + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]).toBe(JSON.stringify(toolCalls)); + // The product reads model output from `gen_ai.output.messages`; tool calls must appear there too. + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'lookup', arguments: JSON.stringify({ city: 'Paris' }) }], + }, + ]), + ); + }); + + it('normalizes OpenAI-compatible tool calls (name/arguments nested under function) into output messages', () => { + const { span, attributes } = createMockSpan(); + const toolCalls = [ + { id: 'call_1', type: 'function', function: { name: 'lookup', arguments: '{"city":"Paris"}' } }, + ]; + + addResponseAttributes(span, { tool_calls: toolCalls }, true); + + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [{ type: 'tool_call', id: 'call_1', name: 'lookup', arguments: '{"city":"Paris"}' }], + }, + ]), + ); + }); + + it('records both response text and tool calls without overwriting each other', () => { + const { span, attributes } = createMockSpan(); + const toolCalls = [{ id: 'call_1', name: 'lookup', arguments: { city: 'Paris' } }]; + + addResponseAttributes(span, { response: 'Looking that up', tool_calls: toolCalls }, true); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe('Looking that up'); + expect(attributes[GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]).toBe(JSON.stringify(toolCalls)); + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBe( + JSON.stringify([ + { + role: 'assistant', + parts: [ + { type: 'text', content: 'Looking that up' }, + { type: 'tool_call', id: 'call_1', name: 'lookup', arguments: JSON.stringify({ city: 'Paris' }) }, + ], + }, + ]), + ); + }); + + it('does not set output messages when recordOutputs is false', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: 'Paris', tool_calls: [{ name: 'lookup' }] }, false); + + expect(attributes[GEN_AI_OUTPUT_MESSAGES_ATTRIBUTE]).toBeUndefined(); + }); + + it('serializes non-string response payloads as JSON', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, { response: { translated_text: 'Bonjour' } }, true); + + expect(attributes[GEN_AI_RESPONSE_TEXT_ATTRIBUTE]).toBe(JSON.stringify({ translated_text: 'Bonjour' })); + }); + + it('ignores raw Response objects', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, new Response('raw'), true); + + expect(attributes).toEqual({}); + }); + + it('ignores non-object results', () => { + const { span, attributes } = createMockSpan(); + + addResponseAttributes(span, null, true); + + expect(attributes).toEqual({}); + }); + }); +}); From ee99468f6d4b0db370d753378a8d5d687ab61f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 20 Jul 2026 12:42:32 +0300 Subject: [PATCH 53/54] feat(cloudflare): Auto-instrument Workers AI binding via env instrumentation (#22126) This auto-wraps workers-ai bindings via `instrumentEnv`. Unfortunately locally there is no AI binding available and would require an actual LLM on Cloudflare, so in the integration tests there is only a MockAi. However, the autobinding is tested via unit tests and have the same pattern as the other instrumentations. --------- Co-authored-by: Claude Opus 4.8 --- .size-limit.js | 4 +- .../cloudflare-integration-tests/package.json | 1 + .../suites/tracing/workers-ai/index.ts | 53 +++++++ .../suites/tracing/workers-ai/mocks.ts | 45 ++++++ .../suites/tracing/workers-ai/test.ts | 148 ++++++++++++++++++ .../suites/tracing/workers-ai/wrangler.jsonc | 6 + .../instrumentations/worker/instrumentEnv.ts | 9 ++ packages/cloudflare/src/utils/isBinding.ts | 16 +- .../instrumentations/instrumentEnv.test.ts | 43 +++++ 9 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/wrangler.jsonc diff --git a/.size-limit.js b/.size-limit.js index 2dac16a7922c..476a39a18815 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -460,7 +460,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '183 KiB', + limit: '193 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -480,7 +480,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '448 KiB', + limit: '475 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index c360d7449ed6..7f3362d1f612 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -23,6 +23,7 @@ "@prisma/adapter-d1": "6.15.0", "@prisma/client": "6.15.0", "@sentry/cloudflare": "10.66.0", + "@sentry/core": "10.66.0", "@sentry/hono": "10.66.0", "hono": "^4.12.25", "openai": "5.18.1" diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts new file mode 100644 index 000000000000..9064ea1cbd4e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -0,0 +1,53 @@ +import * as Sentry from '@sentry/cloudflare'; +import { instrumentWorkersAiClient } from '@sentry/core'; +import { MockAi } from './mocks'; + +interface Env { + SENTRY_DSN: string; +} + +const ai = instrumentWorkersAiClient(new MockAi()); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + // Keep gen_ai spans embedded in the transaction (instead of streamed as a + // separate envelope container) so they can be asserted on `transaction.spans`. + streamGenAiSpans: false, + }), + { + async fetch(request) { + const url = new URL(request.url); + + if (url.pathname === '/error') { + // The Workers AI integration deliberately does not call `captureException` itself. + // A failing `run` must bubble up out of the handler so the top-level Cloudflare + // instrumentation reports it instead — showing up in Sentry exactly once. + const result = await ai.run('error-model', { prompt: 'Hello' }); + return new Response(JSON.stringify(result)); + } + + if (url.pathname === '/stream') { + const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [{ role: 'user', content: 'What is the capital of France?' }], + stream: true, + })) as ReadableStream; + + const text = await new Response(stream).text(); + return new Response(text); + } + + const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', { + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + return new Response(JSON.stringify(result)); + }, + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts new file mode 100644 index 000000000000..bce096d4ae75 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts @@ -0,0 +1,45 @@ +import { simulateReadableStream } from 'ai'; + +function createSseStream(events: string[]): ReadableStream { + const encoder = new TextEncoder(); + return simulateReadableStream({ + initialDelayInMs: 0, + chunkDelayInMs: 0, + chunks: events.map(event => encoder.encode(`data: ${event}\n\n`)), + }); +} + +/** + * Minimal mock of the Cloudflare Workers AI binding (`env.AI`). + */ +export class MockAi { + public async run(model: string, inputs: Record): Promise { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (model === 'error-model') { + const error = new Error('Model not found'); + (error as unknown as { status: number }).status = 404; + throw error; + } + + if (inputs?.stream === true) { + return createSseStream([ + '{"response":"The capital "}', + '{"response":"of France "}', + '{"response":"is Paris."}', + '{"response":"","usage":{"prompt_tokens":12,"completion_tokens":7,"total_tokens":19}}', + '[DONE]', + ]); + } + + return { + response: 'The capital of France is Paris.', + usage: { + prompt_tokens: 12, + completion_tokens: 7, + total_tokens: 19, + }, + }; + } +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts new file mode 100644 index 000000000000..e5c835f37508 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -0,0 +1,148 @@ +import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from '../../../../../packages/core/src/tracing/ai/gen-ai-attributes'; +import { createRunner } from '../../../runner'; + +// These tests are not exhaustive because the instrumentation is +// already tested in the core unit tests and we merely want to test +// that the instrumentation does not break in our cloudflare SDK. + +it('traces a basic Workers AI text generation request', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + // The transaction event is framework-generated and carries non-deterministic fields + // (random ports, ids, timestamps, sdk version), so we assert the stable subset. + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /', + transaction_info: { source: 'route' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + status: 'ok', + }), + }), + spans: [ + expect.objectContaining({ + description: 'chat @cf/meta/llama-3.1-8b-instruct', + op: 'gen_ai.chat', + origin: 'auto.ai.cloudflare.workers_ai', + data: { + 'sentry.origin': 'auto.ai.cloudflare.workers_ai', + 'sentry.op': 'gen_ai.chat', + [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct', + [GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE]: 0.7, + [GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE]: 100, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19, + }, + }), + ], + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/'); + await runner.completed(); +}); + +it('traces a streaming Workers AI text generation request', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as any; + + expect(transactionEvent).toEqual( + expect.objectContaining({ + type: 'transaction', + transaction: 'GET /stream', + transaction_info: { source: 'url' }, + contexts: expect.objectContaining({ + trace: expect.objectContaining({ + op: 'http.server', + origin: 'auto.http.cloudflare', + status: 'ok', + }), + }), + spans: [ + expect.objectContaining({ + description: 'chat @cf/meta/llama-3.1-8b-instruct', + op: 'gen_ai.chat', + origin: 'auto.ai.cloudflare.workers_ai', + data: { + 'sentry.origin': 'auto.ai.cloudflare.workers_ai', + 'sentry.op': 'gen_ai.chat', + [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: '@cf/meta/llama-3.1-8b-instruct', + [GEN_AI_REQUEST_STREAM_ATTRIBUTE]: true, + [GEN_AI_RESPONSE_STREAMING_ATTRIBUTE]: true, + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: 12, + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: 7, + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: 19, + }, + }), + ], + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/stream'); + await runner.completed(); +}); + +// The Workers AI integration deliberately does not call `captureException` itself. +// When a `run` call fails, the error must bubble up out of the fetch handler and be +// reported by the top-level Cloudflare instrumentation instead — so it shows up in +// Sentry exactly once, with the `auto.http.cloudflare` mechanism. +it('bubbles up Workers AI errors to be captured by the top-level handler', async ({ signal }) => { + const runner = createRunner(__dirname) + // A failing run still produces a (sampled) transaction; we only care about the error event here. + .ignore('transaction') + .expect(envelope => { + const errorEvent = envelope[1]?.[0]?.[1] as any; + + expect(errorEvent).toEqual( + expect.objectContaining({ + level: 'error', + exception: { + values: [ + expect.objectContaining({ + type: 'Error', + value: 'Model not found', + stacktrace: { + frames: expect.any(Array), + }, + mechanism: { type: 'auto.http.cloudflare', handled: false }, + }), + ], + }, + request: expect.objectContaining({ + method: 'GET', + url: expect.any(String), + }), + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/wrangler.jsonc new file mode 100644 index 000000000000..2cdb43e06500 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/wrangler.jsonc @@ -0,0 +1,6 @@ +{ + "name": "workers-ai-worker", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 4f8eb0aa3142..474fbf831b86 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,6 +1,8 @@ import { isObjectLike } from '@sentry/core'; +import { instrumentWorkersAiClient } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; import { + isAiBinding, isD1Database, isDurableObjectNamespace, isJSRPC, @@ -33,6 +35,7 @@ const instrumentedBindings = new WeakMap(); * - Queue producers (via `send` + `sendBatch` duck-typing) * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) * - Rate limiters (via `limit` duck-typing) + * - Workers AI (via `run` + `gateway` + `toMarkdown` duck-typing) * * @param env - The Cloudflare env object to instrument * @param options - Optional CloudflareOptions to control RPC trace propagation @@ -85,6 +88,12 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isAiBinding(item)) { + const instrumented = instrumentWorkersAiClient(item); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (!rpcPropagation) { return item; } diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index c8f6387f3c07..4f4e6d012c74 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { D1Database, DurableObjectNamespace, Queue, R2Bucket, RateLimit } from '@cloudflare/workers-types'; +import type { Ai, D1Database, DurableObjectNamespace, Queue, R2Bucket, RateLimit } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -82,6 +82,20 @@ export function isD1Database(item: unknown): item is D1Database { ); } +/** + * Duck-type check for Workers AI bindings. + * The Ai binding has `run`, `gateway`, and `toMarkdown` methods. + */ +export function isAiBinding(item: unknown): item is Ai { + return ( + item != null && + isNotJSRPC(item) && + typeof item.run === 'function' && + typeof item.gateway === 'function' && + typeof item.toMarkdown === 'function' + ); +} + /** * Duck-type check for R2 Bucket bindings. * R2Bucket has `head`, `put`, and `createMultipartUpload` methods. diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 40c575898de0..72f9d0774507 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -284,6 +284,49 @@ describe('instrumentEnv', () => { expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER); }); + describe('Workers AI bindings', () => { + function createMockAiBinding() { + return { + run: vi.fn().mockResolvedValue({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }), + gateway: vi.fn(), + toMarkdown: vi.fn(), + models: vi.fn(), + autorag: vi.fn(), + }; + } + + it('detects and wraps AI bindings, forwarding run calls unchanged', async () => { + const ai = createMockAiBinding(); + const env = { AI: ai }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.AI as typeof ai; + expect(wrapped).not.toBe(ai); + + const result = await wrapped.run('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + + expect(ai.run).toHaveBeenCalledTimes(1); + expect(ai.run).toHaveBeenCalledWith('@cf/meta/llama-3.1-8b-instruct', { prompt: 'Hello' }); + expect(result).toEqual({ response: 'Paris', usage: { prompt_tokens: 1, completion_tokens: 2 } }); + }); + + it('caches the wrapped AI binding across repeated access', () => { + const ai = createMockAiBinding(); + const env = { AI: ai }; + const instrumented = instrumentEnv(env); + + expect(instrumented.AI).toBe(instrumented.AI); + }); + + it('does not treat bindings with only a run method as AI bindings', () => { + const notAi = { run: vi.fn() }; + const env = { RUNNER: notAi }; + const instrumented = instrumentEnv(env); + + expect(instrumented.RUNNER).toBe(notAi); + }); + }); + describe('mTLS Fetcher bindings', () => { function createMtlsFetcherProxy(mockFetch: ReturnType) { return new Proxy( From 3c20967d90de9359b829d570460382f871db6ccf Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Mon, 20 Jul 2026 12:58:25 +0200 Subject: [PATCH 54/54] meta(changelog): Update changelog for 10.67.0 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b588cf99ab66..f8190e95a4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,76 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! +## 10.67.0 ### Important Changes - **feat(sveltekit): Add support for SvelteKit 3 ([#22264](https://github.com/getsentry/sentry-javascript/pull/22264))** - The SvelteKit SDK now supports SvelteKit 3, including client-side pageload and navigation tracing and server-side native tracing, alongside continued SvelteKit 2 support. No Sentry-specific setup changes are required. The SDK detects your SvelteKit version and picks the right implementation automatically. + The SvelteKit SDK now supports the pre-release of SvelteKit 3, including client-side pageload and navigation tracing and server-side native tracing, alongside continued SvelteKit 2 support. No Sentry-specific setup changes are required. The SDK detects your SvelteKit version and picks the right implementation automatically. + +### Other Changes + +- feat(aws-serverless): Use orchestrion aws-sdk integration under diagnostics-channel opt-in ([#22143](https://github.com/getsentry/sentry-javascript/pull/22143)) +- feat(cloudflare): Auto-instrument Workers AI binding via env instrumentation ([#22126](https://github.com/getsentry/sentry-javascript/pull/22126)) +- feat(cloudflare): Instrument Cloudflare rate limiter bindings ([#22035](https://github.com/getsentry/sentry-javascript/pull/22035)) +- feat(core): Instrument workers-ai-provider ([#22119](https://github.com/getsentry/sentry-javascript/pull/22119)) +- feat(core): Rename `queryParams` to `urlQueryParams` ([#22217](https://github.com/getsentry/sentry-javascript/pull/22217)) +- feat(mongodb): implement orchestrion mongodb integration ([#22308](https://github.com/getsentry/sentry-javascript/pull/22308)) +- feat(mongoose): implement orchestrion mongoose integration ([#22202](https://github.com/getsentry/sentry-javascript/pull/22202)) +- feat(node): Rewrite tedious instrumentation to orchestrion tracing channels ([#22238](https://github.com/getsentry/sentry-javascript/pull/22238)) +- feat(nuxt): Add `_experimental.useDiagnosticsChannelInjection` ([#22323](https://github.com/getsentry/sentry-javascript/pull/22323)) +- feat(remix): Add orchestrion-based remix instrumentation ([#22244](https://github.com/getsentry/sentry-javascript/pull/22244)) +- feat(replay): Allow skipping the final flush on stop() via { flush: false } ([#22300](https://github.com/getsentry/sentry-javascript/pull/22300)) +- feat(server-utils): Add @opentelemetry/instrumentation-koa orchestrion integration ([#22146](https://github.com/getsentry/sentry-javascript/pull/22146)) +- feat(server-utils): Add orchestrion aws-sdk channel integration core ([#22142](https://github.com/getsentry/sentry-javascript/pull/22142)) +- feat(server-utils): Allow passing custom transforms to orchestrion ([#22319](https://github.com/getsentry/sentry-javascript/pull/22319)) +- feat(server-utils): Port Bedrock aws-sdk extension and add integration tests ([#22166](https://github.com/getsentry/sentry-javascript/pull/22166)) +- feat(server-utils): Port S3, Kinesis, DynamoDB, SecretsManager and StepFunctions aws-sdk extensions ([#22164](https://github.com/getsentry/sentry-javascript/pull/22164)) +- feat(server-utils): Port SQS, SNS and Lambda aws-sdk extensions with trace propagation ([#22165](https://github.com/getsentry/sentry-javascript/pull/22165)) +- feat(server-utils): Rewrite `SentryLangChainInstrumentation` to orchestrion ([#22266](https://github.com/getsentry/sentry-javascript/pull/22266)) +- feat(server-utils): Rewrite `SentryLangGraphInstrumentation` to orchestrion ([#22268](https://github.com/getsentry/sentry-javascript/pull/22268)) +- feat(vue): Set `navigation.route.id` from the route name ([#22372](https://github.com/getsentry/sentry-javascript/pull/22372)) +- fix(bundler): do not import type from optional peer dep ([#22304](https://github.com/getsentry/sentry-javascript/pull/22304)) +- fix(cloudflare): Bind Durable Object built-in handlers ([#22340](https://github.com/getsentry/sentry-javascript/pull/22340)) +- fix(cloudflare): Instrument custom WorkerEntrypoint RPC methods ([#22310](https://github.com/getsentry/sentry-javascript/pull/22310)) +- fix(cloudflare): Route DO teardown through original `waitUntil` ([#22339](https://github.com/getsentry/sentry-javascript/pull/22339)) +- fix(cloudflare): Skip spans for Cloudflare-internal Durable Object SQL queries ([#22376](https://github.com/getsentry/sentry-javascript/pull/22376)) +- fix(core): Prevent functionToStringIntegration from throwing on cross-origin realms ([#22273](https://github.com/getsentry/sentry-javascript/pull/22273)) +- fix(core): Prevent infinite recursion when console is instrumented twice ([#21959](https://github.com/getsentry/sentry-javascript/pull/21959)) +- fix(nextjs): Mark internal chunk frames as not `in_app` ([#22354](https://github.com/getsentry/sentry-javascript/pull/22354)) +- fix(node): Register tracer provider when OTel API global pre-exists with a different version ([#22374](https://github.com/getsentry/sentry-javascript/pull/22374)) +- fix(node-native): Don't drop breadcrumbs from event loop block events ([#22322](https://github.com/getsentry/sentry-javascript/pull/22322)) +- fix(serve-rutils): Orchestrion diagnostics for async and require ([#22327](https://github.com/getsentry/sentry-javascript/pull/22327)) +- fix(server-utils): Only skip loader thread ([#22336](https://github.com/getsentry/sentry-javascript/pull/22336)) +- fix(sveltekit): Detect SvelteKit server build via Vite Environment API ([#21629](https://github.com/getsentry/sentry-javascript/pull/21629)) +- fix(vercelai): Avoid double-capturing v4 tool errors in orchestrion mode ([#22293](https://github.com/getsentry/sentry-javascript/pull/22293)) +- fix(vue): Support pinia 4 in peer dependency range ([#22324](https://github.com/getsentry/sentry-javascript/pull/22324)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#22357](https://github.com/getsentry/sentry-javascript/pull/22357)) +- chore: Add external contributor to CHANGELOG.md ([#22359](https://github.com/getsentry/sentry-javascript/pull/22359)) +- chore: Remove `@apm-js-collab/code-transformer` as a dependency ([#22317](https://github.com/getsentry/sentry-javascript/pull/22317)) +- chore(changelog): Add callout for SvelteKit 3 support ([#22371](https://github.com/getsentry/sentry-javascript/pull/22371)) +- chore(ci): Use openrouter keys for claude in ci ([#22381](https://github.com/getsentry/sentry-javascript/pull/22381)) +- chore(skills): Add libraries to framework update watcher ([#22326](https://github.com/getsentry/sentry-javascript/pull/22326)) +- docs(agents): Prefer reusing existing utils before adding new ones ([#22370](https://github.com/getsentry/sentry-javascript/pull/22370)) +- feat(deps): Bump @opentelemetry/semantic-conventions from 1.42.0 to 1.43.0 in the opentelemetry group ([#22333](https://github.com/getsentry/sentry-javascript/pull/22333)) +- fix(skills): Make sure digest file is written/read correctly ([#22330](https://github.com/getsentry/sentry-javascript/pull/22330)) +- ref: Omit redundant `captureError: false` from tracing-channel callsites ([#22368](https://github.com/getsentry/sentry-javascript/pull/22368)) +- ref: Use base keys from conventions for dynamic suffix attribute keys ([#22356](https://github.com/getsentry/sentry-javascript/pull/22356)) +- ref(core): Replace `sentry.span.source` attribute with `sentry.segment.name.source` ([#22358](https://github.com/getsentry/sentry-javascript/pull/22358)) +- ref(server-utils): Collapse express route/use into one orchestrion channel ([#22331](https://github.com/getsentry/sentry-javascript/pull/22331)) +- ref(server-utils): Share one orchestrion channel across OpenAI chat/responses/conversations ([#22332](https://github.com/getsentry/sentry-javascript/pull/22332)) +- refactor(nextjs): Change ESM interop. to remove lazy loading ([#22193](https://github.com/getsentry/sentry-javascript/pull/22193)) +- test(cloudflare): Add tests for `ignoreSpans` with continued traces ([#22316](https://github.com/getsentry/sentry-javascript/pull/22316)) +- test(cloudflare): Proof that AI providers work OOTB on Cloudflare ([#22313](https://github.com/getsentry/sentry-javascript/pull/22313)) + +
+ +Work in this release was contributed by @PeterWadie and @akshitsinha. Thank you for your contributions! ## 10.66.0