From a445e55006813b6b6c2727348fc0da1283e4c19c Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 16 Sep 2026 19:02:03 +0200 Subject: [PATCH 01/39] perf(bundler-plugins): Use fast component annotation for all Rollup and Vite versions (#24437) Until now, only Vite 8 used the fast (non-Babel) React component annotation transform. Rollup and Vite 5-7 always went through Babel. This PR moves every Rollup and Vite version to the fast path, as a first step toward removing Babel. Rollup's own `parseAstAsync`, which Vite 5-7 re-export, can't parse TypeScript, and the Vite plugin runs with `enforce: 'pre'`, so it sees raw `.tsx`. So this adds `oxc-parser` as a dependency for those versions. Vite 8 keeps using its own parser. `oxc-parser` needs `preserveParens: false` to give the same AST as Vite 8. It also returns syntax errors instead of throwing them, so the adapter throws on errors, and those files still fall back to Babel. Babel also stays as the fallback on platforms without an `oxc-parser` native binding. The walker's parity tests with Babel previously ran only on the Babel parser's AST. They now also run on `oxc-parser` output, which matches what Vite 8 produces. Ref #24415 --- packages/bundler-plugins/package.json | 1 + ...ast.ts => component-annotation-oxc-ast.ts} | 0 ... => component-annotation-oxc-fragments.ts} | 6 +- ...jsx.ts => component-annotation-oxc-jsx.ts} | 4 +- ...lk.ts => component-annotation-oxc-walk.ts} | 10 +- ...on-vite.ts => component-annotation-oxc.ts} | 44 +++++++-- packages/bundler-plugins/src/rollup/index.ts | 32 +++--- ...st.ts => component-annotation-oxc.test.ts} | 61 +++++++++--- .../test/rollup/public-api.test.ts | 98 +++++++++++++------ 9 files changed, 174 insertions(+), 82 deletions(-) rename packages/bundler-plugins/src/core/{component-annotation-vite-ast.ts => component-annotation-oxc-ast.ts} (100%) rename packages/bundler-plugins/src/core/{component-annotation-vite-fragments.ts => component-annotation-oxc-fragments.ts} (97%) rename packages/bundler-plugins/src/core/{component-annotation-vite-jsx.ts => component-annotation-oxc-jsx.ts} (99%) rename packages/bundler-plugins/src/core/{component-annotation-vite-walk.ts => component-annotation-oxc-walk.ts} (94%) rename packages/bundler-plugins/src/core/{component-annotation-vite.ts => component-annotation-oxc.ts} (74%) rename packages/bundler-plugins/test/core/{component-annotation-vite.test.ts => component-annotation-oxc.test.ts} (78%) diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index f9a7a9e6ca62..cf8aaae74685 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -115,6 +115,7 @@ "dotenv": "^17.4.2", "glob": "^13.0.6", "magic-string": "~0.30.8", + "oxc-parser": "^0.143.0", "sentry": "^0.44.0", "supports-color": "^8.1.1" }, diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-ast.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts similarity index 100% rename from packages/bundler-plugins/src/core/component-annotation-vite-ast.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-ast.ts diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-fragments.ts similarity index 97% rename from packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-fragments.ts index 5ce47fd58491..8baa7d027855 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite-fragments.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-fragments.ts @@ -1,6 +1,6 @@ -import type { AstNode, FragmentContext } from './component-annotation-vite-ast'; -import { isAstNode, isObjectLike, walkAst } from './component-annotation-vite-ast'; -import { getStringName } from './component-annotation-vite-jsx'; +import type { AstNode, FragmentContext } from './component-annotation-oxc-ast'; +import { isAstNode, isObjectLike, walkAst } from './component-annotation-oxc-ast'; +import { getStringName } from './component-annotation-oxc-jsx'; export function collectFragmentContext(ast: AstNode): FragmentContext { const context: FragmentContext = { diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts similarity index 99% rename from packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts index 7c5ecd6a2571..78fd40d99ae6 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite-jsx.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-jsx.ts @@ -12,8 +12,8 @@ import type { JSXFragmentNode, JSXOpeningElementNode, JSXRootNode, -} from './component-annotation-vite-ast'; -import { isAstNode, isObjectLike } from './component-annotation-vite-ast'; +} from './component-annotation-oxc-ast'; +import { isAstNode, isObjectLike } from './component-annotation-oxc-ast'; const UNKNOWN_ELEMENT_NAME = 'unknown'; const WEB_ATTRIBUTE_NAMES = [WEB_ELEMENT_NAME, WEB_COMPONENT_NAME, WEB_SOURCE_FILE_NAME] as const; diff --git a/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts similarity index 94% rename from packages/bundler-plugins/src/core/component-annotation-vite-walk.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts index 420b6ac8033a..7425d145dd4e 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite-walk.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc-walk.ts @@ -1,17 +1,17 @@ -import type { AstNode, AttributeInsertion, FragmentContext, JSXRootNode } from './component-annotation-vite-ast'; +import type { AstNode, AttributeInsertion, FragmentContext, JSXRootNode } from './component-annotation-oxc-ast'; import { addPendingAttributes, getStringName, isJSXElement, isJSXRoot, toAttributeInsertions, -} from './component-annotation-vite-jsx'; -import { isAstNode, isObjectLike, walkAst } from './component-annotation-vite-ast'; -import { collectFragmentContext } from './component-annotation-vite-fragments'; +} from './component-annotation-oxc-jsx'; +import { isAstNode, isObjectLike, walkAst } from './component-annotation-oxc-ast'; +import { collectFragmentContext } from './component-annotation-oxc-fragments'; type ComponentJSXRoots = { name: string; roots: JSXRootNode[] }; -export function collectViteComponentAnnotationInsertions( +export function collectOxcComponentAnnotationInsertions( code: string, ast: AstNode, ignoredComponents: string[], diff --git a/packages/bundler-plugins/src/core/component-annotation-vite.ts b/packages/bundler-plugins/src/core/component-annotation-oxc.ts similarity index 74% rename from packages/bundler-plugins/src/core/component-annotation-vite.ts rename to packages/bundler-plugins/src/core/component-annotation-oxc.ts index a0ce0907268e..8bb27f8a7963 100644 --- a/packages/bundler-plugins/src/core/component-annotation-vite.ts +++ b/packages/bundler-plugins/src/core/component-annotation-oxc.ts @@ -4,26 +4,50 @@ import MagicString from 'magic-string'; import { KNOWN_INCOMPATIBLE_PLUGINS } from '../babel-plugin/constants'; import { stripQueryAndHashFromPath } from './utils'; -import { isAstNode } from './component-annotation-vite-ast'; -import { collectViteComponentAnnotationInsertions } from './component-annotation-vite-walk'; +import { isAstNode } from './component-annotation-oxc-ast'; +import { collectOxcComponentAnnotationInsertions } from './component-annotation-oxc-walk'; import type { AttributeInsertion, ComponentAnnotationTransformMeta, ComponentAnnotationTransformResult, MagicStringLike, ParseAstAsync, -} from './component-annotation-vite-ast'; +} from './component-annotation-oxc-ast'; export type { ComponentAnnotationTransformMeta, ComponentAnnotationTransformResult, -} from './component-annotation-vite-ast'; +} from './component-annotation-oxc-ast'; + +let oxcParseAstAsyncPromise: Promise | undefined; + +export function getOxcParseAstAsync(): Promise { + if (!oxcParseAstAsyncPromise) { + oxcParseAstAsyncPromise = import('oxc-parser') + .then(({ parse }): ParseAstAsync => { + return async (code, { lang }) => { + // preserveParens: false matches the AST Vite 8 produces. The walker + // does not look through ParenthesizedExpression nodes. + const { program, errors } = await parse(`component.${lang}`, code, { lang, preserveParens: false }); + + if (errors.length > 0) { + throw new Error(errors[0]?.message); + } + + return program; + }; + }) + .catch(() => null); + } + + return oxcParseAstAsyncPromise; +} // Keep this as a superset of JSX tag starts Babel can annotate, because a miss suppresses Babel fallback. const JSX_TAG_START_REGEXP = /<[$_\p{ID_Start}][$_\u200c\u200d\p{ID_Continue}.:-]*|<>/u; const JSX_FILE_REGEXP = /\.[jt]sx$/; -function isViteAnnotationFile(idWithoutQueryAndHash: string): boolean { +function isAnnotationFile(idWithoutQueryAndHash: string): boolean { if (idWithoutQueryAndHash.match(/\\node_modules\\|\/node_modules\//)) { return false; } @@ -72,7 +96,7 @@ function getMagicString( return { magicString: new MagicString(code), isNative: false }; } -async function annotateWithViteParser( +async function annotateWithOxcParser( code: string, id: string, ignoredComponents: string[], @@ -83,7 +107,7 @@ async function annotateWithViteParser( if ( !idWithoutQueryAndHash || - !isViteAnnotationFile(idWithoutQueryAndHash) || + !isAnnotationFile(idWithoutQueryAndHash) || !shouldTryParse(code) || shouldSkipIncompatibleFile(idWithoutQueryAndHash) ) { @@ -100,7 +124,7 @@ async function annotateWithViteParser( return undefined; } - const insertions = collectViteComponentAnnotationInsertions( + const insertions = collectOxcComponentAnnotationInsertions( code, ast, ignoredComponents, @@ -132,7 +156,7 @@ async function annotateWithViteParser( }; } -export function createViteComponentNameAnnotateHooks( +export function createOxcComponentNameAnnotateHooks( ignoredComponents: string[], getParseAstAsync: () => Promise, ): { @@ -151,7 +175,7 @@ export function createViteComponentNameAnnotateHooks( return undefined; } - return await annotateWithViteParser(code, id, ignoredComponents, parseAstAsync, meta); + return await annotateWithOxcParser(code, id, ignoredComponents, parseAstAsync, meta); } catch { return undefined; } diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 22fe5a295046..1bb1def0b80d 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -18,7 +18,7 @@ import { import type { ComponentAnnotationTransformMeta, ComponentAnnotationTransformResult, -} from '../core/component-annotation-vite'; +} from '../core/component-annotation-oxc'; import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; import * as path from 'node:path'; @@ -41,7 +41,7 @@ type ViteModule = { }; type ViteParseAstAsync = NonNullable; -type ViteAnnotationHooks = { +type FastAnnotationHooks = { transform( code: string, id: string, @@ -168,27 +168,27 @@ export function _rollupPluginInternal( !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, ) : undefined; - const transformViteAnnotations = - options.reactComponentAnnotation?.enabled && - buildTool === 'vite' && - buildToolMajorVersion === '8' && - !options.reactComponentAnnotation?._experimentalInjectIntoHtml + const transformFastAnnotations = + options.reactComponentAnnotation?.enabled && !options.reactComponentAnnotation?._experimentalInjectIntoHtml ? (() => { - let viteAnnotationHooksPromise: Promise | undefined; + let fastAnnotationHooksPromise: Promise | undefined; return { transform(code: string, id: string, meta?: ComponentAnnotationTransformMeta) { - if (!viteAnnotationHooksPromise) { - viteAnnotationHooksPromise = import('../core/component-annotation-vite').then( - ({ createViteComponentNameAnnotateHooks }) => - createViteComponentNameAnnotateHooks( + if (!fastAnnotationHooksPromise) { + fastAnnotationHooksPromise = import('../core/component-annotation-oxc').then( + ({ createOxcComponentNameAnnotateHooks, getOxcParseAstAsync }) => + createOxcComponentNameAnnotateHooks( options.reactComponentAnnotation?.ignoredComponents || [], - getViteParseAstAsync, + // Vite 8 already loads an oxc-based parser, so reuse it. + buildTool === 'vite' && buildToolMajorVersion === '8' + ? getViteParseAstAsync + : getOxcParseAstAsync, ), ); } - return viteAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta)); + return fastAnnotationHooksPromise.then(hooks => hooks.transform(code, id, meta)); }, }; })() @@ -212,8 +212,8 @@ export function _rollupPluginInternal( // only in Sentry code. If we successfully add annotations, we can return early. let shouldRunBabelAnnotations = true; - if (transformViteAnnotations?.transform) { - const result = await transformViteAnnotations.transform(code, id, meta); + if (transformFastAnnotations?.transform) { + const result = await transformFastAnnotations.transform(code, id, meta); if (result) { return result; } diff --git a/packages/bundler-plugins/test/core/component-annotation-vite.test.ts b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts similarity index 78% rename from packages/bundler-plugins/test/core/component-annotation-vite.test.ts rename to packages/bundler-plugins/test/core/component-annotation-oxc.test.ts index 789437dd7903..2529460dfa3f 100644 --- a/packages/bundler-plugins/test/core/component-annotation-vite.test.ts +++ b/packages/bundler-plugins/test/core/component-annotation-oxc.test.ts @@ -5,9 +5,11 @@ import { describe, expect, it, vi } from 'vitest'; import componentNameAnnotatePlugin from '../../src/babel-plugin'; import { - createViteComponentNameAnnotateHooks, + createOxcComponentNameAnnotateHooks, + getOxcParseAstAsync, type ComponentAnnotationTransformResult, -} from '../../src/core/component-annotation-vite'; +} from '../../src/core/component-annotation-oxc'; +import type { ParseAstAsync } from '../../src/core/component-annotation-oxc-ast'; type Annotation = { elementName: string; @@ -80,17 +82,21 @@ async function annotateWithBabel(code: string, id: string, ignoredComponents: st return collectAnnotations(result?.code ?? '', id); } -async function annotateWithVite( +async function annotateWithOxc( code: string, id: string, ignoredComponents: string[] = [], + getParseAstAsync: () => Promise = async () => parseAstAsync, ): Promise { - const hooks = createViteComponentNameAnnotateHooks(ignoredComponents, async () => parseAstAsync); + const hooks = createOxcComponentNameAnnotateHooks(ignoredComponents, getParseAstAsync); return hooks.transform(code, id); } -describe('createViteComponentNameAnnotateHooks', () => { +describe.each<[string, () => Promise]>([ + ['@babel/parser', async () => parseAstAsync], + ['oxc-parser', getOxcParseAstAsync], +])('createOxcComponentNameAnnotateHooks with %s', (_parserName, getParseAstAsync) => { it.each([ [ 'function declarations and nested children', @@ -254,11 +260,28 @@ export function TypedComponent(props: Props) { }`, [], ], + [ + 'tsx files with parenthesized returns and TypeScript expressions', + '/src/typed-parenthesized.tsx', + `import React from "react"; + +type Props = { items?: T[] }; + +export const List = (props: Props) => { + const items = props.items!; + return ( + + item={items[0]} /> +
+ ); +};`, + [], + ], ])('matches Babel annotations for %s', async (_name, id, code, ignoredComponents) => { - const viteResult = await annotateWithVite(code, id, ignoredComponents); + const oxcResult = await annotateWithOxc(code, id, ignoredComponents, getParseAstAsync); - expect(viteResult).toBeTruthy(); - expect(collectAnnotations(viteResult?.code.toString() ?? '', id)).toEqual( + expect(oxcResult).toBeTruthy(); + expect(collectAnnotations(oxcResult?.code.toString() ?? '', id)).toEqual( await annotateWithBabel(code, id, ignoredComponents), ); }); @@ -267,10 +290,10 @@ export function TypedComponent(props: Props) { const code = `export const App = () => <${elementName} />;`; const id = '/src/app.jsx'; - const viteResult = await annotateWithVite(code, id); + const oxcResult = await annotateWithOxc(code, id, [], getParseAstAsync); - expect(viteResult).toBeTruthy(); - expect(collectAnnotations(viteResult?.code.toString() ?? '', id)).toEqual([ + expect(oxcResult).toBeTruthy(); + expect(collectAnnotations(oxcResult?.code.toString() ?? '', id)).toEqual([ { elementName, attributes: { @@ -281,14 +304,16 @@ export function TypedComponent(props: Props) { }, ]); }); +}); +describe('createOxcComponentNameAnnotateHooks', () => { it('uses the native magicString object from transform metadata when it is available', async () => { const code = `export function App() { return ; }`; const id = '/src/app.jsx'; const magicString = new MagicString(code); - const hooks = createViteComponentNameAnnotateHooks([], async () => parseAstAsync); + const hooks = createOxcComponentNameAnnotateHooks([], async () => parseAstAsync); const result = await hooks.transform(code, id, { magicString }); @@ -296,19 +321,25 @@ export function TypedComponent(props: Props) { expect(result?.code.toString()).toContain(`data-sentry-component="App"`); }); - it('returns null without parsing when the file cannot contain public Vite annotations', async () => { + it('returns null without parsing when the file cannot contain annotations', async () => { const parse = vi.fn(parseAstAsync); - const hooks = createViteComponentNameAnnotateHooks([], async () => parse); + const hooks = createOxcComponentNameAnnotateHooks([], async () => parse); await expect(hooks.transform('const value = 1;', '/src/app.js')).resolves.toBeNull(); expect(parse).not.toHaveBeenCalled(); }); it('returns undefined when parsing fails so callers can fall back to Babel', async () => { - const hooks = createViteComponentNameAnnotateHooks([], async () => { + const hooks = createOxcComponentNameAnnotateHooks([], async () => { throw new Error('parser unavailable'); }); await expect(hooks.transform('export const App = () => ;', '/src/app.jsx')).resolves.toBeUndefined(); }); + + it('returns undefined when oxc-parser reports a syntax error so callers can fall back to Babel', async () => { + const hooks = createOxcComponentNameAnnotateHooks([], getOxcParseAstAsync); + + await expect(hooks.transform('export const App = () => ;', '/src/app.tsx')).resolves.toBeUndefined(); + }); }); diff --git a/packages/bundler-plugins/test/rollup/public-api.test.ts b/packages/bundler-plugins/test/rollup/public-api.test.ts index b54077fce2bc..06b8220dee2c 100644 --- a/packages/bundler-plugins/test/rollup/public-api.test.ts +++ b/packages/bundler-plugins/test/rollup/public-api.test.ts @@ -3,15 +3,25 @@ import { createComponentNameAnnotateHooks } from '../../src/core'; import type { Plugin, SourceMap } from 'rollup'; import { describe, it, expect, test, beforeEach, vi } from 'vitest'; -const { babelCoreImportMock, transformAsyncMock, viteAnnotationModuleImportMock, viteAnnotationTransformMock } = - vi.hoisted(() => { - return { - babelCoreImportMock: vi.fn(), - transformAsyncMock: vi.fn(async (code: string) => ({ code, map: null })), - viteAnnotationModuleImportMock: vi.fn(), - viteAnnotationTransformMock: vi.fn(async () => ({ code: 'fast-path', map: null })), - }; - }); +const { + babelCoreImportMock, + transformAsyncMock, + annotationTransformMock, + createOxcComponentNameAnnotateHooksMock, + getOxcParseAstAsyncMock, +} = vi.hoisted(() => { + const annotationTransformMock = vi.fn(async () => ({ code: 'fast-path', map: null })); + + return { + babelCoreImportMock: vi.fn(), + transformAsyncMock: vi.fn(async (code: string) => ({ code, map: null })), + annotationTransformMock, + createOxcComponentNameAnnotateHooksMock: vi.fn(() => ({ + transform: annotationTransformMock, + })), + getOxcParseAstAsyncMock: vi.fn(), + }; +}); vi.mock('@babel/core', () => { babelCoreImportMock(); @@ -20,12 +30,10 @@ vi.mock('@babel/core', () => { }; }); -vi.mock('../../src/core/component-annotation-vite', () => { - viteAnnotationModuleImportMock(); +vi.mock('../../src/core/component-annotation-oxc', () => { return { - createViteComponentNameAnnotateHooks: vi.fn(() => ({ - transform: viteAnnotationTransformMock, - })), + createOxcComponentNameAnnotateHooks: createOxcComponentNameAnnotateHooksMock, + getOxcParseAstAsync: getOxcParseAstAsyncMock, }; }); @@ -59,29 +67,57 @@ test('component annotations only load Babel when the Babel transform runs', asyn expect(transformAsyncMock).toHaveBeenCalledTimes(1); }); -test('Vite annotation fast path only loads for Vite 8 annotation transforms', async () => { - expect(viteAnnotationModuleImportMock).not.toHaveBeenCalled(); +describe('annotation fast path', () => { + const code = 'export function App() { return
; }'; - const vite7Plugin = _rollupPluginInternal( - { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, - 'vite', - '7', - ) as Plugin; + beforeEach(() => { + vi.clearAllMocks(); + }); - await runTransform(vite7Plugin, 'export function App() { return
; }', '/src/app.jsx'); + it.each<[string, 'rollup' | 'vite', string | undefined]>([ + ['Rollup', 'rollup', undefined], + ['Vite 7', 'vite', '7'], + ])('uses the fast path with oxc-parser for %s', async (_name, buildTool, majorVersion) => { + const plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, + buildTool, + majorVersion, + ) as Plugin; + + await expect(runTransform(plugin, code, '/src/app.jsx')).resolves.toEqual({ code: 'fast-path', map: null }); + + expect(createOxcComponentNameAnnotateHooksMock).toHaveBeenCalledWith([], getOxcParseAstAsyncMock); + expect(annotationTransformMock).toHaveBeenCalledTimes(1); + expect(transformAsyncMock).not.toHaveBeenCalled(); + }); - expect(viteAnnotationModuleImportMock).not.toHaveBeenCalled(); + it("uses the fast path with Vite's parser for Vite 8", async () => { + const plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, + 'vite', + '8', + ) as Plugin; - const vite8Plugin = _rollupPluginInternal( - { release: { inject: false }, reactComponentAnnotation: { enabled: true } }, - 'vite', - '8', - ) as Plugin; + await expect(runTransform(plugin, code, '/src/app.jsx')).resolves.toEqual({ code: 'fast-path', map: null }); + + expect(createOxcComponentNameAnnotateHooksMock).toHaveBeenCalledWith([], expect.any(Function)); + expect(createOxcComponentNameAnnotateHooksMock).not.toHaveBeenCalledWith([], getOxcParseAstAsyncMock); + expect(annotationTransformMock).toHaveBeenCalledTimes(1); + expect(transformAsyncMock).not.toHaveBeenCalled(); + }); - await runTransform(vite8Plugin, 'export function App() { return
; }', '/src/app.jsx'); + it('does not use the fast path when injecting into HTML', async () => { + const plugin = _rollupPluginInternal( + { release: { inject: false }, reactComponentAnnotation: { enabled: true, _experimentalInjectIntoHtml: true } }, + 'vite', + '8', + ) as Plugin; - expect(viteAnnotationModuleImportMock).toHaveBeenCalledTimes(1); - expect(viteAnnotationTransformMock).toHaveBeenCalledTimes(1); + await runTransform(plugin, code, '/src/app.jsx'); + + expect(annotationTransformMock).not.toHaveBeenCalled(); + expect(transformAsyncMock).toHaveBeenCalledTimes(1); + }); }); test('uses a Rollup 3-compatible function transform hook for Rollup builds', () => { From dd784a2fab762280f1c47f91eff6daff448da0d0 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 16 Sep 2026 13:42:39 -0700 Subject: [PATCH 02/39] feat(core): consolidate fetch integrations (#24346) Consolidate the nearly (but not exactly!) identical fetch integrations used by deno, bun, cloudflare, and vercel-edge. The deno `breadcrumbs` integration is folded in, and the separate integration is deprecated. The functionality is placed in `@sentry/core` rather than `@sentry/server-utils` in order to avoid leaking node internals where they don't belong. Node and Browser fetch integrations are not touched, because they use completely different mechanisms. closes JS-3667 closes #24344 --- packages/bun/src/index.ts | 1 + packages/bun/src/integrations/fetch.ts | 166 +-------- packages/bun/test/integrations/fetch.test.ts | 100 ++++++ packages/cloudflare/src/index.ts | 1 + packages/cloudflare/src/integrations/fetch.ts | 166 +-------- .../test/integrations/fetch.test.ts | 227 ++---------- packages/core/src/index.ts | 2 + packages/core/src/integrations/fetch.ts | 212 +++++++++++ packages/core/src/server.ts | 2 + packages/core/src/types/breadcrumb.ts | 3 + packages/core/src/utils/url.ts | 4 +- .../core/test/lib/integrations/fetch.test.ts | 339 ++++++++++++++++++ packages/core/test/lib/utils/url.test.ts | 2 + packages/deno/src/index.ts | 2 +- packages/deno/src/integrations/breadcrumbs.ts | 114 ++---- packages/deno/src/integrations/fetch.ts | 91 +---- packages/deno/src/integrations/http.ts | 3 + packages/deno/test/deno-fetch.test.ts | 82 ++++- packages/vercel-edge/src/index.ts | 1 + .../src/integrations/wintercg-fetch.ts | 164 +-------- .../vercel-edge/test/wintercg-fetch.test.ts | 227 ++---------- 21 files changed, 860 insertions(+), 1049 deletions(-) create mode 100644 packages/bun/test/integrations/fetch.test.ts create mode 100644 packages/core/src/integrations/fetch.ts create mode 100644 packages/core/test/lib/integrations/fetch.test.ts diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c9f7981a3122..cda29a4beeb8 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -215,5 +215,6 @@ export { bunServerIntegration } from './integrations/bunserver'; export type { BunServerIntegrationOptions } from './integrations/bunserver'; export { bunHttpServerIntegration } from './integrations/bunHttpServer'; export { fetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/fetch.ts b/packages/bun/src/integrations/fetch.ts index b908ccaf2e25..21e01ccb2307 100644 --- a/packages/bun/src/integrations/fetch.ts +++ b/packages/bun/src/integrations/fetch.ts @@ -1,166 +1,10 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -interface FetchOptions { - /** - * Whether breadcrumbs should be recorded for requests. - * Defaults to true. - */ - breadcrumbs?: boolean; - - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _fetchIntegration = ((options: FetchOptions = {}) => { - const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - /** Decides whether to attach trace data to the outgoing fetch request */ - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** Helper that wraps shouldCreateSpanForRequest option */ - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - const { propagateTraceparent } = client.getOptions(); - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.fetch', - propagateTraceparent, - }); - - if (breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** * Instruments outgoing `fetch` requests in Bun: creates spans, records breadcrumbs and * attaches trace propagation headers. */ -export const fetchIntegration = defineIntegration(_fetchIntegration); - -function createBreadcrumb(handlerData: HandlerDataFetch): void { - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } -} +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/bun/test/integrations/fetch.test.ts b/packages/bun/test/integrations/fetch.test.ts new file mode 100644 index 000000000000..a201d7462c89 --- /dev/null +++ b/packages/bun/test/integrations/fetch.test.ts @@ -0,0 +1,100 @@ +import http from 'node:http'; +import type { TransactionEvent } from '@sentry/core'; +import { getCurrentScope, getIsolationScope, startSpan } from '@sentry/core'; +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import { init } from '../../src'; + +async function startServer( + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void, +): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer(handler); + const port = await new Promise(resolve => { + server.listen(0, () => resolve((server.address() as { port: number }).port)); + }); + return { + port, + close: () => new Promise(resolve => server.close(() => resolve())), + }; +} + +const transactions: TransactionEvent[] = []; + +/** Bind on the real completion signal so a "never arrives" regression fails instead of hanging. */ +function waitForTransaction(name: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out waiting for the "${name}" transaction`)), 5000); + const poll = setInterval(() => { + const found = transactions.find(event => event.transaction === name); + if (found) { + clearTimeout(timer); + clearInterval(poll); + resolve(found); + } + }, 10); + }); +} + +function header(headers: http.IncomingHttpHeaders | undefined, name: string): string | undefined { + const value = headers?.[name]; + return Array.isArray(value) ? value[0] : value; +} + +describe('fetchIntegration', () => { + beforeAll(() => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, + traceLifecycle: 'static', + beforeSendTransaction(event) { + transactions.push(event); + return null; + }, + transport: () => ({ send: async () => ({}), flush: async () => true }), + }); + }); + + afterAll(() => { + getCurrentScope().setClient(undefined); + }); + + test('creates an http.client span and propagates trace headers', async () => { + let received: http.IncomingHttpHeaders | undefined; + const { port, close } = await startServer((req, res) => { + received = req.headers; + res.end('ok'); + }); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch(`http://localhost:${port}/downstream`).then(res => res.text()); + }); + + const parent = await waitForTransaction('parent'); + await close(); + + const clientSpan = parent.spans?.find(span => span.op === 'http.client'); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.fetch'); + + const traceId = parent.contexts?.trace?.trace_id; + const sentryTrace = header(received, 'sentry-trace'); + expect(sentryTrace).toBeDefined(); + expect(sentryTrace!.split('-')[0]).toBe(traceId!); + expect(sentryTrace!.split('-')[1]).toBe(clientSpan!.span_id!); + expect(header(received, 'baggage')).toContain(`sentry-trace_id=${traceId}`); + }); + + test('records exactly one fetch breadcrumb', async () => { + const { port, close } = await startServer((_req, res) => res.end('ok')); + const url = `http://localhost:${port}/crumb`; + + getIsolationScope().clearBreadcrumbs(); + await fetch(url).then(res => res.text()); + await close(); + + const crumbs = getIsolationScope() + .getScopeData() + .breadcrumbs.filter(crumb => crumb.category === 'fetch' && crumb.data?.url === url); + + expect(crumbs).toHaveLength(1); + }); +}); diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 8645514540d1..16ec6d6d9e24 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -120,6 +120,7 @@ export { _INTERNAL_wrapRequestHandler, getDefaultIntegrations } from './sdk'; export { httpServerIntegration } from './integrations/httpServer'; export { fetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { spotlightIntegration } from './integrations/spotlight'; export { openTelemetryIntegration, diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 585377e9d76d..68d0be8466b0 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -1,165 +1,9 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface Options { - /** - * Whether breadcrumbs should be recorded for requests - * Defaults to true - */ - breadcrumbs: boolean; - - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _fetchIntegration = ((options: Partial = {}) => { - const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - /** Decides whether to attach trace data to the outgoing fetch request */ - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** Helper that wraps shouldCreateSpanForRequest option */ - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - const { propagateTraceparent } = client?.getOptions() || {}; - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.fetch', - propagateTraceparent, - }); - - if (breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** * Creates spans and attaches tracing headers to fetch requests. */ -export const fetchIntegration = defineIntegration(_fetchIntegration); - -function createBreadcrumb(handlerData: HandlerDataFetch): void { - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } -} +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index c2cdda44d182..a57361234e2b 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -1,210 +1,49 @@ -import type { HandlerDataFetch, Integration } from '@sentry/core'; -import * as sentryCore from '@sentry/core'; -import { createStackParser } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TransactionEvent } from '@sentry/core'; +import { createStackParser, setCurrentClient, startSpan } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { CloudflareClient } from '../../src/client'; import { fetchIntegration } from '../../src/integrations/fetch'; +import { getDefaultIntegrations } from '../../src/sdk'; -class FakeClient extends CloudflareClient { - public getIntegrationByName(name: string): T | undefined { - return name === 'Fetch' ? (fetchIntegration() as T) : undefined; - } -} - -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); -const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); -const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); +// The behavior lives in `createFetchIntegration` and is covered by +// `packages/core/test/lib/integrations/fetch.test.ts`. This only pins the wiring. +describe('fetchIntegration', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); -describe('WinterCGFetch instrumentation', () => { - let client: FakeClient; + it('is named `Fetch` and is enabled by default', () => { + expect(fetchIntegration().name).toBe('Fetch'); + expect(getDefaultIntegrations({}).map(integration => integration.name)).toContain('Fetch'); + }); - beforeEach(() => { - vi.clearAllMocks(); + it('creates `http.client` spans with the `auto.http.fetch` origin', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(new Response('ok'))); - client = new FakeClient({ + const transactions: TransactionEvent[] = []; + const client = new CloudflareClient({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: () => Promise.resolve({}), - flush: () => Promise.resolve(true), - }), - tracePropagationTargets: ['http://my-website.com/'], + traceLifecycle: 'static', + integrations: [fetchIntegration()], stackParser: createStackParser(), - }); - - vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( - startHandlerData, - expect.any(Function), - expect.any(Function), - expect.any(Object), - { spanOrigin: 'auto.http.fetch' }, - ); - - const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); - expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); - - // tracePropagationTargets match regardless of casing - expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); - expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); - - expect(shouldCreateSpan('http://my-website.com/')).toBe(true); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); - }); - - it('should not instrument if client is not setup', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - // integration.setup!(client) is not called! - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should properly apply the `shouldCreateSpanForRequest` option', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration({ - shouldCreateSpanForRequest(url) { - return url === 'http://only-acceptable-url.com/'; + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + beforeSendTransaction(event) { + transactions.push(event); + return null; }, }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); - expect(shouldCreateSpan('http://my-website.com/')).toBe(false); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); - }); - - it('should create a breadcrumb for an outgoing request', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + setCurrentClient(client); + client.init(); - const integration = fetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(addBreadcrumbSpy).toBeCalledWith( - { - category: 'fetch', - data: { - method: 'POST', - status_code: 201, - url: 'http://my-website.com/', - }, - type: 'http', - }, - { - endTimestamp, - input: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' }, - startTimestamp, - }, - ); - }); - - it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = fetchIntegration({ breadcrumbs: false }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch('http://my-website.com/').then(response => response.text()); + }); - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); + const parent = transactions.find(event => event.transaction === 'parent'); + const clientSpan = parent?.spans?.find(span => span.op === 'http.client'); - expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.fetch'); }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index efe39a7e1cf3..0146e82a11fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,6 +164,8 @@ export { featureFlagsIntegration } from './integrations/featureFlags'; export { growthbookIntegration } from './integrations/featureFlags'; export { conversationIdIntegration } from './integrations/conversationId'; export { spanStreamingIntegration } from './integrations/spanStreaming'; +export { createFetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from './integrations/fetch'; export { profiler } from './profiling'; // eslint thinks the entire function is deprecated (while only one overload is actually deprecated) // Therefore: diff --git a/packages/core/src/integrations/fetch.ts b/packages/core/src/integrations/fetch.ts new file mode 100644 index 000000000000..5cf7a0489719 --- /dev/null +++ b/packages/core/src/integrations/fetch.ts @@ -0,0 +1,212 @@ +import { URL_FRAGMENT, URL_QUERY } from '@sentry/conventions/attributes'; +import { addBreadcrumb } from '../breadcrumbs'; +import type { Client } from '../client'; +import { getClient } from '../currentScopes'; +import { instrumentFetchRequest } from '../fetch'; +import { defineIntegration } from '../integration'; +import { addFetchInstrumentationHandler } from '../instrument/fetch'; +import type { FetchBreadcrumbData, FetchBreadcrumbHint } from '../types/breadcrumb'; +import type { HandlerDataFetch } from '../types/instrument'; +import type { Integration, IntegrationFn } from '../types/integration'; +import type { Span, SpanOrigin } from '../types/span'; +import { getBreadcrumbLogLevelFromHttpStatusCode } from '../utils/breadcrumb-log-level'; +import { filterCollectedUrlQuery } from '../utils/data-collection/filterCollectedUrl'; +import { isSentryRequestUrl } from '../utils/isSentryRequestUrl'; +import { LRUMap } from '../utils/lru'; +import { shouldPropagateTraceForUrl } from '../utils/tracePropagationTargets'; +import { getSanitizedUrlString, getUrlFragment, getUrlQuery, parseUrl } from '../utils/url'; + +export interface FetchIntegrationOptions { + /** + * Whether breadcrumbs should be recorded for requests. + * + * @default `true` + */ + breadcrumbs?: boolean; + + /** + * Function determining whether or not to create spans to track outgoing requests to the given URL. + * By default, spans will be created for all outgoing requests. + */ + shouldCreateSpanForRequest?: (url: string) => boolean; + + /** + * Whether to inject trace propagation headers (`sentry-trace`, `baggage`) into outgoing requests. + * + * To scope propagation to specific URLs, configure `tracePropagationTargets` in the client options + * instead. Turn this off only to suppress propagation entirely, for example alongside + * `shouldCreateSpanForRequest`, which suppresses the span but not the headers. + * + * Covers the global `fetch` only. A runtime that also instruments another HTTP client switches + * that one separately, for example `denoHttpIntegration({ tracePropagation: false })`. + * + * @default `true` + */ + tracePropagation?: boolean; +} + +interface CreateFetchIntegrationOptions { + /** Integration name, e.g. `'Fetch'`. */ + name: string; + + /** Span origin for the `http.client` spans this integration creates. */ + spanOrigin: SpanOrigin; +} + +interface ClientConfig { + breadcrumbs: boolean; + shouldCreateSpan: (url: string) => boolean; + shouldAttachTraceData: (url: string) => boolean; +} + +/** + * Builds an integration that instruments the global `fetch` function: creates `http.client` spans, + * records breadcrumbs, and attaches trace propagation headers. + * + * Runtimes that patch the global `fetch` (Bun, Cloudflare Workers, Deno, Vercel Edge) differ only in + * the integration name and span origin, so they all share this implementation. Node is not one of + * them: it instruments undici through diagnostics channels instead. Neither is the browser, whose + * fetch tracing is driven by `browserTracingIntegration` and shares its span map with XHR. + */ +export function createFetchIntegration({ + name, + spanOrigin, +}: CreateFetchIntegrationOptions): (options?: FetchIntegrationOptions) => Integration { + // Shared by every instance of this integration, because `setupOnce` runs once per process: the + // handler it registers must be able to end a span that a different instance started. + const spans: Record = {}; + + // Keyed by client rather than captured in the instance closure, so that a second `init()` uses its + // own options instead of silently inheriting the first one's. + const configs = new WeakMap(); + + const integration = ((options: FetchIntegrationOptions = {}) => { + return { + name, + setupOnce() { + addFetchInstrumentationHandler(handlerData => { + const client = getClient(); + const config = client && configs.get(client); + + if (!client || !config) { + return; + } + + if (isSentryRequestUrl(handlerData.fetchData.url, client)) { + return; + } + + const { propagateTraceparent } = client.getOptions(); + instrumentFetchRequest(handlerData, config.shouldCreateSpan, config.shouldAttachTraceData, spans, { + spanOrigin, + propagateTraceparent, + }); + + if (config.breadcrumbs) { + createBreadcrumb(handlerData, client); + } + }); + }, + setup(client) { + configs.set(client, resolveConfig(client, options)); + }, + }; + }) satisfies IntegrationFn; + + return defineIntegration(integration); +} + +function resolveConfig(client: Client, options: FetchIntegrationOptions): ClientConfig { + const { breadcrumbs = true, shouldCreateSpanForRequest, tracePropagation = true } = options; + + const createSpanUrlMap = new LRUMap(100); + const headersUrlMap = new LRUMap(100); + + return { + breadcrumbs, + + shouldCreateSpan(url) { + if (shouldCreateSpanForRequest === undefined) { + return true; + } + + const cachedDecision = createSpanUrlMap.get(url); + if (cachedDecision !== undefined) { + return cachedDecision; + } + + const decision = shouldCreateSpanForRequest(url); + createSpanUrlMap.set(url, decision); + return decision; + }, + + shouldAttachTraceData(url) { + if (!tracePropagation) { + return false; + } + + return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, headersUrlMap); + }, + }; +} + +function createBreadcrumb(handlerData: HandlerDataFetch, client: Client): void { + const { startTimestamp, endTimestamp } = handlerData; + + // We only capture complete fetch requests + if (!endTimestamp) { + return; + } + + const parsedUrl = parseUrl(handlerData.fetchData.url); + + const breadcrumbData: FetchBreadcrumbData = { + method: handlerData.fetchData.method, + url: getSanitizedUrlString(parsedUrl), + [URL_QUERY]: filterCollectedUrlQuery(getUrlQuery(parsedUrl.search), client), + [URL_FRAGMENT]: getUrlFragment(parsedUrl.hash), + }; + + if (handlerData.error) { + const hint: FetchBreadcrumbHint = { + data: handlerData.error, + input: handlerData.args, + startTimestamp, + endTimestamp, + }; + + addBreadcrumb( + { + category: 'fetch', + data: breadcrumbData, + level: 'error', + type: 'http', + }, + hint, + ); + } else { + const response = handlerData.response as Response | undefined; + + breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; + breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; + breadcrumbData.status_code = response?.status; + + const hint: FetchBreadcrumbHint = { + input: handlerData.args, + response, + startTimestamp, + endTimestamp, + }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); + + addBreadcrumb( + { + category: 'fetch', + data: breadcrumbData, + type: 'http', + level, + }, + hint, + ); + } +} diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 25dac9eeb7a4..e48e0ea1de5f 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -38,3 +38,5 @@ export type { HttpServerResponse, HttpModuleExport, } from './integrations/http/types'; +export { createFetchIntegration } from './integrations/fetch'; +export type { FetchIntegrationOptions } from './integrations/fetch'; diff --git a/packages/core/src/types/breadcrumb.ts b/packages/core/src/types/breadcrumb.ts index 391100a5a377..3eb70ac87f7d 100644 --- a/packages/core/src/types/breadcrumb.ts +++ b/packages/core/src/types/breadcrumb.ts @@ -77,10 +77,13 @@ export interface BreadcrumbHint { export interface FetchBreadcrumbData { method: string; + /** Sanitized URL: the query string and fragment live in their own fields below. */ url: string; status_code?: number; request_body_size?: number; response_body_size?: number; + 'url.query'?: string; + 'url.fragment'?: string; } export interface XhrBreadcrumbData { diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 45be6373bf29..63ecad6dc4d8 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -319,7 +319,9 @@ export function getSanitizedUrlString(url: PartialURL): string { .replace(/(:80)$/, '') .replace(/(:443)$/, '') || ''; - return `${protocol ? `${protocol}://` : ''}${filteredHost}${path}`; + // `parseUrl` returns `{}` for an empty or unparseable URL, and interpolating a missing path + // would render the string 'undefined'. + return `${protocol ? `${protocol}://` : ''}${filteredHost}${path || ''}`; } /** diff --git a/packages/core/test/lib/integrations/fetch.test.ts b/packages/core/test/lib/integrations/fetch.test.ts new file mode 100644 index 000000000000..4c332b479fa0 --- /dev/null +++ b/packages/core/test/lib/integrations/fetch.test.ts @@ -0,0 +1,339 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbsModule from '../../../src/breadcrumbs'; +import * as currentScopesModule from '../../../src/currentScopes'; +import * as fetchModule from '../../../src/fetch'; +import { createFetchIntegration } from '../../../src/integrations/fetch'; +import * as instrumentFetchModule from '../../../src/instrument/fetch'; +import type { HandlerDataFetch } from '../../../src/types/instrument'; +import type { Integration } from '../../../src/types/integration'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +const fetchIntegration = createFetchIntegration({ name: 'Fetch', spanOrigin: 'auto.http.fetch' }); + +class FakeClient extends TestClient { + public getIntegrationByName(name: string): T | undefined { + return name === 'Fetch' ? (fetchIntegration() as T) : undefined; + } +} + +const addFetchInstrumentationHandlerSpy = vi.spyOn(instrumentFetchModule, 'addFetchInstrumentationHandler'); +const instrumentFetchRequestSpy = vi.spyOn(fetchModule, 'instrumentFetchRequest'); +const addBreadcrumbSpy = vi.spyOn(breadcrumbsModule, 'addBreadcrumb'); + +function makeClient(options: Partial[0]> = {}): FakeClient { + return new FakeClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + tracePropagationTargets: ['http://my-website.com/'], + ...options, + }), + ); +} + +/** Registers the integration against `client` and returns the handler it installed. */ +function setupIntegration( + integration: ReturnType, + client: FakeClient, +): (handlerData: HandlerDataFetch) => void { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => () => undefined); + integration.setupOnce!(); + integration.setup!(client); + + const [handler] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + expect(handler).toBeDefined(); + return handler; +} + +const startHandlerData: HandlerDataFetch = { + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), +}; + +describe('createFetchIntegration', () => { + let client: FakeClient; + + beforeEach(() => { + vi.clearAllMocks(); + client = makeClient(); + vi.spyOn(currentScopesModule, 'getClient').mockImplementation(() => client); + }); + + it('calls `instrumentFetchRequest` for outgoing fetch requests', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + { spanOrigin: 'auto.http.fetch', propagateTraceparent: undefined }, + ); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); + // tracePropagationTargets match regardless of casing + expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); + }); + + it('uses the span origin it was created with', () => { + const winterCGFetchIntegration = createFetchIntegration({ + name: 'WinterCGFetch', + spanOrigin: 'auto.http.wintercg_fetch', + }); + + const handler = setupIntegration(winterCGFetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + expect.objectContaining({ spanOrigin: 'auto.http.wintercg_fetch' }), + ); + }); + + it('forwards the client `propagateTraceparent` option', () => { + client = makeClient({ propagateTraceparent: true }); + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( + startHandlerData, + expect.any(Function), + expect.any(Function), + expect.any(Object), + expect.objectContaining({ propagateTraceparent: true }), + ); + }); + + it('does not instrument if the client is not set up', () => { + addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => () => undefined); + const integration = fetchIntegration(); + integration.setupOnce!(); + // no `setup(client)` call + + const [handler] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; + handler!(startHandlerData); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + }); + + it('does not instrument outgoing requests to Sentry', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler({ + fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=public', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337?sentry_key=public'], + startTimestamp: Date.now(), + }); + + expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + it('applies the `shouldCreateSpanForRequest` option', () => { + const handler = setupIntegration( + fetchIntegration({ shouldCreateSpanForRequest: url => url === 'http://only-this-one.com/' }), + client, + ); + handler(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + + expect(shouldCreateSpan('http://only-this-one.com/')).toBe(true); + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + }); + + it('attaches trace data by default', () => { + const handler = setupIntegration(fetchIntegration(), client); + handler(startHandlerData); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); + }); + + it('attaches no trace data when `tracePropagation: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ tracePropagation: false }), client); + handler(startHandlerData); + + const [, , shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldAttachTraceData('http://my-website.com/')).toBe(false); + }); + + it('still creates spans when `tracePropagation: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ tracePropagation: false }), client); + handler(startHandlerData); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldCreateSpan('http://my-website.com/')).toBe(true); + }); + + it('creates a breadcrumb for an outgoing request', () => { + const handler = setupIntegration(fetchIntegration(), client); + + const startTimestamp = Date.now(); + const endTimestamp = startTimestamp + 100; + const response = { status: 200 } as Response; + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST', request_body_size: 10, response_body_size: 20 }, + args: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + { + category: 'fetch', + data: { + method: 'POST', + url: 'http://my-website.com/', + request_body_size: 10, + response_body_size: 20, + status_code: 200, + }, + type: 'http', + }, + { + input: ['http://my-website.com/'], + response, + startTimestamp, + endTimestamp, + }, + ); + }); + + it('sanitizes the breadcrumb URL and reports the query separately', () => { + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: 'http://user:pw@my-website.com/path?q=hello&token=secret#frag', method: 'GET' }, + args: ['http://my-website.com/path?q=hello&token=secret#frag'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + url: 'http://[filtered]:[filtered]@my-website.com/path', + 'url.query': 'q=hello&token=[Filtered]', + 'url.fragment': 'frag', + }), + }), + expect.anything(), + ); + }); + + it('redacts the breadcrumb query when `dataCollection.urlQueryParams` is off', () => { + client = makeClient({ dataCollection: { urlQueryParams: false } }); + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: 'http://my-website.com/path?token=secret', method: 'GET' }, + args: ['http://my-website.com/path?token=secret'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ url: 'http://my-website.com/path' }), + }), + expect.anything(), + ); + expect(addBreadcrumbSpy.mock.lastCall?.[0].data?.['url.query']).toBeUndefined(); + }); + + it('records an empty breadcrumb URL rather than the string "undefined"', () => { + const handler = setupIntegration(fetchIntegration(), client); + + handler({ + fetchData: { url: '', method: 'GET' }, + args: [''], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + error: new Error('Invalid URL'), + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ data: { method: 'GET', url: '' } }), + expect.anything(), + ); + }); + + it('creates an error-level breadcrumb for a failed request', () => { + const handler = setupIntegration(fetchIntegration(), client); + + const error = new Error('kaboom'); + const startTimestamp = Date.now(); + const endTimestamp = startTimestamp + 100; + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + error, + }); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + { + category: 'fetch', + data: { method: 'POST', url: 'http://my-website.com/' }, + level: 'error', + type: 'http', + }, + { + data: error, + input: ['http://my-website.com/'], + startTimestamp, + endTimestamp, + }, + ); + }); + + it('creates no breadcrumb when `breadcrumbs: false` is set', () => { + const handler = setupIntegration(fetchIntegration({ breadcrumbs: false }), client); + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + }); + + it('uses each client own options when a second client is set up', () => { + // `setupOnce` runs once per process, so the handler must read the options of whichever client + // is current rather than the ones captured by the first instance. + const handler = setupIntegration(fetchIntegration({ breadcrumbs: false }), client); + + const secondClient = makeClient(); + fetchIntegration({ breadcrumbs: true, shouldCreateSpanForRequest: () => false }).setup!(secondClient); + vi.spyOn(currentScopesModule, 'getClient').mockImplementation(() => secondClient); + + handler({ + fetchData: { url: 'http://my-website.com/', method: 'POST' }, + args: ['http://my-website.com/'], + startTimestamp: Date.now(), + endTimestamp: Date.now() + 100, + response: { status: 200 } as Response, + }); + + const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; + expect(shouldCreateSpan('http://my-website.com/')).toBe(false); + expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/test/lib/utils/url.test.ts b/packages/core/test/lib/utils/url.test.ts index e045366955fe..ad491ea983a6 100644 --- a/packages/core/test/lib/utils/url.test.ts +++ b/packages/core/test/lib/utils/url.test.ts @@ -78,6 +78,8 @@ describe('getSanitizedUrlString', () => { ['url with port 4433', 'http://172.31.12.144:4433/test', 'http://172.31.12.144:4433/test'], ['url with port 443', 'http://172.31.12.144:443/test', 'http://172.31.12.144/test'], ['url with IP and port 80', 'http://172.31.12.144:80/test', 'http://172.31.12.144/test'], + ['empty url', '', ''], + ['unparseable url', '???', ''], ])('returns a sanitized URL for a %s', (_, rawUrl: string, sanitizedURL: string) => { const urlObject = parseUrl(rawUrl); expect(getSanitizedUrlString(urlObject)).toEqual(sanitizedURL); diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index c3d5cf4e95ad..6b22ee9ee63a 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -111,7 +111,7 @@ export { getDefaultIntegrations, init } from './sdk'; export { denoServeIntegration } from './integrations/deno-serve'; export type { DenoServeIntegrationOptions } from './integrations/deno-serve'; export { fetchIntegration } from './integrations/fetch'; -export type { FetchOptions } from './integrations/fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; export { denoHttpIntegration } from './integrations/http'; export type { DenoHttpIntegrationOptions } from './integrations/http'; diff --git a/packages/deno/src/integrations/breadcrumbs.ts b/packages/deno/src/integrations/breadcrumbs.ts index a260a9d6fd91..8cb07f1eaf58 100644 --- a/packages/deno/src/integrations/breadcrumbs.ts +++ b/packages/deno/src/integrations/breadcrumbs.ts @@ -1,23 +1,15 @@ -import type { - Client, - Event as SentryEvent, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - getEventDescription, -} from '@sentry/core'; +import type { Client, Event as SentryEvent, IntegrationFn } from '@sentry/core'; +import { addBreadcrumb, consoleSandbox, defineIntegration, getClient, getEventDescription } from '@sentry/core'; interface BreadcrumbsOptions { - fetch: boolean; sentry: boolean; + + /** + * @deprecated Fetch breadcrumbs are recorded by `fetchIntegration`. Disable them with + * `fetchIntegration({ breadcrumbs: false })` instead. This option no longer has any effect and + * will be removed in a future major version. + */ + fetch: boolean; } const INTEGRATION_NAME = 'Breadcrumbs' as const; @@ -27,8 +19,16 @@ const INTEGRATION_NAME = 'Breadcrumbs' as const; * The Deno-version does not support browser-specific APIs like dom, xhr and history. */ const _breadcrumbsIntegration = ((options: Partial = {}) => { + if ('fetch' in options) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] `breadcrumbsIntegration({ fetch })` is deprecated and no longer has any effect. Fetch breadcrumbs are recorded by `fetchIntegration`; disable them with `fetchIntegration({ breadcrumbs: false })`.', + ); + }); + } + const _options = { - fetch: true, sentry: true, ...options, }; @@ -36,9 +36,6 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => return { name: INTEGRATION_NAME, setup(client) { - if (_options.fetch) { - addFetchInstrumentationHandler(_getFetchBreadcrumbHandler(client)); - } if (_options.sentry) { client.on('beforeSendEvent', _getSentryBreadcrumbHandler(client)); } @@ -47,7 +44,9 @@ const _breadcrumbsIntegration = ((options: Partial = {}) => }) satisfies IntegrationFn; /** - * Adds a breadcrumbs for fetch and sentry events. + * Adds breadcrumbs for sentry events. + * + * Fetch breadcrumbs come from `fetchIntegration`. * * Enabled by default in the Deno SDK. * @@ -84,74 +83,3 @@ function _getSentryBreadcrumbHandler(client: Client): (event: SentryEvent) => vo ); }; } - -/** - * Creates breadcrumbs from fetch API calls - */ -function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFetch) => void { - return function _fetchBreadcrumb(handlerData: HandlerDataFetch): void { - if (getClient() !== client) { - return; - } - - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - if (handlerData.fetchData.url.match(/sentry_key/) && handlerData.fetchData.method === 'POST') { - // We will not create breadcrumbs for fetch requests that contain `sentry_key` (internal sentry requests) - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } - }; -} diff --git a/packages/deno/src/integrations/fetch.ts b/packages/deno/src/integrations/fetch.ts index 17d82072ec00..c873aab979da 100644 --- a/packages/deno/src/integrations/fetch.ts +++ b/packages/deno/src/integrations/fetch.ts @@ -1,87 +1,10 @@ -import type { Client, IntegrationFn, Span } from '@sentry/core'; -import { - addFetchInstrumentationHandler, - defineIntegration, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'Fetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface FetchOptions { - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _fetchIntegration = ((options: FetchOptions = {}) => { - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - const { propagateTraceparent } = client.getOptions(); - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.fetch', - propagateTraceparent, - }); - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** - * Instruments outgoing `fetch` requests in Deno by creating spans and attaching trace propagation headers. - * The separate breadcrumbs integration records fetch breadcrumbs. + * Instruments outgoing `fetch` requests in Deno: creates spans, records breadcrumbs and + * attaches trace propagation headers. */ -export const fetchIntegration = defineIntegration(_fetchIntegration); +export const fetchIntegration = createFetchIntegration({ + name: 'Fetch', + spanOrigin: 'auto.http.fetch', +}); diff --git a/packages/deno/src/integrations/http.ts b/packages/deno/src/integrations/http.ts index f4cfeee81d17..15a5270a6bb7 100644 --- a/packages/deno/src/integrations/http.ts +++ b/packages/deno/src/integrations/http.ts @@ -51,6 +51,9 @@ export interface DenoHttpIntegrationOptions { * When set to `false`, Sentry will not inject any trace propagation headers, but will still create breadcrumbs * (if `breadcrumbs` is enabled). * + * Covers `node:http` requests only. Outgoing `fetch` has its own switch, + * `fetchIntegration({ tracePropagation: false })`. + * * @default `true` */ tracePropagation?: boolean; diff --git a/packages/deno/test/deno-fetch.test.ts b/packages/deno/test/deno-fetch.test.ts index f744c751fc2f..8f13905183a0 100644 --- a/packages/deno/test/deno-fetch.test.ts +++ b/packages/deno/test/deno-fetch.test.ts @@ -6,7 +6,8 @@ import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import type { DenoClient } from '../build/esm/index.js'; -import { captureMessage, init, startSpan } from '../build/esm/index.js'; +import { breadcrumbsIntegration, captureMessage, init, startSpan } from '../build/esm/index.js'; +import { makeTestTransport } from './transport.ts'; function resetGlobals(): void { getMainCarrier().__SENTRY__ = undefined; @@ -105,3 +106,82 @@ Deno.test({ } }, }); + +Deno.test({ + name: 'fetchIntegration: emits an http.client span under the default (streaming) trace lifecycle', + async fn() { + resetGlobals(); + + const server = Deno.serve({ port: 0, hostname: '127.0.0.1', onListen() {} }, () => new Response('ok')); + const url = `http://127.0.0.1:${server.addr.port}/streamed`; + + try { + let resolveSpan: ((name: string) => void) | undefined; + const clientSpan = new Promise(resolve => (resolveSpan = resolve)); + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + // No `traceLifecycle`: this is the default ('stream') path the other tests do not cover. + tracePropagationTargets: [url], + transport: makeTestTransport(envelope => { + for (const [header, body] of envelope[1] as [{ type: string }, Record][]) { + if (header.type !== 'span') continue; + for (const span of (body.items ?? [body]) as Record[]) { + if (span.attributes?.['sentry.op']?.value === 'http.client') { + resolveSpan?.(span.attributes['sentry.origin']?.value); + } + } + } + }), + }); + + await startSpan({ name: 'parent', op: 'test' }, async () => { + const response = await fetch(url); + assertEquals(await response.text(), 'ok'); + }); + + assertEquals(await withTimeout(clientSpan, 5_000, 'streamed http.client span'), 'auto.http.fetch'); + } finally { + await server.shutdown(); + } + }, +}); + +Deno.test({ + name: 'breadcrumbsIntegration: the deprecated `fetch` option no longer suppresses fetch breadcrumbs', + async fn() { + resetGlobals(); + + const server = Deno.serve({ port: 0, hostname: '127.0.0.1', onListen() {} }, () => new Response('ok')); + const url = `http://127.0.0.1:${server.addr.port}/still-recorded`; + + try { + let resolveEvent: ((event: Event) => void) | undefined; + const capturedEvent = new Promise(resolve => (resolveEvent = resolve)); + + init({ + dsn: 'https://username@domain/123', + // oxlint-disable-next-line typescript/no-deprecated + integrations: [breadcrumbsIntegration({ fetch: false })], + beforeSend(event) { + resolveEvent?.(event); + return null; + }, + }); + + await fetch(url).then(response => response.text()); + + captureMessage('capture fetch breadcrumb'); + const event = await withTimeout(capturedEvent, 5_000, 'event containing fetch breadcrumb'); + const fetchBreadcrumbs = event.breadcrumbs?.filter( + breadcrumb => breadcrumb.category === 'fetch' && breadcrumb.data?.url === url, + ); + + // `fetchIntegration` owns fetch breadcrumbs now, so the old switch has no effect. + assertEquals(fetchBreadcrumbs?.length, 1); + } finally { + await server.shutdown(); + } + }, +}); diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index 983df5a23b7f..be7643efa940 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -118,3 +118,4 @@ export { VercelEdgeClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { winterCGFetchIntegration } from './integrations/wintercg-fetch'; +export type { FetchIntegrationOptions } from '@sentry/core'; diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index 217efe00df2d..1922782e193b 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -1,163 +1,9 @@ -import type { - Client, - FetchBreadcrumbData, - FetchBreadcrumbHint, - HandlerDataFetch, - IntegrationFn, - Span, -} from '@sentry/core'; -import { - addBreadcrumb, - addFetchInstrumentationHandler, - defineIntegration, - getBreadcrumbLogLevelFromHttpStatusCode, - getClient, - instrumentFetchRequest, - isSentryRequestUrl, - LRUMap, - shouldPropagateTraceForUrl, -} from '@sentry/core'; - -const INTEGRATION_NAME = 'WinterCGFetch' as const; - -const HAS_CLIENT_MAP = new WeakMap(); - -export interface Options { - /** - * Whether breadcrumbs should be recorded for requests - * Defaults to true - */ - breadcrumbs: boolean; - - /** - * Function determining whether or not to create spans to track outgoing requests to the given URL. - * By default, spans will be created for all outgoing requests. - */ - shouldCreateSpanForRequest?: (url: string) => boolean; -} - -const _winterCGFetch = ((options: Partial = {}) => { - const breadcrumbs = options.breadcrumbs === undefined ? true : options.breadcrumbs; - const shouldCreateSpanForRequest = options.shouldCreateSpanForRequest; - - const _createSpanUrlMap = new LRUMap(100); - const _headersUrlMap = new LRUMap(100); - - const spans: Record = {}; - - /** Decides whether to attach trace data to the outgoing fetch request */ - function _shouldAttachTraceData(url: string): boolean { - const client = getClient(); - - if (!client) { - return false; - } - - return shouldPropagateTraceForUrl(url, client.getOptions().tracePropagationTargets, _headersUrlMap); - } - - /** Helper that wraps shouldCreateSpanForRequest option */ - function _shouldCreateSpan(url: string): boolean { - if (shouldCreateSpanForRequest === undefined) { - return true; - } - - const cachedDecision = _createSpanUrlMap.get(url); - if (cachedDecision !== undefined) { - return cachedDecision; - } - - const decision = shouldCreateSpanForRequest(url); - _createSpanUrlMap.set(url, decision); - return decision; - } - - return { - name: INTEGRATION_NAME, - setupOnce() { - addFetchInstrumentationHandler(handlerData => { - const client = getClient(); - if (!client || !HAS_CLIENT_MAP.get(client)) { - return; - } - - if (isSentryRequestUrl(handlerData.fetchData.url, client)) { - return; - } - - instrumentFetchRequest(handlerData, _shouldCreateSpan, _shouldAttachTraceData, spans, { - spanOrigin: 'auto.http.wintercg_fetch', - }); - - if (breadcrumbs) { - createBreadcrumb(handlerData); - } - }); - }, - setup(client) { - HAS_CLIENT_MAP.set(client, true); - }, - }; -}) satisfies IntegrationFn; +import { createFetchIntegration } from '@sentry/core'; /** * Creates spans and attaches tracing headers to fetch requests on WinterCG runtimes. */ -export const winterCGFetchIntegration = defineIntegration(_winterCGFetch); - -function createBreadcrumb(handlerData: HandlerDataFetch): void { - const { startTimestamp, endTimestamp } = handlerData; - - // We only capture complete fetch requests - if (!endTimestamp) { - return; - } - - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - - if (handlerData.error) { - const hint: FetchBreadcrumbHint = { - data: handlerData.error, - input: handlerData.args, - startTimestamp, - endTimestamp, - }; - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - level: 'error', - type: 'http', - }, - hint, - ); - } else { - const response = handlerData.response as Response | undefined; - - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - - const hint: FetchBreadcrumbHint = { - input: handlerData.args, - response, - startTimestamp, - endTimestamp, - }; - const level = getBreadcrumbLogLevelFromHttpStatusCode(breadcrumbData.status_code); - - addBreadcrumb( - { - category: 'fetch', - data: breadcrumbData, - type: 'http', - level, - }, - hint, - ); - } -} +export const winterCGFetchIntegration = createFetchIntegration({ + name: 'WinterCGFetch', + spanOrigin: 'auto.http.wintercg_fetch', +}); diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index 9d9ffcd755f4..85c24eb11683 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -1,210 +1,49 @@ -import type { HandlerDataFetch, Integration } from '@sentry/core'; -import * as sentryCore from '@sentry/core'; -import { createStackParser } from '@sentry/core'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TransactionEvent } from '@sentry/core'; +import { createStackParser, setCurrentClient, startSpan } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { VercelEdgeClient } from '../src/index'; import { winterCGFetchIntegration } from '../src/integrations/wintercg-fetch'; +import { getDefaultIntegrations } from '../src/sdk'; -class FakeClient extends VercelEdgeClient { - public getIntegrationByName(name: string): T | undefined { - return name === 'WinterCGFetch' ? (winterCGFetchIntegration() as T) : undefined; - } -} - -const addFetchInstrumentationHandlerSpy = vi.spyOn(sentryCore, 'addFetchInstrumentationHandler'); -const instrumentFetchRequestSpy = vi.spyOn(sentryCore, 'instrumentFetchRequest'); -const addBreadcrumbSpy = vi.spyOn(sentryCore, 'addBreadcrumb'); +// The behavior lives in `createFetchIntegration` and is covered by +// `packages/core/test/lib/integrations/fetch.test.ts`. This only pins the wiring. +describe('winterCGFetchIntegration', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); -describe('WinterCGFetch instrumentation', () => { - let client: FakeClient; + it('is named `Fetch` and is enabled by default', () => { + expect(winterCGFetchIntegration().name).toBe('WinterCGFetch'); + expect(getDefaultIntegrations().map(integration => integration.name)).toContain('WinterCGFetch'); + }); - beforeEach(() => { - vi.clearAllMocks(); + it('creates `http.client` spans with the `auto.http.wintercg_fetch` origin', async () => { + vi.stubGlobal('fetch', () => Promise.resolve(new Response('ok'))); - client = new FakeClient({ + const transactions: TransactionEvent[] = []; + const client = new VercelEdgeClient({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1, - integrations: [], - transport: () => ({ - send: () => Promise.resolve({}), - flush: () => Promise.resolve(true), - }), - tracePropagationTargets: ['http://my-website.com/'], + traceLifecycle: 'static', + integrations: [winterCGFetchIntegration()], stackParser: createStackParser(), - }); - - vi.spyOn(sentryCore, 'getClient').mockImplementation(() => client); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).toHaveBeenCalledWith( - startHandlerData, - expect.any(Function), - expect.any(Function), - expect.any(Object), - { spanOrigin: 'auto.http.wintercg_fetch' }, - ); - - const [, shouldCreateSpan, shouldAttachTraceData] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldAttachTraceData('http://my-website.com/')).toBe(true); - expect(shouldAttachTraceData('https://www.3rd-party-website.at/')).toBe(false); - - // tracePropagationTargets match regardless of casing - expect(shouldAttachTraceData('http://MY-WEBSITE.com/')).toBe(true); - expect(shouldAttachTraceData('https://WWW.3RD-PARTY-WEBSITE.at/')).toBe(false); - - expect(shouldCreateSpan('http://my-website.com/')).toBe(true); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(true); - }); - - it('should not instrument if client is not setup', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - // integration.setup!(client) is not called! - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should call `instrumentFetchRequest` for outgoing fetch requests to Sentry', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(instrumentFetchRequestSpy).not.toHaveBeenCalled(); - }); - - it('should properly apply the `shouldCreateSpanForRequest` option', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration({ - shouldCreateSpanForRequest(url) { - return url === 'http://only-acceptable-url.com/'; + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + beforeSendTransaction(event) { + transactions.push(event); + return null; }, }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - startTimestamp: Date.now(), - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - const [, shouldCreateSpan] = instrumentFetchRequestSpy.mock.calls[0]!; - - expect(shouldCreateSpan('http://only-acceptable-url.com/')).toBe(true); - expect(shouldCreateSpan('http://my-website.com/')).toBe(false); - expect(shouldCreateSpan('https://www.3rd-party-website.at/')).toBe(false); - }); - - it('should create a breadcrumb for an outgoing request', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); + setCurrentClient(client); + client.init(); - const integration = winterCGFetchIntegration(); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; - - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); - - expect(addBreadcrumbSpy).toBeCalledWith( - { - category: 'fetch', - data: { - method: 'POST', - status_code: 201, - url: 'http://my-website.com/', - }, - type: 'http', - }, - { - endTimestamp, - input: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' }, - startTimestamp, - }, - ); - }); - - it('should not create a breadcrumb for an outgoing request if `breadcrumbs: false` is set', () => { - addFetchInstrumentationHandlerSpy.mockImplementationOnce(() => undefined); - - const integration = winterCGFetchIntegration({ breadcrumbs: false }); - integration.setupOnce!(); - integration.setup!(client); - - const [fetchInstrumentationHandlerCallback] = addFetchInstrumentationHandlerSpy.mock.calls[0]!; - expect(fetchInstrumentationHandlerCallback).toBeDefined(); - - const startTimestamp = Date.now(); - const endTimestamp = Date.now() + 100; + await startSpan({ name: 'parent', op: 'test' }, async () => { + await fetch('http://my-website.com/').then(response => response.text()); + }); - const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'http://my-website.com/', method: 'POST' }, - args: ['http://my-website.com/'], - response: { ok: true, status: 201, url: 'http://my-website.com/' } as Response, - startTimestamp, - endTimestamp, - }; - fetchInstrumentationHandlerCallback(startHandlerData); + const parent = transactions.find(event => event.transaction === 'parent'); + const clientSpan = parent?.spans?.find(span => span.op === 'http.client'); - expect(addBreadcrumbSpy).not.toHaveBeenCalled(); + expect(clientSpan).toBeDefined(); + expect(clientSpan?.origin).toBe('auto.http.wintercg_fetch'); }); }); From e9b5ddfd9c66c95f53756d7c67e128b1a2dd4270 Mon Sep 17 00:00:00 2001 From: Mark Snelling Date: Thu, 17 Sep 2026 08:48:35 +0100 Subject: [PATCH 03/39] fix(cloudflare): Instrument namespaces returned by jurisdiction() (#24450) `instrumentDurableObjectNamespace` special-cases `get` and `getByName`, and binds every other function to the raw target. `jurisdiction()` therefore returned an uninstrumented namespace, so stubs from a jurisdiction-restricted namespace got no fetch span and never appended `__sentry_rpc_meta__`. Apps using data-location jurisdictions (for example EU residency) lost trace continuity at every Durable Object call. `jurisdiction()` now returns a namespace instrumented the same way as its parent, with the same `propagateRpcTrace` setting. This has unit coverage only: local workerd throws "Jurisdiction restrictions are not implemented in workerd", so an integration suite can't exercise it. Split out of #24447, which now covers #24443 only. Fixes #24442 - [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. _Written with AI assistance (Claude Code)._ Co-authored-by: Claude Opus 5 --- .../instrumentDurableObjectNamespace.ts | 9 ++++ .../instrumentDurableObjectNamespace.test.ts | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts index 321a90dd3c4e..cb3db118125a 100644 --- a/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts +++ b/packages/cloudflare/src/instrumentations/instrumentDurableObjectNamespace.ts @@ -10,6 +10,7 @@ export const STUB_NON_RPC_METHODS = new Set(['fetch', 'connect', 'dup']); * * Wraps: * - `namespace.get(id)` / `namespace.getByName(name)` with a span + instruments returned stub + * - `namespace.jurisdiction(name)` by instrumenting the returned namespace the same way * - `namespace.idFromName(name)` / `namespace.idFromString(id)` / `namespace.newUniqueId()` with breadcrumbs * * @param namespace - The DurableObjectNamespace to instrument @@ -35,6 +36,14 @@ export function instrumentDurableObjectNamespace( }; } + if (prop === 'jurisdiction') { + return function (this: unknown, ...args: unknown[]) { + const subnamespace = Reflect.apply(value, target, args); + + return instrumentDurableObjectNamespace(subnamespace, propagateRpcTrace); + }; + } + return value.bind(target); }, }); diff --git a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts index 05c78ae40089..d86c8081d0c2 100644 --- a/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentDurableObjectNamespace.test.ts @@ -112,6 +112,57 @@ describe('instrumentDurableObjectNamespace', () => { }); }); + describe('jurisdiction', () => { + function createJurisdictionNamespace() { + const rpcMethod = vi.fn().mockReturnValue('rpc-result'); + const { namespace: subnamespace } = createMockNamespace(); + subnamespace.get.mockReturnValue({ + id: { toString: () => 'mock-id', equals: () => false, name: 'test' }, + fetch: vi.fn(), + myRpcMethod: rpcMethod, + }); + const { namespace } = createMockNamespace(); + namespace.jurisdiction.mockReturnValue(subnamespace); + + return { namespace, subnamespace, rpcMethod }; + } + + it('propagates RPC trace context from stubs of the jurisdiction-restricted namespace', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }); + const { namespace, subnamespace, rpcMethod } = createJurisdictionNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace, true); + + const eu = instrumented.jurisdiction('eu'); + const stub = eu.get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1'); + + expect(namespace.jurisdiction).toHaveBeenCalledWith('eu'); + expect(subnamespace.get).toHaveBeenCalledOnce(); + expect(rpcMethod).toHaveBeenCalledWith('arg1', { + __sentry_rpc_meta__: { + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: 'sentry-environment=production', + }, + }); + }); + + it('does not propagate RPC trace context when propagation is off for the binding', () => { + vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + }); + const { namespace, rpcMethod } = createJurisdictionNamespace(); + const instrumented = instrumentDurableObjectNamespace(namespace); + + const stub = instrumented.jurisdiction('eu').get({ toString: () => 'id', equals: () => false } as any); + (stub as any).myRpcMethod('arg1'); + + expect(rpcMethod).toHaveBeenCalledWith('arg1'); + }); + }); + describe('stub instrumentation', () => { it('calls stub.fetch with URL object', async () => { const { namespace, mockStub } = createMockNamespace(); From 11b422ec5ff8edad97c1e502a6705f4bf932a881 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Thu, 17 Sep 2026 14:25:11 +0300 Subject: [PATCH 04/39] test(server-utils): Cover the Flue instrumentation (#24266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #24265 — review that first, this is tests only. Unit coverage for the instrumentation, plus an integration suite that drives an agent through a tool call and asserts the `invoke_agent` → `chat` / `execute_tool` hierarchy — including that tool spans are siblings of `chat` rather than children, matching how Flue's own OpenTelemetry adapter projects them. The scenario uses `pi-ai`'s `faux` provider so responses are scripted in-process and no provider key is needed. It is ESM only (`@flue/runtime` has no `require` export condition) and installed per-suite, since `engines.node >= 22.19` would break `yarn install` on the Node 20 lane. Guarded by `conditionalTest({ min: 22 })`, so it skips on the repo's default Node 20. Each case was mutation-tested rather than just run green, which is what caught the tests that passed vacuously and an unreachable branch in `trackSpan` that has since been removed. --------- Co-authored-by: Claude Opus 5 --- .../suites/tracing/flue/instrument.mjs | 11 + .../suites/tracing/flue/scenario.mjs | 42 ++ .../suites/tracing/flue/test.ts | 114 ++++ .../test/ai/lib/tracing/flue.test.ts | 638 ++++++++++++++++++ 4 files changed, 805 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/flue/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/flue/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/flue/test.ts create mode 100644 packages/server-utils/test/ai/lib/tracing/flue.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/flue/instrument.mjs new file mode 100644 index 000000000000..31609dbe3960 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flue/instrument.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'stream', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/flue/scenario.mjs new file mode 100644 index 000000000000..b71b000bf18f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flue/scenario.mjs @@ -0,0 +1,42 @@ +import * as Sentry from '@sentry/node'; +import { __flueBindAgentModule, init, instrument, useModel, useTool } from '@flue/runtime'; +import { start } from '@flue/runtime/node'; +import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux'; +import * as v from 'valibot'; + +// `pi-ai`'s faux provider scripts model responses in-process, so the run is deterministic and needs +// no provider key or mock server. Two steps: a tool call, then the final answer. +instrument(Sentry.createFlueInstrumentation()); + +const faux = fauxProvider({ + provider: 'faux', + models: [{ id: 'faux-model', cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }], +}); +faux.setResponses([ + fauxAssistantMessage(fauxToolCall('get_weather', { city: 'Berlin' }, { id: 'call_1' }), { stopReason: 'toolUse' }), + fauxAssistantMessage('It is 21 degrees and sunny in Berlin.'), +]); + +function Hello() { + useModel('faux/faux-model'); + useTool({ + name: 'get_weather', + description: 'Get the current weather for a city.', + // Without an `input` schema Flue validates the call against an empty one and rejects the + // model's arguments, so the tool never runs and its span settles as an error. + input: v.object({ city: v.string() }), + run: ({ data }) => `It is 21 degrees and sunny in ${data.city}.`, + }); + return 'You are a helpful assistant.'; +} +__flueBindAgentModule(Hello, { identity: 'Hello' }); + +await Sentry.startSpan({ name: 'flue-test', op: 'function' }, async () => { + const flue = await start({ agents: [Hello], providers: [faux.provider] }); + const agent = init(Hello, { id: 'e2e' }); + const receipt = await agent.dispatch('What is the weather in Berlin?'); + await agent.read(receipt); + await flue[Symbol.asyncDispose]?.(); +}); + +await Sentry.flush(2000); diff --git a/dev-packages/node-integration-tests/suites/tracing/flue/test.ts b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts new file mode 100644 index 000000000000..98ad59dcc4df --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/flue/test.ts @@ -0,0 +1,114 @@ +import { + GEN_AI_AGENT_NAME, + GEN_AI_CONVERSATION_ID, + GEN_AI_COST_TOTAL_TOKENS, + GEN_AI_OPERATION_NAME, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_TOOL_NAME, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, expect } from 'vitest'; +import { conditionalTest } from '../../../utils'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +// `@flue/runtime` declares `engines.node >= 22.19`, so it can't live in the package's root +// `devDependencies` (that would break `yarn install` on the 20.19 CI matrix). Install it per-suite +// instead, guarded by the `min: 22` skip below. +const FLUE_DEPENDENCIES = { + additionalDependencies: { + '@flue/runtime': '2.0.3', + '@earendil-works/pi-ai': '0.85.1', + valibot: '1.1.0', + }, +}; + +conditionalTest({ min: 22 })('Flue integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test, mode) => { + // `@flue/runtime` is ESM-only — its `exports` map has no `require` condition, so there is no + // CJS variant of this scenario to run. + if (mode === 'cjs') { + return; + } + + test('creates the invoke_agent / chat / execute_tool hierarchy', async () => { + await createRunner() + .expect({ + span: container => { + const spans = container.items; + + const root = spans.find(span => span.name === 'flue-test')!; + expect(root.is_segment).toBe(true); + + // Counted rather than looked up: the interceptor skips the submission wrapper + // operation, so one dispatch opens exactly one agent span, and each turn and tool call + // is spanned once. `find` passes just as happily on a duplicate. + expect(spans.filter(span => span.attributes['sentry.origin']?.value === 'auto.ai.flue')).toHaveLength(4); + + const agents = spans.filter(span => span.name === 'invoke_agent Hello'); + const chats = spans.filter(span => span.name === 'chat faux-model'); + const tools = spans.filter(span => span.name === 'execute_tool get_weather'); + expect(agents).toHaveLength(1); + expect(tools).toHaveLength(1); + // One turn asks for the tool, the second answers with its result. + expect(chats).toHaveLength(2); + + const agent = agents[0]!; + expect(agent.attributes['sentry.op']?.value).toBe('gen_ai.invoke_agent'); + expect(agent.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); + expect(agent.attributes[GEN_AI_AGENT_NAME]?.value).toBe('Hello'); + expect(agent.parent_span_id).toBe(root.span_id); + + const conversationId = agent.attributes[GEN_AI_CONVERSATION_ID]?.value; + expect(conversationId).toEqual(expect.any(String)); + + // Both turns, not just the first: they leave the provider by different paths (a tool + // call, then a final answer) and resolve their parent through separate tracker lookups. + for (const chat of chats) { + expect(chat.attributes['sentry.op']?.value).toBe('gen_ai.chat'); + expect(chat.attributes['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(chat.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chat.attributes[GEN_AI_CONVERSATION_ID]?.value).toBe(conversationId); + expect(chat.parent_span_id).toBe(agent.span_id); + expect(chat.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBeGreaterThan(0); + expect(chat.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBeGreaterThan(0); + expect(chat.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBeGreaterThan(0); + // Flue computes cost itself; no provider SDK reports it. The faux provider prices + // every model at zero, so this only proves the attribute is mapped. + expect(chat.attributes[GEN_AI_COST_TOTAL_TOKENS]?.value).toEqual(expect.any(Number)); + } + + expect(chats.map(chat => chat.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).sort()).toEqual([ + '["stop"]', + '["toolUse"]', + ]); + + const tool = tools[0]!; + // The tool has to actually run: a schema mismatch still produces a correctly named and + // parented span, so only the status separates a real call from a rejected one. + expect(tool.status).toBe('ok'); + expect(tool.attributes['sentry.op']?.value).toBe('gen_ai.execute_tool'); + expect(tool.attributes['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(tool.attributes[GEN_AI_TOOL_NAME]?.value).toBe('get_weather'); + + // Tool spans are siblings of `chat` under the agent invocation, matching how Flue's + // own OpenTelemetry adapter projects them. + expect(tool.parent_span_id).toBe(agent.span_id); + }, + }) + .start() + .completed(); + }); + }, + FLUE_DEPENDENCIES, + ); +}); diff --git a/packages/server-utils/test/ai/lib/tracing/flue.test.ts b/packages/server-utils/test/ai/lib/tracing/flue.test.ts new file mode 100644 index 000000000000..5817e1033871 --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/flue.test.ts @@ -0,0 +1,638 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { Event, Span } from '@sentry/core'; +import { + _INTERNAL_clearAiProviderSkips, + _INTERNAL_shouldSkipAiProviderWrapping, + _INTERNAL_skipAiProviderWrapping, + getMainCarrier, + setCurrentClient, + spanToStaticSpanJSON, + startInactiveSpan, + startSpan, +} from '@sentry/core'; +import { ANTHROPIC_AI_INTEGRATION_NAME } from '../../../../src/ai/anthropic-ai/constants'; +import { createFlueInstrumentation } from '../../../../src/ai/flue'; +import { MAX_TRACKED_FLUE_SPANS } from '../../../../src/ai/flue/constants'; +import { GOOGLE_GENAI_INTEGRATION_NAME } from '../../../../src/ai/google-genai/constants'; +import type { FlueInstrumentation, FlueObservation } from '../../../../src/ai/flue/types'; +import { OPENAI_INTEGRATION_NAME } from '../../../../src/ai/openai/constants'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +const AGENT_OP = { type: 'agent', operationId: 'op_1' }; +const AGENT_CTX = { agentName: 'Hello', submissionId: 'sub_1' }; +const INNER_CTX = { conversationId: 'conv_1' }; + +/** A settled turn as Flue reports it, with the field names `ModelRequestInfo`/`ModelResponse` use. */ +function turn(overrides: Partial = {}): FlueObservation { + return { + type: 'turn', + turnId: 'turn_1', + submissionId: 'sub_1', + operationId: 'op_1', + request: { + requestedModel: 'claude-haiku-4.5', + providerId: 'anthropic', + temperature: 0.7, + maxTokens: 1024, + reasoningLevel: 'high', + serverAddress: 'api.anthropic.com', + serverPort: 443, + }, + response: { + responseId: 'resp_1', + finishReason: 'stop', + usage: { + input: 924, + output: 57, + totalTokens: 981, + cacheRead: 0, + cacheWrite: 0, + cost: { input: 0.000924, output: 0.000275, total: 0.001199, cacheRead: 0, cacheWrite: 0 }, + }, + }, + ...overrides, + }; +} + +describe('createFlueInstrumentation', () => { + let endedSpans: Span[]; + let client: TestClient; + let instrumentation: FlueInstrumentation; + + beforeEach(() => { + _INTERNAL_clearAiProviderSkips(); + getMainCarrier().__SENTRY__ = undefined; + client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle: 'stream', + }), + ); + setCurrentClient(client); + client.init(); + + endedSpans = []; + client.on('spanEnd', span => endedSpans.push(span)); + instrumentation = createFlueInstrumentation(); + }); + + afterEach(() => { + _INTERNAL_clearAiProviderSkips(); + getMainCarrier().__SENTRY__ = undefined; + }); + + /** Run `fn` inside an agent operation, the way Flue's interceptor would. */ + function withAgent(fn: () => Promise | T): Promise { + return instrumentation.interceptor(AGENT_OP, AGENT_CTX, async () => fn()); + } + + function agentSpans(): ReturnType[] { + return endedSpans + .map(span => spanToStaticSpanJSON(span)) + .filter(json => json.data['sentry.op'] === 'gen_ai.invoke_agent'); + } + + function findSpan(description: string): ReturnType | undefined { + return endedSpans.map(span => spanToStaticSpanJSON(span)).find(json => json.description === description); + } + + // Flue drives the providers through `pi-ai`, which bundles the `openai` / `@anthropic-ai/sdk` / + // `@google/genai` clients those integrations patch, so their spans duplicate the turn span. + // Not at construction: if `instrument()` rejects the object, suppressing the provider + // integrations would leave the app with no `gen_ai.chat` spans at all. + it('skips raw provider wrapping on first use, not on construction', async () => { + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + + await withAgent(() => undefined); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + }); + + // The registry is reset per client (`_setupIntegrations` clears it, and Cloudflare calls `init()` + // per request), so a one-shot call at construction is wiped by the next reset. + it('re-applies the provider skip after the registry is cleared', async () => { + await withAgent(() => undefined); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + + _INTERNAL_clearAiProviderSkips(); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(false); + + await withAgent(() => undefined); + expect(_INTERNAL_shouldSkipAiProviderWrapping(OPENAI_INTEGRATION_NAME)).toBe(true); + }); + + // The guard has to hold for every provider, not just the first: another integration may have + // registered a skip for one of them already, which would otherwise short-circuit the rest. + it('applies the skip to every provider when only some are already registered', async () => { + _INTERNAL_skipAiProviderWrapping([OPENAI_INTEGRATION_NAME]); + + await withAgent(() => undefined); + + expect(_INTERNAL_shouldSkipAiProviderWrapping(ANTHROPIC_AI_INTEGRATION_NAME)).toBe(true); + expect(_INTERNAL_shouldSkipAiProviderWrapping(GOOGLE_GENAI_INTEGRATION_NAME)).toBe(true); + }); + + // A turn whose stream is abandoned never emits the settled `turn` that would remove it, so the + // tracker is capped. Eviction has to end the span it drops, or it is never sent. + it('ends the oldest chat span when the turn tracker overflows', async () => { + await withAgent(() => { + for (let i = 0; i <= MAX_TRACKED_FLUE_SPANS; i++) { + instrumentation.observe({ type: 'turn_start', turnId: `turn_${i}`, operationId: 'op_1' }, {}); + } + }); + + expect(endedSpans.filter(span => spanToStaticSpanJSON(span).data['sentry.op'] === 'gen_ai.chat')).toHaveLength(1); + }); + + it('names agent spans `invoke_agent {name}` and sets the gen_ai op', async () => { + await withAgent(() => undefined); + + const json = findSpan('invoke_agent Hello'); + expect(json?.data['sentry.op']).toBe('gen_ai.invoke_agent'); + expect(json?.data['sentry.origin']).toBe('auto.ai.flue'); + expect(json?.data['gen_ai.operation.name']).toBe('invoke_agent'); + expect(json?.data['gen_ai.agent.name']).toBe('Hello'); + }); + + // The agent span opens before the conversation is known — the submission-scoped operation names + // the agent, and the conversation arrives on the observations that follow. + it('sets the conversation id on the agent span from the observations', async () => { + await withAgent(() => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', conversationId: 'conv_1' }, + {}, + ); + }); + + expect(findSpan('invoke_agent Hello')?.data['gen_ai.conversation.id']).toBe('conv_1'); + }); + + // The re-entered agent operation carries no `submissionId` and must not open a second span. + it('does not open a second agent span for the re-entry', async () => { + await withAgent(() => instrumentation.interceptor(AGENT_OP, INNER_CTX, async () => undefined)); + + const agentSpans = endedSpans + .map(span => spanToStaticSpanJSON(span)) + .filter(json => json.data['sentry.op'] === 'gen_ai.invoke_agent'); + expect(agentSpans).toHaveLength(1); + }); + + // A durable submission is resumed later, with nothing linking it to the request that enqueued it. + // Flue replays that request's `traceparent`, so the agent span should continue from it. + describe('trace continuation', () => { + const TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const CARRIER = { traceparent: `00-${TRACE_ID}-bbbbbbbbbbbbbbbb-01` }; + + function agentTraceId(): string | undefined { + return endedSpans.map(span => spanToStaticSpanJSON(span)).find(json => json.description === 'invoke_agent Hello') + ?.trace_id; + } + + it('continues the trace from the replayed traceparent', async () => { + await instrumentation.interceptor(AGENT_OP, { ...AGENT_CTX, traceCarrier: CARRIER }, async () => undefined); + + expect(agentTraceId()).toBe(TRACE_ID); + }); + + it('ignores a malformed traceparent', async () => { + await instrumentation.interceptor( + AGENT_OP, + { ...AGENT_CTX, traceCarrier: { traceparent: 'not-a-traceparent' } }, + async () => undefined, + ); + + // Asserting a well-formed id, not just "not the carrier's": the span has to still be opened + // on a fresh trace, and `not.toBe` alone would also pass if no span were opened at all. + expect(agentTraceId()).toMatch(/^[0-9a-f]{32}$/); + expect(agentTraceId()).not.toBe(TRACE_ID); + }); + + // An in-process dispatch is genuinely part of the surrounding trace; continuing the persisted + // one would detach it from the request it is actually running inside. + it('keeps the active trace when one is already running', async () => { + let requestTraceId: string | undefined; + + await startSpan({ name: 'incoming request' }, async span => { + requestTraceId = spanToStaticSpanJSON(span).trace_id; + await instrumentation.interceptor(AGENT_OP, { ...AGENT_CTX, traceCarrier: CARRIER }, async () => undefined); + }); + + expect(agentTraceId()).toBe(requestTraceId); + }); + }); + + // The spanned operation carries no `agentName` — only the submission wrapper does, and that opens + // no span. The name arrives on the observations instead. + it('names the agent span from the observations', async () => { + await instrumentation.interceptor(AGENT_OP, {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_1', agentName: 'Hello' }, {}); + }); + + const json = findSpan('invoke_agent Hello'); + expect(json).toBeDefined(); + expect(json?.data['gen_ai.agent.name']).toBe('Hello'); + }); + + // Delegation nests a second agent operation inside the first, in its own session. Flue defers + // each to a microtask, so the helper mirrors that rather than calling the interceptor directly. + describe('subagent delegation', () => { + function runNested(operationId: string, ctx: Record, next: () => Promise): Promise { + return Promise.resolve().then(() => instrumentation.interceptor({ type: 'agent', operationId }, ctx, next)); + } + + it('opens one agent span per invocation rather than folding the delegate into its parent', async () => { + await runNested('op_parent', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_parent', conversationId: 'conv_parent' }, {}); + // The tool's task delegation, then the delegate's own prompt. + return runNested('op_child', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_child', conversationId: 'conv_child' }, {}); + }); + }); + + expect(agentSpans()).toHaveLength(2); + }); + + // Asserted on conversation rather than agent name: the observation stream reports the root + // agent's name for both operations, so the delegate's own name never reaches us. + it('keeps each invocation on its own conversation', async () => { + await runNested('op_parent', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_parent', conversationId: 'conv_parent' }, {}); + return runNested('op_child', {}, async () => { + instrumentation.observe({ type: 'agent_start', operationId: 'op_child', conversationId: 'conv_child' }, {}); + }); + }); + + // The delegate ends first, so order is inner-to-outer. + expect(agentSpans().map(json => json.data['gen_ai.conversation.id'])).toEqual(['conv_child', 'conv_parent']); + }); + }); + + it('does not span operations other than `agent`', async () => { + await instrumentation.interceptor({ type: 'model', turnId: 'turn_1' }, {}, async () => undefined); + + expect(endedSpans).toHaveLength(0); + }); + + // The `model` and `tool` operations open no span of their own; they make the span `observe` + // already opened active, so the provider's HTTP call and the tool's own work nest inside it + // rather than landing beside it as siblings of the agent invocation. + it('makes the turn span active for the model operation it wraps', async () => { + await withAgent(async () => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + await instrumentation.interceptor({ type: 'model', turnId: 'turn_1' }, {}, async () => { + startInactiveSpan({ name: 'provider request' }).end(); + }); + instrumentation.observe(turn(), {}); + }); + + expect(findSpan('provider request')?.parent_span_id).toBe(findSpan('chat claude-haiku-4.5')?.span_id); + }); + + it('makes the tool span active for the tool operation it wraps', async () => { + await withAgent(async () => { + instrumentation.observe( + { type: 'tool_start', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, + {}, + ); + await instrumentation.interceptor({ type: 'tool', toolCallId: 'call_1' }, {}, async () => { + startInactiveSpan({ name: 'tool work' }).end(); + }); + instrumentation.observe({ type: 'tool', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, {}); + }); + + expect(findSpan('tool work')?.parent_span_id).toBe(findSpan('execute_tool get_weather')?.span_id); + }); + + it('opens a chat span on turn_start and completes it from the settled turn', async () => { + await withAgent(() => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', conversationId: 'conv_1' }, + {}, + ); + instrumentation.observe(turn(), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json?.data['sentry.op']).toBe('gen_ai.chat'); + expect(json?.data['sentry.origin']).toBe('auto.ai.flue'); + expect(json?.data['gen_ai.request.model']).toBe('claude-haiku-4.5'); + expect(json?.data['gen_ai.provider.name']).toBe('anthropic'); + expect(json?.data['gen_ai.response.id']).toBe('resp_1'); + expect(json?.data['gen_ai.response.finish_reasons']).toBe('["stop"]'); + expect(json?.data['gen_ai.conversation.id']).toBe('conv_1'); + }); + + // Flue computes costs itself; the provider SDKs report none. + it('records token usage and Flue-computed cost on the chat span', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe(turn(), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json?.data['gen_ai.usage.input_tokens']).toBe(924); + expect(json?.data['gen_ai.usage.output_tokens']).toBe(57); + expect(json?.data['gen_ai.usage.total_tokens']).toBe(981); + expect(json?.data['gen_ai.cost.total_tokens']).toBe(0.001199); + }); + + it('records the model-call tuning and provider endpoint', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1', purpose: 'agent' }, {}); + instrumentation.observe(turn(), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json?.data['gen_ai.request.temperature']).toBe(0.7); + expect(json?.data['gen_ai.request.max_tokens']).toBe(1024); + expect(json?.data['gen_ai.request.reasoning.level']).toBe('high'); + expect(json?.data['server.address']).toBe('api.anthropic.com'); + expect(json?.data['server.port']).toBe(443); + // Distinguishes a compaction turn from a user-facing one. + expect(json?.data['flue.turn.purpose']).toBe('agent'); + }); + + it('marks a failed turn as errored', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe(turn({ isError: true }), {}); + }); + + expect(findSpan('chat claude-haiku-4.5')?.status).toBe('internal_error'); + }); + + // A turn that fails before the provider bills anything reports every counter as 0; writing those + // reads as a real zero-cost call. + it('omits usage entirely when a failed turn produced no tokens', async () => { + const empty = { + input: 0, + output: 0, + totalTokens: 0, + cacheRead: 0, + cacheWrite: 0, + cost: { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0 }, + }; + + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe(turn({ isError: true, response: { usage: empty } }), {}); + }); + + const json = findSpan('chat claude-haiku-4.5'); + expect(json).toBeDefined(); + expect(json?.data['gen_ai.usage.total_tokens']).toBeUndefined(); + expect(json?.data['gen_ai.cost.total_tokens']).toBeUndefined(); + }); + + describe('content recording', () => { + const requestContent = { + type: 'turn_request', + turnId: 'turn_1', + request: { + requestedModel: 'claude-haiku-4.5', + input: { + systemPrompt: 'You are helpful.', + messages: [{ role: 'user', content: 'hi' }], + tools: [{ name: 'get_weather', description: 'weather', parameters: {} }], + }, + }, + } satisfies FlueObservation; + + async function record(instr: FlueInstrumentation): Promise { + await instr.interceptor(AGENT_OP, AGENT_CTX, async () => { + instr.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instr.observe(requestContent, {}); + instr.observe(turn({ response: { ...turn().response, output: { role: 'assistant' } } }), {}); + instr.observe( + { + type: 'tool_start', + toolCallId: 'c1', + toolName: 'get_weather', + args: { city: 'Berlin' }, + operationId: 'op_1', + }, + {}, + ); + instr.observe( + { type: 'tool', toolCallId: 'c1', toolName: 'get_weather', result: 'sunny', operationId: 'op_1' }, + {}, + ); + }); + } + + it('records messages, instructions, tool definitions, arguments and results by default', async () => { + await record(instrumentation); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat?.data['gen_ai.system_instructions']).toBe('You are helpful.'); + expect(chat?.data['gen_ai.input.messages']).toContain('"role":"user"'); + expect(chat?.data['gen_ai.output.messages']).toContain('"role":"assistant"'); + expect(chat?.data['gen_ai.tool.definitions']).toContain('get_weather'); + + const tool = findSpan('execute_tool get_weather'); + expect(tool?.data['gen_ai.tool.call.arguments']).toBe('{"city":"Berlin"}'); + expect(tool?.data['gen_ai.tool.call.result']).toBe('sunny'); + }); + + it('omits inputs when recordInputs is false but keeps outputs', async () => { + await record(createFlueInstrumentation({ recordInputs: false })); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat?.data['gen_ai.input.messages']).toBeUndefined(); + expect(chat?.data['gen_ai.system_instructions']).toBeUndefined(); + expect(chat?.data['gen_ai.tool.definitions']).toBeUndefined(); + expect(chat?.data['gen_ai.output.messages']).toBeDefined(); + expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.arguments']).toBeUndefined(); + }); + + // The client is replaced per request on Cloudflare, so options captured once at construction + // would be the wrong ones for every later request. + it('follows the current client when it is replaced', async () => { + const instr = createFlueInstrumentation(); + await record(instr); + expect(findSpan('chat claude-haiku-4.5')?.data['gen_ai.input.messages']).toBeDefined(); + + endedSpans.length = 0; + const strict = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle: 'stream', + dataCollection: { genAI: { inputs: false, outputs: false } }, + }), + ); + setCurrentClient(strict); + strict.init(); + strict.on('spanEnd', span => endedSpans.push(span)); + + await record(instr); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat).toBeDefined(); + expect(chat?.data['gen_ai.input.messages']).toBeUndefined(); + }); + + it('omits outputs when recordOutputs is false but keeps inputs', async () => { + await record(createFlueInstrumentation({ recordOutputs: false })); + + const chat = findSpan('chat claude-haiku-4.5'); + expect(chat?.data['gen_ai.output.messages']).toBeUndefined(); + expect(chat?.data['gen_ai.input.messages']).toBeDefined(); + expect(findSpan('execute_tool get_weather')?.data['gen_ai.tool.call.result']).toBeUndefined(); + }); + }); + + it('emits execute_tool spans keyed by tool call id', async () => { + await withAgent(() => { + instrumentation.observe( + { type: 'tool_start', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, + {}, + ); + instrumentation.observe({ type: 'tool', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, {}); + }); + + const json = findSpan('execute_tool get_weather'); + expect(json?.data['sentry.op']).toBe('gen_ai.execute_tool'); + expect(json?.data['sentry.origin']).toBe('auto.ai.flue'); + expect(json?.data['gen_ai.operation.name']).toBe('execute_tool'); + expect(json?.data['gen_ai.tool.name']).toBe('get_weather'); + }); + + // Flue catches whatever the tool threw and feeds it back to the model as a tool result, so + // nothing reaches the SDK's global handlers. Without an explicit capture there is an errored span + // and no error event at all. + it('captures an error event for a failed tool, rebuilt from `errorInfo`', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'c1', toolName: 'boom', operationId: 'op_1' }, {}); + instrumentation.observe( + { + type: 'tool', + toolCallId: 'c1', + toolName: 'boom', + operationId: 'op_1', + isError: true, + errorInfo: { type: 'Error', name: 'TypeError', message: 'kaboom', stack: 'TypeError: kaboom\n at run' }, + }, + {}, + ); + }); + + await client.flush(); + + const exception = client.event?.exception?.values?.[0]; + expect(exception?.type).toBe('TypeError'); + expect(exception?.value).toBe('kaboom'); + expect(exception?.mechanism?.type).toBe('auto.ai.flue'); + // Handled: Flue caught the throw and returned it to the model, so no global hook sees it. + expect(exception?.mechanism?.handled).toBe(true); + }); + + // Flue swallows the throw, so nothing else reports it. If a second capture path is ever added, + // or the error starts propagating to the global handlers, this catches the duplicate. + it('captures a failed tool exactly once', async () => { + const events: Event[] = []; + TestClient.sendEventCalled = event => events.push(event); + + try { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'c1', toolName: 'boom', operationId: 'op_1' }, {}); + instrumentation.observe( + { + type: 'tool', + toolCallId: 'c1', + toolName: 'boom', + operationId: 'op_1', + isError: true, + errorInfo: { name: 'Error', message: 'kaboom' }, + }, + {}, + ); + }); + await client.flush(); + } finally { + TestClient.sendEventCalled = undefined; + } + + expect(events.filter(event => event.exception?.values?.length)).toHaveLength(1); + }); + + it('does not capture an error event for a tool that succeeded', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'c1', toolName: 'ok', operationId: 'op_1' }, {}); + instrumentation.observe({ type: 'tool', toolCallId: 'c1', toolName: 'ok', operationId: 'op_1' }, {}); + }); + await client.flush(); + + expect(client.event).toBeUndefined(); + }); + + it('marks a failed tool call as errored', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'tool_start', toolCallId: 'call_1', toolName: 'boom', operationId: 'op_1' }, {}); + instrumentation.observe( + { type: 'tool', toolCallId: 'call_1', toolName: 'boom', isError: true, operationId: 'op_1' }, + {}, + ); + }); + + expect(findSpan('execute_tool boom')?.status).toBe('internal_error'); + }); + + // Two agent runs overlap on a busy server. With shared closure state the second run is mistaken + // for a re-entry of the first: it gets no span, and its conversation id lands on the first's span. + it('keeps concurrent agent runs separate', async () => { + const runA = instrumentation.interceptor({ type: 'agent', operationId: 'op_a' }, { agentName: 'A' }, async () => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_a', operationId: 'op_a', conversationId: 'conv_a' }, + {}, + ); + // B starts while A is still open. + await instrumentation.interceptor({ type: 'agent', operationId: 'op_b' }, { agentName: 'B' }, async () => { + instrumentation.observe( + { type: 'turn_start', turnId: 'turn_b', operationId: 'op_b', conversationId: 'conv_b' }, + {}, + ); + instrumentation.observe(turn({ turnId: 'turn_b', operationId: 'op_b' }), {}); + }); + instrumentation.observe(turn({ turnId: 'turn_a', operationId: 'op_a' }), {}); + }); + await runA; + + const agentA = findSpan('invoke_agent A'); + const agentB = findSpan('invoke_agent B'); + expect(agentA).toBeDefined(); + expect(agentB).toBeDefined(); + + // Each run keeps its own conversation; neither is overwritten by the other. + expect(agentA?.data['gen_ai.conversation.id']).toBe('conv_a'); + expect(agentB?.data['gen_ai.conversation.id']).toBe('conv_b'); + }); + + it('ignores a settled turn or tool it never opened a span for', async () => { + await withAgent(() => { + instrumentation.observe(turn({ turnId: 'never_started' }), {}); + instrumentation.observe({ type: 'tool', toolCallId: 'never_started', toolName: 'x' }, {}); + }); + + expect(endedSpans.map(span => spanToStaticSpanJSON(span).description)).toEqual(['invoke_agent Hello']); + }); + + it('ends spans still open at dispose', async () => { + await withAgent(() => { + instrumentation.observe({ type: 'turn_start', turnId: 'turn_1', operationId: 'op_1' }, {}); + instrumentation.observe( + { type: 'tool_start', toolCallId: 'call_1', toolName: 'get_weather', operationId: 'op_1' }, + {}, + ); + }); + // Never settled, so the span keeps the unqualified name it opened with. + expect(findSpan('chat')).toBeUndefined(); + + instrumentation.dispose(); + + expect(findSpan('chat')).toBeDefined(); + expect(findSpan('execute_tool get_weather')).toBeDefined(); + }); +}); From ac79402413b1fd6d84960dd89946555b6c50940c Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Thu, 17 Sep 2026 15:00:17 +0300 Subject: [PATCH 05/39] test(e2e): Add a node-flue end-to-end application (#24377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #24266 — review that first, this is the e2e app only. The node-integration-test calls `__flueBindAgentModule` by hand, standing in for what `@flue/vite` does from the `'use agent'` directive, so it can't show whether a scaffolded app actually works. This is `flue init` output, built and served the way a user runs it, against a real provider. Covered, in both dev and prod and in both the base and orchestrion variants: - `invoke_agent` / `chat` / `execute_tool` with usage and cost - a thrown tool error becoming an issue - a manual span inside a tool nesting under `execute_tool` - an orchestrion-instrumented `dataloader` span landing in the agent's trace - the provider's `http.client` call nesting inside `chat` The loader runs inside a tool rather than a route so its span shares the agent's trace. No build externals are needed unlike `node-eve` — a Flue node build leaves dependencies as bare specifiers, so `dataloader` stays a real module for the transform to hook. `@flue/*` is pinned because the internal registry proxy 403s on releases it hasn't scanned. _Found_: a thrown tool error produced an errored span and no issue at all, since Flue catches the throw and hands it back to the model as a tool result. Fixed in #24265. --------- Co-authored-by: Claude Opus 5 --- .../test-applications/node-flue/.gitignore | 7 ++ .../node-flue/flue.config.ts | 5 ++ .../test-applications/node-flue/package.json | 50 +++++++++++ .../node-flue/playwright.config.mjs | 16 ++++ .../node-flue/sentry-init.ts | 13 +++ .../node-flue/src/agents/hello.ts | 53 ++++++++++++ .../test-applications/node-flue/src/app.ts | 10 +++ .../node-flue/start-event-proxy.mjs | 6 ++ .../node-flue/tests/dataloader.test.ts | 38 ++++++++ .../node-flue/tests/errors.test.ts | 24 ++++++ .../node-flue/tests/flue.test.ts | 86 +++++++++++++++++++ .../node-flue/tests/utils.ts | 46 ++++++++++ .../test-applications/node-flue/tsconfig.json | 14 +++ .../node-flue/vite.config.ts | 9 ++ 14 files changed, 377 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/flue.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-flue/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/node-flue/.gitignore b/dev-packages/e2e-tests/test-applications/node-flue/.gitignore new file mode 100644 index 000000000000..2685f6ec088a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.flue +*.tsbuildinfo +results.junit.xml +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/node-flue/flue.config.ts b/dev-packages/e2e-tests/test-applications/node-flue/flue.config.ts new file mode 100644 index 000000000000..a31c8523a9a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/flue.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from '@flue/runtime/config'; + +export default defineConfig({ + target: 'node', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/package.json b/dev-packages/e2e-tests/test-applications/node-flue/package.json new file mode 100644 index 000000000000..7f6afaeff6b8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/package.json @@ -0,0 +1,50 @@ +{ + "name": "node-flue", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "NODE_OPTIONS='--import=@sentry/node/import' vite dev --port 3030", + "build": "vite build", + "start": "NODE_OPTIONS='--import=@sentry/node/import' PORT=3030 node dist/server.mjs", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add @flue/runtime@latest @flue/vite@latest @flue/cli@latest && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev", + "test:prod": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=production playwright test", + "test:dev": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY TEST_ENV=development playwright test" + }, + "dependencies": { + "@flue/runtime": "2.0.5", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "dataloader": "^2.2.3", + "hono": "^4.7.0", + "valibot": "^1.5.0" + }, + "devDependencies": { + "@flue/cli": "2.0.5", + "@flue/vite": "2.0.5", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@types/node": "24.x", + "typescript": "~5.9.0", + "vite": "^8.0.14" + }, + "engines": { + "node": "24.x" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-flue (latest)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-flue/playwright.config.mjs new file mode 100644 index 000000000000..1bbde34c9fce --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/playwright.config.mjs @@ -0,0 +1,16 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const config = getPlaywrightConfig( + { startCommand: testEnv === 'development' ? 'pnpm dev' : 'pnpm start' }, + // Each test drives a real OpenRouter tool-calling turn and then waits for the spans to flush, + // which does not fit the default 30s timeout when the provider is slow. + { timeout: 90_000 }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts b/dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts new file mode 100644 index 000000000000..199d1bb11fcd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/sentry-init.ts @@ -0,0 +1,13 @@ +import { instrument } from '@flue/runtime'; +import * as Sentry from '@sentry/node'; + +// Imported for its side effects as the first line of `src/app.ts`, which is how a Flue app sets +// Sentry up: there is no framework-owned instrumentation hook to auto-discover. +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, +}); + +instrument(Sentry.createFlueInstrumentation()); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts b/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts new file mode 100644 index 000000000000..baf29050d836 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/src/agents/hello.ts @@ -0,0 +1,53 @@ +'use agent'; +import { useModel, useTool } from '@flue/runtime'; +import * as Sentry from '@sentry/node'; +import * as v from 'valibot'; +import DataLoader from 'dataloader'; + +// The `'use agent'` directive is how `@flue/vite` finds this module and binds an identity to it at +// build time. That binding is the part a hand-written scenario cannot reproduce, so it is the main +// reason this app exists alongside the node-integration-test suite. +export function Hello() { + useModel('openrouter/anthropic/claude-haiku-4.5'); + + useTool({ + name: 'get_weather', + description: 'Get the current weather for a city.', + input: v.object({ city: v.string() }), + // Wrapped in a manual span: Flue runs the tool while the SDK's `execute_tool` span is active, + // so this should nest directly under it rather than landing beside it. + run: ({ city }) => + Sentry.startSpan( + { name: 'resolve-weather', attributes: { 'weather.source': 'static-table', 'weather.city': city } }, + () => { + return `It is 21 degrees and sunny in ${city}.`; + }, + ), + }); + + // Called from inside a tool on purpose: the dataloader span then lands under `execute_tool` in + // the agent's trace, which is what "captured alongside the AI spans" has to mean. + useTool({ + name: 'count_items', + description: 'Count items by loading them. Call this when the user asks to count items.', + input: v.object({}), + // Constructed per execution, like node-eve does: a module-level loader caches its keys, so a + // second call would skip the batch function and emit no span. + run: async () => { + const loader = new DataLoader(async keys => keys.map(key => key * 2)); + const doubled = await Promise.all([loader.load(1), loader.load(2), loader.load(3)]); + return `Loaded ${doubled.length} items: ${doubled.join(', ')}.`; + }, + }); + + useTool({ + name: 'fail_now', + description: 'Always throws an error. Call this when the user asks to trigger a failure.', + input: v.object({}), + run: () => { + throw new Error('Intentional flue tool failure'); + }, + }); + + return 'You are a helpful assistant. Use get_weather when asked about weather, count_items when asked to count items, and fail_now when asked to fail.'; +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/src/app.ts b/dev-packages/e2e-tests/test-applications/node-flue/src/app.ts new file mode 100644 index 000000000000..b54a3357e4ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/src/app.ts @@ -0,0 +1,10 @@ +import '../sentry-init.ts'; +import { createAgentRouter } from '@flue/runtime/routing'; +import { Hono } from 'hono'; +import { Hello } from './agents/hello.ts'; + +const app = new Hono(); + +app.route('/agents/hello', createAgentRouter(Hello)); + +export default app; diff --git a/dev-packages/e2e-tests/test-applications/node-flue/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-flue/start-event-proxy.mjs new file mode 100644 index 000000000000..a452f0dd18d3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-flue', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts new file mode 100644 index 000000000000..a4fed849c3b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/dataloader.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { newConversationId, runAgentTurn } from './utils'; + +const APP = 'node-flue'; + +const isDataloaderSpan = (span: { attributes?: Record }): boolean => + span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader'; + +/** + * `dataloader` is instrumented through orchestrion, a module transform, so it only produces spans + * with the loader registered at process start. A Flue node build needs no externals config for + * that: dependencies stay bare specifiers, so `dataloader` is still a real module to hook. If Flue + * ever switches to a bundled server output, this is what catches it. + * + * The loader is called from inside a tool so its span lands in the agent's trace, beside the AI + * spans, rather than in a trace of its own. + */ +test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items') && + spansOfTrace.some(isDataloaderSpan), + ); + + await runAgentTurn(baseURL!, newConversationId('dataloader'), 'Please call count_items to count the items.'); + + const spans = await spansPromise; + const dataloaderSpan = spans.find(isDataloaderSpan); + const toolSpan = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'count_items'); + + // Sharing the trace is the point: the orchestrion span is captured alongside the AI spans rather + // than in a trace of its own. Not asserting the exact parent — the model may call the tool more + // than once, and the span that ran the loader is not reliably the one found here. + expect(getSpanOp(dataloaderSpan!)).toBe('cache.get'); + expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts new file mode 100644 index 000000000000..f8b59be4fc1b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/errors.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils'; +import { newConversationId, runAgentTurn } from './utils'; + +const APP = 'node-flue'; + +test('captures an error thrown inside a Flue tool and marks its span errored', async ({ baseURL }) => { + const errorPromise = waitForError( + APP, + event => event.exception?.values?.[0]?.value === 'Intentional flue tool failure', + ); + const spansPromise = collectStreamedSpans(APP, spansOfTrace => + spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'), + ); + + await runAgentTurn(baseURL!, newConversationId('failure'), 'Please call fail_now to trigger a failure.'); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.value).toBe('Intentional flue tool failure'); + + const spans = await spansPromise; + const executeTool = spans.find(span => span.attributes?.['gen_ai.tool.name']?.value === 'fail_now'); + expect(executeTool?.status).toBe('error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts new file mode 100644 index 000000000000..ccf3e552a87d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/flue.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { newConversationId, runAgentTurn } from './utils'; + +const APP = 'node-flue'; + +type SpanLike = { name?: string; attributes?: Record }; + +const hasOps = (ops: string[]) => (spansOfTrace: SpanLike[]) => + ops.every(op => spansOfTrace.some(span => getSpanOp(span) === op)); + +// Anchored on the tool the test drives, so a leftover trace from another test cannot satisfy it. +const usedTool = (toolName: string) => (spansOfTrace: SpanLike[]) => + spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === toolName); + +test('captures the invoke_agent / chat / execute_tool hierarchy for a Flue turn', async ({ baseURL }) => { + // The trace flushes across several envelopes, so accumulate it rather than asserting on one. + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + hasOps(['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool'])(spansOfTrace) && + usedTool('get_weather')(spansOfTrace), + ); + + await runAgentTurn(baseURL!, newConversationId('weather'), 'What is the weather in Paris?'); + + const spans = await spansPromise; + const invokeAgent = spans.find(span => getSpanOp(span) === 'gen_ai.invoke_agent'); + const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat'); + const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); + + expect(invokeAgent?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(invokeAgent?.attributes?.['gen_ai.operation.name']?.value).toBe('invoke_agent'); + expect(invokeAgent?.attributes?.['gen_ai.agent.name']?.value).toBe('Hello'); + + expect(chat?.attributes?.['sentry.origin']?.value).toBe('auto.ai.flue'); + expect(chat?.attributes?.['gen_ai.provider.name']?.value).toBe('openrouter'); + expect(typeof chat?.attributes?.['gen_ai.usage.input_tokens']?.value).toBe('number'); + expect(typeof chat?.attributes?.['gen_ai.usage.output_tokens']?.value).toBe('number'); + // Flue computes cost itself; no provider SDK reports it. + expect(typeof chat?.attributes?.['gen_ai.cost.total_tokens']?.value).toBe('number'); + + expect(executeTool?.attributes?.['gen_ai.tool.name']?.value).toBe('get_weather'); + + // Tool and chat spans are siblings under the agent invocation, matching how Flue's own + // OpenTelemetry adapter projects them. + expect(chat?.parent_span_id).toBe(invokeAgent?.span_id); + expect(executeTool?.parent_span_id).toBe(invokeAgent?.span_id); +}); + +test('nests a manual span raised inside a tool under that tool span', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => usedTool('get_weather')(spansOfTrace) && spansOfTrace.some(span => span.name === 'resolve-weather'), + ); + + await runAgentTurn(baseURL!, newConversationId('manual-span'), 'What is the weather in Berlin?'); + + const spans = await spansPromise; + const executeTool = spans.find(span => getSpanOp(span) === 'gen_ai.execute_tool'); + const manualSpan = spans.find(span => span.name === 'resolve-weather'); + + expect(manualSpan?.attributes?.['weather.source']?.value).toBe('static-table'); + expect(manualSpan?.trace_id).toBe(executeTool?.trace_id); + expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); +}); + +// Flue's `model` operation is wrapped so the turn span is active for it, which is what puts the +// provider's HTTP call inside `chat` rather than beside it. +test('nests the provider HTTP call inside the chat span', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.chat') && + spansOfTrace.some(span => getSpanOp(span) === 'http.client'), + ); + + await runAgentTurn(baseURL!, newConversationId('provider-http'), 'Say hello.'); + + const spans = await spansPromise; + const chat = spans.find(span => getSpanOp(span) === 'gen_ai.chat'); + const providerCall = spans.find(span => getSpanOp(span) === 'http.client'); + + expect(providerCall?.trace_id).toBe(chat?.trace_id); + expect(providerCall?.parent_span_id).toBe(chat?.span_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts new file mode 100644 index 000000000000..02af2469f47b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tests/utils.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; + +/** + * A conversation id nothing has used yet. + * + * `runAgentTurn` waits for the conversation to report a settlement, so a fixed id that already has + * one — a Playwright retry, or the `test:dev` run hitting the record `test:prod` left behind — + * would return before the new turn finished. + */ +export function newConversationId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Run one agent turn over Flue's agent router and wait for it to settle. + * + * `POST /:id` only admits the work — it returns `202` with a `streamUrl` and the turn runs after. + * Returning there would let one test's turn still be emitting spans while the next one waits for + * spans of its own, so a leftover trace could satisfy the wrong assertion. Reading the conversation + * back until it reports a settlement keeps each test to its own turn. + * + * The conversation id is ours to choose: it is the `:id` path segment. It is not the + * `gen_ai.conversation.id` attribute, which Flue generates. + */ +export async function runAgentTurn(baseURL: string, conversationId: string, message: string): Promise { + const url = `${baseURL}/agents/hello/${conversationId}`; + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'user', body: message }), + }); + expect(res.status).toBe(202); + await res.text(); + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const conversation = (await (await fetch(url)).json()) as { settlements?: unknown[] }; + if (conversation.settlements?.length) { + return; + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + + throw new Error(`Flue turn for "${conversationId}" did not settle within 60s`); +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json new file mode 100644 index 000000000000..2cb98b13c9f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "strict": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "sentry-init.ts", "flue.config.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-flue/vite.config.ts b/dev-packages/e2e-tests/test-applications/node-flue/vite.config.ts new file mode 100644 index 000000000000..2f495966e204 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-flue/vite.config.ts @@ -0,0 +1,9 @@ +import { flue } from '@flue/vite'; +import { defineConfig } from 'vite'; + +// Unmodified from what `flue init` scaffolds. No externals config is needed: a Flue node build +// already leaves dependencies as bare specifiers, so orchestrion's module transform still sees them +// as real modules. +export default defineConfig({ + plugins: [flue()], +}); From d0a7bc1fcb73f8a17f605d4af545f124a449e920 Mon Sep 17 00:00:00 2001 From: Mark Snelling Date: Thu, 17 Sep 2026 14:13:58 +0100 Subject: [PATCH 06/39] fix(cloudflare): Capture telemetry from untraced Durable Object RPC calls (#24447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable Object RPC methods only got a Sentry client when the call carried `__sentry_rpc_meta__`. Every other call ran with no client, so its errors, logs and metrics were dropped. That covers calls from uninstrumented workers, calls made outside the request's async context, and every call through `jurisdiction()`. `jurisdiction()` itself bypassed `instrumentDurableObjectNamespace`, so its stubs never propagated a trace. External RPC calls without metadata now run capture-only (client, isolation scope and flush, but no span), which is what `instrumentWorkerEntrypoint` already does. `jurisdiction()` now returns a namespace that is instrumented the same way as the parent. Some decisions worth calling out: - **Telling external calls from internal ones.** RPC methods are wrapped on the prototype (#23040), so a `this.helper()` call reaches the same wrapper as an external call. workerd dispatches an incoming RPC call outside any async context, so the wrapper treats a call made while an invocation is already in flight (isolation scope is not the default) as internal and runs the original method. Returning a proxy of the instance, as `instrumentWorkerEntrypoint` does, would bring back the private-field breakage from #23040. That's why the change only touches Durable Objects. - **`startNewTrace` for untraced calls, unlike WorkerEntrypoint.** A Durable Object instance is long-lived and serves overlapping calls. Without a new trace, every untraced call inherits the propagation context of the default scope, so unrelated calls share one trace id. The overlapping-calls unit test fails without it. Fixes #24443 - [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. _Written with AI assistance (Claude Code)._ --------- Co-authored-by: Claude Opus 5 Co-authored-by: Jan Peer Stöcklmair --- .../durableobject/rpc-without-trace/index.ts | 64 ++++++++ .../durableobject/rpc-without-trace/test.ts | 49 ++++++ .../rpc-without-trace/wrangler.jsonc | 20 +++ .../worker-do-rpc-overlapping/index.ts | 43 +++++ .../instrument.server.ts | 7 + .../worker-do-rpc-overlapping/test.ts | 49 ++++++ .../worker-do-rpc-overlapping/vite.config.mts | 7 + .../worker-do-rpc-overlapping/wrangler.jsonc | 11 ++ packages/cloudflare/src/durableobject.ts | 83 ++++++---- .../cloudflare/test/durableobject.test.ts | 152 ++++++++++++++++++ 10 files changed, 456 insertions(+), 29 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/index.ts new file mode 100644 index 000000000000..3f3fa91bac71 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/index.ts @@ -0,0 +1,64 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + TEST_DURABLE_OBJECT: DurableObjectNamespace; +} + +class TestDurableObjectBase extends DurableObject { + private overlappingCalls = 0; + private releaseOverlappingCalls: () => void = () => {}; + private readonly allOverlappingCallsArrived = new Promise(resolve => { + this.releaseOverlappingCalls = resolve; + }); + + async failingRpcMethod(): Promise { + throw new Error('Test error from Durable Object RPC method'); + } + + // Each call waits until the other one has arrived, so both are in flight at the same time. + async overlappingFailingRpcMethod(label: string): Promise { + this.overlappingCalls++; + if (this.overlappingCalls === 2) { + this.releaseOverlappingCalls(); + } + + await this.allOverlappingCallsArrived; + throw new Error(`Overlapping RPC call ${label}`); + } +} + +export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + }), + TestDurableObjectBase, +); + +// The caller is not instrumented, so its RPC calls carry no trace metadata. +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/overlapping') { + const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName('overlapping')); + const results = await Promise.allSettled([ + stub.overlappingFailingRpcMethod('a'), + stub.overlappingFailingRpcMethod('b'), + ]); + + return new Response(results.map(result => result.status).join(',')); + } + + const stub = env.TEST_DURABLE_OBJECT.get(env.TEST_DURABLE_OBJECT.idFromName('test')); + + try { + await stub.failingRpcMethod(); + return new Response('no error'); + } catch (error) { + return new Response(String((error as Error).message)); + } + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/test.ts new file mode 100644 index 000000000000..fee13cdd859f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/test.ts @@ -0,0 +1,49 @@ +import { expect, it } from 'vitest'; +import type { Event } from '@sentry/core'; +import { createRunner } from '../../../runner'; + +it('captures errors thrown by a Durable Object RPC method called without trace metadata', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.type).toBe('Error'); + expect(event.exception?.values?.[0]?.value).toBe('Test error from Durable Object RPC method'); + expect(event.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/'); + expect(response).toBe('Test error from Durable Object RPC method'); + + await runner.completed(); +}); + +it('gives overlapping Durable Object RPC calls without trace metadata separate traces', async ({ signal }) => { + const traceIds: Record = {}; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('Overlapping RPC call a'); + expect(event.contexts?.trace?.trace_id).toMatch(/^[\da-f]{32}$/); + traceIds.a = event.contexts?.trace?.trace_id; + }) + .expect(envelope => { + const event = envelope[1]?.[0]?.[1] as Event; + expect(event.exception?.values?.[0]?.value).toBe('Overlapping RPC call b'); + expect(event.contexts?.trace?.trace_id).toMatch(/^[\da-f]{32}$/); + traceIds.b = event.contexts?.trace?.trace_id; + }) + .start(signal); + + const response = await runner.makeRequest('get', '/overlapping'); + expect(response).toBe('rejected,rejected'); + + await runner.completed(); + + expect(traceIds.a).not.toBe(traceIds.b); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/wrangler.jsonc new file mode 100644 index 000000000000..d3bcb34400e6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/rpc-without-trace/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "name": "durableobject-rpc-without-trace-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "migrations": [ + { + "new_sqlite_classes": ["TestDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "TestDurableObject", + "name": "TEST_DURABLE_OBJECT", + }, + ], + }, + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/index.ts new file mode 100644 index 000000000000..62b9f380305d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/index.ts @@ -0,0 +1,43 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// Nothing is wrapped manually, the Vite plugin wraps both exports and enables RPC trace +// propagation for `COUNTER`. +export class Counter extends DurableObject { + private calls = 0; + private releaseCalls: () => void = () => {}; + private readonly allCallsArrived = new Promise(resolve => { + this.releaseCalls = resolve; + }); + + // Each call waits until the other one has arrived, so both are in flight at the same time. + async work(label: string): Promise { + this.calls++; + if (this.calls === 2) { + this.releaseCalls(); + } + + await this.allCallsArrived; + Sentry.getActiveSpan()?.setAttribute('test.label', label); + return label; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/overlapping') { + const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e')); + const labels = await Promise.all([stub.work('a'), stub.work('b')]); + return new Response(labels.join(',')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts new file mode 100644 index 000000000000..4355b90010d6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/instrument.server.ts @@ -0,0 +1,7 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts new file mode 100644 index 000000000000..074a6e258414 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/test.ts @@ -0,0 +1,49 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../../runner'; + +it('propagates the worker trace into each of two overlapping Durable Object RPC calls', async ({ signal }) => { + const doTraces: Record = {}; + let workerTraceId: string | undefined; + let workerSpanId: string | undefined; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.transaction).toBe('work'); + expect(transactionEvent.contexts?.trace?.op).toBe('rpc'); + expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('a'); + doTraces.a = transactionEvent.contexts; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.transaction).toBe('work'); + expect(transactionEvent.contexts?.trace?.op).toBe('rpc'); + expect(transactionEvent.contexts?.trace?.data?.['test.label']).toBe('b'); + doTraces.b = transactionEvent.contexts; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.transaction).toBe('GET /overlapping'); + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + workerTraceId = transactionEvent.contexts?.trace?.trace_id; + workerSpanId = transactionEvent.contexts?.trace?.span_id; + }) + .start(signal); + + const response = await runner.makeRequest('get', '/overlapping'); + expect(response).toBe('a,b'); + + await runner.completed(); + + expect(workerTraceId).toMatch(/^[\da-f]{32}$/); + expect(workerSpanId).toMatch(/^[\da-f]{16}$/); + + expect(doTraces.a?.trace?.trace_id).toBe(workerTraceId); + expect(doTraces.a?.trace?.parent_span_id).toBe(workerSpanId); + expect(doTraces.b?.trace?.trace_id).toBe(workerTraceId); + expect(doTraces.b?.trace?.parent_span_id).toBe(workerSpanId); + + expect(doTraces.a?.trace?.span_id).not.toBe(doTraces.b?.trace?.span_id); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/vite.config.mts @@ -0,0 +1,7 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/wrangler.jsonc new file mode 100644 index 000000000000..764bd9798731 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-overlapping/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "../../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-worker-do-rpc-overlapping", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index b2157e709241..9adc9e5115f0 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ /* eslint-disable @typescript-eslint/unbound-method */ import { RPC } from '@sentry/conventions/op'; -import { isObjectLike } from '@sentry/core'; +import { getDefaultIsolationScope, getIsolationScope, isObjectLike, startNewTrace } from '@sentry/core'; import type { DurableObject } from 'cloudflare:workers'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils/no-diagnostic-channels'; import type { CloudflareOptions } from './client'; @@ -144,8 +144,8 @@ function resolveFrameworkManagedMethods( type RpcInstanceState = { options: CloudflareOptions; context: InstrumentedDurableObjectContext; - /** Per-instance cache of the traced method wrappers, keyed by method name. Created on first use. */ - tracedMethods?: Map; + /** Per-instance cache of the instrumented method wrappers, keyed by method name. Created on first use. */ + instrumentedMethods?: Map; }; /** @@ -170,7 +170,7 @@ const RESERVED_RPC_METHOD_NAMES: ReadonlySet = new Set([ const rpcInstanceStates = new WeakMap(); /** - * Adds trace propagation to a constructed Durable Object's RPC methods. + * Instruments a constructed Durable Object's RPC methods. * * RPC methods are wrapped on the prototype because Cloudflare dispatches them with the Durable * Object instance as the receiver. This preserves native private-field access and keeps the methods @@ -256,17 +256,52 @@ function instrumentPrototypeRpcMethods(obj: object, excludedMethods?: ReadonlySe } /** - * Creates a prototype wrapper that traces RPC calls carrying Sentry metadata. + * Returns the instance's instrumented wrapper for an RPC method, creating it on first use. + */ +function getInstrumentedRpcMethod( + state: RpcInstanceState, + methodName: string, + originalMethod: UncheckedMethod, +): UncheckedMethod { + const instrumentedMethods = (state.instrumentedMethods ??= new Map()); + let instrumented = instrumentedMethods.get(methodName); + + if (!instrumented) { + instrumented = wrapMethodWithSentry( + { + options: state.options, + context: state.context, + spanName: rpcMeta => (rpcMeta ? methodName : undefined), + spanOp: RPC, + origin: 'auto.faas.cloudflare.durable_object', + }, + originalMethod, + undefined, + true, + ); + instrumentedMethods.set(methodName, instrumented); + } + + return instrumented; +} + +/** + * Creates a prototype wrapper that instruments external RPC calls. * * The wrapper looks up SDK state from its receiver, allowing one prototype function to serve every - * instance. Calls without RPC metadata or instance state use the original method directly. The - * original function name and arity are preserved because frameworks may inspect them for dispatch. + * instance. A call carrying Sentry metadata continues that trace in an `rpc` span. A call without it + * still gets a client, so its errors, logs and metrics are captured, but no span. Calls the instance + * makes to its own methods run the original method directly. The original function name and arity + * are preserved because frameworks may inspect them for dispatch. */ function createRpcPrototypeWrapper(methodName: string, originalMethod: UncheckedMethod): UncheckedMethod { const wrapper = function (this: unknown, ...args: unknown[]): unknown { - // Untraced calls are the common case — every internal call the instance makes to one of its - // own methods lands here too — so check the arguments before touching per-instance state. - if (!hasRpcMeta(args)) { + const traced = hasRpcMeta(args); + + // workerd dispatches an incoming RPC call outside any async context, so a call made while an + // invocation is already in flight comes from the instance itself (`this.helper()` inside + // `fetch`, `alarm` or another RPC method). Check that before touching per-instance state. + if (!traced && getIsolationScope() !== getDefaultIsolationScope()) { return Reflect.apply(originalMethod, this, args); } @@ -276,26 +311,15 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked return Reflect.apply(originalMethod, this, args); } - const tracedMethods = (state.tracedMethods ??= new Map()); - let traced = tracedMethods.get(methodName); - - if (!traced) { - traced = wrapMethodWithSentry( - { - options: state.options, - context: state.context, - spanName: methodName, - spanOp: RPC, - origin: 'auto.faas.cloudflare.durable_object', - }, - originalMethod, - undefined, - true, - ); - tracedMethods.set(methodName, traced); + const instrumented = getInstrumentedRpcMethod(state, methodName, originalMethod); + + if (traced) { + return Reflect.apply(instrumented, this, args); } - return Reflect.apply(traced, this, args); + // Unlike a WorkerEntrypoint, a Durable Object instance is long-lived and serves overlapping + // calls. Without a new trace, every untraced call would share the trace of the default scope. + return startNewTrace(() => Reflect.apply(instrumented, this, args)); }; Object.defineProperties(wrapper, { @@ -316,7 +340,8 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked * - webSocketClose * - webSocketError * - * RPC methods (prototype methods) are instrumented too, so an incoming trace continues into them. + * RPC methods (prototype methods) are instrumented too: an incoming trace continues into them, and + * errors, logs and metrics are captured whether or not the caller propagates a trace. * * @param optionsCallback Function that returns the options for the SDK initialization. * @param DurableObjectClass The Durable Object class to instrument. diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index ce1efdca3fe1..3450111286c3 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -640,6 +640,158 @@ describe('instrumentDurableObjectWithSentry', () => { }); }); + // Callers only attach RPC metadata when they are instrumented, have an active trace and got the + // stub from an instrumented binding, so a Durable Object regularly serves calls without it. + describe('RPC calls without trace metadata', () => { + const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + const TRACE_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + function setup any>(testClass: C) { + const events: Event[] = []; + const transactions: Event[] = []; + const metricNames: string[] = []; + const waits: Promise[] = []; + const waitUntil = vi.fn((promise: Promise) => { + waits.push(promise); + }); + + const instrumented = instrumentDurableObjectWithSentry( + () => ({ + dsn: DSN, + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSend(event: Event) { + events.push(event); + return null; + }, + beforeSendTransaction(event: Event) { + transactions.push(event); + return null; + }, + beforeSendMetric(metric: { name: string }) { + metricNames.push(metric.name); + return null; + }, + }), + testClass as any, + ); + const obj = Reflect.construct(instrumented, [{ waitUntil }, {}]) as InstanceType; + const settle = async (): Promise => { + while (waits.length) { + await Promise.all(waits.splice(0)); + } + }; + + return { obj, events, transactions, metricNames, waitUntil, settle }; + } + + it('captures errors and metrics', async () => { + const { obj, events, metricNames, settle } = setup( + class { + async rpcMethod(): Promise { + SentryCore.metrics.count('do.rpc_called', 1); + throw new Error('RPC failed'); + } + }, + ); + + await expect(obj.rpcMethod()).rejects.toThrow('RPC failed'); + await settle(); + + expect(metricNames).toEqual(['do.rpc_called']); + expect(events).toHaveLength(1); + expect(events[0]?.exception?.values?.[0]?.value).toBe('RPC failed'); + expect(events[0]?.exception?.values?.[0]?.mechanism).toEqual({ + type: 'auto.faas.cloudflare.durable_object', + handled: false, + }); + }); + + it('does not create a span', async () => { + const { obj, transactions, settle } = setup( + class { + rpcMethod(): string { + return 'result'; + } + }, + ); + + expect(obj.rpcMethod()).toBe('result'); + await settle(); + + expect(transactions).toHaveLength(0); + }); + + // One Durable Object instance serves overlapping calls, so each call needs its own scope. + it('gives overlapping calls separate traces and keeps the events of both', async () => { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const { obj, events, settle } = setup( + class { + async rpcMethod(label: string): Promise { + await gate; + SentryCore.captureMessage(label); + } + }, + ); + + const first = obj.rpcMethod('first'); + const second = obj.rpcMethod('second'); + release(); + await Promise.all([first, second]); + await settle(); + + expect(events.map(event => event.message).sort()).toEqual(['first', 'second']); + expect(events[0]?.contexts?.trace?.trace_id).toEqual(expect.any(String)); + expect(events[0]?.contexts?.trace?.trace_id).not.toBe(events[1]?.contexts?.trace?.trace_id); + }); + + it('does not instrument calls the instance makes to its own methods', async () => { + const { obj, events, waitUntil, settle } = setup( + class { + async rpcMethod(): Promise { + return this.helper(); + } + + async helper(): Promise { + SentryCore.captureMessage('from helper'); + return 'helper-result'; + } + }, + ); + + await expect(obj.rpcMethod()).resolves.toBe('helper-result'); + await settle(); + + expect(events.map(event => event.message)).toEqual(['from helper']); + expect(waitUntil).toHaveBeenCalledOnce(); + }); + + it('continues the caller trace when the call carries trace metadata', async () => { + const { obj, events, transactions, settle } = setup( + class { + async rpcMethod(arg: string): Promise { + SentryCore.captureMessage('traced'); + return arg; + } + }, + ); + + const result = await obj.rpcMethod('arg', { + __sentry_rpc_meta__: { 'sentry-trace': `${TRACE_ID}-bbbbbbbbbbbbbbbb-1`, baggage: '' }, + }); + await settle(); + + expect(result).toBe('arg'); + expect(events[0]?.contexts?.trace?.trace_id).toBe(TRACE_ID); + expect(transactions).toHaveLength(1); + expect(transactions[0]?.transaction).toBe('rpcMethod'); + expect(transactions[0]?.contexts?.trace?.trace_id).toBe(TRACE_ID); + }); + }); + it('flush performs after all waitUntil promises are finished', async () => { // Spy on Client.prototype.flush and mock it to resolve immediately to avoid timeout issues with fake timers const flush = vi.spyOn(SentryCore.Client.prototype, 'flush').mockResolvedValue(true); From d2da6d5c689e8a5cad004ed2fbf9e45f803a772f Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 17 Sep 2026 16:00:18 +0200 Subject: [PATCH 07/39] fix(nextjs): Resolve Next.js version relative to the SDK when cwd differs (#24462) `withSentryConfig` detected the Next.js version by resolving `next/package.json` from `process.cwd()` only. When the process starts outside the Next.js project root (custom servers, monorepos started from the repo root, supervisors with their own cwd), detection returns `undefined` and the SDK silently falls back to the Next 14 config shape. On Next 15+ this means `serverExternalPackages` is never set, and Next warns about the unrecognized `experimental` keys. Resolution now still tries `process.cwd()` first, so the project's own `next` wins in the normal case, and falls back to resolving from the SDK's own location. `next` is a peer dependency, so it's reachable from there even under pnpm's isolated installs. Fixes #24461 Co-authored-by: Claude Opus 5 --- packages/nextjs/src/config/util.ts | 21 +++++++++++++++++---- packages/nextjs/test/config/util.test.ts | 20 +++++++++++++++++++- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 4ec25dbf0e94..5f0990ac6829 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -22,12 +22,25 @@ export function getNextjsVersion(): string | undefined { return undefined; } +// Anchored at this package so `next` (a peer dependency) still resolves when the process cwd is not +// the Next.js project root, e.g. custom servers or monorepos started from the repo root. +let sdkRequire: NodeJS.Require; +/*! rollup-include-cjs-only */ +sdkRequire = createRequire(__filename); +/*! rollup-include-cjs-only-end */ +/*! rollup-include-esm-only */ +sdkRequire = createRequire(import.meta.url); +/*! rollup-include-esm-only-end */ + function resolveNextjsPackageJson(): string | undefined { - try { - return createRequire(`${process.cwd()}/`).resolve('next/package.json'); - } catch { - return undefined; + for (const nodeRequire of [createRequire(`${process.cwd()}/`), sdkRequire]) { + try { + return nodeRequire.resolve('next/package.json'); + } catch { + // try next + } } + return undefined; } /** diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 01b5e59af28e..62632d4bc7aa 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -1,7 +1,25 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as os from 'os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as util from '../../src/config/util'; describe('util', () => { + describe('getNextjsVersion', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns the installed Next.js version', () => { + expect(util.getNextjsVersion()).toMatch(/^\d+\.\d+\.\d+/); + }); + + it('returns the installed Next.js version when cwd is not the project root', () => { + const expected = util.getNextjsVersion(); + vi.spyOn(process, 'cwd').mockReturnValue(os.tmpdir()); + + expect(util.getNextjsVersion()).toBe(expected); + }); + }); + describe('supportsProductionCompileHook', () => { describe('supported versions', () => { it('returns true for Next.js 15.4.1', () => { From a58e91518f9483ab6f98b2329731983ad20c1d6b Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:18:02 +0200 Subject: [PATCH 08/39] fix(nuxt): Stop importing h3 in the Nitro error hook (#24283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuxt 5 switched on its Nitro v2 compatibility layer for any module whose server code imports `h3`: nuxt/nuxt#36318 The hook now recognizes h3 errors the way h3 itself does, without importing it: h3 v2 by the error's `name`, h3 v1 by the static `__h3_error__` flag on the class. Both expose `statusCode`. Our CI tests currently show this warning: ``` [WebServer] [warn] [NUXT_B9003] Nitro v2 compatibility was applied to server code from 1 module, because of what it imports: [WebServer] - `@sentry/nuxt/module` (imports `h3`) [WebServer] ╰▶ fix: This layer is transitional and will be removed in Nuxt 6. Update the module to import from `nuxt/server`, registering the portable file alongside the one it ships today, or report it to the module author if the module is not your own. ``` --- .../nuxt-4/app/pages/fetch-server-routes.vue | 5 + .../server/api/third-party-http-error.ts | 16 +++ .../nuxt-4/tests/errors.server.test.ts | 22 +++ .../nuxt-5/app/pages/fetch-server-routes.vue | 5 + .../server/api/third-party-http-error.ts | 16 +++ .../nuxt-5/tests/errors.server.test.ts | 23 +++ .../src/runtime/hooks/captureErrorHook.ts | 35 +++-- .../plugins/update-route-name.server.ts | 2 +- packages/nuxt/src/runtime/utils.ts | 28 +++- .../runtime/hooks/captureErrorHook.test.ts | 133 +++++++++++------- packages/nuxt/test/runtime/utils.test.ts | 18 ++- 11 files changed, 240 insertions(+), 63 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue index 089d77a2eee9..3547773a1af9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/app/pages/fetch-server-routes.vue @@ -2,6 +2,7 @@
+
@@ -15,4 +16,8 @@ const fetchError = async () => { const fetchNitroFetch = async () => { await useFetch('/api/nitro-fetch'); }; + +const fetchThirdPartyHttpError = async () => { + await useFetch('/api/third-party-http-error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts new file mode 100644 index 000000000000..1f2d3c2ee90e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/third-party-http-error.ts @@ -0,0 +1,16 @@ +import { defineEventHandler } from '#imports'; + +// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the +// status on `response` instead of on the error itself. +class ThirdPartyHTTPError extends Error { + public readonly response = { status: 404 }; + + public constructor(message: string) { + super(message); + this.name = 'HTTPError'; + } +} + +export default defineEventHandler(() => { + throw new ThirdPartyHTTPError('Nuxt 4 third-party HTTPError'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index 8f7bf451a1f6..ea9c78b2d60e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -69,4 +69,26 @@ test.describe('server-side errors', async () => { exception_id: 0, }); }); + + // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the + // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike + // is covered by the unit tests. + test('captures a thrown third-party `HTTPError`', async ({ page }) => { + const errorPromise = waitForError('nuxt-4', async errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 4 third-party HTTPError'); + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/third-party-http-error'); + expect(error.exception.values).toContainEqual( + expect.objectContaining({ + value: 'Nuxt 4 third-party HTTPError', + mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), + }), + ); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue index 089d77a2eee9..3547773a1af9 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/fetch-server-routes.vue @@ -2,6 +2,7 @@
+
@@ -15,4 +16,8 @@ const fetchError = async () => { const fetchNitroFetch = async () => { await useFetch('/api/nitro-fetch'); }; + +const fetchThirdPartyHttpError = async () => { + await useFetch('/api/third-party-http-error'); +}; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts new file mode 100644 index 000000000000..b6b6d2aff38e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/api/third-party-http-error.ts @@ -0,0 +1,16 @@ +import { defineHandler } from 'nitro'; + +// Mimics ky's and got's `HTTPError`: it shares its `name` with h3's error class, but keeps the +// status on `response` instead of on the error itself. +class ThirdPartyHTTPError extends Error { + public readonly response = { status: 404 }; + + public constructor(message: string) { + super(message); + this.name = 'HTTPError'; + } +} + +export default defineHandler(() => { + throw new ThirdPartyHTTPError('Nuxt 5 third-party HTTPError'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts index fe17f262b0ae..ebe8b5097d5d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -69,4 +69,27 @@ test.describe('server-side errors', async () => { exception_id: 0, }); }); + + // ky and got name their errors `HTTPError` too. h3 wraps a thrown one in its own error before the + // hook sees it, so this checks it still gets reported. The hook's handling of an unwrapped lookalike + // is covered by the unit tests. + test('captures a thrown third-party `HTTPError`', async ({ page }) => { + const errorPromise = waitForError('nuxt-5', async errorEvent => { + return !!errorEvent?.exception?.values?.some(value => value.value === 'Nuxt 5 third-party HTTPError'); + }); + + await page.goto(`/fetch-server-routes`); + await page.getByText('Fetch Third-Party HTTPError', { exact: true }).click(); + + const error = await errorPromise; + + expect(error.transaction).toEqual('GET /api/third-party-http-error'); + expect(error.exception.values).toContainEqual( + expect.objectContaining({ + type: 'HTTPError', + value: 'Nuxt 5 third-party HTTPError', + mechanism: expect.objectContaining({ handled: false, type: 'auto.function.nuxt.nitro' }), + }), + ); + }); }); diff --git a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts index 50d5a61a2828..6d8adaf2ea77 100644 --- a/packages/nuxt/src/runtime/hooks/captureErrorHook.ts +++ b/packages/nuxt/src/runtime/hooks/captureErrorHook.ts @@ -1,9 +1,27 @@ import { captureException, getClient, getCurrentScope } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { H3Error } from 'h3'; import type { CapturedErrorContext } from 'nitropack/types'; -import { extractErrorContext } from '../utils'; +import { extractErrorContext, getEventRequestInfo } from '../utils'; + +/** + * Returns the status code of an error thrown by h3, or `undefined` for any other error. + * + * Mirrors each h3 major's own `isError` instead of importing h3: an `h3` import puts this module + * behind Nuxt 5's transitional Nitro v2 compatibility layer, and `nitro/h3` does not resolve on Nuxt 3/4. + * h3 v2 (Nitro v3) recognizes its errors by name and stores the code on + * `status`, h3 v1 (Nitro v2) by a static flag on the class and on `statusCode`. + */ +function getH3ErrorStatusCode(error: Error): number | undefined { + const isH3Error = + error.name === 'HTTPError' || (error.constructor as { __h3_error__?: boolean } | undefined)?.__h3_error__ === true; + + if (!isH3Error) { + return undefined; + } + + const { status, statusCode } = error as { status?: number; statusCode?: number }; + return status ?? statusCode; +} /** * Hook that can be added in a Nitro plugin. It captures an error and sends it to Sentry. @@ -20,10 +38,12 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture return; } + const statusCode = getH3ErrorStatusCode(error); + // Do not handle 404 and 422 - if (error instanceof H3Error) { + if (statusCode !== undefined) { // Do not report if status code is 3xx or 4xx - if (error.statusCode >= 300 && error.statusCode < 500) { + if (statusCode >= 300 && statusCode < 500) { return; } @@ -39,10 +59,7 @@ export async function sentryCaptureErrorHook(error: Error, errorContext: Capture } } - const { method, path } = { - method: errorContext.event?._method ? errorContext.event._method : '', - path: errorContext.event?._path ? errorContext.event._path : null, - }; + const { method = '', path } = getEventRequestInfo(errorContext.event); if (path) { getCurrentScope().setTransactionName(`${method} ${path}`); diff --git a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts index 72e3d9452e7e..7774e3610ba3 100644 --- a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts +++ b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts @@ -1,6 +1,6 @@ import type { NitroAppPlugin } from 'nitro/types'; import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; -import type { H3Event } from 'h3'; +import type { H3Event } from 'nitro/h3'; export default (nitroApp => { // @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 5a8e9c3db701..becb6ef55d19 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,9 +1,30 @@ import type { ClientOptions, Context, SerializedTraceData } from '@sentry/core'; -import { captureException, debug, getClient, getTraceMetaTags } from '@sentry/core'; +import { captureException, debug, getClient, getTraceMetaTags, isObjectLike } from '@sentry/core'; import type { CapturedErrorContext } from 'nitropack/types'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import type { ComponentPublicInstance } from 'vue'; +/** + * Reads the request method and path off the event Nitro passes to its `error` hook. + * + * h3 v1 (Nitro v2) exposes `method` and `path` getters. h3 v2 (Nitro v3) has neither: the method lives + * on the web `Request` in `req`, and the path on the parsed `url`. + */ +export function getEventRequestInfo(event: unknown): { method?: string; path?: string } { + if (!isObjectLike(event)) { + return {}; + } + + const { method, path, req, url } = event as { + method?: string; + path?: string; + req?: { method?: string }; + url?: { pathname?: string }; + }; + + return { method: method ?? req?.method, path: path ?? url?.pathname }; +} + /** * Extracts the relevant context information from the error context (H3Event in Nitro Error) * and created a structured context object. @@ -16,8 +37,9 @@ export function extractErrorContext(errorContext: CapturedErrorContext | undefin } if (errorContext.event) { - ctx.method = errorContext.event._method; - ctx.path = errorContext.event._path; + const { method, path } = getEventRequestInfo(errorContext.event); + ctx.method = method; + ctx.path = path; } if (Array.isArray(errorContext.tags)) { diff --git a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts index 8e166a5ff4cc..bcf7f01690be 100644 --- a/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts +++ b/packages/nuxt/test/runtime/hooks/captureErrorHook.test.ts @@ -1,19 +1,20 @@ import * as SentryCore from '@sentry/core'; import * as SentryCoreServer from '@sentry/core/server'; import { H3Error } from 'h3'; +import { HTTPError } from 'nitro/h3'; import type { CapturedErrorContext } from 'nitropack/types'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCaptureErrorHook } from '../../../src/runtime/hooks/captureErrorHook'; +const setTransactionName = vi.fn(); + vi.mock('@sentry/core', async importOriginal => { const mod = await importOriginal(); return { ...(mod as any), captureException: vi.fn(), getClient: vi.fn(), - getCurrentScope: vi.fn(() => ({ - setTransactionName: vi.fn(), - })), + getCurrentScope: vi.fn(() => ({ setTransactionName })), }; }); @@ -25,17 +26,38 @@ vi.mock('@sentry/core/server', async importOriginal => { }; }); -vi.mock('../../../src/runtime/utils', () => ({ +vi.mock('../../../src/runtime/utils', async importOriginal => ({ + ...(await importOriginal()), extractErrorContext: vi.fn(() => ({ test: 'context' })), })); -describe('sentryCaptureErrorHook', () => { - const mockErrorContext: CapturedErrorContext = { - event: { - _method: 'GET', - _path: '/test-path', - } as any, - }; +// Nuxt 3/4 run Nitro v2 on h3 v1, Nuxt 5 runs Nitro v3 on h3 v2. The two majors differ in both the +// error class the hook sees and the shape of the event it reads the request from. +const h3Majors = [ + { + name: 'h3 v1 (Nitro v2)', + httpError: (message: string, statusCode: number): Error => { + const error = new H3Error(message); + error.statusCode = statusCode; + return error; + }, + event: { method: 'GET', path: '/test-path' }, + }, + { + name: 'h3 v2 (Nitro v3)', + httpError: (message: string, statusCode: number): Error => new HTTPError({ message, status: statusCode }), + event: { req: new Request('http://localhost/test-path'), url: new URL('http://localhost/test-path') }, + }, +]; + +// The two classes disagree on what the constructor puts on `cause` (h3 v2 stores the whole details +// object), so it is set directly: what is under test is how the hook reads `cause`, not h3. +function withCause(error: Error, cause: unknown): Error { + return Object.defineProperty(error, 'cause', { value: cause, configurable: true }); +} + +describe.each(h3Majors)('sentryCaptureErrorHook - $name', ({ httpError, event }) => { + const mockErrorContext = { event } as unknown as CapturedErrorContext; beforeEach(() => { vi.clearAllMocks(); @@ -58,27 +80,26 @@ describe('sentryCaptureErrorHook', () => { ); }); - it('should skip H3Error with 4xx status codes', async () => { - const error = new H3Error('Not found'); - error.statusCode = 404; + it('sets the transaction name from the request method and path', async () => { + await sentryCaptureErrorHook(new Error('Test error'), mockErrorContext); - await sentryCaptureErrorHook(error, mockErrorContext); + expect(setTransactionName).toHaveBeenCalledWith('GET /test-path'); + }); + + it('should skip HTTP errors with 4xx status codes', async () => { + await sentryCaptureErrorHook(httpError('Not found', 404), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should skip H3Error with 3xx status codes', async () => { - const error = new H3Error('Redirect'); - error.statusCode = 302; - - await sentryCaptureErrorHook(error, mockErrorContext); + it('should skip HTTP errors with 3xx status codes', async () => { + await sentryCaptureErrorHook(httpError('Redirect', 302), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should capture H3Error with 5xx status codes', async () => { - const error = new H3Error('Server error'); - error.statusCode = 500; + it('should capture HTTP errors with 5xx status codes', async () => { + const error = httpError('Server error', 500); await sentryCaptureErrorHook(error, mockErrorContext); @@ -90,7 +111,7 @@ describe('sentryCaptureErrorHook', () => { ); }); - it('should skip H3Error when cause has __sentry_captured__ flag', async () => { + it('should skip HTTP errors when cause has __sentry_captured__ flag', async () => { const originalError = new Error('Original error'); // Mark the original error as already captured by middleware Object.defineProperty(originalError, '__sentry_captured__', { @@ -98,51 +119,44 @@ describe('sentryCaptureErrorHook', () => { enumerable: false, }); - const h3Error = new H3Error('Wrapped error', { cause: originalError }); - h3Error.statusCode = 500; - - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(withCause(httpError('Wrapped error', 500), originalError), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); - it('should capture H3Error when cause does not have __sentry_captured__ flag', async () => { - const originalError = new Error('Original error'); - const h3Error = new H3Error('Wrapped error', { cause: originalError }); - h3Error.statusCode = 500; + it('should capture HTTP errors when cause does not have __sentry_captured__ flag', async () => { + const error = withCause(httpError('Wrapped error', 500), new Error('Original error')); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), ); }); - it('should capture H3Error when cause is not an object', async () => { - const h3Error = new H3Error('Error with string cause', { cause: 'string cause' }); - h3Error.statusCode = 500; + it('should capture HTTP errors when cause is not an object', async () => { + const error = withCause(httpError('Error with string cause', 500), 'string cause'); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), ); }); - it('should capture H3Error when there is no cause', async () => { - const h3Error = new H3Error('Error without cause'); - h3Error.statusCode = 500; + it('should capture HTTP errors when there is no cause', async () => { + const error = httpError('Error without cause', 500); - await sentryCaptureErrorHook(h3Error, mockErrorContext); + await sentryCaptureErrorHook(error, mockErrorContext); expect(SentryCore.captureException).toHaveBeenCalledWith( - h3Error, + error, expect.objectContaining({ mechanism: { handled: false, type: 'auto.function.nuxt.nitro' }, }), @@ -154,10 +168,35 @@ describe('sentryCaptureErrorHook', () => { getOptions: () => ({ enableNitroErrorHandler: false }), }); - const error = new Error('Test error'); - - await sentryCaptureErrorHook(error, mockErrorContext); + await sentryCaptureErrorHook(new Error('Test error'), mockErrorContext); expect(SentryCore.captureException).not.toHaveBeenCalled(); }); }); + +describe('sentryCaptureErrorHook - errors that only look like h3 errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + (SentryCore.getClient as any).mockReturnValue({ getOptions: () => ({}) }); + }); + + it('still reports a plain error that carries a 4xx `statusCode`', async () => { + const error = Object.assign(new Error('Upstream API returned 404'), { statusCode: 404 }); + + await sentryCaptureErrorHook(error, {} as CapturedErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.anything()); + }); + + it('still reports a third-party `HTTPError` whose status lives on `response`', async () => { + // The packages "ky" and "got" name their errors `HTTPError` but keep the status on `response`, not on the error + const error = Object.assign(new Error('Request failed with status code 404'), { + name: 'HTTPError', + response: { status: 404 }, + }); + + await sentryCaptureErrorHook(error, {} as CapturedErrorContext); + + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.anything()); + }); +}); diff --git a/packages/nuxt/test/runtime/utils.test.ts b/packages/nuxt/test/runtime/utils.test.ts index fe1ebd94fdf3..e930f63afde0 100644 --- a/packages/nuxt/test/runtime/utils.test.ts +++ b/packages/nuxt/test/runtime/utils.test.ts @@ -14,8 +14,8 @@ describe('extractErrorContext', () => { it('extracts properties from errorContext and drops them if missing', () => { const context = { event: { - _method: 'GET', - _path: '/test', + method: 'GET', + path: '/test', }, tags: ['tag1', 'tag2'], }; @@ -29,7 +29,7 @@ describe('extractErrorContext', () => { const partialContext = { event: { - _path: '/test', + path: '/test', }, }; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -37,6 +37,18 @@ describe('extractErrorContext', () => { expect(extractErrorContext(partialContext)).toEqual({ path: '/test' }); }); + it('reads method and path from an h3 v2 (Nitro v3) event, which has no `method`/`path` getters', () => { + const context = { + event: { + req: new Request('http://localhost/test?query=1', { method: 'POST' }), + url: new URL('http://localhost/test?query=1'), + }, + }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + expect(extractErrorContext(context)).toEqual({ method: 'POST', path: '/test' }); + }); + it('handles errorContext.tags correctly, including when absent or of unexpected type', () => { const contextWithTags = { tags: ['tag1', 'tag2'], From 1e8dd4d48ee610f466503c4322f52c3c6d639240 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 17 Sep 2026 23:24:34 +0200 Subject: [PATCH 09/39] test(node): Run koa integration tests against koa 3 (#24457) Koa instrumentation already declares `>=2.0.0 <4` and hooks nothing that changed in Koa 3, but only Koa 2 was tested, so the integration suite now runs against both majors. Co-authored-by: Claude Opus 5 --- .../suites/tracing/koa/test.ts | 153 ++++++++++-------- 1 file changed, 82 insertions(+), 71 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts index b1d8ded48e5b..4a9f99364a3a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/koa/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/koa/test.ts @@ -29,81 +29,92 @@ describe('koa auto-instrumentation', () => { }, }; - createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { - test('should auto-instrument `koa` router and middleware layers.', async () => { - const runner = createRunner() - .expect({ - span: container => { - expect(container.items.find(item => item.is_segment)?.name).toBe('GET /'); + describe.each([ + ['v2', {}], + ['v3', { koa: '^3.0.0' }], + ])('%s', (_version, additionalDependencies) => { + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('should auto-instrument `koa` router and middleware layers.', async () => { + const runner = createRunner() + .expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe('GET /'); - // Router layer span (from `@koa/router`), carrying the matched route. - expect(container.items).toContainEqual( - expect.objectContaining({ - name: '/', - attributes: expect.objectContaining({ - 'http.route': { type: 'string', value: '/' }, - 'koa.type': { type: 'string', value: 'router' }, - 'sentry.op': { type: 'string', value: 'router' }, - 'sentry.origin': { type: 'string', value: origin }, - }), - }), - ); + // Router layer span (from `@koa/router`), carrying the matched route. + expect(container.items).toContainEqual( + expect.objectContaining({ + name: '/', + attributes: expect.objectContaining({ + 'http.route': { type: 'string', value: '/' }, + 'koa.type': { type: 'string', value: 'router' }, + 'sentry.op': { type: 'string', value: 'router' }, + 'sentry.origin': { type: 'string', value: origin }, + }), + }), + ); - // Plain middleware span. - expect(container.items).toContainEqual( - expect.objectContaining({ - name: 'simpleMiddleware', - attributes: expect.objectContaining({ - 'koa.type': { type: 'string', value: 'middleware' }, - 'code.function.name': { type: 'string', value: 'simpleMiddleware' }, - 'sentry.op': { type: 'string', value: 'middleware' }, - 'sentry.origin': { type: 'string', value: origin }, - }), - }), - ); - }, - }) - .start(); - runner.makeRequest('get', '/'); - await runner.completed(); - }); + // Plain middleware span. + expect(container.items).toContainEqual( + expect.objectContaining({ + name: 'simpleMiddleware', + attributes: expect.objectContaining({ + 'koa.type': { type: 'string', value: 'middleware' }, + 'code.function.name': { type: 'string', value: 'simpleMiddleware' }, + 'sentry.op': { type: 'string', value: 'middleware' }, + 'sentry.origin': { type: 'string', value: origin }, + }), + }), + ); + }, + }) + .start(); + runner.makeRequest('get', '/'); + await runner.completed(); + }); - test('should assign a parameterized segment name.', async () => { - const runner = createRunner() - .expect({ - span: container => { - expect(container.items.find(item => item.is_segment)?.name).toBe('GET /test-param/:id'); + test('should assign a parameterized segment name.', async () => { + const runner = createRunner() + .expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe('GET /test-param/:id'); - expect(container.items).toContainEqual( - expect.objectContaining({ - name: '/test-param/:id', - attributes: expect.objectContaining({ - 'http.route': { type: 'string', value: '/test-param/:id' }, - 'koa.type': { type: 'string', value: 'router' }, - 'sentry.op': { type: 'string', value: 'router' }, - 'sentry.origin': { type: 'string', value: origin }, - }), - }), - ); - }, - }) - .start(); - runner.makeRequest('get', '/test-param/123'); - await runner.completed(); - }); + expect(container.items).toContainEqual( + expect.objectContaining({ + name: '/test-param/:id', + attributes: expect.objectContaining({ + 'http.route': { type: 'string', value: '/test-param/:id' }, + 'koa.type': { type: 'string', value: 'router' }, + 'sentry.op': { type: 'string', value: 'router' }, + 'sentry.origin': { type: 'string', value: 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({ - span: container => { - expect(container.items.find(item => item.is_segment)?.name).toBe('GET /error'); - }, - }) - .expect({ event: EXPECTED_ERROR_EVENT }) - .start(); - runner.makeRequest('get', '/error', { expectError: true }); - await runner.completed(); - }); + test('should capture errors thrown in routes via the koa error handler.', async () => { + const runner = createRunner() + .unordered() + .expect({ + span: container => { + expect(container.items.find(item => item.is_segment)?.name).toBe('GET /error'); + }, + }) + .expect({ event: EXPECTED_ERROR_EVENT }) + .start(); + runner.makeRequest('get', '/error', { expectError: true }); + await runner.completed(); + }); + }, + { additionalDependencies }, + ); }); }); From 2e5ba4889ee741833475abc351b589a8d165f487 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:32:57 +0000 Subject: [PATCH 10/39] feat(deps): bump devalue from 5.9.0 to 5.9.2 (#24486) Bumps [devalue](https://github.com/sveltejs/devalue) from 5.9.0 to 5.9.2.
Release notes

Sourced from devalue's releases.

v5.9.2

Patch Changes

  • 8b2a456: fix: reject out-of-bounds indices

v5.9.1

Patch Changes

  • 39457ce: fix: uneval emits valid JS for graphs with more than 65534 repeated references
  • 686e379: fix: emit valid JS for Node Buffer in uneval
  • 376b65c: fix: preserve -0 in float typed arrays in uneval
Changelog

Sourced from devalue's changelog.

5.9.2

Patch Changes

  • 8b2a456: fix: reject out-of-bounds indices

5.9.1

Patch Changes

  • 39457ce: fix: uneval emits valid JS for graphs with more than 65534 repeated references
  • 686e379: fix: emit valid JS for Node Buffer in uneval
  • 376b65c: fix: preserve -0 in float typed arrays in uneval
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=devalue&package-manager=npm_and_yarn&previous-version=5.9.0&new-version=5.9.2)](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 this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2c1b930b29dd..db856fb5774e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14044,9 +14044,9 @@ deterministic-object-hash@^2.0.2: base-64 "^1.0.0" devalue@^5.1.1, devalue@^5.8.1, devalue@^5.9.0: - version "5.9.0" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.0.tgz#5d30db41a0db9171cf4ee9dbf6617e0b77cd7c2a" - integrity sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A== + version "5.9.2" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.2.tgz#2a3a8ad21904c6a630bf7bb160acb7ca2fa1e467" + integrity sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w== devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" From def05074788ca31642c0fd6abba9c4bb17b84e70 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 18 Sep 2026 10:15:18 +0200 Subject: [PATCH 11/39] feat(node): Add Groq and Together AI integrations (#24454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds auto-instrumentation for the `groq-sdk` and `together-ai` clients — chat completions and embeddings, streaming and non-streaming. Both are Stainless-generated SDKs that speak the OpenAI wire format, so rather than duplicating the openai integration twice, a shared `createOpenAiCompatibleIntegration` factory reuses the existing `ai/openai` span-building, streaming and response-parsing logic and only overrides `gen_ai.provider.name` and the span origin. Each provider is then a small orchestrion config + a one-call integration file, so the next OpenAI-compatible provider (Perplexity, Cerebras, …) is ~15 lines on the same rail. Groq is the meaningful one by volume (~3.4M npm downloads/mo); Together is included since it shares the exact layout at no extra cost. `gen_ai.request.max_tokens` is intentionally not asserted — the shared openai request extractor doesn't capture it today (a separate, pre-existing gap), so these providers match its behaviour. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../node-integration-tests/package.json | 2 + .../tracing/groq/instrument-with-pii.mjs | 10 ++ .../suites/tracing/groq/instrument.mjs | 10 ++ .../suites/tracing/groq/scenario.mjs | 139 ++++++++++++++++ .../suites/tracing/groq/test.ts | 109 ++++++++++++ .../together-ai/instrument-with-pii.mjs | 10 ++ .../suites/tracing/together-ai/instrument.mjs | 10 ++ .../suites/tracing/together-ai/scenario.mjs | 138 ++++++++++++++++ .../suites/tracing/together-ai/test.ts | 109 ++++++++++++ packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/deno/src/index.ts | 2 + packages/elysia/src/index.ts | 2 + packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/index.ts | 2 + packages/server-utils/src/index.ts | 2 + .../server-utils/src/integrations/groq.ts | 23 +++ .../server-utils/src/integrations/index.ts | 4 + .../src/integrations/langchain.ts | 3 + .../src/integrations/openai-compatible.ts | 156 ++++++++++++++++++ .../src/integrations/together-ai.ts | 19 +++ .../server-utils/src/orchestrion/channels.ts | 4 + .../config/channel-integration-definitions.ts | 2 + .../src/orchestrion/config/groq.ts | 17 ++ .../src/orchestrion/config/index.ts | 4 + .../orchestrion/config/openai-compatible.ts | 27 +++ .../src/orchestrion/config/together-ai.ts | 17 ++ yarn.lock | 10 ++ 29 files changed, 839 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/groq/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/groq/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/groq/test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/together-ai/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/together-ai/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts create mode 100644 packages/server-utils/src/integrations/groq.ts create mode 100644 packages/server-utils/src/integrations/openai-compatible.ts create mode 100644 packages/server-utils/src/integrations/together-ai.ts create mode 100644 packages/server-utils/src/orchestrion/config/groq.ts create mode 100644 packages/server-utils/src/orchestrion/config/openai-compatible.ts create mode 100644 packages/server-utils/src/orchestrion/config/together-ai.ts diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 2e46a20631e8..1436b6a9734d 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -71,6 +71,7 @@ "fastify": "^5.12.1", "generic-pool": "^3.9.0", "graphql": "^16.11.0", + "groq-sdk": "1.6.0", "graphql-tag": "^2.12.7", "hono": "^4.13.5", "http-terminator": "^3.2.0", @@ -99,6 +100,7 @@ "redis-5": "npm:redis@~5.11.0", "redis-5-tracing": "npm:redis@^5.12.0", "reflect-metadata": "0.2.1", + "together-ai": "0.54.0", "rxjs": "^7.8.2", "tedious": "^19.2.1", "winston": "^3.17.0", diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/groq/instrument-with-pii.mjs new file mode 100644 index 000000000000..1c507ba84b9c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/instrument-with-pii.mjs @@ -0,0 +1,10 @@ +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, + dataCollection: { genAI: { inputs: true, outputs: true } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/groq/instrument.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/instrument.mjs @@ -0,0 +1,10 @@ +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, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs new file mode 100644 index 000000000000..03f656eb1ee4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/scenario.mjs @@ -0,0 +1,139 @@ +import Groq from 'groq-sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/openai/v1/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // error-model returns 404 (not retried by the SDK) so the span records an error + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const chunks = [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { content: 'Hello from Groq streaming!' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from Groq mock!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + }); + + app.post('/openai/v1/embeddings', (req, res) => { + const { model } = req.body; + res.send({ + id: 'embd-mock123', + object: 'list', + model, + data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Groq({ + apiKey: 'mock-api-key', + // `groq-sdk` appends `/openai/v1/...` to the baseURL, so it stays bare here. + baseURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.completions.create({ + model: 'llama-3.3-70b-versatile', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // expected + } + + const stream = await client.chat.completions.create({ + model: 'llama-3.1-8b-instant', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + temperature: 0.8, + stream: true, + }); + + for await (const chunk of stream) { + void chunk; + } + + await client.embeddings.create({ + model: 'nomic-embed-text-v1_5', + input: 'Embedding test!', + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/groq/test.ts b/dev-packages/node-integration-tests/suites/tracing/groq/test.ts new file mode 100644 index 000000000000..c494401817b0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/groq/test.ts @@ -0,0 +1,109 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_RESPONSE_TEXT, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +const PROVIDER = 'groq'; +const ORIGIN = 'auto.ai.groq'; + +describe('Groq integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates chat and embeddings spans with genAI recording disabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.name).toBe('chat llama-3.3-70b-versatile'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('llama-3.3-70b-versatile'); + expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]?.value).toBe(0.7); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('llama-3.3-70b-versatile'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]'); + expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10); + expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(15); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + // recording disabled → no prompt/response content + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.name).toBe('chat llama-3.1-8b-instant'); + expect(streamSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + + const errorSpan = container.items.find(s => s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-model'); + expect(errorSpan).toBeDefined(); + expect(errorSpan!.status).toBe('error'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.name).toBe('embeddings nomic-embed-text-v1_5'); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.embeddings'); + expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records chat and embeddings inputs/outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + // The system message is split out into gen_ai.system_instructions. + expect(chatSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"What is the capital of France?"}]', + ); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Groq mock!"]'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument-with-pii.mjs new file mode 100644 index 000000000000..1c507ba84b9c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument-with-pii.mjs @@ -0,0 +1,10 @@ +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, + dataCollection: { genAI: { inputs: true, outputs: true } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/instrument.mjs @@ -0,0 +1,10 @@ +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, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs new file mode 100644 index 000000000000..6ef405ed2abf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/scenario.mjs @@ -0,0 +1,138 @@ +import Together from 'together-ai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockServer() { + const app = express(); + app.use(express.json()); + + app.post('/chat/completions', (req, res) => { + const { model, stream } = req.body; + + // error-model returns 404 (not retried by the SDK) so the span records an error + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + if (stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + const chunks = [ + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: { content: 'Hello from Together streaming!' }, finish_reason: null }], + }, + { + id: 'chatcmpl-stream-123', + object: 'chat.completion.chunk', + created: 1677652300, + model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 18, total_tokens: 30 }, + }, + ]; + + chunks.forEach((chunk, index) => { + setTimeout(() => { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + if (index === chunks.length - 1) { + res.write('data: [DONE]\n\n'); + res.end(); + } + }, index * 10); + }); + } else { + res.send({ + id: 'chatcmpl-mock123', + object: 'chat.completion', + created: 1677652288, + model, + choices: [ + { index: 0, message: { role: 'assistant', content: 'Hello from Together mock!' }, finish_reason: 'stop' }, + ], + usage: { prompt_tokens: 10, completion_tokens: 15, total_tokens: 25 }, + }); + } + }); + + app.post('/embeddings', (req, res) => { + const { model } = req.body; + res.send({ + id: 'embd-mock123', + object: 'list', + model, + data: [{ object: 'embedding', index: 0, embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Together({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}`, + }); + + await client.chat.completions.create({ + model: 'meta-llama/Llama-3.3-70B-Instruct-Turbo', + messages: [ + { role: 'system', content: 'You are a helpful assistant.' }, + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + try { + await client.chat.completions.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // expected + } + + const stream = await client.chat.completions.create({ + model: 'meta-llama/Llama-3.1-8B-Instruct-Turbo', + messages: [{ role: 'user', content: 'Tell me about streaming' }], + temperature: 0.8, + stream: true, + }); + + for await (const chunk of stream) { + void chunk; + } + + await client.embeddings.create({ + model: 'togethercomputer/m2-bert-80M-8k-retrieval', + input: 'Embedding test!', + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts b/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts new file mode 100644 index 000000000000..c33de6ebfb53 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/together-ai/test.ts @@ -0,0 +1,109 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { + GEN_AI_EMBEDDINGS_INPUT, + GEN_AI_INPUT_MESSAGES, + GEN_AI_OPERATION_NAME, + GEN_AI_PROVIDER_NAME, + GEN_AI_REQUEST_MODEL, + GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_FINISH_REASONS, + GEN_AI_RESPONSE_ID, + GEN_AI_RESPONSE_MODEL, + GEN_AI_RESPONSE_STREAMING, + GEN_AI_RESPONSE_TEXT, + GEN_AI_SYSTEM_INSTRUCTIONS, + GEN_AI_USAGE_INPUT_TOKENS, + GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_TOTAL_TOKENS, +} from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +const PROVIDER = 'together_ai'; +const ORIGIN = 'auto.ai.together_ai'; + +describe('Together integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates chat and embeddings spans with genAI recording disabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + expect(chatSpan!.name).toBe('chat meta-llama/Llama-3.3-70B-Instruct-Turbo'); + expect(chatSpan!.status).toBe('ok'); + expect(chatSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.chat'); + expect(chatSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]?.value).toBe(ORIGIN); + expect(chatSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(chatSpan!.attributes[GEN_AI_REQUEST_MODEL]?.value).toBe('meta-llama/Llama-3.3-70B-Instruct-Turbo'); + expect(chatSpan!.attributes[GEN_AI_REQUEST_TEMPERATURE]?.value).toBe(0.7); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_MODEL]?.value).toBe('meta-llama/Llama-3.3-70B-Instruct-Turbo'); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_FINISH_REASONS]?.value).toBe('["stop"]'); + expect(chatSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(10); + expect(chatSpan!.attributes[GEN_AI_USAGE_OUTPUT_TOKENS]?.value).toBe(15); + expect(chatSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(25); + // recording disabled → no prompt/response content + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]).toBeUndefined(); + + const streamSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-stream-123', + ); + expect(streamSpan).toBeDefined(); + expect(streamSpan!.name).toBe('chat meta-llama/Llama-3.1-8B-Instruct-Turbo'); + expect(streamSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('chat'); + expect(streamSpan!.attributes[GEN_AI_RESPONSE_STREAMING]?.value).toBe(true); + expect(streamSpan!.attributes[GEN_AI_USAGE_TOTAL_TOKENS]?.value).toBe(30); + + const errorSpan = container.items.find(s => s.attributes[GEN_AI_REQUEST_MODEL]?.value === 'error-model'); + expect(errorSpan).toBeDefined(); + expect(errorSpan!.status).toBe('error'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.name).toBe('embeddings togethercomputer/m2-bert-80M-8k-retrieval'); + expect(embeddingsSpan!.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('embeddings'); + expect(embeddingsSpan!.attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP]?.value).toBe('gen_ai.embeddings'); + expect(embeddingsSpan!.attributes[GEN_AI_PROVIDER_NAME]?.value).toBe(PROVIDER); + expect(embeddingsSpan!.attributes[GEN_AI_USAGE_INPUT_TOKENS]?.value).toBe(8); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]).toBeUndefined(); + }, + }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('records chat and embeddings inputs/outputs with PII enabled', async () => { + await createRunner() + .expect({ + span: container => { + const chatSpan = container.items.find(s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'chatcmpl-mock123'); + expect(chatSpan).toBeDefined(); + // The system message is split out into gen_ai.system_instructions. + expect(chatSpan!.attributes[GEN_AI_SYSTEM_INSTRUCTIONS]?.value).toContain('You are a helpful assistant.'); + expect(chatSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value).toBe( + '[{"role":"user","content":"What is the capital of France?"}]', + ); + expect(chatSpan!.attributes[GEN_AI_RESPONSE_TEXT]?.value).toBe('["Hello from Together mock!"]'); + + const embeddingsSpan = container.items.find( + s => s.attributes[GEN_AI_RESPONSE_ID]?.value === 'embd-mock123', + ); + expect(embeddingsSpan).toBeDefined(); + expect(embeddingsSpan!.attributes[GEN_AI_EMBEDDINGS_INPUT]?.value).toContain('Embedding test!'); + }, + }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 66faa541489e..116c06dab64a 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -93,6 +93,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, createFlueInstrumentation, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index d231a660a9f9..4f3b416066b5 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -61,6 +61,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index cda29a4beeb8..e412d01db9ae 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -83,6 +83,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 6b22ee9ee63a..187eb0b5cbf7 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -146,6 +146,8 @@ export { mysql2Integration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, postgresIntegration, postgresJsIntegration, tediousIntegration, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 28e6fc1ee2f3..1f65611a6b6b 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -62,6 +62,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, createFlueInstrumentation, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9f36e06e9ed8..a53893c979e9 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -61,6 +61,8 @@ export { onUnhandledRejectionIntegration, mistralAIIntegration, openAIIntegration, + groqIntegration, + togetherAIIntegration, langChainIntegration, langGraphIntegration, mastraIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3754f8408f86..de280e57f11c 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -13,6 +13,7 @@ export { genericPoolIntegration, googleGenAIIntegration, graphqlIntegration, + groqIntegration, hapiIntegration, kafkaIntegration, knexIntegration, @@ -29,6 +30,7 @@ export { mistralAIIntegration, mysql2Integration, openAIIntegration, + togetherAIIntegration, postgresIntegration, postgresJsIntegration, redisIntegration, diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 57fa3f913cda..62f74e3675ef 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -50,6 +50,8 @@ export { lruMemoizerIntegration } from './integrations/lru-memoizer'; export { mongoIntegration } from './integrations/mongodb'; export { mongooseIntegration } from './integrations/mongoose'; export { mistralAIIntegration } from './integrations/mistral'; +export { groqIntegration } from './integrations/groq'; +export { togetherAIIntegration } from './integrations/together-ai'; export { mysqlIntegration } from './integrations/mysql'; export { mysql2Integration } from './integrations/mysql2'; export { openAIIntegration } from './integrations/openai'; diff --git a/packages/server-utils/src/integrations/groq.ts b/packages/server-utils/src/integrations/groq.ts new file mode 100644 index 000000000000..cbbb8a156630 --- /dev/null +++ b/packages/server-utils/src/integrations/groq.ts @@ -0,0 +1,23 @@ +import { defineIntegration } from '@sentry/core'; +import { CHANNELS } from '../orchestrion/channels'; +import { groqModuleNames } from '../orchestrion/config/groq'; +import { createOpenAiCompatibleIntegration } from './openai-compatible'; + +// Exported so LangChain can add it to its provider-skip list: `@langchain/groq` drives `groq-sdk`, so +// a `ChatGroq` call would otherwise open both a LangChain span and this integration's Groq span. +export const GROQ_INTEGRATION_NAME = 'Groq' as const; + +/** + * Instruments the `groq-sdk` client (chat completions and embeddings). Groq speaks the OpenAI wire format, + * so this reuses the openai span/streaming logic; see `createOpenAiCompatibleIntegration`. Requires the + * Sentry runtime hook or bundler plugin so the diagnostics channels get injected into `groq-sdk`. + */ +export const groqIntegration = defineIntegration( + createOpenAiCompatibleIntegration({ + integrationName: GROQ_INTEGRATION_NAME, + providerName: 'groq', + origin: 'auto.ai.groq', + moduleNames: groqModuleNames, + channels: { chat: CHANNELS.GROQ_CHAT, embeddings: CHANNELS.GROQ_EMBEDDINGS }, + }), +); diff --git a/packages/server-utils/src/integrations/index.ts b/packages/server-utils/src/integrations/index.ts index 6e319bfc5e2b..fa6cf1759906 100644 --- a/packages/server-utils/src/integrations/index.ts +++ b/packages/server-utils/src/integrations/index.ts @@ -21,6 +21,8 @@ import { openAIIntegration } from './openai'; import { anthropicAIIntegration } from './anthropic'; import { googleGenAIIntegration } from './google-genai'; import { mistralAIIntegration } from './mistral'; +import { groqIntegration } from './groq'; +import { togetherAIIntegration } from './together-ai'; import { postgresJsIntegration } from './postgres-js'; import { firebaseIntegration } from './firebase'; import { expressIntegration } from './express'; @@ -59,6 +61,8 @@ export function getTracingIntegrations(): Integration[] { anthropicAIIntegration(), googleGenAIIntegration(), mistralAIIntegration(), + groqIntegration(), + togetherAIIntegration(), postgresJsIntegration(), firebaseIntegration(), ]; diff --git a/packages/server-utils/src/integrations/langchain.ts b/packages/server-utils/src/integrations/langchain.ts index 52fb4a438a2c..0e8016853445 100644 --- a/packages/server-utils/src/integrations/langchain.ts +++ b/packages/server-utils/src/integrations/langchain.ts @@ -10,6 +10,7 @@ import type { LangChainOptions } from '../ai/langchain/types'; import { _INTERNAL_mergeLangChainCallbackHandler } from '../ai/langchain/utils'; import { MISTRAL_INTEGRATION_NAME } from '../ai/mistral/constants'; import { OPENAI_INTEGRATION_NAME } from '../ai/openai/constants'; +import { GROQ_INTEGRATION_NAME } from './groq'; import { CHANNELS } from '../orchestrion/channels'; import { langchainEmbeddingsChannels } from '../orchestrion/config/langchain'; import { bindTracingChannelToSpan } from '../tracing-channel'; @@ -27,6 +28,8 @@ const SKIPPED_PROVIDERS = [ ANTHROPIC_AI_INTEGRATION_NAME, GOOGLE_GENAI_INTEGRATION_NAME, MISTRAL_INTEGRATION_NAME, + // `@langchain/groq` drives `groq-sdk`, so ChatGroq calls must not also open the Groq integration's span. + GROQ_INTEGRATION_NAME, ]; // The chat-model channels carry the live args array of `invoke(input, options)` / `_streamIterator(input, options)`. diff --git a/packages/server-utils/src/integrations/openai-compatible.ts b/packages/server-utils/src/integrations/openai-compatible.ts new file mode 100644 index 000000000000..ae5c76a86135 --- /dev/null +++ b/packages/server-utils/src/integrations/openai-compatible.ts @@ -0,0 +1,156 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import type { Integration, IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; +import { + _INTERNAL_shouldSkipAiProviderWrapping, + getClient, + hasSpanStreamingEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startInactiveSpan, +} from '@sentry/core'; +import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; +import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; +import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; +import { instrumentStream } from '../ai/openai/streaming'; +import type { OpenAiOptions } from '../ai/openai/types'; +import { addResponseAttributes } from '../ai/openai/utils'; +import { invokeOrchestrionInstrumentation } from '../orchestrion/instrumentation'; +import { bindTracingChannelToSpan } from '../tracing-channel'; + +/** + * Describes an OpenAI-compatible provider whose SDK mirrors the openai wire format (Groq, Together, ...), + * so the span building, streaming and response parsing can all be reused from `ai/openai`. + */ +export interface OpenAiCompatibleProvider { + /** Integration name; also the key used to skip double-wrapping when another provider drives the SDK. */ + integrationName: string; + /** Value reported on `gen_ai.provider.name`. */ + providerName: string; + /** Span origin, `auto.ai.`. */ + origin: string; + /** The instrumented `module.name`s (from the orchestrion config's `getModuleNames`). */ + moduleNames: string[]; + /** The fully-qualified orchestrion channels this provider publishes to. */ + channels: { chat: string; embeddings: string }; +} + +/** + * The context orchestrion shares across the tracing-channel lifecycle hooks: `arguments` is the live args + * array passed to `Completions.create(body, options)`, and Node's `tracingChannel` attaches `result` when + * the returned promise settles. + */ +interface OpenAiCompatibleChannelContext { + arguments: unknown[]; + result?: unknown; +} + +/** + * Builds a diagnostics-channel integration for an OpenAI-compatible provider SDK. It subscribes to the + * `orchestrion::{chat,embeddings}` channels injected into the SDK's `create` methods, so it + * requires the Sentry runtime hook or bundler plugin. Everything below the channel — request/response + * attributes and streaming — is shared with the openai integration; only the provider name and origin + * differ. + */ +export function createOpenAiCompatibleIntegration( + provider: T, +): (options?: OpenAiOptions) => Integration & { name: T['integrationName'] } { + const instrumentedChannels = [ + { channel: provider.channels.chat, operation: 'chat' }, + { channel: provider.channels.embeddings, operation: 'embeddings' }, + ] as const; + + function instrument(options: OpenAiOptions): void { + for (const { channel, operation } of instrumentedChannels) { + bindTracingChannelToSpan( + diagnosticsChannel.tracingChannel(channel), + data => createGenAiSpan(data, operation, provider, options), + { + beforeSpanEnd: (span, data) => { + addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + }, + // Streaming: the result is a `Stream` consumed later, so instrument it and let it end the span. + deferSpanEnd: ({ span, data }) => wrapStreamResult(span, data, options), + }, + ); + } + } + + return ((options: OpenAiOptions = {}) => { + return { + name: provider.integrationName, + setup(client) { + invokeOrchestrionInstrumentation(client, provider.moduleNames, instrument, [options]); + }, + }; + }) satisfies IntegrationFn; +} + +/** + * Build the span for an instrumented `create` call. + * Returning `undefined` opts the payload out so no span is opened. + */ +function createGenAiSpan( + data: OpenAiCompatibleChannelContext, + operation: string, + provider: OpenAiCompatibleProvider, + options: OpenAiOptions, +): Span | undefined { + // When another provider (e.g. LangChain) is driving the SDK, it records the spans itself and marks this + // provider as skipped; skip here to avoid double spans. + if (_INTERNAL_shouldSkipAiProviderWrapping(provider.integrationName)) { + return undefined; + } + + const args = data.arguments ?? []; + const params = args[0] as Record | undefined; + + const { recordInputs } = resolveAIRecordingOptions(options); + + // `extractRequestAttributes` defaults the provider to openai; override it for the concrete provider. + const attributes = extractRequestAttributes(args, operation, recordInputs); + attributes[GEN_AI_PROVIDER_NAME] = provider.providerName; + attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = provider.origin; + const model = (params?.model as string) || 'unknown'; + const client = getClient(); + + const span = startInactiveSpan({ + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: model !== 'unknown' || !(client && hasSpanStreamingEnabled(client)) ? `${operation} ${model}` : operation, + op: getGenAiSpanOp(operation), + attributes: attributes as Record, + }); + + if (recordInputs && params) { + addRequestAttributes(span, params, operation); + } + + return span; +} + +type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator }; + +function isAsyncIterable(value: unknown): value is AsyncIterableStream { + return !!value && typeof (value as AsyncIterableStream)[Symbol.asyncIterator] === 'function'; +} + +/** + * For a streaming `create({ stream: true })` the result is a `Stream` the caller consumes later. We can't + * swap what `create` returns, but the `Stream` in `data.result` is the same instance the caller holds and + * `asyncEnd` fires before the caller iterates — so we patch its async iterator in place to run through + * `instrumentStream`, which accumulates the streamed attributes and ends the span when iteration finishes. + * Only a streaming call resolves to an async-iterable, so that check alone distinguishes it. Returns `true` + * to hand span-ending ownership to `instrumentStream`; `false` for non-streaming/errored results, which end + * via the normal `beforeSpanEnd` path. + */ +function wrapStreamResult(span: Span, data: OpenAiCompatibleChannelContext, options: OpenAiOptions): boolean { + const result = data.result; + if (!isAsyncIterable(result)) { + return false; + } + + const { recordOutputs } = resolveAIRecordingOptions(options); + const iterate = result[Symbol.asyncIterator].bind(result); + const instrumented = instrumentStream({ [Symbol.asyncIterator]: iterate }, span, recordOutputs ?? false); + result[Symbol.asyncIterator] = () => instrumented; + + return true; +} diff --git a/packages/server-utils/src/integrations/together-ai.ts b/packages/server-utils/src/integrations/together-ai.ts new file mode 100644 index 000000000000..614e8b17a831 --- /dev/null +++ b/packages/server-utils/src/integrations/together-ai.ts @@ -0,0 +1,19 @@ +import { defineIntegration } from '@sentry/core'; +import { CHANNELS } from '../orchestrion/channels'; +import { togetherAiModuleNames } from '../orchestrion/config/together-ai'; +import { createOpenAiCompatibleIntegration } from './openai-compatible'; + +/** + * Instruments the `together-ai` client (chat completions and embeddings). Together speaks the OpenAI wire + * format, so this reuses the openai span/streaming logic; see `createOpenAiCompatibleIntegration`. Requires + * the Sentry runtime hook or bundler plugin so the diagnostics channels get injected into `together-ai`. + */ +export const togetherAIIntegration = defineIntegration( + createOpenAiCompatibleIntegration({ + integrationName: 'TogetherAI', + providerName: 'together_ai', + origin: 'auto.ai.together_ai', + moduleNames: togetherAiModuleNames, + channels: { chat: CHANNELS.TOGETHER_CHAT, embeddings: CHANNELS.TOGETHER_EMBEDDINGS }, + }), +); diff --git a/packages/server-utils/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts index 65ab3e56899e..a88495d6ed8c 100644 --- a/packages/server-utils/src/orchestrion/channels.ts +++ b/packages/server-utils/src/orchestrion/channels.ts @@ -7,6 +7,7 @@ import { firebaseChannels } from './config/firebase'; import { genericPoolChannels } from './config/generic-pool'; import { googleGenAiChannels } from './config/google-genai'; import { graphqlChannels } from './config/graphql'; +import { groqChannels } from './config/groq'; import { hapiChannels } from './config/hapi'; import { ioredisChannels } from './config/ioredis'; import { kafkajsChannels } from './config/kafkajs'; @@ -28,6 +29,7 @@ import { postgresJsChannels } from './config/postgres'; import { redisChannels } from './config/redis'; import { remixChannels } from './config/remix'; import { tediousChannels } from './config/tedious'; +import { togetherAiChannels } from './config/together-ai'; import { vercelAiChannels } from './config/vercel-ai'; /** @@ -56,6 +58,7 @@ export const CHANNELS = { ...genericPoolChannels, ...googleGenAiChannels, ...graphqlChannels, + ...groqChannels, ...hapiChannels, ...ioredisChannels, ...kafkajsChannels, @@ -77,6 +80,7 @@ export const CHANNELS = { ...redisChannels, ...remixChannels, ...tediousChannels, + ...togetherAiChannels, ...vercelAiChannels, } as const; diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index 73d7c164cfe7..a1c51f6008fa 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -30,6 +30,8 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ { exportName: 'anthropicAIIntegration', modules: ['@anthropic-ai/sdk'] }, { exportName: 'googleGenAIIntegration', modules: ['@google/genai'] }, { exportName: 'mistralAIIntegration', modules: ['@mistralai/mistralai'] }, + { exportName: 'groqIntegration', modules: ['groq-sdk'] }, + { exportName: 'togetherAIIntegration', modules: ['together-ai'] }, { exportName: 'vercelAIIntegration', modules: ['ai'] }, { exportName: 'langChainIntegration', diff --git a/packages/server-utils/src/orchestrion/config/groq.ts b/packages/server-utils/src/orchestrion/config/groq.ts new file mode 100644 index 000000000000..b5f6a4039d60 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/groq.ts @@ -0,0 +1,17 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +import { getModuleNames } from './module-names'; +import { openAiCompatibleConfig } from './openai-compatible'; + +// `groq-sdk` is a Stainless-generated, OpenAI-compatible SDK — see `openAiCompatibleConfig`. +export const groqConfig = openAiCompatibleConfig({ + name: 'groq-sdk', + versionRange: '>=0.3.0 <2', +}) satisfies InstrumentationConfig[]; + +export const groqModuleNames = getModuleNames(groqConfig); + +export const groqChannels = { + GROQ_CHAT: 'orchestrion:groq-sdk:chat', + GROQ_EMBEDDINGS: 'orchestrion:groq-sdk:embeddings', +} as const; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 723c5857b8f1..dced48dc622d 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -11,6 +11,7 @@ import { firebaseConfig } from './firebase'; import { genericPoolConfig } from './generic-pool'; import { googleGenAiConfig } from './google-genai'; import { graphqlConfig } from './graphql'; +import { groqConfig } from './groq'; import { hapiConfig } from './hapi'; import { ioredisConfig } from './ioredis'; import { kafkajsConfig } from './kafkajs'; @@ -32,6 +33,7 @@ import { postgresJsConfig } from './postgres'; import { redisConfig } from './redis'; import { remixConfig } from './remix'; import { tediousConfig } from './tedious'; +import { togetherAiConfig } from './together-ai'; import { vercelAiConfig } from './vercel-ai'; // Kept sorted alphabetically by module so concurrent additions insert at different // points rather than all appending to the end (fewer merge conflicts). @@ -59,6 +61,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...genericPoolConfig, ...googleGenAiConfig, ...graphqlConfig, + ...groqConfig, ...hapiConfig, ...ioredisConfig, ...kafkajsConfig, @@ -80,6 +83,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...redisConfig, ...remixConfig, ...tediousConfig, + ...togetherAiConfig, ...vercelAiConfig, ]; diff --git a/packages/server-utils/src/orchestrion/config/openai-compatible.ts b/packages/server-utils/src/orchestrion/config/openai-compatible.ts new file mode 100644 index 000000000000..1c4e8fad0370 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/openai-compatible.ts @@ -0,0 +1,27 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +/** + * Many providers (Groq, Together, ...) ship a Stainless-generated SDK that mirrors the classic `openai` + * file layout: `resources/chat/completions.{js,mjs}` (class `Completions`) and + * `resources/embeddings.{js,mjs}` (class `Embeddings`), each with a `create(body, options)` that returns + * a thenable `APIPromise` — so `kind: 'Auto'` resolves to `wrapPromise`, and a streaming call resolves to + * the same OpenAI-style async-iterable `Stream` the openai integration already knows how to consume. These + * SDKs ship dual CJS/ESM and the matcher compares `filePath` exactly, hence one entry per built file. + * + * Because the wire format is OpenAI-compatible, the span building, streaming and response parsing are all + * reused from `ai/openai`; only the module name, channels and `gen_ai.provider.name` differ per provider. + */ +export function openAiCompatibleConfig(module: { name: string; versionRange: string }): InstrumentationConfig[] { + return [ + ...['resources/chat/completions.js', 'resources/chat/completions.mjs'].map(filePath => ({ + channelName: 'chat', + module: { ...module, filePath }, + functionQuery: { className: 'Completions', methodName: 'create', kind: 'Auto' as const }, + })), + ...['resources/embeddings.js', 'resources/embeddings.mjs'].map(filePath => ({ + channelName: 'embeddings', + module: { ...module, filePath }, + functionQuery: { className: 'Embeddings', methodName: 'create', kind: 'Auto' as const }, + })), + ]; +} diff --git a/packages/server-utils/src/orchestrion/config/together-ai.ts b/packages/server-utils/src/orchestrion/config/together-ai.ts new file mode 100644 index 000000000000..82a7f4ed006e --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/together-ai.ts @@ -0,0 +1,17 @@ +import type { InstrumentationConfig } from '../apmTypes'; + +import { getModuleNames } from './module-names'; +import { openAiCompatibleConfig } from './openai-compatible'; + +// `together-ai` is a Stainless-generated, OpenAI-compatible SDK — see `openAiCompatibleConfig`. +export const togetherAiConfig = openAiCompatibleConfig({ + name: 'together-ai', + versionRange: '>=0.6.0 <1', +}) satisfies InstrumentationConfig[]; + +export const togetherAiModuleNames = getModuleNames(togetherAiConfig); + +export const togetherAiChannels = { + TOGETHER_CHAT: 'orchestrion:together-ai:chat', + TOGETHER_EMBEDDINGS: 'orchestrion:together-ai:embeddings', +} as const; diff --git a/yarn.lock b/yarn.lock index db856fb5774e..e66adedac544 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16883,6 +16883,11 @@ gray-matter@^4.0.3: section-matter "^1.0.0" strip-bom-string "^1.0.0" +groq-sdk@1.6.0: + version "1.6.0" + resolved "https://sfw.security.sentry.io/npm/groq-sdk/-/groq-sdk-1.6.0.tgz#6938848229935425e29aa5a9b225b50f4fbc89a9" + integrity sha512-ggMgo6n84aZdNObgkSbSxWrdAq4wABsViIRb5e4N15bMUtDLZVhqlgZOI7X4TeqdBMdaVUOuUdZxAZIp06u0vw== + gtoken@^5.0.4: version "5.2.1" resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.2.1.tgz#4dae1fea17270f457954b4a45234bba5fc796d16" @@ -26443,6 +26448,11 @@ 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== +together-ai@0.54.0: + version "0.54.0" + resolved "https://sfw.security.sentry.io/npm/together-ai/-/together-ai-0.54.0.tgz#dd1345de5662ef5c4778da9d9f8bc184db07c17a" + integrity sha512-SiAzvfHCQMaX/omwpCpbFJmUFP8vUQntT2wpAgfUknOc/UpdjF0mORg/IHIAXfa35FurNyCjfBPsexHaHbpy0w== + toidentifier@1.0.1, toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" From 860b34a54a85fadc33bf19a6f16ba0684f209456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 18 Sep 2026 12:20:57 +0300 Subject: [PATCH 12/39] test(test-utils): Add Sentry CLI trace helpers as `test-utils/cli` (#24279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `@sentry-internal/test-utils/cli`, a helper module for E2E apps that send real data to Sentry and have to wait until it is queryable. It shells out to the `sentry` CLI (`sentry trace view // --json --fresh`) instead of hand-rolling requests against the organization trace endpoint, and exposes `findErrorInTrace`, `findSpanInTrace`, `fetchTrace`, `flattenTrace`, `traceTarget` and `EVENT_POLLING_OPTIONS`. Decisions: - The CLI itself is a dev dependency of the consuming app (the `sentry` package on npm), not of test-utils. The helper runs `pnpm exec sentry` in the app's working directory, which keeps the 15 MB CLI out of every other test app. - The helper hands `E2E_TEST_AUTH_TOKEN` to the CLI as `SENTRY_AUTH_TOKEN` and sets `SENTRY_FORCE_ENV_TOKEN=1`. Without the flag the CLI prefers a developer's stored login over the env token, so local runs would authenticate differently from CI. - `--fresh` bypasses the CLI's response cache, which would otherwise defeat polling. - Exit code 23 ("not found") means the trace has not landed yet. The auth exit codes and a 401 or 403 in the output fail immediately, because waiting never fixes a token that lacks `org:read`. - A separate subpath export instead of the root index, so the dependency on the CLI stays with the apps that opt in. - `traceTarget` returns the CLI target, so a test can log a `sentry trace view ...` line that is paste-able into a terminal when a CI run fails. The first consumer is the Cloudflare send-to-sentry E2E app in the follow-up PR. `react-send-to-sentry` and `node-express-send-to-sentry` keep their fetch-based copies for now and can move over separately. Part of #23610 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: isaacs --- dev-packages/test-utils/package.json | 10 ++ dev-packages/test-utils/rollup.npm.config.mjs | 1 + dev-packages/test-utils/src/cli.ts | 131 ++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 dev-packages/test-utils/src/cli.ts diff --git a/dev-packages/test-utils/package.json b/dev-packages/test-utils/package.json index 18cab043a8ae..5aa53b9db183 100644 --- a/dev-packages/test-utils/package.json +++ b/dev-packages/test-utils/package.json @@ -23,6 +23,16 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./cli": { + "import": { + "types": "./build/types/cli.d.ts", + "default": "./build/esm/cli.js" + }, + "require": { + "types": "./build/types/cli.d.ts", + "default": "./build/cjs/cli.js" + } } }, "sideEffects": false, diff --git a/dev-packages/test-utils/rollup.npm.config.mjs b/dev-packages/test-utils/rollup.npm.config.mjs index b684e2efe16b..3c774d19e5a6 100644 --- a/dev-packages/test-utils/rollup.npm.config.mjs +++ b/dev-packages/test-utils/rollup.npm.config.mjs @@ -2,6 +2,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/cli.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts new file mode 100644 index 000000000000..b35e37bed6c9 --- /dev/null +++ b/dev-packages/test-utils/src/cli.ts @@ -0,0 +1,131 @@ +import { spawnSync } from 'node:child_process'; + +/** + * Spans only become queryable once they have made it through to EAP, which takes + * noticeably longer than the error pipeline (~2min vs ~20s when this was measured). + */ +export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] }; + +/** + * A node of the span tree returned by `sentry trace view`. Spans, errors and occurrences all + * share this shape and are discriminated by `event_type`. + */ +export interface TraceItem { + /** On a span this is the span id. */ + event_id?: string; + event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; + op?: string | null; + children?: TraceItem[] | null; + errors?: TraceItem[] | null; + occurrences?: TraceItem[] | null; +} + +/** + * The `sentry trace view` target of a trace in the E2E test project, so a log line can be pasted + * into a terminal as-is. + */ +export function traceTarget(traceId: string): string { + return `${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/${process.env['E2E_TEST_SENTRY_PROJECT']}/${traceId}`; +} + +/** + * Fetch a trace of the E2E test project through the `sentry` CLI, which the calling test app has to + * list as a dev dependency. Returns an empty list while the trace has not landed yet. + */ +export function fetchTrace(traceId: string): TraceItem[] { + const target = traceTarget(traceId); + const result = spawnSync('pnpm', ['exec', 'sentry', 'trace', 'view', target, '--json', '--fresh'], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + env: { + ...process.env, + // The E2E token is the only credential CI has. Locally the CLI would prefer a stored login + // over an env token, so force the env token for identical behaviour everywhere. + SENTRY_AUTH_TOKEN: process.env['E2E_TEST_AUTH_TOKEN'], + SENTRY_FORCE_ENV_TOKEN: '1', + }, + }); + + if (result.error) { + throw new Error( + `Could not run \`pnpm exec sentry trace view\`: ${result.error.message}. ` + + 'The test app needs `sentry` as a dev dependency.', + ); + } + + if (result.status === 0) { + return (JSON.parse(result.stdout) as { spans?: TraceItem[] }).spans ?? []; + } + + // Exit codes 10-19 are auth errors, and a rejected token also surfaces as an API error (exit 30) + // with a 401 in the message. Neither resolves by waiting, so fail loudly instead of polling until + // the timeout and reporting it as a missing event. The trace endpoint is org scoped, so the token + // needs `org:read` on top of the project scopes. + const isAuthError = result.status !== null && result.status >= 10 && result.status < 20; + if (isAuthError || /\b40[13]\b/.test(result.stderr)) { + throw new Error( + `sentry trace view ${target} failed with exit code ${result.status}: ${result.stderr}` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } + + const traceMissing = result.status === 23 && result.stderr.includes(`Trace '${traceId}' not found`); + + if (traceMissing) { + return []; + } + + throw new Error(`sentry trace view ${target} exited with ${result.status}: ${result.stderr}`); +} + +/** + * Errors attach to whichever span was active when they were captured, and relocate from the + * top level into that span once it lands, so a given event can surface at any depth. + */ +export function flattenTrace(items: TraceItem[]): TraceItem[] { + return items.flatMap(item => [ + item, + ...flattenTrace(item.children ?? []), + ...flattenTrace(item.errors ?? []), + ...flattenTrace(item.occurrences ?? []), + ]); +} + +/** + * Without an `eventId` any error in the trace matches. That is what a request the server failed + * needs, because the client never learns the event id of an unhandled exception. + */ +export function findErrorInTrace(traceId: string, eventId?: string): TraceItem | undefined { + return flattenTrace(fetchTrace(traceId)).find( + item => item.event_type === 'error' && (eventId === undefined || item.event_id === eventId), + ); +} + +let loggedTraceShape = false; + +/** + * Streamed spans never become transaction events, so the segment is matched by its op rather than by + * the event id of an enclosing transaction. The trace is already unique to the request under test, + * so the op identifies the segment within it. + */ +export function findSpanInTrace(traceId: string, op: string): TraceItem | undefined { + const items = flattenTrace(fetchTrace(traceId)); + const match = items.find(item => item.op === op); + + // The trace endpoint's exact span shape is what this lookup depends on, so report it once when a + // non-empty trace does not contain the op we are waiting for. + if (!match && items.length && !loggedTraceShape) { + loggedTraceShape = true; + // eslint-disable-next-line no-console + console.log( + `Trace ${traceId} has no "${op}" item yet. Items so far:`, + JSON.stringify( + items.map(item => ({ event_type: item.event_type, op: item.op, event_id: item.event_id })), + null, + 2, + ), + ); + } + + return match; +} From b9fc87caada6fb5de09b58ba193ef98905e4d8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 18 Sep 2026 12:20:58 +0300 Subject: [PATCH 13/39] test(cloudflare): Add E2E test that deploys a real Worker and sends to Sentry (#24280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `cloudflare-workers-send-to-sentry`, an optional E2E app that deploys a real Cloudflare Worker and verifies in Sentry that the SDK's data arrives, in the spirit of `react-send-to-sentry`. Local `wrangler dev` cannot reproduce what the platform does around `waitUntil`, isolate reuse, Durable Object hibernation and eviction. This app is the place for tests that need the real runtime. The app is built with Vite, `@cloudflare/vite-plugin` and `sentryCloudflareVitePlugin()`, so the worker entry stays uninstrumented and the options live in `src/instrument.server.ts` via `defineCloudflareOptions`. Playwright's global setup deploys the Worker and waits for its workers.dev URL. The three tests cover a captured exception, an unhandled exception together with its request span, and a plain request span. They poll Sentry through `@sentry-internal/test-utils/cli` and log a paste-able `sentry trace view` command per trace. Decisions: - **One Worker per ref in CI, never deleted by the test run.** Pull requests and merge-queue runs deploy to `e2e-send-to-sentry-pr-`, pushes to `e2e-send-to-sentry-` (`develop`, `master`, `v10`). A run overwrites the Worker of its ref, so a failed run leaves its Worker in place for debugging, and Workers Logs are enabled for it. The new `cleanup-e2e-workers.yml` workflow deletes the PR Worker when the PR closes. The branch Workers stay. Local runs use a throwaway `-local-` name that the teardown deletes; `E2E_KEEP_WORKER=1` keeps it. - **Redeploying is always done, never skipped.** A deploy takes about 10 s and the SDK tarball changes on almost every run. - **The unhandled-error test sends `baggage` next to `sentry-trace`.** The test has to pick the trace id itself, because a failed request cannot report ids. Without `baggage` the SDK freezes an empty dynamic sampling context (`propagationContextFromHeaders` in core, on purpose: "not head of trace"), and Relay drops the streamed span with the outcome `missing_dsc`. The error still arrives, only the request span is lost. This affects every server request whose upstream sends `sentry-trace` without `baggage` under span streaming. - **Only `CLOUDFLARE_ACCOUNT_ID` is required by the setup.** Wrangler authenticates with `CLOUDFLARE_API_TOKEN` in CI or a `wrangler login` session locally. Repository setup: `CLOUDFLARE_API_TOKEN` (Workers Scripts: Edit, Workers KV Storage: Read) and `CLOUDFLARE_ACCOUNT_ID` point at the Sentry Dev account, and the optional E2E job passes both through. Not in this PR: protecting the Worker URLs with Cloudflare Access and a service token. Worker-level Access rejects WebSocket upgrades, so it should be a hostname-based application per Worker, which needs "Access: Apps and Policies: Edit" on the API token. Verified with real runs against the Sentry Dev account: all three tests pass in about 1.3 minutes, and the throwaway Worker is deleted afterwards. Fixes #23610 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: isaacs --- .github/workflows/build.yml | 3 + .github/workflows/cleanup-e2e-workers.yml | 36 +++++++++ dev-packages/e2e-tests/.env.example | 6 ++ dev-packages/e2e-tests/README.md | 4 + .../.gitignore | 5 ++ .../deployed-worker.mjs | 74 +++++++++++++++++++ .../global-setup.mjs | 61 +++++++++++++++ .../global-teardown.mjs | 24 ++++++ .../package.json | 35 +++++++++ .../playwright.config.ts | 16 ++++ .../src/env.d.ts | 3 + .../src/index.ts | 23 ++++++ .../src/instrument.server.ts | 9 +++ .../tests/send-to-sentry.test.ts | 45 +++++++++++ .../tsconfig.json | 16 ++++ .../vite.config.ts | 9 +++ .../wrangler.jsonc | 11 +++ 17 files changed, 380 insertions(+) create mode 100644 .github/workflows/cleanup-e2e-workers.yml create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70256b2a3276..7f7013812884 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1150,6 +1150,9 @@ jobs: E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks' E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' E2E_OPENROUTER_API_KEY: ${{ secrets.E2E_OPENROUTER_API_KEY }} + # Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} strategy: fail-fast: false matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }} diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml new file mode 100644 index 000000000000..02fb540f71a3 --- /dev/null +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -0,0 +1,36 @@ +name: 'Automation: Cleanup E2E workers' +on: + pull_request: + types: + - closed + +jobs: + cleanup: + # The optional E2E job deploys only for PRs from this repository, so forks never have a worker to delete. + if: github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: {} + timeout-minutes: 5 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + strategy: + matrix: + # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.mjs + worker-prefix: + - e2e-send-to-sentry + steps: + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Delete worker + run: | + WORKER="${{ matrix.worker-prefix }}-pr-${{ github.event.pull_request.number }}" + + if ! output=$(npx --yes wrangler@4 delete --name "$WORKER" --force 2>&1); then + echo "$output" + # 10007 means the worker does not exist, i.e. the PR never ran the optional E2E job. + echo "$output" | grep -q 'code: 10007' || exit 1 + fi diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 1a709a9b02b9..180fb07078a1 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -15,3 +15,9 @@ E2E_TEST_SENTRY_PROJECT= # An OpenRouter API key to make real model calls. # Only needed to run that test app locally. E2E_OPENROUTER_API_KEY= + +# Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry). +# The API token needs "Workers Scripts: Edit" on the account; "Workers KV Storage: Read" additionally silences a +# warning when a worker is deleted. Leave it empty to use a `wrangler login` session instead. +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 4145890ed605..4a398a2b9002 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -18,6 +18,10 @@ current state. - Copy `.env.example` to `.env` - OPTIONAL: Fill in auth information in `.env` for an example Sentry project - you only need this to run E2E tests that send data to Sentry. +- OPTIONAL: Fill in the Cloudflare credentials in `.env` - you only need this to run E2E tests that deploy a real + Cloudflare Worker (e.g. `cloudflare-workers-send-to-sentry`). A local run deploys a throwaway worker and deletes it + again afterwards; set `E2E_KEEP_WORKER=1` to keep it for debugging. CI keeps one worker per branch or PR instead, and + PR workers are deleted by the `cleanup-e2e-workers` workflow when the PR closes. - Run `yarn build:tarball` in the root of the repository (needs to be rerun after every update in /packages for the changes to have effect on the tests). diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore new file mode 100644 index 000000000000..b6c344e957e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +dist +.wrangler +node_modules +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs new file mode 100644 index 000000000000..da689df28552 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/deployed-worker.mjs @@ -0,0 +1,74 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function wrangler(args, env = {}) { + execFileSync('pnpm', ['exec', 'wrangler', ...args], { + cwd: __dirname, + env: { ...process.env, ...env }, + stdio: ['ignore', 'inherit', 'inherit'], + }); +} + +/** Deploys the worker under `name` and returns its workers.dev URL. */ +export function deployWorker(name, dsn) { + const outputDir = mkdtempSync(join(tmpdir(), 'wrangler-output-')); + const outputFile = join(outputDir, 'output.ndjson'); + + try { + wrangler(['deploy', '--name', name, '--var', `E2E_TEST_DSN:${dsn}`], { WRANGLER_OUTPUT_FILE_PATH: outputFile }); + + const url = readFileSync(outputFile, 'utf8') + .split('\n') + .filter(Boolean) + .map(line => JSON.parse(line)) + .find(entry => entry.type === 'deploy') + ?.targets?.find(target => target.endsWith('.workers.dev')); + + if (!url) { + throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`); + } + + return url; + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } +} + +export function deleteWorker(name) { + wrangler(['delete', '--name', name, '--force']); +} + +/** + * CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the + * cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set. + */ +export function keepsWorker() { + return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER); +} + +/** A freshly created workers.dev route can take a moment to become reachable. */ +export async function waitForWorker(url) { + const deadline = Date.now() + 60_000; + + while (Date.now() < deadline) { + try { + // The SDK does not trace HEAD requests, so the probe leaves no spans behind in Sentry. + const response = await fetch(url, { method: 'HEAD' }); + + if (response.ok) { + return; + } + } catch { + // DNS for the new subdomain may not have propagated yet. + } + + await new Promise(resolve => setTimeout(resolve, 2_000)); + } + + throw new Error(`Worker at ${url} did not become reachable within 60s.`); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs new file mode 100644 index 000000000000..08e9311cb7ca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-setup.mjs @@ -0,0 +1,61 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs'; + +const WORKER_PREFIX = 'e2e-send-to-sentry'; + +/** + * In CI the name follows the ref, so `develop`, `master` and every PR get a stable Worker that the + * next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs + * like `gh-readonly-queue//pr-123-`; both map to the PR's Worker. + */ +export function getWorkerName() { + if (!process.env.GITHUB_ACTIONS) { + return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`; + } + + const { GITHUB_EVENT_NAME, GITHUB_REF_NAME = '' } = process.env; + const prNumber = + GITHUB_EVENT_NAME === 'pull_request' ? GITHUB_REF_NAME.split('/')[0] : /\/pr-(\d+)-/.exec(GITHUB_REF_NAME)?.[1]; + const ref = prNumber ? `pr-${prNumber}` : GITHUB_REF_NAME; + // Worker names allow lowercase alphanumerics and dashes only, up to 63 characters. + const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + + return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, ''); +} + +export default async function globalSetup() { + if (!existsSync(new URL('.wrangler/deploy/config.json', import.meta.url))) { + throw new Error('Run `pnpm build` first: wrangler would deploy the uninstrumented source.'); + } + const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env; + if (!E2E_TEST_DSN) { + throw new Error('E2E_TEST_DSN must be set to deploy the test worker.'); + } + + // Wrangler authenticates with `CLOUDFLARE_API_TOKEN` (CI) or a `wrangler login` session (local), + // but it cannot pick an account on its own outside of a terminal. + if (!CLOUDFLARE_ACCOUNT_ID) { + throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); + } + + const workerName = getWorkerName(); + const workerUrl = deployWorker(workerName, E2E_TEST_DSN); + process.env.E2E_TEST_WORKER_NAME = workerName; + + try { + await waitForWorker(workerUrl); + } catch (error) { + if (!keepsWorker()) { + try { + deleteWorker(workerName); + } catch (deleteError) { + // The unreachable worker is the failure to report, not the cleanup. + console.error(`Failed to delete worker ${workerName}:`, deleteError); + } + } + throw error; + } + + process.env.E2E_TEST_WORKER_URL = workerUrl; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs new file mode 100644 index 000000000000..0fee903dc5c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/global-teardown.mjs @@ -0,0 +1,24 @@ +import { deleteWorker, keepsWorker } from './deployed-worker.mjs'; + +export default function globalTeardown() { + const workerName = process.env.E2E_TEST_WORKER_NAME; + + if (!workerName) { + return; + } + + if (keepsWorker()) { + console.log(`Keeping worker ${workerName} at ${process.env.E2E_TEST_WORKER_URL}`); + return; + } + + try { + deleteWorker(workerName); + } catch (error) { + // A leaked worker is not an SDK failure, so it must not fail a run whose tests passed. + console.error( + `Failed to delete worker ${workerName}, delete it with \`wrangler delete --name ${workerName}\`:`, + error, + ); + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json new file mode 100644 index 000000000000..75bbb77d0b7c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -0,0 +1,35 @@ +{ + "name": "cloudflare-workers-send-to-sentry", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "typecheck": "tsc --noEmit", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist .wrangler", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^26.1.2", + "sentry": "~0.44.1", + "typescript": "~6.0.3", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + }, + "sentryTest": { + "optional": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..0b79fb88052f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/playwright.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + // The worker is deployed once for the whole run and deleted again afterwards. + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + /* Spans take ~2min to become queryable via the trace endpoint. */ + timeout: 210_000, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + // Every test spends most of its time polling Sentry, so run them all at once. + workers: '100%', + reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts new file mode 100644 index 000000000000..c48a38137713 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/index.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/cloudflare'; + +export default { + async fetch(request) { + const url = new URL(request.url); + // The handler runs inside the request span the Vite plugin's `withSentry` wrapper starts, so + // this is the `http.server` span. + const spanContext = Sentry.getActiveSpan()?.spanContext(); + + switch (url.pathname) { + case '/test-error': { + const eventId = Sentry.captureException(new Error('E2E test error')); + return Response.json({ eventId, traceId: spanContext?.traceId }); + } + case '/test-unhandled-error': + throw new Error('E2E test unhandled error'); + case '/test-span': + return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); + default: + return new Response('Hello World!'); + } + }, +} satisfies ExportedHandler; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts new file mode 100644 index 000000000000..cb0d3bfb44aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/src/instrument.server.ts @@ -0,0 +1,9 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// The Sentry Vite plugin picks this file up by convention, next to the worker entry named in +// wrangler's `main`, and hands its default export to `withSentry`. +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..49813f134b10 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,45 @@ +import { randomBytes } from 'node:crypto'; +import { expect, test } from '@playwright/test'; +import { EVENT_POLLING_OPTIONS, findErrorInTrace, findSpanInTrace, traceTarget } from '@sentry-internal/test-utils/cli'; + +// Set by global-setup.mjs once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL; + +test('Sends a captured exception to Sentry', async () => { + const response = await fetch(`${workerUrl}/test-error`); + expect(response.status).toBe(200); + const { eventId, traceId } = await response.json(); + + console.log(`Polling for error eventId ${eventId}: sentry trace view ${traceTarget(traceId)}`); + + await expect.poll(() => findErrorInTrace(traceId, eventId), EVENT_POLLING_OPTIONS).toBeDefined(); +}); + +test('Sends an unhandled exception and its request span to Sentry', async () => { + const traceId = randomBytes(16).toString('hex'); + const publicKey = new URL(process.env.E2E_TEST_DSN!).username; + const response = await fetch(`${workerUrl}/test-unhandled-error`, { + headers: { + 'sentry-trace': `${traceId}-${randomBytes(8).toString('hex')}-1`, + baggage: `sentry-trace_id=${traceId},sentry-public_key=${publicKey},sentry-sampled=true,sentry-sample_rate=1`, + }, + }); + expect(response.status).toBe(500); + + console.log(`Polling for unhandled error: sentry trace view ${traceTarget(traceId)}`); + + await expect.poll(() => findErrorInTrace(traceId), EVENT_POLLING_OPTIONS).toBeDefined(); + await expect.poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS).toBeDefined(); +}); + +test('Sends a request span to Sentry', async () => { + const response = await fetch(`${workerUrl}/test-span`); + expect(response.status).toBe(200); + const { spanId, traceId } = await response.json(); + + console.log(`Polling for request spanId ${spanId}: sentry trace view ${traceTarget(traceId)}`); + + await expect + .poll(() => findSpanInTrace(traceId, 'http.server'), EVENT_POLLING_OPTIONS) + .toMatchObject({ event_id: spanId }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..94b03468f288 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts new file mode 100644 index 000000000000..75a667ec89d6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/vite.config.ts @@ -0,0 +1,9 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +// The Sentry plugin wraps the default export of `src/index.ts` with `withSentry` at build time and +// takes the options from `src/instrument.server.ts`, so the entry itself stays uninstrumented. +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc new file mode 100644 index 000000000000..cf5ad9bee22b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // Placeholder only: every test run deploys under a unique name, see global-setup.mjs. + "name": "cloudflare-workers-send-to-sentry", + "main": "src/index.ts", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + // Workers Logs keep the invocations of the last 7 days, so a failed CI run can still be inspected. + "observability": { "enabled": true }, +} From 3d3fb7c2414fcabc521ecb628b24f9baf55f3734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 18 Sep 2026 13:53:52 +0300 Subject: [PATCH 14/39] fix(hono): Allow @cloudflare/workers-types v5 as peer dependency (#24494) closes #24484 closes [JS-3715](https://linear.app/getsentry/issue/JS-3715) Seems I forgot to update this in #22180 --- packages/hono/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hono/package.json b/packages/hono/package.json index 7f74b8a8c21e..9dbaaf6647cb 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -77,7 +77,7 @@ "@sentry/conventions": "^0.23.0" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.x", + "@cloudflare/workers-types": "^4.x || ^5.x", "@hono/node-server": "^1.x || ^2.x", "@sentry/bun": "11.0.0-rc.0", "@sentry/cloudflare": "11.0.0-rc.0", From a2dcc9c0ced9b1a3f1dbc43bf2b7e439dffda3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=A4=80=EC=84=9C=20Junseo=20Kim?= Date: Fri, 18 Sep 2026 20:56:05 +0900 Subject: [PATCH 15/39] fix(nextjs): Prevent sourceMappingURL stripping from truncating minified chunks (#24022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While deploying our app (Next.js 16.2.10 turbopack build, `@sentry/nextjs` 10.68.0, `deleteSourcemapsAfterUpload: true`), we noticed some JS chunks were shipped cut off mid-file and threw `SyntaxError: Invalid or unexpected token` on every page load. We traced it to the sourceMappingURL stripping step, and this PR addresses it with a small regex change. The regex in `stripSourceMappingURLComments` doesn't check that the comment starts at the beginning of a line: ```js /\n?\/\/[#@] sourceMappingURL=[^\n]+$/ ``` So it also matches comment-shaped text inside a string literal — and since a minified chunk is a single line, everything from the match to EOF gets deleted: ```js // minified chunk — comment-shaped text inside a string const a=1;const worker='self.onmessage=()=>{};\n//# sourceMappingURL=worker.js.map\n';use(worker);const b=2; // after stripping — truncated mid-string, no longer parseable const a=1;const worker='self.onmessage=()=>{};\n ``` In our case the text came from rrweb (the recording engine behind Amplitude Session Replay), which inlines its worker code as a string. The regex is unchanged in 10.73.0 and on current develop. We fixed it by requiring the match to start at a line start — we've been running this in production via `pnpm patch`, upstreaming it in case it's useful: ```js /(?:^|\n)\/\/[#@] sourceMappingURL=[^\s'"`]+$/ ``` While digging around we noticed the repo already does this when *reading* the comment (`debug-id-upload.ts` uses `/^\s*\/\/# sourceMappingURL=(.*)$/m`), so this aligns the stripping side with it. Genuine comments sit on their own line and are stripped exactly as before; string-embedded text no longer matches. Excluding whitespace/quotes from the URL matches how browsers read the value (`data:` URIs still work) and blocks the template-literal variant of the same issue. The CSS regex gets the same line-start condition. Added 4 tests — two reproduce the truncation and fail on the old regex, two pin existing behavior. Would love to hear whether this approach makes sense. If you'd prefer a more minimal change, the URL charset restriction can be dropped — the line-start condition alone fixes the truncation. --------- Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> --- .../config/handleRunAfterProductionCompile.ts | 4 +- .../handleRunAfterProductionCompile.test.ts | 104 ++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts index ae4b0a91d84a..5d21f2653b3a 100644 --- a/packages/nextjs/src/config/handleRunAfterProductionCompile.ts +++ b/packages/nextjs/src/config/handleRunAfterProductionCompile.ts @@ -137,8 +137,8 @@ async function warnAboutUncoveredSourcemaps( } } -const SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\/[#@] sourceMappingURL=[^\n]+$/; -const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\n]+\*\/$/; +const SOURCEMAPPING_URL_COMMENT_REGEX = /(?:^|\n)\/\/[#@] sourceMappingURL=[^\s'"`*]+\s*$/; +const CSS_SOURCEMAPPING_URL_COMMENT_REGEX = /\n?\/\*[#@] sourceMappingURL=[^\s*]+\s*\*\/\s*$/; /** * Strips sourceMappingURL comments from all JS/MJS/CJS/CSS files in the given directory. diff --git a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts index ebff37a09c5a..b7a81d3fc1c0 100644 --- a/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts +++ b/packages/nextjs/test/config/handleRunAfterProductionCompile.test.ts @@ -740,4 +740,108 @@ describe('stripSourceMappingURLComments', () => { expect(content).not.toContain('sourceMappingURL'); } }); + + it('does not modify minified files with a sourceMappingURL marker inside a string literal', async () => { + const filePath = path.join(tmpDir, 'chunks', 'minified.js'); + const originalContent = `const worker = 'self.onmessage = () => {};\\n//# sourceMappingURL=worker.js.map\\n'; use(worker);`; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('does not modify files ending with a template literal containing a sourceMappingURL marker', async () => { + const filePath = path.join(tmpDir, 'chunks', 'template.js'); + const originalContent = 'const s = `line1\n//# sourceMappingURL=worker.js.map`;'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('strips sourceMappingURL comment from files consisting only of the comment', async () => { + const filePath = path.join(tmpDir, 'chunks', 'comment-only.js'); + await fs.promises.writeFile(filePath, '//# sourceMappingURL=comment-only.js.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(''); + }); + + it('strips sourceMappingURL comment with a data: URI', async () => { + const filePath = path.join(tmpDir, 'chunks', 'inline.js'); + await fs.promises.writeFile(filePath, 'var a = 1;\n//# sourceMappingURL=data:application/json;base64,eyJ2IjozfQ=='); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var a = 1;'); + }); + + it('strips sourceMappingURL comment followed by trailing whitespace', async () => { + const filePath = path.join(tmpDir, 'chunks', 'trailing-whitespace.js'); + await fs.promises.writeFile(filePath, 'var a = 1;\n//# sourceMappingURL=trailing-whitespace.js.map \r\n'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var a = 1;'); + }); + + it('strips sourceMappingURL comment from CSS files when it is on the same line', async () => { + const filePath = path.join(tmpDir, 'chunks', 'same-line.css'); + await fs.promises.writeFile(filePath, '.foo{color:red}/*# sourceMappingURL=same-line.css.map */\n'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('.foo{color:red}'); + }); + + it('does not strip past an earlier sourceMappingURL comment in minified CSS files', async () => { + const filePath = path.join(tmpDir, 'chunks', 'concat.css'); + await fs.promises.writeFile(filePath, '.a{}/*# sourceMappingURL=a.css.map*/.b{}/*x*/'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('.a{}/*# sourceMappingURL=a.css.map*/.b{}/*x*/'); + }); + + it('does not modify files ending with a block comment containing a sourceMappingURL marker', async () => { + const filePath = path.join(tmpDir, 'chunks', 'block-comment.js'); + const originalContent = 'var a = 1;\n/*\n//# sourceMappingURL=block-comment.js.map*/'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it('does not strip a sourceMappingURL comment that is not at the end of the file', async () => { + const filePath = path.join(tmpDir, 'chunks', 'not-at-eof.js'); + const originalContent = '//# sourceMappingURL=not-at-eof.js.map\nvar a = 1;'; + await fs.promises.writeFile(filePath, originalContent); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe(originalContent); + }); + + it.fails('strips sourceMappingURL comment from JS files when it is on the same line', async () => { + const filePath = path.join(tmpDir, 'chunks', 'same-line.js'); + await fs.promises.writeFile(filePath, 'var a=1;//# sourceMappingURL=same-line.js.map'); + + await stripSourceMappingURLComments(tmpDir); + + const content = await fs.promises.readFile(filePath, 'utf-8'); + expect(content).toBe('var a=1;'); + }); }); From ce1c31f3c86697c5069db8312eea2fc210f8e212 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Fri, 18 Sep 2026 14:45:37 +0200 Subject: [PATCH 16/39] chore(bundler-plugins): move traces sample rate from 1.0 to 0.3 (#24488) - With the move off of dynamic sampling, the data from this DSN is now overrepresented - In order to reduce the telemetry collected, introduce a sample rate below 1 for the bunder plugins Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com> --- .../fixtures/esbuild/telemetry.test.ts | 2 +- .../fixtures/rolldown/telemetry.test.ts | 2 +- .../fixtures/rollup3/telemetry.test.ts | 2 +- .../fixtures/rollup4/telemetry.test.ts | 2 +- .../fixtures/vite4/telemetry.test.ts | 2 +- .../fixtures/vite7/telemetry.test.ts | 2 +- .../fixtures/vite8/telemetry.test.ts | 2 +- .../fixtures/webpack5/telemetry.test.ts | 2 +- packages/bundler-plugins/src/core/sentry/telemetry.ts | 7 ++++++- 9 files changed, 14 insertions(+), 9 deletions(-) diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts index 6395a8728321..972a967bbe09 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/esbuild/telemetry.test.ts @@ -6,7 +6,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { expect(readOutputFiles()).toMatchInlineSnapshot(` { "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"esbuild","bundler-major-version":"28"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", "telemetry.js": "(() => { diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts index 88a82dc3e008..f8004bc8ee3c 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/telemetry.test.ts @@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { //#endregion ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts index 970ebdaefd1d..648a01c4719b 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup3/telemetry.test.ts @@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"3"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts index cfb27aee5b5e..94c6834500fd 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rollup4/telemetry.test.ts @@ -9,7 +9,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};e.SENTRY_RELEASE={id:"CURRENT_SHA"};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="00000000-0000-0000-0000-000000000000",e._sentryDebugIdIdentifier="sentry-dbid-00000000-0000-0000-0000-000000000000");}catch(e){}}();console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"rollup","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts index 6f8d4de0de7a..1f3f77b816df 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite4/telemetry.test.ts @@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"4"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts index fb6a03910565..ac0e8b7b217f 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/telemetry.test.ts @@ -17,7 +17,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { console.log("hello world"); ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"7"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts index d5e24152625f..608d363246ba 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/telemetry.test.ts @@ -18,7 +18,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { //#endregion ", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"vite","bundler-major-version":"8"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts index b26f095092af..0fa7cf672d64 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/webpack5/telemetry.test.ts @@ -14,7 +14,7 @@ test(import.meta.url, ({ runBundler, readOutputFiles, runFileInNode }) => { /******/ })() ;", "sentry-telemetry.json": "[{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":true,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"ok","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], - [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"1"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":1},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], + [{"event_id":"UUID","sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"},"trace":{"environment":"production","release":"PLUGIN_VERSION","public_key":"UUID","trace_id":"UUID","org_id":"1","transaction":"Sentry Bundler Plugin execution","sampled":"true","sample_rand":"SAMPLE_RAND","sample_rate":"0.3"}},[[{"type":"transaction"},{"contexts":{"trace":{"span_id":"SHORT_UUID","trace_id":"UUID","data":{"sentry.origin":"manual","sentry.segment.name.source":"custom","sentry.sample_rate":0.3},"status":"ok","origin":"manual"},"runtime":{"name":"node","version":"NODE_VERSION"}},"spans":[],"start_timestamp":START_TIMESTAMP,"timestamp":TIMESTAMP,"transaction":"Sentry Bundler Plugin execution","type":"transaction","transaction_info":{"source":"custom"},"platform":"PLATFORM","event_id":"UUID","environment":"production","release":"PLUGIN_VERSION","tags":{"upload-legacy-sourcemaps":false,"module-metadata":false,"inject-build-information":false,"set-commits":"auto","finalize-release":true,"deploy-options":false,"custom-error-handler":false,"sourcemaps-assets":false,"delete-after-upload":false,"sourcemaps-disabled":false,"react-annotate":false,"node":"NODE_VERSION","platform":"PLATFORM","meta-framework":"none","application-key-set":false,"ci":true,"project":"undefined","bundler":"webpack","bundler-major-version":"5"},"user":{},"sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION","integrations":[],"packages":[{"name":"npm:@sentry/node","version":"SDK_VERSION"}]}}]]], [{"sent_at":"TIMESTAMP","sdk":{"name":"sentry.javascript.node","version":"SDK_VERSION"}},[[{"type":"session"},{"sid":"UUID","init":false,"started":"TIMESTAMP","timestamp":"TIMESTAMP","status":"exited","errors":0,"duration":DURATION,"attrs":{"release":"PLUGIN_VERSION","environment":"production"}}]]], ", } diff --git a/packages/bundler-plugins/src/core/sentry/telemetry.ts b/packages/bundler-plugins/src/core/sentry/telemetry.ts index a68f106e07b9..72ea587bfef8 100644 --- a/packages/bundler-plugins/src/core/sentry/telemetry.ts +++ b/packages/bundler-plugins/src/core/sentry/telemetry.ts @@ -27,7 +27,7 @@ export function createSentryInstance( dsn: 'https://4c2bae7d9fbc413e8f7385f55c515d51@o1.ingest.sentry.io/6690737', - tracesSampleRate: 1, + tracesSampleRate: 0.3, traceLifecycle: 'static', sampleRate: 1, @@ -64,6 +64,11 @@ export function createSentryInstance( const scope = new Scope(); scope.setClient(client); + // Integration tests snapshot the emitted transaction, so the sampling decision must not depend on chance. + if (process.env['SENTRY_TEST_OUT_DIR']) { + scope.setPropagationContext({ ...scope.getPropagationContext(), sampleRand: 0 }); + } + setTelemetryDataOnScope(options, scope, buildTool, buildToolMajorVersion); return { sentryScope: scope, sentryClient: client }; From 94467756b24973e47986e42ca4a9bd592101e243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 18 Sep 2026 14:59:37 +0200 Subject: [PATCH 17/39] fix(core): correct MCP error and request attribution (#24493) Unsupported MCP discovery requests such as `resources/list` currently inflate server failure rates. Classify JSON-RPC responses using the [OTel MCP server convention](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md#server), recording the response code without marking caller errors as server failures. Tool results with `isError: true` now report `tool_error`; JSON-RPC payloads and transport options are unchanged. Keep MCP 2026-07-28 client/server metadata on the corresponding request span instead of caching it across requests on a reused transport. Legacy `initialize` session attribution remains supported. The OTel convention is still Development and excludes exactly `-32700`, `-32600`, `-32601`, `-32602`, and `-32002` from server errors. Newer MCP codes retain the convention's default error classification; changing that policy belongs in a separate follow-up. --------- Co-authored-by: GPT-6 (Codex) Co-authored-by: GPT-6 (Codex) --- .../integrations/mcp-server/correlation.ts | 24 ++- .../integrations/mcp-server/errorCapture.ts | 9 + .../mcp-server/sessionExtraction.ts | 14 +- .../core/src/integrations/mcp-server/spans.ts | 4 +- .../src/integrations/mcp-server/transport.ts | 31 ++-- .../mcp-server/errorClassification.test.ts | 160 ++++++++++++++++++ .../mcp-server/modernMetadata.test.ts | 128 ++++++++++++++ .../transportInstrumentation.test.ts | 2 +- 8 files changed, 337 insertions(+), 35 deletions(-) create mode 100644 packages/core/test/lib/integrations/mcp-server/errorClassification.test.ts create mode 100644 packages/core/test/lib/integrations/mcp-server/modernMetadata.test.ts diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index c9a7ba4b5c94..3b0e8e9a5d0e 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -10,9 +10,11 @@ * Falls back to WeakMap by transport instance for stateless transports (no sessionId). */ +import { ERROR_TYPE, RPC_RESPONSE_STATUS_CODE } from '@sentry/conventions/attributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types/span'; -import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; +import { MCP_PROTOCOL_VERSION_ATTRIBUTE, MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE } from './attributes'; +import { isJsonRpcServerError } from './errorCapture'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { buildServerAttributesFromInfo, @@ -20,7 +22,7 @@ import { extractSessionDataFromResponse, } from './sessionExtraction'; import { updateSessionDataForTransport } from './sessionManagement'; -import type { MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; +import type { JsonRpcError, MCPTransport, RequestId, RequestSpanMapValue, ResolvedMcpOptions } from './types'; /** * Session-scoped correlation for stateful transports (with sessionId) @@ -93,13 +95,13 @@ export function storeSpanForRequest( * @param transport - MCP transport instance * @param requestId - Request identifier * @param result - Execution result for attribute extraction - * @param hasError - Whether the JSON-RPC response contained an error + * @param error - The JSON-RPC error response, if any */ export function completeSpanWithResults( transport: MCPTransport, requestId: RequestId, result: unknown, - hasError = false, + error?: JsonRpcError, ): void { const spanMap = getOrCreateSpanMap(transport); const spanData = spanMap.get(requestId); @@ -109,7 +111,7 @@ export function completeSpanWithResults( method === 'initialize' ? extractSessionDataFromInitializeResponse(result) : extractSessionDataFromResponse(result); - if (responseSessionData.protocolVersion || responseSessionData.serverInfo) { + if (method === 'initialize' && (responseSessionData.protocolVersion || responseSessionData.serverInfo)) { updateSessionDataForTransport(transport, responseSessionData); } const responseAttributes: Record = { @@ -122,11 +124,19 @@ export function completeSpanWithResults( span.setAttributes(responseAttributes); } - if (hasError) { - span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + if (error) { + span.setAttributes({ [RPC_RESPONSE_STATUS_CODE]: String(error.code) }); + if (isJsonRpcServerError(error.code)) { + span.setAttributes({ [ERROR_TYPE]: String(error.code) }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: error.message }); + } } else if (method === 'tools/call') { const toolAttributes = extractToolResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(toolAttributes); + if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] === true) { + span.setAttributes({ [ERROR_TYPE]: 'tool_error' }); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + } } else if (method === 'prompts/get') { const promptAttributes = extractPromptResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(promptAttributes); diff --git a/packages/core/src/integrations/mcp-server/errorCapture.ts b/packages/core/src/integrations/mcp-server/errorCapture.ts index 2ba401c93d08..18ff6c054ecb 100644 --- a/packages/core/src/integrations/mcp-server/errorCapture.ts +++ b/packages/core/src/integrations/mcp-server/errorCapture.ts @@ -11,6 +11,15 @@ import { SPAN_STATUS_ERROR } from '../../tracing'; import { getActiveSpan } from '../../utils/spanUtils'; import type { McpErrorType } from './types'; +/** + * Classifies JSON-RPC errors using MCP server semantic conventions. + * @param code - JSON-RPC error code + * @see https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md#server + */ +export function isJsonRpcServerError(code: number): boolean { + return ![-32700, -32600, -32601, -32602, -32002].includes(code); +} + /** * Captures an error without affecting MCP server operation. * diff --git a/packages/core/src/integrations/mcp-server/sessionExtraction.ts b/packages/core/src/integrations/mcp-server/sessionExtraction.ts index e487dcad1873..a4de982bf1b4 100644 --- a/packages/core/src/integrations/mcp-server/sessionExtraction.ts +++ b/packages/core/src/integrations/mcp-server/sessionExtraction.ts @@ -267,19 +267,25 @@ export function getTransportTypes(transport: MCPTransport): { mcpTransport: stri * Build transport and network attributes * @param transport - MCP transport instance * @param extra - Optional extra handler data + * @param message - Current message carrying request-scoped protocol metadata * @returns Transport attributes for span instrumentation * @note sessionId may be undefined during initial setup - session should be established by client during initialize flow */ export function buildTransportAttributes( transport: MCPTransport, extra?: ExtraHandlerData, + message?: JsonRpcRequest | JsonRpcNotification, ): Record { - const sessionId = transport && 'sessionId' in transport ? transport.sessionId : undefined; + const messageData = message && extractSessionDataFromMessage(message); + const hasRequestMetadata = messageData?.protocolVersion !== undefined || messageData?.clientInfo !== undefined; + const sessionId = !hasRequestMetadata && transport && 'sessionId' in transport ? transport.sessionId : undefined; const clientInfo = extra ? extractClientInfo(extra) : {}; const { mcpTransport, networkTransport } = getTransportTypes(transport); - const clientAttributes = getClientAttributes(transport); - const serverAttributes = getServerAttributes(transport); - const protocolVersion = getProtocolVersionForTransport(transport); + const clientAttributes = hasRequestMetadata + ? buildClientAttributesFromInfo(messageData?.clientInfo) + : getClientAttributes(transport); + const serverAttributes = hasRequestMetadata ? {} : getServerAttributes(transport); + const protocolVersion = hasRequestMetadata ? messageData?.protocolVersion : getProtocolVersionForTransport(transport); const attributes = { ...(sessionId && { [MCP_SESSION_ID_ATTRIBUTE]: sessionId }), diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index 7277d199a06e..9159ce497122 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -103,7 +103,7 @@ function createMcpSpan(config: McpSpanConfig): unknown { } const rawAttributes: Record = { - ...buildTransportAttributes(transport, extra), + ...buildTransportAttributes(transport, extra, message), [MCP_METHOD_NAME_ATTRIBUTE]: method, ...buildTypeSpecificAttributes(type, message, params, options?.recordInputs), ...buildSentryAttributes(type), @@ -201,7 +201,7 @@ export function buildMcpServerSpanConfig( const spanName = method ? createSpanName(method, target) : MCP_SERVER_SPAN_NAME_FALLBACK; const rawAttributes: Record = { - ...buildTransportAttributes(transport, extra), + ...buildTransportAttributes(transport, extra, jsonRpcMessage), [MCP_METHOD_NAME_ATTRIBUTE]: method, ...buildTypeSpecificAttributes('request', jsonRpcMessage, params, options?.recordInputs), ...buildSentryAttributes('request'), diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index e235a754741f..599457b01cfd 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -12,12 +12,8 @@ import { isObjectLike } from '../../utils/is'; import { fill } from '../../utils/object'; import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; import { cleanupPendingSpansForTransport, completeSpanWithResults, storeSpanForRequest } from './correlation'; -import { captureError } from './errorCapture'; -import { - buildClientAttributesFromInfo, - extractSessionDataFromInitializeRequest, - extractSessionDataFromMessage, -} from './sessionExtraction'; +import { captureError, isJsonRpcServerError } from './errorCapture'; +import { buildClientAttributesFromInfo, extractSessionDataFromInitializeRequest } from './sessionExtraction'; import { cleanupSessionDataForTransport, updateSessionDataForTransport } from './sessionManagement'; import { buildMcpServerSpanConfig, createMcpNotificationSpan, createMcpOutgoingNotificationSpan } from './spans'; import type { ExtraHandlerData, McpServerWrapperOptions, MCPTransport, ResolvedMcpOptions, SessionData } from './types'; @@ -41,8 +37,8 @@ function resolveMcpOptions(options: McpServerWrapperOptions): ResolvedMcpOptions /** * Wraps transport.onmessage to create spans for incoming messages. - * Extracts and stores client info and protocol version from legacy initialize - * requests and modern message envelopes. + * Stores client info and protocol version only for legacy initialize requests. + * Modern request metadata is read directly when building each message's span. * @param transport - MCP transport instance to wrap * @param options - MCP capture overrides */ @@ -52,15 +48,11 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ return function (this: MCPTransport, message: unknown, extra?: unknown) { const request = isJsonRpcRequest(message) ? message : undefined; const notification = isJsonRpcNotification(message) ? message : undefined; - const jsonRpcMessage = request || notification; let messageSessionData: SessionData | undefined; - if (jsonRpcMessage) { + if (request?.method === 'initialize') { try { - messageSessionData = - request?.method === 'initialize' - ? extractSessionDataFromInitializeRequest(request) - : extractSessionDataFromMessage(jsonRpcMessage); + messageSessionData = extractSessionDataFromInitializeRequest(request); if (messageSessionData.protocolVersion || messageSessionData.clientInfo) { updateSessionDataForTransport(transport, messageSessionData); } @@ -109,8 +101,8 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ /** * Wraps transport.send to handle outgoing messages and response correlation. - * Extracts and stores protocol version and server info from legacy initialize - * responses and modern result metadata. + * Caches legacy initialize metadata and applies modern result metadata only to + * the corresponding request span. * @param transport - MCP transport instance to wrap * @param options - MCP capture overrides */ @@ -133,7 +125,7 @@ export function wrapTransportSend(transport: MCPTransport, options: McpServerWra captureJsonRpcErrorResponse(message.error); } - completeSpanWithResults(transport, message.id, message.result, !!message.error); + completeSpanWithResults(transport, message.id, message.result, message.error); } } @@ -185,10 +177,7 @@ function captureJsonRpcErrorResponse(errorResponse: unknown): void { if (isObjectLike(errorResponse) && 'code' in errorResponse && 'message' in errorResponse) { const jsonRpcError = errorResponse as { code: number; message: string; data?: unknown }; - const isServerError = - jsonRpcError.code === -32603 || (jsonRpcError.code >= -32099 && jsonRpcError.code <= -32000); - - if (isServerError) { + if (typeof jsonRpcError.code === 'number' && isJsonRpcServerError(jsonRpcError.code)) { const error = new Error(jsonRpcError.message); error.name = `JsonRpcError_${jsonRpcError.code}`; diff --git a/packages/core/test/lib/integrations/mcp-server/errorClassification.test.ts b/packages/core/test/lib/integrations/mcp-server/errorClassification.test.ts new file mode 100644 index 000000000000..2dab513ff24f --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/errorClassification.test.ts @@ -0,0 +1,160 @@ +import { ERROR_TYPE, RPC_RESPONSE_STATUS_CODE } from '@sentry/conventions/attributes'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import * as exports from '../../../../src/exports'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import type { JsonRpcResponse } from '../../../../src/integrations/mcp-server/types'; +import { SentrySpan } from '../../../../src/tracing/sentrySpan'; +import { SPAN_STATUS_ERROR } from '../../../../src/tracing/spanstatus'; +import * as tracing from '../../../../src/tracing/trace'; +import { spanToJSON } from '../../../../src/utils/spanUtils'; +import { createMockClient, createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP server response error classification', () => { + beforeEach(() => { + vi.spyOn(currentScopes, 'getClient').mockReturnValue(createMockClient()); + vi.spyOn(exports, 'captureException').mockReturnValue('event-id'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function createRequest(method: string) { + const span = new SentrySpan({ name: method }); + vi.spyOn(tracing, 'startInactiveSpan').mockReturnValue(span); + const server = wrapMcpServerWithSentry(createMockMcpServer(), { recordOutputs: false }); + const transport = createMockTransport(); + const send = transport.send; + + await server.connect(transport); + transport.onmessage({ jsonrpc: '2.0', id: 'request-1', method }, {}); + + return { span, transport, send }; + } + + it.each([ + { code: -32700, message: 'Parse error' }, + { code: -32600, message: 'Invalid Request' }, + { code: -32601, message: 'Method not found' }, + { code: -32602, message: 'Invalid params' }, + { code: -32002, message: 'Resource not found' }, + ])('records $message ($code) without reporting a server failure', async ({ code, message }) => { + const { span, transport, send } = await createRequest('resources/list'); + const response: JsonRpcResponse = Object.freeze({ + jsonrpc: '2.0', + id: 'request-1', + error: Object.freeze({ code, message, data: { reason: 'unsupported request' } }), + }); + const sendOptions = { relatedRequestId: 'request-1' }; + + await transport.send(response, sendOptions); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(response, sendOptions); + const result = spanToJSON(span); + expect(result.status).toBe('ok'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBe(String(code)); + expect(result.attributes[ERROR_TYPE]).toBeUndefined(); + expect(result.attributes['sentry.status.message']).toBeUndefined(); + expect(result.end_timestamp).toBeDefined(); + expect(exports.captureException).not.toHaveBeenCalled(); + }); + + it.each([-32603, -32099, -32000, -32020, -32021, -32022, -32042, 1000])( + 'reports JSON-RPC error %s as a server failure', + async code => { + const { span, transport, send } = await createRequest('tools/call'); + const response: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'request-1', + error: { code, message: 'Request could not be completed' }, + }; + + await transport.send(response); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(response); + const result = spanToJSON(span); + expect(result.status).toBe('error'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBe(String(code)); + expect(result.attributes[ERROR_TYPE]).toBe(String(code)); + expect(result.attributes['sentry.status.message']).toBe('Request could not be completed'); + expect(exports.captureException).toHaveBeenCalledTimes(1); + expect(exports.captureException).toHaveBeenCalledWith( + Object.assign(new Error('Request could not be completed'), { name: `JsonRpcError_${code}` }), + { mechanism: { type: 'auto.ai.mcp_server', handled: false, data: { error_type: 'protocol' } } }, + ); + }, + ); + + it('preserves a previously recorded failure when the response is a caller error', async () => { + const { span, transport } = await createRequest('resources/list'); + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'A separate failure' }); + + await transport.send({ + jsonrpc: '2.0', + id: 'request-1', + error: { code: -32601, message: 'Method not found' }, + }); + + const result = spanToJSON(span); + expect(result.status).toBe('error'); + expect(result.attributes['sentry.status.message']).toBe('A separate failure'); + }); + + it('reports a tool error independently of output capture', async () => { + const { span, transport, send } = await createRequest('tools/call'); + const response: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'request-1', + result: { isError: true, content: [{ type: 'text', text: 'Tool failed' }] }, + }; + + await transport.send(response); + + expect(send).toHaveBeenCalledWith(response); + const result = spanToJSON(span); + expect(result.status).toBe('error'); + expect(result.attributes[ERROR_TYPE]).toBe('tool_error'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBeUndefined(); + expect(result.attributes['mcp.tool.result.is_error']).toBe(true); + expect(result.attributes['mcp.tool.result.content']).toBeUndefined(); + expect(exports.captureException).not.toHaveBeenCalled(); + }); + + it('does not classify an input_required tool response as a server failure', async () => { + const { span, transport, send } = await createRequest('tools/call'); + const response: JsonRpcResponse = { + jsonrpc: '2.0', + id: 'request-1', + result: { resultType: 'input_required', requestState: 'opaque-state' }, + }; + + await transport.send(response); + + expect(send).toHaveBeenCalledWith(response); + const result = spanToJSON(span); + expect(result.status).toBe('ok'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBeUndefined(); + expect(result.attributes[ERROR_TYPE]).toBeUndefined(); + expect(result.end_timestamp).toBeDefined(); + expect(exports.captureException).not.toHaveBeenCalled(); + }); + + it('does not leak error classification into a subsequent successful response', async () => { + const { transport } = await createRequest('resources/list'); + await transport.send({ jsonrpc: '2.0', id: 'request-1', error: { code: -32601, message: 'Method not found' } }); + const span = new SentrySpan({ name: 'tools/call' }); + vi.mocked(tracing.startInactiveSpan).mockReturnValue(span); + transport.onmessage({ jsonrpc: '2.0', id: 'request-2', method: 'tools/call' }, {}); + + await transport.send({ jsonrpc: '2.0', id: 'request-2', result: { isError: false, content: [] } }); + + const result = spanToJSON(span); + expect(result.status).toBe('ok'); + expect(result.attributes[RPC_RESPONSE_STATUS_CODE]).toBeUndefined(); + expect(result.attributes[ERROR_TYPE]).toBeUndefined(); + expect(result.attributes['mcp.tool.result.is_error']).toBe(false); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/modernMetadata.test.ts b/packages/core/test/lib/integrations/mcp-server/modernMetadata.test.ts new file mode 100644 index 000000000000..31eab524d194 --- /dev/null +++ b/packages/core/test/lib/integrations/mcp-server/modernMetadata.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as currentScopes from '../../../../src/currentScopes'; +import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; +import { SentrySpan } from '../../../../src/tracing/sentrySpan'; +import * as tracing from '../../../../src/tracing/trace'; +import { spanToJSON } from '../../../../src/utils/spanUtils'; +import { createMockClient, createMockMcpServer, createMockTransport } from './testUtils'; + +describe('MCP request metadata isolation', () => { + const spans: SentrySpan[] = []; + const modernMeta = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientCapabilities': {}, + }; + const resourcesResult = { resultType: 'complete', resources: [], ttlMs: 0, cacheScope: 'private' }; + + beforeEach(() => { + spans.length = 0; + vi.spyOn(currentScopes, 'getClient').mockReturnValue(createMockClient()); + vi.spyOn(tracing, 'startInactiveSpan').mockImplementation(options => { + const span = new SentrySpan(options); + spans.push(span); + return span; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function connect() { + const transport = createMockTransport(); + transport.sessionId = ''; + await wrapMcpServerWithSentry(createMockMcpServer()).connect(transport); + return transport; + } + + it('does not inherit optional client or server identity from another modern request', async () => { + const transport = await connect(); + transport.onmessage({ + jsonrpc: '2.0', + id: 1, + method: 'resources/list', + params: { + _meta: { + ...modernMeta, + 'io.modelcontextprotocol/clientInfo': { name: 'first-client', version: '1.0' }, + }, + }, + }); + await transport.send({ + jsonrpc: '2.0', + id: 1, + result: { + ...resourcesResult, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'first-server', version: '1.0' } }, + }, + }); + transport.onmessage({ + jsonrpc: '2.0', + id: 2, + method: 'resources/list', + params: { _meta: modernMeta }, + }); + await transport.send({ jsonrpc: '2.0', id: 2, result: resourcesResult }); + + expect(spans.map(span => spanToJSON(span).attributes)).toEqual([ + expect.objectContaining({ + 'mcp.client.name': 'first-client', + 'mcp.client.version': '1.0', + 'mcp.server.name': 'first-server', + 'mcp.server.version': '1.0', + 'mcp.protocol.version': '2026-07-28', + }), + expect.objectContaining({ 'mcp.protocol.version': '2026-07-28' }), + ]); + const secondAttributes = spanToJSON(spans[1]!).attributes; + for (const attribute of ['mcp.client.name', 'mcp.client.version', 'mcp.server.name', 'mcp.server.version']) { + expect(secondAttributes).not.toHaveProperty(attribute); + } + }); + + it('does not attribute a request missing protocol metadata to the preceding modern request', async () => { + const transport = await connect(); + transport.onmessage({ jsonrpc: '2.0', id: 1, method: 'resources/list', params: { _meta: modernMeta } }); + await transport.send({ jsonrpc: '2.0', id: 1, result: resourcesResult }); + transport.onmessage({ jsonrpc: '2.0', id: 2, method: 'resources/list' }); + await transport.send({ jsonrpc: '2.0', id: 2, error: { code: -32602, message: 'Missing protocol metadata' } }); + + expect(spanToJSON(spans[0]!).attributes['mcp.protocol.version']).toBe('2026-07-28'); + expect(spanToJSON(spans[1]!).attributes).not.toHaveProperty('mcp.protocol.version'); + }); + + it('preserves established legacy session metadata but does not apply it to a modern request', async () => { + const transport = await connect(); + transport.sessionId = 'legacy-metadata-session'; + transport.onmessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy-client', version: '1.0' } }, + }); + await transport.send({ + jsonrpc: '2.0', + id: 1, + result: { protocolVersion: '2025-06-18', serverInfo: { name: 'legacy-server', version: '1.0' } }, + }); + transport.onmessage({ jsonrpc: '2.0', id: 2, method: 'resources/list' }); + await transport.send({ jsonrpc: '2.0', id: 2, result: { resources: [] } }); + transport.onmessage({ jsonrpc: '2.0', id: 3, method: 'resources/list', params: { _meta: modernMeta } }); + await transport.send({ jsonrpc: '2.0', id: 3, result: resourcesResult }); + + expect(spanToJSON(spans[1]!).attributes).toEqual( + expect.objectContaining({ + 'mcp.protocol.version': '2025-06-18', + 'mcp.client.name': 'legacy-client', + 'mcp.server.name': 'legacy-server', + 'mcp.session.id': 'legacy-metadata-session', + }), + ); + const modernAttributes = spanToJSON(spans[2]!).attributes; + expect(modernAttributes['mcp.protocol.version']).toBe('2026-07-28'); + for (const attribute of ['mcp.client.name', 'mcp.server.name', 'mcp.session.id']) { + expect(modernAttributes).not.toHaveProperty(attribute); + } + transport.onclose(); + }); +}); diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index e97745c57253..adaa3c51e719 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -209,7 +209,7 @@ describe('MCP Server Transport Instrumentation', () => { }; await mockTransport.send?.(jsonRpcErrorResponse as any); - expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: 2, message: 'internal_error' }); + expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: 2, message: jsonRpcErrorResponse.error.message }); expect(mockSpan.end).toHaveBeenCalled(); }); From 95e5a7cc05642ba24631e8c776f8b87b68184fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Fri, 18 Sep 2026 14:59:56 +0200 Subject: [PATCH 18/39] fix(core): instrument MCP transports before start (#23978) Fixes missing MCP spans when a transport delivers queued requests during `server.connect()`. The MCP SDK installs its callbacks before calling `transport.start()`, so instrumentation must run at that boundary. The temporary interceptor restores `start()` before invoking it, preserving its receiver, Promise and synchronous errors. Transports without a patchable `start()` retain the post-connect fallback. Regression tests cover MCP v1 and v2 in ESM/CJS, including optional Sentry-managed OpenTelemetry setup. A deployed Worker A/B reproduced the missing spans without the fix; the modern protocol entry path was unaffected. This does not change OpenTelemetry providers, exporters or propagation. Fixes #23977 --------- Co-authored-by: OpenAI Codex --- .../mcp-server-streamed/instrument.mjs | 10 ++ .../mcp-server-streamed/scenario-start-v2.mjs | 30 ++++ .../mcp-server-streamed/scenario-v1.mjs | 31 ++++ .../tracing/mcp-server-streamed/test.ts | 41 +++++ .../core/src/integrations/mcp-server/index.ts | 109 ++++++++++-- .../core/src/integrations/mcp-server/types.ts | 3 + .../transportInstrumentation.test.ts | 160 ++++++++++++++++++ 7 files changed, 372 insertions(+), 12 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs index 46a27dd03b74..984d2b7d2ef0 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/instrument.mjs @@ -6,4 +6,14 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + enableOpenTelemetrySetup: process.env.ENABLE_OTEL === 'true', +}); + +let initializeSpansStarted = 0; +Sentry.getClient()?.on('spanStart', span => { + const attributes = Sentry.spanToJSON(span).attributes; + if (attributes['sentry.op'] === 'mcp.server' && attributes['mcp.method.name'] === 'initialize') { + initializeSpansStarted += 1; + span.setAttribute('test.mcp.initialize_spans_started', initializeSpansStarted); + } }); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs new file mode 100644 index 000000000000..1f51dbd77342 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-start-v2.mjs @@ -0,0 +1,30 @@ +import { Client } from '@modelcontextprotocol/client'; +import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +const server = wrapMcpServerWithSentry(new McpServer({ name: 'Echo', version: '1.0.0' })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); + const originalSend = clientTransport.send.bind(clientTransport); + const requestQueued = new Promise(resolve => { + clientTransport.send = async (...args) => { + const result = await originalSend(...args); + if (args[0]?.method === 'initialize') { + resolve(); + } + return result; + }; + }); + + const clientConnection = client.connect(clientTransport); + await requestQueued; + await server.connect(serverTransport); + await clientConnection; + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs new file mode 100644 index 000000000000..1bc775aef562 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/scenario-v1.mjs @@ -0,0 +1,31 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { wrapMcpServerWithSentry } from '@sentry/node'; + +const server = wrapMcpServerWithSentry(new McpServer({ name: 'Echo', version: '1.0.0' })); + +async function run() { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const originalSend = clientTransport.send.bind(clientTransport); + const requestQueued = new Promise(resolve => { + clientTransport.send = async (...args) => { + const result = await originalSend(...args); + if (args[0]?.method === 'initialize') { + resolve(); + } + return result; + }; + }); + + const clientConnection = client.connect(clientTransport); + await requestQueued; + await server.connect(serverTransport); + await clientConnection; + + await client.close(); + await server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts index eb146ca33fbd..d53f5756a4b6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mcp-server-streamed/test.ts @@ -6,6 +6,20 @@ function mcpSpans(container: SerializedStreamedSpanContainer): SerializedStreame return container.items.filter(item => item.attributes['sentry.op']?.value === 'mcp.server'); } +function assertInitializeSpan(container: SerializedStreamedSpanContainer): void { + const initializeSpans = mcpSpans(container).filter( + span => span.attributes['mcp.method.name']?.value === 'initialize', + ); + + expect(initializeSpans).toHaveLength(1); + const initializeSpan = initializeSpans[0]!; + expect(initializeSpan.name).toBe('initialize'); + expect(initializeSpan.status).toBe('ok'); + expect(initializeSpan.attributes['sentry.op']).toEqual({ type: 'string', value: 'mcp.server' }); + expect(initializeSpan.attributes['sentry.origin']).toEqual({ type: 'string', value: 'auto.function.mcp_server' }); + expect(initializeSpan.attributes['test.mcp.initialize_spans_started']).toEqual({ type: 'integer', value: 1 }); +} + describe('MCP server spans (streamed)', () => { afterAll(() => { cleanupChildProcesses(); @@ -43,4 +57,31 @@ describe('MCP server spans (streamed)', () => { .completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-start-v2.mjs', 'instrument.mjs', (createTestRunner, test) => { + test('captures an MCP v2 initialize request queued before transport start once', async () => { + await createTestRunner().unordered().expect({ span: assertInitializeSpan }).start().completed(); + }); + + test('captures the queued request with Sentry OpenTelemetry setup enabled', async () => { + await createTestRunner() + .withEnv({ ENABLE_OTEL: 'true' }) + .unordered() + .expect({ span: assertInitializeSpan }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-v1.mjs', + 'instrument.mjs', + (createTestRunner, test) => { + test('captures an MCP v1 initialize request queued before transport start once', async () => { + await createTestRunner().unordered().expect({ span: assertInitializeSpan }).start().completed(); + }); + }, + { additionalDependencies: { '@modelcontextprotocol/sdk': '1.30.0' } }, + ); }); diff --git a/packages/core/src/integrations/mcp-server/index.ts b/packages/core/src/integrations/mcp-server/index.ts index 62521def4d01..192372462a9e 100644 --- a/packages/core/src/integrations/mcp-server/index.ts +++ b/packages/core/src/integrations/mcp-server/index.ts @@ -10,6 +10,79 @@ import { validateMcpServerInstance } from './validation'; */ const wrappedMcpServerInstances = new WeakSet(); +function instrumentTransport(transport: MCPTransport, options: McpServerWrapperOptions): void { + wrapTransportOnMessage(transport, options); + wrapTransportSend(transport, options); + wrapTransportOnClose(transport); + wrapTransportError(transport); +} + +function interceptTransportStart(transport: MCPTransport, beforeStart: () => void): () => void { + let transportStart: MCPTransport['start']; + let originalDescriptor: PropertyDescriptor | undefined; + + try { + transportStart = transport.start; + originalDescriptor = Object.getOwnPropertyDescriptor(transport, 'start'); + } catch { + return () => undefined; + } + + if (typeof transportStart !== 'function') { + return () => undefined; + } + + const originalStart = transportStart; + let isInstalled = false; + + const restoreStart = (): void => { + if (!isInstalled) { + return; + } + + try { + const currentDescriptor = Object.getOwnPropertyDescriptor(transport, 'start'); + if (currentDescriptor?.value !== interceptedStart) { + isInstalled = false; + return; + } + + if (originalDescriptor) { + Object.defineProperty(transport, 'start', originalDescriptor); + isInstalled = false; + } else if (Reflect.deleteProperty(transport, 'start')) { + isInstalled = false; + } + } catch {} + }; + + function interceptedStart(this: MCPTransport): Promise { + // Restoring first keeps recursive calls and user-observed method identity identical to the original transport. + restoreStart(); + beforeStart(); + return originalStart.call(this); + } + + const replacementDescriptor: PropertyDescriptor = + originalDescriptor && 'value' in originalDescriptor + ? { ...originalDescriptor, value: interceptedStart } + : { + configurable: originalDescriptor?.configurable ?? true, + enumerable: originalDescriptor?.enumerable ?? false, + writable: true, + value: interceptedStart, + }; + + try { + Object.defineProperty(transport, 'start', replacementDescriptor); + isInstalled = true; + } catch { + // The post-connect fallback preserves the previous behavior for transports which cannot be patched. + } + + return restoreStart; +} + /** * Wraps an MCP Server instance with Sentry instrumentation. * @@ -63,18 +136,30 @@ export function wrapMcpServerWithSentry(mcpServerInstance: S, fill(serverInstance, 'connect', originalConnect => { return async function (this: MCPServerInstance, transport: MCPTransport, ...restArgs: unknown[]) { - const result = await (originalConnect as (...args: unknown[]) => Promise).call( - this, - transport, - ...restArgs, - ); - - wrapTransportOnMessage(transport, captureOptions); - wrapTransportSend(transport, captureOptions); - wrapTransportOnClose(transport); - wrapTransportError(transport); - - return result; + let isTransportInstrumented = false; + const instrumentTransportOnce = (): void => { + if (isTransportInstrumented) { + return; + } + + isTransportInstrumented = true; + instrumentTransport(transport, captureOptions); + }; + const restoreStart = interceptTransportStart(transport, instrumentTransportOnce); + + try { + const result = await (originalConnect as (...args: unknown[]) => Promise).call( + this, + transport, + ...restArgs, + ); + + instrumentTransportOnce(); + + return result; + } finally { + restoreStart(); + } }; }); diff --git a/packages/core/src/integrations/mcp-server/types.ts b/packages/core/src/integrations/mcp-server/types.ts index 7cca7051b785..dc8cceb7d896 100644 --- a/packages/core/src/integrations/mcp-server/types.ts +++ b/packages/core/src/integrations/mcp-server/types.ts @@ -65,6 +65,9 @@ export interface JsonRpcNotification { * @description Abstraction for MCP communication transport layer */ export interface MCPTransport { + /** Starts the transport lifecycle. */ + start?: () => Promise; + /** * Message handler for incoming JSON-RPC messages * The first argument is a JSON RPC message diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index adaa3c51e719..6428b777bef3 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -34,6 +34,33 @@ import { createMockWrapperTransport, } from './testUtils'; +type StartImplementation = (transport: InMemoryTransport) => Promise; + +class InMemoryTransport { + public onmessage?: (...args: unknown[]) => void; + public onclose?: (...args: unknown[]) => void; + public onerror?: (error: Error) => void; + public send = vi.fn().mockResolvedValue(undefined); + + public constructor(private readonly startImplementation: StartImplementation = () => Promise.resolve()) {} + + public start(): Promise { + return this.startImplementation(this); + } +} + +function createStartingMcpServer() { + return { + ...createMockMcpServer(), + connect: vi.fn(async (transport: InMemoryTransport) => { + transport.onmessage = vi.fn(); + transport.onclose = vi.fn(); + transport.onerror = vi.fn(); + await transport.start(); + }), + }; +} + describe('MCP Server Transport Instrumentation', () => { const startSpanSpy = vi.spyOn(tracingModule, 'startSpan'); const startInactiveSpanSpy = vi.spyOn(tracingModule, 'startInactiveSpan'); @@ -101,6 +128,139 @@ describe('MCP Server Transport Instrumentation', () => { expect(originalConnect).toHaveBeenCalledWith(mockTransport); }); + it('instruments requests once during and after transport startup', async () => { + const transport = new InMemoryTransport(connectedTransport => { + connectedTransport.onmessage?.({ + jsonrpc: '2.0', + method: 'tools/call', + id: 'queued-request', + params: { name: 'get-weather' }, + }); + return Promise.resolve(); + }); + + await wrapMcpServerWithSentry(createStartingMcpServer()).connect(transport); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledWith({ + name: 'tools/call get-weather', + forceTransaction: true, + attributes: { + 'mcp.method.name': 'tools/call', + 'mcp.tool.name': 'get-weather', + 'mcp.request.id': 'queued-request', + 'mcp.transport': 'InMemoryTransport', + 'network.transport': 'unknown', + 'network.protocol.version': '2.0', + 'sentry.op': 'mcp.server', + 'sentry.origin': 'auto.function.mcp_server', + 'sentry.segment.name.source': 'route', + }, + }); + + startInactiveSpanSpy.mockClear(); + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'tools/call', + id: 'post-start-request', + params: { name: 'get-weather' }, + }); + + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + }); + + it('preserves the start receiver and Promise and restores the inherited method before calling it', async () => { + const startPromise = Promise.resolve(); + let startResult: Promise | undefined; + let receivedExpectedThis = false; + let wasRestoredBeforeStart = false; + let originalStart: InMemoryTransport['start']; + const transport = new InMemoryTransport(connectedTransport => { + receivedExpectedThis = connectedTransport === transport; + wasRestoredBeforeStart = connectedTransport.start === originalStart; + return startPromise; + }); + originalStart = transport.start; + const server = createStartingMcpServer(); + server.connect.mockImplementation(async connectedTransport => { + startResult = connectedTransport.start(); + await startResult; + }); + + await wrapMcpServerWithSentry(server).connect(transport); + + expect(startResult).toBe(startPromise); + expect(receivedExpectedThis).toBe(true); + expect(wasRestoredBeforeStart).toBe(true); + expect(transport.start).toBe(originalStart); + expect(Object.prototype.hasOwnProperty.call(transport, 'start')).toBe(false); + }); + + it('restores start when connect rejects before starting the transport', async () => { + const connectionError = new Error('connection failed'); + const transport = new InMemoryTransport(); + const server = { + ...createMockMcpServer(), + connect: vi.fn().mockRejectedValue(connectionError), + }; + + const connection = wrapMcpServerWithSentry(server).connect(transport); + + await expect(connection).rejects.toBe(connectionError); + expect(Object.prototype.hasOwnProperty.call(transport, 'start')).toBe(false); + }); + + it('restores start and preserves a synchronous start error', async () => { + const startError = new Error('start failed'); + const originalStart = vi.fn(() => { + throw startError; + }); + const transport = new InMemoryTransport(); + Object.defineProperty(transport, 'start', { + configurable: true, + enumerable: false, + value: originalStart, + writable: false, + }); + const originalDescriptor = Object.getOwnPropertyDescriptor(transport, 'start'); + const callStart = vi.fn((connectedTransport: InMemoryTransport) => connectedTransport.start()); + const server = createStartingMcpServer(); + server.connect.mockImplementation(async connectedTransport => { + await callStart(connectedTransport); + }); + + const connection = wrapMcpServerWithSentry(server).connect(transport); + + await expect(connection).rejects.toBe(startError); + expect(callStart).toHaveBeenCalledOnce(); + expect(callStart).not.toHaveReturned(); + expect(Object.getOwnPropertyDescriptor(transport, 'start')).toEqual(originalDescriptor); + expect(originalStart).toHaveBeenCalledOnce(); + }); + + it('falls back to post-connect instrumentation when start cannot be replaced', async () => { + const originalStart = vi.fn().mockResolvedValue(undefined); + const transport = new InMemoryTransport(); + Object.defineProperty(transport, 'start', { + configurable: false, + enumerable: false, + value: originalStart, + writable: false, + }); + + await wrapMcpServerWithSentry(createStartingMcpServer()).connect(transport); + + transport.onmessage?.({ + jsonrpc: '2.0', + method: 'tools/call', + id: 'post-start-request', + params: { name: 'get-weather' }, + }); + + expect(originalStart).toHaveBeenCalledOnce(); + expect(startInactiveSpanSpy).toHaveBeenCalledOnce(); + }); + it('should create spans for incoming JSON-RPC requests', async () => { await wrappedMcpServer.connect(mockTransport); From 1c35f077444b7193452ad79bf8c349cbc6b998b1 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 18 Sep 2026 15:06:55 +0200 Subject: [PATCH 19/39] feat(remix): Upload source maps from the Vite plugin (#24463) `sentryRemixVitePlugin` now injects debug IDs and uploads source maps as part of the build. Options widen to `BuildTimeOptionsBase`; opt out with `sourcemaps.disable`. Deletion defaults to `./build/**/*.map` rather than `./**/*.map`, which the bundler plugin globs without ignoring `node_modules` before deleting with `force: true`. --------- Co-authored-by: Claude Opus 5 --- MIGRATION.md | 2 + packages/remix/package.json | 1 + .../remix/scripts/sentry-upload-sourcemaps.js | 3 + packages/remix/src/vite/index.ts | 31 ++- packages/remix/src/vite/sourceMaps.ts | 189 ++++++++++++++++ packages/remix/src/vite/types.ts | 15 +- packages/remix/test/vite/index.test.ts | 95 +++++++- packages/remix/test/vite/sourceMaps.test.ts | 209 ++++++++++++++++++ 8 files changed, 528 insertions(+), 17 deletions(-) create mode 100644 packages/remix/src/vite/sourceMaps.ts create mode 100644 packages/remix/test/vite/sourceMaps.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index f2d127a7c465..bc8e4e35c9cb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1428,6 +1428,8 @@ Affected SDKs: `@sentry/remix`. The plugin now also applies the build-time instrumentation transform. If you added `sentryOrchestrionPlugin()` from `@sentry/server-utils/orchestrion/vite` to your Vite config manually, remove it. Opt out with `sentryRemixVitePlugin({ buildTimeInstrumentation: false })`. +It also injects debug IDs and uploads source maps once you pass `org`, `project` and `authToken` — opt out with `sentryRemixVitePlugin({ sourcemaps: { disable: true } })`. + ### React: Simpler React Router setup via `@sentry/react/react-router` Affected SDKs: `@sentry/react`. diff --git a/packages/remix/package.json b/packages/remix/package.json index 552c740c04e5..556a64501145 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -58,6 +58,7 @@ }, "dependencies": { "@remix-run/router": "^1.23.4", + "@sentry/bundler-plugins": "10.67.0", "@sentry/conventions": "^0.23.0", "@sentry/core": "11.0.0-rc.0", "@sentry/node": "11.0.0-rc.0", diff --git a/packages/remix/scripts/sentry-upload-sourcemaps.js b/packages/remix/scripts/sentry-upload-sourcemaps.js index a5394e23b69c..28d705fc7e68 100755 --- a/packages/remix/scripts/sentry-upload-sourcemaps.js +++ b/packages/remix/scripts/sentry-upload-sourcemaps.js @@ -63,6 +63,9 @@ const argv = yargs(process.argv.slice(2)) ' [--deleteAfterUpload true|false]\n\n' + 'This CLI tool will upload sourcemaps to Sentry for the given release.\n' + 'It has defaults for URL prefix and build path for Remix builds, but you can override them.\n\n' + + 'Vite-based Remix apps should use `sentryRemixVitePlugin()` from `@sentry/remix/vite` instead,\n' + + 'which injects debug IDs and uploads source maps as part of the build. Running both injects a\n' + + 'second debug ID per chunk, which breaks source map resolution.\n\n' + 'If you need a more advanced configuration, you can use `sentry-cli` instead.\n' + 'https://github.com/getsentry/sentry-cli', ) diff --git a/packages/remix/src/vite/index.ts b/packages/remix/src/vite/index.ts index 3689266f2f44..b38abdda0c80 100644 --- a/packages/remix/src/vite/index.ts +++ b/packages/remix/src/vite/index.ts @@ -1,6 +1,7 @@ import type { Plugin } from 'vite'; import { makeOrchestrionPlugin } from './orchestrionPlugin'; import { makeRouteManifestPlugin } from './routeManifestPlugin'; +import { makeAddSentryVitePlugin, makeEnableSourceMapsPlugin } from './sourceMaps'; import type { SentryRemixVitePluginOptions } from './types'; export type { SentryRemixVitePluginOptions }; @@ -9,8 +10,9 @@ export type { SentryRemixVitePluginOptions }; * Sentry Vite plugins for Remix. * * Add these to your Vite configuration to - * - inject the Remix route manifest, so client-side transactions are parameterized, and - * - build-time instrument supported server-side dependencies (such as database clients). + * - inject the Remix route manifest, so client-side transactions are parameterized, + * - build-time instrument supported server-side dependencies (such as database clients), and + * - inject debug IDs and upload source maps to Sentry. * * @example * ```typescript @@ -24,11 +26,34 @@ export type { SentryRemixVitePluginOptions }; * remix(), * sentryRemixVitePlugin({ * appDirPath: './app', + * org: 'your-org', + * project: 'your-project', + * authToken: process.env.SENTRY_AUTH_TOKEN, * }), * ], * }); * ``` */ export function sentryRemixVitePlugin(options: SentryRemixVitePluginOptions = {}): Plugin[] { - return [makeRouteManifestPlugin(options), makeOrchestrionPlugin(options)]; + const plugins: Plugin[] = [makeRouteManifestPlugin(options), makeOrchestrionPlugin(options)]; + + // Uploading from the dev server would create a new set of artifacts on every restart. + if (process.env.NODE_ENV === 'development') { + return plugins; + } + + // Added even when source maps are disabled: the bundler plugin also applies bundle size + // optimizations, module metadata, the application key and release management, and it already + // skips the upload itself. + // + // Order matters: Vite passes the already-merged config to every `config` hook, so the deletion + // plugin has to read `build.sourcemap` before `makeEnableSourceMapsPlugin` sets it to 'hidden'. + plugins.push(...makeAddSentryVitePlugin(options)); + + // `'disable-upload'` still generates them - debug IDs are injected and the user uploads by hand. + if (options.sourcemaps?.disable !== true) { + plugins.push(makeEnableSourceMapsPlugin(options)); + } + + return plugins; } diff --git a/packages/remix/src/vite/sourceMaps.ts b/packages/remix/src/vite/sourceMaps.ts new file mode 100644 index 000000000000..e53860f39c28 --- /dev/null +++ b/packages/remix/src/vite/sourceMaps.ts @@ -0,0 +1,189 @@ +import * as path from 'path'; +import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; +import type { Plugin, UserConfig } from 'vite'; +import type { SentryRemixVitePluginOptions } from './types'; + +type FilesToDeleteAfterUpload = string | string[] | undefined; + +/** + * Adds the Sentry bundler plugin, which injects debug IDs and uploads source maps to Sentry. + */ +export function makeAddSentryVitePlugin(options: SentryRemixVitePluginOptions): Plugin[] { + const { + applicationKey, + authToken, + bundleSizeOptimizations, + debug, + errorHandler, + headers, + moduleMetadata, + org, + project, + release, + sentryUrl, + silent, + sourcemaps, + telemetry, + } = options; + + // The default depends on `build.sourcemap`, which is only known once Vite resolves the config. + let resolveFilesToDeleteAfterUpload: ((value: FilesToDeleteAfterUpload) => void) | undefined; + const filesToDeleteAfterUploadPromise = new Promise(resolve => { + resolveFilesToDeleteAfterUpload = resolve; + }); + + const configPlugin: Plugin = { + name: 'sentry-remix-files-to-delete-after-upload', + apply: 'build', + enforce: 'post', + config(config) { + const userFilesToDelete = sourcemaps?.filesToDeleteAfterUpload; + + // Only clean up after ourselves: if the user asked for source maps, they are theirs to keep. + // Scoped to the build output rather than `./**/*.map`, which the bundler plugin globs without + // ignoring `node_modules` and then deletes with `force: true`. + // + // `disable: 'disable-upload'` injects debug IDs but leaves uploading to the user, and the + // bundler plugin deletes in a `finally` block even when it skipped the upload - so defaulting + // the deletion there would remove the very maps they still have to upload by hand. + if ( + typeof userFilesToDelete === 'undefined' && + typeof config.build?.sourcemap === 'undefined' && + !sourcemaps?.disable + ) { + // Derived from the configured `outDir` rather than hardcoding `build`, so a custom Remix + // `buildDirectory` still gets cleaned up. Remix runs a client and an SSR build, each with + // its own `outDir`, so each pass deletes only what it emitted. + // + // Separators are normalised because `glob` reads a backslash as an escape, and both path + // flavours are checked because `path.isAbsolute` only recognises the host platform's form. + // Only a relative path may take the `./` prefix: `.//abs/path/**/*.map` matches nothing. + const outDir = (config.build?.outDir || 'build').replace(/\\/g, '/').replace(/\/+$/, ''); + const isAbsoluteOutDir = path.posix.isAbsolute(outDir) || path.win32.isAbsolute(outDir); + const outDirGlob = isAbsoluteOutDir ? outDir : `./${outDir.replace(/^\.\//, '')}`; + const filesToDelete = [`${outDirGlob}/**/*.map`]; + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify( + filesToDelete, + )}\` to delete generated source maps after they were uploaded to Sentry.`, + ); + } + resolveFilesToDeleteAfterUpload?.(filesToDelete); + } else { + resolveFilesToDeleteAfterUpload?.(userFilesToDelete); + } + }, + }; + + const sentryPlugins = sentryVitePlugin({ + applicationKey, + authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, + bundleSizeOptimizations, + debug: debug ?? false, + errorHandler, + headers, + moduleMetadata, + org: org ?? process.env.SENTRY_ORG, + project: project ?? process.env.SENTRY_PROJECT, + release, + silent, + sourcemaps: { + assets: sourcemaps?.assets, + disable: sourcemaps?.disable, + ignore: sourcemaps?.ignore, + rewriteSources: sourcemaps?.rewriteSources, + resolveSourceMap: sourcemaps?.resolveSourceMap, + filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, + }, + telemetry: telemetry ?? true, + url: sentryUrl, + _metaOptions: { + telemetry: { + metaFramework: 'remix', + }, + }, + }); + + return [configPlugin, ...sentryPlugins]; +} + +/** + * Enables "hidden" source maps if the user did not configure them. + */ +export function makeEnableSourceMapsPlugin(options: SentryRemixVitePluginOptions): Plugin { + return { + name: 'sentry-remix-update-source-map-setting', + apply: 'build', + enforce: 'post', + // Returning only the changed key: Vite concatenates arrays when merging a `config` return + // value, so echoing the whole config back duplicates `ssr.noExternal`, `resolve.alias` etc. + config(viteConfig) { + return { + build: { + sourcemap: getUpdatedSourceMapSettings(viteConfig, options), + }, + }; + }, + }; +} + +/** There are 3 ways to set up source map generation (https://github.com/getsentry/sentry-javascript/issues/13993) + * + * 1. User explicitly disabled source maps + * - keep this setting (emit a warning that errors won't be unminified in Sentry) + * - we won't upload anything + * + * 2. Users enabled source map generation (true, 'hidden', 'inline'). + * - keep this setting (don't do anything - like deletion - besides uploading) + * + * 3. Users didn't set source maps generation + * - we enable 'hidden' source maps generation + * - configure `filesToDeleteAfterUpload` to delete all .map files (we emit a log about this) + * + * --> only exported for testing + */ +export function getUpdatedSourceMapSettings( + viteConfig: UserConfig, + sentryPluginOptions?: SentryRemixVitePluginOptions, +): boolean | 'inline' | 'hidden' { + const viteUserSourceMapSetting = viteConfig.build?.sourcemap; + const settingKey = 'vite.build.sourcemap'; + const debug = sentryPluginOptions?.debug; + + if (viteUserSourceMapSetting === false) { + if (debug) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] Source map generation is currently disabled in your Vite configuration (\`${settingKey}: false\`). Sentry won't override this setting. Without source maps, code snippets on the Sentry Issues page will remain minified.`, + ); + } else { + // eslint-disable-next-line no-console + console.warn('[Sentry] Source map generation is disabled in your Vite configuration.'); + } + + return viteUserSourceMapSetting; + } + + if (viteUserSourceMapSetting && ['hidden', 'inline', true].includes(viteUserSourceMapSetting)) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] We discovered \`${settingKey}\` is set to \`${viteUserSourceMapSetting.toString()}\`. Sentry will keep this source map setting.`, + ); + } + + return viteUserSourceMapSetting; + } + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Enabled source map generation in the build options with \`${settingKey}: 'hidden'\`. The source maps will be deleted after they were uploaded to Sentry.`, + ); + } + + return 'hidden'; +} diff --git a/packages/remix/src/vite/types.ts b/packages/remix/src/vite/types.ts index cf12a0fb3372..45a8ac76f572 100644 --- a/packages/remix/src/vite/types.ts +++ b/packages/remix/src/vite/types.ts @@ -1,4 +1,6 @@ -export type SentryRemixVitePluginOptions = { +import type { BuildTimeOptionsBase } from '@sentry/core'; + +export type SentryRemixVitePluginOptions = BuildTimeOptionsBase & { /** * Path to the app directory (where routes folder is located). * Can be relative to project root or absolute. @@ -8,15 +10,4 @@ export type SentryRemixVitePluginOptions = { * @example '/absolute/path/to/app' */ appDirPath?: string; - - /** - * Build-time instrumentation of server-side dependencies (e.g. `mysql`, `ioredis`, - * `@remix-run/server-runtime`): the plugin injects `diagnostics_channel` publishers into the - * bundled SSR output, so the SDK traces them without monkey-patching. - * - * Set to `false` to opt out. - * - * @default true - */ - buildTimeInstrumentation?: boolean; }; diff --git a/packages/remix/test/vite/index.test.ts b/packages/remix/test/vite/index.test.ts index efaff6ddda83..c105a19789df 100644 --- a/packages/remix/test/vite/index.test.ts +++ b/packages/remix/test/vite/index.test.ts @@ -26,6 +26,27 @@ vi.mock('@sentry/server-utils/orchestrion/vite', () => ({ sentryOrchestrionPlugin: (options?: { buildTimeInstrumentation?: boolean }) => orchestrionVite(options), })); +type CapturedSentryOptions = { + applicationKey?: string; + bundleSizeOptimizations?: { excludeTracing?: boolean }; + sourcemaps?: { filesToDeleteAfterUpload?: Promise }; +}; + +let capturedSentryOptions: CapturedSentryOptions | undefined; + +vi.mock('@sentry/bundler-plugins/vite', () => ({ + sentryVitePlugin: (options: CapturedSentryOptions) => { + capturedSentryOptions = options; + return [{ name: 'sentry-vite-plugin' }]; + }, +})); + +const SOURCE_MAP_PLUGINS = [ + 'sentry-remix-files-to-delete-after-upload', + 'sentry-vite-plugin', + 'sentry-remix-update-source-map-setting', +]; + const NODE_CONFIG = { ssr: { target: 'node' } } as UserConfig; const WORKER_CONFIG = { ssr: { target: 'webworker' } } as UserConfig; // Remix's own Vite plugin never sets `ssr.target`, so a Cloudflare app is only recognizable by its @@ -48,15 +69,78 @@ function callHook(hook: unknown, ...args: unknown[]): unknown { describe('sentryRemixVitePlugin', () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); + capturedSentryOptions = undefined; }); - it('returns the route manifest plugin and the orchestrion plugin', () => { + // Vite hands every `config` hook the already-merged config, so the deletion plugin has to read + // `build.sourcemap` before `makeEnableSourceMapsPlugin` sets it. Running the hooks in isolation + // hides that, which is why this drives them in plugin order over one shared config. + it('still deletes the generated source maps once the hooks run in plugin order', async () => { const plugins = sentryRemixVitePlugin(); + const config: UserConfig = {}; - expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-remix-route-manifest', 'code-transformer']); + for (const plugin of plugins) { + if (!plugin.config) { + continue; + } + + const result = callHook(plugin.config, config, BUILD_ENV) as UserConfig | null; + const sourcemap = result?.build?.sourcemap; + + if (sourcemap !== undefined) { + config.build = { ...config.build, sourcemap }; + } + } + + await expect(capturedSentryOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./build/**/*.map']); + }); + + it('returns the route manifest, orchestrion and source map plugins', () => { + const plugins = sentryRemixVitePlugin(); + + expect(plugins.map(plugin => plugin.name)).toEqual([ + 'sentry-remix-route-manifest', + 'code-transformer', + ...SOURCE_MAP_PLUGINS, + ]); expect(orchestrionVite).toHaveBeenCalledWith({ buildTimeInstrumentation: undefined }); }); + // Uploading from the dev server would upload a new set of artifacts on every restart. + it('leaves out the source map plugins in development', () => { + vi.stubEnv('NODE_ENV', 'development'); + + const plugins = sentryRemixVitePlugin(); + + expect(plugins.map(plugin => plugin.name)).toEqual(['sentry-remix-route-manifest', 'code-transformer']); + }); + + // Disabling source maps must not drop the bundler plugin: it also applies bundle size + // optimizations, module metadata, the application key and release management, and it skips the + // upload on its own. + it('only drops the source map setting plugin when source maps are disabled', () => { + const plugins = sentryRemixVitePlugin({ sourcemaps: { disable: true } }); + + expect(plugins.map(plugin => plugin.name)).toEqual([ + 'sentry-remix-route-manifest', + 'code-transformer', + 'sentry-remix-files-to-delete-after-upload', + 'sentry-vite-plugin', + ]); + }); + + it('forwards the non-source-map options when source maps are disabled', () => { + sentryRemixVitePlugin({ + sourcemaps: { disable: true }, + applicationKey: 'my-app-key', + bundleSizeOptimizations: { excludeTracing: true }, + }); + + expect(capturedSentryOptions?.applicationKey).toBe('my-app-key'); + expect(capturedSentryOptions?.bundleSizeOptimizations).toEqual({ excludeTracing: true }); + }); + it('adds an inert orchestrion plugin when `buildTimeInstrumentation` is `false`', () => { const plugins = sentryRemixVitePlugin({ buildTimeInstrumentation: false }); @@ -64,6 +148,13 @@ describe('sentryRemixVitePlugin', () => { expect(plugins.map(plugin => plugin.name)).toContain('sentry-orchestrion-disabled'); }); + // Turning off the build-time transform says nothing about source maps. + it('keeps the source map plugins when `buildTimeInstrumentation` is `false`', () => { + const plugins = sentryRemixVitePlugin({ buildTimeInstrumentation: false }); + + expect(plugins.map(plugin => plugin.name)).toEqual(expect.arrayContaining(SOURCE_MAP_PLUGINS)); + }); + it('keeps the upstream `enforce: "pre"` but defers its `config` hook to the end', () => { const orchestrion = sentryRemixVitePlugin()[1] as { enforce?: string; config?: { order?: string } }; diff --git a/packages/remix/test/vite/sourceMaps.test.ts b/packages/remix/test/vite/sourceMaps.test.ts new file mode 100644 index 000000000000..4e400e2b8305 --- /dev/null +++ b/packages/remix/test/vite/sourceMaps.test.ts @@ -0,0 +1,209 @@ +import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { UserConfig } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getUpdatedSourceMapSettings, + makeAddSentryVitePlugin, + makeEnableSourceMapsPlugin, +} from '../../src/vite/sourceMaps'; + +let capturedOptions: SentryVitePluginOptions | undefined; + +const sentryVitePluginSpy = vi.fn((options: SentryVitePluginOptions) => { + capturedOptions = options; + return [{ name: 'sentry-vite-plugin' }]; +}); + +vi.mock('@sentry/bundler-plugins/vite', () => ({ + sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), +})); + +beforeEach(() => { + vi.clearAllMocks(); + capturedOptions = undefined; + delete process.env.SENTRY_ORG; + delete process.env.SENTRY_PROJECT; + delete process.env.SENTRY_AUTH_TOKEN; +}); + +describe('makeEnableSourceMapsPlugin', () => { + it('returns a build-time plugin that updates the source map setting', () => { + const plugin = makeEnableSourceMapsPlugin({}); + + expect(plugin.name).toBe('sentry-remix-update-source-map-setting'); + expect(plugin.apply).toBe('build'); + expect(plugin.enforce).toBe('post'); + }); + + it('enables hidden source maps through its config hook', () => { + const plugin = makeEnableSourceMapsPlugin({}); + const config = plugin.config as (config: UserConfig) => UserConfig; + + expect(config({}).build?.sourcemap).toBe('hidden'); + }); +}); + +describe('getUpdatedSourceMapSettings', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + it('keeps source maps disabled when the user turned them off', () => { + expect(getUpdatedSourceMapSettings({ build: { sourcemap: false } })).toBe(false); + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Source map generation is disabled')); + }); + + it.each([['hidden'], ['inline'], [true]] as ('hidden' | 'inline' | boolean)[][])( + 'keeps the explicit `%s` setting', + setting => { + expect(getUpdatedSourceMapSettings({ build: { sourcemap: setting } })).toBe(setting); + }, + ); + + it.each([[undefined], ['invalid'], [null]])('enables hidden source maps when the setting is %s', setting => { + expect(getUpdatedSourceMapSettings({ build: { sourcemap: setting as any } })).toBe('hidden'); + }); + + it('enables hidden source maps when there is no build config', () => { + expect(getUpdatedSourceMapSettings({})).toBe('hidden'); + }); +}); + +describe('makeAddSentryVitePlugin', () => { + it('forwards the user-specified options to the bundler plugin', () => { + const errorHandler = vi.fn(); + + makeAddSentryVitePlugin({ + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + sentryUrl: 'https://custom.sentry.io', + headers: { 'X-Custom-Header': 'value' }, + silent: true, + errorHandler, + release: { name: 'my-release' }, + bundleSizeOptimizations: { excludeTracing: true }, + }); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'my-org', + project: 'my-project', + authToken: 'my-token', + url: 'https://custom.sentry.io', + headers: { 'X-Custom-Header': 'value' }, + silent: true, + errorHandler, + release: { name: 'my-release' }, + bundleSizeOptimizations: { excludeTracing: true }, + }), + ); + }); + + it('falls back to the Sentry environment variables', () => { + process.env.SENTRY_ORG = 'env-org'; + process.env.SENTRY_PROJECT = 'env-project'; + process.env.SENTRY_AUTH_TOKEN = 'env-token'; + + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ org: 'env-org', project: 'env-project', authToken: 'env-token' }), + ); + }); + + it('reports remix as the metaFramework', () => { + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ _metaOptions: { telemetry: { metaFramework: 'remix' } } }), + ); + }); + + // Unlike React Router - which uploads from `sentryOnBuildEnd` and therefore has to keep the + // bundler plugin's own upload switched off - this plugin *is* the uploader, so `disable` must + // stay whatever the user chose. + it('leaves the source map upload enabled', () => { + makeAddSentryVitePlugin({}); + + expect(capturedOptions?.sourcemaps?.disable).toBeUndefined(); + }); + + it('deletes the generated source maps when the user configured neither setting', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({}); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./build/**/*.map']); + }); + + // Remix's `buildDirectory` is configurable, and it runs a client and an SSR build with their own + // `outDir`s - a hardcoded `./build/**/*.map` would leave a custom output directory's maps on disk. + it('scopes the deletion glob to the configured outDir', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { outDir: 'dist/client' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./dist/client/**/*.map']); + }); + + // Vite resolves `outDir` to an absolute path by the time this hook runs, and `.//abs/path` would + // match nothing - leaving the maps in the production output. + it('does not prefix an absolute outDir', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { outDir: '/tmp/app/build/client' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual([ + '/tmp/app/build/client/**/*.map', + ]); + }); + + // `glob` reads a backslash as an escape, so an unnormalised Windows `outDir` matches nothing and + // leaves the maps on disk. Both path flavours are checked, so this holds on POSIX runners too. + it('normalises a Windows absolute outDir', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { outDir: 'C:\\proj\\build\\client' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual([ + 'C:/proj/build/client/**/*.map', + ]); + }); + + it('keeps the source maps when the user set their own build.sourcemap', async () => { + const plugins = makeAddSentryVitePlugin({}); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({ build: { sourcemap: 'hidden' } }); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toBeUndefined(); + }); + + // `disable: 'disable-upload'` injects debug IDs but leaves the upload to the user. The bundler + // plugin deletes in a `finally` block even when it skipped uploading, so defaulting the deletion + // here would remove the maps they still have to upload by hand. + it('keeps the source maps when only the upload is disabled', async () => { + const plugins = makeAddSentryVitePlugin({ sourcemaps: { disable: 'disable-upload' } }); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({}); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toBeUndefined(); + }); + + it('honours a user-specified filesToDeleteAfterUpload', async () => { + const plugins = makeAddSentryVitePlugin({ sourcemaps: { filesToDeleteAfterUpload: ['./dist/**/*.map'] } }); + const configPlugin = plugins.find(plugin => plugin.name === 'sentry-remix-files-to-delete-after-upload'); + + (configPlugin?.config as (config: UserConfig) => void)({}); + + await expect(capturedOptions?.sourcemaps?.filesToDeleteAfterUpload).resolves.toEqual(['./dist/**/*.map']); + }); +}); From ee796df411985d5270e62cebf7128e54f2af78ec Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 18 Sep 2026 15:06:55 +0200 Subject: [PATCH 20/39] test(remix): Add source map upload e2e test app (#24464) Asserts each client chunk carries exactly one debug ID and that the shipped IDs match the uploaded artifact bundles. Stacked on https://github.com/getsentry/sentry-javascript/pull/24463. --------- Co-authored-by: Claude Opus 5 --- .../remix-sourcemaps/.gitignore | 6 + .../remix-sourcemaps/app/entry.client.tsx | 12 ++ .../remix-sourcemaps/app/root.tsx | 19 +++ .../remix-sourcemaps/app/routes/_index.tsx | 3 + .../remix-sourcemaps/assert-build.ts | 128 ++++++++++++++++++ .../remix-sourcemaps/package.json | 33 +++++ .../start-mock-sentry-server.mjs | 3 + .../remix-sourcemaps/tsconfig.json | 20 +++ .../remix-sourcemaps/vite.config.ts | 21 +++ 9 files changed, 245 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/remix-sourcemaps/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/.gitignore new file mode 100644 index 000000000000..a7bced322ecd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/build +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx new file mode 100644 index 000000000000..9a290b88c9a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { RemixBrowser } from '@remix-run/react'; +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/root.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/root.tsx new file mode 100644 index 000000000000..9081dbd5c5c6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/root.tsx @@ -0,0 +1,19 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react'; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx new file mode 100644 index 000000000000..ea184ec80a18 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/app/routes/_index.tsx @@ -0,0 +1,3 @@ +export default function Index() { + return

Sourcemaps test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..7f4ae319867b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/assert-build.ts @@ -0,0 +1,128 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils'; + +const BUILD_DIR = 'build'; +const CLIENT_ASSETS_DIR = 'build/client/assets'; + +// Both injectors write this assignment, so counting it per file counts injections regardless of +// which one ran. Matching only the bundler plugin's trailing `_sentryDebugIdIdentifier` would miss +// the `sentry-cli` snippet, which omits it. +const DEBUG_ID_ASSIGNMENT = + /_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi; + +function getClientChunks(): string[] { + assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); + + return fs + .readdirSync(CLIENT_ASSETS_DIR) + .filter(file => file.endsWith('.js')) + .map(file => path.join(CLIENT_ASSETS_DIR, file)); +} + +const chunks = getClientChunks(); +assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`); + +// 1. No chunk carries more than one debug ID. +// +// Two injections per chunk is the failure mode of +// https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at runtime, +// `applyDebugIds` flattens them to a single filename, and the last one wins. For Remix that happens +// when an app keeps `npx @sentry/remix --upload-sourcemaps` in its build script alongside the Vite +// plugin, so the CLI injects a second ID over the plugin's. +const injectedDebugIds = new Map(); + +for (const chunk of chunks) { + const code = fs.readFileSync(chunk, 'utf-8'); + const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); + + assert.ok( + ids.length <= 1, + `Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}.`, + ); + + if (ids.length === 1) { + injectedDebugIds.set(chunk, ids[0] as string); + } +} + +console.log(`no client chunk carries more than one debug ID (${injectedDebugIds.size}/${chunks.length} carry one)\n`); + +const requests = loadMockServerResults(); +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle'); + +// 2. Source maps with real content reached Sentry. +// +// Asserting on the upload rather than on disk, because deleting the maps after a successful upload +// is the intended behaviour - the plugin defaults `filesToDeleteAfterUpload` when the app does not +// configure source maps itself. +const uploadedSourcemaps = getSourcemaps(bundles); +assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles'); +assert.ok( + uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0), + 'Expected at least one uploaded source map with non-empty mappings', +); +console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`); + +// 3. The debug IDs that shipped are the ones that were uploaded. +// +// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug ID that +// has no artifact bundle behind it. +const debugIdPairs = getDebugIdPairs(bundles); +const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID'); + +// The uploaded artifacts are named after the debug ID (`~/-.js`), not after the chunk +// they came from, so the two file name sets never line up. Cross-check the IDs themselves: every +// debug ID that shipped has to have an artifact bundle behind it. +let crossCheckedChunks = 0; + +for (const [chunk, injectedDebugId] of injectedDebugIds) { + assert.ok( + uploadedDebugIds.has(injectedDebugId.toLowerCase()), + `Debug ID ${injectedDebugId} in ${chunk} was never uploaded.\n` + + `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}`, + ); + crossCheckedChunks++; +} + +assert.ok( + crossCheckedChunks > 0, + 'Expected at least one chunk carrying a debug ID to cross-check against the upload.\n' + + `Client chunks: ${JSON.stringify(chunks.map(chunk => path.basename(chunk)))}\n` + + `Uploaded debug IDs: ${JSON.stringify([...uploadedDebugIds])}\n` + + `Uploaded JS urls: ${JSON.stringify(debugIdPairs.map(pair => pair.jsUrl))}`, +); +console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`); + +// 4. No source map survived the build. +// +// The plugin defaults `filesToDeleteAfterUpload` when the app configures no source map setting, so +// a leftover `.map` means one of the builds was never cleaned up. Remix runs a client and an SSR +// pass with different `outDir`s, while the deletion glob is held in a promise that settles once - +// this is what catches the second pass being left behind. +function findSourceMaps(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return findSourceMaps(entryPath); + } + + return entry.name.endsWith('.map') ? [entryPath] : []; + }); +} + +const leftoverSourceMaps = findSourceMaps(BUILD_DIR); +assert.deepEqual( + leftoverSourceMaps, + [], + `Expected every source map to be deleted after upload, found ${leftoverSourceMaps.length}:\n${leftoverSourceMaps.join( + '\n', + )}`, +); +console.log('no source maps left in the build output\n'); + +console.log('All remix source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json new file mode 100644 index 000000000000..d1eb5cdce759 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/package.json @@ -0,0 +1,33 @@ +{ + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & remix vite:build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; cat .tmp_build_stdout .tmp_build_stderr; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm tsx assert-build.ts" + }, + "dependencies": { + "@sentry/remix": "file:../../packed/sentry-remix-packed.tgz", + "@remix-run/node": "2.17.4", + "@remix-run/react": "2.17.4", + "@remix-run/serve": "2.17.4", + "isbot": "^3.6.8", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@remix-run/dev": "2.17.4", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.34", + "tsx": "^4.23.0", + "typescript": "^5.1.6", + "vite": "^5.4.11" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..69a346a1e440 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ port: 3033 }); diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..144b70c821b3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["./app/**/*.ts", "./app/**/*.tsx", "./*.ts"], + "exclude": ["node_modules", "build"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "bundler", + "module": "ES2022", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + } +} diff --git a/dev-packages/e2e-tests/test-applications/remix-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/vite.config.ts new file mode 100644 index 000000000000..0229581eda4f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-sourcemaps/vite.config.ts @@ -0,0 +1,21 @@ +import { vitePlugin as remix } from '@remix-run/dev'; +import { sentryRemixVitePlugin } from '@sentry/remix/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + remix({ + ignoredRouteFiles: ['**/.*'], + }), + sentryRemixVitePlugin({ + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + sentryUrl: 'http://localhost:3033', + release: { + name: 'test-release', + }, + debug: true, + }), + ], +}); From 878b685c85dc12045e47e1932c96f5381d7ac532 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 18 Sep 2026 15:53:00 +0200 Subject: [PATCH 21/39] fix(nextjs): Align tunnel request matching in middleware with tunnel rewrite (#24499) The middleware wrapper treated every path under the tunnel route as a tunnel request, while the tunnel rewrite only serves the exact route with the org and project query params. Match tunnel requests the same way the rewrite does so the two stay consistent. Fixes JS-3719 Co-authored-by: Claude Fable 5.1 --- .../src/common/utils/tunnelPathnameMatch.ts | 25 +++++++++++++++ .../src/common/wrapMiddlewareWithSentry.ts | 26 ++++++--------- packages/nextjs/test/config/wrappers.test.ts | 32 +++++++++++++++---- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts index 9f107d33636c..ce8cacd0d264 100644 --- a/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts +++ b/packages/nextjs/src/common/utils/tunnelPathnameMatch.ts @@ -6,3 +6,28 @@ export function isPathnameUnderSentryTunnelRoute(pathname: string, tunnelPath: string): boolean { return pathname === tunnelPath || pathname.startsWith(`${tunnelPath}/`); } + +/** + * Returns true only for requests the tunnel rewrite (see `setUpTunnelRewriteRules`) would serve. + * + * This decides whether the user's middleware is skipped, so it must never be broader than the rewrite: + * anything it matches that Next.js does not rewrite to Sentry reaches the app without middleware. + */ +export function isSentryTunnelRequest(request: Request, tunnelPath: string): boolean { + // The SDK transport only ever sends POST requests + if (request.method !== 'POST') { + return false; + } + + const url = new URL(request.url); + + if (url.pathname !== tunnelPath && url.pathname !== `${tunnelPath}/`) { + return false; + } + + // Next.js evaluates `has` conditions against the last value of a repeated query param, so every value has to qualify + return ['o', 'p'].every(key => { + const values = url.searchParams.getAll(key); + return values.length > 0 && values.every(value => /^\d+$/.test(value)); + }); +} diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 04d5ba04ae80..c9367433b123 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -9,7 +9,7 @@ import { withIsolationScope, } from '@sentry/core'; import { flushSafelyWithTimeout, waitUntil } from '../common/utils/responseEnd'; -import { isPathnameUnderSentryTunnelRoute } from '../common/utils/tunnelPathnameMatch'; +import { isSentryTunnelRequest } from '../common/utils/tunnelPathnameMatch'; import type { EdgeRouteHandler } from '../edge/types'; /** @@ -35,21 +35,15 @@ export function wrapMiddlewareWithSentry( // TODO: This can never work with Turbopack, need to remove it for consistency between builds. if (tunnelRoute && typeof tunnelRoute === 'string') { const req: unknown = args[0]; - // Check if the current request matches the tunnel route - if (req instanceof Request) { - const url = new URL(req.url); - const isTunnelRequest = isPathnameUnderSentryTunnelRoute(url.pathname, tunnelRoute); - - if (isTunnelRequest) { - // Create a simple response that mimics NextResponse.next() so we don't need to import Next.js internals here - // https://github.com/vercel/next.js/blob/c12c9c1f78ad384270902f0890dc4cd341408105/packages/next/src/server/web/spec-extension/response.ts#L146 - return new Response(null, { - status: 200, - headers: { - 'x-middleware-next': '1', - }, - }) as ReturnType; - } + if (req instanceof Request && isSentryTunnelRequest(req, tunnelRoute)) { + // Create a simple response that mimics NextResponse.next() so we don't need to import Next.js internals here + // https://github.com/vercel/next.js/blob/c12c9c1f78ad384270902f0890dc4cd341408105/packages/next/src/server/web/spec-extension/response.ts#L146 + return new Response(null, { + status: 200, + headers: { + 'x-middleware-next': '1', + }, + }) as ReturnType; } } diff --git a/packages/nextjs/test/config/wrappers.test.ts b/packages/nextjs/test/config/wrappers.test.ts index 7d5f4029bd94..e2059853ce96 100644 --- a/packages/nextjs/test/config/wrappers.test.ts +++ b/packages/nextjs/test/config/wrappers.test.ts @@ -112,7 +112,7 @@ describe('wrapMiddlewareWithSentry', () => { const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); // Create a mock Request that matches the tunnel route - const mockRequest = new Request('https://example.com/monitoring/tunnel?o=123'); + const mockRequest = new Request('https://example.com/monitoring/tunnel?o=123&p=456', { method: 'POST' }); const result = await wrappedOriginal(mockRequest); @@ -209,17 +209,37 @@ describe('wrapMiddlewareWithSentry', () => { expect(result).toBe(mockReturnValue); }); - test('should skip processing for tunnel sub-paths under tunnelRoute', async () => { - (globalThis as any)._sentryRewritesTunnelPath = '/api/t'; + test('should skip processing for the tunnel route with a trailing slash', async () => { + (globalThis as any)._sentryRewritesTunnelPath = '/monitoring'; const origFunction: EdgeRouteHandler = vi.fn(async () => ({ status: 200 })); const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); - const mockRequest = new Request('https://example.com/api/t/envelope?o=1'); + await wrappedOriginal(new Request('https://example.com/monitoring/?o=123&p=456&r=us', { method: 'POST' })); + + expect(origFunction).not.toHaveBeenCalled(); + }); + + test.each([ + ['a sub-path of the tunnel route', 'https://example.com/monitoring/anything/at/all?o=123&p=456', 'POST'], + ['a tunnel request without query params', 'https://example.com/monitoring', 'POST'], + ['a tunnel request without project id', 'https://example.com/monitoring?o=123', 'POST'], + ['a tunnel request with non-numeric ids', 'https://example.com/monitoring?o=abc&p=456', 'POST'], + ['a tunnel request with a repeated non-numeric org id', 'https://example.com/monitoring?o=123&o=abc&p=456', 'POST'], + ['a tunnel request with a repeated empty project id', 'https://example.com/monitoring?o=123&p=456&p=', 'POST'], + ['a non-POST tunnel request', 'https://example.com/monitoring?o=123&p=456', 'GET'], + ])('should run the middleware for %s', async (_, url, method) => { + (globalThis as any)._sentryRewritesTunnelPath = '/monitoring'; + + const mockReturnValue = { status: 200 }; + const origFunction: EdgeRouteHandler = vi.fn(async (..._args) => mockReturnValue); + const wrappedOriginal = wrapMiddlewareWithSentry(origFunction); + + const mockRequest = new Request(url, { method }); const result = await wrappedOriginal(mockRequest); - expect(origFunction).not.toHaveBeenCalled(); - expect(result).toBeDefined(); + expect(origFunction).toHaveBeenCalledWith(mockRequest); + expect(result).toBe(mockReturnValue); }); }); From cb23872cb4b854b4aafa3ade8468be33e391b8e2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 18 Sep 2026 16:26:07 +0200 Subject: [PATCH 22/39] feat(browser)!: Report `browser.navigation.type` exactly as web-vitals does (#24479) The attribute folded `back-forward` and `restore` into `navigate` and renamed `back-forward-cache` to `bfcache`, which is lossy in a direction nobody can undo server-side. I changed the mapping test to assert every web-vitals navigation type is reported verbatim on the attribute. On the product side, the filter will account for all these unfolded values. --- .../browser-bfcache/tests/bfcache.test.ts | 6 ++-- .../instrumentation/performanceObserver.ts | 3 +- .../browser-utils/src/web-vitals/emitSpan.ts | 16 +-------- .../browser-utils/src/web-vitals/spans.ts | 4 +-- .../test/web-vitals/spans.test.ts | 33 +++++++++---------- .../browser/src/integrations/webVitals.ts | 2 +- .../src/tracing/browserTracingIntegration.ts | 2 +- .../tracing/browserTracingIntegration.test.ts | 2 +- 8 files changed, 26 insertions(+), 42 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts index 4d72591e9249..31e6c83eabbe 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/tests/bfcache.test.ts @@ -372,7 +372,7 @@ test.describe('the navigation span for a restore', () => { attributes: { 'sentry.op': { type: 'string', value: 'navigation' }, 'sentry.origin': { type: 'string', value: 'auto.navigation.browser.bfcache' }, - 'browser.navigation.type': { type: 'string', value: 'bfcache' }, + 'browser.navigation.type': { type: 'string', value: 'back-forward-cache' }, }, }); }); @@ -467,7 +467,7 @@ test.describe('the navigation span for a restore', () => { const inpSpan = spans.find(span => getSpanOp(span) === 'ui.interaction.click')!; expect(restoreSpan.attributes).toMatchObject({ - 'browser.navigation.type': { type: 'string', value: 'bfcache' }, + 'browser.navigation.type': { type: 'string', value: 'back-forward-cache' }, }); // All three hang off the restore itself. They also carry the `bfcache` navigation type, so the @@ -475,7 +475,7 @@ test.describe('the navigation span for a restore', () => { for (const vital of [lcpSpan, clsSpan, inpSpan]) { expect(vital.parent_span_id).toBe(restoreSpan.span_id); expect(vital.attributes).toMatchObject({ - 'browser.navigation.type': { type: 'string', value: 'bfcache' }, + 'browser.navigation.type': { type: 'string', value: 'back-forward-cache' }, }); } }); diff --git a/packages/browser-utils/src/instrumentation/performanceObserver.ts b/packages/browser-utils/src/instrumentation/performanceObserver.ts index 7bdefc299c20..4424a5f3ef87 100644 --- a/packages/browser-utils/src/instrumentation/performanceObserver.ts +++ b/packages/browser-utils/src/instrumentation/performanceObserver.ts @@ -59,8 +59,7 @@ export interface PerformanceLongAnimationFrameTiming extends PerformanceEntry { // TypeScript lib version (e.g. `NavigationType`), which leaks into and breaks consumers on older // TS. Keeping this local keeps web-vitals' global augmentations out of our published types. /** - * The navigation types web-vitals reports a metric for. Wider than the set the - * `browser.navigation.type` attribute uses - see `toBrowserNavigationType`. + * The navigation types web-vitals reports a metric for, reported as-is on `browser.navigation.type`. */ export type MetricNavigationType = | 'navigate' diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts index 3a32e1eddd3c..890de057b6a6 100644 --- a/packages/browser-utils/src/web-vitals/emitSpan.ts +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -21,20 +21,6 @@ import { WINDOW } from '../types'; import type { MetricNavigationType } from '../instrumentation/performanceObserver'; import type { WebVitalReportEvent } from './reportEvents'; -// web-vitals reports a wider set of navigation types than the attribute defines. Only the states -// Navigation Timing cannot express keep their own value; every ordinary document navigation folds -// into `navigate`, including a back/forward that missed the bfcache and a discarded-tab restore. -const BROWSER_NAVIGATION_TYPES: Partial> = { - reload: 'reload', - prerender: 'prerender', - 'back-forward-cache': 'bfcache', - 'soft-navigation': 'soft-navigation', -}; - -function toBrowserNavigationType(navigationType: MetricNavigationType): string { - return BROWSER_NAVIGATION_TYPES[navigationType] ?? 'navigate'; -} - // Locally-defined interfaces to avoid leaking bare global type references into the // generated .d.ts. The `declare global` augmentations in web-vitals/types.ts make these // available during this package's compilation but are NOT carried to consumers. @@ -134,7 +120,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { } if (navigationType) { - attributes[BROWSER_NAVIGATION_TYPE] = toBrowserNavigationType(navigationType); + attributes[BROWSER_NAVIGATION_TYPE] = navigationType; } // A standalone span is sent as a plain v2 span without running the `processSpan` hooks (see diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index fd5d6ebbd6ac..1996715d51cb 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -73,12 +73,12 @@ function trackWebVitalPerNavigation( let bfcacheNavigationSpan: Span | undefined; client.on('spanStart', span => { // The op has to be checked too: the web vital spans emitted for a restore carry the same - // `bfcache` navigation type, so matching on that alone lets the first of them replace the + // `back-forward-cache` navigation type, so matching on that alone lets the first of them replace the // navigation span, and every later vital then hangs off a sibling vital instead. const attributes = spanToJSON(span).attributes; if ( attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === NAVIGATION && - attributes[BROWSER_NAVIGATION_TYPE] === 'bfcache' + attributes[BROWSER_NAVIGATION_TYPE] === 'back-forward-cache' ) { bfcacheNavigationSpan = span; } diff --git a/packages/browser-utils/test/web-vitals/spans.test.ts b/packages/browser-utils/test/web-vitals/spans.test.ts index b148faa2758a..daad65d7b5cb 100644 --- a/packages/browser-utils/test/web-vitals/spans.test.ts +++ b/packages/browser-utils/test/web-vitals/spans.test.ts @@ -79,7 +79,7 @@ describe('_emitWebVitalSpan', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); // A root span is its own root, which is what the web vital spans are parented to. @@ -338,15 +338,14 @@ describe('_emitWebVitalSpan', () => { }); it.each([ - ['navigate', 'navigate'], - ['reload', 'reload'], - ['prerender', 'prerender'], - ['soft-navigation', 'soft-navigation'], - ['back-forward-cache', 'bfcache'], - // Ordinary document navigations the attribute has no separate value for. - ['back-forward', 'navigate'], - ['restore', 'navigate'], - ] as const)('reports navigationType %s as browser.navigation.type %s', (navigationType, expected) => { + 'navigate', + 'reload', + 'prerender', + 'soft-navigation', + 'back-forward-cache', + 'back-forward', + 'restore', + ] as const)('reports navigationType %s as browser.navigation.type unchanged', navigationType => { _emitWebVitalSpan({ name: 'Test', op: 'ui.webvital.lcp', @@ -359,7 +358,7 @@ describe('_emitWebVitalSpan', () => { expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ - attributes: expect.objectContaining({ 'browser.navigation.type': expected }), + attributes: expect.objectContaining({ 'browser.navigation.type': navigationType }), }), ); }); @@ -593,7 +592,7 @@ describe('_sendInpSpan', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); // A root span is its own root, which is what the web vital spans are parented to. @@ -717,7 +716,7 @@ describe('trackInpAsSpan', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); // A root span is its own root, which is what the web vital spans are parented to. @@ -810,9 +809,9 @@ describe('soft navigation web vitals', () => { vi.mocked(SentryCore.spanToJSON).mockImplementation( (span: any) => (span === bfcacheNavigationSpan - ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'navigation', 'browser.navigation.type': 'back-forward-cache' } } : span === bfcacheVitalSpan - ? { attributes: { 'sentry.op': 'ui.webvital.lcp', 'browser.navigation.type': 'bfcache' } } + ? { attributes: { 'sentry.op': 'ui.webvital.lcp', 'browser.navigation.type': 'back-forward-cache' } } : { attributes: {} }) as any, ); vi.mocked(htmlTreeAsString).mockReturnValue('
'); @@ -930,7 +929,7 @@ describe('soft navigation web vitals', () => { expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith( expect.objectContaining({ parentSpan: bfcacheNavigationSpan, - attributes: expect.objectContaining({ 'browser.navigation.type': 'bfcache' }), + attributes: expect.objectContaining({ 'browser.navigation.type': 'back-forward-cache' }), }), ); expect(SentryCoreBrowser.startInactiveSpan).not.toHaveBeenCalledWith( @@ -961,7 +960,7 @@ describe('soft navigation web vitals', () => { }); it("does not let a restore's own vital span become the parent of the next one", () => { - // Web vital spans for a restore carry the same `bfcache` navigation type as the navigation span + // Web vital spans for a restore carry the same `back-forward-cache` navigation type as the navigation span // they hang off, so the second vital would otherwise be parented to the first. vi.mocked(SentryCore.getActiveSpan).mockReturnValue(undefined); diff --git a/packages/browser/src/integrations/webVitals.ts b/packages/browser/src/integrations/webVitals.ts index 7c1b3d14a9fe..3d62d9146a53 100644 --- a/packages/browser/src/integrations/webVitals.ts +++ b/packages/browser/src/integrations/webVitals.ts @@ -49,7 +49,7 @@ export interface WebVitalsOptions { * * A restore is a new page view measured against a document that was never reloaded, so its vitals * are reported against the navigation span `browserTracingIntegration` starts for the restore, - * and tagged `browser.navigation.type: bfcache`. A restore is near-instant by construction, so + * and tagged `browser.navigation.type: back-forward-cache`. A restore is near-instant by construction, so * these are a distinct population from page load vitals and are meant to be read through that * attribute rather than pooled with them. Set this to `false` to leave restores unmeasured. * diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 080d4154f609..b791dddecefc 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -723,7 +723,7 @@ export const browserTracingIntegration = ((options: Partial { expect.objectContaining({ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser.bfcache', - 'browser.navigation.type': 'bfcache', + 'browser.navigation.type': 'back-forward-cache', }), ); }); From 7cdef8b52b6d0a7a3d39d41315e303a5f7ddd766 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 18 Sep 2026 08:23:21 -0700 Subject: [PATCH 23/39] fix(server-runtime-injection): Keep `require()` of JSON working on Deno (#24412) Deno's `nextLoad` reports no module format for a `.json` file, where Node reports `'json'`. With any load hook installed, Deno's CJS loader then compiles the JSON as JavaScript, so `require()` of it throws `SyntaxError: Unexpected token ':'`. A pass-through `Module.registerHooks({ resolve, load })` reproduces this with no Sentry code involved, on both Deno 2.8.3 and 2.9.6. That made `@sentry/deno/import` (as a first import or as a `--preload`) crash any app whose dependency graph requires JSON. `redis` and `ioredis` both do, through `@ioredis/commands`. Restoring the format in our load wrapper is enough. Node never reports a missing format, so the wrapper only applies on Deno. Co-authored-by: Claude Opus 5 (1M context) --- .../orchestrion-json-require/fixture.cjs | 3 ++ .../orchestrion-json-require/fixture.json | 1 + .../orchestrion-json-require/scenario.mjs | 14 +++++++++ .../suites/orchestrion-json-require/test.ts | 30 +++++++++++++++++++ .../server-runtime-injection/src/register.ts | 18 ++++++++++- 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.cjs create mode 100644 dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.json create mode 100644 dev-packages/deno-integration-tests/suites/orchestrion-json-require/scenario.mjs create mode 100644 dev-packages/deno-integration-tests/suites/orchestrion-json-require/test.ts diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.cjs b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.cjs new file mode 100644 index 000000000000..5a2853d18c4c --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.cjs @@ -0,0 +1,3 @@ +// Deno reports no module format for a `.json` file, so with any load hook +// installed its CJS loader used to compile this JSON as JavaScript. +module.exports = require('./fixture.json'); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.json b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.json new file mode 100644 index 000000000000..6e887fd6abe8 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/fixture.json @@ -0,0 +1 @@ +{ "answer": 42 } diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/scenario.mjs b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/scenario.mjs new file mode 100644 index 000000000000..4fa36618430e --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/scenario.mjs @@ -0,0 +1,14 @@ +// Spawned by test.ts via `deno run`. +// +// Importing `@sentry/deno/import` registers the orchestrion module hook. No +// module here is instrumented — the point is that installing the hook at all +// must not change how `require()` loads JSON. +import '@sentry/deno/import'; + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { answer } = require('./fixture.cjs'); + +// eslint-disable-next-line no-console +console.log(`SCENARIO answer=${answer}`); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-json-require/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/test.ts new file mode 100644 index 000000000000..80b35fa8c903 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-json-require/test.ts @@ -0,0 +1,30 @@ +// + +import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; +import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; + +Deno.test('the runtime hook leaves `require()` of JSON working', async () => { + const scenario = new URL('./scenario.mjs', import.meta.url); + + // The package root — where `node_modules` (and thus `@sentry/deno`) resolves + // for the spawned `deno run`. + const cwd = new URL('../../', import.meta.url); + + const command = new Deno.Command('deno', { + args: ['run', '--allow-all', scenario.pathname], + cwd: cwd.pathname, + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stdout, stderr } = await command.output(); + const out = new TextDecoder().decode(stdout); + const err = new TextDecoder().decode(stderr); + + // Without the format fixup in `@sentry/server-runtime-injection` this exits 1 + // with `SyntaxError: Unexpected token ':'` from the JSON compiled as JS. + assertEquals(code, 0, `scenario exited ${code}\nstdout:\n${out}\nstderr:\n${err}`); + + const line = out.split('\n').find(l => l.startsWith('SCENARIO')) ?? ''; + assert(line.includes('answer=42'), `expected the parsed JSON value, got: ${line}\nstderr:\n${err}`); +}); diff --git a/packages/server-runtime-injection/src/register.ts b/packages/server-runtime-injection/src/register.ts index 9d130ab41c53..38b55efe48d7 100644 --- a/packages/server-runtime-injection/src/register.ts +++ b/packages/server-runtime-injection/src/register.ts @@ -28,6 +28,22 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); } +/** + * Deno's `nextLoad` reports no `format` for a `.json` file, where Node reports `'json'`. With any + * load hook installed, Deno's CJS loader then compiles the JSON as JavaScript and `require()` of it + * throws `SyntaxError: Unexpected token ':'`. Restoring the format is enough, and only Deno needs + * it: on Node the format is never missing. + */ +function withDenoJsonFormat(loadHook: Function): Function { + return (url: string, context: unknown, nextLoad: Function) => { + const result = loadHook(url, context, nextLoad) as { format?: string }; + if (result?.format === undefined && url.endsWith('.json')) { + result.format = 'json'; + } + return result; + }; +} + /** * Emit an always-on warning. Unlike `debug.warn` (gated behind `debug: true`), this reaches every * user — otherwise a broken transform silently records no channel-based spans. @@ -165,7 +181,7 @@ export function registerDiagnosticsChannelInjection(): void { try { if (typeof mod.registerHooks === 'function' && stableSyncHooks) { initialize({ instrumentations: SENTRY_RUNTIME_INSTRUMENTATIONS }); - mod.registerHooks({ resolve, load }); + mod.registerHooks({ resolve, load: globalAny.Deno ? withDenoJsonFormat(load) : load }); 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 From ee3e9040e54fa64ee3d3cb905e79b4eadec6f7bd Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 18 Sep 2026 08:23:22 -0700 Subject: [PATCH 24/39] test(e2e): consolidate the deno e2e test applications (#24414) Five Deno E2E apps become one. `deno-static`, `deno-mysql`, `deno-pg` and `deno-redis` are deleted, and CI goes from five required rows plus one optional to two required plus one optional. The split was accidental rather than designed. `deno-static` was a verbatim copy of `deno` taken before the span streaming port (#24002), one of twenty such copies from that rollout. `deno-mysql` and `deno-pg` were one route each, and carried an `import '@sentry/deno/import'` first-import rule that a comment tied to Deno 2.8.0 through 2.8.2. That rule is stale: the SDK now requires Deno >= 2.8.3 (#22790), and the README documents `--preload` (#24215). Nothing in E2E ran that documented setup. The consolidated app now starts with `deno run --preload=@sentry/deno/import`, so it needs no import ordering rule and no dynamic `import()`. `mysql`, `pg`, `redis` and `ioredis` are plain static imports, and one `docker-compose.yml` starts all three databases. The static trace lifecycle becomes a `sentryTest` variant of the same app, driven by `E2E_TEST_STATIC`, rather than a second copy. The six lifecycle-agnostic specs (errors, scope, context, breadcrumbs, logs, metrics) live once and run in both modes. Only the specs that assert envelope shape are split, into `tests/streamed/` and `tests/static/`, selected by `testIgnore`. `isSegmentFor` and the Redis span helpers, previously copied across four files, move into `tests/streamed/utils.ts`. Decisions: - `deno.json` maps `@sentry/deno/import` to the installed file rather than to `npm:@sentry/deno/import`. This app installs the SDK from a local tarball, which has no registry version for Deno to match a preloaded `npm:` specifier against. `--preload=npm:zod` works here, so the limitation is the tarball, not `--preload`. - The variant runs `pnpm test:build:static` / `pnpm test:assert:static` rather than an inline `E2E_TEST_STATIC=true` prefix. The local runner wraps the command in `volta run`, which reads a leading assignment as the executable name. `node-express`'s existing `E2E_TEST_OTEL_SETUP` variant has the same problem and is broken locally today; fixing `run.ts` is separate work. - The database specs stay streaming only, as they are today. There is no static database coverage to preserve, and writing it is separate work. - `docker compose up` stays in the start command. Playwright creates its web server tasks before global setup, so the app starts first. The three deleted apps all did the same. - The first-import form keeps its coverage in `deno-integration-tests` (`suites/orchestrion-{mysql,postgres}`). Their stale `TODO: revisit --preload once Deno 2.8.3 ships` comments are dropped. Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 5 +- .../suites/orchestrion-mysql/test.ts | 5 +- .../suites/orchestrion-postgres/test.ts | 5 +- .../test-applications/deno-mysql/deno.json | 7 - .../deno-mysql/docker-compose.yml | 18 - .../deno-mysql/global-setup.mjs | 14 - .../test-applications/deno-mysql/package.json | 23 -- .../deno-mysql/playwright.config.mjs | 12 - .../test-applications/deno-mysql/src/app.ts | 66 ---- .../deno-mysql/start-event-proxy.mjs | 6 - .../test-applications/deno-pg/deno.json | 7 - .../deno-pg/docker-compose.yml | 17 - .../deno-pg/global-setup.mjs | 14 - .../deno-pg/global-teardown.mjs | 12 - .../test-applications/deno-pg/package.json | 23 -- .../deno-pg/playwright.config.mjs | 12 - .../test-applications/deno-pg/src/app.ts | 69 ---- .../deno-pg/start-event-proxy.mjs | 6 - .../test-applications/deno-redis/deno.json | 8 - .../deno-redis/docker-compose.yml | 12 - .../deno-redis/global-teardown.mjs | 12 - .../test-applications/deno-redis/package.json | 24 -- .../deno-redis/playwright.config.mjs | 12 - .../test-applications/deno-redis/src/app.ts | 113 ------- .../deno-redis/start-event-proxy.mjs | 6 - .../test-applications/deno-static/deno.json | 11 - .../deno-static/package.json | 25 -- .../deno-static/playwright.config.mjs | 8 - .../test-applications/deno-static/src/app.ts | 307 ------------------ .../deno-static/start-event-proxy.mjs | 6 - .../deno-static/tests/breadcrumbs.test.ts | 25 -- .../deno-static/tests/context.test.ts | 34 -- .../deno-static/tests/errors.test.ts | 15 - .../deno-static/tests/logs.test.ts | 16 - .../deno-static/tests/metrics.test.ts | 67 ---- .../deno-static/tests/scope.test.ts | 27 -- .../test-applications/deno/deno.json | 5 + .../test-applications/deno/docker-compose.yml | 47 +++ .../{deno-redis => deno}/global-setup.mjs | 5 +- .../{deno-mysql => deno}/global-teardown.mjs | 0 .../test-applications/deno/package.json | 17 +- .../deno/playwright.config.mjs | 20 +- .../test-applications/deno/src/app.ts | 186 ++++++++++- .../tests/static}/ai-error.test.ts | 4 +- .../tests => deno/tests/static}/ai.test.ts | 2 +- .../tests => deno/tests/static}/fetch.test.ts | 2 +- .../tests/static}/transactions.test.ts | 8 +- .../tests/{ => streamed}/ai-error.test.ts | 0 .../deno/tests/{ => streamed}/ai.test.ts | 0 .../deno/tests/{ => streamed}/fetch.test.ts | 0 .../tests/streamed}/ioredis.test.ts | 28 +- .../tests/streamed}/mysql.test.ts | 19 +- .../tests => deno/tests/streamed}/pg.test.ts | 19 +- .../tests/streamed}/redis.test.ts | 23 +- .../tests/{ => streamed}/transactions.test.ts | 12 +- .../deno/tests/streamed/utils.ts | 23 ++ 56 files changed, 335 insertions(+), 1134 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/deno-mysql/deno.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-mysql/docker-compose.yml delete mode 100644 dev-packages/e2e-tests/test-applications/deno-mysql/global-setup.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-mysql/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-mysql/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-mysql/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/deno.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/deno.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml delete mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/deno.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/src/app.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/deno/docker-compose.yml rename dev-packages/e2e-tests/test-applications/{deno-redis => deno}/global-setup.mjs (64%) rename dev-packages/e2e-tests/test-applications/{deno-mysql => deno}/global-teardown.mjs (100%) rename dev-packages/e2e-tests/test-applications/{deno-static/tests => deno/tests/static}/ai-error.test.ts (87%) rename dev-packages/e2e-tests/test-applications/{deno-static/tests => deno/tests/static}/ai.test.ts (96%) rename dev-packages/e2e-tests/test-applications/{deno-static/tests => deno/tests/static}/fetch.test.ts (88%) rename dev-packages/e2e-tests/test-applications/{deno-static/tests => deno/tests/static}/transactions.test.ts (90%) rename dev-packages/e2e-tests/test-applications/deno/tests/{ => streamed}/ai-error.test.ts (100%) rename dev-packages/e2e-tests/test-applications/deno/tests/{ => streamed}/ai.test.ts (100%) rename dev-packages/e2e-tests/test-applications/deno/tests/{ => streamed}/fetch.test.ts (100%) rename dev-packages/e2e-tests/test-applications/{deno-redis/tests => deno/tests/streamed}/ioredis.test.ts (77%) rename dev-packages/e2e-tests/test-applications/{deno-mysql/tests => deno/tests/streamed}/mysql.test.ts (77%) rename dev-packages/e2e-tests/test-applications/{deno-pg/tests => deno/tests/streamed}/pg.test.ts (77%) rename dev-packages/e2e-tests/test-applications/{deno-redis/tests => deno/tests/streamed}/redis.test.ts (76%) rename dev-packages/e2e-tests/test-applications/deno/tests/{ => streamed}/transactions.test.ts (94%) create mode 100644 dev-packages/e2e-tests/test-applications/deno/tests/streamed/utils.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7f7013812884..45e298794f10 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1040,10 +1040,7 @@ jobs: use-installer: true token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Deno - if: - matrix.test-application == 'deno' || matrix.test-application == 'deno-static' || matrix.test-application == - 'deno-redis' || matrix.test-application == 'hono-4' || matrix.test-application == 'deno-mysql' || - matrix.test-application == 'deno-pg' + if: matrix.test-application == 'deno' || matrix.test-application == 'hono-4' uses: denoland/setup-deno@v2.0.5 with: deno-version: ${{ matrix.deno-version || 'v2.8.3' }} diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts index 077e1bd9f5bb..c263bdace328 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts @@ -19,9 +19,8 @@ Deno.test('mysql instrumentation: included in default integrations (Deno 2.8.0+) assert(names.includes('Mysql'), `Mysql should be in defaults, got ${names.join(', ')}`); }); -// The orchestrion runtime hook (`@sentry/deno/import`) only works as a FIRST -// import inside the entry graph in Deno 2.8.0 through 2.8.2. -// TODO: revisit a `--import` or `--preload` approach once Deno 2.8.3 ships. +// Covers the import form of the hook. The `--preload` form the README +// documents is covered by the `deno` E2E app. Deno.test('@sentry/deno/import: transforms mysql so it publishes the orchestrion channel', async () => { const scenario = new URL('./scenario.mjs', import.meta.url); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts index c47715c6a9fc..81012e6fb979 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts @@ -19,9 +19,8 @@ Deno.test('pg instrumentation: included in default integrations (Deno 2.8.0+)', assert(names.includes('Postgres'), `Postgres should be in defaults, got ${names.join(', ')}`); }); -// The orchestrion runtime hook (`@sentry/deno/import`) only works as a FIRST -// import inside the entry graph in Deno 2.8.0 through 2.8.2. -// TODO: revisit a `--import` or `--preload` approach once Deno 2.8.3 ships. +// Covers the import form of the hook. The `--preload` form the README +// documents is covered by the `deno` E2E app. Deno.test('@sentry/deno/import: transforms pg so it publishes the orchestrion channel', async () => { const scenario = new URL('./scenario.mjs', import.meta.url); diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/deno.json b/dev-packages/e2e-tests/test-applications/deno-mysql/deno.json deleted file mode 100644 index f82f2da8e1e3..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/deno.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "mysql": "npm:mysql@2.18.1" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-mysql/docker-compose.yml deleted file mode 100644 index d4a50957c884..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/docker-compose.yml +++ /dev/null @@ -1,18 +0,0 @@ -services: - db: - image: mysql:8.0 - restart: always - container_name: e2e-tests-deno-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'] - ports: - - '3306:3306' - environment: - MYSQL_ROOT_PASSWORD: password - healthcheck: - test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] - interval: 2s - timeout: 3s - retries: 30 - start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno-mysql/global-setup.mjs deleted file mode 100644 index 328522280a26..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/global-setup.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalSetup() { - // Start MySQL via Docker Compose. `--wait` blocks until the healthcheck - // in docker-compose.yml passes, so the Deno app can connect immediately. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/package.json b/dev-packages/e2e-tests/test-applications/deno-mysql/package.json deleted file mode 100644 index 8fbc85885d33..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "deno-mysql", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "mysql": "2.18.1" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-mysql/playwright.config.mjs deleted file mode 100644 index d525dd371bc9..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/playwright.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default { - ...config, - globalSetup: './global-setup.mjs', - globalTeardown: './global-teardown.mjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts deleted file mode 100644 index 585c0788b9a0..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/src/app.ts +++ /dev/null @@ -1,66 +0,0 @@ -// `@sentry/deno/import` MUST be the very first import: it registers the -// orchestrion runtime hook, which transforms `mysql` (imported dynamically -// below) to publish the `orchestrion:mysql:query` diagnostics channel. -// In Deno 2.8.0–2.8.2 the hook only works as the first import in the entry graph. -import '@sentry/deno/import'; -import * as Sentry from '@sentry/deno'; - -Sentry.init({ - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1, -}); - -// Dynamic import AFTER init so the orchestrion hook (registered above) is in -// place to transform `mysql/lib/Connection.js`'s `query`, and so -// `denoMysqlIntegration` (wired by `init()`) is already subscribed. -const { default: mysql } = await import('mysql'); - -const connection = mysql.createConnection({ - host: Deno.env.get('MYSQL_HOST') ?? '127.0.0.1', - port: Number(Deno.env.get('MYSQL_PORT') ?? 3306), - user: 'root', - password: 'password', -}); - -// Swallow connection errors (e.g. the DB container going away at teardown) so -// they don't become an uncaught exception that crashes the process on shutdown. -connection.on('error', (err: unknown) => { - // eslint-disable-next-line no-console - console.error('mysql connection error', err); -}); - -connection.connect((err: unknown) => { - if (err) { - // eslint-disable-next-line no-console - console.error('mysql connect error', err); - } -}); - -const port = 3030; - -Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { - const url = new URL(req.url); - - // Runs two queries, the second NESTED inside the first's callback. mysql - // dispatches that callback from its socket data handler (a fresh async - // context), so the nested query's span only lands on this request's - // http.server transaction if `denoMysqlIntegration`'s AsyncLocalStorage - // context strategy restored the parent across the async boundary. - if (url.pathname === '/test-mysql') { - await new Promise((resolve, reject) => { - connection.query('SELECT 1 + 1 AS solution', (err: unknown) => { - if (err) return reject(err); - connection.query('SELECT NOW()', (err2: unknown) => { - if (err2) return reject(err2); - resolve(); - }); - }); - }); - return Response.json({ status: 'ok' }); - } - - return new Response('Not found', { status: 404 }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-mysql/start-event-proxy.mjs deleted file mode 100644 index c01f6f1cca6d..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'deno-mysql', -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/deno.json b/dev-packages/e2e-tests/test-applications/deno-pg/deno.json deleted file mode 100644 index 2bc35855c689..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/deno.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "pg": "npm:pg@8.16.0" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml deleted file mode 100644 index aeee1935341e..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/docker-compose.yml +++ /dev/null @@ -1,17 +0,0 @@ -services: - db: - image: postgres:13 - restart: always - container_name: e2e-tests-deno-pg - ports: - - '5432:5432' - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: postgres - healthcheck: - test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres'] - interval: 2s - timeout: 3s - retries: 30 - start_period: 5s diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs deleted file mode 100644 index 2e9841a6fdbf..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/global-setup.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalSetup() { - // Start PostgreSQL via Docker Compose. `--wait` blocks until the healthcheck - // in docker-compose.yml passes, so the Deno app can connect immediately. - execSync('docker compose up -d --wait', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs deleted file mode 100644 index 2742279431ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/global-teardown.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalTeardown() { - execSync('docker compose down --volumes', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/package.json b/dev-packages/e2e-tests/test-applications/deno-pg/package.json deleted file mode 100644 index 36b1cf960b65..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "deno-pg", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "pg": "8.16.0" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs deleted file mode 100644 index d525dd371bc9..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/playwright.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default { - ...config, - globalSetup: './global-setup.mjs', - globalTeardown: './global-teardown.mjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts deleted file mode 100644 index 2b9e7a432376..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/src/app.ts +++ /dev/null @@ -1,69 +0,0 @@ -// `@sentry/deno/import` MUST be the very first import: it registers the -// orchestrion runtime hook, which transforms `pg` (imported dynamically below) -// to publish the `orchestrion:pg:query` diagnostics channel. -// In Deno 2.8.0–2.8.2 the hook only works as the first import in the entry -// graph. -import '@sentry/deno/import'; -import * as Sentry from '@sentry/deno'; - -Sentry.init({ - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1, -}); - -// Dynamic import AFTER init so the orchestrion hook (registered above) is in -// place to transform `pg/lib/client.js`'s `query`, and so -// `denoPostgresIntegration` (wired by `init()`) is already subscribed. -const { default: pg } = await import('pg'); - -const client = new pg.Client({ - host: Deno.env.get('PGHOST') ?? '127.0.0.1', - port: Number(Deno.env.get('PGPORT') ?? 5432), - user: 'postgres', - password: 'password', - database: 'postgres', -}); - -// Swallow connection errors (e.g. the DB container going away at teardown) so -// they don't become an uncaught exception that crashes the process on -// shutdown. -client.on('error', (err: unknown) => { - // eslint-disable-next-line no-console - console.error('pg client error', err); -}); - -client.connect((err: unknown) => { - if (err) { - // eslint-disable-next-line no-console - console.error('pg connect error', err); - } -}); - -const port = 3030; - -Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { - const url = new URL(req.url); - - // Runs two queries, the second NESTED inside the first's callback. pg - // dispatches that callback from its socket data handler (a fresh async - // context), so the nested query's span only lands on this request's - // http.server transaction if `denoPostgresIntegration`'s AsyncLocalStorage - // context strategy restored the parent across the async boundary. - if (url.pathname === '/test-pg') { - await new Promise((resolve, reject) => { - client.query('SELECT 1 + 1 AS solution', (err: unknown) => { - if (err) return reject(err); - client.query('SELECT NOW()', (err2: unknown) => { - if (err2) return reject(err2); - resolve(); - }); - }); - }); - return Response.json({ status: 'ok' }); - } - - return new Response('Not found', { status: 404 }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs deleted file mode 100644 index 7f5c950f439e..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-pg/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'deno-pg', -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/deno.json b/dev-packages/e2e-tests/test-applications/deno-redis/deno.json deleted file mode 100644 index 682591cfa09e..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/deno.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "ioredis": "npm:ioredis@^5.11.0", - "redis": "npm:redis@^5.12.0" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml deleted file mode 100644 index b695ab5a7308..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - redis: - image: redis:8 - restart: always - container_name: e2e-tests-deno-redis - ports: - - '6379:6379' - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 1s - timeout: 3s - retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs deleted file mode 100644 index 2742279431ad..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/global-teardown.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { execSync } from 'child_process'; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export default async function globalTeardown() { - execSync('docker compose down --volumes', { - cwd: __dirname, - stdio: 'inherit', - }); -} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/package.json b/dev-packages/e2e-tests/test-applications/deno-redis/package.json deleted file mode 100644 index 439845814f99..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "deno-redis", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "ioredis": "^5.11.0", - "redis": "^5.12.0" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs deleted file mode 100644 index d525dd371bc9..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/playwright.config.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default { - ...config, - globalSetup: './global-setup.mjs', - globalTeardown: './global-teardown.mjs', -}; diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts deleted file mode 100644 index 929e913873c6..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/src/app.ts +++ /dev/null @@ -1,113 +0,0 @@ -import * as Sentry from '@sentry/deno'; -import IORedis from 'ioredis'; -import { createClient } from 'redis'; - -Sentry.init({ - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1, -}); - -const redisUrl = Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'; - -// One shared client per process. node-redis publishes to the -// `node-redis:command` / `:batch` / `:connect` diagnostics channels for every -// operation on this client; redisIntegration is already subscribed to -// those. -const redis = createClient({ url: redisUrl }); -function onRedisError(err: unknown) { - // eslint-disable-next-line no-console - console.error('redis client error', err); -} -redis.on('error', onRedisError); -await redis.connect(); - -// Separate ioredis client. ioredis >= 5.11 publishes to the `ioredis:command` -// and `ioredis:connect` channels, which redisIntegration also subscribes -// to. lazyConnect so we can yield a microtick before connecting and ensure -// the DC subscriber is registered before ioredis creates its tracing channels. -await Promise.resolve(); -const ioredisUrl = new URL(redisUrl); -const ioredis = new IORedis({ - host: ioredisUrl.hostname, - port: Number(ioredisUrl.port) || 6379, - lazyConnect: true, -}); -function onIoredisError(err: unknown) { - // eslint-disable-next-line no-console - console.error('ioredis client error', err); -} -ioredis.on('error', onIoredisError); -await ioredis.connect(); - -const port = 3030; - -Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { - const url = new URL(req.url); - - // node-redis: GET — exercises the command channel, success path. - if (url.pathname === '/redis-get') { - const key = url.searchParams.get('key') ?? 'cache:key'; - const value = await redis.get(key); - return Response.json({ key, value }); - } - - // node-redis: SET then GET — exercises two commands inside a single - // transaction so we can assert the parent has two db.query children. - if (url.pathname === '/redis-set-get') { - const key = url.searchParams.get('key') ?? 'cache:key'; - const value = url.searchParams.get('value') ?? 'hello'; - await redis.set(key, value); - const echoed = await redis.get(key); - return Response.json({ key, value: echoed }); - } - - // node-redis: MULTI — exercises the batch channel. - if (url.pathname === '/redis-multi') { - const result = await redis.multi().set('multi:a', '1').set('multi:b', '2').get('multi:a').exec(); - return Response.json({ result }); - } - - // ioredis: GET — exercises the ioredis:command channel. - if (url.pathname === '/ioredis-get') { - const key = url.searchParams.get('key') ?? 'iocache:key'; - const value = await ioredis.get(key); - return Response.json({ key, value }); - } - - // ioredis: SET then GET — two commands inside a transaction. - if (url.pathname === '/ioredis-set-get') { - const key = url.searchParams.get('key') ?? 'iocache:key'; - const value = url.searchParams.get('value') ?? 'hello'; - await ioredis.set(key, value); - const echoed = await ioredis.get(key); - return Response.json({ key, value: echoed }); - } - - // ioredis: MULTI — ioredis has no separate batch channel; per-command - // payloads carry `batchMode`/`batchSize` instead, so we still expect one - // db.query span per command. - if (url.pathname === '/ioredis-multi') { - const result = await ioredis.multi().set('iomulti:a', '1').set('iomulti:b', '2').get('iomulti:a').exec(); - return Response.json({ result }); - } - - // ioredis: PIPELINE — same shape as MULTI from the perspective of the - // diagnostics channel. - if (url.pathname === '/ioredis-pipeline') { - const result = await ioredis.pipeline().set('iopipe:a', '1').set('iopipe:b', '2').get('iopipe:a').exec(); - return Response.json({ result }); - } - - if (url.pathname === '/redis-disconnect') { - redis.off('error', onRedisError); - redis.close(); - ioredis.off('error', onIoredisError); - ioredis.disconnect(); - return new Response('ok'); - } - - return new Response('Not found', { status: 404 }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs deleted file mode 100644 index 0c77d1f6d4f3..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-redis/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'deno-redis', -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/deno.json b/dev-packages/e2e-tests/test-applications/deno-static/deno.json deleted file mode 100644 index 35242c740171..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/deno.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "imports": { - "@sentry/deno": "npm:@sentry/deno", - "@sentry/core": "npm:@sentry/core", - "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", - "ai": "npm:ai@^3.0.0", - "ai/test": "npm:ai@^3.0.0/test", - "zod": "npm:zod@^3.22.4" - }, - "nodeModulesDir": "manual" -} diff --git a/dev-packages/e2e-tests/test-applications/deno-static/package.json b/dev-packages/e2e-tests/test-applications/deno-static/package.json deleted file mode 100644 index 0e7b7337ecc4..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "deno-static-app", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "deno run --allow-net --allow-env --allow-read src/app.ts", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", - "@opentelemetry/api": "^1.9.1", - "ai": "^3.0.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "@playwright/test": "~1.63.0", - "@sentry-internal/test-utils": "link:../../../test-utils" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs deleted file mode 100644 index 3d3ab7d8df02..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/playwright.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts b/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts deleted file mode 100644 index 45da3a4209f8..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/src/app.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { trace } from '@opentelemetry/api'; - -// Simulate a pre-existing OTel provider (like Supabase Edge Runtime registers -// before user code runs). Without trace.disable() in Sentry's setup, this would -// cause setGlobalTracerProvider to be a no-op, silently dropping all OTel spans. -const fakeProvider = { - getTracer: () => ({ - startSpan: () => ({ end: () => {}, setAttributes: () => {} }), - startActiveSpan: (_name: string, fn: Function) => fn({ end: () => {}, setAttributes: () => {} }), - }), -}; -trace.setGlobalTracerProvider(fakeProvider as any); - -// Sentry.init() must call trace.disable() to clear the fake provider above -import * as Sentry from '@sentry/deno'; -import { generateText } from 'ai'; -import { MockLanguageModelV1 } from 'ai/test'; -import { z } from 'zod'; - -Sentry.init({ - traceLifecycle: 'static', - environment: 'qa', - dsn: Deno.env.get('E2E_TEST_DSN'), - debug: !!Deno.env.get('DEBUG'), - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1, -}); - -const port = 3030; - -Deno.serve({ port }, async (req: Request) => { - const url = new URL(req.url); - - if (url.pathname === '/test-success') { - return new Response(JSON.stringify({ version: 'v1' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - if (url.pathname === '/test-error') { - const exceptionId = Sentry.captureException(new Error('This is an error')); - return new Response(JSON.stringify({ exceptionId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test Sentry.startSpan — uses Sentry's internal pipeline - if (url.pathname === '/test-sentry-span') { - Sentry.startSpan({ name: 'test-sentry-span' }, () => { - // noop - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test OTel tracer.startSpan — goes through the global TracerProvider - if (url.pathname === '/test-otel-span') { - const tracer = trace.getTracer('test-tracer'); - const span = tracer.startSpan('test-otel-span'); - span.end(); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test OTel tracer.startActiveSpan — what AI SDK and most instrumentations use - if (url.pathname === '/test-otel-active-span') { - const tracer = trace.getTracer('test-tracer'); - tracer.startActiveSpan('test-otel-active-span', span => { - span.setAttributes({ 'test.active': true }); - span.end(); - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test interop: OTel span inside a Sentry span - if (url.pathname === '/test-interop') { - Sentry.startSpan({ name: 'sentry-parent' }, () => { - const tracer = trace.getTracer('test-tracer'); - const span = tracer.startSpan('otel-child'); - span.end(); - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test breadcrumbs: add a breadcrumb then capture an error - if (url.pathname === '/test-breadcrumb') { - Sentry.addBreadcrumb({ - message: 'test-breadcrumb', - category: 'custom', - level: 'info', - }); - const exceptionId = Sentry.captureException(new Error('breadcrumb-test')); - return new Response(JSON.stringify({ exceptionId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test context: set user, tag, extra then capture an error - if (url.pathname === '/test-context') { - Sentry.setUser({ id: '123', email: 'test@sentry.io' }); - Sentry.setTag('deno-runtime', 'true'); - Sentry.setExtra('detail', { key: 'value' }); - const exceptionId = Sentry.captureException(new Error('context-test')); - return new Response(JSON.stringify({ exceptionId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test scope isolation: tags inside withScope do not leak - if (url.pathname === '/test-scope-isolation') { - let insideId: string | undefined; - let outsideId: string | undefined; - - Sentry.withScope(scope => { - scope.setTag('isolated', 'yes'); - insideId = Sentry.captureException(new Error('inside-scope')); - }); - - outsideId = Sentry.captureException(new Error('outside-scope')); - - return new Response(JSON.stringify({ insideId, outsideId }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test outbound fetch instrumentation - if (url.pathname === '/test-outgoing-fetch') { - const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => { - const res = await fetch('http://localhost:3030/test-success'); - return res.json(); - }); - return new Response(JSON.stringify(response), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test AI: Vercel AI SDK generateText with mock model - if (url.pathname === '/test-ai') { - const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { - // First call - telemetry enabled by default - const result1 = await generateText({ - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'First span here!', - }), - }), - prompt: 'Where is the first span?', - }); - - // Second call - explicitly enabled telemetry - const result2 = await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'Second span here!', - }), - }), - prompt: 'Where is the second span?', - }); - - // Third call - with tool calls - const result3 = await generateText({ - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'tool-calls', - usage: { promptTokens: 15, completionTokens: 25 }, - text: 'Tool call completed!', - toolCalls: [ - { - toolCallType: 'function', - toolCallId: 'call-1', - toolName: 'getWeather', - args: '{ "location": "San Francisco" }', - }, - ], - }), - }), - tools: { - getWeather: { - parameters: z.object({ location: z.string() }), - execute: async (args: { location: string }) => { - return `Weather in ${args.location}: Sunny, 72°F`; - }, - }, - }, - prompt: 'What is the weather in San Francisco?', - }); - - // Fourth call - explicitly disabled telemetry, should not be captured - const result4 = await generateText({ - experimental_telemetry: { isEnabled: false }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'stop', - usage: { promptTokens: 10, completionTokens: 20 }, - text: 'Should not be captured!', - }), - }), - prompt: 'Where is the disabled span?', - }); - - return { - result1: result1.text, - result2: result2.text, - result3: result3.text, - result4: result4.text, - }; - }); - - return new Response(JSON.stringify(results), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test AI error: tool call that throws - if (url.pathname === '/test-ai-error') { - try { - await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { - await generateText({ - experimental_telemetry: { isEnabled: true }, - model: new MockLanguageModelV1({ - doGenerate: async () => ({ - rawCall: { rawPrompt: null, rawSettings: {} }, - finishReason: 'tool-calls', - usage: { promptTokens: 15, completionTokens: 25 }, - text: 'Tool call completed!', - toolCalls: [ - { - toolCallType: 'function', - toolCallId: 'call-1', - toolName: 'getWeather', - args: '{ "location": "San Francisco" }', - }, - ], - }), - }), - tools: { - getWeather: { - parameters: z.object({ location: z.string() }), - execute: async (_args: { location: string }) => { - throw new Error('Tool call failed'); - }, - }, - }, - prompt: 'What is the weather in San Francisco?', - }); - }); - } catch (e) { - Sentry.captureException(e); - } - - return new Response(JSON.stringify({ status: 'error-handled' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test metrics: emit counter, distribution, and gauge - if (url.pathname === '/test-metrics') { - Sentry.metrics.count('test.deno.count', 1, { - attributes: { - endpoint: '/test-metrics', - 'random.attribute': 'Apples', - }, - }); - Sentry.metrics.distribution('test.deno.distribution', 100, { - attributes: { - endpoint: '/test-metrics', - 'random.attribute': 'Bananas', - }, - }); - Sentry.metrics.gauge('test.deno.gauge', 200, { - attributes: { - endpoint: '/test-metrics', - 'random.attribute': 'Cherries', - }, - }); - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - // Test logs: emit a debug log via Sentry.logger - if (url.pathname === '/test-log') { - Sentry.logger.debug('Accessed /test-log route'); - return new Response(JSON.stringify({ message: 'Log sent' }), { - headers: { 'Content-Type': 'application/json' }, - }); - } - - return new Response('Not found', { status: 404 }); -}); - -console.log(`Deno test app listening on port ${port}`); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs deleted file mode 100644 index a9ac2fbee1cd..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'deno-static', -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts deleted file mode 100644 index b995758f826b..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/breadcrumbs.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends error event with breadcrumbs', async ({ baseURL }) => { - const errorEventPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'breadcrumb-test'; - }); - - await fetch(`${baseURL}/test-breadcrumb`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('breadcrumb-test'); - - expect(errorEvent.breadcrumbs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: 'test-breadcrumb', - category: 'custom', - level: 'info', - }), - ]), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts deleted file mode 100644 index 5b7fae12fd4e..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/context.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends error event with user, tags, and extras', async ({ baseURL }) => { - const errorEventPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'context-test'; - }); - - await fetch(`${baseURL}/test-context`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('context-test'); - - expect(errorEvent.user).toEqual( - expect.objectContaining({ - id: '123', - email: 'test@sentry.io', - }), - ); - - expect(errorEvent.tags).toEqual( - expect.objectContaining({ - 'deno-runtime': 'true', - }), - ); - - expect(errorEvent.extra).toEqual( - expect.objectContaining({ - detail: { key: 'value' }, - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts deleted file mode 100644 index 1088141661b2..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/errors.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends error event', async ({ baseURL }) => { - const errorEventPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'This is an error'; - }); - - await fetch(`${baseURL}/test-error`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an error'); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts deleted file mode 100644 index 04c7e08b2e93..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/logs.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; -import type { SerializedLogContainer } from '@sentry/core'; - -test('should send logs via Sentry.logger', async ({ baseURL }) => { - const logEnvelopePromise = waitForEnvelopeItem('deno-static', envelope => { - return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; - }); - - await fetch(`${baseURL}/test-log`); - - const logEnvelope = await logEnvelopePromise; - const log = (logEnvelope[1] as SerializedLogContainer).items[0]; - expect(log?.level).toBe('debug'); - expect(log?.body).toBe('Accessed /test-log route'); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts deleted file mode 100644 index c3af9b88a7db..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/metrics.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForMetric } from '@sentry-internal/test-utils'; - -test('Should emit counter, distribution, and gauge metrics', async ({ baseURL }) => { - const countPromise = waitForMetric('deno-static', metric => { - return metric.name === 'test.deno.count'; - }); - - const distributionPromise = waitForMetric('deno-static', metric => { - return metric.name === 'test.deno.distribution'; - }); - - const gaugePromise = waitForMetric('deno-static', metric => { - return metric.name === 'test.deno.gauge'; - }); - - await fetch(`${baseURL}/test-metrics`); - - const count = await countPromise; - const distribution = await distributionPromise; - const gauge = await gaugePromise; - - expect(count).toMatchObject({ - timestamp: expect.any(Number), - trace_id: expect.any(String), - name: 'test.deno.count', - type: 'counter', - value: 1, - attributes: { - endpoint: { value: '/test-metrics', type: 'string' }, - 'random.attribute': { value: 'Apples', type: 'string' }, - 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, - 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, - }, - }); - - expect(distribution).toMatchObject({ - timestamp: expect.any(Number), - trace_id: expect.any(String), - name: 'test.deno.distribution', - type: 'distribution', - value: 100, - attributes: { - endpoint: { value: '/test-metrics', type: 'string' }, - 'random.attribute': { value: 'Bananas', type: 'string' }, - 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, - 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, - }, - }); - - expect(gauge).toMatchObject({ - timestamp: expect.any(Number), - trace_id: expect.any(String), - name: 'test.deno.gauge', - type: 'gauge', - value: 200, - attributes: { - endpoint: { value: '/test-metrics', type: 'string' }, - 'random.attribute': { value: 'Cherries', type: 'string' }, - 'sentry.environment': { value: 'qa', type: 'string' }, - 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, - 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, - }, - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts b/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts deleted file mode 100644 index 5224cdc71ff3..000000000000 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/scope.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Scope isolation prevents tag leakage between scopes', async ({ baseURL }) => { - const insideErrorPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'inside-scope'; - }); - - const outsideErrorPromise = waitForError('deno-static', event => { - return !event.type && event.exception?.values?.[0]?.value === 'outside-scope'; - }); - - await fetch(`${baseURL}/test-scope-isolation`); - - const insideError = await insideErrorPromise; - const outsideError = await outsideErrorPromise; - - // The error inside withScope should have the isolated tag - expect(insideError.tags).toEqual( - expect.objectContaining({ - isolated: 'yes', - }), - ); - - // The error outside withScope should NOT have the isolated tag - expect(outsideError.tags?.['isolated']).toBeUndefined(); -}); diff --git a/dev-packages/e2e-tests/test-applications/deno/deno.json b/dev-packages/e2e-tests/test-applications/deno/deno.json index 35242c740171..5a684e3ed7c5 100644 --- a/dev-packages/e2e-tests/test-applications/deno/deno.json +++ b/dev-packages/e2e-tests/test-applications/deno/deno.json @@ -1,10 +1,15 @@ { "imports": { "@sentry/deno": "npm:@sentry/deno", + "@sentry/deno/import": "./node_modules/@sentry/deno/build/import.mjs", "@sentry/core": "npm:@sentry/core", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "ai": "npm:ai@^3.0.0", "ai/test": "npm:ai@^3.0.0/test", + "ioredis": "npm:ioredis@^5.11.0", + "mysql": "npm:mysql@2.18.1", + "pg": "npm:pg@8.16.0", + "redis": "npm:redis@^5.12.0", "zod": "npm:zod@^3.22.4" }, "nodeModulesDir": "manual" diff --git a/dev-packages/e2e-tests/test-applications/deno/docker-compose.yml b/dev-packages/e2e-tests/test-applications/deno/docker-compose.yml new file mode 100644 index 000000000000..5bc98ef22c13 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/docker-compose.yml @@ -0,0 +1,47 @@ +services: + mysql: + image: mysql:8.0 + restart: always + container_name: e2e-tests-deno-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'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: password + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s + + postgres: + image: postgres:13 + restart: always + container_name: e2e-tests-deno-postgres + ports: + - '5432:5432' + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: postgres + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 5s + + redis: + image: redis:8 + restart: always + container_name: e2e-tests-deno-redis + ports: + - '6379:6379' + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 30 diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs b/dev-packages/e2e-tests/test-applications/deno/global-setup.mjs similarity index 64% rename from dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs rename to dev-packages/e2e-tests/test-applications/deno/global-setup.mjs index ba03e561eb6f..bd183b8cd220 100644 --- a/dev-packages/e2e-tests/test-applications/deno-redis/global-setup.mjs +++ b/dev-packages/e2e-tests/test-applications/deno/global-setup.mjs @@ -5,8 +5,9 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); export default async function globalSetup() { - // Start Redis via Docker Compose. `--wait` blocks until the healthcheck - // in docker-compose.yml passes, so the Deno app can connect immediately. + // Start MySQL, Postgres and Redis via Docker Compose. `--wait` blocks + // until the healthchecks in docker-compose.yml pass, so the Deno app can + // connect immediately. execSync('docker compose up -d --wait', { cwd: __dirname, stdio: 'inherit', diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/deno/global-teardown.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno-mysql/global-teardown.mjs rename to dev-packages/e2e-tests/test-applications/deno/global-teardown.mjs diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json index 68752bf82541..22b3bf4544c3 100644 --- a/dev-packages/e2e-tests/test-applications/deno/package.json +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -3,16 +3,22 @@ "version": "1.0.0", "private": true, "scripts": { - "start": "deno run --allow-net --allow-env --allow-read --allow-sys src/app.ts", + "start": "docker compose up -d --wait && deno run --allow-net --allow-env --allow-read --allow-sys --allow-write --preload=@sentry/deno/import src/app.ts", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install", - "test:assert": "pnpm test" + "test:assert": "pnpm test", + "test:build:static": "E2E_TEST_STATIC=true pnpm install", + "test:assert:static": "E2E_TEST_STATIC=true pnpm test" }, "dependencies": { "@sentry/deno": "file:../../packed/sentry-deno-packed.tgz", "@opentelemetry/api": "^1.9.1", "ai": "^3.0.0", + "ioredis": "^5.11.0", + "mysql": "2.18.1", + "pg": "8.16.0", + "redis": "^5.12.0", "zod": "^3.22.4" }, "devDependencies": { @@ -23,6 +29,13 @@ "extends": "../../package.json" }, "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "deno (static trace lifecycle)" + } + ], "optionalVariants": [ { "deno-version": "latest", diff --git a/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs index 3d3ab7d8df02..66d063976e96 100644 --- a/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/deno/playwright.config.mjs @@ -1,8 +1,20 @@ import { getPlaywrightConfig } from '@sentry-internal/test-utils'; -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, - port: 3030, -}); +// The static trace lifecycle runs as a `sentryTest` variant of this same app, +// so the specs that assert envelope shape are split by directory and only one +// set runs per mode. Everything directly under `tests/` runs in both. +const isStatic = !!process.env.E2E_TEST_STATIC; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + port: 3030, + }, + { + testIgnore: isStatic ? '**/streamed/**' : '**/static/**', + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', + }, +); export default config; diff --git a/dev-packages/e2e-tests/test-applications/deno/src/app.ts b/dev-packages/e2e-tests/test-applications/deno/src/app.ts index 71dba56c3c3b..6e0162ca98c3 100644 --- a/dev-packages/e2e-tests/test-applications/deno/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -15,6 +15,10 @@ trace.setGlobalTracerProvider(fakeProvider as any); import * as Sentry from '@sentry/deno'; import { generateText } from 'ai'; import { MockLanguageModelV1 } from 'ai/test'; +import IORedis from 'ioredis'; +import mysql from 'mysql'; +import pg from 'pg'; +import { createClient } from 'redis'; import { z } from 'zod'; Sentry.init({ @@ -23,11 +27,96 @@ Sentry.init({ debug: !!Deno.env.get('DEBUG'), tunnel: 'http://localhost:3031/', tracesSampleRate: 1, + // Left unset (so the default span streaming applies) unless the + // `deno (static trace lifecycle)` variant asks for the other lifecycle. + traceLifecycle: Deno.env.get('E2E_TEST_STATIC') ? 'static' : undefined, }); +// `mysql` and `pg` don't emit tracing signals on their own. The +// `--preload=@sentry/deno/import` in this app's start script registers the +// orchestrion runtime hook before the entry graph loads, so both are +// transformed to publish the `orchestrion:mysql:query` / `orchestrion:pg:query` +// diagnostics channels that `init()` above has just subscribed to. `deno.json` +// maps that specifier to the installed file rather than to +// `npm:@sentry/deno`, because this app installs the SDK from a local tarball. +// A tarball has no registry version for Deno to match a preloaded `npm:` +// specifier against. +const connection = mysql.createConnection({ + host: Deno.env.get('MYSQL_HOST') ?? '127.0.0.1', + port: Number(Deno.env.get('MYSQL_PORT') ?? 3306), + user: 'root', + password: 'password', +}); + +// Swallow connection errors (e.g. a DB container going away at teardown) so +// they don't become an uncaught exception that crashes the process on shutdown. +connection.on('error', (err: unknown) => { + // eslint-disable-next-line no-console + console.error('mysql connection error', err); +}); + +connection.connect((err: unknown) => { + if (err) { + // eslint-disable-next-line no-console + console.error('mysql connect error', err); + } +}); + +const pgClient = new pg.Client({ + host: Deno.env.get('PGHOST') ?? '127.0.0.1', + port: Number(Deno.env.get('PGPORT') ?? 5432), + user: 'postgres', + password: 'password', + database: 'postgres', +}); + +pgClient.on('error', (err: unknown) => { + // eslint-disable-next-line no-console + console.error('pg client error', err); +}); + +pgClient.connect((err: unknown) => { + if (err) { + // eslint-disable-next-line no-console + console.error('pg connect error', err); + } +}); + +const redisUrl = Deno.env.get('REDIS_URL') ?? 'redis://127.0.0.1:6379'; + +// One shared client per process. node-redis publishes to the +// `node-redis:command` / `:batch` / `:connect` diagnostics channels for every +// operation on this client; redisIntegration is already subscribed to +// those. +const redis = createClient({ url: redisUrl }); +function onRedisError(err: unknown) { + // eslint-disable-next-line no-console + console.error('redis client error', err); +} +redis.on('error', onRedisError); +await redis.connect(); + +// Separate ioredis client. ioredis >= 5.11 publishes to the `ioredis:command` +// and `ioredis:connect` channels, which redisIntegration also subscribes +// to. lazyConnect so we can yield a microtick before connecting and ensure +// the DC subscriber is registered before ioredis creates its tracing channels. +await Promise.resolve(); +const ioredisUrl = new URL(redisUrl); +const ioredis = new IORedis({ + host: ioredisUrl.hostname, + port: Number(ioredisUrl.port) || 6379, + lazyConnect: true, +}); +function onIoredisError(err: unknown) { + // eslint-disable-next-line no-console + console.error('ioredis client error', err); +} +ioredis.on('error', onIoredisError); +await ioredis.connect(); + const port = 3030; -Deno.serve({ port }, async (req: Request) => { +Deno.serve({ port, hostname: '0.0.0.0' }, async (req: Request) => { const url = new URL(req.url); if (url.pathname === '/test-success') { @@ -300,6 +389,101 @@ Deno.serve({ port }, async (req: Request) => { }); } + // Runs two queries, the second NESTED inside the first's callback. mysql + // dispatches that callback from its socket data handler (a fresh async + // context), so the nested query's span only lands on this request's + // http.server transaction if `denoMysqlIntegration`'s AsyncLocalStorage + // context strategy restored the parent across the async boundary. + if (url.pathname === '/test-mysql') { + await new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + connection.query('SELECT NOW()', (err2: unknown) => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + return Response.json({ status: 'ok' }); + } + + // Same nested-callback shape as `/test-mysql`, for + // `denoPostgresIntegration`'s context strategy. + if (url.pathname === '/test-pg') { + await new Promise((resolve, reject) => { + pgClient.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + pgClient.query('SELECT NOW()', (err2: unknown) => { + if (err2) return reject(err2); + resolve(); + }); + }); + }); + return Response.json({ status: 'ok' }); + } + + // node-redis: GET — exercises the command channel, success path. + if (url.pathname === '/redis-get') { + const key = url.searchParams.get('key') ?? 'cache:key'; + const value = await redis.get(key); + return Response.json({ key, value }); + } + + // node-redis: SET then GET — exercises two commands inside a single + // transaction so we can assert the parent has two db.query children. + if (url.pathname === '/redis-set-get') { + const key = url.searchParams.get('key') ?? 'cache:key'; + const value = url.searchParams.get('value') ?? 'hello'; + await redis.set(key, value); + const echoed = await redis.get(key); + return Response.json({ key, value: echoed }); + } + + // node-redis: MULTI — exercises the batch channel. + if (url.pathname === '/redis-multi') { + const result = await redis.multi().set('multi:a', '1').set('multi:b', '2').get('multi:a').exec(); + return Response.json({ result }); + } + + // ioredis: GET — exercises the ioredis:command channel. + if (url.pathname === '/ioredis-get') { + const key = url.searchParams.get('key') ?? 'iocache:key'; + const value = await ioredis.get(key); + return Response.json({ key, value }); + } + + // ioredis: SET then GET — two commands inside a transaction. + if (url.pathname === '/ioredis-set-get') { + const key = url.searchParams.get('key') ?? 'iocache:key'; + const value = url.searchParams.get('value') ?? 'hello'; + await ioredis.set(key, value); + const echoed = await ioredis.get(key); + return Response.json({ key, value: echoed }); + } + + // ioredis: MULTI — ioredis has no separate batch channel; per-command + // payloads carry `batchMode`/`batchSize` instead, so we still expect one + // db.query span per command. + if (url.pathname === '/ioredis-multi') { + const result = await ioredis.multi().set('iomulti:a', '1').set('iomulti:b', '2').get('iomulti:a').exec(); + return Response.json({ result }); + } + + // ioredis: PIPELINE — same shape as MULTI from the perspective of the + // diagnostics channel. + if (url.pathname === '/ioredis-pipeline') { + const result = await ioredis.pipeline().set('iopipe:a', '1').set('iopipe:b', '2').get('iopipe:a').exec(); + return Response.json({ result }); + } + + if (url.pathname === '/redis-disconnect') { + redis.off('error', onRedisError); + redis.close(); + ioredis.off('error', onIoredisError); + ioredis.disconnect(); + return new Response('ok'); + } + return new Response('Not found', { status: 404 }); }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai-error.test.ts similarity index 87% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/ai-error.test.ts index 5edaa5000f91..8cf82e56de15 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai-error.test.ts @@ -2,11 +2,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; test('should link AI errors to the correct trace', async ({ baseURL }) => { - const aiTransactionPromise = waitForTransaction('deno-static', event => { + const aiTransactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'ai-error-test') ?? false; }); - const errorEventPromise = waitForError('deno-static', event => { + const errorEventPromise = waitForError('deno', event => { return event.exception?.values?.[0]?.value?.includes('Tool call failed') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai.test.ts similarity index 96% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/ai.test.ts index cf4d77de93bc..0ba5f463d65d 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/ai.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/ai.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) => { - const aiTransactionPromise = waitForTransaction('deno-static', event => { + const aiTransactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'ai-test') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/fetch.test.ts similarity index 88% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/fetch.test.ts index 3386c6bcc7a8..7a0dcb30c82e 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/fetch.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/fetch.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-outgoing-fetch') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/static/transactions.test.ts similarity index 90% rename from dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/static/transactions.test.ts index 75f925594005..19077bb76b75 100644 --- a/dev-packages/e2e-tests/test-applications/deno-static/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/static/transactions.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; test('Sends transaction with Sentry.startSpan', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-sentry-span') ?? false; }); @@ -21,7 +21,7 @@ test('Sends transaction with Sentry.startSpan', async ({ baseURL }) => { }); test('Sends transaction with OTel tracer.startSpan despite pre-existing provider', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-otel-span') ?? false; }); @@ -45,7 +45,7 @@ test('Sends transaction with OTel tracer.startSpan despite pre-existing provider }); test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'test-otel-active-span') ?? false; }); @@ -68,7 +68,7 @@ test('Sends transaction with OTel tracer.startActiveSpan', async ({ baseURL }) = }); test('OTel span appears as child of Sentry span (interop)', async ({ baseURL }) => { - const transactionPromise = waitForTransaction('deno-static', event => { + const transactionPromise = waitForTransaction('deno', event => { return event?.spans?.some(span => span.description === 'sentry-parent') ?? false; }); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai-error.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai-error.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/ai.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/fetch.test.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/fetch.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ioredis.test.ts similarity index 77% rename from dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/ioredis.test.ts index 2dd1df6d0f8a..39208a581309 100644 --- a/dev-packages/e2e-tests/test-applications/deno-redis/tests/ioredis.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/ioredis.test.ts @@ -1,30 +1,14 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean { - return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path; -} - -function isRedisCommand(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'db.query'; -} - -// `db.query.text` carries the key, so with span streaming a redis command span is named -// `{db.operation.name} {server.address}:{server.port}` instead. -function expectedCommandName(span: SerializedStreamedSpan): string { - const { 'db.operation.name': operation, 'server.address': address, 'server.port': port } = span.attributes; - return `${operation?.value} ${address?.value}:${port?.value}`; -} +import { expectedCommandName, isRedisCommand, isSegmentFor } from './utils'; test('ioredis GET emits an http.server segment containing a db.query child span', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the ioredis command runs inside it, so the // child span joins that trace. const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-get')) && spans.some(isRedisCommand), ); @@ -48,7 +32,7 @@ test('ioredis GET emits an http.server segment containing a db.query child span' test('ioredis SET then GET emit two db.query child spans on the same trace', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-set-get')) && spans.filter(isRedisCommand).length >= 2, ); @@ -72,7 +56,7 @@ test('ioredis MULTI emits one db.query span per command (no batch channel)', asy // own payload. So the trace should contain multiple command child spans, // but no PIPELINE/MULTI batch span. const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-multi')) && spans.filter(isRedisCommand).length >= 3, ); @@ -93,7 +77,7 @@ test('ioredis MULTI emits one db.query span per command (no batch channel)', asy test('ioredis PIPELINE emits one db.query span per command', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/ioredis-pipeline')) && spans.filter(isRedisCommand).length >= 3, ); diff --git a/dev-packages/e2e-tests/test-applications/deno-mysql/tests/mysql.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/mysql.test.ts similarity index 77% rename from dev-packages/e2e-tests/test-applications/deno-mysql/tests/mysql.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/mysql.test.ts index 0ac07ed48870..eb84da633a5a 100644 --- a/dev-packages/e2e-tests/test-applications/deno-mysql/tests/mysql.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/mysql.test.ts @@ -1,20 +1,17 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isTestMysqlSegment(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-mysql'; -} +import { isSegmentFor } from './utils'; + +const isRequestSegment = isSegmentFor('/test-mysql'); test('mysql queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the mysql queries run inside it, so their // db spans join that trace. const spansPromise = collectStreamedSpans( - 'deno-mysql', - spans => spans.some(isTestMysqlSegment) && spans.some(span => getSpanOp(span) === 'db'), + 'deno', + spans => spans.some(isRequestSegment) && spans.some(span => getSpanOp(span) === 'db'), ); const res = await fetch(`${baseURL}/test-mysql`); @@ -44,8 +41,8 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored // restored the parent span across that async boundary (otherwise the nested // query would start its own trace and never join this one). const spansPromise = collectStreamedSpans( - 'deno-mysql', - spans => spans.some(isTestMysqlSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, + 'deno', + spans => spans.some(isRequestSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, ); const res = await fetch(`${baseURL}/test-mysql`); @@ -53,7 +50,7 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored await res.json(); const spans = await spansPromise; - const segment = spans.find(isTestMysqlSegment)!; + const segment = spans.find(isRequestSegment)!; const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); const queries = dbSpans.map(span => span.attributes['db.query.text']?.value); diff --git a/dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/pg.test.ts similarity index 77% rename from dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/pg.test.ts index 32f24dcb43a4..be7d2aefec8e 100644 --- a/dev-packages/e2e-tests/test-applications/deno-pg/tests/pg.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/pg.test.ts @@ -1,20 +1,17 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import type { SerializedStreamedSpan } from '@sentry/core'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isTestPgSegment(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === '/test-pg'; -} +import { isSegmentFor } from './utils'; + +const isRequestSegment = isSegmentFor('/test-pg'); test('pg queries emit a db span with orchestrion-channel attributes', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the pg queries run inside it, so their // db spans join that trace. const spansPromise = collectStreamedSpans( - 'deno-pg', - spans => spans.some(isTestPgSegment) && spans.some(span => getSpanOp(span) === 'db'), + 'deno', + spans => spans.some(isRequestSegment) && spans.some(span => getSpanOp(span) === 'db'), ); const res = await fetch(`${baseURL}/test-pg`); @@ -45,8 +42,8 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored // (otherwise the nested query would start its own trace and never join // this one). const spansPromise = collectStreamedSpans( - 'deno-pg', - spans => spans.some(isTestPgSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, + 'deno', + spans => spans.some(isRequestSegment) && spans.filter(span => getSpanOp(span) === 'db').length >= 2, ); const res = await fetch(`${baseURL}/test-pg`); @@ -54,7 +51,7 @@ test('a nested query lands on the same trace (AsyncLocalStorage context restored await res.json(); const spans = await spansPromise; - const segment = spans.find(isTestPgSegment)!; + const segment = spans.find(isRequestSegment)!; const dbSpans = spans.filter(span => getSpanOp(span) === 'db'); const queries = dbSpans.map(span => span.attributes['db.query.text']?.value); diff --git a/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/redis.test.ts similarity index 76% rename from dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/redis.test.ts index 999444a1d3f6..4660f92ad7a8 100644 --- a/dev-packages/e2e-tests/test-applications/deno-redis/tests/redis.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/redis.test.ts @@ -2,29 +2,14 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import type { SerializedStreamedSpan } from '@sentry/core'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only; the path lives in `url.path`. -function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean { - return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path; -} - -function isRedisCommand(span: SerializedStreamedSpan): boolean { - return getSpanOp(span) === 'db.query'; -} - -// `db.query.text` carries the key, so with span streaming a redis command span is named -// `{db.operation.name} {server.address}:{server.port}` instead. -function expectedCommandName(span: SerializedStreamedSpan): string { - const { 'db.operation.name': operation, 'server.address': address, 'server.port': port } = span.attributes; - return `${operation?.value} ${address?.value}:${port?.value}`; -} +import { expectedCommandName, isRedisCommand, isSegmentFor } from './utils'; test('GET command emits an http.server segment containing a db.query child span', async ({ baseURL }) => { // Each incoming request gets a Sentry http.server segment span (via the // default denoServeIntegration); the redis command runs inside it, so the // child span joins that trace. const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/redis-get')) && spans.some(isRedisCommand), ); @@ -49,7 +34,7 @@ test('GET command emits an http.server segment containing a db.query child span' test('SET then GET emit two db.query child spans on the same trace', async ({ baseURL }) => { const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/redis-set-get')) && spans.filter(isRedisCommand).length >= 2, ); @@ -71,7 +56,7 @@ test('MULTI batch emits a PIPELINE/MULTI batch span', async ({ baseURL }) => { const isBatchSpan = (span: SerializedStreamedSpan) => span.name === 'MULTI' || span.name === 'PIPELINE'; const spansPromise = collectStreamedSpans( - 'deno-redis', + 'deno', spans => spans.some(isSegmentFor('/redis-multi')) && spans.some(isBatchSpan), ); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts similarity index 94% rename from dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts rename to dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts index 41ae5a43d59a..5d46c1daa69c 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts @@ -1,15 +1,7 @@ import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { getSpanOp } from '@sentry-internal/test-utils'; -// `Deno.serve` has no route information, so with span streaming the http.server segment is -// named after the method only and the path lives in `url.path`. -function collectRequestSpans(path: string) { - return collectStreamedSpans('deno', spans => - spans.some( - span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path, - ), - ); -} +import { collectRequestSpans } from './utils'; const SEGMENT_SPAN = { attributes: { diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/utils.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/utils.ts new file mode 100644 index 000000000000..b21f72febe6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/utils.ts @@ -0,0 +1,23 @@ +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import type { SerializedStreamedSpan } from '@sentry/core'; + +// `Deno.serve` has no route information, so with span streaming the http.server segment is +// named after the method only; the path lives in `url.path`. +export function isSegmentFor(path: string): (span: SerializedStreamedSpan) => boolean { + return span => getSpanOp(span) === 'http.server' && span.is_segment && span.attributes['url.path']?.value === path; +} + +export function collectRequestSpans(path: string): Promise { + return collectStreamedSpans('deno', spans => spans.some(isSegmentFor(path))); +} + +export function isRedisCommand(span: SerializedStreamedSpan): boolean { + return getSpanOp(span) === 'db.query'; +} + +// `db.query.text` carries the key, so with span streaming a redis command span is named +// `{db.operation.name} {server.address}:{server.port}` instead. +export function expectedCommandName(span: SerializedStreamedSpan): string { + const { 'db.operation.name': operation, 'server.address': address, 'server.port': port } = span.attributes; + return `${operation?.value} ${address?.value}:${port?.value}`; +} From bf2fbbf89205457bda08d0c524b6f83e6d7e00b8 Mon Sep 17 00:00:00 2001 From: isaacs Date: Fri, 18 Sep 2026 08:24:30 -0700 Subject: [PATCH 25/39] chore(test): De-flake slow and racy tests (#24452) Three unrelated flakes, all of which pass locally and fail only under CI load. **Tests that time out loading the SDK** Several unit tests time out in CI on the first test in a file. The first dynamic import of a large SDK graph pays for transforming it, and that cost is charged to whichever test triggers it. Locally it is a few hundred milliseconds. On a loaded runner it crosses the 5000ms test timeout. Swept every unit test in the repo and fixed the files that show the pattern. Fixed with a bare side-effect `import` at the top of the file. Collection is not bounded by the test timeout, so pulling the load into it takes the cost off the test without changing what the test exercises. Exception: `nuxt vite/orchestrion` has to register its `vi.doMock` calls before the module under test loads, and a static import is hoisted above them, so it warms the graph from `beforeAll` instead. | file | before | after | | -------------------------------------- | -----: | ----: | | browser-utils inp | 594ms | 9ms | | browser-utils metricObserverOrdering | 409ms | 22ms | | nextjs appRouterRoutingInstrumentation | 750ms | 233ms | | nuxt vite/orchestrion | 536ms | 1ms | | sveltekit index.workerd | 625ms | 8ms | | tanstackstart-react client/tunnelRoute | 433ms | 1ms | Which file pays is not fixed: the two browser-utils files traded places between runs, because the transform lands on whichever test reaches the graph first. Three tests in these packages still run over 400ms, and all three are slow for a reason: - The two nextjs `appRouter` tests busy-wait 1100ms on purpose. - `conflictingDebugOptions` re-evaluates three SDK entry points for each `debug-build` mock, which no warmup can avoid. **INP span never sent** `hidePage` waits for one `requestIdleCallback` before hiding, assuming web-vitals has already queued its own idle callback and will therefore run first. But web-vitals only queues its callback once the click's Event Timing entry reaches its observer, which is after `hidePage` queues its own. So `hidePage` wins the race and hides the page too early. That loses the interaction. The forced report web-vitals does on `visibilitychange` runs while the metric is still -1, and the pending callback that sets the real value afterwards reports unforced. No INP is emitted at all and the test waits out its 30s timeout. Fixed by waiting for the entry and queuing the idle callback from a task after it, which puts web-vitals' callback first. Callers that never interacted skip the wait, so the 13 that only need the page hidden pay nothing. The react-router-6 e2e suite now passes 10/10 and drops from 1.4m to ~53s. **Cron job that never runs** Two bugs in `suites/cron/cron`, both reproducible under CPU load. `cron`'s `start()` sizes the delay to the next tick from two clock reads, clamps a negative result to -1, and then treats that -1 as "stop". A pause between the two reads that straddles a second boundary is enough. The job is left scheduled for nothing, and the scenario exits on its own 15s timer having sent nothing. That is the reported failure, which reads as `0/5 expected envelope(s)` on a clean exit 0 with no captured output. A probe measured that clamp firing 5 times in 613,775 calls under load and never on an idle machine. The scenario now starts the job again, which re-reads the clock away from the boundary, and throws if it still refuses, so a recurrence fails loudly instead of silently. Separately, each check-in is its own HTTP request, so the order they reach the mock server is not the order the SDK sent them in. Under load the `ok` check-in overtook `in_progress`. This is fallout from #20661, which moved these envelopes off stdout, where they were inherently ordered. The assertions are now `.unordered()`. Failed roughly 1 run in 30 under load before, pass 120/120 after. Co-authored-by: Claude Opus 5 (1M context) --- .../utils/helpers.ts | 60 +++++++++++++++--- .../suites/cron/cron/scenario.ts | 13 ++++ .../suites/cron/cron/test.ts | 3 + .../local-variables-out-of-app-default.js | 7 ++- .../local-variables-out-of-app.js | 7 ++- .../suites/public-api/LocalVariables/test.ts | 4 -- .../wait-for-local-variables.js | 34 +++++++++++ dev-packages/test-utils/src/page.ts | 61 ++++++++++++++++--- .../metricObserverOrdering.test.ts | 4 ++ .../browser-utils/test/web-vitals/inp.test.ts | 4 ++ .../appRouterRoutingInstrumentation.test.ts | 7 +++ packages/nuxt/test/vite/orchestrion.test.ts | 8 ++- packages/sveltekit/test/index.workerd.test.ts | 6 ++ .../test/client/tunnelRoute.test.ts | 5 ++ 14 files changed, 195 insertions(+), 28 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/public-api/LocalVariables/wait-for-local-variables.js diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 87372e0c0c97..97c40002e305 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -549,18 +549,61 @@ export async function getFirstSentryEnvelopeRequest( } export async function hidePage(page: Page): Promise { - // web-vitals defers processing an interaction's event entries into - // `requestIdleCallback(..., { timeout: 1000 })`, and Chromium only reaches idle here once that - // timeout elapses. Hiding the page first forces a report while the metric is still unset, so no - // vital is emitted at all. Idle callbacks run in scheduling order, so waiting for one queued now - // means web-vitals' earlier callback has already run. + // web-vitals processes an interaction's event entries in `requestIdleCallback(..., { timeout: + // 1000 })`, and Chromium only reaches idle here once that timeout elapses. Hiding before that + // callback runs loses the interaction: the forced report web-vitals does on `visibilitychange` + // runs ahead of it, while the callback itself reports unforced, so INP is never emitted at all. + // + // Idle callbacks run in scheduling order, but web-vitals only schedules its callback once the + // Event Timing entry reaches its observer, which is after the click. Queuing one right away + // therefore queues it first and hides the page too early. Waiting for the entry and queuing from + // a task after it keeps web-vitals ahead: its observer is registered first, so it is notified + // first, and the `setTimeout` lands after the microtask it defers that work into. await page.evaluate(() => { return new Promise(resolve => { - if (typeof requestIdleCallback !== 'function') { - resolve(); + const scheduleIdle = (): void => { + if (typeof requestIdleCallback !== 'function') { + resolve(); + return; + } + requestIdleCallback(() => resolve(), { timeout: 1000 }); + }; + + // Callers that never interacted have no Event Timing entry coming, so there is nothing to + // order against and nothing to wait for. + const interactionCount = (performance as Performance & { interactionCount?: number }).interactionCount ?? 0; + if (!interactionCount && !performance.getEntriesByType('first-input').length) { + scheduleIdle(); return; } - requestIdleCallback(() => resolve(), { timeout: 1000 }); + + let observer: PerformanceObserver | undefined; + let fallback: ReturnType; + + // An interaction the Event Timing buffer no longer reports would otherwise wait here forever, + // so cap the wait rather than require an entry. + const done = (): void => { + clearTimeout(fallback); + observer?.disconnect(); + setTimeout(scheduleIdle, 0); + }; + + fallback = setTimeout(done, 1000); + + try { + // `durationThreshold` is missing from the DOM types, as it is in the SDK's own observer. + const eventOptions: PerformanceObserverInit & { durationThreshold?: number } = { + type: 'event', + buffered: true, + durationThreshold: 0, + }; + + observer = new PerformanceObserver(done); + observer.observe(eventOptions); + observer.observe({ type: 'first-input', buffered: true }); + } catch { + done(); + } }); }); @@ -572,7 +615,6 @@ export async function hidePage(page: Page): Promise { }, }); - // Dispatch the visibilitychange event to notify listeners document.dispatchEvent(new Event('visibilitychange')); }); } diff --git a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts index fa0e7bb4c601..f1e1ba0c3b8c 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/scenario.ts @@ -24,6 +24,19 @@ const cron = new CronJobWithCheckIn('* * * * * *', () => { cron.start(); +// `cron` sizes the delay to the next tick from two clock reads, clamps a negative result to -1 and +// then treats that -1 as "stop". A pause between those two reads that straddles a second boundary +// is enough to hit it, and the job is left scheduled for nothing: no ticks, no check-ins, and the +// scenario exits on the timeout below having sent nothing at all. Starting again re-reads the clock +// away from the boundary. +for (let attempt = 0; attempt < 5 && !cron.running; attempt++) { + cron.start(); +} + +if (!cron.running) { + throw new Error('`cron` refused to schedule the job'); +} + setTimeout(() => { process.exit(); }, 15_000); diff --git a/dev-packages/node-integration-tests/suites/cron/cron/test.ts b/dev-packages/node-integration-tests/suites/cron/cron/test.ts index d539f03eba44..68f0beda37d3 100644 --- a/dev-packages/node-integration-tests/suites/cron/cron/test.ts +++ b/dev-packages/node-integration-tests/suites/cron/cron/test.ts @@ -8,6 +8,9 @@ afterAll(() => { test('cron instrumentation', { timeout: 60_000 }, async () => { await createRunner(__dirname, 'scenario.ts') .withMockSentryServer() + // Each check-in is its own HTTP request, so the order they reach the mock server is not the + // order the SDK sent them in. Under load the `ok` check-in has overtaken the `in_progress` one. + .unordered() .expect({ check_in: { check_in_id: expect.any(String), diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js index 04c3e3e2142e..6e400cce7eba 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app-default.js @@ -1,5 +1,6 @@ const Sentry = require('@sentry/node'); const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { waitForLocalVariables } = require('./wait-for-local-variables'); const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); @@ -17,11 +18,13 @@ Sentry.init({ includeLocalVariables: true, }); -setTimeout(async () => { +void (async () => { + await waitForLocalVariables(); + try { in_app_function(); } catch (e) { Sentry.captureException(e); await Sentry.flush(); } -}, 500); +})(); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js index 66fd4d80e2d6..76fc88a8a905 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/local-variables-out-of-app.js @@ -1,5 +1,6 @@ const Sentry = require('@sentry/node'); const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const { waitForLocalVariables } = require('./wait-for-local-variables'); const externalFunctionFile = require.resolve('./node_modules/out-of-app-function.js'); @@ -22,11 +23,13 @@ function in_app_function() { out_of_app_function(`${inAppVar} modified value`); } -setTimeout(async () => { +void (async () => { + await waitForLocalVariables(); + try { in_app_function(); } catch (e) { Sentry.captureException(e); await Sentry.flush(); } -}, 500); +})(); diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts index fcb1c9dcfe72..63683b9ab7fd 100644 --- a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/test.ts @@ -3,10 +3,6 @@ import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; -// This test takes some time because it connects the debugger etc. -// So we increase the timeout here -// vi.setTimeout(45_000); - const EXPECTED_LOCAL_VARIABLES_EVENT = { exception: { values: [ diff --git a/dev-packages/node-integration-tests/suites/public-api/LocalVariables/wait-for-local-variables.js b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/wait-for-local-variables.js new file mode 100644 index 000000000000..680b15c0fe73 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/LocalVariables/wait-for-local-variables.js @@ -0,0 +1,34 @@ +// The async local variables integration attaches its debugger from a worker thread, and nothing +// reports when that is done. Until it has attached, a caught exception is not paused on and the +// frames arrive with no `vars` at all, so a scenario that throws too early sees the integration do +// nothing. Attaching takes around 100ms on an idle machine and over 300ms under load, which the +// fixed 500ms wait these scenarios used was not a safe margin for on a loaded CI runner. +// +// The integration tags the thrown error once it has captured its scope, so throwing a probe and +// looking for that tag is the signal. It is an internal key, so if it ever changes this stops +// resolving early and falls back to the bounded wait below, which is the behavior it replaces. +const LOCAL_VARIABLES_KEY = '__SENTRY_ERROR_LOCAL_VARIABLES__'; + +// Slow enough to stay well under the integration's 50 exceptions/second rate limit, which would +// otherwise switch it back to capturing uncaught exceptions only. +const POLL_INTERVAL_MS = 25; + +async function waitForLocalVariables(timeoutMs = 10_000) { + const giveUpAt = Date.now() + timeoutMs; + + while (Date.now() < giveUpAt) { + try { + throw new Error('local variables readiness probe'); + } catch (e) { + if (e[LOCAL_VARIABLES_KEY]) { + return true; + } + } + + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + return false; +} + +module.exports = { waitForLocalVariables }; diff --git a/dev-packages/test-utils/src/page.ts b/dev-packages/test-utils/src/page.ts index 11a444e572eb..83344967bcd4 100644 --- a/dev-packages/test-utils/src/page.ts +++ b/dev-packages/test-utils/src/page.ts @@ -4,23 +4,66 @@ import type { Page } from '@playwright/test'; * Hides the page so the SDK reports the web vitals that are only finalized on pagehide. */ export async function hidePage(page: Page): Promise { - // web-vitals defers processing an interaction's event entries into - // `requestIdleCallback(..., { timeout: 1000 })`, and Chromium only reaches idle here once that - // timeout elapses. Hiding the page first forces a report while the metric is still unset, so no - // vital is emitted at all. Idle callbacks run in scheduling order, so waiting for one queued now - // means web-vitals' earlier callback has already run. + // web-vitals processes an interaction's event entries in `requestIdleCallback(..., { timeout: + // 1000 })`, and Chromium only reaches idle here once that timeout elapses. Hiding before that + // callback runs loses the interaction: the forced report web-vitals does on `visibilitychange` + // runs ahead of it, while the callback itself reports unforced, so INP is never emitted at all. + // + // Idle callbacks run in scheduling order, but web-vitals only schedules its callback once the + // Event Timing entry reaches its observer, which is after the click. Queuing one right away + // therefore queues it first and hides the page too early. Waiting for the entry and queuing from + // a task after it keeps web-vitals ahead: its observer is registered first, so it is notified + // first, and the `setTimeout` lands after the microtask it defers that work into. + /* oxlint-disable no-restricted-globals */ await page.evaluate(() => { return new Promise(resolve => { - if (typeof requestIdleCallback !== 'function') { - resolve(); + const scheduleIdle = (): void => { + if (typeof requestIdleCallback !== 'function') { + resolve(); + return; + } + requestIdleCallback(() => resolve(), { timeout: 1000 }); + }; + + // Callers that never interacted have no Event Timing entry coming, so there is nothing to + // order against and nothing to wait for. + const interactionCount = (performance as Performance & { interactionCount?: number }).interactionCount ?? 0; + if (!interactionCount && !performance.getEntriesByType('first-input').length) { + scheduleIdle(); return; } - requestIdleCallback(() => resolve(), { timeout: 1000 }); + + let observer: PerformanceObserver | undefined; + let fallback: ReturnType; + + // An interaction the Event Timing buffer no longer reports would otherwise wait here forever, + // so cap the wait rather than require an entry. + const done = (): void => { + clearTimeout(fallback); + observer?.disconnect(); + setTimeout(scheduleIdle, 0); + }; + + fallback = setTimeout(done, 1000); + + try { + // `durationThreshold` is missing from the DOM types, as it is in the SDK's own observer. + const eventOptions: PerformanceObserverInit & { durationThreshold?: number } = { + type: 'event', + buffered: true, + durationThreshold: 0, + }; + + observer = new PerformanceObserver(done); + observer.observe(eventOptions); + observer.observe({ type: 'first-input', buffered: true }); + } catch { + done(); + } }); }); // The callback below runs in the page, so `document` is the browser's, not Node's. - /* oxlint-disable no-restricted-globals */ await page.evaluate(() => { Object.defineProperty(document, 'visibilityState', { configurable: true, diff --git a/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts b/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts index abaf22003625..f7b51f71c473 100644 --- a/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts +++ b/packages/browser-utils/test/instrumentation/metricObserverOrdering.test.ts @@ -1,4 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +// Loaded for its side effect: `performanceObserver` reaches `@sentry/core`, and transforming that +// graph is charged to whichever test imports it first, which timed out on slower CI runners. +// Importing it here pays that once at collect time instead. +import '@sentry/core'; // The web vital observers are shared: whoever registers the first handler used to create them, which // froze web-vitals' options for every other consumer. Replay registers its handlers from its own diff --git a/packages/browser-utils/test/web-vitals/inp.test.ts b/packages/browser-utils/test/web-vitals/inp.test.ts index ad21d8c86b42..a7aa2db57f54 100644 --- a/packages/browser-utils/test/web-vitals/inp.test.ts +++ b/packages/browser-utils/test/web-vitals/inp.test.ts @@ -3,6 +3,10 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Loaded for its side effect: importing the mocked specifier here runs the factory below at collect +// time, so the one-time transform of the `@sentry/core` graph is not charged to whichever test +// imports it first. That timed out on slower CI runners. +import '@sentry/core'; const windowListeners = vi.hoisted(() => new Map void>()); const performanceHandlers = vi.hoisted(() => new Map void>()); diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 098f54581b58..e54e9867cc89 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -3,6 +3,13 @@ import type { Client } from '@sentry/core'; import type * as SentryCore from '@sentry/core'; import type * as SentryReact from '@sentry/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Loaded for their side effect: `setup()` re-imports these for every test, and the first of +// those imports also pays for transforming the whole `@sentry/react` graph. Charged to a test +// that ran into the 5s timeout on a loaded CI runner; charged to collection it is untimed. +// Later imports only re-evaluate an already transformed graph, which is cheap. +import '@sentry/core'; +import '@sentry/react'; +import '../../src/client/routing/appRouterRoutingInstrumentation'; import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation'; import type { RouteManifest } from '../../src/config/manifest/types'; diff --git a/packages/nuxt/test/vite/orchestrion.test.ts b/packages/nuxt/test/vite/orchestrion.test.ts index 544de8c4ba60..9461699412b6 100644 --- a/packages/nuxt/test/vite/orchestrion.test.ts +++ b/packages/nuxt/test/vite/orchestrion.test.ts @@ -21,14 +21,18 @@ function createMockNuxt(options: { _prepare?: boolean; dev?: boolean } = {}) { } describe('setupOrchestrion', () => { - beforeAll(() => { + beforeAll(async () => { vi.doMock('@sentry/server-utils/orchestrion/config', () => ({ INSTRUMENTED_MODULE_NAMES: ['mysql', 'ioredis'], })); vi.doMock('@sentry/server-utils/orchestrion/rollup', () => ({ sentryOrchestrionPlugin: mockSentryOrchestrionPlugin, })); - }); + // The module reaches `@sentry/core` and `@nuxt/kit` through `./utils`. Transforming those + // charged the first test, which timed out on slower CI runners. The tests never reset the + // module registry, so this one evaluation is the one they all reuse. + await import('../../src/vite/orchestrion'); + }, 60_000); afterAll(() => { vi.doUnmock('@sentry/server-utils/orchestrion/config'); diff --git a/packages/sveltekit/test/index.workerd.test.ts b/packages/sveltekit/test/index.workerd.test.ts index 1326f3ef4064..1e9799a1c864 100644 --- a/packages/sveltekit/test/index.workerd.test.ts +++ b/packages/sveltekit/test/index.workerd.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getCloudflareExecutionContext, setCloudflareExecutionContextFallback } from '../src/server-common/utils'; +// Loaded for its side effect: `index.workerd` re-exports this, and its `@sentry/cloudflare` graph +// is the bulk of the import each test makes. Transforming it charged the first test, which timed +// out on slower CI runners. Importing the re-exported module rather than `index.workerd` itself +// leaves the registry state the tests depend on alone: they need their own `cloudflare:workers` +// mock in place before `index.workerd` first evaluates. +import '../src/worker'; describe('workerd entry point', () => { afterEach(() => { diff --git a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts index 90b91481305b..9dca196f072d 100644 --- a/packages/tanstackstart-react/test/client/tunnelRoute.test.ts +++ b/packages/tanstackstart-react/test/client/tunnelRoute.test.ts @@ -1,6 +1,11 @@ import type { BrowserOptions } from '@sentry/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +// Loaded for its side effect: the module under test reaches `@sentry/core`, and transforming that +// graph is charged to whichever test imports it first, which timed out on slower CI runners. +// Importing it here pays that once at collect time instead. +import '@sentry/core'; + describe('applyTunnelRouteOption()', () => { beforeEach(() => { vi.resetModules(); From e99382aeaac40f391234a3ff45f8c4d8897dce65 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Sun, 20 Sep 2026 14:51:06 +0200 Subject: [PATCH 26/39] ref(bundler-plugins)!: Require webpack 5.1 and remove the `webpack5` entry point (#24455) The webpack plugin still carried its webpack 4 era structure: a `sentryWebpackPluginFactory` that took `BannerPlugin`/`DefinePlugin` classes so the same code could serve webpack 4 and 5, a `require('webpack')` at module load to feed those fallbacks, and a separate `webpack5` entry point whose only difference was calling that factory without them. Webpack 4 hasn't been supported for a long time. The plugin now reads the classes from `compiler.webpack` only, so the factory and the fallback are gone. `compiler.webpack` was added in webpack 5.1.0, three days after 5.0.0, so the `webpack` peer dependency moves to `>=5.1.0`. With the fallback gone there is nothing left to distinguish `@sentry/bundler-plugins/webpack5` from `@sentry/bundler-plugins/webpack`, so the `webpack5` entry point is removed rather than deprecated. Both changes are documented in the v11 migration guide. --------- Co-authored-by: Tim Fish Co-authored-by: Charly Gomez --- MIGRATION.md | 13 + packages/bundler-plugins/package.json | 8 +- .../bundler-plugins/rollup.npm.config.mjs | 1 - packages/bundler-plugins/src/webpack/index.ts | 362 +++++++++++++++-- .../src/webpack/webpack4and5.ts | 375 ------------------ .../bundler-plugins/src/webpack/webpack5.ts | 9 - .../test/webpack/public-api.test.ts | 51 ++- .../test/webpack/webpack5.test.ts | 21 - yarn.lock | 40 +- 9 files changed, 404 insertions(+), 476 deletions(-) delete mode 100644 packages/bundler-plugins/src/webpack/webpack4and5.ts delete mode 100644 packages/bundler-plugins/src/webpack/webpack5.ts delete mode 100644 packages/bundler-plugins/test/webpack/webpack5.test.ts diff --git a/MIGRATION.md b/MIGRATION.md index bc8e4e35c9cb..d0e36226ad56 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -54,6 +54,7 @@ We raised the minimum supported versions of several frameworks and libraries: - **Astro:** dropped Astro 3 (minimum is now 4). - **React Router (framework mode):** minimum is now 7.15. - **Fastify:** dropped Fastify 3.0 through 3.20 (minimum is now 3.21). +- **webpack (bundler plugin):** dropped webpack 5.0.x (minimum is now 5.1). ### AWS Lambda Layer Changes @@ -1818,6 +1819,18 @@ The deprecated `sourceMapsUploadOptions` and other deprecated Vite/build plugin Deploys that the bundler plugins create automatically on Vercel now use the value of `VERCEL_TARGET_ENV` (`production`, `preview`, or a custom environment name) as their environment instead of `vercel-production` / `vercel-preview`. This matches the new default runtime `environment` of `@sentry/nextjs`, and the `production` default of all other SDKs. If your events use a different environment, set `release.deploy.env` to the same value, or set `release.deploy` to `false` to opt out. +### Bundler plugins: `@sentry/bundler-plugins/webpack5` was removed + +The `@sentry/bundler-plugins/webpack5` entry point was removed. It exported the same `sentryWebpackPlugin` as `@sentry/bundler-plugins/webpack`, minus a fallback that only mattered on webpack 4 and 5.0.x. The webpack plugin now requires webpack 5.1 or newer (the first version that exposes `compiler.webpack`), so there is nothing left to distinguish the two entry points. + +```js +// before +import { sentryWebpackPlugin } from '@sentry/bundler-plugins/webpack5'; + +// after +import { sentryWebpackPlugin } from '@sentry/bundler-plugins/webpack'; +``` + ### Removed `unstable_` bundler plugin options The `unstable_sentry*PluginOptions` escape hatch was removed from every SDK. It existed because the Sentry diff --git a/packages/bundler-plugins/package.json b/packages/bundler-plugins/package.json index cf8aaae74685..11a34f9e063a 100644 --- a/packages/bundler-plugins/package.json +++ b/packages/bundler-plugins/package.json @@ -24,11 +24,6 @@ "import": "./build/esm/webpack/index.js", "require": "./build/cjs/webpack/index.js" }, - "./webpack5": { - "types": "./build/types/webpack/webpack5.d.ts", - "import": "./build/esm/webpack/webpack5.js", - "require": "./build/cjs/webpack/webpack5.js" - }, "./rollup": { "types": "./build/types/rollup/index.d.ts", "import": "./build/esm/rollup/index.js", @@ -121,7 +116,7 @@ }, "peerDependencies": { "rollup": ">=3.2.0", - "webpack": ">=5.0.0" + "webpack": ">=5.1.0" }, "peerDependenciesMeta": { "rollup": { @@ -135,7 +130,6 @@ "@babel/preset-react": "^7.23.3", "@types/babel__core": "^7.20.5", "@types/node": "^18.6.3", - "@types/webpack": "npm:@types/webpack@^4", "premove": "^4.0.0", "rolldown": "^1.0.0", "vitest": "^3.2.7", diff --git a/packages/bundler-plugins/rollup.npm.config.mjs b/packages/bundler-plugins/rollup.npm.config.mjs index bd0f18d8a23d..fe1cb44cbe76 100644 --- a/packages/bundler-plugins/rollup.npm.config.mjs +++ b/packages/bundler-plugins/rollup.npm.config.mjs @@ -8,7 +8,6 @@ export default makeNPMConfigVariants( 'src/vite/index.ts', 'src/esbuild/index.ts', 'src/webpack/index.ts', - 'src/webpack/webpack5.ts', 'src/webpack/component-annotation-transform.ts', 'src/babel-plugin/index.ts', ], diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index 634f2c1e958f..0597b5794c08 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -1,37 +1,353 @@ -import type { SentryWebpackPluginOptions } from './webpack4and5'; -import { sentryWebpackPluginFactory } from './webpack4and5'; +import type { Options } from '../core/index'; +import { + createSentryBuildPluginManager, + generateReleaseInjectorCode, + generateModuleMetadataInjectorCode, + stringToUUID, + createComponentNameAnnotateHooks, + CodeInjection, + getDebugIdSnippet, + createDebugIdUploadFunction, + isJsFile, + stampDebugId, +} from '../core/index'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type PluginClass = new (options: any) => unknown; +const _req = createRequire(import.meta.url); + +// Resolve the loader path via the package's own exports. +// This module may end up in a shared chunk (_chunks/) whose import.meta.url +// does not point to the webpack/ directory where the transform file lives, so +// a path-relative lookup would fail. Using require.resolve on the package export +// always finds the correct installed file regardless of chunk placement. +let COMPONENT_ANNOTATION_LOADER: string; +try { + COMPONENT_ANNOTATION_LOADER = _req.resolve('@sentry/bundler-plugins/webpack-loader'); +} catch { + // Fallback for non-packaged environments (e.g., monorepo source runs without dist) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Rollup transpiles import.meta for us for CJS + const dirname = path.dirname(fileURLToPath(import.meta.url)); + // The Rollup build emits `.js` for both CJS and ESM, so the extension is the same in both. + COMPONENT_ANNOTATION_LOADER = path.resolve(dirname, 'component-annotation-transform.js'); +} + +interface BannerPluginCallbackArg { + chunk?: { + hash?: string; + contentHash?: { + javascript?: string; + }; + }; +} + +type PluginClass = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new (options: any): unknown; +}; + +type WebpackSource = { + source: () => string | Buffer; +}; + +type WebpackRawSource = { + new (source: string): WebpackSource; +}; + +type WebpackAsset = { + name: string; + source: WebpackSource; + info: { + related?: { + sourceMap?: string | string[]; + }; + }; +}; + +type WebpackCompiler = { + options: { + plugins?: unknown[]; + mode?: string; + module?: { + rules?: unknown[]; + }; + }; + hooks: { + thisCompilation: { + tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; + }; + afterEmit: { + tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; + }; + done: { + tap: (name: string, callback: () => void) => void; + }; + }; + webpack?: { + BannerPlugin?: PluginClass; + DefinePlugin?: PluginClass; + Compilation?: { + PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; + }; + sources?: { + RawSource?: WebpackRawSource; + }; + }; +}; + +type WebpackCompilation = { + outputOptions: { + path?: string; + }; + assets: Record; + getAssets: () => WebpackAsset[]; + getAsset: (name: string) => WebpackAsset | undefined; + updateAsset: (name: string, source: WebpackSource) => void; + hooks: { + processAssets: { + tap: (options: { name: string; stage: number }, callback: () => void) => void; + }; + }; +}; type WebpackModule = { - BannerPlugin?: PluginClass; - DefinePlugin?: PluginClass; - default?: WebpackModule; + version?: string; + default?: { version?: string }; }; -// `webpack` is an optional peer dependency. We require it lazily so the plugin doesn't -// crash on load in bundlers that don't ship `webpack` (e.g. rspack) — those provide -// the plugin classes via `compiler.webpack` at runtime instead. -function loadWebpack(): WebpackModule { +// Only used for telemetry; `webpack` is an optional peer dependency and may be absent (e.g. rspack). +function getWebpackMajorVersion(): string | undefined { try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Rollup transpiles import.meta for CJS - return createRequire(import.meta.url)('webpack') as WebpackModule; + const webpack = _req('webpack') as WebpackModule; + const version = webpack.version ?? webpack.default?.version; + return version?.split('.')[0]; } catch { - return {}; + return undefined; + } +} + +/** + * Stamps each JS asset's debug ID into the asset itself and its source map asset. + * + * Runs after source maps have been generated, so the JS asset no longer needs to carry + * source map information and can be replaced with a plain `RawSource`. + */ +function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: WebpackRawSource): void { + for (const asset of compilation.getAssets()) { + if (!isJsFile(asset.name)) { + continue; + } + + const bundleSource = asset.source.source().toString(); + const relatedSourceMap = asset.info.related?.sourceMap; + const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; + const sourceMapAsset = compilation.getAsset(sourceMapName); + + const stamped = stampDebugId(bundleSource, sourceMapAsset?.source.source().toString()); + if (!stamped) { + continue; + } + + compilation.updateAsset(asset.name, new RawSource(stamped.bundleSource)); + if (stamped.sourceMapSource !== undefined) { + compilation.updateAsset(sourceMapName, new RawSource(stamped.sourceMapSource)); + } } } -const webpack = loadWebpack(); -const BannerPlugin = webpack.BannerPlugin ?? webpack.default?.BannerPlugin; -const DefinePlugin = webpack.DefinePlugin ?? webpack.default?.DefinePlugin; +function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { + const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { + loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', + buildTool: 'webpack', + buildToolMajorVersion: getWebpackMajorVersion(), + }); + + const { + logger, + normalizedOptions: options, + bundleSizeOptimizationReplacementValues: replacementValues, + bundleMetadata, + createDependencyOnBuildArtifacts, + } = sentryBuildPluginManager; + + if (options.disable) { + return { + apply() { + // noop plugin + }, + }; + } + + if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { + logger.warn('Running Sentry plugin from within a `node_modules` folder. Some features may not work.'); + } + + const sourcemapsEnabled = options.sourcemaps?.disable !== true; + const staticInjectionCode = new CodeInjection(); + + if (!options.release.inject) { + logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); + } else if (!options.release.name) { + logger.debug( + 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', + ); + } else { + staticInjectionCode.append( + generateReleaseInjectorCode({ + release: options.release.name, + injectBuildInformation: options._experiments.injectBuildInformation || false, + }), + ); + } + + if (Object.keys(bundleMetadata).length > 0) { + staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + } + + const transformAnnotations = options.reactComponentAnnotation?.enabled + ? createComponentNameAnnotateHooks( + options.reactComponentAnnotation?.ignoredComponents || [], + !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, + ) + : undefined; + + const transformReplace = Object.keys(replacementValues).length > 0; + + return { + apply(compiler: WebpackCompiler) { + void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { + // Telemetry failures are acceptable + }); + + const { BannerPlugin, DefinePlugin } = compiler.webpack ?? {}; + + // Add BannerPlugin for code injection (release, metadata, debug IDs) + if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) { + if (!BannerPlugin) { + logger.warn( + 'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.', + ); + } else { + compiler.options.plugins = compiler.options.plugins || []; + compiler.options.plugins.push( + new BannerPlugin({ + raw: true, + include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, + banner: (arg?: BannerPluginCallbackArg) => { + const codeToInject = staticInjectionCode.clone(); + if (sourcemapsEnabled) { + const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; + const debugId = hash ? stringToUUID(hash) : randomUUID(); + codeToInject.append(getDebugIdSnippet(debugId)); + } + return codeToInject.code(); + }, + }), + ); + } + } + + // The upload routine (which stamps debug IDs into temp copies of the artifacts) is skipped + // with `disable-upload`, so the emitted artifacts get stamped in the asset pipeline instead. + if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { + const RawSource = compiler.webpack?.sources?.RawSource; + // Right after source map generation (and thus after minification, which would strip the comment), + // so later stages (real content hashing, subresource integrity) see the final assets. + const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_DEV_TOOLING ?? 500) + 1; + + if (!RawSource) { + logger.warn( + 'Webpack sources are not available. Skipping debug ID injection into emitted source maps. This usually means webpack is not properly configured.', + ); + } else { + compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { + compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage }, () => { + addDebugIdsToAssets(compilation, RawSource); + }); + }); + } + } + + // Add DefinePlugin for bundle size optimizations + if (transformReplace && DefinePlugin) { + compiler.options.plugins = compiler.options.plugins || []; + compiler.options.plugins.push(new DefinePlugin(replacementValues)); + } + + // Add component name annotation transform + if (transformAnnotations?.transform) { + compiler.options.module = compiler.options.module || {}; + compiler.options.module.rules = compiler.options.module.rules || []; + compiler.options.module.rules.unshift({ + test: /\.[jt]sx$/, + exclude: /node_modules/, + enforce: 'pre', + use: [ + { + loader: COMPONENT_ANNOTATION_LOADER, + options: { + transform: transformAnnotations.transform, + }, + }, + ], + }); + } + + compiler.hooks.afterEmit.tapAsync( + 'sentry-webpack-plugin', + (compilation: WebpackCompilation, callback: (err?: Error) => void) => { + const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); + const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); + + const run = async (): Promise => { + try { + await sentryBuildPluginManager.createRelease(); + if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { + const outputPath = compilation.outputOptions.path ?? path.resolve(); + const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); + await upload(buildArtifacts); + } + } finally { + freeGlobalDependencyOnBuildArtifacts(); + await sentryBuildPluginManager.deleteArtifacts(); + } + }; + + run().then( + () => callback(), + (err: Error) => callback(err), + ); + }, + ); + + if (userOptions._experiments?.forceExitOnBuildCompletion && compiler.options.mode === 'production') { + compiler.hooks.done.tap('sentry-webpack-plugin', () => { + setTimeout(() => { + logger.debug('Exiting process after debug file upload'); + process.exit(0); + }); + }); + } + }, + }; +} // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = sentryWebpackPluginFactory({ - BannerPlugin, - DefinePlugin, -}); +export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = createSentryWebpackPlugin; -export type { SentryWebpackPluginOptions }; +export type SentryWebpackPluginOptions = Options & { + _experiments?: Options['_experiments'] & { + /** + * If enabled, the webpack plugin will exit the build process after the build completes. + * Use this with caution, as it will terminate the process. + * + * More information: https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/345 + * + * @default false + */ + forceExitOnBuildCompletion?: boolean; + }; +}; diff --git a/packages/bundler-plugins/src/webpack/webpack4and5.ts b/packages/bundler-plugins/src/webpack/webpack4and5.ts deleted file mode 100644 index 42c635e37bb2..000000000000 --- a/packages/bundler-plugins/src/webpack/webpack4and5.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { Options } from '../core/index'; -import { - createSentryBuildPluginManager, - generateReleaseInjectorCode, - generateModuleMetadataInjectorCode, - stringToUUID, - createComponentNameAnnotateHooks, - CodeInjection, - getDebugIdSnippet, - createDebugIdUploadFunction, - isJsFile, - stampDebugId, -} from '../core/index'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; -import { randomUUID } from 'node:crypto'; - -const _req = createRequire(import.meta.url); - -// Resolve the loader path via the package's own exports. -// webpack4and5.ts may end up in a shared chunk (_chunks/) whose import.meta.url -// does not point to the webpack/ directory where the transform file lives, so -// a path-relative lookup would fail. Using require.resolve on the package export -// always finds the correct installed file regardless of chunk placement. -let COMPONENT_ANNOTATION_LOADER: string; -try { - COMPONENT_ANNOTATION_LOADER = _req.resolve('@sentry/bundler-plugins/webpack-loader'); -} catch { - // Fallback for non-packaged environments (e.g., monorepo source runs without dist) - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Rollup transpiles import.meta for us for CJS - const dirname = path.dirname(fileURLToPath(import.meta.url)); - // The Rollup build emits `.js` for both CJS and ESM, so the extension is the same in both. - COMPONENT_ANNOTATION_LOADER = path.resolve(dirname, 'component-annotation-transform.js'); -} - -// since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version -// https://github.com/webpack/webpack/commit/65eca2e529ce1d79b79200d4bdb1ce1b81141459 - -interface BannerPluginCallbackArg { - chunk?: { - hash?: string; - contentHash?: { - javascript?: string; - }; - }; -} - -type UnsafeBannerPlugin = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (options: any): unknown; -}; - -type UnsafeDefinePlugin = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - new (options: any): unknown; -}; - -type WebpackSource = { - source: () => string | Buffer; -}; - -type WebpackRawSource = { - new (source: string): WebpackSource; -}; - -type WebpackAsset = { - name: string; - source: WebpackSource; - info: { - related?: { - sourceMap?: string | string[]; - }; - }; -}; - -type WebpackCompiler = { - options: { - plugins?: unknown[]; - mode?: string; - module?: { - rules?: unknown[]; - }; - }; - hooks: { - thisCompilation: { - tap: (name: string, callback: (compilation: WebpackCompilation) => void) => void; - }; - afterEmit: { - tapAsync: (name: string, callback: (compilation: WebpackCompilation, cb: () => void) => void) => void; - }; - done: { - tap: (name: string, callback: () => void) => void; - }; - }; - webpack?: { - BannerPlugin?: UnsafeBannerPlugin; - DefinePlugin?: UnsafeDefinePlugin; - Compilation?: { - PROCESS_ASSETS_STAGE_DEV_TOOLING?: number; - }; - sources?: { - RawSource?: WebpackRawSource; - }; - }; -}; - -type WebpackCompilation = { - outputOptions: { - path?: string; - }; - assets: Record; - getAssets: () => WebpackAsset[]; - getAsset: (name: string) => WebpackAsset | undefined; - updateAsset: (name: string, source: WebpackSource) => void; - hooks: { - processAssets: { - tap: (options: { name: string; stage: number }, callback: () => void) => void; - }; - }; -}; - -// Detect webpack major version for telemetry (helps differentiate webpack 4 vs 5 usage) -function getWebpackMajorVersion(): string | undefined { - try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - Rollup already transpiles this for us - const req = createRequire(import.meta.url); - const webpack = req('webpack') as { version?: string; default?: { version?: string } }; - const version = webpack?.version ?? webpack?.default?.version; - const webpackMajorVersion = version?.split('.')[0]; // "4" or "5" - return webpackMajorVersion; - } catch { - return undefined; - } -} - -/** - * Stamps each JS asset's debug ID into the asset itself and its source map asset. - * - * Runs after source maps have been generated, so the JS asset no longer needs to carry - * source map information and can be replaced with a plain `RawSource`. - */ -function addDebugIdsToAssets(compilation: WebpackCompilation, RawSource: WebpackRawSource): void { - for (const asset of compilation.getAssets()) { - if (!isJsFile(asset.name)) { - continue; - } - - const bundleSource = asset.source.source().toString(); - const relatedSourceMap = asset.info.related?.sourceMap; - const sourceMapName = typeof relatedSourceMap === 'string' ? relatedSourceMap : `${asset.name}.map`; - const sourceMapAsset = compilation.getAsset(sourceMapName); - - const stamped = stampDebugId(bundleSource, sourceMapAsset?.source.source().toString()); - if (!stamped) { - continue; - } - - compilation.updateAsset(asset.name, new RawSource(stamped.bundleSource)); - if (stamped.sourceMapSource !== undefined) { - compilation.updateAsset(sourceMapName, new RawSource(stamped.sourceMapSource)); - } - } -} - -/** - * The factory function accepts BannerPlugin and DefinePlugin classes in - * order to avoid direct dependencies on webpack. - * - * This allow us to export version of the plugin for webpack 5.1+ and compatible environments. - * - * Since webpack 5.1 compiler contains webpack module so plugins always use correct webpack version. - */ -export function sentryWebpackPluginFactory({ - BannerPlugin: UnsafeBannerPlugin, - DefinePlugin: UnsafeDefinePlugin, -}: { - BannerPlugin?: UnsafeBannerPlugin; - DefinePlugin?: UnsafeDefinePlugin; -} = {}) { - return function sentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) { - const sentryBuildPluginManager = createSentryBuildPluginManager(userOptions, { - loggerPrefix: userOptions._metaOptions?.loggerPrefixOverride ?? '[sentry-webpack-plugin]', - buildTool: 'webpack', - buildToolMajorVersion: getWebpackMajorVersion(), - }); - - const { - logger, - normalizedOptions: options, - bundleSizeOptimizationReplacementValues: replacementValues, - bundleMetadata, - createDependencyOnBuildArtifacts, - } = sentryBuildPluginManager; - - if (options.disable) { - return { - apply() { - // noop plugin - }, - }; - } - - if (process.cwd().match(/\\node_modules\\|\/node_modules\//)) { - logger.warn('Running Sentry plugin from within a `node_modules` folder. Some features may not work.'); - } - - const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); - - if (!options.release.inject) { - logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); - } else if (!options.release.name) { - logger.debug( - 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', - ); - } else { - staticInjectionCode.append( - generateReleaseInjectorCode({ - release: options.release.name, - injectBuildInformation: options._experiments.injectBuildInformation || false, - }), - ); - } - - if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); - } - - const transformAnnotations = options.reactComponentAnnotation?.enabled - ? createComponentNameAnnotateHooks( - options.reactComponentAnnotation?.ignoredComponents || [], - !!options.reactComponentAnnotation?._experimentalInjectIntoHtml, - ) - : undefined; - - const transformReplace = Object.keys(replacementValues).length > 0; - - return { - apply(compiler: WebpackCompiler) { - void sentryBuildPluginManager.telemetry.emitBundlerPluginExecutionSignal().catch(() => { - // Telemetry failures are acceptable - }); - - // Get the correct plugin classes (webpack 5.1+ vs older versions) - const BannerPlugin = compiler?.webpack?.BannerPlugin || UnsafeBannerPlugin; - const DefinePlugin = compiler?.webpack?.DefinePlugin || UnsafeDefinePlugin; - - // Add BannerPlugin for code injection (release, metadata, debug IDs) - if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) { - if (!BannerPlugin) { - logger.warn( - 'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.', - ); - } else { - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push( - new BannerPlugin({ - raw: true, - include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/, - banner: (arg?: BannerPluginCallbackArg) => { - const codeToInject = staticInjectionCode.clone(); - if (sourcemapsEnabled) { - const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash; - const debugId = hash ? stringToUUID(hash) : randomUUID(); - codeToInject.append(getDebugIdSnippet(debugId)); - } - return codeToInject.code(); - }, - }), - ); - } - } - - // The upload routine (which stamps debug IDs into temp copies of the artifacts) is skipped - // with `disable-upload`, so the emitted artifacts get stamped in the asset pipeline instead. - if (sourcemapsEnabled && options.sourcemaps?.disable === 'disable-upload') { - const RawSource = compiler.webpack?.sources?.RawSource; - // Right after source map generation (and thus after minification, which would strip the comment), - // so later stages (real content hashing, subresource integrity) see the final assets. - const stage = (compiler.webpack?.Compilation?.PROCESS_ASSETS_STAGE_DEV_TOOLING ?? 500) + 1; - - if (!RawSource) { - logger.warn( - 'Webpack sources are not available. Skipping debug ID injection into emitted source maps. This usually means webpack is not properly configured.', - ); - } else { - compiler.hooks.thisCompilation.tap('sentry-webpack-plugin', compilation => { - compilation.hooks.processAssets.tap({ name: 'sentry-webpack-plugin', stage }, () => { - addDebugIdsToAssets(compilation, RawSource); - }); - }); - } - } - - // Add DefinePlugin for bundle size optimizations - if (transformReplace && DefinePlugin) { - compiler.options.plugins = compiler.options.plugins || []; - compiler.options.plugins.push(new DefinePlugin(replacementValues)); - } - - // Add component name annotation transform - if (transformAnnotations?.transform) { - compiler.options.module = compiler.options.module || {}; - compiler.options.module.rules = compiler.options.module.rules || []; - compiler.options.module.rules.unshift({ - test: /\.[jt]sx$/, - exclude: /node_modules/, - enforce: 'pre', - use: [ - { - loader: COMPONENT_ANNOTATION_LOADER, - options: { - transform: transformAnnotations.transform, - }, - }, - ], - }); - } - - compiler.hooks.afterEmit.tapAsync( - 'sentry-webpack-plugin', - (compilation: WebpackCompilation, callback: (err?: Error) => void) => { - const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); - const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); - - const run = async (): Promise => { - try { - await sentryBuildPluginManager.createRelease(); - if (sourcemapsEnabled && options.sourcemaps?.disable !== 'disable-upload') { - const outputPath = compilation.outputOptions.path ?? path.resolve(); - const buildArtifacts = Object.keys(compilation.assets).map(asset => path.join(outputPath, asset)); - await upload(buildArtifacts); - } - } finally { - freeGlobalDependencyOnBuildArtifacts(); - await sentryBuildPluginManager.deleteArtifacts(); - } - }; - - run().then( - () => callback(), - (err: Error) => callback(err), - ); - }, - ); - - if (userOptions._experiments?.forceExitOnBuildCompletion && compiler.options.mode === 'production') { - compiler.hooks.done.tap('sentry-webpack-plugin', () => { - setTimeout(() => { - logger.debug('Exiting process after debug file upload'); - process.exit(0); - }); - }); - } - }, - }; - }; -} - -export type SentryWebpackPluginOptions = Options & { - _experiments?: Options['_experiments'] & { - /** - * If enabled, the webpack plugin will exit the build process after the build completes. - * Use this with caution, as it will terminate the process. - * - * More information: https://github.com/getsentry/sentry-javascript-bundler-plugins/issues/345 - * - * @default false - */ - forceExitOnBuildCompletion?: boolean; - }; -}; diff --git a/packages/bundler-plugins/src/webpack/webpack5.ts b/packages/bundler-plugins/src/webpack/webpack5.ts deleted file mode 100644 index 063aee71da02..000000000000 --- a/packages/bundler-plugins/src/webpack/webpack5.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { SentryWebpackPluginOptions } from './webpack4and5'; -import { sentryWebpackPluginFactory } from './webpack4and5'; - -const createSentryWebpackPlugin = sentryWebpackPluginFactory(); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const sentryWebpackPlugin: (options?: SentryWebpackPluginOptions) => any = createSentryWebpackPlugin; - -export type { SentryWebpackPluginOptions }; diff --git a/packages/bundler-plugins/test/webpack/public-api.test.ts b/packages/bundler-plugins/test/webpack/public-api.test.ts index daa1f3e0d87b..97854c6352bf 100644 --- a/packages/bundler-plugins/test/webpack/public-api.test.ts +++ b/packages/bundler-plugins/test/webpack/public-api.test.ts @@ -1,12 +1,40 @@ import type { WebpackPluginInstance } from 'webpack'; import { sentryWebpackPlugin } from '../../src/webpack'; -import { describe, it, expect, test } from 'vitest'; +import { describe, it, expect, test, vi } from 'vitest'; test('Webpack plugin should exist', () => { expect(sentryWebpackPlugin).toBeDefined(); expect(typeof sentryWebpackPlugin).toBe('function'); }); +type PluginClass = new (options: unknown) => unknown; + +type Compiler = { + options: { plugins: unknown[] }; + hooks: Record>>; + webpack?: { BannerPlugin: PluginClass; DefinePlugin: PluginClass }; +}; + +class BannerPlugin { + public constructor(public options: unknown) {} +} + +class DefinePlugin { + public constructor(public options: unknown) {} +} + +function createCompiler(webpack?: Compiler['webpack']): Compiler { + return { + options: { plugins: [] }, + hooks: { + thisCompilation: { tap: vi.fn() }, + afterEmit: { tapAsync: vi.fn() }, + done: { tap: vi.fn() }, + }, + webpack, + }; +} + describe('sentryWebpackPlugin', () => { it('returns a webpack plugin', () => { const plugin = sentryWebpackPlugin({ @@ -18,4 +46,25 @@ describe('sentryWebpackPlugin', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment expect(plugin).toEqual({ apply: expect.any(Function) }); }); + + it('registers the plugin classes provided by `compiler.webpack`', () => { + const compiler = createCompiler({ BannerPlugin, DefinePlugin }); + + sentryWebpackPlugin({ telemetry: false, release: { name: 'my-release' } }).apply(compiler); + + expect(compiler.options.plugins).toEqual([expect.any(BannerPlugin)]); + }); + + it('warns instead of throwing when `compiler.webpack` is unavailable', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const compiler = createCompiler(undefined); + + expect(() => + sentryWebpackPlugin({ telemetry: false, release: { name: 'my-release' } }).apply(compiler), + ).not.toThrow(); + + expect(compiler.options.plugins).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('BannerPlugin is not available')); + warn.mockRestore(); + }); }); diff --git a/packages/bundler-plugins/test/webpack/webpack5.test.ts b/packages/bundler-plugins/test/webpack/webpack5.test.ts deleted file mode 100644 index b4d7b0b26f66..000000000000 --- a/packages/bundler-plugins/test/webpack/webpack5.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { WebpackPluginInstance } from 'webpack'; -import { sentryWebpackPlugin } from '../../src/webpack/index'; -import { describe, it, expect, test } from 'vitest'; - -test('Webpack plugin should exist', () => { - expect(sentryWebpackPlugin).toBeDefined(); - expect(typeof sentryWebpackPlugin).toBe('function'); -}); - -describe('sentryWebpackPlugin', () => { - it('returns a webpack plugin', () => { - const plugin = sentryWebpackPlugin({ - authToken: 'test-token', - org: 'test-org', - project: 'test-project', - }) as WebpackPluginInstance; - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - expect(plugin).toEqual({ apply: expect.any(Function) }); - }); -}); diff --git a/yarn.lock b/yarn.lock index e66adedac544..f8a780a37590 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9696,21 +9696,11 @@ dependencies: "@types/node" "*" -"@types/source-list-map@*": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.6.tgz#164e169dd061795b50b83c19e4d3be09f8d3a454" - integrity sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g== - "@types/symlink-or-copy@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/symlink-or-copy/-/symlink-or-copy-1.2.0.tgz#4151a81b4052c80bc2becbae09f3a9ec010a9c7a" integrity sha512-Lja2xYuuf2B3knEsga8ShbOdsfNOtzT73GyJmZyY7eGl2+ajOqrs8yM5ze0fsSoYwvA6bw7/Qr7OZ7PEEmYwWg== -"@types/tapable@^1": - version "1.0.12" - resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.12.tgz#bc2cab12e87978eee89fb21576b670350d6d86ab" - integrity sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q== - "@types/tough-cookie@*": version "4.0.0" resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" @@ -9721,13 +9711,6 @@ resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== -"@types/uglify-js@*": - version "3.17.5" - resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.5.tgz#905ce03a3cbbf2e31cbefcbc68d15497ee2e17df" - integrity sha512-TU+fZFBTBcXj/GpDpDaBmgWk/gn96kMZ+uocaFUlV2f8a6WdMzzI44QBCmGcCiYR0Y6ZlNRiyUyKKt5nl/lbzQ== - dependencies: - source-map "^0.6.1" - "@types/unist@*", "@types/unist@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.0.tgz#988ae8af1e5239e89f9fbb1ade4c935f4eeedf9a" @@ -9743,27 +9726,6 @@ resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== -"@types/webpack-sources@*": - version "3.2.3" - resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-3.2.3.tgz#b667bd13e9fa15a9c26603dce502c7985418c3d8" - integrity sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw== - dependencies: - "@types/node" "*" - "@types/source-list-map" "*" - source-map "^0.7.3" - -"@types/webpack@npm:@types/webpack@^4": - version "4.41.40" - resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.40.tgz#41ea11cfafe08de24c3ef410c58976350667e2d1" - integrity sha512-u6kMFSBM9HcoTpUXnL6mt2HSzftqb3JgYV6oxIgL2dl6sX6aCa5k6SOkzv5DuZjBTPUE/dJltKtwwuqrkZHpfw== - dependencies: - "@types/node" "*" - "@types/tapable" "^1" - "@types/uglify-js" "*" - "@types/webpack-sources" "*" - anymatch "^3.0.0" - source-map "^0.6.0" - "@types/whatwg-url@^13.0.0": version "13.0.0" resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-13.0.0.tgz#2b11e32772fd321c0dedf4d655953ea8ce587b2a" @@ -11210,7 +11172,7 @@ any-promise@^1.1.0: resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha1-q8av7tzqUugJzcA3au0845Y10X8= -anymatch@^3.0.0, anymatch@^3.1.1, anymatch@^3.1.3, anymatch@~3.1.2: +anymatch@^3.1.1, anymatch@^3.1.3, anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== From 91c008e67fa459cf17751bc3101bbdcdc030287d Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Mon, 21 Sep 2026 08:52:52 +0200 Subject: [PATCH 27/39] test(e2e): Avoid SAM runtime port collisions in aws-serverless tests (#24492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SAM starts EAGER runtime containers before binding its API endpoint. Those containers randomly choose ports in 5000–8999, so one can take our old port 7120. Its plain-text 404 passed the readiness check, then every Lambda invocation failed with a JSON-deserialization error, matching the reported CI failures. Move both AWS E2E apps to port 17120, outside that range. Require SAM's `PathNotFoundLocally` response header for readiness and pause between unsuccessful probes. All 10 layer tests passed with the old port deliberately occupied by a runtime container. Fixes #24465, fixes #24466, fixes #24467, fixes #24468, fixes #24469, fixes #24470, fixes #24471, fixes #24472, fixes #24473. Co-authored-by: GPT-6 --- .../aws-serverless-layer/src/stack.ts | 11 ++++++----- .../test-applications/aws-serverless/src/stack.ts | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index ed77302ec088..df5a076ef56d 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -10,7 +10,8 @@ import { globSync } from 'glob'; const LAMBDA_FUNCTIONS_DIR = './src/lambda-functions-layer'; const LAMBDA_FUNCTION_TIMEOUT = 10; const LAYER_DIR = './node_modules/@sentry/aws-serverless/'; -export const SAM_PORT = Number(process.env.SAM_PORT) || 7120; +// SAM allocates runtime container ports in [5000, 9000) before binding its own endpoint. +export const SAM_PORT = Number(process.env.SAM_PORT) || 17120; /** Match SAM / Docker to this machine so Apple Silicon does not mix arm64 images with an x86_64 template default. */ function samLambdaArchitecture(): 'arm64' | 'x86_64' { @@ -102,13 +103,13 @@ export class LocalLambdaStack extends Stack { try { const response = await fetch(`http://127.0.0.1:${port}/`); - if (response.ok || response.status === 404) { + if (response.status === 404 && response.headers.get('x-amzn-errortype') === 'PathNotFoundLocally') { console.log(`[LocalLambdaStack] SAM stack is ready`); return; } - } catch { - await new Promise(resolve => setTimeout(resolve, 1000)); - } + } catch {} + + await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`[LocalLambdaStack] Failed to start SAM stack after ${timeout}ms`); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index a2a201bd9486..f314643572c2 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -9,7 +9,8 @@ import { execFileSync } from 'node:child_process'; const LAMBDA_FUNCTIONS_DIR = './src/lambda-functions-npm'; const LAMBDA_FUNCTION_TIMEOUT = 10; -export const SAM_PORT = Number(process.env.SAM_PORT) || 7120; +// SAM allocates runtime container ports in [5000, 9000) before binding its own endpoint. +export const SAM_PORT = Number(process.env.SAM_PORT) || 17120; /** Match SAM / Docker to this machine so Apple Silicon does not mix arm64 images with an x86_64 template default. */ function samLambdaArchitecture(): 'arm64' | 'x86_64' { @@ -143,13 +144,13 @@ export class LocalLambdaStack extends Stack { try { const response = await fetch(`http://127.0.0.1:${port}/`); - if (response.ok || response.status === 404) { + if (response.status === 404 && response.headers.get('x-amzn-errortype') === 'PathNotFoundLocally') { console.log(`[LocalLambdaStack] SAM stack is ready`); return; } - } catch { - await new Promise(resolve => setTimeout(resolve, 1000)); - } + } catch {} + + await new Promise(resolve => setTimeout(resolve, 1000)); } throw new Error(`[LocalLambdaStack] Failed to start SAM stack after ${timeout}ms`); From e5468561cbec1f06ba420e72922c423fd27915da Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 21 Sep 2026 09:01:40 +0200 Subject: [PATCH 28/39] test(e2e): Add gen-ai-libraries app covering AI libraries via OpenRouter (#24456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #24454. Adds an optional `gen-ai-libraries` e2e app that drives every gen-AI **library** we instrument — OpenAI, Anthropic, Mistral, Together and the Vercel AI SDK — against a real model through the single `E2E_OPENROUTER_API_KEY` the other AI e2e apps use, with one chat-query route and one forced-tool-call route each. `node-mistral` is folded in and removed; Mistral is now one of the covered libraries. ## Structure Shared, framework-agnostic handlers in `src/libraries.ts` are wired into two runtime entries, selected by a `RUNTIME` variant (`tests/constants.ts`), so both instrumentation paths get real-call coverage from one app: - **node** (default) — `src/entry.node.ts`, an express app with runtime channel injection preloaded via `node --import ./src/instrument.node.ts`. - **cloudflare** (`optionalVariants`, `RUNTIME=cloudflare`) — `src/entry.cloudflare.ts`, built with `vite` + `@sentry/cloudflare/vite` (build-time channel injection) and run on workerd via `wrangler dev` against the build's generated config (`dist/gen_ai_libraries/wrangler.json`). `playwright.config.ts` picks the start command and port per `RUNTIME`; the tests (`tests/chat.test.ts`, `tests/tools.test.ts`) are runtime-agnostic — they hit `baseURL` and assert the streamed gen_ai spans, parametrized over the library list in `tests/utils.ts`. ## Why these five Every call uses `openai/gpt-4o-mini`. OpenRouter serves an OpenAI-compatible `/api/v1/chat/completions` and an Anthropic-compatible `/api/v1/messages` ("Anthropic skin"), so all five SDKs reach it (Together/Mistral via lenient OpenAI-shape parsing, the Vercel AI SDK via `@openrouter/ai-sdk-provider`). The model is incidental; each SDK's own request/response code path is what's under test. Two instrumented libraries can't reach OpenRouter and are omitted with a code comment: **Google GenAI** (native Gemini `generateContent` format isn't served) and **Groq** (its SDK hardcodes a `/openai/v1` request path OpenRouter doesn't expose). Groq shares Together's OpenAI-compatible instrumentation code path — exercised here — and is covered by the node-integration-tests in the base PR. ## Notes - The node variant was verified end-to-end against real OpenRouter calls: all five libraries emit the expected gen_ai spans (`together_ai`, `anthropic`, `mistralai`, `openai`, plus the Vercel AI SDK's `gen_ai.generate_content`), including recorded tool calls. The Cloudflare variant runs the same suite in CI. - Folding in `node-mistral` drops its Mistral-specific stream-drain edge cases (tee/pipe, dataloader co-instrumentation) in favour of the uniform query+tool-call coverage across libraries. - The app is marked `optional`, so it only runs when the OpenRouter key is present. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../gen-ai-libraries/.gitignore | 6 + .../gen-ai-libraries/package.json | 56 +++++ .../gen-ai-libraries/playwright.config.ts | 21 ++ .../gen-ai-libraries/src/entry.cloudflare.ts | 39 ++++ .../gen-ai-libraries/src/entry.node.ts | 48 ++++ .../gen-ai-libraries/src/env.d.ts | 4 + .../gen-ai-libraries/src/instrument.node.ts | 12 + .../gen-ai-libraries/src/libraries.ts | 219 ++++++++++++++++++ .../start-event-proxy.mjs | 2 +- .../gen-ai-libraries/tests/chat.test.ts | 24 ++ .../gen-ai-libraries/tests/constants.ts | 5 + .../gen-ai-libraries/tests/tools.test.ts | 47 ++++ .../gen-ai-libraries/tests/utils.ts | 80 +++++++ .../gen-ai-libraries/tsconfig.json | 21 ++ .../gen-ai-libraries/vite.config.mts | 9 + .../gen-ai-libraries/wrangler.jsonc | 7 + .../test-applications/node-mistral/.gitignore | 1 - .../test-applications/node-mistral/build.mjs | 40 ---- .../node-mistral/package.json | 36 --- .../node-mistral/playwright.config.mjs | 15 -- .../node-mistral/src/app.mjs | 180 -------------- .../node-mistral/src/instrument.mjs | 24 -- .../node-mistral/tests/ai-spans.test.ts | 65 ------ .../tests/co-instrumentation.test.ts | 33 --- .../node-mistral/tests/drain-paths.test.ts | 57 ----- .../node-mistral/tests/errors.test.ts | 51 ---- .../tests/instrumentation-path.test.ts | 17 -- .../node-mistral/tests/nesting.test.ts | 60 ----- .../node-mistral/tests/utils.ts | 53 ----- 29 files changed, 599 insertions(+), 633 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/instrument.node.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts rename dev-packages/e2e-tests/test-applications/{node-mistral => gen-ai-libraries}/start-event-proxy.mjs (74%) create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/constants.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/tools.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/vite.config.mts create mode 100644 dev-packages/e2e-tests/test-applications/gen-ai-libraries/wrangler.jsonc delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/.gitignore delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/build.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/src/instrument.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/tests/ai-spans.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/tests/co-instrumentation.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/tests/drain-paths.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/tests/errors.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/tests/instrumentation-path.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/tests/nesting.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-mistral/tests/utils.ts diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/.gitignore b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/.gitignore new file mode 100644 index 000000000000..fdc614d4c5e0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/.gitignore @@ -0,0 +1,6 @@ +node_modules +pnpm-lock.yaml +dist +.wrangler +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json new file mode 100644 index 000000000000..89364cbbe423 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json @@ -0,0 +1,56 @@ +{ + "name": "gen-ai-libraries", + "description": "Real gen_ai spans for every instrumented AI library (OpenAI, Anthropic, Mistral, Together, Vercel AI), each driven through OpenRouter with a chat query and a tool call, on Node and on Cloudflare", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev:node": "node --import tsx/esm --import ./src/instrument.node.ts src/entry.node.ts", + "dev:cloudflare": "wrangler dev --config ./dist/gen_ai_libraries/wrangler.json --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --var \"E2E_OPENROUTER_API_KEY:$E2E_OPENROUTER_API_KEY\" --port 38787", + "preview": "vite preview --port 38787", + "test": "playwright test", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install", + "test:build:cloudflare": "pnpm install && vite build", + "test:assert": "pnpm test", + "test:assert:cloudflare": "RUNTIME=cloudflare pnpm test" + }, + "dependencies": { + "@anthropic-ai/sdk": "0.63.0", + "@mistralai/mistralai": "^2.6.4", + "@openrouter/ai-sdk-provider": "~3.0.0", + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "ai": "~7.0.97", + "express": "^4.21.2", + "openai": "5.18.1", + "together-ai": "0.54.0", + "zod": "4.5.4" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "1.52.0", + "@cloudflare/workers-types": "^4.20260426.0", + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "tsx": "4.21.0", + "typescript": "^5.5.2", + "vite": "8.3.0", + "wrangler": "^4.86.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:assert:cloudflare", + "label": "gen-ai-libraries (cloudflare)" + } + ] + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/playwright.config.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/playwright.config.ts new file mode 100644 index 000000000000..e3119c8f0ce2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/playwright.config.ts @@ -0,0 +1,21 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { RUNTIME } from './tests/constants'; + +// The same suite runs against both runtimes, selected by the `RUNTIME` env var (see the `sentryTest` +// variants in package.json): the Node entry (runtime channel injection) or the Cloudflare entry (Vite +// build + `@sentry/cloudflare/vite` plugin at build time, the prebuilt bundle served by `wrangler dev`). +const CF_PORT = 38787; +const NODE_PORT = 3030; + +const config = getPlaywrightConfig( + { + startCommand: RUNTIME === 'cloudflare' ? 'pnpm dev:cloudflare' : 'pnpm dev:node', + port: RUNTIME === 'cloudflare' ? CF_PORT : NODE_PORT, + }, + // Every test drives a real OpenRouter model call (a tool-calling turn does two) and then waits for + // the gen_ai spans to flush, which does not fit the default 30s test timeout when the provider is + // slow. + { timeout: 90_000, retries: 0 }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts new file mode 100644 index 000000000000..265b8122f0df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.cloudflare.ts @@ -0,0 +1,39 @@ +// The Cloudflare variant: the same libraries and routes as the Node entry, but instrumented by the +// `@sentry/cloudflare/vite` bundler plugin (build-time channel injection) and run on workerd. +import * as Sentry from '@sentry/cloudflare'; +import { libraries } from './libraries'; + +const byId = new Map(libraries.map(library => [library.id, library])); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + }), + { + async fetch(request, env, _ctx) { + const url = new URL(request.url); + const [, id, action] = url.pathname.split('/'); + const library = id ? byId.get(id) : undefined; + + if (!library || (action !== 'chat' && action !== 'tools')) { + return new Response('Not found', { status: 404 }); + } + + const apiKey = env.E2E_OPENROUTER_API_KEY; + if (!apiKey) { + return new Response('E2E_OPENROUTER_API_KEY is not set', { status: 500 }); + } + + try { + const spanName = action === 'tools' ? 'ai-tool-workflow' : 'ai-workflow'; + const result = await Sentry.startSpan({ name: spanName, op: 'function' }, () => library[action](apiKey)); + return Response.json({ result }); + } catch (error) { + return Response.json({ message: (error as Error).message }, { status: 500 }); + } + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts new file mode 100644 index 000000000000..726924dbd69d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/entry.node.ts @@ -0,0 +1,48 @@ +// `instrument.node.ts` is preloaded via `node --import`, so Sentry is already initialised here. +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { libraries } from './libraries'; + +const apiKey = process.env.E2E_OPENROUTER_API_KEY; +if (!apiKey) { + throw new Error('E2E_OPENROUTER_API_KEY is not set'); +} + +const app = express(); + +// One `/:lib/chat` and `/:lib/tools` per instrumented library. Each SDK call is wrapped in a manual +// `ai-workflow` span, so the gen_ai span nests inside it, and it inside the auto-instrumented request +// span. +for (const library of libraries) { + app.get(`/${library.id}/chat`, async (_req, res, next) => { + try { + const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, () => library.chat(apiKey)); + res.send({ answer }); + } catch (error) { + next(error); + } + }); + + app.get(`/${library.id}/tools`, async (_req, res, next) => { + try { + const toolCalls = await Sentry.startSpan({ name: 'ai-tool-workflow', op: 'function' }, () => + library.tools(apiKey), + ); + res.send({ toolCalls }); + } catch (error) { + next(error); + } + }); +} + +Sentry.setupExpressErrorHandler(app); + +app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).send({ message: error.message }); +}); + +const port = Number(process.env.PORT ?? 3030); +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`gen-ai-libraries (Node) listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/env.d.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/env.d.ts new file mode 100644 index 000000000000..b7f1170dd7e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/env.d.ts @@ -0,0 +1,4 @@ +interface Env { + E2E_TEST_DSN: ''; + E2E_OPENROUTER_API_KEY: ''; +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/instrument.node.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/instrument.node.ts new file mode 100644 index 000000000000..4d8f7f625877 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/instrument.node.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; + +// Loaded through `node --import`, so the runtime channel-injection hook transforms the AI SDKs and +// express as they load. (The Cloudflare variant covers the build-time bundler-plugin injection path.) +Sentry.init({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1, + enableRuntimeChannelInjection: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts new file mode 100644 index 000000000000..1b1490e7e9b4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/src/libraries.ts @@ -0,0 +1,219 @@ +// Each gen-AI *library* (not framework) we instrument, exercised against a real model through +// OpenRouter — the single `E2E_OPENROUTER_API_KEY` the other AI e2e apps already use. Every entry runs +// a plain chat query and a forced tool call, so the same two assertions apply to all of them. These +// handlers are framework- and runtime-agnostic: `entry.node.ts` (express) and `entry.cloudflare.ts` +// (workerd) both call them, passing the key from their respective environments. +// +// OpenRouter serves an OpenAI-compatible `/api/v1/chat/completions` and an Anthropic-compatible +// `/api/v1/messages` ("Anthropic skin"), which is why the OpenAI, Together, Mistral, Anthropic and +// Vercel AI SDKs can all point at it. Every request uses the same `openai/gpt-4o-mini` model — the +// model is incidental; what is under test is each SDK's own request/response code path, the thing +// Sentry instruments. +// +// Two libraries we instrument are intentionally absent because they cannot reach OpenRouter: +// - Google GenAI (`@google/genai`) speaks the native Gemini `generateContent` format, which +// OpenRouter does not serve. +// - Groq (`groq-sdk`) hardcodes a `/openai/v1/...` request path that OpenRouter (served under +// `/api/v1`) does not expose. Its instrumentation is the shared OpenAI-compatible code path that +// Together exercises here, and it is covered by the node-integration-tests. +import Anthropic from '@anthropic-ai/sdk'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { Mistral } from '@mistralai/mistralai'; +import OpenAI from 'openai'; +import Together from 'together-ai'; +import { generateText, tool } from 'ai'; +import { z } from 'zod'; + +const OPENROUTER_V1 = 'https://openrouter.ai/api/v1'; +const OPENROUTER_BASE = 'https://openrouter.ai/api'; +const MODEL = 'openai/gpt-4o-mini'; + +const SHORT_ANSWER = 'Answer in at most five words.'; +const CHAT_PROMPT = `What is the capital of France? ${SHORT_ANSWER}`; +// Deliberately does not name the tool: `tool_choice: 'required'` forces the call, and keeping +// "get_weather" out of the prompt means the string only appears in an actual recorded tool call, not +// in `gen_ai.input.messages`. +const WEATHER_PROMPT = `What is the weather in Paris? ${SHORT_ANSWER}`; +const SYSTEM = 'You are a helpful assistant used by an automated test.'; + +// OpenAI-style function tool, shared by the OpenAI-compatible SDKs. +const OPENAI_TOOL = { + type: 'function' as const, + function: { + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: { + type: 'object', + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + }, + }, +}; + +export interface Library { + id: string; + /** The op of the model-call span; asserted by the tests. */ + op: 'gen_ai.chat' | 'gen_ai.generate_content'; + /** `gen_ai.provider.name` for the direct-SDK libraries; unset for the Vercel AI SDK. */ + provider?: string; + /** `sentry.origin` for the direct-SDK libraries; unset for the Vercel AI SDK. */ + origin?: string; + chat: (apiKey: string) => Promise; + tools: (apiKey: string) => Promise; +} + +/** Chat + forced tool call for an OpenAI-compatible chat-completions client (OpenAI, Together). */ +function openAiCompatible( + id: string, + provider: string, + origin: string, + makeClient: (apiKey: string) => { chat: { completions: { create: (body: unknown) => Promise } } }, +): Library { + return { + id, + op: 'gen_ai.chat', + provider, + origin, + chat: async apiKey => { + const completion = await makeClient(apiKey).chat.completions.create({ + model: MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + max_tokens: 32, + }); + return completion.choices?.[0]?.message?.content ?? ''; + }, + tools: async apiKey => { + const completion = await makeClient(apiKey).chat.completions.create({ + model: MODEL, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [OPENAI_TOOL], + tool_choice: 'required', + max_tokens: 64, + }); + return completion.choices?.[0]?.message?.tool_calls ?? []; + }, + }; +} + +export const libraries: Library[] = [ + openAiCompatible('openai', 'openai', 'auto.ai.openai', apiKey => new OpenAI({ apiKey, baseURL: OPENROUTER_V1 })), + openAiCompatible( + 'together', + 'together_ai', + 'auto.ai.together_ai', + apiKey => new Together({ apiKey, baseURL: OPENROUTER_V1 }) as any, + ), + + { + id: 'mistral', + op: 'gen_ai.chat', + provider: 'mistralai', + origin: 'auto.ai.mistralai', + chat: async apiKey => { + const client = new Mistral({ apiKey, serverURL: OPENROUTER_BASE }); + const completion = await client.chat.complete({ + model: MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + maxTokens: 32, + }); + return completion.choices?.[0]?.message?.content ?? ''; + }, + tools: async apiKey => { + const client = new Mistral({ apiKey, serverURL: OPENROUTER_BASE }); + const completion = await client.chat.complete({ + model: MODEL, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [OPENAI_TOOL] as any, + // OpenRouter's OpenAI-compatible endpoint only accepts none/auto/required, not Mistral's `any`. + toolChoice: 'required', + maxTokens: 64, + }); + return completion.choices?.[0]?.message?.toolCalls ?? []; + }, + }, + + { + id: 'anthropic', + op: 'gen_ai.chat', + provider: 'anthropic', + origin: 'auto.ai.anthropic', + chat: async apiKey => { + // OpenRouter's Anthropic skin authenticates with a bearer token, so the key goes in `authToken` + // (Authorization: Bearer) rather than `apiKey` (x-api-key). + const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE }); + const message = await client.messages.create({ + model: MODEL, + max_tokens: 32, + temperature: 0, + system: SYSTEM, + messages: [{ role: 'user', content: CHAT_PROMPT }], + }); + const first = message.content?.[0]; + return first && first.type === 'text' ? first.text : ''; + }, + tools: async apiKey => { + const client = new Anthropic({ authToken: apiKey, baseURL: OPENROUTER_BASE }); + const message = await client.messages.create({ + model: MODEL, + max_tokens: 64, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [ + { + name: 'get_weather', + description: 'Get the current weather for a city.', + input_schema: { + type: 'object', + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + }, + }, + ], + tool_choice: { type: 'tool', name: 'get_weather' }, + }); + return (message.content ?? []).filter(block => block.type === 'tool_use'); + }, + }, + + { + id: 'vercel-ai', + // The Vercel AI SDK emits `gen_ai.generate_content` (nested in a `gen_ai.invoke_agent` span), + // reports the provider from the model id, and uses its own span origin. + op: 'gen_ai.generate_content', + chat: async apiKey => { + const openrouter = createOpenRouter({ apiKey }); + const { text } = await generateText({ + model: openrouter(MODEL), + system: SYSTEM, + prompt: CHAT_PROMPT, + temperature: 0, + experimental_telemetry: { isEnabled: true }, + }); + return text; + }, + tools: async apiKey => { + const openrouter = createOpenRouter({ apiKey }); + const result = await generateText({ + model: openrouter(MODEL), + prompt: WEATHER_PROMPT, + toolChoice: 'required', + experimental_telemetry: { isEnabled: true }, + tools: { + get_weather: tool({ + description: 'Get the current weather for a city.', + inputSchema: z.object({ city: z.string().describe('The city name') }), + execute: async ({ city }) => `It is sunny in ${city}.`, + }), + }, + }); + return result.toolCalls ?? []; + }, + }, +]; diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/start-event-proxy.mjs similarity index 74% rename from dev-packages/e2e-tests/test-applications/node-mistral/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/gen-ai-libraries/start-event-proxy.mjs index 2c8fdc947553..ad68d9340fce 100644 --- a/dev-packages/e2e-tests/test-applications/node-mistral/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'node-mistral', + proxyServerName: 'gen-ai-libraries', }); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat.test.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat.test.ts new file mode 100644 index 000000000000..59d406841821 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/chat.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans } from '@sentry-internal/test-utils'; +import { APP } from './constants'; +import { describeTree, expectCommonChatAttributes, isModelCallSpan, LIBRARIES, traceHasToolEvidence } from './utils'; + +for (const library of LIBRARIES) { + test(`${library.id}: a chat query emits a ${library.op} span`, async ({ baseURL }) => { + // Scope to this chat request's own trace: it carries this library's model-call span and, unlike the + // tools request, no tool-call evidence — so a leftover trace from another request cannot satisfy it. + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => spansOfTrace.some(span => isModelCallSpan(span, library)) && !traceHasToolEvidence(spansOfTrace), + ); + + const response = await fetch(`${baseURL}/${library.id}/chat`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const modelSpan = spans.find(span => isModelCallSpan(span, library)); + + expect(modelSpan, `expected a ${library.op} span in:\n${describeTree(spans)}`).toBeDefined(); + expectCommonChatAttributes(modelSpan!, library); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/constants.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/constants.ts new file mode 100644 index 000000000000..fb2a2d8999ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/constants.ts @@ -0,0 +1,5 @@ +export type Runtime = 'node' | 'cloudflare'; + +export const RUNTIME = (process.env.RUNTIME || 'node') as Runtime; + +export const APP = 'gen-ai-libraries'; diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/tools.test.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/tools.test.ts new file mode 100644 index 000000000000..d2ff269ca0a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/tools.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; +import { APP } from './constants'; +import { attr, describeTree, hasRecordedToolCalls, isExecuteToolSpan, isModelCallSpan, LIBRARIES } from './utils'; + +// The direct-SDK libraries record the model's tool call on the chat span's `gen_ai.response.tool_calls` +// attribute, which only exists when the model actually returned tool calls. +const DIRECT_SDK_LIBRARIES = LIBRARIES.filter(library => library.provider); + +for (const library of DIRECT_SDK_LIBRARIES) { + test(`${library.id}: the model's tool call is recorded on the ${library.op} span`, async ({ baseURL }) => { + // Scope to this tools request's trace: this library's model-call span, carrying recorded tool calls. + const spansPromise = collectStreamedSpans(APP, spansOfTrace => + spansOfTrace.some(span => isModelCallSpan(span, library) && hasRecordedToolCalls(span)), + ); + + const response = await fetch(`${baseURL}/${library.id}/tools`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const modelSpan = spans.find(span => isModelCallSpan(span, library) && hasRecordedToolCalls(span)); + + expect( + modelSpan, + `expected a ${library.op} span with recorded tool calls in:\n${describeTree(spans)}`, + ).toBeDefined(); + expect(attr(modelSpan!, 'gen_ai.response.tool_calls')).toContain('get_weather'); + }); +} + +// The Vercel AI SDK executes the tool and emits a dedicated `gen_ai.execute_tool` span instead. +test('vercel-ai: the tool call is captured as a gen_ai.execute_tool span', async ({ baseURL }) => { + const spansPromise = collectStreamedSpans( + APP, + spansOfTrace => + spansOfTrace.some(isExecuteToolSpan) && spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.generate_content'), + ); + + const response = await fetch(`${baseURL}/vercel-ai/tools`); + expect(response.status).toBe(200); + + const spans = await spansPromise; + const toolSpan = spans.find(isExecuteToolSpan); + + expect(toolSpan, `expected a gen_ai.execute_tool span in:\n${describeTree(spans)}`).toBeDefined(); + expect(attr(toolSpan!, 'gen_ai.tool.name')).toBe('get_weather'); +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/utils.ts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/utils.ts new file mode 100644 index 000000000000..4861a01c5334 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tests/utils.ts @@ -0,0 +1,80 @@ +import { expect } from '@playwright/test'; +import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp } from '@sentry-internal/test-utils'; + +/** Mirrors `src/libraries.ts`, and records how strictly each library's spans can be asserted. */ +export interface LibraryUnderTest { + id: string; + /** The op of the model-call span: `gen_ai.chat` for the direct SDKs, `gen_ai.generate_content` for + * the Vercel AI SDK. */ + op: string; + /** `gen_ai.provider.name` for the direct-SDK libraries; unset for the Vercel AI SDK, whose provider + * name comes from the model id and is not asserted. */ + provider?: string; + /** `sentry.origin` for the direct-SDK libraries; unset for the Vercel AI SDK. */ + origin?: string; +} + +export const LIBRARIES: LibraryUnderTest[] = [ + { id: 'openai', op: 'gen_ai.chat', provider: 'openai', origin: 'auto.ai.openai' }, + { id: 'together', op: 'gen_ai.chat', provider: 'together_ai', origin: 'auto.ai.together_ai' }, + { id: 'mistral', op: 'gen_ai.chat', provider: 'mistralai', origin: 'auto.ai.mistralai' }, + { id: 'anthropic', op: 'gen_ai.chat', provider: 'anthropic', origin: 'auto.ai.anthropic' }, + { id: 'vercel-ai', op: 'gen_ai.generate_content' }, +]; + +export const attr = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; + +/** + * The model-call span for a library. The four direct SDKs all report `gen_ai.chat`, so the provider + * name is what tells them apart — without it, a leftover span from another library's request could be + * mistaken for this one's. + */ +export const isModelCallSpan = (span: SerializedStreamedSpan, library: LibraryUnderTest): boolean => + getSpanOp(span) === library.op && (!library.provider || attr(span, 'gen_ai.provider.name') === library.provider); + +/** A span that recorded the model returning tool calls on a chat-completions request (direct SDKs). */ +export const hasRecordedToolCalls = (span: SerializedStreamedSpan): boolean => + typeof attr(span, 'gen_ai.response.tool_calls') === 'string'; + +/** The dedicated tool-execution span the Vercel AI SDK emits. */ +export const isExecuteToolSpan = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === 'gen_ai.execute_tool'; + +/** Whether a trace shows any evidence of a tool call, used to tell a chat request from a tools one. */ +export const traceHasToolEvidence = (spansOfTrace: SerializedStreamedSpan[]): boolean => + spansOfTrace.some(span => hasRecordedToolCalls(span) || isExecuteToolSpan(span)); + +/** A readable span tree, used as a failure message so a broken assertion is diagnosable. */ +export function describeTree(spans: SerializedStreamedSpan[]): string { + return spans + .map(span => `${span.name} [${getSpanOp(span) ?? '-'}] id=${span.span_id} parent=${span.parent_span_id ?? '-'}`) + .join('\n'); +} + +/** + * The attributes every successful gen_ai model-call span carries, whatever the model happens to + * answer. Model-dependent values (token counts, response text) are checked for shape, not content. + */ +export function expectCommonChatAttributes(span: SerializedStreamedSpan, library: LibraryUnderTest): void { + const operationName = library.op.replace('gen_ai.', ''); + + expect(getSpanOp(span), describeTree([span])).toBe(library.op); + expect(attr(span, 'gen_ai.operation.name')).toBe(operationName); + expect(span.status).toBe('ok'); + + expect(typeof attr(span, 'gen_ai.provider.name')).toBe('string'); + expect(typeof attr(span, 'gen_ai.request.model')).toBe('string'); + expect(typeof attr(span, 'gen_ai.response.model')).toBe('string'); + expect(attr(span, 'gen_ai.usage.input_tokens')).toBeGreaterThan(0); + expect(attr(span, 'gen_ai.usage.output_tokens')).toBeGreaterThan(0); + expect(attr(span, 'gen_ai.usage.total_tokens')).toBeGreaterThan(0); + + // The direct-SDK libraries carry a stable provider name and origin; the Vercel AI SDK does not. + if (library.provider) { + expect(attr(span, 'gen_ai.provider.name')).toBe(library.provider); + expect(span.name).toBe(`${operationName} ${attr(span, 'gen_ai.request.model')}`); + } + if (library.origin) { + expect(attr(span, 'sentry.origin')).toBe(library.origin); + } +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tsconfig.json b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tsconfig.json new file mode 100644 index 000000000000..91fe6c743269 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "types": ["node", "@cloudflare/workers-types/experimental"] + }, + "exclude": ["tests"], + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/vite.config.mts b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/vite.config.mts new file mode 100644 index 000000000000..1c4c4863046d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/vite.config.mts @@ -0,0 +1,9 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +// Builds the Cloudflare variant (`src/entry.cloudflare.ts`, per `wrangler.toml`). The Node variant runs +// straight from source via tsx and does not use this config. +export default defineConfig({ + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/wrangler.jsonc new file mode 100644 index 000000000000..07b7bc9ee832 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "gen-ai-libraries", + "main": "src/entry.cloudflare.ts", + "compatibility_date": "2026-04-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/.gitignore b/dev-packages/e2e-tests/test-applications/node-mistral/.gitignore deleted file mode 100644 index 1521c8b7652b..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/build.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/build.mjs deleted file mode 100644 index acb282f9c706..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/build.mjs +++ /dev/null @@ -1,40 +0,0 @@ -// Produces the prod-mode artifact: a single bundle whose `@mistralai/mistralai`, `dataloader` and -// `express` copies were transformed at build time by `sentryEsbuildPlugin`. Nothing is left for a -// runtime hook to do, which is what `enableRuntimeChannelInjection: false` in `instrument.mjs` -// asserts. -// -// `@sentry/node` stays external: the SDK is the subscriber, not a transform target, and inlining it -// would force its CommonJS `require('node:async_hooks')` through esbuild's ESM interop for no gain. -// CJS output for the same reason the `node-esbuild` app uses it. Left unminified so the injected -// snippet keeps its identifiers. -import { rmSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { sentryEsbuildPlugin } from '@sentry/node/esbuild'; -import { build } from 'esbuild'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); - -await build({ - entryPoints: [join(__dirname, 'src', 'app.mjs')], - outfile: join(__dirname, 'dist', 'app.cjs'), - bundle: true, - platform: 'node', - format: 'cjs', - target: 'node18', - external: ['@sentry/node'], - minify: false, - logLevel: 'info', - plugins: [ - sentryEsbuildPlugin({ - telemetry: false, - sourcemaps: { disable: true }, - release: { create: false, finalize: false, inject: false }, - }), - ], -}); - -// eslint-disable-next-line no-console -console.log('built dist/app.cjs with sentryEsbuildPlugin'); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/package.json b/dev-packages/e2e-tests/test-applications/node-mistral/package.json deleted file mode 100644 index 16b53b297d36..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "node-mistral", - "description": "Mistral AI gen_ai spans, errors, span nesting and co-instrumented dataloader spans, exercised through both the runtime loader (dev) and a bundler-instrumented build (prod)", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "start": "node --import ./src/instrument.mjs src/app.mjs", - "start:bundled": "node dist/app.cjs", - "build": "node build.mjs", - "clean": "npx rimraf node_modules dist pnpm-lock.yaml", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test:prod && pnpm test:dev", - "test:prod": "TEST_ENV=production playwright test", - "test:dev": "TEST_ENV=development playwright test" - }, - "dependencies": { - "@mistralai/mistralai": "^2.6.4", - "@sentry/node": "file:../../packed/sentry-node-packed.tgz", - "dataloader": "^2.2.2", - "express": "^4.21.2" - }, - "devDependencies": { - "@playwright/test": "~1.56.0", - "@sentry-internal/test-utils": "link:../../../test-utils", - "@sentry/bundler-plugins": "file:../../packed/sentry-bundler-plugins-packed.tgz", - "@sentry/core": "file:../../packed/sentry-core-packed.tgz", - "esbuild": "0.28.2" - }, - "sentryTest": { - "optional": true - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/playwright.config.mjs deleted file mode 100644 index 39daff08107f..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/playwright.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -// The suite runs twice, once per instrumentation path, the way `node-mastra` splits dev and prod: -// -// production - `dist/app.cjs`, whose Mistral, dataloader and express copies were transformed at -// build time by `sentryEsbuildPlugin`. `instrument.mjs` turns runtime injection off -// there, so the bundler plugin is the only thing that can have instrumented them. -// development - unbundled ESM behind the runtime `--import` hook. -const isDev = process.env.TEST_ENV === 'development'; - -const config = getPlaywrightConfig({ - startCommand: isDev ? 'pnpm start' : 'pnpm start:bundled', -}); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs deleted file mode 100644 index f9e408f057b4..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/src/app.mjs +++ /dev/null @@ -1,180 +0,0 @@ -// `instrument.mjs` is imported for its side effect in the prod bundle; in dev `--import` has already -// run it, and a second import is a no-op because ES modules are evaluated once. -import './instrument.mjs'; - -import { Mistral } from '@mistralai/mistralai'; -import * as Sentry from '@sentry/node'; -import DataLoader from 'dataloader'; -import express from 'express'; - -const apiKey = process.env.E2E_OPENROUTER_API_KEY; -if (!apiKey) { - throw new Error('E2E_OPENROUTER_API_KEY is not set'); -} - -// The Mistral SDK talks to OpenRouter rather than api.mistral.ai, so the suite needs only the one -// OpenRouter key the other AI e2e apps already use. OpenRouter serves an OpenAI-compatible -// `/v1/chat/completions`, which is the endpoint `chat.complete` and `chat.stream` post to, and the -// SDK's response schemas are lenient enough to accept it (`usage` has a `catchall`, `finish_reason` -// is an open enum). What is under test is the SDK's own code path, which is what Sentry instruments. -const client = new Mistral({ apiKey, serverURL: 'https://openrouter.ai/api' }); - -// Same model the eve and mastra apps drive through this key. The model is incidental here; the -// Mistral SDK request/response path is the thing being instrumented. -const MODEL = 'openai/gpt-4o-mini'; - -// Kept short so a live model stays cheap and quick, and so streamed responses still arrive in more -// than one chunk. -const SHORT_ANSWER = 'Answer in at most five words.'; - -const userLoader = new DataLoader(async keys => keys.map(key => ({ id: key, name: `user-${key}` }))); - -async function main() { - const port = Number(process.env.PORT ?? 3030); - const app = express(); - - app.get('/chat', async (req, res) => { - // A manual span wrapping the SDK call: the gen_ai span has to nest inside this one, and this one - // has to nest inside the auto-instrumented request span. - const answer = await Sentry.startSpan({ name: 'ai-workflow', op: 'function' }, async () => { - const completion = await client.chat.complete({ - model: MODEL, - messages: [ - { role: 'system', content: 'You are a helpful assistant used by an automated test.' }, - { role: 'user', content: `What is the capital of France? ${SHORT_ANSWER}` }, - ], - temperature: 0, - maxTokens: 32, - }); - - // A manual sibling of the gen_ai span, so the assertions can tell "child of the manual span" - // apart from "child of whatever ran last". - return Sentry.startSpan( - { name: 'post-process', op: 'function' }, - () => completion.choices?.[0]?.message?.content ?? '', - ); - }); - - res.send({ answer }); - }); - - app.get('/chat-stream', async (req, res) => { - const chunks = []; - - await Sentry.startSpan({ name: 'ai-stream-workflow', op: 'function' }, async () => { - const stream = await client.chat.stream({ - model: MODEL, - messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - for await (const event of stream) { - const content = event.data?.choices?.[0]?.delta?.content; - if (typeof content === 'string') { - chunks.push(content); - } - } - }); - - res.send({ answer: chunks.join('') }); - }); - - // `tee()` acquires its reader through internal slots rather than the public `getReader`, so it is - // the drain path most likely to escape instrumentation. Both branches are drained so the response - // only comes back once the stream is finished. - app.get('/chat-stream-tee', async (req, res) => { - const branches = await Sentry.startSpan({ name: 'ai-tee-workflow', op: 'function' }, async () => { - const stream = await client.chat.stream({ - model: MODEL, - messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - const [left, right] = stream.tee(); - - const drain = async branch => { - const parts = []; - for await (const event of branch) { - const content = event.data?.choices?.[0]?.delta?.content; - if (typeof content === 'string') { - parts.push(content); - } - } - return parts.join(''); - }; - - return Promise.all([drain(left), drain(right)]); - }); - - res.send({ left: branches[0], right: branches[1] }); - }); - - // Relays the stream through a transform, the shape an edge handler would use to forward tokens. - app.get('/chat-stream-pipe', async (req, res) => { - const answer = await Sentry.startSpan({ name: 'ai-pipe-workflow', op: 'function' }, async () => { - const stream = await client.chat.stream({ - model: MODEL, - messages: [{ role: 'user', content: `Name three colours. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - const relayed = stream.pipeThrough( - new TransformStream({ - transform(event, controller) { - controller.enqueue(event.data?.choices?.[0]?.delta?.content ?? ''); - }, - }), - ); - - const parts = []; - for await (const part of relayed) { - parts.push(part); - } - return parts.join(''); - }); - - res.send({ answer }); - }); - - // A model id the upstream will reject, so the failure is a real API error rather than a simulated - // one. The caller-supplied id makes each request identifiable in the spans it produces. - app.get('/chat-error', async (req, res, next) => { - const model = `no-such-model/${req.query.id ?? 'default'}`; - - try { - await client.chat.complete({ model, messages: [{ role: 'user', content: 'This will fail' }] }); - res.send({ ok: true }); - } catch (error) { - // Rethrown through the express error handler so the SDK captures it the way a real app would. - next(new Error(`Mistral call failed for ${model}: ${error.message}`)); - } - }); - - // A dataloader (orchestrion-instrumented, like Mistral) and a Mistral call in one request, so the - // assertions can prove both sets of spans land in the same trace. - app.get('/dataloader-and-chat', async (req, res) => { - const user = await userLoader.load(`${req.query.id ?? '1'}`); - - const completion = await client.chat.complete({ - model: MODEL, - messages: [{ role: 'user', content: `Say hello to ${user.name}. ${SHORT_ANSWER}` }], - temperature: 0, - maxTokens: 32, - }); - - res.send({ user, answer: completion.choices?.[0]?.message?.content ?? '' }); - }); - - Sentry.setupExpressErrorHandler(app); - - app.use((error, req, res, _next) => { - res.status(500).send({ message: error.message }); - }); - - app.listen(port); -} - -void main(); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-mistral/src/instrument.mjs deleted file mode 100644 index 30abaa918aa1..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/src/instrument.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// Shared Sentry bootstrap for both modes. -// -// dev - loaded through `node --import`, so the runtime channel-injection hook transforms -// `@mistralai/mistralai`, `dataloader` and `express` as they load. -// prod - bundled into `dist/app.cjs` by `build.mjs`, where `sentryEsbuildPlugin` applies the same -// transforms at build time. Runtime injection is switched off there so the bundler plugin is -// the only possible injector and a passing prod test really proves the build-time path. -import * as Sentry from '@sentry/node'; - -// `production` is the bundled build, where `sentryEsbuildPlugin` already injected the channels. -const isDev = process.env.TEST_ENV === 'development'; - -Sentry.init({ - environment: 'qa', - dsn: process.env.E2E_TEST_DSN, - debug: !!process.env.DEBUG, - tunnel: 'http://localhost:3031/', - tracesSampleRate: 1, - traceLifecycle: 'stream', - enableRuntimeChannelInjection: isDev, - integrations: [Sentry.spanStreamingIntegration()], -}); - -Sentry.setTag('e2e.mode', isDev ? 'development' : 'production'); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/ai-spans.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/ai-spans.test.ts deleted file mode 100644 index aabe2af66f5c..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/ai-spans.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; -import { APP, attr, expectCommonChatAttributes, isChatSpan } from './utils'; - -test('emits a gen_ai.chat span for a non-streaming call', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat'); - - const response = await request.get(`${baseURL}/chat`); - expect(response.status()).toBe(200); - expect((await response.json()).answer).toBeTruthy(); - - const spans = await spansPromise; - const chatSpan = spans.find(isChatSpan); - - expect(chatSpan).toBeDefined(); - expectCommonChatAttributes(chatSpan!); - expect(attr(chatSpan!, 'gen_ai.request.stream')).toBe(false); - expect(attr(chatSpan!, 'gen_ai.request.temperature')).toBe(0); - expect(attr(chatSpan!, 'gen_ai.request.max_tokens')).toBe(32); -}); - -test('emits a gen_ai.chat span for a streaming call', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream'); - - const response = await request.get(`${baseURL}/chat-stream`); - expect(response.status()).toBe(200); - expect((await response.json()).answer).toBeTruthy(); - - const spans = await spansPromise; - const streamSpan = spans.find(isChatSpan); - - expect(streamSpan).toBeDefined(); - expectCommonChatAttributes(streamSpan!); - // Set from the called method: v2's `stream` request field is optional and the app never passes it. - expect(attr(streamSpan!, 'gen_ai.request.stream')).toBe(true); - expect(attr(streamSpan!, 'gen_ai.response.streaming')).toBe(true); -}); - -test('records inputs and outputs in the shape the gen_ai conventions specify', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat'); - - await request.get(`${baseURL}/chat`); - - const spans = await spansPromise; - const chatSpan = spans.find(isChatSpan)!; - - // The system message is split out from the rest of the prompt. - expect(attr(chatSpan, 'gen_ai.system_instructions')).toContain('automated test'); - expect(attr(chatSpan, 'gen_ai.input.messages')).toContain('capital of France'); - - // A stringified array of messages, not one concatenated string. - const responseText = JSON.parse(attr(chatSpan, 'gen_ai.response.text') as string); - expect(Array.isArray(responseText)).toBe(true); - expect(responseText).toHaveLength(1); - expect(typeof responseText[0]).toBe('string'); - - const outputMessages = JSON.parse(attr(chatSpan, 'gen_ai.output.messages') as string); - expect(outputMessages).toEqual([ - { - role: 'assistant', - parts: [{ type: 'text', content: expect.any(String) }], - finish_reason: expect.any(String), - }, - ]); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/co-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/co-instrumentation.test.ts deleted file mode 100644 index 9888e154f7cf..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/co-instrumentation.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment, getSpanOp } from '@sentry-internal/test-utils'; -import { APP, attr, isChatSpan } from './utils'; - -// Mistral and dataloader are both instrumented through orchestrion, so one request that touches -// both proves the Mistral channels coexist with the rest of the injected set rather than displacing -// them. dataloader is also CommonJS where Mistral is ESM-only, so this covers both module formats -// going through the same transform in one process. -test('emits dataloader spans alongside gen_ai spans in one trace', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /dataloader-and-chat'); - - const response = await request.get(`${baseURL}/dataloader-and-chat?id=7`); - expect(response.status()).toBe(200); - expect((await response.json()).user).toEqual({ id: '7', name: 'user-7' }); - - const spans = await spansPromise; - const segment = spans.find(span => span.is_segment && span.name === 'GET /dataloader-and-chat')!; - - const chatSpan = spans.find(isChatSpan); - const dataloaderSpans = spans.filter(span => attr(span, 'sentry.origin') === 'auto.db.dataloader'); - - expect(chatSpan).toBeDefined(); - expect(dataloaderSpans.length).toBeGreaterThan(0); - - // `load` is recorded as a cache read. - expect(dataloaderSpans.some(span => getSpanOp(span) === 'cache.get')).toBe(true); - - // Both instrumentations contribute to the same trace, under the same request. - expect(chatSpan!.trace_id).toBe(segment.trace_id); - for (const span of dataloaderSpans) { - expect(span.trace_id).toBe(segment.trace_id); - } -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/drain-paths.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/drain-paths.test.ts deleted file mode 100644 index 40563d376a4d..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/drain-paths.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; -import { APP, attr, byName, expectCommonChatAttributes, isChatSpan } from './utils'; - -// `tee`, `pipeTo` and `pipeThrough` take their reader from internal slots rather than the public -// `getReader`, so they bypass a stream instrumented only through `getReader` and the async iterator. -// These cover the two an app is realistically built on: teeing to relay and persist at once, and -// piping through a transform to forward tokens to a client. - -test('records a gen_ai span for a teed stream, once', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream-tee'); - - const response = await request.get(`${baseURL}/chat-stream-tee`); - expect(response.status()).toBe(200); - - // Both branches receive the same stream. - const { left, right } = await response.json(); - expect(left).toBeTruthy(); - expect(left).toBe(right); - - const spans = await spansPromise; - const chatSpans = spans.filter(isChatSpan); - - // One span, not one per tee branch. - expect(chatSpans).toHaveLength(1); - const chatSpan = chatSpans[0]!; - - expectCommonChatAttributes(chatSpan); - expect(attr(chatSpan, 'gen_ai.request.stream')).toBe(true); - expect(attr(chatSpan, 'gen_ai.response.streaming')).toBe(true); - - // Nesting still holds on this drain path. - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat-stream-tee')!; - const workflow = byName(spans, 'ai-tee-workflow'); - expect(chatSpan.parent_span_id).toBe(workflow.span_id); - expect(chatSpan.trace_id).toBe(segment.trace_id); -}); - -test('records a gen_ai span for a stream relayed through a transform', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream-pipe'); - - const response = await request.get(`${baseURL}/chat-stream-pipe`); - expect(response.status()).toBe(200); - expect((await response.json()).answer).toBeTruthy(); - - const spans = await spansPromise; - const chatSpan = spans.find(isChatSpan); - - expect(chatSpan).toBeDefined(); - expectCommonChatAttributes(chatSpan!); - expect(attr(chatSpan!, 'gen_ai.response.streaming')).toBe(true); - - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat-stream-pipe')!; - const workflow = byName(spans, 'ai-pipe-workflow'); - expect(chatSpan!.parent_span_id).toBe(workflow.span_id); - expect(chatSpan!.trace_id).toBe(segment.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/errors.test.ts deleted file mode 100644 index c2dc4067d9d5..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/errors.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils'; -import { APP, attr, isChatSpan } from './utils'; - -test('captures an error thrown by a failed Mistral call', async ({ baseURL, request }) => { - const model = 'no-such-model/capture'; - const errorPromise = waitForError( - APP, - event => !event.type && !!event.exception?.values?.[0]?.value?.includes(model), - ); - - const response = await request.get(`${baseURL}/chat-error?id=capture`); - expect(response.status()).toBe(500); - - const errorEvent = await errorPromise; - - expect(errorEvent.exception?.values?.[0]?.value).toContain('Mistral call failed'); - expect(errorEvent.transaction).toBe('GET /chat-error'); - expect(errorEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); -}); - -test('marks the gen_ai span errored and ties it to the captured error', async ({ baseURL, request }) => { - const id = 'linked'; - const model = `no-such-model/${id}`; - - const errorPromise = waitForError( - APP, - event => !event.type && !!event.exception?.values?.[0]?.value?.includes(model), - ); - // Every request to this route produces an equivalent-looking trace, so the predicate names the - // per-request model rather than the route. - const spansPromise = collectStreamedSpans( - APP, - spansOfTrace => - spansOfTrace.some(span => span.is_segment && span.name === 'GET /chat-error') && - spansOfTrace.some(span => attr(span, 'gen_ai.request.model') === model), - ); - - await request.get(`${baseURL}/chat-error?id=${id}`); - - const [errorEvent, spans] = await Promise.all([errorPromise, spansPromise]); - const chatSpan = spans.find(isChatSpan)!; - - expect(chatSpan).toBeDefined(); - expect(chatSpan.status).not.toBe('ok'); - // No response was produced, so nothing should have been recorded from one. - expect(attr(chatSpan, 'gen_ai.response.text')).toBeUndefined(); - expect(attr(chatSpan, 'gen_ai.output.messages')).toBeUndefined(); - - expect(chatSpan.trace_id).toBe(errorEvent.contexts?.trace?.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/instrumentation-path.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/instrumentation-path.test.ts deleted file mode 100644 index 1c53b9d332ae..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/instrumentation-path.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { expect, test } from '@playwright/test'; - -// Guards the premise the prod run rests on. `Sentry.init` registers the runtime injection hook -// unless `enableRuntimeChannelInjection` is false, which `instrument.mjs` sets outside dev. With -// that off and no `--import` on the bundled start command, the bundler plugin is the only thing -// that can have injected these channels, so finding them in the built file is what makes a passing -// production run mean build-time instrumentation rather than a silent fallback. -test('the bundle carries build-time injected channels', () => { - test.skip(process.env.TEST_ENV === 'development', 'the dev run is instrumented by the runtime hook'); - - const bundle = readFileSync('dist/app.cjs', 'utf8'); - - expect(bundle).toContain('orchestrion:@mistralai/mistralai:chat'); - expect(bundle).toContain('orchestrion:@mistralai/mistralai:chat-stream'); - expect(bundle).toContain('orchestrion:dataloader:load'); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/nesting.test.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/nesting.test.ts deleted file mode 100644 index 8cb72ffa1728..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/nesting.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpansUntilSegment } from '@sentry-internal/test-utils'; -import { ancestorIds, APP, byName, describeTree, isChatSpan } from './utils'; - -test('nests the manual span under the request span and the gen_ai span under the manual span', async ({ - baseURL, - request, -}) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat'); - - await request.get(`${baseURL}/chat`); - - const spans = await spansPromise; - const tree = describeTree(spans); - - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat')!; - const workflow = byName(spans, 'ai-workflow'); - const postProcess = byName(spans, 'post-process'); - const chatSpan = spans.find(isChatSpan)!; - - // Manual span inside the generated request span. Express contributes its own middleware and - // request-handler spans in between, so this is an ancestry check, not a direct-parent one. - expect(ancestorIds(spans, workflow), `ai-workflow is not under the request span:\n${tree}`).toContain( - segment.span_id, - ); - - // Generated span directly inside the manual one: nothing should slip between them. - expect(chatSpan.parent_span_id, `gen_ai span is not a child of ai-workflow:\n${tree}`).toBe(workflow.span_id); - - // A second manual span, sibling of the gen_ai span rather than its child. - expect(postProcess.parent_span_id, `post-process is not a child of ai-workflow:\n${tree}`).toBe(workflow.span_id); - - for (const span of [workflow, postProcess, chatSpan]) { - expect(span.trace_id).toBe(segment.trace_id); - } -}); - -test('nests the streaming gen_ai span under its manual parent', async ({ baseURL, request }) => { - const spansPromise = collectStreamedSpansUntilSegment(APP, 'GET /chat-stream'); - - await request.get(`${baseURL}/chat-stream`); - - const spans = await spansPromise; - const tree = describeTree(spans); - - const segment = spans.find(span => span.is_segment && span.name === 'GET /chat-stream')!; - const workflow = byName(spans, 'ai-stream-workflow'); - const streamSpan = spans.find(isChatSpan)!; - - expect(ancestorIds(spans, workflow), `ai-stream-workflow is not under the request span:\n${tree}`).toContain( - segment.span_id, - ); - - // The stream is drained inside the manual span, so the gen_ai span has to close under it rather - // than escaping to the request root. - expect(streamSpan.parent_span_id, `streamed gen_ai span is not a child of ai-stream-workflow:\n${tree}`).toBe( - workflow.span_id, - ); - expect(streamSpan.trace_id).toBe(segment.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-mistral/tests/utils.ts b/dev-packages/e2e-tests/test-applications/node-mistral/tests/utils.ts deleted file mode 100644 index 5172a74beb54..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-mistral/tests/utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { expect } from '@playwright/test'; -import type { SerializedStreamedSpan } from '@sentry-internal/test-utils'; -import { getSpanOp } from '@sentry-internal/test-utils'; - -export const APP = 'node-mistral'; - -export const attr = (span: SerializedStreamedSpan, key: string): unknown => span.attributes?.[key]?.value; - -export const isChatSpan = (span: SerializedStreamedSpan): boolean => getSpanOp(span) === 'gen_ai.chat'; - -/** A readable span tree, used as a failure message so a broken assertion is diagnosable. */ -export function describeTree(spans: SerializedStreamedSpan[]): string { - return spans - .map(span => `${span.name} [${getSpanOp(span) ?? '-'}] id=${span.span_id} parent=${span.parent_span_id ?? '-'}`) - .join('\n'); -} - -/** Walk to the trace root, so assertions can allow auto-instrumented spans in between. */ -export function ancestorIds(spans: SerializedStreamedSpan[], span: SerializedStreamedSpan): string[] { - const byId = new Map(spans.map(candidate => [candidate.span_id, candidate])); - const ids: string[] = []; - - let current: SerializedStreamedSpan | undefined = span; - while (current?.parent_span_id) { - ids.push(current.parent_span_id); - current = byId.get(current.parent_span_id); - } - - return ids; -} - -export function byName(spans: SerializedStreamedSpan[], name: string): SerializedStreamedSpan { - const span = spans.find(candidate => candidate.name === name); - expect(span, `expected a span named "${name}" in:\n${describeTree(spans)}`).toBeDefined(); - return span!; -} - -/** - * Attributes every successful gen_ai span carries, whatever the model happens to answer. Values that - * depend on the model (token counts, response text) are checked for shape and not for content. - */ -export function expectCommonChatAttributes(span: SerializedStreamedSpan): void { - expect(attr(span, 'sentry.origin')).toBe('auto.ai.mistralai'); - expect(attr(span, 'gen_ai.provider.name')).toBe('mistralai'); - expect(attr(span, 'gen_ai.operation.name')).toBe('chat'); - expect(span.name).toBe(`chat ${attr(span, 'gen_ai.request.model')}`); - expect(span.status).toBe('ok'); - - expect(typeof attr(span, 'gen_ai.response.model')).toBe('string'); - expect(attr(span, 'gen_ai.usage.input_tokens')).toBeGreaterThan(0); - expect(attr(span, 'gen_ai.usage.output_tokens')).toBeGreaterThan(0); - expect(attr(span, 'gen_ai.usage.total_tokens')).toBeGreaterThan(0); -} From 9aa9a02413dda076019bcee6ecc643566add88b9 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:19:51 +0200 Subject: [PATCH 29/39] fix(core): Apply the sensitive denylist to cookie headers and configured fetch headers (#24090) Three ways a sensitive value slipped past the denylist, now that cookies ship as one array attribute (#24231). A cookie segment without an `=` is a nameless cookie, so the bare token is its value (RFC 6265bis). The SDK treated it as a name, and no name-based denylist can match a value, so `Cookie: ` shipped the token in the clear. Such segments now become a `[Filtered]` array element. The `Cookie` header was also split on `"; "`, but the space is not guaranteed on the wire, so a cookie glued on with a bare `;` leaked inside the previous cookie's value. The split is now on `";"`. Headers listed in `headersToSpanAttributes` skipped the denylist entirely, so `authorization` went out in the clear. The spec says an allowlist never exempts a sensitive name, so those now emit `['[Filtered]']`. Fixes #24085 --------- Co-authored-by: Claude Opus 5 --- .../instrument.mjs | 10 ++++- .../scenario.mjs | 4 +- .../fetch-headers-to-span-attributes/test.ts | 5 +++ .../browser/src/integrations/httpclient.ts | 12 ++---- .../utils/data-collection/filterCookies.ts | 5 ++- packages/core/src/utils/request.ts | 38 +++++++++++++------ .../data-collection/filterCookies.test.ts | 20 +++++++++- packages/core/test/lib/utils/request.test.ts | 36 ++++++++++++++++-- .../node/src/integrations/node-fetch/types.ts | 11 +++++- .../node-fetch/undici-instrumentation.ts | 31 +++++++++------ 10 files changed, 129 insertions(+), 43 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs index bd934b7a9c2b..11ff9c8eb451 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/instrument.mjs @@ -7,11 +7,17 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, + dataCollection: { + httpHeaders: { + request: { deny: ['x-tenant-id'] }, + response: { deny: ['content-length'] }, + }, + }, integrations: [ Sentry.nativeNodeFetchIntegration({ headersToSpanAttributes: { - requestHeaders: ['x-test-header'], - responseHeaders: ['x-powered-by'], + requestHeaders: ['x-test-header', 'authorization', 'x-tenant-id'], + responseHeaders: ['x-powered-by', 'content-length'], }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs index 0edf81a9a50a..9f5650834bd4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/scenario.mjs @@ -2,5 +2,7 @@ import * as Sentry from '@sentry/node'; // eslint-disable-next-line @typescript-eslint/no-floating-promises Sentry.startSpan({ name: 'test_transaction' }, async () => { - await fetch(`${process.env.SERVER_URL}/api/v0`, { headers: { 'x-test-header': 'test-value' } }); + await fetch(`${process.env.SERVER_URL}/api/v0`, { + headers: { 'x-test-header': 'test-value', authorization: 'Bearer super-secret', 'x-tenant-id': 'acme-corp' }, + }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts index d17d0a4132fe..6bfb18909525 100644 --- a/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/http-client-spans/fetch-headers-to-span-attributes/test.ts @@ -29,7 +29,12 @@ describe('outgoing fetch spans - headers to span attributes', () => { origin: 'auto.http.node_fetch', data: expect.objectContaining({ 'http.request.header.x-test-header': ['test-value'], + // Listed in `headersToSpanAttributes`, but the built-in denylist still wins. + 'http.request.header.authorization': ['[Filtered]'], + // Listed in `headersToSpanAttributes`, but denied via `dataCollection.httpHeaders`. + 'http.request.header.x-tenant-id': ['[Filtered]'], 'http.response.header.x-powered-by': ['Express'], + 'http.response.header.content-length': ['[Filtered]'], }), }), ]), diff --git a/packages/browser/src/integrations/httpclient.ts b/packages/browser/src/integrations/httpclient.ts index f9d01c5719b5..a47725b3d37d 100644 --- a/packages/browser/src/integrations/httpclient.ts +++ b/packages/browser/src/integrations/httpclient.ts @@ -93,16 +93,12 @@ function _fetchResponseHandler( const reqCookieStr = request.headers.get('Cookie') || undefined; if (reqCookieStr) { const filtered = _INTERNAL_filterCookies(reqCookieStr, dc.cookies); - if (typeof filtered === 'object') { - requestCookies = filtered; - } + requestCookies = typeof filtered === 'string' ? { cookie: filtered } : filtered; } const resCookieStr = response.headers.get('Set-Cookie') || undefined; if (resCookieStr) { const filtered = _INTERNAL_filterCookies(resCookieStr, dc.cookies); - if (typeof filtered === 'object') { - responseCookies = filtered; - } + responseCookies = typeof filtered === 'string' ? { 'set-cookie': filtered } : filtered; } } @@ -146,9 +142,7 @@ function _xhrResponseHandler( const cookieString = xhr.getResponseHeader('Set-Cookie') || xhr.getResponseHeader('set-cookie') || undefined; if (cookieString) { const filtered = _INTERNAL_filterCookies(cookieString, dc.cookies); - if (typeof filtered === 'object') { - responseCookies = filtered; - } + responseCookies = typeof filtered === 'string' ? { 'set-cookie': filtered } : filtered; } } catch { // ignore it if parsing fails diff --git a/packages/core/src/utils/data-collection/filterCookies.ts b/packages/core/src/utils/data-collection/filterCookies.ts index ad18d67fe14a..0fc373f4bfce 100644 --- a/packages/core/src/utils/data-collection/filterCookies.ts +++ b/packages/core/src/utils/data-collection/filterCookies.ts @@ -8,6 +8,8 @@ import { filterKeyValueData } from './filterKeyValueData'; * * When individual cookies can be parsed, each key-value pair is filtered * independently. When parsing fails, the entire string is replaced with `[Filtered]`. + * A nameless segment inside an otherwise parseable string (`"opaque-blob; theme=dark"`) is + * dropped, since a record key cannot carry a `[Filtered]` marker without leaking the token. */ export function filterCookies(cookieString: string, behavior: CollectBehavior): Record | string { if (behavior === false) { @@ -17,8 +19,9 @@ export function filterCookies(cookieString: string, behavior: CollectBehavior): try { const parsed = parseCookie(cookieString); + // A non-empty string we cannot parse may still hold a session token, so it counts as sensitive. if (Object.keys(parsed).length === 0) { - return {}; + return cookieString ? FILTERED : {}; } return filterKeyValueData(parsed, behavior, SENSITIVE_COOKIE_NAME_SNIPPETS); diff --git a/packages/core/src/utils/request.ts b/packages/core/src/utils/request.ts index 932a10f652b8..7c6ae29a3304 100644 --- a/packages/core/src/utils/request.ts +++ b/packages/core/src/utils/request.ts @@ -305,11 +305,16 @@ export function httpHeadersToSpanAttributes( const cookies = parseCookieHeader(value, lowerKey === 'set-cookie'); spanAttributes[`${prefix}${lowerKey}`] = cookies.length - ? cookies.map(([cookieKey, cookieValue]) => - shouldFilterDataKey(cookieKey, cookieBehavior, SENSITIVE_COOKIE_NAME_SNIPPETS) + ? cookies.map(([cookieKey, cookieValue]) => { + // A nameless cookie's bare token is its value; no denylist could match it, so it is + // always filtered. + if (cookieKey === '') { + return FILTERED_VALUE; + } + return shouldFilterDataKey(cookieKey, cookieBehavior, SENSITIVE_COOKIE_NAME_SNIPPETS) ? `${cookieKey}=${FILTERED_VALUE}` - : `${cookieKey}=${cookieValue}`, - ) + : `${cookieKey}=${cookieValue}`; + }) : [FILTERED_VALUE]; } else { if (headerBehavior === false) { @@ -338,22 +343,31 @@ export function httpHeadersToSpanAttributes( return spanAttributes; } +/** + * Splits a `Cookie` / `Set-Cookie` header into its name-value pairs. + * + * A segment without an `=` is a nameless cookie, so the bare token is its value (RFC 6265bis): + * it is returned as a pair with an empty name. + */ function parseCookieHeader(value: string | string[], isSetCookie: boolean): [string, string][] { // Set-Cookie: one cookie per value, with attributes ("name=value; HttpOnly; Secure") - // Cookie: multiple cookies separated by "; " ("cookie1=value1; cookie2=value2") + // Cookie: multiple cookies separated by ";" (the space after ";" is not guaranteed on the wire) const cookies = (Array.isArray(value) ? value : [value]).flatMap(headerValue => { if (typeof headerValue !== 'string' || headerValue === '') { return []; } - return isSetCookie ? [headerValue.split(';')[0]!] : headerValue.split('; '); + return isSetCookie ? [headerValue.split(';')[0]!] : headerValue.split(';'); }); - return cookies.map(cookie => { - const equalSignIndex = cookie.indexOf('='); - return equalSignIndex !== -1 - ? [cookie.substring(0, equalSignIndex), cookie.substring(equalSignIndex + 1)] - : [cookie, '']; - }); + return cookies + .map(cookie => cookie.trim()) + .filter(cookie => cookie !== '') + .map(cookie => { + const equalSignIndex = cookie.indexOf('='); + return equalSignIndex !== -1 + ? [cookie.substring(0, equalSignIndex), cookie.substring(equalSignIndex + 1)] + : ['', cookie]; + }); } /** Extract the query params from an URL. */ diff --git a/packages/core/test/lib/utils/data-collection/filterCookies.test.ts b/packages/core/test/lib/utils/data-collection/filterCookies.test.ts index 11e5a660c1e6..4f8ed3d57fba 100644 --- a/packages/core/test/lib/utils/data-collection/filterCookies.test.ts +++ b/packages/core/test/lib/utils/data-collection/filterCookies.test.ts @@ -78,8 +78,24 @@ describe('filterCookies', () => { expect(filterCookies('', true)).toEqual({}); }); - it('returns empty record for string with no key-value pairs', () => { - expect(filterCookies(';;;', true)).toEqual({}); + it('filters the whole string when no key-value pairs can be extracted', () => { + expect(filterCookies(';;;', true)).toBe('[Filtered]'); + expect(filterCookies('opaque-session-blob', true)).toBe('[Filtered]'); + }); + }); + + // Intended behavior for the cookie parsing consolidation follow-up: `Set-Cookie` attributes are + // metadata, not cookies, so they must not show up as key-value pairs. Marked `fails` until the + // shared parser handles them. + describe('Set-Cookie attribute handling (known gaps)', () => { + it.fails('does not report Set-Cookie attributes as cookie pairs', () => { + expect(filterCookies('sid=1; Max-Age=3600; Path=/', true)).toEqual({ sid: '[Filtered]' }); + }); + + it.fails('does not report Expires/Domain attributes as cookie pairs', () => { + expect(filterCookies('theme=dark; Expires=Wed, 21 Oct 2026 07:28:00 GMT; Domain=example.com', true)).toEqual({ + theme: 'dark', + }); }); }); diff --git a/packages/core/test/lib/utils/request.test.ts b/packages/core/test/lib/utils/request.test.ts index 2041e06ec3da..58e29ad12ddd 100644 --- a/packages/core/test/lib/utils/request.test.ts +++ b/packages/core/test/lib/utils/request.test.ts @@ -650,8 +650,7 @@ describe('request utils', () => { it('attaches and filters sensitive cookie headers', () => { const headers = { - Cookie: - 'session=abc123; tracking=enabled; cookie-authentication-key-without-value; theme=dark; lang=en; user_session=xyz789; pref=1', + Cookie: 'session=abc123; tracking=enabled; theme=dark; lang=en; user_session=xyz789; pref=1', }; const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); @@ -660,7 +659,6 @@ describe('request utils', () => { 'http.request.header.cookie': [ 'session=[Filtered]', 'tracking=enabled', - 'cookie-authentication-key-without-value=[Filtered]', 'theme=dark', 'lang=en', 'user_session=[Filtered]', @@ -669,6 +667,35 @@ describe('request utils', () => { }); }); + it('filters cookie segments that are not a name=value pair', () => { + // The bare token is a nameless cookie's value, so it must be filtered. + const headers = { Cookie: 'session=abc123; theme=dark; y7Uu0Rk2QpLmXv3' }; + + const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); + + expect(result).toEqual({ + 'http.request.header.cookie': ['session=[Filtered]', 'theme=dark', '[Filtered]'], + }); + }); + + it('filters a cookie header that holds no name=value pair', () => { + const headers = { Cookie: 'y7Uu0Rk2QpLmXv3' }; + + const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); + + expect(result).toEqual({ 'http.request.header.cookie': ['[Filtered]'] }); + }); + + it('splits cookies on ";" without a following space', () => { + const headers = { Cookie: 'theme=dark;__Secure-session=abc123' }; + + const result = httpHeadersToSpanAttributes(headers, resolveDataCollectionOptions({})); + + expect(result).toEqual({ + 'http.request.header.cookie': ['theme=dark', '__Secure-session=[Filtered]'], + }); + }); + it('filters common framework and provider session-style cookie names', () => { const headers = { Cookie: @@ -728,7 +755,8 @@ describe('request utils', () => { ['pref=1; Max-Age=3600', { 'http.request.header.set-cookie': ['pref=1'] }], ['color=blue; Path=/dashboard', { 'http.request.header.set-cookie': ['color=blue'] }], ['token=eyJhbGc=.eyJzdWI=.SflKxw; Secure', { 'http.request.header.set-cookie': ['token=[Filtered]'] }], - ['auth_required; HttpOnly', { 'http.request.header.set-cookie': ['auth_required=[Filtered]'] }], + // A set-cookie string without "=" is a nameless cookie: the bare token is its value. + ['auth_required; HttpOnly', { 'http.request.header.set-cookie': ['[Filtered]'] }], ['empty=; Secure', { 'http.request.header.set-cookie': ['empty='] }], ])('should parse and filter Set-Cookie header: %s', (setCookieValue, expected) => { const headers = { 'Set-Cookie': setCookieValue }; diff --git a/packages/node/src/integrations/node-fetch/types.ts b/packages/node/src/integrations/node-fetch/types.ts index 4a5d38c5bc86..3f0e6a477537 100644 --- a/packages/node/src/integrations/node-fetch/types.ts +++ b/packages/node/src/integrations/node-fetch/types.ts @@ -87,7 +87,16 @@ export interface UndiciInstrumentationConfig; /** Function called once response headers have been received */ responseHook?: ResponseHookFunction; - /** Map the following HTTP headers to span attributes. */ + /** + * Capture the listed HTTP headers as span attributes + * (`http.request.header.` / `http.response.header.`). + * + * Privacy filtering still applies to every header listed here. A header keeps its value only if + * `dataCollection.httpHeaders` permits it: + * - Sensitive names (`authorization`, `cookie`, ...) always show up as `[Filtered]`. + * - Names on the `deny` list show up as `[Filtered]`. + * - If an `allow` list is configured, a header must appear there as well, or it shows up as `[Filtered]`. + */ headersToSpanAttributes?: { requestHeaders?: string[]; responseHeaders?: string[]; diff --git a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts index 62fc4992ef4f..89eed4800d46 100644 --- a/packages/node/src/integrations/node-fetch/undici-instrumentation.ts +++ b/packages/node/src/integrations/node-fetch/undici-instrumentation.ts @@ -40,6 +40,7 @@ import { getUrlQuery, filterCollectedUrl, filterCollectedUrlQuery, + httpHeadersToSpanAttributes, } from '@sentry/core'; import { addFetchRequestBreadcrumb, addTracePropagationHeadersToFetchRequest } from '../../utils/outgoingFetchRequest'; import { @@ -312,16 +313,21 @@ function onRequestHeaders(config: NodeFetchOptions, { request, socket }: Request // After hooks have been processed (which may modify request headers) // we can collect the headers based on the configuration - if (config.headersToSpanAttributes?.requestHeaders) { + const client = getClient(); + if (config.headersToSpanAttributes?.requestHeaders && client) { const headersToAttribs = new Set(config.headersToSpanAttributes.requestHeaders.map(n => n.toLowerCase())); const headersMap = parseRequestHeaders(request); + const allowlisted: Record = {}; for (const [name, value] of headersMap.entries()) { if (headersToAttribs.has(name)) { - const attrValue = Array.isArray(value) ? value : [value]; - spanAttributes[`http.request.header.${name}`] = attrValue; + allowlisted[name] = value; } } + + // An entry in `headersToSpanAttributes` does not exempt a header from the `dataCollection` + // filtering, so the allowlisted subset goes through the same pipeline as any other header. + Object.assign(spanAttributes, httpHeadersToSpanAttributes(allowlisted, client.getDataCollectionOptions())); } span.setAttributes(spanAttributes); @@ -354,10 +360,12 @@ function onResponseHeaders(config: NodeFetchOptions, { request, response }: Resp () => undefined, ); - if (config.headersToSpanAttributes?.responseHeaders) { + const client = getClient(); + if (config.headersToSpanAttributes?.responseHeaders && client) { const headersToAttribs = new Set(); config.headersToSpanAttributes?.responseHeaders.forEach(name => headersToAttribs.add(name.toLowerCase())); + const allowlisted: Record = {}; for (let idx = 0; idx < response.headers.length; idx = idx + 2) { const nameBuf = response.headers[idx]; const valueBuf = response.headers[idx + 1]; @@ -365,17 +373,18 @@ function onResponseHeaders(config: NodeFetchOptions, { request, response }: Resp continue; } const name = nameBuf.toString().toLowerCase(); - const value = valueBuf; if (headersToAttribs.has(name)) { - const attrName = `http.response.header.${name}`; - if (!Object.prototype.hasOwnProperty.call(spanAttributes, attrName)) { - spanAttributes[attrName] = [value.toString()]; - } else { - (spanAttributes[attrName] as string[]).push(value.toString()); - } + (allowlisted[name] ??= []).push(valueBuf.toString()); } } + + // An entry in `headersToSpanAttributes` does not exempt a header from the `dataCollection` + // filtering, so the allowlisted subset goes through the same pipeline as any other header. + Object.assign( + spanAttributes, + httpHeadersToSpanAttributes(allowlisted, client.getDataCollectionOptions(), 'response'), + ); } span.setAttributes(spanAttributes); From d088221b9d1c1c0c73e8da738a5e98c88e9c7503 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 21 Sep 2026 12:20:24 +0300 Subject: [PATCH 30/39] feat(cloudflare): Auto-register Flue instrumentation in bundled workers (#24476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flue is registered, not patched — `instrument()` writes into module-scope state — so instrumenting it needs a reference to that module's own binding, and no channel payload carries one. A bundled worker has no `node_modules` to resolve one from, so this supplies it at build time. `@sentry/cloudflare/vite` splices a static `@flue/runtime` import into Sentry's own Flue integration module and exposes the namespace on `providedModules`; `flueIntegration()` reads it there and registers. A `registrationOnly` orchestrion entry installs the integration on a bundler-only SDK and keeps it reachable under `sideEffects: false`. `@flue/runtime` is ESM-only, so the presence check resolves with `createRequire` from the Vite root and counts `ERR_PACKAGE_PATH_NOT_EXPORTED` as a hit — the package publishes no `require` condition on any subpath, while a genuinely missing one reports `MODULE_NOT_FOUND`. The namespace is exposed through a getter rather than assigned, because the bundler may evaluate Sentry's module before `@flue/runtime` is initialized. An app that also calls `instrument()` itself is unaffected: its own registration wins, and only the resulting `InstrumentationAlreadyInstalledError` is swallowed. On Node registering stays a manual `instrument(Sentry.createFlueInstrumentation())` call — `flueIntegration()` is not among the default integrations there. Verified end to end in #24477. --------- Co-authored-by: Claude Opus 5 --- packages/cloudflare/src/index.ts | 1 + packages/cloudflare/src/vite/flueRuntime.ts | 75 ++++++++ packages/cloudflare/src/vite/index.ts | 2 + .../cloudflare/test/vite/flueRuntime.test.ts | 162 ++++++++++++++++++ .../server-utils/src/ai/flue/constants.ts | 4 + packages/server-utils/src/index.ts | 1 + .../server-utils/src/integrations/flue.ts | 67 ++++++++ .../config/channel-integration-definitions.ts | 1 + .../src/orchestrion/config/flue.ts | 12 ++ .../src/orchestrion/config/index.ts | 2 + .../test/integrations/flue.test.ts | 105 ++++++++++++ .../test/orchestrion/config.test.ts | 15 ++ 12 files changed, 447 insertions(+) create mode 100644 packages/cloudflare/src/vite/flueRuntime.ts create mode 100644 packages/cloudflare/test/vite/flueRuntime.test.ts create mode 100644 packages/server-utils/src/integrations/flue.ts create mode 100644 packages/server-utils/src/orchestrion/config/flue.ts create mode 100644 packages/server-utils/test/integrations/flue.test.ts diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 16ec6d6d9e24..2a24871ab186 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -108,6 +108,7 @@ export { } from '@sentry/core'; export { trpcMiddleware, wrapMcpServerWithSentry } from '@sentry/core/server'; export { createFlueInstrumentation, instrumentPostgresJsSql } from '@sentry/server-utils'; +export type { FlueOptions } from '@sentry/server-utils'; export { withSentry } from './withSentry'; export { defineCloudflareOptions } from './defineCloudflareOptions'; diff --git a/packages/cloudflare/src/vite/flueRuntime.ts b/packages/cloudflare/src/vite/flueRuntime.ts new file mode 100644 index 000000000000..1ac2d09aed77 --- /dev/null +++ b/packages/cloudflare/src/vite/flueRuntime.ts @@ -0,0 +1,75 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import MagicString from 'magic-string'; + +// Namespace binding the injected provider import uses; read back by the integration +// off the global marker. +const PROVIDER_IDENTIFIER = '__SENTRY_FLUE_RUNTIME__'; + +const FLUE_MODULE = '@flue/runtime'; + +// The bundled `@sentry/server-utils` Flue integration module (ESM build — the only one a +// worker loads). It reads `@flue/runtime` off the global marker this provider populates, +// because `instrument()` registers into module-scope state no channel payload can carry. +const FLUE_INTEGRATION_ID = /@sentry\/server-utils\/build\/esm\/integrations\/flue\.js$/; + +/** Whether `id` is the Sentry Flue integration module the provider injects into. */ +export function isFlueIntegrationModuleId(id: string): boolean { + const normalizedId = id.replace(/\\/g, '/').replace(/[?#].*$/, ''); + return FLUE_INTEGRATION_ID.test(normalizedId); +} + +/** + * Splices a static `import * as … from '@flue/runtime'` into Sentry's own Flue integration module + * and exposes the namespace on the global orchestrion marker. + * + * Flue is registered rather than patched — `instrument()` writes into module-scope state — so + * instrumenting it needs that module's own binding, and no channel payload carries one. On Node the + * user passes it by calling `instrument()` themselves; a bundled worker has no `node_modules` to + * resolve from, so it is supplied at build time instead. + */ +export function sentryFlueRuntimeProviderPlugin(): { + name: string; + configResolved(config: { root: string }): void; + transform(code: string, id: string): { code: string; map: ReturnType } | undefined; +} { + let providerSnippet: string | undefined; + + return { + name: 'sentry-cloudflare-flue-runtime-provider', + + configResolved(config: { root: string }): void { + // Build-time only; never ships to the worker. Probed with CJS resolution, which an ESM-only + // `@flue/runtime` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` — so only a module-not-found + // counts as absent, and any other failure still injects and lets Vite report it. Not + // `import.meta.resolve`: `parentURL` is ignored without a flag, and it is absent from the + // CJS build. + try { + createRequire(resolve(config.root, 'noop.js')).resolve(FLUE_MODULE); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') { + return; + } + } + // A getter where Mastra assigns: the bundler may evaluate Sentry's module before + // `@flue/runtime` is initialized, and assigning there would store `undefined`. + providerSnippet = + `import * as ${PROVIDER_IDENTIFIER} from '${FLUE_MODULE}';\n` + + '(globalThis.__SENTRY_ORCHESTRION__ = globalThis.__SENTRY_ORCHESTRION__ || {});\n' + + '(globalThis.__SENTRY_ORCHESTRION__.providedModules = globalThis.__SENTRY_ORCHESTRION__.providedModules || {});\n' + + `Object.defineProperty(globalThis.__SENTRY_ORCHESTRION__.providedModules, '${FLUE_MODULE}', ` + + `{ configurable: true, enumerable: true, get() { return ${PROVIDER_IDENTIFIER}; } });\n`; + }, + + transform(code: string, id: string): { code: string; map: ReturnType } | undefined { + // `code.includes` keeps this idempotent: a second pass over already-injected output would + // otherwise emit a duplicate `import * as` binding, which is a syntax error. + if (!providerSnippet || !isFlueIntegrationModuleId(id) || code.includes(PROVIDER_IDENTIFIER)) return undefined; + + const ms = new MagicString(code); + ms.prepend(providerSnippet); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index be679c4823f5..d6005250d9ee 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -5,6 +5,7 @@ // expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself. import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import { sentryCloudflareAutoInstrumentPlugin } from './autoInstrument'; +import { sentryFlueRuntimeProviderPlugin } from './flueRuntime'; import { sentryMastraObservabilityProviderPlugin } from './mastraObservability'; /** @@ -91,6 +92,7 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp dcModule: '@sentry/cloudflare/orchestrion-diagnostics-channel', }), sentryMastraObservabilityProviderPlugin(), + sentryFlueRuntimeProviderPlugin(), ...(options.autoInstrumentation !== false ? [sentryCloudflareAutoInstrumentPlugin({ wranglerConfigPath: options.wranglerConfigPath })] : []), diff --git a/packages/cloudflare/test/vite/flueRuntime.test.ts b/packages/cloudflare/test/vite/flueRuntime.test.ts new file mode 100644 index 000000000000..8ebc1ae595f0 --- /dev/null +++ b/packages/cloudflare/test/vite/flueRuntime.test.ts @@ -0,0 +1,162 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { sentryCloudflareVitePlugin } from '../../src/vite/index'; +import { isFlueIntegrationModuleId, sentryFlueRuntimeProviderPlugin } from '../../src/vite/flueRuntime'; + +const PROVIDER_PLUGIN = 'sentry-cloudflare-flue-runtime-provider'; +const FLUE_INTEGRATION_MODULE = '/app/node_modules/@sentry/server-utils/build/esm/integrations/flue.js'; + +/** An app root whose `node_modules` holds an ESM-only `@flue/runtime`, as published. */ +function createRootWithFlue(): string { + const root = mkdtempSync(join(tmpdir(), 'sentry-flue-root-')); + const pkgDir = join(root, 'node_modules', '@flue', 'runtime'); + mkdirSync(join(pkgDir, 'dist'), { recursive: true }); + writeFileSync( + join(pkgDir, 'package.json'), + // No `require` condition — the reason `resolve()` reports ERR_PACKAGE_PATH_NOT_EXPORTED. + JSON.stringify({ + name: '@flue/runtime', + version: '2.0.8', + type: 'module', + exports: { '.': { import: './dist/index.mjs' } }, + }), + ); + writeFileSync(join(pkgDir, 'dist', 'index.mjs'), 'export const instrument = () => {};\n'); + return root; +} + +function createEmptyRoot(): string { + return mkdtempSync(join(tmpdir(), 'sentry-flue-empty-')); +} + +/** An app root holding an installed but unreadable `@flue/runtime`. */ +function createRootWithBrokenFlue(): string { + const root = mkdtempSync(join(tmpdir(), 'sentry-flue-broken-')); + const pkgDir = join(root, 'node_modules', '@flue', 'runtime'); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync(join(pkgDir, 'package.json'), '{ not json'); + return root; +} + +describe('isFlueIntegrationModuleId', () => { + it('matches the ESM Flue integration module', () => { + expect(isFlueIntegrationModuleId(FLUE_INTEGRATION_MODULE)).toBe(true); + }); + + it('ignores a trailing query/hash Vite may append', () => { + expect(isFlueIntegrationModuleId(`${FLUE_INTEGRATION_MODULE}?v=abc`)).toBe(true); + }); + + it('normalizes Windows separators', () => { + expect( + isFlueIntegrationModuleId('C:\\app\\node_modules\\@sentry\\server-utils\\build\\esm\\integrations\\flue.js'), + ).toBe(true); + }); + + it('does not match the CJS build (workers load ESM)', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@sentry/server-utils/build/cjs/integrations/flue.js')).toBe( + false, + ); + }); + + it('does not match another integration module', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@sentry/server-utils/build/esm/integrations/mastra.js')).toBe( + false, + ); + }); + + it('does not match Flue itself', () => { + expect(isFlueIntegrationModuleId('/app/node_modules/@flue/runtime/dist/index.mjs')).toBe(false); + }); +}); + +describe('sentryFlueRuntimeProviderPlugin', () => { + describe('when the app has @flue/runtime installed', () => { + let root: string; + + beforeAll(() => { + root = createRootWithFlue(); + }); + + it('injects the provider even though the package is ESM-only', () => { + // Regression guard: treating that error as "absent" silently disabled auto-instrumentation. + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + const result = plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE); + + expect(result?.code).toContain("import * as __SENTRY_FLUE_RUNTIME__ from '@flue/runtime';"); + expect(result?.code).toContain('__SENTRY_ORCHESTRION__.providedModules'); + expect(result?.code).toContain('export const x = 1;'); + }); + + it('exposes the namespace through a getter rather than a snapshot', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + expect(plugin.transform('', FLUE_INTEGRATION_MODULE)?.code).toContain( + 'get() { return __SENTRY_FLUE_RUNTIME__; }', + ); + }); + + it('leaves every other module untouched', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + expect(plugin.transform('export const x = 1;', '/app/src/index.ts')).toBeUndefined(); + }); + + it('injects once, so a second pass cannot emit a duplicate binding', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root }); + + const once = plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE)?.code ?? ''; + + expect(plugin.transform(once, FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + }); + + describe('when @flue/runtime is installed but unresolvable', () => { + it('still injects, so the failure surfaces from Vite instead of silently disabling tracing', () => { + // Only a module-not-found means absent. Skipping on every other resolve failure is how an + // installed package silently loses instrumentation, which is the bug this plugin fixes. + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root: createRootWithBrokenFlue() }); + + expect(plugin.transform('', FLUE_INTEGRATION_MODULE)).toBeDefined(); + }); + }); + + describe('when the app does not have @flue/runtime installed', () => { + it('injects nothing', () => { + const plugin = sentryFlueRuntimeProviderPlugin(); + plugin.configResolved({ root: createEmptyRoot() }); + + expect(plugin.transform('export const x = 1;', FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + + it("resolves from the app root, not from Sentry's own install", () => { + // This repo has no `@flue/runtime`, so only an app root that does can pass the check. + const withFlue = sentryFlueRuntimeProviderPlugin(); + withFlue.configResolved({ root: createRootWithFlue() }); + + const withoutFlue = sentryFlueRuntimeProviderPlugin(); + withoutFlue.configResolved({ root: createEmptyRoot() }); + + expect(withFlue.transform('', FLUE_INTEGRATION_MODULE)).toBeDefined(); + expect(withoutFlue.transform('', FLUE_INTEGRATION_MODULE)).toBeUndefined(); + }); + }); +}); + +describe('sentryCloudflareVitePlugin', () => { + it('always includes the Flue runtime provider plugin', () => { + expect(sentryCloudflareVitePlugin().map(plugin => plugin.name)).toContain(PROVIDER_PLUGIN); + // Not gated by auto-instrumentation: it injects into Sentry's own module, not the entry. + expect(sentryCloudflareVitePlugin({ autoInstrumentation: false }).map(plugin => plugin.name)).toContain( + PROVIDER_PLUGIN, + ); + }); +}); diff --git a/packages/server-utils/src/ai/flue/constants.ts b/packages/server-utils/src/ai/flue/constants.ts index ae568d24312a..6a3bbaaefaa9 100644 --- a/packages/server-utils/src/ai/flue/constants.ts +++ b/packages/server-utils/src/ai/flue/constants.ts @@ -1,3 +1,7 @@ +export const FLUE_INTEGRATION_NAME = 'Flue' as const; + +export const FLUE_MODULE_NAME = '@flue/runtime'; + export const FLUE_ORIGIN = 'auto.ai.flue'; /** diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 62f74e3675ef..65620fd403cb 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -43,6 +43,7 @@ export { knexIntegration } from './integrations/knex'; export { langChainIntegration } from './integrations/langchain'; export { langGraphIntegration } from './integrations/langgraph'; export { createFlueInstrumentation } from './ai/flue'; +export { flueIntegration } from './integrations/flue'; export type { FlueOptions } from './ai/flue'; export { mastraIntegration } from './integrations/mastra'; export { SentryMastraExporter } from './ai/mastra'; diff --git a/packages/server-utils/src/integrations/flue.ts b/packages/server-utils/src/integrations/flue.ts new file mode 100644 index 000000000000..dacf328d72ce --- /dev/null +++ b/packages/server-utils/src/integrations/flue.ts @@ -0,0 +1,67 @@ +import type { IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, GLOBAL_OBJ } from '@sentry/core'; +import type { FlueOptions } from '../ai/flue'; +import { createFlueInstrumentation } from '../ai/flue'; +import { FLUE_INTEGRATION_NAME, FLUE_MODULE_NAME } from '../ai/flue/constants'; +import { DEBUG_BUILD } from '../debug-build'; + +type FlueInstrumentFn = (instrumentation: ReturnType) => unknown; + +/** + * The `instrument` we last registered against, so a second `setup()` in the same isolate is a + * no-op. Cloudflare runs `init()` per request, and with `cacheClient: false` that reaches here + * every time: in production Flue throws on the repeat, and under `vite dev` it disposes our + * previous registration instead — ending the turn and tool spans of every in-flight request. + * + * Keyed on the binding rather than a bare boolean so a fresh `@flue/runtime` instance (a new + * isolate reusing this module, a test swapping the marker) still registers. + */ +let registeredBinding: FlueInstrumentFn | undefined; + +/** + * Register the instrumentation with Flue on the user's behalf, when the runtime binding is available. + * + * Flue is registered rather than patched — `instrument()` writes into module-scope state — so this + * needs a reference to that module's own binding. In a bundled worker there is no `node_modules` to + * resolve one from, so `@sentry/cloudflare/vite` splices a static `@flue/runtime` import into this + * module at build time and stashes the namespace on the global marker. Outside that setup the marker + * is empty and this no-ops, leaving the user's own `instrument(Sentry.createFlueInstrumentation())` + * as the way in. + */ +const _flueIntegration = ((options: FlueOptions = {}) => { + return { + name: FLUE_INTEGRATION_NAME, + setup() { + const provided = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.providedModules?.[FLUE_MODULE_NAME]; + const instrument = provided?.instrument as FlueInstrumentFn | undefined; + + if (typeof instrument !== 'function') { + DEBUG_BUILD && debug.log('[Flue] no provided `@flue/runtime` binding; skipping auto-registration'); + return; + } + + if (instrument === registeredBinding) { + DEBUG_BUILD && debug.log('[Flue] already registered in this isolate; skipping auto-registration'); + return; + } + + try { + instrument(createFlueInstrumentation(options)); + registeredBinding = instrument; + } catch (error) { + // Never rethrow: `setup()` runs inside `Sentry.init()`, which core calls unguarded and + // Cloudflare calls per request, so throwing here would take down the request handler. + if ((error as Error | undefined)?.name === 'InstrumentationAlreadyInstalledError') { + // The app owns the key and we will never win it, so stop rebuilding the instrumentation + // (two 1000-entry `LRUMap`s) on every later `init()`. + registeredBinding = instrument; + DEBUG_BUILD && debug.log('[Flue] already instrumented by the app; skipping auto-registration'); + } else { + debug.warn('[Flue] auto-registration failed; Flue spans will not be recorded:', error); + } + } + }, + }; +}) satisfies IntegrationFn; + +export const flueIntegration = defineIntegration(_flueIntegration); diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts index a1c51f6008fa..881a38da55fd 100644 --- a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -45,6 +45,7 @@ export const CHANNEL_INTEGRATION_DEFINITIONS = [ }, { exportName: 'langGraphIntegration', modules: ['@langchain/langgraph'] }, { exportName: 'mastraIntegration', modules: ['@mastra/core'] }, + { exportName: 'flueIntegration', modules: ['@flue/runtime'] }, { exportName: 'awsIntegration', modules: ['@aws-sdk/smithy-client', '@smithy/core', '@smithy/smithy-client'] }, { exportName: 'firebaseIntegration', modules: ['@firebase/firestore', 'firebase-functions'] }, { exportName: 'amqplibIntegration', modules: ['amqplib'] }, diff --git a/packages/server-utils/src/orchestrion/config/flue.ts b/packages/server-utils/src/orchestrion/config/flue.ts new file mode 100644 index 000000000000..da6841a1ae9b --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/flue.ts @@ -0,0 +1,12 @@ +import type { InstrumentationConfig } from '../apmTypes'; +import { registrationOnly } from './registration-only'; + +/** + * Flue publishes no diagnostics channels and needs none: it is instrumented by registering with + * `instrument()`, not by patching call sites. Transforming the entry is only how the module's + * integration gets registered at evaluation time, which is what installs it on a bundler-only SDK + * like `@sentry/cloudflare`. + */ +export const flueConfig = [ + registrationOnly({ name: '@flue/runtime', versionRange: '>=2.0.0 <3.0.0', filePath: 'dist/index.mjs' }), +] satisfies InstrumentationConfig[]; diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index dced48dc622d..0ed9055935d4 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -20,6 +20,7 @@ import { koaConfig } from './koa'; import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; import { lruMemoizerConfig } from './lru-memoizer'; +import { flueConfig } from './flue'; import { mastraConfig } from './mastra'; import { mistralConfig } from './mistral'; import { mongodbConfig } from './mongodb'; @@ -70,6 +71,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...langchainConfig, ...langgraphConfig, ...lruMemoizerConfig, + ...flueConfig, ...mastraConfig, ...mistralConfig, ...mongodbConfig, diff --git a/packages/server-utils/test/integrations/flue.test.ts b/packages/server-utils/test/integrations/flue.test.ts new file mode 100644 index 000000000000..9ebd62921ae4 --- /dev/null +++ b/packages/server-utils/test/integrations/flue.test.ts @@ -0,0 +1,105 @@ +import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { flueIntegration } from '../../src/integrations/flue'; + +function setProvidedFlue(instrument: unknown): void { + const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {} as NonNullable); + (marker as { providedModules?: Record }).providedModules = { + '@flue/runtime': { instrument }, + }; +} + +function clearMarker(): void { + delete (GLOBAL_OBJ as { __SENTRY_ORCHESTRION__?: unknown }).__SENTRY_ORCHESTRION__; +} + +/** Flue's own error for a duplicate `instrument()`, which sets `name` on the instance. */ +function alreadyInstalledError(): Error { + const error = new Error('An instrumentation is already installed for this key'); + error.name = 'InstrumentationAlreadyInstalledError'; + return error; +} + +describe('flueIntegration', () => { + afterEach(() => { + clearMarker(); + vi.restoreAllMocks(); + }); + + it('registers the instrumentation when a Flue binding is provided', () => { + const instrument = vi.fn(); + setProvidedFlue(instrument); + + flueIntegration().setup?.({} as never); + + expect(instrument).toHaveBeenCalledTimes(1); + }); + + it('does nothing when no Flue binding is on the marker', () => { + clearMarker(); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + }); + + it('registers once per isolate, however often `init()` reruns', () => { + // Cloudflare calls `init()` per request; with `cacheClient: false` `setup()` runs every time. + // Under `vite dev` Flue answers a repeat by disposing our previous registration, which ends + // the turn and tool spans of every in-flight request. + const instrument = vi.fn(); + setProvidedFlue(instrument); + + flueIntegration().setup?.({} as never); + flueIntegration().setup?.({} as never); + + expect(instrument).toHaveBeenCalledTimes(1); + }); + + it('registers again when a new Flue binding is provided', () => { + const first = vi.fn(); + setProvidedFlue(first); + flueIntegration().setup?.({} as never); + + const second = vi.fn(); + setProvidedFlue(second); + flueIntegration().setup?.({} as never); + + expect(second).toHaveBeenCalledTimes(1); + }); + + it('swallows a duplicate registration from an app that also calls instrument()', () => { + setProvidedFlue( + vi.fn(() => { + throw alreadyInstalledError(); + }), + ); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + }); + + it('stops rebuilding the instrumentation once the app owns the registration', () => { + const instrument = vi.fn(() => { + throw alreadyInstalledError(); + }); + setProvidedFlue(instrument); + + flueIntegration().setup?.({} as never); + flueIntegration().setup?.({} as never); + + expect(instrument).toHaveBeenCalledTimes(1); + }); + + it('warns but never throws when registration fails for any other reason', () => { + // `setup()` runs inside `Sentry.init()`, which core calls unguarded — throwing would take + // down the Cloudflare request handler. + const warn = vi.spyOn(debug, 'warn').mockImplementation(() => undefined); + const error = new TypeError('instrument is not a function'); + setProvidedFlue( + vi.fn(() => { + throw error; + }), + ); + + expect(() => flueIntegration().setup?.({} as never)).not.toThrow(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[Flue] auto-registration failed'), error); + }); +}); diff --git a/packages/server-utils/test/orchestrion/config.test.ts b/packages/server-utils/test/orchestrion/config.test.ts index 78d489dc2175..3d9a76415b9c 100644 --- a/packages/server-utils/test/orchestrion/config.test.ts +++ b/packages/server-utils/test/orchestrion/config.test.ts @@ -64,6 +64,21 @@ describe('orchestrion config — channel-subscriber coverage', () => { }); }); +describe('orchestrion config — Flue', () => { + it('transforms @flue/runtime', () => { + expect(SENTRY_INSTRUMENTATIONS.map(i => i.module.name)).toContain('@flue/runtime'); + }); + + it('is force-bundled as a side effect of being instrumented', () => { + expect(INSTRUMENTED_MODULE_NAMES).toContain('@flue/runtime'); + }); + + // Registration-only configs carry a custom transform the runtime loader cannot apply. + it('excludes @flue/runtime from the runtime loader', () => { + expect(SENTRY_RUNTIME_INSTRUMENTATIONS.map(i => i.module.name)).not.toContain('@flue/runtime'); + }); +}); + describe('orchestrion config — custom instrumentations', () => { const customInstrumentation = { module: { name: 'my-lib' } } as InstrumentationConfig; From 991b0e4cb1a2a31baf008acf7badc24fe907e3aa Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Mon, 21 Sep 2026 18:39:10 +0800 Subject: [PATCH 31/39] fix(bun): Respect caller-supplied `runtime` option (#24487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Closes #24047 ### Problem `@sentry/bun`'s `_init` spreads the user options and then unconditionally overwrites `runtime` with `{ name: 'bun', version: ... }`. `@sentry/elysia` correctly computes `runtime: getRuntime()` (→ `node` off Bun) and passes it down, but the Bun SDK discards it, so every Elysia-on-Node event is attributed to `{ name: 'bun', version: 'unknown' }`. This is the same bug that #19269 fixed one layer up in `NodeClient` (`runtime: options.runtime || { name: 'node', ... }`). ### Fix - `packages/bun/src/sdk.ts`: only default `runtime` when the caller did not supply one. - `packages/bun/src/types.ts`: add the (hidden) `runtime` option to `BaseBunOptions`, mirroring `BaseNodeOptions`, so the above type-checks (Elysia's `ElysiaOptions` is an alias of `BunOptions`). ### Tests - `packages/bun/test/init.test.ts`: `runtime` block — asserts the default is `{ name: 'bun', version: Bun.version }` and that a caller-supplied `runtime` is respected. The second test fails on `develop` (`{ name: 'bun', version: '1.4.2' }` received) and passes with the fix. - `packages/elysia/test/sdk.test.ts`: added the Bun-side counterpart of the existing `detects runtime correctly` test (stubs the `Bun` global) so both runtimes are asserted at the Elysia layer too. Verified locally: `bun test` in `packages/bun` (64 pass, 0 fail), `vitest run` in `packages/elysia` (9 pass), `yarn build:types` + `yarn lint` in `packages/bun`, `oxfmt --check` on the touched files. --- packages/bun/src/sdk.ts | 2 +- packages/bun/src/types.ts | 8 ++++++++ packages/bun/test/init.test.ts | 14 ++++++++++++++ packages/elysia/test/sdk.test.ts | 11 +++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index ba77f87ef9a9..2884cd542851 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -145,7 +145,7 @@ function _init( const options = { ...userOptions, platform: 'javascript', - runtime: { name: 'bun', version: typeof Bun !== 'undefined' ? Bun.version : 'unknown' }, + runtime: userOptions.runtime || { name: 'bun', version: typeof Bun !== 'undefined' ? Bun.version : 'unknown' }, serverName: userOptions.serverName || global.process.env.SENTRY_NAME || os.hostname(), }; diff --git a/packages/bun/src/types.ts b/packages/bun/src/types.ts index 34643a995ab1..ade64f83137f 100644 --- a/packages/bun/src/types.ts +++ b/packages/bun/src/types.ts @@ -21,6 +21,14 @@ export interface BaseBunOptions extends ServerRuntimeOptions { * @default false */ enableOpenTelemetrySetup?: boolean; + + /** + * Override the runtime name reported in events. + * Defaults to 'bun' with the current Bun version if not specified. + * + * @hidden This is primarily used internally to support SDKs wrapping the Bun SDK, like Elysia. + */ + runtime?: { name: string; version?: string }; } /** diff --git a/packages/bun/test/init.test.ts b/packages/bun/test/init.test.ts index abf3aabf060e..2fced80fd67a 100644 --- a/packages/bun/test/init.test.ts +++ b/packages/bun/test/init.test.ts @@ -129,6 +129,20 @@ describe('init()', () => { }); }); + describe('runtime', () => { + it('defaults to bun', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'static' }); + + expect(getClient()?.getOptions().runtime).toEqual({ name: 'bun', version: Bun.version }); + }); + + it('respects a runtime provided through options', () => { + init({ dsn: PUBLIC_DSN, traceLifecycle: 'static', runtime: { name: 'node', version: '20.0.0' } }); + + expect(getClient()?.getOptions().runtime).toEqual({ name: 'node', version: '20.0.0' }); + }); + }); + describe('initWithoutDefaultIntegrations()', () => { it('installs no default integrations', () => { initWithoutDefaultIntegrations({ dsn: PUBLIC_DSN, traceLifecycle: 'static' }); diff --git a/packages/elysia/test/sdk.test.ts b/packages/elysia/test/sdk.test.ts index d27011f0e416..142c3f4ff456 100644 --- a/packages/elysia/test/sdk.test.ts +++ b/packages/elysia/test/sdk.test.ts @@ -29,6 +29,7 @@ const { init, getDefaultIntegrations } = await import('../src/sdk'); describe('init', () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllGlobals(); }); it('sets SDK metadata to elysia', () => { @@ -108,6 +109,16 @@ describe('init', () => { expect(calledOptions.runtime.name).toBe('node'); expect(calledOptions.runtime.version).toBe(process.version); }); + + it('detects bun runtime when Bun is defined', () => { + vi.stubGlobal('Bun', { version: '1.2.3' }); + + init({ dsn: 'https://***@o0.ingest.sentry.io/0' }); + + const calledOptions = mockInitNode.mock.calls[0]![0]; + expect(calledOptions.runtime).toEqual({ name: 'bun', version: '1.2.3' }); + expect(mockApplySdkMetadata).toHaveBeenCalledWith(expect.anything(), 'elysia', ['elysia', 'bun']); + }); }); describe('getDefaultIntegrations', () => { From a1195334642cc43c58cd870975442a861716fd5d Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 21 Sep 2026 13:14:03 +0200 Subject: [PATCH 32/39] fix(core): Stop sending client reports for breadcrumbs (#24537) This PR removes the `{ reason: 'buffer_overflow', category: 'log_item' }` client report the SDK sent when the breadcrumbs buffer (100 breadcrumbs limit) overflowed and we evicted breadcrumbs. Why? When we started working on logs, we [wanted to know](https://github.com/getsentry/team-sdks/issues/116) how many breadcrumbs we currently discard due to the 100 crumbs limit. We used the new `log_item` data category for this and `buffer_overflow` reason. Later on, we added client reports for actual log discards and re-used this data category but sent different reasons. Meaning different telemtry items now fall into the same discard bucket as reported in https://github.com/getsentry/sentry-javascript/issues/24510. Since I don't think we still need to know about breadcrumb discards, and they're not their own telemetry items but part of errror and transaction events, I think we can just stop emitting client reports for them. closes https://github.com/getsentry/sentry-javascript/issues/24510 --- packages/core/src/scope.ts | 1 - packages/core/test/lib/client.test.ts | 16 ---------------- 2 files changed, 17 deletions(-) diff --git a/packages/core/src/scope.ts b/packages/core/src/scope.ts index 028e8aef5c8a..d2fba52a8fc0 100644 --- a/packages/core/src/scope.ts +++ b/packages/core/src/scope.ts @@ -574,7 +574,6 @@ export class Scope { this._breadcrumbs.push(mergedBreadcrumb); if (this._breadcrumbs.length > maxCrumbs) { this._breadcrumbs = this._breadcrumbs.slice(-maxCrumbs); - this._client?.recordDroppedEvent('buffer_overflow', 'log_item'); } this._notifyScopeListeners(); diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index d820b599e686..e2c3269c4929 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -357,22 +357,6 @@ describe('Client', () => { expect(isolationScopeBreadcrumbs).toEqual([{ message: 'hello3', timestamp: expect.any(Number) }]); }); - test('it records `buffer_overflow` client discard reason when buffer overflows', () => { - const options = getDefaultTestClientOptions({ maxBreadcrumbs: 1 }); - const client = new TestClient(options); - const recordLostEventSpy = vi.spyOn(client, 'recordDroppedEvent'); - setCurrentClient(client); - getIsolationScope().setClient(client); - client.init(); - - addBreadcrumb({ message: 'hello1' }); - addBreadcrumb({ message: 'hello2' }); - addBreadcrumb({ message: 'hello3' }); - - expect(recordLostEventSpy).toHaveBeenCalledTimes(2); - expect(recordLostEventSpy).toHaveBeenLastCalledWith('buffer_overflow', 'log_item'); - }); - test('calls `beforeBreadcrumb` and adds the breadcrumb without any changes', () => { const beforeBreadcrumb = vi.fn(breadcrumb => breadcrumb); const options = getDefaultTestClientOptions({ beforeBreadcrumb }); From 7ae159db0c68e193a5bf729850ab310961355451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 21 Sep 2026 14:52:00 +0300 Subject: [PATCH 33/39] chore(bun): Fix stale comment in `getPerformanceIntegrations` (#24533) closes #23888 The orchestrion gate that the comment described was removed on purpose when the bun auto-instrumentation handling was streamlined. The tracing integrations are now always added when spans are enabled, and the channel-based ones do nothing without the bun plugin. got stale with #23392 Co-authored-by: Claude Opus 5 --- packages/bun/src/sdk.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/bun/src/sdk.ts b/packages/bun/src/sdk.ts index 2884cd542851..983d45cb3938 100644 --- a/packages/bun/src/sdk.ts +++ b/packages/bun/src/sdk.ts @@ -28,12 +28,9 @@ import { bunHttpServerIntegration } from './integrations/bunHttpServer'; import { getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; /** - * The performance integrations for bun: the OTel auto-performance set, but with - * the orchestrion diagnostics-channel subscribers swapped in for their OTel - * equivalents *only* when the orchestrion channels were actually injected (i.e. - * the app was built with `@sentry/bun/plugin`). Without that, the channels - * never fire — and the OTel versions rely on a runtime require-hook bun doesn't - * support — so leave the auto-performance set alone. + * The tracing integrations for bun, added whenever spans are enabled. Most of them listen on + * the orchestrion diagnostics channels, which only exist when the app is built with + * `@sentry/bun/plugin`. Without the plugin, those integrations stay installed but create no spans. */ function getPerformanceIntegrations(options: Options): Integration[] { if (!hasSpansEnabled(options)) { From 3401bf06368b288ef325cd92a799ba7ddb02b1aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 21 Sep 2026 15:06:44 +0300 Subject: [PATCH 34/39] fix: Remove by accident exported convention (#24542) --- MIGRATION.md | 2 +- packages/astro/src/index.server.ts | 1 - packages/aws-serverless/src/index.ts | 1 - packages/browser/src/exports.ts | 1 - packages/bun/src/index.ts | 1 - packages/cloudflare/src/index.ts | 1 - packages/core/src/index.ts | 1 - packages/deno/src/index.ts | 1 - packages/elysia/src/index.ts | 1 - packages/google-cloud-serverless/src/index.ts | 1 - packages/node/src/index.ts | 1 - packages/remix/src/cloudflare/index.ts | 1 - packages/remix/src/server/index.ts | 1 - packages/solidstart/src/server/index.ts | 1 - packages/sveltekit/src/server/index.ts | 1 - packages/sveltekit/src/worker/index.ts | 1 - packages/vercel-edge/src/index.ts | 1 - 17 files changed, 1 insertion(+), 17 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index d0e36226ad56..f4cd13e03f3b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -872,7 +872,7 @@ Attribute availability remains runtime-dependent. For example, browser and Worke Span attributes now use the shared `@sentry/conventions` package under the hood. The deprecated `semanticAttributes` re-export was removed. Import span attribute constants from `@sentry/core` directly. -`SEMANTIC_ATTRIBUTE_SENTRY_SOURCE` (`sentry.source`) was removed. Use `SENTRY_SEGMENT_NAME_SOURCE` (`sentry.segment.name.source`) instead. +`SEMANTIC_ATTRIBUTE_SENTRY_SOURCE` (`sentry.source`) was removed. Use the `sentry.segment.name.source` attribute instead. The SDK does not export a constant for it, import `SENTRY_SEGMENT_NAME_SOURCE` from `@sentry/conventions/attributes` or use the string. `sentry.segment.name.source` is only set on the root span. Setting it on a child span is a no-op: `setAttribute` ignores it, and a value passed in a child span's initial attributes is dropped when the span is linked to its parent. ### Span operation (`op`) changes diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 116c06dab64a..6b5732fb46df 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -119,7 +119,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setConversationId, setCurrentClient, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 4f3b416066b5..6d2ac72f15f6 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -95,7 +95,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, diff --git a/packages/browser/src/exports.ts b/packages/browser/src/exports.ts index a6d9d57766ee..6708e12549ba 100644 --- a/packages/browser/src/exports.ts +++ b/packages/browser/src/exports.ts @@ -81,7 +81,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, } from '@sentry/core'; export { WINDOW } from './helpers'; diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index e412d01db9ae..40df75b4c77a 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -116,7 +116,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 2a24871ab186..f1d5d3dbd5de 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -88,7 +88,6 @@ export { consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, spanToStaticSpanJSON, spanToJSON, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0146e82a11fa..20b52be30603 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,7 +10,6 @@ export type { OfflineStore, OfflineTransportOptions } from './transports/offline export type { IntegrationIndex } from './integration'; export * from './tracing'; export * from './semanticAttributes'; -export { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; export * from './tracing/spans/spanNames'; export type { RawAttributes } from './attributes'; export { createEventEnvelope, createSessionEnvelope } from './envelope'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index 187eb0b5cbf7..b5cc8b069b2d 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -83,7 +83,6 @@ export { zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, startSession, captureSession, diff --git a/packages/elysia/src/index.ts b/packages/elysia/src/index.ts index 1f65611a6b6b..80e3c9e303ba 100644 --- a/packages/elysia/src/index.ts +++ b/packages/elysia/src/index.ts @@ -95,7 +95,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index a53893c979e9..7e2e8c2baa97 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -96,7 +96,6 @@ export { parameterize, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, dataloaderIntegration, expressIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index de280e57f11c..48e4c1d8a993 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -220,7 +220,6 @@ export { defaultStackParser, getSentryRelease } from './sdk/api'; export { makeNodeTransport } from './transports'; export { createGetModuleFromFilename } from './utils/module'; -export { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; export { eveConversationHook, getInstrumentedModuleNames } from '@sentry/server-utils'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; diff --git a/packages/remix/src/cloudflare/index.ts b/packages/remix/src/cloudflare/index.ts index 3436abae61f2..543dd239a67e 100644 --- a/packages/remix/src/cloudflare/index.ts +++ b/packages/remix/src/cloudflare/index.ts @@ -108,7 +108,6 @@ export { zodErrorsIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, spanToStaticSpanJSON, spanToJSON, diff --git a/packages/remix/src/server/index.ts b/packages/remix/src/server/index.ts index f89345ae2de5..f019b947d1ab 100644 --- a/packages/remix/src/server/index.ts +++ b/packages/remix/src/server/index.ts @@ -85,7 +85,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index da4972e07590..ce66e7c5374f 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -89,7 +89,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index b850e256ba38..c2074839dfbc 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -88,7 +88,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/sveltekit/src/worker/index.ts b/packages/sveltekit/src/worker/index.ts index 696d1ff1809d..22927842682c 100644 --- a/packages/sveltekit/src/worker/index.ts +++ b/packages/sveltekit/src/worker/index.ts @@ -56,7 +56,6 @@ export { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SENTRY_SEGMENT_NAME_SOURCE, setContext, setCurrentClient, setExtra, diff --git a/packages/vercel-edge/src/index.ts b/packages/vercel-edge/src/index.ts index be7643efa940..9d32602e0c76 100644 --- a/packages/vercel-edge/src/index.ts +++ b/packages/vercel-edge/src/index.ts @@ -85,7 +85,6 @@ export { consoleIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SENTRY_SEGMENT_NAME_SOURCE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, spanToStaticSpanJSON, spanToJSON, From ef2f11f0f4dede4c682ecf93b3f910f6b8fd0222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Mon, 21 Sep 2026 15:17:56 +0300 Subject: [PATCH 35/39] docs: Update MIGRATION.md (#24550) Overlooked a change https://github.com/getsentry/sentry-javascript/pull/24542#discussion_r4061923640 --- MIGRATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index f4cd13e03f3b..aadb4dd7793a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -872,7 +872,7 @@ Attribute availability remains runtime-dependent. For example, browser and Worke Span attributes now use the shared `@sentry/conventions` package under the hood. The deprecated `semanticAttributes` re-export was removed. Import span attribute constants from `@sentry/core` directly. -`SEMANTIC_ATTRIBUTE_SENTRY_SOURCE` (`sentry.source`) was removed. Use the `sentry.segment.name.source` attribute instead. The SDK does not export a constant for it, import `SENTRY_SEGMENT_NAME_SOURCE` from `@sentry/conventions/attributes` or use the string. +`SEMANTIC_ATTRIBUTE_SENTRY_SOURCE` (`sentry.source`) was removed. Use the `sentry.segment.name.source` attribute instead and make sure to only set it on segment/root spans. `sentry.segment.name.source` is only set on the root span. Setting it on a child span is a no-op: `setAttribute` ignores it, and a value passed in a child span's initial attributes is dropped when the span is linked to its parent. ### Span operation (`op`) changes From 6ea130fbae0490b1704b20a45b404580539abc57 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Mon, 21 Sep 2026 17:56:48 +0530 Subject: [PATCH 36/39] fix(ember): Add route IDs to pageload and navigation spans (#24372) Ember pageload and navigation spans lack the route ID used to infer their descriptions. Add `router.navigation.route.id` with the framework's route name when available. Omit it for missing or empty names so delayed updates preserve caller-provided metadata. Fixes #24284. --------- Co-authored-by: GPT-6 --- .../tests/sentry-performance.test.ts | 6 + .../tests/streamed-performance.test.ts | 46 ++++ ...nstrumentEmberAppInstanceForPerformance.ts | 15 +- ...ent-ember-app-instance-performance.test.ts | 236 ++++++++++++++++++ 4 files changed, 299 insertions(+), 4 deletions(-) create mode 100644 packages/ember/tests/instrument-ember-app-instance-performance.test.ts diff --git a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts index 57d9a6c26972..00a5eefc6d6e 100644 --- a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/sentry-performance.test.ts @@ -19,6 +19,9 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) = trace: { op: 'pageload', origin: 'auto.pageload.ember', + data: { + 'router.navigation.route.id': 'index', + }, }, }, transaction: 'route:index', @@ -47,6 +50,9 @@ test('sends a navigation transaction with a parameterized URL', async ({ page }) trace: { op: 'navigation', origin: 'auto.navigation.ember', + data: { + 'router.navigation.route.id': 'tracing', + }, }, }, transaction: 'route:tracing', diff --git a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts index e66312a19316..a39ae9d880d2 100644 --- a/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts +++ b/dev-packages/e2e-tests/test-applications/ember-strict-resolver/tests/streamed-performance.test.ts @@ -4,6 +4,52 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; // Only the `ember-strict-resolver (streamed)` variant builds the app with `traceLifecycle: 'stream'`. test.skip(process.env.E2E_TEST_TRACE_LIFECYCLE !== 'stream', 'requires the app built with span streaming'); +test('adds the route ID to a streamed pageload', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => { + return ( + span.is_segment && + getSpanOp(span) === 'pageload' && + span.attributes['sentry.origin']?.value === 'auto.pageload.ember' + ); + }); + + await page.goto('/'); + + const pageloadSpan = await pageloadSpanPromise; + + expect(pageloadSpan.attributes['router.navigation.route.id']).toEqual({ type: 'string', value: 'index' }); + expect(pageloadSpan.name).toBe('route:index'); +}); + +test('adds the route ID to a streamed navigation', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => { + return ( + span.is_segment && + getSpanOp(span) === 'pageload' && + span.attributes['sentry.origin']?.value === 'auto.pageload.ember' + ); + }); + + await page.goto('/'); + await pageloadSpanPromise; + + const navigationSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => { + return ( + span.is_segment && + getSpanOp(span) === 'navigation' && + span.attributes['sentry.origin']?.value === 'auto.navigation.ember' + ); + }); + + await page.getByText('Tracing').click(); + await expect(page).toHaveURL(/\/tracing$/); + + const navigationSpan = await navigationSpanPromise; + + expect(navigationSpan.attributes['router.navigation.route.id']).toEqual({ type: 'string', value: 'tracing' }); + expect(navigationSpan.name).toBe('route:tracing'); +}); + test('names the transition span with the low cardinality fallback', async ({ page }) => { const transitionSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => getSpanOp(span) === 'router'); diff --git a/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts index 3abd359bad21..d598c41fa834 100644 --- a/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts +++ b/packages/ember/src/utils/instrumentEmberAppInstanceForPerformance.ts @@ -7,6 +7,7 @@ import type { } from '@sentry/browser'; import { getAbsoluteUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, WINDOW } from '@sentry/browser'; import { + ROUTER_NAVIGATION_ROUTE_ID, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, URL_FULL, @@ -68,13 +69,16 @@ export function instrumentEmberAppInstanceForPerformance( activeRootSpan = startBrowserTracingPageLoadSpan(client, { // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. name: routeInfo - ? `route:${routeInfo.name}` + ? routeInfo.name + ? `route:${routeInfo.name}` + : 'route' : hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : url || WINDOW.location.pathname, attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: routeInfo ? 'route' : 'url', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.ember', + ...(routeInfo?.name && { [ROUTER_NAVIGATION_ROUTE_ID]: routeInfo.name }), ...(url ? _getRouteUrlAttributes(client, url, routeInfo?.params) : {}), toRoute: routeInfo?.name, }, @@ -91,9 +95,10 @@ export function instrumentEmberAppInstanceForPerformance( routerService.on('routeWillChange', (transition: Transition) => { const { fromRoute, toRoute } = getTransitionInformation(transition, routerService); + const transactionName = toRoute ? `route:${toRoute}` : 'route'; // Store this here to be used, even if the active span has ended - getCurrentScope().setTransactionName(`route:${toRoute}`); + getCurrentScope().setTransactionName(transactionName); // We want to ignore loading && error routes if (transitionIsIntermediate(transition)) { @@ -113,10 +118,11 @@ export function instrumentEmberAppInstanceForPerformance( const urlAttributes = targetUrl ? _getRouteUrlAttributes(client, targetUrl, transition.to?.params) : {}; activeRootSpan = startBrowserTracingNavigationSpan(client, { - name: `route:${toRoute}`, + name: transactionName, attributes: { [SENTRY_SEGMENT_NAME_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.ember', + ...(toRoute && { [ROUTER_NAVIGATION_ROUTE_ID]: toRoute }), ...urlAttributes, fromRoute, toRoute, @@ -130,9 +136,10 @@ export function instrumentEmberAppInstanceForPerformance( const url = _getLocationURL(location); if (url) { const routeInfo = _recognizeURL(routerService, url); - activeRootSpan.updateName(`route:${toRoute}`); + activeRootSpan.updateName(transactionName); activeRootSpan.setAttributes({ [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + ...(toRoute && { [ROUTER_NAVIGATION_ROUTE_ID]: toRoute }), ..._getRouteUrlAttributes(client, url, routeInfo?.params), toRoute: toRoute, }); diff --git a/packages/ember/tests/instrument-ember-app-instance-performance.test.ts b/packages/ember/tests/instrument-ember-app-instance-performance.test.ts new file mode 100644 index 000000000000..a790ac8fc74e --- /dev/null +++ b/packages/ember/tests/instrument-ember-app-instance-performance.test.ts @@ -0,0 +1,236 @@ +import type ApplicationInstance from '@ember/application/instance'; +import type Transition from '@ember/routing/transition'; +import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { getCurrentScope, SentrySpan, spanToJSON, type Client, type StartSpanOptions } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { instrumentEmberAppInstanceForPerformance } from '../src/utils/instrumentEmberAppInstanceForPerformance.ts'; + +function createRouterFixture(traceLifecycle: 'stream' | 'static' = 'stream') { + const handlers = new Map void>(); + const router = { + recognize: vi.fn<() => { name: string; params: Record } | undefined>().mockReturnValue({ + name: 'index', + params: {}, + }), + currentRouteName: undefined as string | undefined, + currentURL: '/', + on: (event: string, callback: (transition: Transition) => void) => handlers.set(event, callback), + }; + const location = { + rootURL: '/', + getURL: () => '/', + formatURL: (url: string) => url, + }; + const appInstance = { + lookup: (name: string) => (name === 'service:router' ? router : { location }), + } as unknown as ApplicationInstance; + const client = { + getOptions: () => ({ traceLifecycle }), + getDataCollectionOptions: () => ({ urlQueryParams: true }), + } as unknown as Client; + const pageloadSpan = new SentrySpan({ name: 'Pageload' }); + const navigationSpan = new SentrySpan({ name: 'Navigation' }); + const startPageloadSpan = vi.fn((_client: Client, options: StartSpanOptions) => { + pageloadSpan.updateName(options.name); + pageloadSpan.setAttributes(options.attributes ?? {}); + return pageloadSpan; + }); + const startNavigationSpan = vi.fn((_client: Client, options: StartSpanOptions) => { + navigationSpan.updateName(options.name); + navigationSpan.setAttributes(options.attributes ?? {}); + return navigationSpan; + }); + + return { + client, + router, + pageloadSpan, + navigationSpan, + startPageloadSpan, + startNavigationSpan, + instrument: (config: Parameters[2] = {}) => + instrumentEmberAppInstanceForPerformance(client, appInstance, config, startPageloadSpan, startNavigationSpan), + routeWillChange: (transition: { from?: { name: string }; to?: { name?: string; localName?: string } }) => { + const handler = handlers.get('routeWillChange'); + if (!handler) { + throw new Error('routeWillChange was not registered'); + } + handler(transition as Transition); + }, + }; +} + +describe('instrumentEmberAppInstanceForPerformance', () => { + let previousTransactionName: string | undefined; + + beforeEach(() => { + previousTransactionName = getCurrentScope().getScopeData().transactionName; + getCurrentScope().setTransactionName(undefined); + vi.stubGlobal('location', { origin: 'https://ember.example.com', pathname: '/' }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + getCurrentScope().setTransactionName(previousTransactionName); + }); + + it('adds the recognized route ID to the pageload', () => { + const fixture = createRouterFixture(); + + fixture.instrument(); + + expect(fixture.startPageloadSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, { + name: 'route:index', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'sentry.origin': 'auto.pageload.ember', + 'router.navigation.route.id': 'index', + 'url.path': '/', + 'url.full': 'https://ember.example.com/', + 'url.template': '/', + toRoute: 'index', + }, + }); + }); + + it.each(['stream', 'static'] as const)('falls back for an empty %s pageload name', traceLifecycle => { + const fixture = createRouterFixture(traceLifecycle); + fixture.router.recognize.mockReturnValue({ name: '', params: {} }); + + fixture.instrument(); + + expect(fixture.startPageloadSpan).toHaveBeenCalledTimes(1); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('route'); + expect(spanToJSON(fixture.pageloadSpan).attributes).not.toHaveProperty('router.navigation.route.id'); + }); + + it.each([true, false])('updates the initial pageload when navigation instrumentation is %s', instrumentNavigation => { + const fixture = createRouterFixture(); + fixture.router.recognize.mockReturnValue(undefined); + fixture.instrument({ instrumentNavigation }); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('Pageload'); + expect(spanToJSON(fixture.pageloadSpan).attributes).not.toHaveProperty('router.navigation.route.id'); + + fixture.routeWillChange({ to: { name: 'index' } }); + + expect(fixture.startPageloadSpan).toHaveBeenCalledTimes(1); + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('route:index'); + expect(getCurrentScope().getScopeData().transactionName).toBe('route:index'); + expect(spanToJSON(fixture.pageloadSpan).attributes['router.navigation.route.id']).toBe('index'); + expect(spanToJSON(fixture.pageloadSpan).attributes[SENTRY_SEGMENT_NAME_SOURCE]).toBe('route'); + }); + + it('adds the destination route ID without a destination URL', () => { + const fixture = createRouterFixture(); + fixture.instrument(); + + fixture.routeWillChange({ from: { name: 'index' }, to: { name: 'tracing' } }); + + expect(getCurrentScope().getScopeData().transactionName).toBe('route:tracing'); + expect(fixture.startNavigationSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, { + name: 'route:tracing', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'sentry.origin': 'auto.navigation.ember', + 'router.navigation.route.id': 'tracing', + fromRoute: 'index', + toRoute: 'tracing', + }, + }); + }); + + it('uses the current route fallback when the transition has no destination', () => { + const fixture = createRouterFixture(); + fixture.router.currentRouteName = 'tracing'; + fixture.instrument(); + + fixture.routeWillChange({ from: { name: 'index' } }); + + expect(spanToJSON(fixture.navigationSpan).attributes['router.navigation.route.id']).toBe('tracing'); + }); + + it.each([ + ['stream', undefined], + ['stream', ''], + ['static', undefined], + ['static', ''], + ] as const)('uses a navigation fallback in %s mode when the route name is %j', (traceLifecycle, currentRouteName) => { + const fixture = createRouterFixture(traceLifecycle); + fixture.router.currentRouteName = currentRouteName; + fixture.instrument(); + + fixture.routeWillChange({ from: { name: 'index' } }); + + expect(fixture.startNavigationSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, { + name: 'route', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'sentry.origin': 'auto.navigation.ember', + fromRoute: 'index', + toRoute: currentRouteName, + }, + }); + expect(getCurrentScope().getScopeData().transactionName).toBe('route'); + }); + + it.each([ + ['stream', undefined], + ['stream', ''], + ['static', undefined], + ['static', ''], + ] as const)('preserves caller route ID in %s mode for %j', (traceLifecycle, currentRouteName) => { + const fixture = createRouterFixture(traceLifecycle); + fixture.router.currentRouteName = currentRouteName; + fixture.router.recognize.mockReturnValue(undefined); + fixture.instrument(); + fixture.pageloadSpan.setAttribute('router.navigation.route.id', 'caller-route'); + const updateName = vi.spyOn(fixture.pageloadSpan, 'updateName'); + const setAttributes = vi.spyOn(fixture.pageloadSpan, 'setAttributes'); + + fixture.routeWillChange({}); + + expect(updateName).toHaveBeenCalledExactlyOnceWith('route'); + expect(spanToJSON(fixture.pageloadSpan).name).toBe('route'); + expect(getCurrentScope().getScopeData().transactionName).toBe('route'); + expect(setAttributes).toHaveBeenCalledExactlyOnceWith({ + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + 'url.path': '/', + 'url.full': 'https://ember.example.com/', + 'url.template': '/', + toRoute: currentRouteName, + }); + expect(spanToJSON(fixture.pageloadSpan).attributes['router.navigation.route.id']).toBe('caller-route'); + }); + + it('does not create a pageload when pageload instrumentation is disabled', () => { + const fixture = createRouterFixture(); + + fixture.instrument({ instrumentPageLoad: false }); + fixture.routeWillChange({ to: { name: 'index' } }); + + expect(fixture.startPageloadSpan).not.toHaveBeenCalled(); + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + }); + + it('does not create a navigation when navigation instrumentation is disabled', () => { + const fixture = createRouterFixture(); + fixture.instrument({ instrumentNavigation: false }); + + fixture.routeWillChange({ from: { name: 'index' }, to: { name: 'tracing' } }); + + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + }); + + it.each(['loading', 'error'])('does not create a navigation for an intermediate %s route', localName => { + const fixture = createRouterFixture(); + fixture.instrument(); + const endPageload = vi.spyOn(fixture.pageloadSpan, 'end'); + + fixture.routeWillChange({ from: { name: 'index' }, to: { name: `tracing.${localName}`, localName } }); + + expect(endPageload).not.toHaveBeenCalled(); + expect(fixture.startNavigationSpan).not.toHaveBeenCalled(); + }); +}); From 026cddb2ccab74b2862c7382fa012773ce487023 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 21 Sep 2026 14:39:05 +0200 Subject: [PATCH 37/39] feat(node): Add eveInstrumentation and eveIntegration for eve apps (#24539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eve 0.62/0.63 changed its instrumentation contract in two ways that broke the `node-eve` e2e app and made gen_ai content capture awkward: a flat `agent/instrumentation.ts` is no longer discovered (it must live under `agent/instrumentation/.ts` and export `defineInstrumentation(...)`), and eve now stamps every AI SDK call with `recordInputs`/`recordOutputs: false`. That per-call flag on the `ai:telemetry` channel outranks the global `dataCollection.genAI` setting, so gen_ai message content was silently dropped — the only escape was setting `recordInputs`/`recordOutputs` per integration, which is poor DX. This adds two APIs in `@sentry/server-utils`, re-exported from the runtime packages: - **`eveIntegration()`** — marks the client so the Vercel AI channel subscriber records gen_ai inputs/outputs by default under eve. An explicit `dataCollection.genAI` or `vercelAIIntegration({ recordInputs })` still take precedence. - **`Sentry.eveInstrumentation(options)`** (in `@sentry/node`) — an all-in-one provider for a single `agent/instrumentation/*.ts` file. It runs `Sentry.init` (adding `eveIntegration`) at server startup and tags each turn's AI spans with the eve session id as the Sentry conversation id via `turn.started` / `step.attempt.started` events, replacing the separate `eveConversationHook` file. Accepts `getConversationId` to customize the id. The `node-eve` e2e app is migrated to eve `^0.63.0` and the new single-file setup. ## New Usage ```ts // agent/instrumentation/sentry.ts import * as Sentry from '@sentry/node'; import { defineInstrumentation } from 'eve/instrumentation'; export default defineInstrumentation( Sentry.eveInstrumentation({ dsn: process.env.E2E_TEST_DSN, tracesSampleRate: 1.0, }), ); ``` --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jan Peer Stöcklmair --- .../node-eve/agent/hooks/sentry.ts | 7 -- .../node-eve/agent/instrumentation.ts | 18 ----- .../node-eve/agent/instrumentation/sentry.ts | 11 +++ .../test-applications/node-eve/package.json | 2 +- .../node-eve/tests/eve.test.ts | 4 +- packages/astro/src/index.server.ts | 2 + packages/aws-serverless/src/index.ts | 2 + packages/bun/src/index.ts | 2 + packages/cloudflare/src/index.ts | 1 + packages/deno/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 2 + packages/node/src/eve.ts | 78 +++++++++++++++++++ packages/node/src/index.ts | 3 +- packages/node/test/eve.test.ts | 42 ++++++++++ packages/server-utils/src/eve.ts | 15 +++- packages/server-utils/src/index.ts | 2 +- .../vercel-ai/gen-ai-recording-mode.ts | 22 ++++++ .../vercel-ai/vercel-ai-dc-subscriber.ts | 23 +++++- packages/server-utils/test/eve.test.ts | 19 ++++- .../vercel-ai/eve-recording.test.ts | 64 +++++++++++++++ 20 files changed, 284 insertions(+), 36 deletions(-) delete mode 100644 dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts create mode 100644 packages/node/src/eve.ts create mode 100644 packages/node/test/eve.test.ts create mode 100644 packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts create mode 100644 packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts deleted file mode 100644 index 3e7b0ad0a5d5..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-eve/agent/hooks/sentry.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { defineHook } from 'eve/hooks'; - -// Tags every turn of an eve session with the session id as the Sentry conversation id, so a -// session's AI spans — which land in separate traces (each turn is its own durable workflow) — -// group into one conversation in Sentry. -export default defineHook(Sentry.eveConversationHook()); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts deleted file mode 100644 index e2d33fd49591..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as Sentry from '@sentry/node'; - -// eve auto-discovers `agent/instrumentation.ts` and runs it at server startup, -// before it loads the agent (and the `ai` SDK). That is early enough for the -// Sentry SDK to install its instrumentation, so no `--import` / `NODE_OPTIONS` -// bootstrap is needed. eve's own OpenTelemetry pipeline is intentionally left -// unused: the gen_ai spans come from Sentry's `ai` instrumentation, not OTel. -Sentry.init({ - environment: 'qa', - dsn: process.env.E2E_TEST_DSN, - tunnel: 'http://localhost:3031/', // proxy server - tracesSampleRate: 1.0, - // Not a default integration. It only produces spans in the "orchestrion" test - // variant, where the server is started with - // `NODE_OPTIONS=--import=@sentry/node/import` so the orchestrion module - // transform is registered before `dataloader` loads. - integrations: [Sentry.dataloaderIntegration()], -}); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts new file mode 100644 index 000000000000..850b9983ac20 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-eve/agent/instrumentation/sentry.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node'; +import { defineInstrumentation } from 'eve/instrumentation'; + +export default defineInstrumentation( + Sentry.eveInstrumentation({ + environment: 'qa', + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3031/', // proxy server + tracesSampleRate: 1.0, + }), +); diff --git a/dev-packages/e2e-tests/test-applications/node-eve/package.json b/dev-packages/e2e-tests/test-applications/node-eve/package.json index e95a1aaafe41..f51d32a86a5f 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/package.json +++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json @@ -26,7 +26,7 @@ "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "ai": "^7.0.82", "dataloader": "^2.2.3", - "eve": "^0.52.3", + "eve": "^0.63.0", "zod": "4.5.4" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts index 14b458ca82d7..9099824bd7be 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-eve/tests/eve.test.ts @@ -76,8 +76,8 @@ test('captures Vercel AI agent spans (invoke_agent, generate_content, execute_to expect(manualSpan?.trace_id).toBe(executeTool?.trace_id); expect(manualSpan?.parent_span_id).toBe(executeTool?.span_id); - // `agent/hooks/sentry.ts` sets the eve session id as the conversation id via - // `Sentry.eveConversationHook()`, so every gen_ai span in the turn is tagged with it — that is + // `Sentry.eveInstrumentation()` (see `agent/instrumentation/sentry.ts`) sets the eve session id as + // the conversation id on each turn, so every gen_ai span in the turn is tagged with it — that is // what links a multi-turn session (each turn is its own trace) into one Sentry conversation. expect(sessionId).toBeTruthy(); for (const span of [invokeAgent, generateContent, executeTool]) { diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 6b5732fb46df..8f765fc367f8 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -189,6 +189,8 @@ export { withStreamedSpan, metrics, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 6d2ac72f15f6..c467d1b2266f 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -175,6 +175,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 40df75b4c77a..10aa2d422844 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -192,6 +192,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index f1d5d3dbd5de..acd492c06c15 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -137,6 +137,7 @@ export { instrumentCreateReactAgent, vercelAIIntegration, eveConversationHook, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/server-utils'; diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index b5cc8b069b2d..3b44d8b6d875 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -151,6 +151,7 @@ export { postgresJsIntegration, tediousIntegration, eveConversationHook, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/server-utils'; export { openTelemetryIntegration, getOtlpTracesEndpoint } from '@sentry/server-utils/no-diagnostic-channels'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 7e2e8c2baa97..e86252691726 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -175,6 +175,8 @@ export { // oxlint-disable-next-line typescript/no-deprecated withStreamedSpan, eveConversationHook, + eveInstrumentation, + eveIntegration, getInstrumentedModuleNames, } from '@sentry/node'; diff --git a/packages/node/src/eve.ts b/packages/node/src/eve.ts new file mode 100644 index 000000000000..4e1d93f20e71 --- /dev/null +++ b/packages/node/src/eve.ts @@ -0,0 +1,78 @@ +import { type Integration, setConversationId } from '@sentry/core'; +import { eveIntegration } from '@sentry/server-utils'; +import { init } from './sdk'; +import type { NodeOptions } from './types'; + +/** + * Options for {@link eveInstrumentation}: every `Sentry.init` option, plus how to derive the + * conversation id. + */ +export interface EveInstrumentationOptions extends NodeOptions { + /** + * Derive the Sentry conversation id from the eve session. Defaults to the durable session id + * (`session.id`), which is stable across every turn of a session and so groups them into one + * conversation. Return `null`/`undefined` to skip setting it for a turn. + */ + getConversationId?: (context: { session: { id: string } }) => string | null | undefined; +} + +/** + * The `defineInstrumentation` provider shape this returns. Typed structurally rather than importing + * from `eve`, so the SDK carries no dependency on the framework — the shape is checked at the + * `defineInstrumentation(...)` call site in the user's app instead. + */ +interface EveInstrumentationProvider { + setup: () => void; + events: { + 'turn.started': (event: { sessionId: string }) => void; + 'step.attempt.started': (event: { scope: { sessionId: string } }) => void; + }; +} + +/** + * All-in-one Sentry setup for an [eve](https://eve.dev) agent, wired into a single + * `agent/instrumentation/*.ts` provider file. + * + * @example + * ```ts + * // agent/instrumentation/sentry.ts + * import * as Sentry from '@sentry/node'; + * import { defineInstrumentation } from 'eve/instrumentation'; + * + * export default defineInstrumentation(Sentry.eveInstrumentation({ dsn: '__DSN__' })); + * ``` + * + * It: + * - runs `Sentry.init` with the given options at server startup (`setup`), adding + * {@link eveIntegration} so gen_ai inputs/outputs are recorded by default (eve stamps every AI SDK + * call with `recordInputs`/`recordOutputs: false`); + * - tags each turn's AI spans with a Sentry conversation id via `turn.started` / `step.attempt.started` + * events — the instrumentation-provider equivalent of `eveConversationHook`. Provider event handlers + * receive the session id on the event (`event.sessionId` / `event.scope.sessionId`), unlike hook + * handlers, which read it from their context. + */ +export function eveInstrumentation(options: EveInstrumentationOptions = {}): EveInstrumentationProvider { + const { getConversationId, ...initOptions } = options; + + const setConversationIdFromSession = (sessionId: string): void => { + setConversationId(getConversationId ? getConversationId({ session: { id: sessionId } }) : sessionId); + }; + + return { + setup() { + init({ ...initOptions, integrations: withEveIntegration(initOptions.integrations) }); + }, + events: { + 'turn.started': event => setConversationIdFromSession(event.sessionId), + 'step.attempt.started': event => setConversationIdFromSession(event.scope.sessionId), + }, + }; +} + +function withEveIntegration(integrations: NodeOptions['integrations']): NodeOptions['integrations'] { + const eve = eveIntegration(); + if (typeof integrations === 'function') { + return (defaults: Integration[]) => [...integrations(defaults), eve]; + } + return [...(integrations ?? []), eve]; +} diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 48e4c1d8a993..c4f2edc27610 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -220,7 +220,8 @@ export { defaultStackParser, getSentryRelease } from './sdk/api'; export { makeNodeTransport } from './transports'; export { createGetModuleFromFilename } from './utils/module'; -export { eveConversationHook, getInstrumentedModuleNames } from '@sentry/server-utils'; +export { eveConversationHook, eveIntegration, getInstrumentedModuleNames } from '@sentry/server-utils'; +export { eveInstrumentation } from './eve'; export { httpServerIntegration } from './integrations/http/httpServerIntegration'; export { httpServerSpansIntegration } from './integrations/http/httpServerSpansIntegration'; export { processSessionIntegration } from './integrations/processSession'; diff --git a/packages/node/test/eve.test.ts b/packages/node/test/eve.test.ts new file mode 100644 index 000000000000..11176944a0a5 --- /dev/null +++ b/packages/node/test/eve.test.ts @@ -0,0 +1,42 @@ +import * as SentryCore from '@sentry/core'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { eveInstrumentation } from '../src/eve'; + +describe('eveInstrumentation', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('exposes a setup and turn.started / step.attempt.started events', () => { + const provider = eveInstrumentation(); + + expect(typeof provider.setup).toBe('function'); + expect(Object.keys(provider.events).sort()).toEqual(['step.attempt.started', 'turn.started']); + }); + + test('sets the session id as the conversation id from turn.started (event.sessionId)', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation().events['turn.started']({ sessionId: 'sess_abc' }); + + expect(setConversationId).toHaveBeenCalledWith('sess_abc'); + }); + + test('sets it from step.attempt.started (event.scope.sessionId) too', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation().events['step.attempt.started']({ scope: { sessionId: 'sess_resumed' } }); + + expect(setConversationId).toHaveBeenCalledWith('sess_resumed'); + }); + + test('honors a custom getConversationId', () => { + const setConversationId = vi.spyOn(SentryCore, 'setConversationId').mockImplementation(() => undefined); + + eveInstrumentation({ getConversationId: context => `conv-${context.session.id}` }).events['turn.started']({ + sessionId: 'xyz', + }); + + expect(setConversationId).toHaveBeenCalledWith('conv-xyz'); + }); +}); diff --git a/packages/server-utils/src/eve.ts b/packages/server-utils/src/eve.ts index 8ab70981f41b..5c35d858542d 100644 --- a/packages/server-utils/src/eve.ts +++ b/packages/server-utils/src/eve.ts @@ -1,4 +1,5 @@ -import { setConversationId } from '@sentry/core'; +import { defineIntegration, setConversationId } from '@sentry/core'; +import { markEveGenAiRecordingDefault } from './integrations/vercel-ai/gen-ai-recording-mode'; /** * The subset of eve's hook context (`HookContext` from `eve/hooks`) this helper reads. Typed @@ -60,3 +61,15 @@ export function eveConversationHook(options: EveConversationHookOptions = {}): { }, }; } + +/** + * Integration that handles tracing instrumentation of [eve](https://eve.dev) apps. + */ +export const eveIntegration = defineIntegration(() => { + return { + name: 'Eve' as const, + setup(client) { + markEveGenAiRecordingDefault(client); + }, + }; +}); diff --git a/packages/server-utils/src/index.ts b/packages/server-utils/src/index.ts index 65620fd403cb..54e82b0dae6f 100644 --- a/packages/server-utils/src/index.ts +++ b/packages/server-utils/src/index.ts @@ -13,7 +13,7 @@ export type { InstrumentationConfig } from './orchestrion/apmTypes'; // `orchestrion/bundler/moduleInjectedTransform.ts`); it is a plain runtime // helper with no orchestrion build-time dependency. export { orchestrionModuleInjected } from './utils/moduleInjected'; -export { eveConversationHook } from './eve'; +export { eveConversationHook, eveIntegration } from './eve'; export { getInstrumentedModuleNames } from './orchestrion/config'; export { fastifyIntegration, diff --git a/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts b/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts new file mode 100644 index 000000000000..719b6830dac7 --- /dev/null +++ b/packages/server-utils/src/integrations/vercel-ai/gen-ai-recording-mode.ts @@ -0,0 +1,22 @@ +import type { Client } from '@sentry/core'; + +const eveRecordingClients = new WeakSet(); + +/** + * Mark this client as running under eve, so the Vercel AI channel subscriber records gen_ai + * inputs/outputs by default. + * + * eve stamps every AI SDK call with `recordInputs`/`recordOutputs: false` as its framework default + * (its content-capture default is "public conversations only"). That per-call flag on the + * `ai:telemetry` channel event otherwise outranks the global `dataCollection.genAI` setting, so + * message content would be dropped even when the user wants it. In this mode the per-call flag is + * treated as eve's default rather than an end-user decision: an explicit `recordInputs`/`recordOutputs` + * on the integration or a `dataCollection.genAI` setting still takes precedence. + */ +export function markEveGenAiRecordingDefault(client: Client): void { + eveRecordingClients.add(client); +} + +export function isEveGenAiRecordingDefault(client: Client): boolean { + return eveRecordingClients.has(client); +} diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 924cde857b6b..76b1ddda69fc 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -49,6 +49,7 @@ import { } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../../ai/core/gen-ai-attributes'; +import { isEveGenAiRecordingDefault } from './gen-ai-recording-mode'; import type { GenAiOptions } from '../../ai/core/utils'; import { getProviderMetadataAttributes, LAST_STEP_ONLY_USAGE_KEYS } from '../../ai/vercel-ai'; import { WORKERS_AI_INTEGRATION_NAME } from '../../ai/workers-ai/constants'; @@ -750,11 +751,13 @@ function getRecordingOptions( recordInputs: boolean; recordOutputs: boolean; } { - const genAI = getClient()?.getDataCollectionOptions().genAI; + const client = getClient(); + const genAI = client?.getDataCollectionOptions().genAI; + const eveMode = client ? isEveGenAiRecordingDefault(client) : false; return { - recordInputs: resolveRecording(channelOptions.recordInputs, event.recordInputs, genAI?.inputs), - recordOutputs: resolveRecording(channelOptions.recordOutputs, event.recordOutputs, genAI?.outputs), + recordInputs: resolveRecording(channelOptions.recordInputs, event.recordInputs, genAI?.inputs, eveMode), + recordOutputs: resolveRecording(channelOptions.recordOutputs, event.recordOutputs, genAI?.outputs, eveMode), }; } @@ -767,11 +770,23 @@ function getRecordingOptions( * `experimental_telemetry: { isEnabled: true }`. The `ai:telemetry` channel does not expose `isEnabled` * (nor a resolved recording flag), so that per-call default cannot be reproduced here — v7 users who * want inputs/outputs recorded must enable `dataCollection.genAI` or set `recordInputs`/`recordOutputs`. + * + * Under `eveMode` (set by `eveIntegration()`) the per-call flag is eve's blanket framework default + * rather than an end-user decision, so it is skipped: an explicit `dataCollection.genAI` still wins, + * otherwise recording defaults to `true`. An integration-level option outranks both regardless. */ -function resolveRecording(integrationOption: unknown, perCallOption: unknown, globalDefault: unknown): boolean { +function resolveRecording( + integrationOption: unknown, + perCallOption: unknown, + globalDefault: unknown, + eveMode = false, +): boolean { if (typeof integrationOption === 'boolean') { return integrationOption; } + if (eveMode) { + return typeof globalDefault === 'boolean' ? globalDefault : true; + } if (typeof perCallOption === 'boolean') { return perCallOption; } diff --git a/packages/server-utils/test/eve.test.ts b/packages/server-utils/test/eve.test.ts index 2212f7b8e387..1e8b8dfa4bc2 100644 --- a/packages/server-utils/test/eve.test.ts +++ b/packages/server-utils/test/eve.test.ts @@ -1,6 +1,7 @@ import * as SentryCore from '@sentry/core'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { eveConversationHook } from '../src/eve'; +import { eveConversationHook, eveIntegration } from '../src/eve'; +import { isEveGenAiRecordingDefault } from '../src/integrations/vercel-ai/gen-ai-recording-mode'; describe('eveConversationHook', () => { afterEach(() => { @@ -53,3 +54,19 @@ describe('eveConversationHook', () => { expect(setConversationId).toHaveBeenCalledWith(returnValue); }); }); + +describe('eveIntegration', () => { + test('is named Eve', () => { + expect(eveIntegration().name).toBe('Eve'); + }); + + test('marks the client so the Vercel AI subscriber records gen_ai content by default', () => { + const client = {} as SentryCore.Client; + + expect(isEveGenAiRecordingDefault(client)).toBe(false); + + eveIntegration().setup?.(client); + + expect(isEveGenAiRecordingDefault(client)).toBe(true); + }); +}); diff --git a/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts b/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts new file mode 100644 index 000000000000..4c40743e4aea --- /dev/null +++ b/packages/server-utils/test/integrations/vercel-ai/eve-recording.test.ts @@ -0,0 +1,64 @@ +import { GEN_AI_INPUT_MESSAGES } from '@sentry/conventions/attributes'; +import { getClient, getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { markEveGenAiRecordingDefault } from '../../../src/integrations/vercel-ai/gen-ai-recording-mode'; +import { createSpanFromMessage } from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +// eve stamps every AI SDK call with `recordInputs: false`, which the channel subscriber otherwise +// honors over the global default. `eveIntegration()` (via `markEveGenAiRecordingDefault`) flips that so +// content is recorded by default under eve, while explicit settings still win. +describe('Vercel AI recording under eve', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(dataCollection: { genAI?: { inputs?: boolean } } = {}): void { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + dataCollection, + }), + ); + setCurrentClient(client); + client.init(); + } + + // A `generateText` operation carrying input messages and eve's per-call `recordInputs: false`. + function recordedInputMessages(): unknown { + const message = { + type: 'generateText', + event: { + messages: [{ role: 'user', content: 'What is the weather in Paris?' }], + recordInputs: false, + }, + } as Parameters[0]; + const span = createSpanFromMessage(message, {}); + return (spanToStaticSpanJSON(span!).data ?? {})[GEN_AI_INPUT_MESSAGES]; + } + + it('honors the per-call recordInputs:false without eve mode', () => { + setupClient(); + + expect(recordedInputMessages()).toBeUndefined(); + }); + + it('records inputs by default under eve despite the per-call recordInputs:false', () => { + setupClient(); + markEveGenAiRecordingDefault(getClient()!); + + expect(recordedInputMessages()).toContain('What is the weather in Paris?'); + }); + + it('still honors an explicit dataCollection.genAI opt-out under eve', () => { + setupClient({ genAI: { inputs: false } }); + markEveGenAiRecordingDefault(getClient()!); + + expect(recordedInputMessages()).toBeUndefined(); + }); +}); From 98567129ae225c19d2bb675bc1b8beb3fab91177 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:58:35 +0200 Subject: [PATCH 38/39] meta(changelog): Update changelog for 11.0.0-rc.1 --- CHANGELOG.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c016edff21f..d4975fdd4342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,70 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 11.0.0-rc.1 + +### Important Changes + +- **feat(browser)!: Report `browser.navigation.type` exactly as web-vitals does ([#24479](https://github.com/getsentry/sentry-javascript/pull/24479))** + +The `browser.navigation.type` attribute now reports the same values as the `web-vitals` library. + +- **feat(core): consolidate fetch integrations ([#24346](https://github.com/getsentry/sentry-javascript/pull/24346))** + +The global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge` now share one implementation. All four gain a `tracePropagation` option (default `true`) to stop injecting `sentry-trace` and `baggage` without also turning off spans. In `@sentry/deno`, fetch breadcrumbs are now recorded by `fetchIntegration` instead of `breadcrumbsIntegration`, matching the other runtime SDKs. + +- **feat(node): Add Groq and Together AI integrations ([#24454](https://github.com/getsentry/sentry-javascript/pull/24454))** + +Adds instrumentation for the Groq and Together AI SDKs, enabled by default. + +- **ref(bundler-plugins)!: Require webpack 5.1 and remove the `webpack5` entry point ([#24455](https://github.com/getsentry/sentry-javascript/pull/24455))** + +The Sentry webpack plugin now requires webpack `>=5.1` and is only exported from the package root. + +### Other Changes + +- feat(cloudflare): Auto-register Flue instrumentation in bundled workers ([#24476](https://github.com/getsentry/sentry-javascript/pull/24476)) +- feat(deps): bump devalue from 5.9.0 to 5.9.2 ([#24486](https://github.com/getsentry/sentry-javascript/pull/24486)) +- feat(node): Add eveInstrumentation and eveIntegration for eve apps ([#24539](https://github.com/getsentry/sentry-javascript/pull/24539)) +- feat(remix): Upload source maps from the Vite plugin ([#24463](https://github.com/getsentry/sentry-javascript/pull/24463)) +- fix: Remove by accident exported convention ([#24542](https://github.com/getsentry/sentry-javascript/pull/24542)) +- fix(bun): Respect caller-supplied `runtime` option ([#24487](https://github.com/getsentry/sentry-javascript/pull/24487)) +- fix(cloudflare): Capture telemetry from untraced Durable Object RPC calls ([#24447](https://github.com/getsentry/sentry-javascript/pull/24447)) +- fix(cloudflare): Instrument namespaces returned by jurisdiction() ([#24450](https://github.com/getsentry/sentry-javascript/pull/24450)) +- fix(core): Apply the sensitive denylist to cookie headers and configured fetch headers ([#24090](https://github.com/getsentry/sentry-javascript/pull/24090)) +- fix(core): correct MCP error and request attribution ([#24493](https://github.com/getsentry/sentry-javascript/pull/24493)) +- fix(core): instrument MCP transports before start ([#23978](https://github.com/getsentry/sentry-javascript/pull/23978)) +- fix(core): Stop sending client reports for breadcrumbs ([#24537](https://github.com/getsentry/sentry-javascript/pull/24537)) +- fix(ember): Add route IDs to pageload and navigation spans ([#24372](https://github.com/getsentry/sentry-javascript/pull/24372)) +- fix(hono): Allow @cloudflare/workers-types v5 as peer dependency ([#24494](https://github.com/getsentry/sentry-javascript/pull/24494)) +- fix(nextjs): Align tunnel request matching in middleware with tunnel rewrite ([#24499](https://github.com/getsentry/sentry-javascript/pull/24499)) +- fix(nextjs): Prevent sourceMappingURL stripping from truncating minified chunks ([#24022](https://github.com/getsentry/sentry-javascript/pull/24022)) +- fix(nextjs): Resolve Next.js version relative to the SDK when cwd differs ([#24462](https://github.com/getsentry/sentry-javascript/pull/24462)) +- fix(nuxt): Stop importing h3 in the Nitro error hook ([#24283](https://github.com/getsentry/sentry-javascript/pull/24283)) +- fix(server-runtime-injection): Keep `require()` of JSON working on Deno ([#24412](https://github.com/getsentry/sentry-javascript/pull/24412)) +- perf(bundler-plugins): Use fast component annotation for all Rollup and Vite versions ([#24437](https://github.com/getsentry/sentry-javascript/pull/24437)) + +
+ Internal Changes + +- chore(bun): Fix stale comment in `getPerformanceIntegrations` ([#24533](https://github.com/getsentry/sentry-javascript/pull/24533)) +- chore(bundler-plugins): move traces sample rate from 1.0 to 0.3 ([#24488](https://github.com/getsentry/sentry-javascript/pull/24488)) +- chore(test): De-flake slow and racy tests ([#24452](https://github.com/getsentry/sentry-javascript/pull/24452)) +- docs: Update MIGRATION.md ([#24550](https://github.com/getsentry/sentry-javascript/pull/24550)) +- test(cloudflare): Add E2E test that deploys a real Worker and sends to Sentry ([#24280](https://github.com/getsentry/sentry-javascript/pull/24280)) +- test(e2e): Add a node-flue end-to-end application ([#24377](https://github.com/getsentry/sentry-javascript/pull/24377)) +- test(e2e): Add gen-ai-libraries app covering AI libraries via OpenRouter ([#24456](https://github.com/getsentry/sentry-javascript/pull/24456)) +- test(e2e): Avoid SAM runtime port collisions in aws-serverless tests ([#24492](https://github.com/getsentry/sentry-javascript/pull/24492)) +- test(e2e): consolidate the deno e2e test applications ([#24414](https://github.com/getsentry/sentry-javascript/pull/24414)) +- test(node): Run koa integration tests against koa 3 ([#24457](https://github.com/getsentry/sentry-javascript/pull/24457)) +- test(remix): Add source map upload e2e test app ([#24464](https://github.com/getsentry/sentry-javascript/pull/24464)) +- test(server-utils): Cover the Flue instrumentation ([#24266](https://github.com/getsentry/sentry-javascript/pull/24266)) +- test(test-utils): Add Sentry CLI trace helpers as `test-utils/cli` ([#24279](https://github.com/getsentry/sentry-javascript/pull/24279)) + +
+ +Work in this release was contributed by @chiliec, @ihsraham, @msnelling, and @oesnuj. Thank you for your contributions! + ## 11.0.0-rc.0 This is the first release candidate of version 11 of the Sentry JavaScript SDKs. From f2dfbe17792be4572136bd07917a6ffd1ee9c2ee Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:32:49 +0200 Subject: [PATCH 39/39] ref: Update yarn.lock --- yarn.lock | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/yarn.lock b/yarn.lock index f8a780a37590..e74384208891 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8061,11 +8061,96 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" +"@sentry/bundler-plugins@10.67.0": + version "10.67.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/bundler-plugins/-/bundler-plugins-10.67.0.tgz#0b43b5a19a372a80a9f25464484937b3417aa43e" + integrity sha512-HKLhbMZJsabZlXTog8CTa1ReeDW/mf1cwN7O8K+DKhe/kGHB3whHRseqsyjxyJwPdzC/0lM+8rgfqgxpc7jR9A== + dependencies: + "@babel/core" "^7.18.5" + "@sentry/cli" "^2.58.6" + "@sentry/core" "10.67.0" + dotenv "^17.4.2" + find-up "^5.0.0" + glob "^13.0.6" + magic-string "~0.30.8" + +"@sentry/cli-darwin@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz#38fd82751014b287e58e99ef948d01ca1e09f41d" + integrity sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA== + +"@sentry/cli-linux-arm64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz#6e660e457af7928c1be8191c77646801fe3fa6a0" + integrity sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g== + +"@sentry/cli-linux-arm@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz#41256912d636193d2a67985b6c9b3efbbe6a47c9" + integrity sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw== + +"@sentry/cli-linux-i686@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz#278e7696d82e51dfbfd7d82ec125dda65d249a43" + integrity sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg== + +"@sentry/cli-linux-x64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz#57860d46ac3397c33bbcc6224ac19b7de2502c18" + integrity sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q== + +"@sentry/cli-win32-arm64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz#9335a5d2411381dca1d6b11fdd71a4342b375fc3" + integrity sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A== + +"@sentry/cli-win32-i686@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz#2afd19536ef111af43538ccc5f9c8c0b179d930e" + integrity sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg== + +"@sentry/cli-win32-x64@2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz#8d0e70b5660cc82a7763a4bbe9346cf18e49e07e" + integrity sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA== + +"@sentry/cli@^2.58.6": + version "2.58.6" + resolved "https://sfw.security.sentry.io/npm/@sentry/cli/-/cli-2.58.6.tgz#72edb4977d822757511b279e006b00f139e24945" + integrity sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg== + dependencies: + https-proxy-agent "^5.0.0" + node-fetch "^2.6.7" + progress "^2.0.3" + proxy-from-env "^1.1.0" + which "^2.0.2" + optionalDependencies: + "@sentry/cli-darwin" "2.58.6" + "@sentry/cli-linux-arm" "2.58.6" + "@sentry/cli-linux-arm64" "2.58.6" + "@sentry/cli-linux-i686" "2.58.6" + "@sentry/cli-linux-x64" "2.58.6" + "@sentry/cli-win32-arm64" "2.58.6" + "@sentry/cli-win32-i686" "2.58.6" + "@sentry/cli-win32-x64" "2.58.6" + "@sentry/conventions@0.23.0", "@sentry/conventions@^0.23.0": version "0.23.0" resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.23.0.tgz#26ecad45f91bcf70940325ab9af2b9544fa18242" integrity sha512-+Euyo7CNVecoOusgUnq27oV97oshifgceKfnGnuKfXrPUVWA/cisvii3E1rlvy2yBrKvl9K1gCEo1Jqog5zjEQ== +"@sentry/conventions@^0.16.0": + version "0.16.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009" + integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ== + +"@sentry/core@10.67.0": + version "10.67.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/core/-/core-10.67.0.tgz#38afb5484d2581d40b212303e8a1abba2daea7a2" + integrity sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w== + dependencies: + "@sentry/conventions" "^0.16.0" + "@sentry/node-cpu-profiler@^2.4.4": version "2.4.4" resolved "https://registry.yarnpkg.com/@sentry/node-cpu-profiler/-/node-cpu-profiler-2.4.4.tgz#b275d249bd6e0c98c1e4f2ebc87acf702e6ae04d" @@ -23258,6 +23343,11 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= +progress@^2.0.3: + version "2.0.3" + resolved "https://sfw.security.sentry.io/npm/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -23332,6 +23422,11 @@ proxy-from-env@2.1.0, proxy-from-env@^2.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://sfw.security.sentry.io/npm/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== + proxy@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/proxy/-/proxy-2.1.1.tgz#45f9b307508ffcae12bdc71678d44a4ab79cbf8b"