From c01ade018a70ed250c8f8e8bd4b72c904695637f Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 1 Oct 2026 12:45:23 +0200 Subject: [PATCH 01/57] test(cloudflare): Match the expected error in the WebSocket e2e tests (#24923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebSocket e2e tests in the Cloudflare test apps waited for any error event. Playwright runs these files with parallel workers, so the waiter could pick up the error from the RPC or Durable Object fetch test instead. The test then failed on the message, and `socket.close()` threw because the socket hadn't opened yet. Each WebSocket test now waits for its own error message. Same fix applied to `cloudflare-workers`, `cloudflare-workers-static` and `cloudflare-workersentrypoint`, which share this test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../cloudflare-workers-static/tests/index.test.ts | 4 ++-- .../test-applications/cloudflare-workers/tests/index.test.ts | 4 ++-- .../cloudflare-workersentrypoint/tests/index.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts index 8d53746ee43d..81c4f72bfdda 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-static/tests/index.test.ts @@ -41,7 +41,7 @@ test("Request processed by DurableObject's fetch is recorded", async ({ baseURL test('Websocket.webSocketMessage', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers-static', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketMessage'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); @@ -57,7 +57,7 @@ test('Websocket.webSocketMessage', async ({ baseURL }) => { test('Websocket.webSocketClose', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers-static', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketClose'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts index 3a5e72530801..ca79ad3c71ec 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers/tests/index.test.ts @@ -63,7 +63,7 @@ test("Request processed by DurableObject's fetch is recorded", async ({ baseURL test('Websocket.webSocketMessage', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketMessage'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); @@ -79,7 +79,7 @@ test('Websocket.webSocketMessage', async ({ baseURL }) => { test('Websocket.webSocketClose', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workers', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketClose'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts index b815e7b7e3c7..b24d5f804ddd 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workersentrypoint/tests/index.test.ts @@ -63,7 +63,7 @@ test("Request processed by DurableObject's fetch is recorded", async ({ baseURL test('Websocket.webSocketMessage', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workersentrypoint', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketMessage'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); @@ -79,7 +79,7 @@ test('Websocket.webSocketMessage', async ({ baseURL }) => { test('Websocket.webSocketClose', async ({ baseURL }) => { const eventWaiter = waitForError('cloudflare-workersentrypoint', event => { - return !!event.exception?.values?.[0]; + return event.exception?.values?.[0]?.value === 'Should be recorded in Sentry: webSocketClose'; }); const url = new URL('/pass-to-object/ws', baseURL); url.protocol = url.protocol.replace('http', 'ws'); From ce2631a0e61fb9b8583a3fc3724f4d317c08e1ec Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 1 Oct 2026 13:22:40 +0200 Subject: [PATCH 02/57] fix(tanstackstart-react): Avoid duplicating Vite config arrays (#24929) Returning the incoming config from the source-map hook causes Vite to concatenate existing arrays with themselves. With Nitro, duplicated asset processing can embed module source instead of the original file content. Return only the source-map setting, following the existing SolidStart implementation. Fixes #23753 --------- Co-authored-by: GPT-6 --- .../src/vite/sourceMaps.ts | 2 -- .../test/vite/sourceMaps.test.ts | 30 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 742f4227758b..817cad432a22 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -101,9 +101,7 @@ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptio enforce: 'post', config(viteConfig) { return { - ...viteConfig, build: { - ...viteConfig.build, sourcemap: getUpdatedSourceMapSettings(viteConfig, options), }, }; diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index abf8f24301da..e5ea14c95b3e 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -1,5 +1,7 @@ +// @vitest-environment node import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; import type { UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getUpdatedSourceMapSettings, @@ -34,6 +36,34 @@ describe('makeEnableSourceMapsVitePlugin()', () => { expect(sourceMapsConfigPlugins).toHaveLength(1); }); + + it.each([undefined, false, true, 'hidden', 'inline'] as const)( + 'preserves user config without duplicating arrays when sourcemap is %s', + sourcemap => { + const plugin = makeEnableSourceMapsVitePlugin({})[0]!; + const configHook = plugin.config as (config: UserConfig) => UserConfig; + const userConfig: UserConfig = { + optimizeDeps: { include: ['react'] }, + build: { + sourcemap, + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }; + + const merged = mergeConfig(userConfig, configHook(userConfig)); + + expect(merged).toEqual({ + optimizeDeps: { include: ['react'] }, + build: { + sourcemap: sourcemap ?? 'hidden', + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + rolldownOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }); + }, + ); }); describe('makeAddSentryVitePlugin()', () => { From 06dd51e4b49bf7d1ae521acad2a4c1e153d2197a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 1 Oct 2026 13:22:55 +0200 Subject: [PATCH 03/57] fix(react-router): Avoid duplicating Vite config arrays (#24930) Apply the same config-hook fix as #24929 to React Router. Returning the incoming config causes Vite to concatenate existing arrays with themselves, potentially duplicating build plugins. Return only the source-map setting instead. --------- Co-authored-by: GPT-6 --- .../src/vite/makeEnableSourceMapsPlugin.ts | 2 -- .../react-router/test/vite/sourceMaps.test.ts | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts b/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts index aaae86c09b76..719acb397035 100644 --- a/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts +++ b/packages/react-router/src/vite/makeEnableSourceMapsPlugin.ts @@ -11,9 +11,7 @@ export function makeEnableSourceMapsPlugin(options: SentryReactRouterBuildOption enforce: 'post', config(viteConfig) { return { - ...viteConfig, build: { - ...viteConfig.build, sourcemap: getUpdatedSourceMapSettings(viteConfig, options), }, }; diff --git a/packages/react-router/test/vite/sourceMaps.test.ts b/packages/react-router/test/vite/sourceMaps.test.ts index ae73ef43f5c4..aa23f9c8bed0 100644 --- a/packages/react-router/test/vite/sourceMaps.test.ts +++ b/packages/react-router/test/vite/sourceMaps.test.ts @@ -1,4 +1,7 @@ +// @vitest-environment node import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; +import type { UserConfig } from 'vite'; +import { mergeConfig } from 'vite'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getUpdatedSourceMapSettings, makeEnableSourceMapsPlugin } from '../../src/vite/makeEnableSourceMapsPlugin'; @@ -31,6 +34,34 @@ describe('makeEnableSourceMapsPlugin()', () => { expect(enableSourceMapPlugin?.enforce).toEqual('post'); expect(enableSourceMapPlugin?.config).toEqual(expect.any(Function)); }); + + it.each([undefined, false, true, 'hidden', 'inline'] as const)( + 'preserves user config without duplicating arrays when sourcemap is %s', + sourcemap => { + const plugin = makeEnableSourceMapsPlugin({}); + const configHook = plugin.config as (config: UserConfig) => UserConfig; + const userConfig: UserConfig = { + optimizeDeps: { include: ['react'] }, + build: { + sourcemap, + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }; + + const merged = mergeConfig(userConfig, configHook(userConfig)); + + expect(merged).toEqual({ + optimizeDeps: { include: ['react'] }, + build: { + sourcemap: sourcemap ?? 'hidden', + outDir: 'custom-dist', + rollupOptions: { plugins: [{ name: 'asset-transform' }] }, + rolldownOptions: { plugins: [{ name: 'asset-transform' }] }, + }, + }); + }, + ); }); describe('getUpdatedSourceMapSettings', () => { From 116ee682b8404a22989bc049acea110208d2c1b9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 1 Oct 2026 13:58:45 +0200 Subject: [PATCH 04/57] feat(core): Add route provider API for parameterized route resolution (#23551) Adds the route provider API to core so framework SDKs can register how to resolve a URL to a parameterized route, and wires up `bfcacheMetricsIntegration` as the first consumer since that is where the fragmentation currently produces a raw URL on a metric dimension. `bfcacheMetrics` keeps its pathname fallback, so nothing changes until a provider is registered. closes #23556 --- .../browser-bfcache/tests/bfcache.test.ts | 27 ++ packages/browser-utils/src/index.ts | 10 + packages/browser-utils/src/routing.ts | 168 ++++++++++++ packages/browser-utils/test/routing.test.ts | 255 ++++++++++++++++++ packages/browser/src/client.ts | 14 + packages/browser/src/index.ts | 9 + .../src/integrations/bfcacheMetrics.ts | 19 +- packages/browser/test/client.test.ts | 25 +- .../test/integrations/bfcacheMetrics.test.ts | 51 +++- 9 files changed, 568 insertions(+), 10 deletions(-) create mode 100644 packages/browser-utils/src/routing.ts create mode 100644 packages/browser-utils/test/routing.test.ts 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 31e6c83eabbe..2a157ff659c6 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 @@ -277,6 +277,33 @@ test('a hit carries the parameterized route that was on the scope before the fre expect(attr(hit, 'sentry.segment.name')).toBe('/users/:id'); }); +// A registered route provider is preferred over the scope, which is what lets a framework SDK name the +// segment even when nothing stamped a route on the scope. The scope holds a different name here to prove it. +test('a hit carries the route resolved by a registered route provider', async ({ page }) => { + const hitPromise = waitForMetric(PROXY_SERVER_NAME, metric => isNavigation(metric, 'hit')); + + await page.goto('/'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 1'); + + await page.evaluate(() => { + const { Sentry } = window as unknown as { Sentry: typeof import('@sentry/browser') }; + Sentry.getCurrentScope().setTransactionName('/from-scope'); + Sentry.setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }); + }); + + await page.click('#to-page-2'); + await page.waitForFunction(() => document.title === 'BFCache E2E - Page 2'); + await page.waitForTimeout(500); + + await page.evaluate(() => history.back()); + await page.waitForFunction(() => (window as unknown as { __bfcacheRestored?: boolean }).__bfcacheRestored === true, { + timeout: 5000, + }); + + const hit = await hitPromise; + expect(attr(hit, 'sentry.segment.name')).toBe('/users/:id'); +}); + // Without a routing integration the scope has no transaction name, so the segment name falls back to // `location.pathname` (page 1 is served at '/'). This matches how browserTracing names an unrouted pageload. test('a hit falls back to the raw pathname when no route is on the scope', async ({ page }) => { diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index 7c3b08c40e3b..52da63c3d6b6 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -29,6 +29,16 @@ export { isBotUserAgent } from './isBotUserAgent'; export { getLocationHref } from './getLocationHref'; +export { + createCachedRouteProvider, + createUrlRouteProvider, + getRouteProvider, + resolveCurrentRoute, + resolveRoute, + setRouteProvider, +} from './routing'; +export type { CachedRouteProvider, RouteProvider } from './routing'; + export { userTimingIntegration } from './performance/userTiming'; export { extractNetworkProtocol } from './performance/utils'; diff --git a/packages/browser-utils/src/routing.ts b/packages/browser-utils/src/routing.ts new file mode 100644 index 000000000000..9f58f9b30e43 --- /dev/null +++ b/packages/browser-utils/src/routing.ts @@ -0,0 +1,168 @@ +import type { Client } from '@sentry/core'; +import { debug, getClient, LRUMap, parseStringToURLObject, safeCallback } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; +import { getLocationHref } from './getLocationHref'; + +/** The parts of a URL a route depends on. A `URL` satisfies it. */ +type RouteUrl = Pick; + +/** + * Resolves URLs to low-cardinality route names. + * + * Framework SDKs register one so that everything the SDK names after a route (span names, the scope's + * transaction name, metric and span segment attributes) gets the parameterized route instead of the raw + * URL, without each integration having to reach into the framework's router itself. + */ +export interface RouteProvider { + /** + * Resolves a URL path template for a specific URL, e.g. `/users/42` -> `/users/:id`. + * + * Must return a path template, never a route identifier. Routers that name routes independently of + * their path (Vue Router's `route.name`, Ember's `posts.show`) have to return the matched path + * instead: callers set `url.template` from this, and an identifier is not a template. An SDK that + * wants to name its span after the identifier still can, on the span itself. + * + * Returns `undefined` when the URL matches no known route. Must answer for the URL it is given rather + * than for wherever the router currently is, so that callers can resolve a URL they captured earlier + * (a web vital reported after a soft navigation, for example). + */ + resolveRoute(url: RouteUrl): string | undefined; + + /** + * Resolves the route the app is currently on. + * + * Routers whose location lives in the address bar can delegate to `resolveRoute`, which is what + * {@link createUrlRouteProvider} does. Routers that keep their own location (memory and hash routers) + * have to answer from that location instead: for those, `location.href` is the unchanging shell URL + * and would bucket every route together. + */ + resolveCurrentRoute(): string | undefined; +} + +const CLIENT_ROUTE_PROVIDERS = new WeakMap(); + +/** + * Registers the route provider for a client, replacing any previously registered one. `BrowserClient` + * calls this with its `routeProvider` option. + * + * Prefer the `routeProvider` option where the provider is known at `init`: the pageload span is named + * while `browserTracingIntegration` sets up, so a provider registered after `init` can only rename it + * after the fact. + * + * A client holds one provider. An app running two routers (a framework migration, or a shell plus an + * island) registers twice and the last one wins, so the first router's routes stop resolving. + */ +export function setRouteProvider(provider: RouteProvider, client: Client | undefined = getClient()): void { + if (!client) { + DEBUG_BUILD && debug.warn('Cannot set a route provider without a client.'); + return; + } + + if (DEBUG_BUILD && getRouteProvider(client)) { + debug.warn( + 'A route provider is already registered for this client and will be replaced. Routes only the previous provider knows about will no longer resolve.', + ); + } + + CLIENT_ROUTE_PROVIDERS.set(client, provider); +} + +/** + * Returns the route provider registered for a client. + */ +export function getRouteProvider(client: Client | undefined = getClient()): RouteProvider | undefined { + return client && CLIENT_ROUTE_PROVIDERS.get(client); +} + +/** + * Resolves a URL to a low-cardinality route name, e.g. `/users/42` -> `/users/:id`. + * + * Returns `undefined` when no route provider is registered or the URL matches no route. Callers pick + * their own fallback, because the right one differs: a span name falls back to a low-cardinality + * constant, the scope's transaction name to the raw path. + */ +export function resolveRoute(url: string | URL, client: Client | undefined = getClient()): string | undefined { + const provider = getRouteProvider(client); + if (!provider) { + return undefined; + } + + const urlObject = typeof url === 'string' ? parseLocation(url) : url; + if (!urlObject) { + return undefined; + } + + return safeCallback( + DEBUG_BUILD ? 'Route provider threw while resolving a route:' : '', + () => provider.resolveRoute(urlObject) || undefined, + () => undefined, + ); +} + +/** + * Resolves the route the app is currently on. + * + * Returns `undefined` when no route provider is registered or the current location matches no route. + */ +export function resolveCurrentRoute(client: Client | undefined = getClient()): string | undefined { + const provider = getRouteProvider(client); + + return safeCallback( + DEBUG_BUILD ? 'Route provider threw while resolving a route:' : '', + () => provider?.resolveCurrentRoute() || undefined, + () => undefined, + ); +} + +/** + * Builds a {@link RouteProvider} for a router whose location is the browser's, which covers every + * router except memory and hash routers. + */ +export function createUrlRouteProvider(resolveRouteFromUrl: (url: RouteUrl) => string | undefined): RouteProvider { + return { + resolveRoute: resolveRouteFromUrl, + resolveCurrentRoute: () => { + const urlObject = parseLocation(getLocationHref()); + + return urlObject && resolveRouteFromUrl(urlObject); + }, + }; +} + +/** + * A {@link RouteProvider} that answers from routes it has been told about, rather than by matching. + */ +export interface CachedRouteProvider extends RouteProvider { + /** Records the route name a router reported for a path. Ignores empty values. */ + record(pathname: string | undefined, routeName: string | null | undefined): void; +} + +/** + * Builds a route provider for a router with no usable matcher, which can only report the route it is + * on as it gets there (SvelteKit's `page.route.id`, Solid Router's current matches). + * + * A URL the app has not visited resolves to `undefined`, which includes the first pageload until the + * router reports. Backed by an LRU so a long-lived app visiting many URLs can't grow it without end, + * and so routes that keep being resolved outlive ones passed through once. + */ +export function createCachedRouteProvider(maxEntries: number = 50): CachedRouteProvider { + const routeNames = new LRUMap(maxEntries); + + return { + ...createUrlRouteProvider(url => routeNames.get(url.pathname)), + record(pathname, routeName) { + if (pathname && routeName) { + routeNames.set(pathname, routeName); + } + }, + }; +} + +/** + * Parses up front so providers never have to, and resolves relative locations (which memory routers + * hand around) against the document. + */ +function parseLocation(url: string): RouteUrl | undefined { + // Without a document `getLocationHref()` is empty, which would otherwise parse as `/`. + return url ? parseStringToURLObject(url, getLocationHref() || undefined) : undefined; +} diff --git a/packages/browser-utils/test/routing.test.ts b/packages/browser-utils/test/routing.test.ts new file mode 100644 index 000000000000..7c7b749678a9 --- /dev/null +++ b/packages/browser-utils/test/routing.test.ts @@ -0,0 +1,255 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + createCachedRouteProvider, + createUrlRouteProvider, + resolveCurrentRoute, + getRouteProvider, + resolveRoute, + setRouteProvider, +} from '../src/routing'; +import type { RouteProvider } from '../src/routing'; +import { debug, getCurrentScope, GLOBAL_OBJ, setCurrentClient } from '@sentry/core'; +import { getDefaultClientOptions, TestClient } from './utils/TestClient'; + +function setLocationHref(href: string): void { + (GLOBAL_OBJ as { document?: unknown }).document = { location: { href } }; +} + +function makeClient(): TestClient { + const client = new TestClient(getDefaultClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337' })); + setCurrentClient(client); + client.init(); + + return client; +} + +describe('routing', () => { + let client: TestClient; + + beforeEach(() => { + client = makeClient(); + setLocationHref('https://example.com/users/42?q=1#frag'); + }); + + afterEach(() => { + delete (GLOBAL_OBJ as { document?: unknown }).document; + vi.restoreAllMocks(); + }); + + describe('without a registered provider', () => { + it('returns undefined rather than falling back to the raw path', () => { + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + expect(getRouteProvider()).toBeUndefined(); + }); + }); + + describe('resolveRoute', () => { + it('hands the provider a parsed URL so it never has to parse itself', () => { + const resolveSpy = vi.fn().mockReturnValue('/users/:id'); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + expect(resolveRoute('https://example.com/users/42?q=1')).toBe('/users/:id'); + expect(resolveSpy).toHaveBeenCalledWith(new URL('https://example.com/users/42?q=1')); + }); + + it('accepts a URL object as-is', () => { + const url = new URL('https://example.com/users/42'); + const resolveSpy = vi.fn().mockReturnValue('/users/:id'); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + expect(resolveRoute(url)).toBe('/users/:id'); + expect(resolveSpy).toHaveBeenCalledWith(url); + }); + + it('resolves a relative location against the document, which memory routers rely on', () => { + const resolveSpy = vi.fn().mockReturnValue('/users/:id'); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + resolveRoute('7'); + + expect(resolveSpy).toHaveBeenCalledWith(expect.objectContaining({ pathname: '/users/7', search: '', hash: '' })); + }); + + it('resolves a URL the router has already navigated away from', () => { + setRouteProvider({ + resolveRoute: url => (url.pathname.startsWith('/posts/') ? '/posts/:slug' : undefined), + resolveCurrentRoute: () => '/users/:id', + }); + + expect(resolveRoute('https://example.com/posts/hello')).toBe('/posts/:slug'); + expect(resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined for an unparseable URL without calling the provider', () => { + const resolveSpy = vi.fn(); + setRouteProvider({ resolveRoute: resolveSpy, resolveCurrentRoute: () => undefined }); + + expect(resolveRoute('http://')).toBeUndefined(); + expect(resolveSpy).not.toHaveBeenCalled(); + }); + + it('normalizes an empty route name to undefined', () => { + setRouteProvider({ resolveRoute: () => '', resolveCurrentRoute: () => '' }); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); + + describe('provider errors', () => { + it('swallows a throwing provider instead of taking down the caller', () => { + setRouteProvider({ + resolveRoute: () => { + throw new Error('router blew up'); + }, + resolveCurrentRoute: () => { + throw new Error('router blew up'); + }, + }); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); + + describe('setRouteProvider', () => { + it('scopes the provider to its client', () => { + const otherClient = new TestClient(getDefaultClientOptions()); + setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }, client); + + expect(resolveCurrentRoute(client)).toBe('/users/:id'); + expect(resolveCurrentRoute(otherClient)).toBeUndefined(); + }); + + it('replaces a previously registered provider and warns', () => { + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + setRouteProvider({ resolveRoute: () => '/first', resolveCurrentRoute: () => '/first' }); + setRouteProvider({ resolveRoute: () => '/second', resolveCurrentRoute: () => '/second' }); + + expect(resolveCurrentRoute()).toBe('/second'); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('does not warn when registering the first provider', () => { + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + setRouteProvider({ resolveRoute: () => '/first', resolveCurrentRoute: () => '/first' }); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warns per client rather than globally', () => { + const otherClient = makeClient(); + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + const provider = { resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }; + + setRouteProvider(provider, client); + setRouteProvider(provider, otherClient); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no current client', () => { + getCurrentScope().setClient(undefined); + const provider: RouteProvider = { resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }; + + expect(() => setRouteProvider(provider)).not.toThrow(); + expect(getRouteProvider()).toBeUndefined(); + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); + + describe('createCachedRouteProvider', () => { + it('resolves a path the router has reported', () => { + const provider = createCachedRouteProvider(); + provider.record('/users/42', '/users/:id'); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/users/42')).toBe('/users/:id'); + expect(resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined for a path the router has not reported yet', () => { + const provider = createCachedRouteProvider(); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + }); + + it('keeps other routes when re-recording one into a full cache', () => { + const provider = createCachedRouteProvider(2); + provider.record('/users/42', '/users/:id'); + provider.record('/posts/hello', '/posts/:slug'); + + provider.record('/posts/hello', '/posts/:slug'); + + expect(provider.resolveRoute(new URL('https://example.com/posts/hello'))).toBe('/posts/:slug'); + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('keeps resolving a URL the router has navigated away from', () => { + const provider = createCachedRouteProvider(); + provider.record('/posts/hello', '/posts/:slug'); + provider.record('/users/42', '/users/:id'); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/posts/hello')).toBe('/posts/:slug'); + }); + + it('ignores empty paths and route names', () => { + const provider = createCachedRouteProvider(); + provider.record(undefined, '/users/:id'); + provider.record('/users/42', null); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/users/42')).toBeUndefined(); + }); + + it('evicts the oldest entry once the cache is full', () => { + const provider = createCachedRouteProvider(2); + provider.record('/a', '/a'); + provider.record('/b', '/b'); + provider.record('/c', '/c'); + setRouteProvider(provider); + + expect(resolveRoute('https://example.com/a')).toBeUndefined(); + expect(resolveRoute('https://example.com/b')).toBe('/b'); + expect(resolveRoute('https://example.com/c')).toBe('/c'); + }); + + it('keeps a recently resolved path alive past newer entries', () => { + const provider = createCachedRouteProvider(2); + provider.record('/a', '/a'); + provider.record('/b', '/b'); + setRouteProvider(provider); + + // Resolving `/a` makes it the most recently used, so `/b` is evicted instead. + expect(resolveRoute('https://example.com/a')).toBe('/a'); + provider.record('/c', '/c'); + + expect(resolveRoute('https://example.com/a')).toBe('/a'); + expect(resolveRoute('https://example.com/b')).toBeUndefined(); + }); + }); + + describe('createUrlRouteProvider', () => { + it('derives the current route from the document location', () => { + setRouteProvider(createUrlRouteProvider(url => (url.pathname === '/users/42' ? '/users/:id' : undefined))); + + expect(resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined when the current location matches no route', () => { + setRouteProvider(createUrlRouteProvider(() => undefined)); + + expect(resolveCurrentRoute()).toBeUndefined(); + }); + + it('returns undefined when there is no document location to read', () => { + delete (GLOBAL_OBJ as { document?: unknown }).document; + setRouteProvider(createUrlRouteProvider(() => '/users/:id')); + + expect(resolveCurrentRoute()).toBeUndefined(); + }); + }); +}); diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index 0221ca2d30ce..e7777cc89d8a 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -8,6 +8,8 @@ import type { SeverityLevel, } from '@sentry/core'; import type { BrowserClientReplayOptions } from '@sentry/core/browser'; +import type { RouteProvider } from '@sentry/browser-utils'; +import { setRouteProvider } from '@sentry/browser-utils'; import { addAutoIpAddressToSession, applySdkMetadata, Client, getSDKSource } from '@sentry/core'; import { eventFromException, eventFromMessage } from './eventbuilder'; import { WINDOW } from './helpers'; @@ -76,6 +78,14 @@ type BrowserSpecificOptions = BrowserClientReplayOptions & * IMPORTANT: Only set this option to `true` while developing, not in production! */ spotlight?: boolean | string; + + /** + * Resolves URLs to low-cardinality route names, e.g. `/users/42` -> `/users/:id`. + * + * Framework SDKs set this for you. Set it yourself to name routes for a router the SDK has no + * integration for. `setRouteProvider` replaces it at runtime. + */ + routeProvider?: RouteProvider; }; /** * Configuration options for the Sentry Browser SDK. @@ -108,6 +118,10 @@ export class BrowserClient extends Client { super(opts); + if (opts.routeProvider) { + setRouteProvider(opts.routeProvider, this); + } + // Unhandled errors don't actually crash the browser, so we report `unhandled` rather than `crashed`. this._unhandledSessionStatus = 'unhandled'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index d6ee777fb402..dc8bde1ec231 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -40,6 +40,15 @@ export { fetchStreamPerformanceIntegration } from './integrations/fetchStreamPer export { webVitalsIntegration } from './integrations/webVitals'; export { userTimingIntegration } from './integrations/usertiming'; export { bfcacheMetricsIntegration } from './integrations/bfcacheMetrics'; +export { + createCachedRouteProvider, + createUrlRouteProvider, + getRouteProvider, + resolveCurrentRoute, + resolveRoute, + setRouteProvider, +} from '@sentry/browser-utils'; +export type { CachedRouteProvider, RouteProvider } from '@sentry/browser-utils'; export { interactionsIntegration } from './integrations/interactions'; export type { RequestInstrumentationOptions } from './tracing/request'; diff --git a/packages/browser/src/integrations/bfcacheMetrics.ts b/packages/browser/src/integrations/bfcacheMetrics.ts index 5dd076c2783a..78610dc3b2a2 100644 --- a/packages/browser/src/integrations/bfcacheMetrics.ts +++ b/packages/browser/src/integrations/bfcacheMetrics.ts @@ -8,6 +8,7 @@ import { } from '@sentry/conventions/attributes'; import type { IntegrationFn, SpanAttributes } from '@sentry/core'; import { debug, defineIntegration, getCurrentScope, metrics } from '@sentry/core'; +import { resolveCurrentRoute } from '@sentry/browser-utils'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; @@ -129,14 +130,20 @@ function _captureBFCacheReason({ reason, frame }: CollectedReason, routeName?: s } /** - * The segment name for a bfcache navigation, read from the scope rather than any span. + * The segment name for a bfcache navigation. * - * A hit restore is silent to tracing (no pageload span), but the frozen scope still holds the last - * transaction name a downstream SDK (Vue/React/etc.) set before the freeze, so we reuse that. On a miss the - * page reloads with a fresh scope, so this is the new pageload name. Falls back to the raw pathname when unset. + * A registered route provider is preferred because it is parameterized, which matters more here than + * elsewhere: this ends up as a metric dimension, where a raw URL is unbounded cardinality. + * + * Without one we fall back to the scope. A hit restore is silent to tracing (no pageload span), but the + * frozen scope still holds the last transaction name a downstream SDK (Vue/React/etc.) set before the + * freeze, so we reuse that. On a miss the page reloads with a fresh scope, so this is the new pageload + * name. Falls back to the raw pathname when unset. + * + * Exported for tests only. */ -function _getSegmentName(): string | undefined { - return getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname; +export function _getSegmentName(): string | undefined { + return resolveCurrentRoute() || getCurrentScope().getScopeData().transactionName || WINDOW.location?.pathname; } /** diff --git a/packages/browser/test/client.test.ts b/packages/browser/test/client.test.ts index cc7a6cbbafc9..eb73cb98051a 100644 --- a/packages/browser/test/client.test.ts +++ b/packages/browser/test/client.test.ts @@ -2,7 +2,8 @@ * @vitest-environment jsdom */ -import { getCurrentScope, makeSession, setCurrentClient } from '@sentry/core'; +import { getRouteProvider, resolveCurrentRoute, setRouteProvider } from '@sentry/browser-utils'; +import { debug, getCurrentScope, makeSession, setCurrentClient } from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { applyDefaultOptions, BrowserClient } from '../src/client'; import { WINDOW } from '../src/helpers'; @@ -51,6 +52,28 @@ describe('BrowserClient', () => { expect(flushSpy).toHaveBeenCalledTimes(1); }); + describe('routeProvider option', () => { + it('registers the provider for the client', () => { + const routeProvider = { resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }; + client = new BrowserClient(getDefaultBrowserClientOptions({ routeProvider })); + + expect(getRouteProvider(client)).toBe(routeProvider); + }); + + it('is replaced by a provider registered at runtime', () => { + vi.spyOn(debug, 'warn').mockImplementation(() => {}); + client = new BrowserClient( + getDefaultBrowserClientOptions({ + routeProvider: { resolveRoute: () => '/option', resolveCurrentRoute: () => '/option' }, + }), + ); + + setRouteProvider({ resolveRoute: () => '/runtime', resolveCurrentRoute: () => '/runtime' }, client); + + expect(resolveCurrentRoute(client)).toBe('/runtime'); + }); + }); + describe('session status on unhandled errors', () => { afterEach(() => { getCurrentScope().setSession(undefined); diff --git a/packages/browser/test/integrations/bfcacheMetrics.test.ts b/packages/browser/test/integrations/bfcacheMetrics.test.ts index 42b51dd58d14..74ec02ea9a66 100644 --- a/packages/browser/test/integrations/bfcacheMetrics.test.ts +++ b/packages/browser/test/integrations/bfcacheMetrics.test.ts @@ -1,8 +1,53 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { debug } from '@sentry/core'; -import { _collectNotRestoredReasons, _resolveMaxReasons } from '../../src/integrations/bfcacheMetrics'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { debug, getCurrentScope, setCurrentClient } from '@sentry/core'; +import { setRouteProvider } from '@sentry/browser-utils'; +import { BrowserClient } from '../../src/client'; +import { _collectNotRestoredReasons, _getSegmentName, _resolveMaxReasons } from '../../src/integrations/bfcacheMetrics'; +import { WINDOW } from '../../src/helpers'; +import { getDefaultBrowserClientOptions } from '../helper/browser-client-options'; describe('bfcacheMetricsIntegration', () => { + describe('_getSegmentName', () => { + beforeEach(() => { + getCurrentScope().setTransactionName(undefined); + const client = new BrowserClient(getDefaultBrowserClientOptions()); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + delete (WINDOW as { location?: unknown }).location; + getCurrentScope().setTransactionName(undefined); + getCurrentScope().setClient(undefined); + }); + + it('prefers the parameterized route from a registered provider', () => { + getCurrentScope().setTransactionName('/users/42'); + setRouteProvider({ resolveRoute: () => '/users/:id', resolveCurrentRoute: () => '/users/:id' }); + + expect(_getSegmentName()).toBe('/users/:id'); + }); + + it('falls back to the scope when no provider is registered', () => { + getCurrentScope().setTransactionName('/users/:id'); + + expect(_getSegmentName()).toBe('/users/:id'); + }); + + it('falls back to the scope when the provider matches no route', () => { + getCurrentScope().setTransactionName('/users/:id'); + setRouteProvider({ resolveRoute: () => undefined, resolveCurrentRoute: () => undefined }); + + expect(_getSegmentName()).toBe('/users/:id'); + }); + + it('falls back to the raw pathname when nothing else knows the route', () => { + (WINDOW as { location?: unknown }).location = { pathname: '/users/42' }; + + expect(_getSegmentName()).toBe('/users/42'); + }); + }); + describe('_resolveMaxReasons', () => { afterEach(() => { vi.restoreAllMocks(); From f471f80653fe49381ede756551827c0c9452212b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 1 Oct 2026 13:58:45 +0200 Subject: [PATCH 05/57] feat(nextjs): Register nextjs route provider (#23552) Registers a route provider for Next.js from the manifests already injected at build time, covering both the App Router and the Pages Router. Registered from `init()` rather than from `browserTracingIntegration`, which is the point of the change: both manifests are on the global object before `Sentry.init` runs, so nothing has to wait for a router or for tracing. `bfcacheMetricsIntegration` now resolves a parameterized route with `browserTracingIntegration` absent entirely, and the same will hold for web vitals and interactions as they move over. The two manifests want the pathname differently, since App Router routes are generated with `basePath` baked in while Next strips it internally for the Pages Router, so the provider normalizes per manifest. Part of #23556 --- packages/nextjs/src/client/index.ts | 4 + .../appRouterRoutingInstrumentation.ts | 20 ++-- .../pagesRouterNavigationInstrumentation.ts | 60 +----------- .../pagesRouterRoutingInstrumentation.ts | 60 ++++++++++++ .../src/client/routing/parameterization.ts | 26 +++++ .../src/client/routing/routeProvider.ts | 26 +++++ .../appRouterRoutingInstrumentation.test.ts | 4 + .../test/client/parameterization.test.ts | 26 ++++- .../nextjs/test/client/routeProvider.test.ts | 97 +++++++++++++++++++ packages/nextjs/test/clientSdk.test.ts | 32 +++++- 10 files changed, 282 insertions(+), 73 deletions(-) create mode 100644 packages/nextjs/src/client/routing/routeProvider.ts create mode 100644 packages/nextjs/test/client/routeProvider.test.ts diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 828a4f0a541a..37763c0e0607 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -12,6 +12,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; +import { createNextRouteProvider } from './routing/routeProvider'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -65,6 +66,9 @@ export function init(options: BrowserOptions): Client | undefined { environment: options.environment || process.env.SENTRY_ENVIRONMENT || getClientVercelEnv() || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), release: process.env._sentryRelease || globalWithInjectedValues._sentryRelease, + // Both route manifests are injected at build time, so route parameterization works from `init` on, + // including for the pageload span and with tracing disabled. + routeProvider: createNextRouteProvider(), ...options, } satisfies BrowserOptions; diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 07258ff72416..1a7bb8a113de 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -13,8 +13,10 @@ import { startBrowserTracingPageLoadSpan, WINDOW, getAbsoluteUrl, + resolveCurrentRoute, + resolveRoute, } from '@sentry/react'; -import { maybeParameterizeRoute } from './parameterization'; +import { stripTrailingSlash } from './parameterization'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -24,14 +26,6 @@ import { } from '@sentry/conventions/attributes'; import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; -/** - * Strips trailing slash from a pathname, unless it's the root path. - * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. - */ -function stripTrailingSlash(pathname: string): string { - return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; -} - function setNavigationSpanUrlAttributes(span: Span, urlPath: string, urlOrPath: string): void { span.setAttributes({ [URL_PATH]: urlPath, @@ -103,7 +97,7 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); startBrowserTracingPageLoadSpan(client, { // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. name: parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? PAGELOAD_SPAN_NAME_FALLBACK : pathname), @@ -158,7 +152,7 @@ export function appRouterInstrumentNavigation(client: Client): void { const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; const normalizedHref = basePath && href.startsWith('/') && !href.startsWith(basePath) ? `${basePath}${href}` : href; const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname); - const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); + const parameterizedPathname = resolveRoute(normalizedHref, client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? @@ -198,7 +192,7 @@ export function appRouterInstrumentNavigation(client: Client): void { WINDOW.addEventListener('popstate', () => { const pathname = stripTrailingSlash(WINDOW.location.pathname); - const parameterizedPathname = maybeParameterizeRoute(pathname); + const parameterizedPathname = resolveCurrentRoute(client); // With span streaming, span names have to be low cardinality, so we can't fall back to the URL. const spanName = parameterizedPathname ?? (hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathname); @@ -308,7 +302,7 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe ? `${basePath}${href}` : href; const transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref)); - const parameterizedPathname = maybeParameterizeRoute(transactionName); + const parameterizedPathname = resolveRoute(transactionName, client); currentNavigationSpanRef.current = startBrowserTracingNavigationSpan( client, diff --git a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts index b94eadc5c134..44d842b6193a 100644 --- a/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterNavigationInstrumentation.ts @@ -5,10 +5,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stripUrlQueryAndFragment, } from '@sentry/core'; -import { getAbsoluteUrl, startBrowserTracingNavigationSpan, WINDOW } from '@sentry/react'; +import { getAbsoluteUrl, startBrowserTracingNavigationSpan } from '@sentry/react'; import RouterImport from 'next/router'; import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { NAVIGATION } from '@sentry/conventions/op'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; // next/router v10 is CJS // @@ -17,8 +18,6 @@ const Router: typeof RouterImport = RouterImport.events ? RouterImport : (RouterImport as unknown as { default: typeof RouterImport }).default; -const globalObject = WINDOW; - /** * Instruments the Next.js pages router for navigation. * Only supported for client side routing. Works for Next >= 10. @@ -59,58 +58,3 @@ export function pagesRouterInstrumentNavigation(client: Client): void { ); }); } - -function getNextRouteFromPathname(pathname: string): string | undefined { - const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; - - // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here - if (!pageRoutes) { - return; - } - - return pageRoutes.find(route => { - const routeRegExp = convertNextRouteToRegExp(route); - return pathname.match(routeRegExp); - }); -} - -/** - * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). - * - * In general this involves replacing any instances of square brackets in a route with a wildcard: - * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ - * - * Some additional edgecases need to be considered: - * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or - * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". - * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). - * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). - * - * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` - */ -function convertNextRouteToRegExp(route: string): RegExp { - // We can assume a route is at least "/". - const routeParts = route.split('/'); - - let optionalCatchallWildcardRegex = ''; - if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { - // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing - // slash that would come before it if we didn't pop it. - routeParts.pop(); - optionalCatchallWildcardRegex = '(?:/(.+?))?'; - } - - const rejoinedRouteParts = routeParts - .map( - routePart => - routePart - .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard - .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards - ) - .join('/'); - - // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input - return new RegExp( - `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end - ); -} diff --git a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts index 8cbf116334e4..7a4ebaad3877 100644 --- a/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/pagesRouterRoutingInstrumentation.ts @@ -122,3 +122,63 @@ export function pagesRouterInstrumentPageLoad(client: Client): void { { sentryTrace, baggage }, ); } + +/** + * Matches a pathname against the Pages Router build manifest, e.g. `/users/1` -> `/users/[id]`. + * + * Expects a pathname without `basePath`, which is what Next reports internally. + */ +export function getNextRouteFromPathname(pathname: string): string | undefined { + const pageRoutes = globalObject.__BUILD_MANIFEST?.sortedPages; + + // Page route should in 99.999% of the cases be defined by now but just to be sure we make a check here + if (!pageRoutes) { + return; + } + + return pageRoutes.find(route => { + const routeRegExp = convertNextRouteToRegExp(route); + return pathname.match(routeRegExp); + }); +} + +/** + * Converts a Next.js style route to a regular expression that matches on pathnames (no query params or URL fragments). + * + * In general this involves replacing any instances of square brackets in a route with a wildcard: + * e.g. "/users/[id]/info" becomes /\/users\/([^/]+?)\/info/ + * + * Some additional edgecases need to be considered: + * - All routes have an optional slash at the end, meaning users can navigate to "/users/[id]/info" or + * "/users/[id]/info/" - both will be resolved to "/users/[id]/info". + * - Non-optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[...params]"). + * - Optional "catchall"s at the end of a route must be considered when matching (e.g. "/users/[[...params]]"). + * + * @param route A Next.js style route as it is found in `global.__BUILD_MANIFEST.sortedPages` + */ +function convertNextRouteToRegExp(route: string): RegExp { + // We can assume a route is at least "/". + const routeParts = route.split('/'); + + let optionalCatchallWildcardRegex = ''; + if (routeParts[routeParts.length - 1]?.match(/^\[\[\.\.\..+\]\]$/)) { + // If last route part has pattern "[[...xyz]]" we pop the latest route part to get rid of the required trailing + // slash that would come before it if we didn't pop it. + routeParts.pop(); + optionalCatchallWildcardRegex = '(?:/(.+?))?'; + } + + const rejoinedRouteParts = routeParts + .map( + routePart => + routePart + .replace(/^\[\.\.\..+\]$/, '(.+?)') // Replace catch all wildcard with regex wildcard + .replace(/^\[.*\]$/, '([^/]+?)'), // Replace route wildcards with lazy regex wildcards + ) + .join('/'); + + // oxlint-disable-next-line sdk/no-regexp-constructor -- routeParts are from the build manifest, so no raw user input + return new RegExp( + `^${rejoinedRouteParts}${optionalCatchallWildcardRegex}(?:/)?$`, // optional slash at the end + ); +} diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index da25c1beb840..9b366d705637 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -12,6 +12,32 @@ let cachedManifestString: string | undefined = undefined; const compiledRegexCache: Map = new Map(); const routeResultCache: Map = new Map(); +const globalWithInjectedBasePath = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryBasePath: string | undefined; +}; + +/** + * Strips trailing slash from a pathname, unless it's the root path. + * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. + */ +export function stripTrailingSlash(pathname: string): string { + return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + +/** + * Removes the configured `basePath` from a pathname. + * + * App Router routes are generated with `basePath` baked in, but Next strips it internally for the + * Pages Router, so `__BUILD_MANIFEST.sortedPages` holds routes without it. + */ +export function stripBasePath(pathname: string): string { + const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; + + return basePath && (pathname === basePath || pathname.startsWith(`${basePath}/`)) + ? pathname.slice(basePath.length) || '/' + : pathname; +} + // Specificity ranks for a single route segment, from most to least specific. `END` is the rank of // the position just past the last segment of a route, so that a route which stops is compared // against whatever the longer route continues with. diff --git a/packages/nextjs/src/client/routing/routeProvider.ts b/packages/nextjs/src/client/routing/routeProvider.ts new file mode 100644 index 000000000000..d6ef74b8698c --- /dev/null +++ b/packages/nextjs/src/client/routing/routeProvider.ts @@ -0,0 +1,26 @@ +import type { RouteProvider } from '@sentry/react'; +import { createUrlRouteProvider } from '@sentry/react'; +import { maybeParameterizeRoute, stripBasePath, stripTrailingSlash } from './parameterization'; +import { getNextRouteFromPathname } from './pagesRouterRoutingInstrumentation'; + +/** + * Resolves a URL against whichever router manifest the app ships. + * + * App Router routes are generated with `basePath` baked in, which is what `location.pathname` gives + * us; Next strips it internally for the Pages Router, so the fallback strips it too. + */ +function resolveNextRoute(url: { pathname: string }): string | undefined { + const pathname = stripTrailingSlash(url.pathname); + + return maybeParameterizeRoute(pathname) ?? getNextRouteFromPathname(stripBasePath(pathname)); +} + +/** + * A route provider backed by the route manifests Next.js injects at build time. + * + * Both manifests are on the global object before `Sentry.init` runs, so this needs no router and no + * tracing integration: registering it is what lets anything else in the SDK name a route. + */ +export function createNextRouteProvider(): RouteProvider { + return createUrlRouteProvider(resolveNextRoute); +} diff --git a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts index 37adaa05c25a..0f8a49816253 100644 --- a/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts +++ b/packages/nextjs/test/client/appRouterRoutingInstrumentation.test.ts @@ -11,11 +11,13 @@ import '@sentry/core'; import '@sentry/react'; import '../../src/client/routing/appRouterRoutingInstrumentation'; import type * as AppRouterInstrumentation from '../../src/client/routing/appRouterRoutingInstrumentation'; +import type * as RouteProvider from '../../src/client/routing/routeProvider'; import type { RouteManifest } from '../../src/config/manifest/types'; type Core = typeof SentryCore; type React = typeof SentryReact; type Instrumentation = typeof AppRouterInstrumentation; +type RouteProviderModule = typeof RouteProvider; interface NextRouter { back: () => void; @@ -69,6 +71,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ const core: Core = await import('@sentry/core'); const react: React = await import('@sentry/react'); const instrumentation: Instrumentation = await import('../../src/client/routing/appRouterRoutingInstrumentation'); + const routeProvider: RouteProviderModule = await import('../../src/client/routing/routeProvider'); const client = new react.BrowserClient({ dsn: 'http://examplePublicKey@localhost/0', @@ -76,6 +79,7 @@ async function setup(traceLifecycle: 'stream' | 'static'): Promise<{ stackParser: () => [], tracesSampleRate: 1, traceLifecycle, + routeProvider: routeProvider.createNextRouteProvider(), integrations: [react.browserTracingIntegration({ instrumentPageLoad: false, instrumentNavigation: false })], }); core.setCurrentClient(client); diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index 88817c2cb518..50b7e426b0c6 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -1,10 +1,11 @@ import { GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; -import { maybeParameterizeRoute } from '../../src/client/routing/parameterization'; +import { maybeParameterizeRoute, stripBasePath } from '../../src/client/routing/parameterization'; import type { RouteManifest } from '../../src/config/manifest/types'; const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest: string | undefined; + _sentryBasePath?: string; }; describe('maybeParameterizeRoute', () => { @@ -1112,3 +1113,26 @@ describe('maybeParameterizeRoute', () => { }); }); }); + +describe('stripBasePath', () => { + afterEach(() => { + delete globalWithInjectedManifest._sentryBasePath; + }); + + it('strips the base path from paths under it', () => { + globalWithInjectedManifest._sentryBasePath = '/docs'; + + expect(stripBasePath('/docs/posts/hello')).toBe('/posts/hello'); + expect(stripBasePath('/docs')).toBe('/'); + }); + + it('leaves paths that only share a prefix with the base path alone', () => { + globalWithInjectedManifest._sentryBasePath = '/docs'; + + expect(stripBasePath('/documentation')).toBe('/documentation'); + }); + + it('leaves paths alone without a base path', () => { + expect(stripBasePath('/docs/posts/hello')).toBe('/docs/posts/hello'); + }); +}); diff --git a/packages/nextjs/test/client/routeProvider.test.ts b/packages/nextjs/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..90721c503867 --- /dev/null +++ b/packages/nextjs/test/client/routeProvider.test.ts @@ -0,0 +1,97 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { BrowserClient, setCurrentClient, resolveCurrentRoute, resolveRoute, setRouteProvider } from '@sentry/react'; +import { createNextRouteProvider } from '../../src/client/routing/routeProvider'; + +const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest?: string; + _sentryBasePath?: string; + __BUILD_MANIFEST?: { sortedPages?: string[] }; +}; + +let originalDocument: unknown; + +const MANIFEST = JSON.stringify({ + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], + isrRoutes: [], +}); + +function makeClient(): BrowserClient { + // Deliberately no integrations at all, so nothing tracing-related can be supplying the route. + const client = new BrowserClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + setCurrentClient(client); + client.init(); + + return client; +} + +describe('createNextRouteProvider', () => { + beforeEach(() => { + globalWithManifest._sentryRouteManifest = MANIFEST; + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + // `getLocationHref()` reads `document.location.href`; the listener stubs are only here so + // `client.init()` does not trip over the stand-in. + (GLOBAL_OBJ as { document?: unknown }).document = { + location: { href: 'https://example.com/users/42' }, + addEventListener: () => {}, + removeEventListener: () => {}, + }; + }); + + afterEach(() => { + delete globalWithManifest._sentryRouteManifest; + delete globalWithManifest._sentryBasePath; + delete globalWithManifest.__BUILD_MANIFEST; + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + }); + + it('parameterizes a URL from the build-time manifest', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('resolves the current route without a tracing integration', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(client.getIntegrationByName('BrowserTracing')).toBeUndefined(); + expect(resolveCurrentRoute(client)).toBe('/users/:id'); + }); + + it('returns undefined for a URL the manifest does not know', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/nope/deep', client)).toBeUndefined(); + }); + + describe('Pages Router', () => { + beforeEach(() => { + globalWithManifest.__BUILD_MANIFEST = { sortedPages: ['/', '/_app', '/_error', '/posts/[slug]'] }; + }); + + it('falls back to the Pages Router manifest when the App Router manifest has no match', () => { + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/posts/hello', client)).toBe('/posts/[slug]'); + }); + + it('strips `basePath` before matching, since Pages Router routes are generated without it', () => { + globalWithManifest._sentryBasePath = '/docs'; + const client = makeClient(); + setRouteProvider(createNextRouteProvider(), client); + + expect(resolveRoute('https://example.com/docs/posts/hello', client)).toBe('/posts/[slug]'); + expect(resolveRoute('https://example.com/docs', client)).toBe('/'); + }); + }); +}); diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index a4cf4869102f..f9367080f699 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -1,5 +1,5 @@ import type { Integration } from '@sentry/core'; -import { debug, getMainCarrier, SentryNonRecordingSpan } from '@sentry/core'; +import { debug, getMainCarrier, GLOBAL_OBJ, SentryNonRecordingSpan, spanToJSON } from '@sentry/core'; import * as SentryReact from '@sentry/react'; import { getClient, WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; @@ -188,6 +188,36 @@ describe('Client init()', () => { delete globalThis.__SENTRY_TRACING__; }); + it('names the pageload span after the parameterized route', () => { + const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest?: string }; + globalWithManifest._sentryRouteManifest = JSON.stringify({ + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + isrRoutes: [], + }); + + init({ dsn: TEST_DSN, tracesSampleRate: 1.0 }); + + expect(spanToJSON(SentryReact.getActiveSpan()!)).toMatchObject({ + name: '/', + attributes: { + 'sentry.op': 'pageload', + 'sentry.segment.name.source': 'route', + 'url.template': '/', + }, + }); + + delete globalWithManifest._sentryRouteManifest; + }); + + it('keeps a route provider passed by the user', () => { + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + + init({ dsn: TEST_DSN, routeProvider }); + + expect(reactInit).toHaveBeenCalledWith(expect.objectContaining({ routeProvider })); + }); + it("doesn't run Next.js router instrumentation for bot user agents", () => { Object.defineProperty(WINDOW, 'navigator', { value: { From 97f19ed742151ad4ebdfb520da8b99e61ed7ca12 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 1 Oct 2026 13:58:46 +0200 Subject: [PATCH 06/57] feat(remix): Register remix route provider (#23791) Registers a route provider for Remix from the route manifest the Vite plugin already injects at build time. `maybeParameterizeRemixRoute` was already a pure matcher over that manifest, it was just only reachable from the pageload and navigation instrumentation. Registered from `init()` rather than a tracing integration, so route parameterization no longer depends on tracing being enabled. Same shape as #23552, and simpler: Remix has one manifest and no `basePath` asymmetry. Part of #23556 --- .../app/routes/route-provider.$id.tsx | 12 +++++ .../tests/route-provider.test.ts | 9 ++++ packages/remix/src/client/routeProvider.ts | 13 +++++ packages/remix/src/client/sdk.ts | 4 ++ .../remix/test/client/routeProvider.test.ts | 50 +++++++++++++++++++ packages/remix/test/index.client.test.ts | 11 ++++ 6 files changed, 99 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx create mode 100644 dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts create mode 100644 packages/remix/src/client/routeProvider.ts create mode 100644 packages/remix/test/client/routeProvider.test.ts diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx new file mode 100644 index 000000000000..6134dbb17ab0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/app/routes/route-provider.$id.tsx @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/remix'; +import { useEffect, useState } from 'react'; + +export default function RouteProvider() { + const [route, setRoute] = useState(); + + useEffect(() => { + setRoute(Sentry.resolveCurrentRoute() ?? 'unresolved'); + }, []); + + return
{route}
; +} diff --git a/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts new file mode 100644 index 000000000000..a99e7ec07314 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/create-remix-app-express/tests/route-provider.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +// The route provider is backed by the manifest the Vite plugin injects into the client bundle, so this +// fails if that manifest never reaches the browser. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id'); +}); diff --git a/packages/remix/src/client/routeProvider.ts b/packages/remix/src/client/routeProvider.ts new file mode 100644 index 000000000000..5cc470a9c5a0 --- /dev/null +++ b/packages/remix/src/client/routeProvider.ts @@ -0,0 +1,13 @@ +import type { RouteProvider } from '@sentry/react'; +import { createUrlRouteProvider } from '@sentry/react'; +import { maybeParameterizeRemixRoute } from './remixRouteParameterization'; + +/** + * A route provider backed by the route manifest the Vite plugin injects at build time. + * + * The manifest is on the global object before `Sentry.init` runs, so this needs no router and no + * tracing integration: registering it is what lets anything else in the SDK name a route. + */ +export function createRemixRouteProvider(): RouteProvider { + return createUrlRouteProvider(url => maybeParameterizeRemixRoute(url.pathname)); +} diff --git a/packages/remix/src/client/sdk.ts b/packages/remix/src/client/sdk.ts index d09ec6786ce4..46bf5a92c501 100644 --- a/packages/remix/src/client/sdk.ts +++ b/packages/remix/src/client/sdk.ts @@ -2,6 +2,7 @@ import type { Client } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import { init as reactInit } from '@sentry/react'; import type { RemixOptions } from '../utils/remixOptions'; +import { createRemixRouteProvider } from './routeProvider'; /** * Initializes the Remix SDK. @@ -10,6 +11,9 @@ import type { RemixOptions } from '../utils/remixOptions'; */ export function init(options: RemixOptions): Client | undefined { const opts = { + // The manifest is injected at build time, so route parameterization works from `init` on, even with + // tracing disabled. + routeProvider: createRemixRouteProvider(), ...options, environment: options.environment || process.env.NODE_ENV, }; diff --git a/packages/remix/test/client/routeProvider.test.ts b/packages/remix/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..d92edc120cba --- /dev/null +++ b/packages/remix/test/client/routeProvider.test.ts @@ -0,0 +1,50 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createRemixRouteProvider } from '../../src/client/routeProvider'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRemixRouteManifest: string | undefined; +}; + +const MANIFEST = JSON.stringify({ + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [{ path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'] }], +}); + +let originalDocument: unknown; + +describe('createRemixRouteProvider', () => { + beforeEach(() => { + globalWithInjectedManifest._sentryRemixRouteManifest = MANIFEST; + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + // `resolveCurrentRoute` reads `document.location.href`. + (GLOBAL_OBJ as { document?: unknown }).document = { location: { href: 'https://example.com/users/42' } }; + }); + + afterEach(() => { + globalWithInjectedManifest._sentryRemixRouteManifest = undefined; + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + }); + + it('parameterizes a URL from the build-time manifest', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('resolves a static route', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/about'))).toBe('/about'); + }); + + it('resolves the current route from the document location', () => { + expect(createRemixRouteProvider().resolveCurrentRoute()).toBe('/users/:id'); + }); + + it('returns undefined for a URL the manifest does not know', () => { + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/nope/deep'))).toBeUndefined(); + }); + + it('returns undefined when the manifest was never injected', () => { + globalWithInjectedManifest._sentryRemixRouteManifest = undefined; + + expect(createRemixRouteProvider().resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined(); + }); +}); diff --git a/packages/remix/test/index.client.test.ts b/packages/remix/test/index.client.test.ts index 5b39e7736e29..1e0e26343494 100644 --- a/packages/remix/test/index.client.test.ts +++ b/packages/remix/test/index.client.test.ts @@ -43,6 +43,17 @@ describe('Client init()', () => { ); }); + it('passes the Remix route provider unless the user passed one', () => { + init({}); + expect(reactInit).toHaveBeenLastCalledWith( + expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }), + ); + + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + init({ routeProvider }); + expect(reactInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); From bad33259119f5e26b5ee1be6edeb05655ad80320 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 1 Oct 2026 13:58:46 +0200 Subject: [PATCH 07/57] feat(astro): Register astro route provider (#23792) Registers a route provider for Astro from the `sentry-route-name` meta tag the middleware already injects into every rendered document. Registered from `init()` rather than the tracing integration, so route parameterization no longer depends on tracing being enabled. Unlike the Next.js and Remix providers this is not a matcher. The document only ever describes the page it rendered, so a URL other than the current one resolves to `undefined` rather than a guess. That guard is also what stops a navigation being named after the route it is leaving. I checked the soft-navigation behaviour against Astro 5.18 with a throwaway app rather than assuming it. `ClientRouter` swaps the tag during `astro:after-swap`, at the same moment `location` changes, and does not accumulate duplicates, so reading it per call stays correct across client-side navigations and back/forward. It is only stale *during* a navigation, before the swap, which the current-path guard already excludes. Part of #23556 --- .../src/pages/route-provider/[id].astro | 18 +++++ .../astro-5/tests/route-provider.test.ts | 10 +++ packages/astro/src/client/routeProvider.ts | 40 ++++++++++++ packages/astro/src/client/sdk.ts | 4 ++ .../astro/test/client/routeProvider.test.ts | 65 +++++++++++++++++++ packages/astro/test/client/sdk.test.ts | 11 ++++ 6 files changed, 148 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro create mode 100644 dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts create mode 100644 packages/astro/src/client/routeProvider.ts create mode 100644 packages/astro/test/client/routeProvider.test.ts diff --git a/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro new file mode 100644 index 000000000000..ff0ee0f13d59 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/src/pages/route-provider/[id].astro @@ -0,0 +1,18 @@ +--- +import Layout from '../../layouts/Layout.astro'; + +export const prerender = false; +--- + + + +
+
+ + diff --git a/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts new file mode 100644 index 000000000000..09e444a04c26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-5/tests/route-provider.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the route the middleware renders into the page, so this fails if that route +// never reaches the browser. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + await page.locator('#resolve-route').click(); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/[id]'); +}); diff --git a/packages/astro/src/client/routeProvider.ts b/packages/astro/src/client/routeProvider.ts new file mode 100644 index 000000000000..6aeec7bdfd02 --- /dev/null +++ b/packages/astro/src/client/routeProvider.ts @@ -0,0 +1,40 @@ +import type { RouteProvider } from '@sentry/browser'; +import { WINDOW } from '@sentry/browser'; + +/** + * Reads the parameterized route the Astro middleware injects into the document it rendered. + */ +function readRouteNameFromMeta(): string | undefined { + const optionalDocument = WINDOW.document as (typeof WINDOW)['document'] | undefined; + const content = optionalDocument?.querySelector('meta[name=sentry-route-name]')?.getAttribute('content'); + if (!content) { + return undefined; + } + + try { + return decodeURIComponent(content); + } catch { + // The middleware encodes the route, so a value we can't decode isn't one we put there. + return undefined; + } +} + +/** + * A route provider backed by the `sentry-route-name` meta tag the Astro middleware injects. + * + * Unlike a manifest-backed provider this is not a matcher: the document only ever describes the page + * it rendered, so a URL other than the current one resolves to `undefined` rather than a guess. + * + * The tag does track client-side navigations. Astro's `ClientRouter` swaps it during + * `astro:after-swap`, at the same moment `location` changes, so reading it per call stays correct + * across soft navigations and back/forward. It is only stale *during* a navigation, before the swap, + * which is why `resolveRoute` refuses to answer for anything but the current path. + */ +export function createAstroRouteProvider(): RouteProvider { + const isCurrentPath = (url: { pathname: string }): boolean => url.pathname === WINDOW.location?.pathname; + + return { + resolveRoute: url => (isCurrentPath(url) ? readRouteNameFromMeta() : undefined), + resolveCurrentRoute: readRouteNameFromMeta, + }; +} diff --git a/packages/astro/src/client/sdk.ts b/packages/astro/src/client/sdk.ts index 21c5770f255f..14effd00cacb 100644 --- a/packages/astro/src/client/sdk.ts +++ b/packages/astro/src/client/sdk.ts @@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as initBr import type { Client, Integration } from '@sentry/core'; import { applySdkMetadata } from '@sentry/core'; import { browserTracingIntegration } from './browserTracingIntegration'; +import { createAstroRouteProvider } from './routeProvider'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -15,6 +16,9 @@ declare const __SENTRY_TRACING__: boolean; export function init(options: BrowserOptions): Client | undefined { const opts = { defaultIntegrations: getDefaultIntegrations(options), + // The middleware injects the route into the document, so route parameterization works from `init` on, + // even with tracing disabled. + routeProvider: createAstroRouteProvider(), ...options, }; diff --git a/packages/astro/test/client/routeProvider.test.ts b/packages/astro/test/client/routeProvider.test.ts new file mode 100644 index 000000000000..a81080eb37ab --- /dev/null +++ b/packages/astro/test/client/routeProvider.test.ts @@ -0,0 +1,65 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createAstroRouteProvider } from '../../src/client/routeProvider'; + +let originalDocument: unknown; +let originalLocation: unknown; + +/** Mirrors what the Astro middleware injects: an encoded route on a `sentry-route-name` meta tag. */ +function renderPage(pathname: string, routeName: string | undefined): void { + const meta = routeName + ? { getAttribute: (attr: string) => (attr === 'content' ? encodeURIComponent(routeName) : null) } + : null; + + (GLOBAL_OBJ as { document?: unknown }).document = { + querySelector: (selector: string) => (selector === 'meta[name=sentry-route-name]' ? meta : null), + }; + (GLOBAL_OBJ as { location?: unknown }).location = { pathname }; +} + +describe('createAstroRouteProvider', () => { + beforeEach(() => { + originalDocument = (GLOBAL_OBJ as { document?: unknown }).document; + originalLocation = (GLOBAL_OBJ as { location?: unknown }).location; + }); + + afterEach(() => { + (GLOBAL_OBJ as { document?: unknown }).document = originalDocument; + (GLOBAL_OBJ as { location?: unknown }).location = originalLocation; + }); + + it('resolves the current route from the meta tag', () => { + renderPage('/users/1', '/users/[id]'); + + expect(createAstroRouteProvider().resolveCurrentRoute()).toBe('/users/[id]'); + }); + + it('resolves a URL that is the current page', () => { + renderPage('/users/1', '/users/[id]'); + + expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/users/1'))).toBe('/users/[id]'); + }); + + it('refuses to answer for a URL that is not the current page', () => { + renderPage('/users/1', '/users/[id]'); + + // The document only ever describes the page it rendered, so guessing here would be wrong. This is + // also what keeps a navigation from being named after the route it is leaving. + expect(createAstroRouteProvider().resolveRoute(new URL('https://example.com/posts/hello'))).toBeUndefined(); + }); + + it('follows a client-side navigation, since the meta tag is swapped with the document', () => { + const provider = createAstroRouteProvider(); + renderPage('/users/1', '/users/[id]'); + expect(provider.resolveCurrentRoute()).toBe('/users/[id]'); + + renderPage('/posts/hello', '/posts/[slug]'); + expect(provider.resolveCurrentRoute()).toBe('/posts/[slug]'); + }); + + it('returns undefined when the middleware injected no route', () => { + renderPage('/users/1', undefined); + + expect(createAstroRouteProvider().resolveCurrentRoute()).toBeUndefined(); + }); +}); diff --git a/packages/astro/test/client/sdk.test.ts b/packages/astro/test/client/sdk.test.ts index 9225af26b564..041034f9164e 100644 --- a/packages/astro/test/client/sdk.test.ts +++ b/packages/astro/test/client/sdk.test.ts @@ -92,6 +92,17 @@ describe('Sentry client SDK', () => { }); }); + it('passes the Astro route provider unless the user passed one', () => { + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' }); + expect(browserInit).toHaveBeenLastCalledWith( + expect.objectContaining({ routeProvider: expect.objectContaining({ resolveRoute: expect.any(Function) }) }), + ); + + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', routeProvider }); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining({ routeProvider })); + }); + it('returns client from init', () => { expect(init({})).not.toBeUndefined(); }); From de0041583ed6d5116e2618929570a4dc876e3703 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 1 Oct 2026 13:58:47 +0200 Subject: [PATCH 08/57] feat(vue): Register Vue route provider (#23793) Registers a route provider for Vue so route parameterization works without tracing and without passing `router` to `browserTracingIntegration`. The provider is registered in `vueIntegration`'s `setup`, so it picks up `app` whether it's passed to `init` or to the integration; a `routeProvider` passed to `init` takes precedence. On Vue 3 the router is read off `app.config.globalProperties.$router` on each call, since `app.use(router)` may run either side of `Sentry.init()`. Vue 2 only exposes the router on instances, so a `beforeCreate` mixin picks it up from the root `new Vue({ router })`. Until that instance exists nothing resolves, so the Vue 2 pageload span keeps the raw URL. `resolve()` matches the router's own location, not the browser's, so URLs are converted first: the path comes from the hash in hash mode, and the router's `base` is stripped otherwise (Vue Router 3 and 4). Returns the matched path rather than `route.name`, even under `routeLabel: 'name'`. Callers set `url.template` from this, and a route name is an identifier, not a template. The factory is exported as `_INTERNAL_createVueRouteProvider` for Nuxt; it isn't meant for users. Part of #23556 --- .../vue-3/src/router/index.ts | 4 + .../vue-3/src/views/RouteProviderView.vue | 14 +++ .../vue-3/tests/route-provider.test.ts | 9 ++ packages/vue/src/index.ts | 1 + packages/vue/src/integration.ts | 9 ++ packages/vue/src/routeProvider.ts | 89 ++++++++++++++ .../integration/mixinRegistration.test.ts | 3 +- packages/vue/test/routeProvider.test.ts | 113 ++++++++++++++++++ packages/vue/test/sdk.test.ts | 63 ++++++++++ 9 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue create mode 100644 dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts create mode 100644 packages/vue/src/routeProvider.ts create mode 100644 packages/vue/test/routeProvider.test.ts create mode 100644 packages/vue/test/sdk.test.ts diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts index 030d75dffb23..3f718ae945b4 100644 --- a/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/router/index.ts @@ -23,6 +23,10 @@ const router = createRouter({ path: '/users/:id', component: () => import('../views/UserIdView.vue'), }, + { + path: '/route-provider/:id', + component: () => import('../views/RouteProviderView.vue'), + }, { path: '/users-error/:id', component: () => import('../views/UserIdErrorView.vue'), diff --git a/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue b/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue new file mode 100644 index 000000000000..3f7d4dacf895 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/src/views/RouteProviderView.vue @@ -0,0 +1,14 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts new file mode 100644 index 000000000000..55e890e7398e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/vue-3/tests/route-provider.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the router off the app passed to `Sentry.init`, so this fails if it can't +// find it there. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id'); +}); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 3e870ff1062b..00d44693e193 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -8,5 +8,6 @@ export { browserTracingIntegration } from './browserTracingIntegration'; export { attachErrorHandler } from './errorhandler'; export { createTracingMixins } from './tracing'; export { vueIntegration } from './integration'; +export { createVueRouteProvider as _INTERNAL_createVueRouteProvider } from './routeProvider'; export type { VueIntegrationOptions } from './integration'; export { createSentryPiniaPlugin } from './pinia'; diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index 8bdac3d5db44..21835f8234dc 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -1,8 +1,10 @@ +import { getRouteProvider, setRouteProvider } from '@sentry/browser'; import { consoleSandbox, defineIntegration, GLOBAL_OBJ, hasSpansEnabled } from '@sentry/core'; import { DEFAULT_HOOKS, DEFAULT_ROOT_SPAN_TIMEOUT } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { attachErrorHandler } from './errorhandler'; import { instrumentAppMountWithoutMixin } from './rootInstrumentation'; +import { captureRouterFromVue, createVueRouteProvider, getRouterFromApp } from './routeProvider'; import { createTracingMixins } from './tracing'; import type { Options, TracingOptions, Vue, VueOptions } from './types'; @@ -38,6 +40,13 @@ export const vueIntegration = defineIntegration((integrationOptions: Partial getRouterFromApp(app) : captureRouterFromVue(Vue!); + setRouteProvider(createVueRouteProvider(getRouter), client); + } + if (options.app) { const apps = Array.isArray(options.app) ? options.app : [options.app]; apps.forEach(app => vueInit(app, options)); diff --git a/packages/vue/src/routeProvider.ts b/packages/vue/src/routeProvider.ts new file mode 100644 index 000000000000..7b9e6faf4a30 --- /dev/null +++ b/packages/vue/src/routeProvider.ts @@ -0,0 +1,89 @@ +import type { RouteProvider } from '@sentry/browser'; +import { createUrlRouteProvider } from '@sentry/browser'; +import type { Route } from './router'; +import type { Vue } from './types'; + +// Vue Router 3 resolves to `{ route }`, Vue Router 4+ returns the route itself. +type ResolvedLocation = Route | { route: Route }; + +type RouteUrl = Parameters[0]; + +interface InstalledRouter { + resolve?: (to: string) => ResolvedLocation; + /** Vue Router 4+ */ + options?: { history?: { base?: string } }; + /** Vue Router 3 */ + mode?: string; + history?: { base?: string }; +} + +interface AppWithRouter { + config?: { globalProperties?: { $router?: InstalledRouter } }; +} + +/** + * Builds a route provider from a `vue-router` instance, however the SDK got hold of one. + * + * The router is looked up per call rather than captured once, because `app.use(router)` may run + * either side of `Sentry.init()` and only the app itself is guaranteed to exist by then. + */ +export function createVueRouteProvider(getRouter: () => InstalledRouter | undefined): RouteProvider { + return createUrlRouteProvider(url => { + const router = getRouter(); + const resolved = router?.resolve?.(getRouterLocation(router, url)); + if (!resolved) { + return undefined; + } + + const route = 'matched' in resolved ? resolved : resolved.route; + + // Always the matched path, never `route.name`. Callers set `url.template` from this, and a route + // name is an identifier rather than a template. + return route.matched[route.matched.length - 1]?.path; + }); +} + +/** + * Reads the router `vue-router` installed onto a Vue app. + */ +export function getRouterFromApp(app: Vue | Vue[] | undefined): InstalledRouter | undefined { + const firstApp = (Array.isArray(app) ? app[0] : app) as AppWithRouter | undefined; + + return firstApp?.config?.globalProperties?.$router; +} + +/** + * Vue 2 only exposes the router on instances, not on the constructor passed to `init`, so this picks it + * up from the root instance (`new Vue({ router })`) as it is created. + */ +export function captureRouterFromVue(Vue: Vue): () => InstalledRouter | undefined { + let router: InstalledRouter | undefined; + Vue.mixin({ + beforeCreate(this: { $options: { router?: InstalledRouter } }) { + router = router || this.$options.router; + }, + }); + + return () => router; +} + +/** + * `resolve` matches the router's own location rather than the browser's: the hash in hash mode, and + * the path without the router's base otherwise. Mirrors vue-router's `createCurrentLocation`. + */ +function getRouterLocation(router: InstalledRouter, { pathname, search, hash }: RouteUrl): string { + const base = router.options?.history?.base ?? router.history?.base ?? ''; + const hashPos = base.indexOf('#'); + + if (hashPos > -1 || router.mode === 'hash') { + const hashBase = hashPos > -1 ? base.slice(hashPos) : '#'; + const path = hash.slice(hash.startsWith(hashBase) ? hashBase.length : 1); + + return path.startsWith('/') ? path : `/${path}`; + } + + const path = + base && pathname.toLowerCase().startsWith(base.toLowerCase()) ? pathname.slice(base.length) || '/' : pathname; + + return `${path}${search}${hash}`; +} diff --git a/packages/vue/test/integration/mixinRegistration.test.ts b/packages/vue/test/integration/mixinRegistration.test.ts index 8b5eb9de4048..90abef41e74b 100644 --- a/packages/vue/test/integration/mixinRegistration.test.ts +++ b/packages/vue/test/integration/mixinRegistration.test.ts @@ -160,7 +160,8 @@ describe('tracing mixin registration', () => { it('registers the tracing mixin on the constructor passed as `Vue` (Vue 2 setup)', ({ app, initSentry }) => { initSentry({ sdk: { app: undefined, Vue: app } }); - expect(getRegisteredMixins(app)).toHaveLength(1); + // The tracing mixin, plus the one that picks up the router for the route provider. + expect(getRegisteredMixins(app)).toHaveLength(2); }); }); diff --git a/packages/vue/test/routeProvider.test.ts b/packages/vue/test/routeProvider.test.ts new file mode 100644 index 000000000000..cc641d64edc4 --- /dev/null +++ b/packages/vue/test/routeProvider.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest'; +import { createVueRouteProvider, getRouterFromApp } from '../src/routeProvider'; +import type { Route } from '../src/router'; +import type { Vue } from '../src/types'; + +function makeRoute(overrides: Partial = {}): Route { + return { path: '/users/42', query: {}, params: {}, matched: [{ path: '/users/:id' }], ...overrides }; +} + +/** Vue Router 4+ returns the route itself. Only `/users/42` matches, so a wrong location resolves to nothing. */ +const v4Router = (route: Route | undefined, base = '') => ({ + options: { history: { base } }, + resolve: (to: string) => (to.split('?')[0] === '/users/42' ? route : makeRoute({ matched: [] })) as Route, +}); +/** Vue Router 3 wraps it in `{ route }`. */ +const v3Router = (route: Route, mode = 'history', base = '') => ({ + mode, + history: { base }, + resolve: (to: string) => ({ route: to.split('?')[0] === '/users/42' ? route : makeRoute({ matched: [] }) }), +}); + +/** A Vue 3 app with `vue-router` installed, which sets `config.globalProperties.$router`. */ +const appWithRouter = (router: unknown) => ({ config: { globalProperties: { $router: router } } }) as unknown as Vue; + +describe('getRouterFromApp', () => { + it('reads the router vue-router installed on the app', () => { + const router = v4Router(makeRoute()); + + expect(getRouterFromApp(appWithRouter(router))).toBe(router); + }); + + it('reads from the first app when several were passed', () => { + const router = v4Router(makeRoute()); + + expect(getRouterFromApp([appWithRouter(router), appWithRouter(undefined)])).toBe(router); + }); + + it('returns undefined when no router is installed yet', () => { + expect(getRouterFromApp(appWithRouter(undefined))).toBeUndefined(); + expect(getRouterFromApp(undefined)).toBeUndefined(); + }); +}); + +describe('createVueRouteProvider', () => { + it('resolves the matched path for Vue Router 4+', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute())); + + expect(provider.resolveRoute(new URL('https://example.com/users/42?tab=1'))).toBe('/users/:id'); + }); + + it('unwraps the `{ route }` shape Vue Router 3 resolves to', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute())); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('returns the matched path even for a named route, since a name is not a template', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute({ name: 'UserProfile' }))); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('picks the router up late, since `app.use(router)` may run after `Sentry.init`', () => { + let router: ReturnType | undefined; + const provider = createVueRouteProvider(() => router); + + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBeUndefined(); + + router = v4Router(makeRoute()); + expect(provider.resolveRoute(new URL('https://example.com/users/42'))).toBe('/users/:id'); + }); + + it('returns undefined when nothing matched', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute())); + + expect(provider.resolveRoute(new URL('https://example.com/nope'))).toBeUndefined(); + }); + + describe('Vue Router 4+ history base', () => { + it('strips a non-root base', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/app')); + + expect(provider.resolveRoute(new URL('https://example.com/app/users/42'))).toBe('/users/:id'); + }); + + it('resolves from the hash with hash history', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/#')); + + expect(provider.resolveRoute(new URL('https://example.com/#/users/42?tab=1'))).toBe('/users/:id'); + expect(provider.resolveRoute(new URL('https://example.com/'))).toBeUndefined(); + }); + + it('resolves from the hash with hash history under a base', () => { + const provider = createVueRouteProvider(() => v4Router(makeRoute(), '/app/#')); + + expect(provider.resolveRoute(new URL('https://example.com/app/#/users/42'))).toBe('/users/:id'); + }); + }); + + describe('Vue Router 3 mode', () => { + it('strips a non-root base in history mode', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute(), 'history', '/app')); + + expect(provider.resolveRoute(new URL('https://example.com/app/users/42'))).toBe('/users/:id'); + }); + + it('resolves from the hash in hash mode', () => { + const provider = createVueRouteProvider(() => v3Router(makeRoute(), 'hash')); + + expect(provider.resolveRoute(new URL('https://example.com/#/users/42'))).toBe('/users/:id'); + }); + }); +}); diff --git a/packages/vue/test/sdk.test.ts b/packages/vue/test/sdk.test.ts new file mode 100644 index 000000000000..4473ea1175d2 --- /dev/null +++ b/packages/vue/test/sdk.test.ts @@ -0,0 +1,63 @@ +import { getRouteProvider, resolveRoute } from '@sentry/browser'; +import { getMainCarrier } from '@sentry/core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { vueIntegration } from '../src/integration'; +import { init } from '../src/sdk'; +import type { Vue } from '../src/types'; + +const DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +const router = { resolve: () => ({ matched: [{ path: '/users/:id' }] }) }; + +const app = { + config: { globalProperties: { $router: router } }, + mixin: vi.fn(), +} as unknown as Vue; + +describe('route provider registration', () => { + afterEach(() => { + vi.clearAllMocks(); + getMainCarrier().__SENTRY__ = undefined; + }); + + it('registers a route provider that reads the router off the app passed to `init`', () => { + const client = init({ dsn: DSN, app, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('registers a route provider when the app is passed to `vueIntegration`', () => { + const client = init({ dsn: DSN, defaultIntegrations: false, integrations: [vueIntegration({ app })] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('registers a route provider that picks the router up from the Vue 2 root instance', () => { + let beforeCreate: (this: unknown) => void = () => {}; + const Vue2 = { + config: {}, + mixin: (mixin: { beforeCreate: typeof beforeCreate }) => { + beforeCreate = mixin.beforeCreate; + }, + } as unknown as Vue; + const client = init({ dsn: DSN, Vue: Vue2, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(resolveRoute('https://example.com/users/42', client)).toBeUndefined(); + + beforeCreate.call({ $options: { router } }); + expect(resolveRoute('https://example.com/users/42', client)).toBe('/users/:id'); + }); + + it('does not register a route provider without an app to read the router from', () => { + const client = init({ dsn: DSN, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(getRouteProvider(client)).toBeUndefined(); + }); + + it('keeps a route provider passed by the user', () => { + const routeProvider = { resolveRoute: () => '/custom', resolveCurrentRoute: () => '/custom' }; + const client = init({ dsn: DSN, app, routeProvider, defaultIntegrations: false, integrations: [vueIntegration()] }); + + expect(getRouteProvider(client)).toBe(routeProvider); + }); +}); From c4034d4f735d9f2000651cfbb0db356a0371cd92 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 1 Oct 2026 13:58:47 +0200 Subject: [PATCH 09/57] feat(nuxt): Register nuxt route provider (#23794) Registers a route provider for Nuxt in the client plugin, outside the `__SENTRY_TRACING__` guard. Nuxt installs the router before its plugins run, so the plugin can read it straight off `nuxtApp` rather than waiting for a Vue app the way `@sentry/vue` has to. Reuses `createVueRouteProvider` from #23553's successor rather than reimplementing the vue-router resolve handling. Because it sits outside the tracing guard, route parameterization survives when tracing is tree-shaken away. Part of #23556 --- .../nuxt-5/app/pages/route-provider/[id].vue | 14 +++++++++++ .../nuxt-5/tests/route-provider.test.ts | 8 +++++++ .../nuxt/src/runtime/plugins/sentry.client.ts | 23 ++++++++++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/route-provider/[id].vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5/tests/route-provider.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/route-provider/[id].vue b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/route-provider/[id].vue new file mode 100644 index 000000000000..15ecf3348e94 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/app/pages/route-provider/[id].vue @@ -0,0 +1,14 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/route-provider.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/route-provider.test.ts new file mode 100644 index 000000000000..4796384fbb47 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/route-provider.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test'; + +// The route provider reads the router off the Nuxt app, so this fails if the plugin never registers it. +test('resolves the parameterized route through the route provider', async ({ page }) => { + await page.goto('/route-provider/123'); + + await expect(page.locator('#resolved-route')).toHaveText('/route-provider/:id()'); +}); diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 6d03b3cd9625..92c9478b7079 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -1,5 +1,11 @@ import { getClient, GLOBAL_OBJ } from '@sentry/core'; -import { browserTracingIntegration, vueIntegration } from '@sentry/vue'; +import { + browserTracingIntegration, + _INTERNAL_createVueRouteProvider, + getRouteProvider, + setRouteProvider, + vueIntegration, +} from '@sentry/vue'; import { defineNuxtPlugin, isNuxtError } from 'nuxt/app'; import type { GlobalObjWithIntegrationOptions } from '../../client/vueIntegration'; import { reportNuxtError } from '../utils'; @@ -28,6 +34,8 @@ interface VueRouter { beforeEach: (fn: (to: Route, from: Route, next?: () => void) => void) => void; } +type VueRouteProviderRouter = ReturnType[0]>; + // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -35,6 +43,19 @@ export default defineNuxtPlugin({ name: 'sentry-client-integrations', dependsOn: ['sentry-client-config'], async setup(nuxtApp) { + // Registered outside the tracing guard, because route parameterization should not depend on + // tracing: anything that needs a route name (bfcache metrics, web vitals) can resolve one even + // when tracing is tree-shaken away. Nuxt installs the router before its plugins run, so unlike + // `@sentry/vue` this can read it straight off `nuxtApp`. + const client = getClient(); + // A `routeProvider` passed to `Sentry.init` is the user's choice, so it is left in place. + if (client && '$router' in nuxtApp && !getRouteProvider(client)) { + setRouteProvider( + _INTERNAL_createVueRouteProvider(() => nuxtApp.$router as VueRouteProviderRouter), + client, + ); + } + // This evaluates to true unless __SENTRY_TRACING__ is text-replaced with "false", in which case everything inside // will get tree-shaken away if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { From 1e5b1bf1ea231733e85e88372e5c827afbb16c3a Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:08:41 +0200 Subject: [PATCH 10/57] feat(nextjs): Add `cache_origin` span links to `use cache` hit spans (#24821) A `"use cache"` hit tells you nothing about where the value came from. This PR connects the two traces: a `cache.get` hit span now carries a span link (`sentry.link.type: 'cache_origin'`) to the `cache.put` span of the request that filled the entry. This covers cached functions, components, and layouts that Next reads through its cache handlers at request time. That's `"use cache"` in route handlers and in dynamically rendered pages. Successful cache fills are remembered in a bounded per-process `LRUMap`. When the cache origin is unknown, the hit span gets no link. Closes https://github.com/getsentry/sentry-javascript/issues/24294 Linear: https://linear.app/getsentry/issue/JS-3656/link-cache-hits-to-the-trace-that-filled-the-cache-entry --- CHANGELOG.md | 6 + .../tests/cacheOriginLinks-nesting.spec.ts | 14 +- .../tests/cacheOriginLinks-page.spec.ts | 11 +- .../cacheOriginLinks-routeHandler.spec.ts | 10 +- .../tests/cacheOriginLinks-nesting.spec.ts | 14 +- .../tests/cacheOriginLinks-page.spec.ts | 11 +- .../cacheOriginLinks-routeHandler.spec.ts | 10 +- .../src/server/useCacheInstrumentation.ts | 125 +++++++++++-- .../server/useCacheInstrumentation.test.ts | 170 +++++++++++++++++- 9 files changed, 303 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec252e25122e..4a8a3b701211 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,12 @@ Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, @EmileBrunelle, and @diobriggs. Thank you for your contributions! +### Important Changes + +- **feat(nextjs): Add `cache_origin` span links to `use cache` hit spans ([#24821](https://github.com/getsentry/sentry-javascript/pull/24821))** + + A `cache.get` span for a `"use cache"` hit now carries a span link (`sentry.link.type: 'cache_origin'`) to the `cache.put` span of the request that filled the cache entry, connecting the trace that reads a cached value to the trace that produced it. + ## 11.1.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index 5025d7d2e104..9c6245a06f5c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -3,15 +3,12 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. -// Not implemented yet — every test is `test.fail()` with the final expected assertions. // A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] -// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the -// cache handlers, there is no `cache.get` span at all — then this stays failing until Next -// exposes RDC reads. +// param. These runtime-filled entries are read through the cache handlers here; entries served +// from the prerendered shell (Resume Data Cache) would produce no `cache.get` span at all until +// Next exposes RDC reads. test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -56,8 +53,6 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -101,7 +96,6 @@ test('links a cached leaf component under dynamic layouts to the trace that fill // spans point at two different origin traces. test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); @@ -180,8 +174,6 @@ test('links two cached levels to different origin traces after the layout expire test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const fillTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts index a0acf25df078..6d3f36e297d0 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -4,15 +4,13 @@ import { waitForTransaction } from '@sentry-internal/test-utils'; // Origin links for `use cache` inside rendered pages (cached components and nested cached // functions). Target behavior: a cache hit records a `cache.get` span carrying a // `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled -// the entry. Not implemented yet — every test is `test.fail()`. +// the entry. // Two cached sibling components are two cache entries (props are part of the key), so one request -// carries one `cache.get` hit span per section, each linking to its own fill. The components sit -// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach -// the cache handlers and produce no spans until Next.js exposes RDC reads. +// carries one `cache.get` hit span per section, each linking to its own fill. These runtime-filled entries are read +// through the cache handlers; entries served from the prerendered shell (Resume Data Cache) would +// produce no spans at all until Next.js exposes RDC reads. test('links each sibling component hit to the fill of its own entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missTxPromise = waitForTransaction('nextjs-16-cacheComponents', transactionEvent => { @@ -65,7 +63,6 @@ test('links each sibling component hit to the fill of its own entry', async ({ r test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts index 2f604f0402f3..e1e7a468f7cd 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -1,14 +1,11 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction } from '@sentry-internal/test-utils'; -// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a -// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` -// span of the trace that filled the entry; unknown origin means no link. Not implemented yet — -// every test is `test.fail()`; shipping the feature should only require deleting those lines. +// Origin links for `use cache` in route handlers: a cache hit records a `cache.get` span +// carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace +// that filled the entry; unknown origin means no link. test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => { - test.fail(); - // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key) // even when the test is retried against the same server. const id = crypto.randomUUID(); @@ -55,7 +52,6 @@ test('links a route handler cache hit to the trace that filled the entry', async test('moves the origin link to the refill trace after the entry expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts index d456686f24ec..abd984c429dc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-nesting.spec.ts @@ -4,15 +4,12 @@ import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks- // Origin links (`sentry.link.type: 'cache_origin'` on `cache.get` hit spans, pointing at the // filling `cache.put`) for `use cache` in nested layout trees under `app/(cached-nesting)/`. -// Not implemented yet — every test is `test.fail()` with the final expected assertions. // A `use cache` layout between dynamic segments. The layout entry is keyed by the awaited [id] -// param. If Next serves the entry from the prerendered shell (Resume Data Cache) instead of the -// cache handlers, there is no `cache.get` span at all — then this stays failing until Next -// exposes RDC reads. +// param. These runtime-filled entries are read through the cache handlers here; entries served +// from the prerendered shell (Resume Data Cache) would produce no `cache.get` span at all until +// Next exposes RDC reads. test('links a cached layout hit to the trace that filled the layout entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -57,8 +54,6 @@ test('links a cached layout hit to the trace that filled the layout entry', asyn test('links a cached leaf component under dynamic layouts to the trace that filled the leaf entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -102,7 +97,6 @@ test('links a cached leaf component under dynamic layouts to the trace that fill // spans point at two different origin traces. test('links two cached levels to different origin traces after the layout expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); @@ -185,8 +179,6 @@ test('links two cached levels to different origin traces after the layout expire test('links a shared layout hit on a sibling route to the trace of the route that filled the entry', async ({ request, }) => { - test.fail(); - const id = crypto.randomUUID(); const fillSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts index fd8a86a5c848..0bac1aeec817 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-page.spec.ts @@ -5,15 +5,13 @@ import { CACHE_ORIGIN_LINK_ATTRIBUTES } from './cacheOriginLinks-utils'; // Origin links for `use cache` inside rendered pages (cached components and nested cached // functions). Target behavior: a cache hit records a `cache.get` span carrying a // `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace that filled -// the entry. Not implemented yet — every test is `test.fail()`. +// the entry. // Two cached sibling components are two cache entries (props are part of the key), so one request -// carries one `cache.get` hit span per section, each linking to its own fill. The components sit -// in a dynamic hole: entries served from the prerendered shell (Resume Data Cache) never reach -// the cache handlers and produce no spans until Next.js exposes RDC reads. +// carries one `cache.get` hit span per section, each linking to its own fill. These runtime-filled entries are read +// through the cache handlers; entries served from the prerendered shell (Resume Data Cache) would +// produce no spans at all until Next.js exposes RDC reads. test('links each sibling component hit to the fill of its own entry', async ({ request }) => { - test.fail(); - const id = crypto.randomUUID(); const missSpansPromise = collectStreamedSpans('nextjs-16-streaming-cacheComponents', spansOfTrace => { @@ -70,7 +68,6 @@ test('links each sibling component hit to the fill of its own entry', async ({ r test("links a nested cache hit inside another entry's refill to the original fill trace", async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts index 09148406915d..cf1d5351d49b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-streaming-cacheComponents/tests/cacheOriginLinks-routeHandler.spec.ts @@ -2,14 +2,11 @@ import { expect, test } from '@playwright/test'; import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; import { CACHE_ORIGIN_LINK_ATTRIBUTES, findCacheSpan } from './cacheOriginLinks-utils'; -// Origin links for `use cache` in route handlers. Target behavior: a cache hit records a -// `cache.get` span carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` -// span of the trace that filled the entry; unknown origin means no link. Not implemented yet — -// every test is `test.fail()`; shipping the feature should only require deleting those lines. +// Origin links for `use cache` in route handlers: a cache hit records a `cache.get` span +// carrying a `sentry.link.type: 'cache_origin'` span link to the `cache.put` span of the trace +// that filled the entry; unknown origin means no link. test('links a route handler cache hit to the trace that filled the entry', async ({ request }) => { - test.fail(); - // A fresh id makes the first request a guaranteed cache miss (the id is part of the cache key) // even when the test is retried against the same server. const id = crypto.randomUUID(); @@ -56,7 +53,6 @@ test('links a route handler cache hit to the trace that filled the entry', async test('moves the origin link to the refill trace after the entry expires', async ({ request }) => { test.skip(process.env.TEST_ENV !== 'production', 'Entries are only discarded at `expire` in production'); - test.fail(); const id = crypto.randomUUID(); diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index 124e2c8b13ca..399b00722877 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -1,7 +1,15 @@ import { createHash } from 'node:crypto'; -import { CACHE_ITEM_AGE, CACHE_OPERATION, CACHE_TAGS, CACHE_TTL } from '@sentry/conventions/attributes'; +import { + CACHE_HIT, + CACHE_ITEM_AGE, + CACHE_KEY, + CACHE_OPERATION, + CACHE_TAGS, + CACHE_TTL, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT } from '@sentry/conventions/op'; -import type { Span } from '@sentry/core'; +import type { Span, SpanContextData } from '@sentry/core'; import { CACHE_OPERATION_NAMES, debug, @@ -11,9 +19,8 @@ import { getClient, hasSpanStreamingEnabled, hasSpansEnabled, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + LRUMap, + SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanIsSampled, startSpan, timestampInSeconds, @@ -27,9 +34,15 @@ const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); +const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins'); const INTEGRATION_NAME = 'NextjsUseCache'; const CACHE_SPAN_ORIGIN = 'auto.cache.nextjs'; +const CACHE_ORIGIN_LINK_TYPE = 'cache_origin'; + +// Hard memory bound for remembered fill origins: 2000 entries of a 12-char digest plus one span +// context stay well under 1 MB. +const CACHE_ORIGINS_MAX_SIZE = 2_000; // Next.js' `INFINITE_CACHE` sentinel. An `expire` at or above it means "never expires", which carries no signal as a TTL attribute. // https://github.com/vercel/next.js/blob/ed1aab5d386d07ee2f553107dd39995251a6e44e/packages/next/src/lib/constants.ts#L43-L46 @@ -62,8 +75,15 @@ type GlobalWithCacheHandlers = typeof globalThis & { [NEXT_PRIVATE_CACHE_HANDLER]?: UseCacheHandler; [SENTRY_CACHE_INSTRUMENTED]?: boolean; [SENTRY_WRAPPED_HANDLERS]?: WeakSet; + [SENTRY_CACHE_ORIGINS]?: LRUMap; }; +interface CacheOrigin { + context: SpanContextData; + /** The fill's `CacheEntry.timestamp`. Identifies the entry revision this origin wrote. */ + entryTimestamp: number; +} + /** * Cache keys are long serialized payloads (function id + arguments), so spans carry a digest * instead. This bounds span size, avoids leaking user data, and still groups identical keys. @@ -82,9 +102,8 @@ function shouldRecordCacheSpan(): boolean { return !!activeSpan && spanIsSampled(activeSpan); } -function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: string, callback: (span: Span) => T): T { +function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, digest: string, callback: (span: Span) => T): T { const client = getClient(); - const digest = keyDigest(cacheKey); return startSpan( { @@ -92,8 +111,8 @@ function startCacheSpan(op: typeof CACHE_GET | typeof CACHE_PUT, cacheKey: st name: client && hasSpanStreamingEnabled(client) ? op : digest, op, attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: [digest], + [SENTRY_ORIGIN]: CACHE_SPAN_ORIGIN, + [CACHE_KEY]: [digest], [CACHE_OPERATION]: CACHE_OPERATION_NAMES[op], }, }, @@ -115,18 +134,19 @@ function isExpired(ageMs: number | undefined, expire: number | undefined): boole /** * A missing entry is a miss. Next.js' default handler also returns no entry for expired, evicted, - * or tag-invalidated entries, so those count as misses too. + * or tag-invalidated entries, so those count as misses too. Returns whether the read was a hit. */ -function setEntryAttributes(span: Span, entry: unknown): void { +function recordCacheEntry(span: Span, entry: unknown): boolean { if (entry === undefined) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, false); - return; + span.setAttribute(CACHE_HIT, false); + return false; } const { timestamp, expire, tags } = (entry ?? {}) as UseCacheEntry; const ageMs = typeof timestamp === 'number' ? timestampInSeconds() * 1000 - timestamp : undefined; - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isExpired(ageMs, expire)); + const hit = !isExpired(ageMs, expire); + span.setAttribute(CACHE_HIT, hit); if (ageMs !== undefined) { // Clamped: with a remote handler, the filling and the reading machine's clocks can drift. @@ -140,6 +160,56 @@ function setEntryAttributes(span: Span, entry: unknown): void { if (stringTags.length > 0) { span.setAttribute(CACHE_TAGS, stringTags); } + return hit; +} + +// Cache origins are scoped per-process (known limitation: no links across server instances — that would require storing metadata on `CacheEntry`). +// Hits on entries filled by another instance get no link. Lives on `globalThis` like the rest of the instrumentation state. +function getCacheOrigins(): LRUMap { + const globalWithCacheHandlers = globalThis as GlobalWithCacheHandlers; + if (!globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]) { + globalWithCacheHandlers[SENTRY_CACHE_ORIGINS] = new LRUMap(CACHE_ORIGINS_MAX_SIZE); + } + return globalWithCacheHandlers[SENTRY_CACHE_ORIGINS]; +} + +/** + * Links a cache hit to the fill that wrote this entry revision (matched by timestamp). + * A hit with no matching origin (unsampled or dropped refill, another instance, process restart) + * gets no link instead of a stale one. + */ +function linkCacheOrigin(span: Span, originKey: string, entry: unknown): void { + const origin = getCacheOrigins().get(originKey); + const { timestamp } = (entry ?? {}) as UseCacheEntry; + if (origin && origin.entryTimestamp === timestamp) { + span.addLink({ + context: origin.context, + attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, + }); + } +} + +/** + * Remembers the `cache.put` span as the origin of the entry revision it wrote. The handler drains + * `pendingEntry` before the write resolves, so the read here resolves immediately. + * An entry without a numeric `timestamp` has no revision identity and is not remembered. + */ +function rememberCacheOrigin(originKey: string, span: Span, pendingEntry: Promise): void { + Promise.resolve(pendingEntry).then( + entry => { + try { + const { timestamp } = (entry ?? {}) as UseCacheEntry; + if (typeof timestamp === 'number') { + getCacheOrigins().set(originKey, { context: span.spanContext(), entryTimestamp: timestamp }); + } + } catch (error) { + DEBUG_BUILD && debug.warn('Failed to remember a Next.js cache fill origin', error); + } + }, + () => { + // A rejected entry was never stored, so there is nothing to remember. + }, + ); } function isCacheHandler(value: unknown): value is UseCacheHandler { @@ -160,6 +230,10 @@ function getWrappedHandlers(): WeakSet { return globalWithCacheHandlers[SENTRY_WRAPPED_HANDLERS]; } +// One origin namespace per wrapped handler, so identical cache keys in different cache stores +// (default, remote, private) can never link across stores. +let wrappedHandlerCount = 0; + function instrumentHandler(handler: unknown): void { // Runs inside Next.js' handler registration, which must never fail because of Sentry. try { @@ -168,17 +242,21 @@ function instrumentHandler(handler: unknown): void { return; } wrappedHandlers.add(handler); + const originKeyPrefix = `${wrappedHandlerCount++}:`; fill(handler, 'get', (originalGet: UseCacheHandler['get']) => { return function (this: UseCacheHandler, cacheKey: string, softTags?: string[]): Promise { if (!shouldRecordCacheSpan()) { return originalGet.call(this, cacheKey, softTags); } - return startCacheSpan(CACHE_GET, cacheKey, span => + const digest = keyDigest(cacheKey); + return startCacheSpan(CACHE_GET, digest, span => // `Promise.resolve` because custom handlers may return the entry synchronously. Promise.resolve(originalGet.call(this, cacheKey, softTags)).then(entry => { try { - setEntryAttributes(span, entry); + if (recordCacheEntry(span, entry)) { + linkCacheOrigin(span, originKeyPrefix + digest, entry); + } } catch (error) { DEBUG_BUILD && debug.warn('Failed to read Next.js cache entry metadata', error); } @@ -193,9 +271,21 @@ function instrumentHandler(handler: unknown): void { if (!shouldRecordCacheSpan()) { return originalSet.call(this, cacheKey, pendingEntry); } + + const digest = keyDigest(cacheKey); // The handler drains `pendingEntry` (the still-streaming entry) before storing, so this // span covers producing and storing the entry, not just the write. - return startCacheSpan(CACHE_PUT, cacheKey, () => originalSet.call(this, cacheKey, pendingEntry)); + return startCacheSpan(CACHE_PUT, digest, span => + // Only a successful write becomes a fill origin: a failed write leaves no entry or the + // previous one (whose origin still stands). A dropped span (`ignoreSpans`) never + // reaches Sentry, so a link to it would be broken. + Promise.resolve(originalSet.call(this, cacheKey, pendingEntry)).then(result => { + if (span.isRecording()) { + rememberCacheOrigin(originKeyPrefix + digest, span, pendingEntry); + } + return result; + }), + ); }; }); } catch (error) { @@ -276,6 +366,7 @@ export function _instrumentUseCacheHandlers(): void { /** * Wraps Next.js' `use cache` handlers with `cache.get`/`cache.put` spans, so cached function * reads and fills show up in traces with hit/miss information. + * A hit also adds a `cache_origin` span link to the `cache.put` span of the trace that filled the entry. */ export const nextjsUseCacheIntegration = defineIntegration(() => { return { diff --git a/packages/nextjs/test/server/useCacheInstrumentation.test.ts b/packages/nextjs/test/server/useCacheInstrumentation.test.ts index 29763cdaadb9..51037a67c005 100644 --- a/packages/nextjs/test/server/useCacheInstrumentation.test.ts +++ b/packages/nextjs/test/server/useCacheInstrumentation.test.ts @@ -3,9 +3,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => { const setAttribute = vi.fn(); + const addLink = vi.fn(); + const state = { spanCount: 0, recording: true }; return { setAttribute, - startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => callback({ setAttribute })), + addLink, + state, + startSpan: vi.fn((_options: unknown, callback: (span: unknown) => unknown) => { + const n = ++state.spanCount; + const spanContext = { traceId: `trace-${n}`, spanId: `span-${n}`, traceFlags: 1 }; + const recording = state.recording; + return callback({ setAttribute, addLink, spanContext: () => spanContext, isRecording: () => recording }); + }), activeSpan: undefined as object | undefined, sampled: true, client: undefined as { getOptions: () => { traceLifecycle?: 'static' | 'stream' } } | undefined, @@ -26,6 +35,7 @@ const NEXT_CACHE_HANDLERS_MAP = Symbol.for('@next/cache-handlers-map'); const NEXT_PRIVATE_CACHE_HANDLER = Symbol.for('@next/cache-handlers-private'); const SENTRY_CACHE_INSTRUMENTED = Symbol.for('sentry.nextjs.cacheHandlersInstrumented'); const SENTRY_WRAPPED_HANDLERS = Symbol.for('sentry.nextjs.wrappedCacheHandlers'); +const SENTRY_CACHE_ORIGINS = Symbol.for('sentry.nextjs.cacheOrigins'); function createHandler(entry?: unknown) { return { @@ -55,6 +65,8 @@ describe('instrumentUseCacheHandlers', () => { mocks.activeSpan = {}; mocks.sampled = true; mocks.client = undefined; + mocks.state.spanCount = 0; + mocks.state.recording = true; }); afterEach(() => { @@ -67,6 +79,7 @@ describe('instrumentUseCacheHandlers', () => { NEXT_PRIVATE_CACHE_HANDLER, SENTRY_CACHE_INSTRUMENTED, SENTRY_WRAPPED_HANDLERS, + SENTRY_CACHE_ORIGINS, ]) { Reflect.deleteProperty(globalThis, symbol); } @@ -316,6 +329,161 @@ describe('instrumentUseCacheHandlers', () => { ); }); + describe('origin links', () => { + it('links a cache hit to the `cache.put` span of the fill', async () => { + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); + + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + expect(mocks.addLink).toHaveBeenCalledWith({ + context: { traceId: 'trace-1', spanId: 'span-1', traceFlags: 1 }, + attributes: { 'sentry.link.type': 'cache_origin' }, + }); + }); + + it('links to the most recent fill', async () => { + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); + + await handler.set('cache-key', Promise.resolve({ timestamp: entry.timestamp - 1_000 })); + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledWith( + expect.objectContaining({ context: { traceId: 'trace-2', spanId: 'span-2', traceFlags: 1 } }), + ); + }); + + it('does not link a miss', async () => { + const handler = installWithDefaultHandler(undefined); + + await handler.set('cache-key', Promise.resolve({ timestamp: nowMs() })); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit whose fill is unknown', async () => { + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit to a fill of the same cache key in a different handler', async () => { + const entry = { timestamp: nowMs() }; + const defaultHandler = createHandler(entry); + const remoteHandler = createHandler(entry); + setGlobal( + NEXT_CACHE_HANDLERS_MAP, + new Map([ + ['default', defaultHandler], + ['remote', remoteHandler], + ]), + ); + _instrumentUseCacheHandlers(); + + await defaultHandler.set('cache-key', Promise.resolve(entry)); + await remoteHandler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + + await defaultHandler.get('cache-key'); + + expect(mocks.addLink).toHaveBeenCalledTimes(1); + }); + + it('does not link a hit that was filled under a different cache key', async () => { + const entry = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entry); + + await handler.set('other-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit on an entry refilled without a sampled parent span', async () => { + const refill = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(refill); + + await handler.set('cache-key', Promise.resolve({ timestamp: refill.timestamp - 1_000 })); + + mocks.activeSpan = undefined; + await handler.set('cache-key', Promise.resolve(refill)); + mocks.activeSpan = {}; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit on an entry whose refill `cache.put` span is not recording', async () => { + const refill = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(refill); + + await handler.set('cache-key', Promise.resolve({ timestamp: refill.timestamp - 1_000 })); + + // e.g. the `cache.put` op is filtered via `ignoreSpans` + mocks.state.recording = false; + await handler.set('cache-key', Promise.resolve(refill)); + mocks.state.recording = true; + + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link a hit on an entry refilled by another server instance', async () => { + const entryFromOtherInstance = { timestamp: nowMs() }; + const handler = installWithDefaultHandler(entryFromOtherInstance); + + await handler.set('cache-key', Promise.resolve({ timestamp: entryFromOtherInstance.timestamp - 1_000 })); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not link hits on entries that carry no fill timestamp', async () => { + const entry = { expire: 3_600 }; + const handler = installWithDefaultHandler(entry); + + await handler.set('cache-key', Promise.resolve(entry)); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose entry rejected even though the write resolved', async () => { + // Custom handlers can swallow a failed entry and resolve the write anyway. + const handler = installWithDefaultHandler({ timestamp: nowMs() }); + + await handler.set('cache-key', Promise.reject(new Error('entry failed'))); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + + it('does not remember fills whose write failed', async () => { + const entry = { timestamp: nowMs() }; + const handler = { + get: vi.fn(() => Promise.resolve(entry)), + set: vi.fn(() => Promise.reject(new Error('write failed'))), + }; + setGlobal(NEXT_CACHE_HANDLERS_MAP, new Map([['default', handler]])); + _instrumentUseCacheHandlers(); + + await expect(handler.set('cache-key', Promise.resolve(entry))).rejects.toThrow('write failed'); + await handler.get('cache-key'); + + expect(mocks.addLink).not.toHaveBeenCalled(); + }); + }); + it('creates a `cache.put` span around handler writes', async () => { const handler = installWithDefaultHandler(); From db0854ed4f0ed8536da36d299928699af0ee4cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 1 Oct 2026 15:10:22 +0300 Subject: [PATCH 11/57] test(cloudflare): Add Flue E2E test that deploys a real Worker and sends to Sentry (#24816) test(cloudflare): Add Flue E2E test that deploys a real Worker and sends to Sentry --- .github/workflows/cleanup-e2e-workers.yml | 1 + dev-packages/e2e-tests/README.md | 18 ++++ .../cloudflare-flue-send-to-sentry/.gitignore | 5 ++ .../flue.config.ts | 0 .../global-setup.ts | 6 ++ .../global-teardown.ts | 1 + .../package.json | 17 ++-- .../playwright.config.ts | 16 ++++ .../src/agents/hello.ts | 4 +- .../src/app.ts | 4 + .../src/auth.ts | 14 +++ .../src/cloudflare.ts | 0 .../src/env.d.ts | 1 + .../src/loaders.ts | 0 .../src/sentry.ts | 3 +- .../tests/dataloader.test.ts | 47 ++++++++++ .../tests/errors.test.ts | 52 +++++++++++ .../tests/flue.test.ts | 86 +++++++++++++++++++ .../tests/utils.ts | 38 ++++++++ .../tsconfig.json | 1 + .../tsconfig.node.json | 8 ++ .../vite.config.mts | 0 .../wrangler.jsonc | 12 +++ .../cloudflare-flue/playwright.config.ts | 16 ---- .../cloudflare-flue/start-event-proxy.mjs | 6 -- .../cloudflare-flue/tests/dataloader.test.ts | 31 ------- .../cloudflare-flue/tests/errors.test.ts | 24 ------ .../cloudflare-flue/tests/flue.test.ts | 59 ------------- .../cloudflare-flue/tests/utils.ts | 35 -------- .../cloudflare-flue/wrangler.jsonc | 11 --- dev-packages/test-utils/src/cli.ts | 56 ++++++++++++ dev-packages/test-utils/src/cloudflare.ts | 26 +++++- 32 files changed, 400 insertions(+), 198 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/.gitignore rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/flue.config.ts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-setup.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-teardown.ts rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/package.json (64%) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/playwright.config.ts rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/src/agents/hello.ts (95%) rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/src/app.ts (53%) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/auth.ts rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/src/cloudflare.ts (100%) rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/src/env.d.ts (68%) rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/src/loaders.ts (100%) rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/src/sentry.ts (84%) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/dataloader.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/utils.ts rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/tsconfig.json (92%) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.node.json rename dev-packages/e2e-tests/test-applications/{cloudflare-flue => cloudflare-flue-send-to-sentry}/vite.config.mts (100%) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/wrangler.jsonc delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue/playwright.config.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/dataloader.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/errors.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/flue.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/utils.ts delete mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-flue/wrangler.jsonc diff --git a/.github/workflows/cleanup-e2e-workers.yml b/.github/workflows/cleanup-e2e-workers.yml index f87a387d281b..69aae225e408 100644 --- a/.github/workflows/cleanup-e2e-workers.yml +++ b/.github/workflows/cleanup-e2e-workers.yml @@ -19,6 +19,7 @@ jobs: # Name prefix of every E2E app that deploys a real worker, see the app's global-setup.ts worker-prefix: - e2e-send-to-sentry + - e2e-flue-send-to-sentry steps: - name: Set up Node uses: actions/setup-node@v7 diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 4a398a2b9002..cb2964332492 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -116,6 +116,24 @@ Make sure to add a `test:build` and `test:assert` command to the new app's `pack Sentry packages are automatically resolved to the local build via pnpm overrides injected at test time, so no manual registry configuration is needed. +### Test apps that deploy a real Cloudflare Worker + +Apps like `cloudflare-workers-send-to-sentry` deploy the built app as a real Worker and check what arrives in Sentry: + +- Use `createWorkerGlobalSetup` and `workerGlobalTeardown` from `@sentry-internal/test-utils/cloudflare` as the + Playwright global setup and teardown. Tests read the Worker URL from `E2E_TEST_WORKER_URL`, send requests with + `fetchFromWorker`, and poll Sentry with the helpers from `@sentry-internal/test-utils/cli` (the app needs `sentry` as + a dev dependency). +- The Worker gets `E2E_TEST_DSN`, `E2E_TEST_WORKER_TOKEN` and the values of the `secrets` option as secrets. +- CI keeps the Worker after the run, and anyone can reach its `workers.dev` URL. If the Worker can spend money (for + example, it holds an LLM API key), it must reject requests without `Authorization: Bearer `. + The token is new for every deploy, and `fetchFromWorker` sends it. See + `cloudflare-flue-send-to-sentry/src/auth.ts`. +- Add the `workerPrefix` to `.github/workflows/cleanup-e2e-workers.yml`, which deletes the Worker of a PR when the PR + closes. +- Set `"sentryTest": { "optional": true }` in `package.json`, because only the optional CI job has the Cloudflare + secrets. + ## Troubleshooting ### Common Issues diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/.gitignore new file mode 100644 index 000000000000..b6c344e957e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-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-flue/flue.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/flue.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/flue.config.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/flue.config.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-setup.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-setup.ts new file mode 100644 index 000000000000..631329e9f0e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-setup.ts @@ -0,0 +1,6 @@ +import { createWorkerGlobalSetup } from '@sentry-internal/test-utils/cloudflare'; + +export default createWorkerGlobalSetup({ + workerPrefix: 'e2e-flue-send-to-sentry', + secrets: { OPENROUTER_API_KEY: 'E2E_OPENROUTER_API_KEY' }, +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-teardown.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-teardown.ts new file mode 100644 index 000000000000..c7bcfe696e66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/global-teardown.ts @@ -0,0 +1 @@ +export { workerGlobalTeardown as default } from '@sentry-internal/test-utils/cloudflare'; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json similarity index 64% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/package.json rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json index ec82ef912a33..7f7be894c7e6 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json @@ -1,16 +1,15 @@ { - "name": "cloudflare-flue", + "name": "cloudflare-flue-send-to-sentry", "version": "0.0.0", "private": true, "type": "module", "scripts": { "build": "vite build", - "preview": "vite preview --port 4112", - "typecheck": "tsc --noEmit", - "clean": "npx rimraf node_modules dist .wrangler pnpm-lock.yaml", - "test:build": "pnpm install && OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY pnpm build", - "test:assert": "pnpm test:prod", - "test:prod": "TEST_ENV=production OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json", + "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": { "@flue/runtime": "2.0.5", @@ -27,9 +26,11 @@ "@flue/vite": "2.0.5", "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^24.0.0", + "sentry": "~0.44.1", "typescript": "^5.5.2", "vite": "7.3.5", - "wrangler": "^4.86.0" + "wrangler": "^4.114.0" }, "volta": { "node": "24.15.0", diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..cfb1b52d440e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/playwright.config.ts @@ -0,0 +1,16 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig(undefined, { + // The tests read what arrived in Sentry through the sentry CLI, so there is no event proxy to start. + webServer: undefined, + // The worker is deployed once for the whole run and deleted again afterwards. + globalSetup: './global-setup.ts', + globalTeardown: './global-teardown.ts', + // Each test drives a real OpenRouter turn and then waits ~2min until its spans are queryable. + timeout: 300_000, + // Every test spends most of its time polling Sentry, so run them all at once. + fullyParallel: true, + workers: '100%', +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/agents/hello.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/agents/hello.ts similarity index 95% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/agents/hello.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/agents/hello.ts index df0589a1988e..4671208793f3 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/agents/hello.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/agents/hello.ts @@ -17,9 +17,9 @@ export function Hello() { 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 }) => + run: ({ data }) => Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.source': 'static-table' } }, () => { - return `It is 21 degrees and sunny in ${city}.`; + return `It is 21 degrees and sunny in ${data.city}.`; }), }); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/app.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/app.ts similarity index 53% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/app.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/app.ts index 613e9453c506..974c275b185e 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/app.ts @@ -1,9 +1,13 @@ import { createAgentRouter } from '@flue/runtime/routing'; import { Hono } from 'hono'; import { Hello } from './agents/hello.ts'; +import { requireTestToken } from './auth.ts'; const app = new Hono(); +// global-setup.ts waits for this route to answer before the tests start. +app.get('/', c => c.text('Hello World!')); +app.use('/agents/*', requireTestToken); app.route('/agents/hello', createAgentRouter(Hello)); export default app; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/auth.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/auth.ts new file mode 100644 index 000000000000..a2c81dd494f1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/auth.ts @@ -0,0 +1,14 @@ +import { createMiddleware } from 'hono/factory'; + +/** + * CI keeps the Worker after the run, and it holds an OpenRouter key. So the agent routes only answer + * the test run that deployed the Worker. `createWorkerGlobalSetup` makes the token for each deploy, and + * `fetchFromWorker` sends it. + */ +export const requireTestToken = createMiddleware<{ Bindings: Env }>(async (c, next) => { + const token = c.env.E2E_TEST_WORKER_TOKEN; + if (!token || c.req.header('Authorization') !== `Bearer ${token}`) { + return c.text('Unauthorized', 401); + } + await next(); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/cloudflare.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/cloudflare.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/cloudflare.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/cloudflare.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/env.d.ts similarity index 68% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/env.d.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/env.d.ts index ecd15b570543..2bbac1d75aa7 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/env.d.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/env.d.ts @@ -1,4 +1,5 @@ interface Env { E2E_TEST_DSN: string; OPENROUTER_API_KEY: string; + E2E_TEST_WORKER_TOKEN: string; } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/loaders.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/loaders.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/loaders.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/loaders.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/sentry.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/sentry.ts similarity index 84% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/src/sentry.ts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/sentry.ts index 5cbc2856bfff..c983d58861ab 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/src/sentry.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/src/sentry.ts @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/cloudflare'; // Each Flue agent runs in its own Durable Object, so the DO class is what has to be wrapped for // `Sentry.init` to run and spans to be flushed. The agent module re-exports this as `cloudflare`, -// which is how Flue picks it up — defining it here alone does nothing. +// which is how Flue picks it up, defining it here alone does nothing. // // There is deliberately no `instrument()` call in this app: registering the Flue instrumentation is // what `@sentry/cloudflare/vite` does at build time, and these tests exist to prove it. @@ -13,7 +13,6 @@ export const cloudflare = extend({ (env: Env) => ({ dsn: env.E2E_TEST_DSN, environment: 'qa', - tunnel: 'http://localhost:3031/', // proxy server tracesSampleRate: 1.0, }), Final, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/dataloader.test.ts new file mode 100644 index 000000000000..defcd51510d4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/dataloader.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findTraceIdOfSpan, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn } from './utils'; + +// Set by global-setup.ts once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL!; + +/** + * On Cloudflare orchestrion runs at build time (`@sentry/cloudflare/vite` injects the channels), + * so unlike the Node app there is no `--import` bootstrap and no variant: the span is either there + * or the build-time instrumentation regressed. + */ +test('captures orchestrion-instrumented dataloader spans in the same trace as the AI spans', async () => { + const conversationId = await runAgentTurn( + workerUrl, + newInstanceId('dataloader'), + 'Please call count_items to count the items.', + ); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`gen_ai.conversation.id:${conversationId}`)), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + console.log(`Polling for the dataloader span: sentry trace view ${traceTarget(traceId!)}`); + + let spans: TraceItem[] = []; + await expect + .poll(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.op), EVENT_POLLING_OPTIONS) + .toEqual(expect.arrayContaining(['gen_ai.execute_tool', 'cache.get'])); + + const dataloaderSpan = spans.find(span => span.op === 'cache.get'); + const toolSpan = spans.find(span => span.description === 'execute_tool count_items'); + + await expect + .poll(() => fetchSpanAttributes(traceId!, dataloaderSpan!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ origin: 'auto.db.dataloader' }); + expect(toolSpan).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/errors.test.ts new file mode 100644 index 000000000000..6b7258deed4d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/errors.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchEvent, + fetchSpanAttributes, + fetchTrace, + findErrorInTrace, + findTraceIdOfSpan, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn } from './utils'; + +// Set by global-setup.ts once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL!; + +test('captures an error thrown inside a Flue tool and marks its span errored', async () => { + const conversationId = await runAgentTurn( + workerUrl, + newInstanceId('failure'), + 'Please call fail_now to trigger a failure.', + ); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`gen_ai.conversation.id:${conversationId}`)), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + console.log(`Polling for the tool error: sentry trace view ${traceTarget(traceId!)}`); + + let error: TraceItem | undefined; + await expect.poll(() => (error = findErrorInTrace(traceId!)), EVENT_POLLING_OPTIONS).toBeDefined(); + await expect + .poll( + () => + fetchEvent(error!.event_id!)?.entries.find(entry => entry.type === 'exception')?.data.values?.[0]?.mechanism + ?.type, + EVENT_POLLING_OPTIONS, + ) + .toBe('auto.ai.flue'); + + let spans: TraceItem[] = []; + await expect + .poll(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.description), EVENT_POLLING_OPTIONS) + .toContain('execute_tool fail_now'); + + const executeTool = spans.find(span => span.description === 'execute_tool fail_now'); + await expect + .poll(() => fetchSpanAttributes(traceId!, executeTool!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ 'gen_ai.tool.name': 'fail_now', 'span.status': 'error' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts new file mode 100644 index 000000000000..ac6c0dab180f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/flue.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findTraceIdOfSpan, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; +import { newInstanceId, runAgentTurn } from './utils'; + +// Set by global-setup.ts once the worker for this run is deployed. +const workerUrl = process.env.E2E_TEST_WORKER_URL!; + +/** + * This app never calls `instrument()`. On Cloudflare the registration comes from the build: + * `@sentry/cloudflare/vite` provides the `@flue/runtime` binding and the orchestrion registration + * installs `flueIntegration()`. So any `gen_ai` span here is itself the proof that the auto-wiring + * worked, and a manual-registration regression shows up as an empty trace, not a wrong attribute. + */ +test('instruments a Flue agent with no manual instrument() call', async () => { + const conversationId = await runAgentTurn(workerUrl, newInstanceId('weather'), 'What is the weather in Paris?'); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`gen_ai.conversation.id:${conversationId}`)), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + console.log(`Polling for the agent spans: sentry trace view ${traceTarget(traceId!)}`); + + let spans: TraceItem[] = []; + await expect + .poll(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.op), EVENT_POLLING_OPTIONS) + .toEqual(expect.arrayContaining(['gen_ai.invoke_agent', 'gen_ai.chat', 'gen_ai.execute_tool'])); + + const invokeAgent = spans.find(span => span.op === 'gen_ai.invoke_agent'); + const chat = spans.find(span => span.op === 'gen_ai.chat'); + const executeTool = spans.find(span => span.op === 'gen_ai.execute_tool'); + + await expect + .poll(() => fetchSpanAttributes(traceId!, invokeAgent!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + origin: 'auto.ai.flue', + 'gen_ai.agent.name': 'Hello', + }); + + await expect + .poll(() => fetchSpanAttributes(traceId!, chat!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + origin: 'auto.ai.flue', + 'gen_ai.provider.name': 'openrouter', + 'gen_ai.usage.input_tokens': expect.anything(), + 'gen_ai.cost.total_tokens': expect.anything(), + }); + + await expect + .poll(() => fetchSpanAttributes(traceId!, executeTool!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ 'gen_ai.tool.name': 'get_weather' }); + expect(chat?.parent_span_id).toBe(invokeAgent?.event_id); + expect(executeTool?.parent_span_id).toBe(invokeAgent?.event_id); +}); + +test('nests a manual span raised inside a tool under that tool span', async () => { + const conversationId = await runAgentTurn(workerUrl, newInstanceId('manual-span'), 'What is the weather in Berlin?'); + + let traceId: string | undefined; + await expect + .poll(() => (traceId = findTraceIdOfSpan(`gen_ai.conversation.id:${conversationId}`)), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + console.log(`Polling for the manual span: sentry trace view ${traceTarget(traceId!)}`); + + let spans: TraceItem[] = []; + await expect + .poll(() => (spans = flattenTrace(fetchTrace(traceId!))).map(span => span.description), EVENT_POLLING_OPTIONS) + .toEqual(expect.arrayContaining(['execute_tool get_weather', 'resolve-weather'])); + + const executeTool = spans.find(span => span.op === 'gen_ai.execute_tool'); + const manualSpan = spans.find(span => span.description === 'resolve-weather'); + + await expect + .poll(() => fetchSpanAttributes(traceId!, manualSpan!.event_id!), EVENT_POLLING_OPTIONS) + .toMatchObject({ 'weather.source': 'static-table' }); + expect(manualSpan?.parent_span_id).toBe(executeTool?.event_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/utils.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/utils.ts new file mode 100644 index 000000000000..1730d7781025 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tests/utils.ts @@ -0,0 +1,38 @@ +import { fetchFromWorker } from '@sentry-internal/test-utils/cloudflare'; + +/** An agent instance id nothing has used yet, so a settled record cannot end the wait early. */ +export function newInstanceId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Run one agent turn and wait for it to settle. Returns the id of the conversation, which the SDK + * sets as `gen_ai.conversation.id` on the agent span. Flue runs the turn from a Durable Object alarm, + * and the SDK starts a new trace for every alarm, so the tests find the trace by this id. + * + * `POST /:id` only admits the work (it returns `202` and the turn runs after), so this reads the + * conversation back until it reports a settlement. + */ +export async function runAgentTurn(workerUrl: string, instanceId: string, message: string): Promise { + const url = `${workerUrl}/agents/hello/${instanceId}`; + + await fetchFromWorker(url, 202, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kind: 'user', body: message }), + }); + + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + const conversation = JSON.parse(await fetchFromWorker(url, 200)) as { + conversationId: string; + settlements?: unknown[]; + }; + if (conversation.settlements?.length) { + return conversation.conversationId; + } + await new Promise(resolve => setTimeout(resolve, 1_000)); + } + + throw new Error(`Flue turn for "${instanceId}" did not settle within 90s`); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.json similarity index 92% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/tsconfig.json rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.json index c612b8a5194a..83c877306e74 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.json @@ -4,6 +4,7 @@ "lib": ["es2021"], "module": "es2022", "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "allowJs": true, "checkJs": false, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.node.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.node.json new file mode 100644 index 000000000000..cd031756584c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "exclude": [], + "include": ["tests/**/*", "global-setup.ts", "global-teardown.ts", "playwright.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/vite.config.mts b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/vite.config.mts similarity index 100% rename from dev-packages/e2e-tests/test-applications/cloudflare-flue/vite.config.mts rename to dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/vite.config.mts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/wrangler.jsonc new file mode 100644 index 000000000000..f3883b2d5ff9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + // Placeholder only: every test run deploys under a unique name, see global-setup.ts. + "name": "cloudflare-flue-send-to-sentry", + "compatibility_date": "2026-06-01", + "compatibility_flags": ["nodejs_compat"], + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["FlueHelloAgent"] }], + "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 }, + "secrets": { "required": ["E2E_TEST_DSN", "OPENROUTER_API_KEY", "E2E_TEST_WORKER_TOKEN"] }, +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/playwright.config.ts deleted file mode 100644 index a11f1656965f..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/playwright.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -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: 'pnpm preview', port: 4112 }, - // Each test drives a real OpenRouter 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/cloudflare-flue/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-flue/start-event-proxy.mjs deleted file mode 100644 index a24f2d9a08a0..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'cloudflare-flue', -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/dataloader.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/dataloader.test.ts deleted file mode 100644 index 0193e0febbd0..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/dataloader.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import { newConversationId, runAgentTurn } from './utils'; - -const APP = 'cloudflare-flue'; - -const isDataloaderSpan = (span: { attributes?: Record }): boolean => - span.attributes?.['sentry.origin']?.value === 'auto.db.dataloader'; - -/** - * On Cloudflare orchestrion runs at build time — `@sentry/cloudflare/vite` injects the channels — - * so unlike the Node app there is no `--import` bootstrap and no variant: the span is either there - * or the build-time instrumentation regressed. - */ -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'); - - expect(getSpanOp(dataloaderSpan!)).toBe('cache.get'); - expect(dataloaderSpan?.trace_id).toBe(toolSpan?.trace_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/errors.test.ts deleted file mode 100644 index 98a12ebc4c3a..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/errors.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, waitForError } from '@sentry-internal/test-utils'; -import { newConversationId, runAgentTurn } from './utils'; - -const APP = 'cloudflare-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]?.mechanism?.type).toBe('auto.ai.flue'); - - 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/cloudflare-flue/tests/flue.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/flue.test.ts deleted file mode 100644 index 41142178aebd..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/flue.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { collectStreamedSpans, getSpanOp } from '@sentry-internal/test-utils'; -import { newConversationId, runAgentTurn } from './utils'; - -const APP = 'cloudflare-flue'; - -type SpanLike = { name?: string; attributes?: Record }; - -const usedTool = (toolName: string) => (spansOfTrace: SpanLike[]) => - spansOfTrace.some(span => span.attributes?.['gen_ai.tool.name']?.value === toolName); - -/** - * This app never calls `instrument()`. On Cloudflare the registration comes from the build: - * `@sentry/cloudflare/vite` provides the `@flue/runtime` binding and the orchestrion registration - * installs `flueIntegration()`. So any `gen_ai` span here is itself the proof that the auto-wiring - * worked — a manual-registration regression shows up as an empty trace, not a wrong attribute. - */ -test('instruments a Flue agent with no manual instrument() call', async ({ baseURL }) => { - const spansPromise = collectStreamedSpans( - APP, - spansOfTrace => - spansOfTrace.some(span => getSpanOp(span) === 'gen_ai.invoke_agent') && 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.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.cost.total_tokens']?.value).toBe('number'); - - expect(executeTool?.attributes?.['gen_ai.tool.name']?.value).toBe('get_weather'); - 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?.parent_span_id).toBe(executeTool?.span_id); -}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/utils.ts b/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/utils.ts deleted file mode 100644 index 4a3932ada389..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/tests/utils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { expect } from '@playwright/test'; - -/** A conversation id nothing has used yet, so a settled record cannot end the wait early. */ -export function newConversationId(prefix: string): string { - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -/** - * Run one agent turn and wait for it to settle. - * - * `POST /:id` only admits the work — it returns `202` and the turn runs after — so this reads the - * conversation back until it reports a settlement. - */ -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/cloudflare-flue/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-flue/wrangler.jsonc deleted file mode 100644 index 273779ec6976..000000000000 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue/wrangler.jsonc +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "cloudflare-flue", - "compatibility_date": "2026-06-01", - "compatibility_flags": ["nodejs_compat"], - "migrations": [{ "tag": "v1", "new_sqlite_classes": ["FlueHelloAgent"] }], - // `vite build` snapshots these from the build-time environment into `dist/.dev.vars`, and - // `vite preview` reads that file in preference to its own environment. So both have to be set - // for the build, which is why `test:build` maps `E2E_OPENROUTER_API_KEY` across. - "secrets": { "required": ["E2E_TEST_DSN", "OPENROUTER_API_KEY"] }, -} diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts index 1feffa3761f2..7093ce4ce7a1 100644 --- a/dev-packages/test-utils/src/cli.ts +++ b/dev-packages/test-utils/src/cli.ts @@ -14,6 +14,8 @@ export const EVENT_POLLING_OPTIONS = { timeout: 180_000, intervals: [5_000] }; export interface TraceItem { /** On a span this is the span id. */ event_id?: string; + /** On a span this is the span id of its parent span. */ + parent_span_id?: string | null; event_type?: 'span' | 'error' | 'occurrence' | 'uptime_check'; op?: string | null; /** On a span this is the span name. */ @@ -41,6 +43,9 @@ function runSentryCli(args: string[]): SpawnSyncReturns { // 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', + // Every call polls for data that is still arriving. `sentry api` has no `--fresh` flag and would + // otherwise answer every poll from the cached response of the first one. + SENTRY_NO_CACHE: '1', }, }); @@ -112,6 +117,57 @@ export function fetchSpanAttributes(traceId: string, spanId: string): Record; } @@ -138,6 +138,11 @@ function getAppDir(config: FullConfig): string { /** * Returns a Playwright global setup that deploys the built test app as a real Worker. The tests read * its URL from `E2E_TEST_WORKER_URL`, and {@link workerGlobalTeardown} deletes it again. + * + * The Worker also gets the secret `E2E_TEST_WORKER_TOKEN`, a random value that is new for every deploy + * and that {@link fetchFromWorker} sends as `Authorization: Bearer `. CI keeps its Workers after + * the run, so a Worker that can spend money (for example with an LLM API key) must reject requests + * without this token. */ export function createWorkerGlobalSetup(options: WorkerGlobalSetupOptions): (config: FullConfig) => Promise { return async config => { @@ -153,8 +158,14 @@ export function createWorkerGlobalSetup(options: WorkerGlobalSetupOptions): (con throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.'); } + process.env.E2E_TEST_WORKER_TOKEN = randomBytes(32).toString('hex'); + const secrets: Record = {}; - for (const [binding, envName] of Object.entries({ E2E_TEST_DSN: 'E2E_TEST_DSN', ...options.secrets })) { + for (const [binding, envName] of Object.entries({ + E2E_TEST_DSN: 'E2E_TEST_DSN', + E2E_TEST_WORKER_TOKEN: 'E2E_TEST_WORKER_TOKEN', + ...options.secrets, + })) { const value = process.env[envName]; if (!value) { throw new Error(`${envName} must be set to deploy the test worker.`); @@ -218,14 +229,21 @@ export function workerGlobalTeardown(config: FullConfig): void { * Workers Logs had no invocation for it. `status` is the status the Worker answers with. A Worker * that threw answers with status 500 and Cloudflare error code 1101, which sets it apart from a 500 * that did not come from the Worker. + * + * The request carries the `E2E_TEST_WORKER_TOKEN` of {@link createWorkerGlobalSetup}, unless `init` + * sets its own `Authorization` header. */ export async function fetchFromWorker(url: string, status: number, init?: RequestInit): Promise { const deadline = Date.now() + 60_000; let lastAnswer = 'no answer'; + const headers = new Headers(init?.headers); + if (process.env.E2E_TEST_WORKER_TOKEN && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${process.env.E2E_TEST_WORKER_TOKEN}`); + } while (Date.now() < deadline) { try { - const response = await fetch(url, init); + const response = await fetch(url, { ...init, headers }); const body = await response.text(); // Cloudflare sends its error page as HTML to some clients (Node's fetch among them) and as // `error code: ` plain text to others, so the code is read from either format. From 18c739867af5ce30db38fa0e843463572ef80ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 1 Oct 2026 15:16:05 +0300 Subject: [PATCH 12/57] fix(cloudflare): Don't treat DO constructor work as incoming RPC calls (#24829) fix(cloudflare): Don't treat DO constructor work as incoming RPC calls --- CHANGELOG.md | 2 + .../constructor-async-work/index.ts | 87 +++++++++++ .../instrument.server.ts | 6 + .../constructor-async-work/test.ts | 48 ++++++ .../constructor-async-work/vite.config.mts | 7 + .../constructor-async-work/wrangler.jsonc | 14 ++ packages/cloudflare/src/durableobject.ts | 12 +- .../cloudflare/src/utils/invocationScope.ts | 30 +++- .../cloudflare/test/durableobject.test.ts | 145 +++++++++++++++++- 9 files changed, 342 insertions(+), 9 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/wrangler.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a8a3b701211..6fb763cf84b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +- **fix(cloudflare)**: Durable Object constructors now run in their own isolation scope. When work that the constructor starts, for example a `blockConcurrencyWhile` callback, calls a method of the instance, that call is no longer treated as an incoming RPC call, so an error the instance catches itself is no longer reported as unhandled. As a result, `Sentry.setTag()`, `setUser()` and `setContext()` in a constructor now apply only to that constructor work. They no longer reach later `fetch`, RPC or `alarm` invocations, because the scope they used to write to is shared by every Durable Object and handler in the isolate. Use `initialScope` for static data, and set per-instance data in each handler. + ## 11.2.0 ### Important Changes diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/index.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/index.ts new file mode 100644 index 000000000000..715594b5b401 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/index.ts @@ -0,0 +1,87 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + BLOCK_CONCURRENCY_DURABLE_OBJECT: DurableObjectNamespace; + ASYNC_METHOD_DURABLE_OBJECT: DurableObjectNamespace; +} + +export class BlockConcurrencyDurableObject extends DurableObject { + private failure?: Error; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + + // The callback runs after the constructor has returned, so after the SDK has wrapped `init`. + void ctx.blockConcurrencyWhile(async () => { + try { + this.init(); + } catch (error) { + this.failure = error as Error; + } + }); + } + + init(): void { + throw new Error('Init failed'); + } + + ping(): string { + const status = this.failure ? 'degraded' : 'ok'; + Sentry.captureMessage(`block-concurrency-while ping: ${status}`); + return status; + } +} + +export class AsyncMethodDurableObject extends DurableObject { + private failure?: Error; + private readonly loaded: Promise; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + + this.loaded = this.load(); + } + + async load(): Promise { + // Everything after the first `await` runs after the constructor has returned, so after the SDK + // has wrapped `init`. + await this.ctx.storage.get('config'); + + try { + this.init(); + } catch (error) { + this.failure = error as Error; + } + } + + init(): void { + throw new Error('Init failed'); + } + + async ping(): Promise { + await this.loaded; + const status = this.failure ? 'degraded' : 'ok'; + Sentry.captureMessage(`async-method ping: ${status}`); + return status; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/block-concurrency-while') { + const namespace = env.BLOCK_CONCURRENCY_DURABLE_OBJECT; + return new Response(await namespace.get(namespace.idFromName('test')).ping()); + } + + if (url.pathname === '/async-method') { + const namespace = env.ASYNC_METHOD_DURABLE_OBJECT; + return new Response(await namespace.get(namespace.idFromName('test')).ping()); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/test.ts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/test.ts new file mode 100644 index 000000000000..08d34656ee5b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/test.ts @@ -0,0 +1,48 @@ +import type { Event, SerializedStreamedSpanContainer } from '@sentry/core'; +import { SENTRY_OP, SENTRY_ORIGIN, URL_PATH } from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// Every envelope of both requests is expected, and `failOnUnexpected` fails the test on any other one. +// All of them are sent after the constructor work ran, so an error or span from that work would arrive +// before the runner completes. +it('does not capture errors a Durable Object catches in work its constructor started', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .failOnUnexpected() + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + expect((envelope[1][0][1] as Event).message).toBe('block-concurrency-while ping: degraded'); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('event'); + expect((envelope[1][0][1] as Event).message).toBe('async-method ping: degraded'); + }) + .expectN(2, envelope => { + expect(envelope[1][0][0].type).toBe('span'); + const container = envelope[1][0][1] as SerializedStreamedSpanContainer; + expect(container.items.map(item => item.name)).toEqual(['ping']); + expect(container.items[0]?.attributes[SENTRY_OP]?.value).toBe('rpc'); + expect(container.items[0]?.attributes[SENTRY_ORIGIN]?.value).toBe('auto.faas.cloudflare.durable_object'); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + const container = envelope[1][0][1] as SerializedStreamedSpanContainer; + expect(container.items.map(item => item.attributes[URL_PATH]?.value)).toEqual(['/block-concurrency-while']); + expect(container.items[0]?.attributes[SENTRY_OP]?.value).toBe('http.server'); + }) + .expect(envelope => { + expect(envelope[1][0][0].type).toBe('span'); + const container = envelope[1][0][1] as SerializedStreamedSpanContainer; + expect(container.items.map(item => item.attributes[URL_PATH]?.value)).toEqual(['/async-method']); + expect(container.items[0]?.attributes[SENTRY_OP]?.value).toBe('http.server'); + }) + .start(signal); + + const blockConcurrencyResponse = await runner.makeRequest('get', '/block-concurrency-while'); + const asyncMethodResponse = await runner.makeRequest('get', '/async-method'); + + expect(blockConcurrencyResponse).toBe('degraded'); + expect(asyncMethodResponse).toBe('degraded'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/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/durableobject/constructor-async-work/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/wrangler.jsonc new file mode 100644 index 000000000000..952cd3f08583 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/durableobject/constructor-async-work/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "durableobject-constructor-async-work-worker", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "BLOCK_CONCURRENCY_DURABLE_OBJECT", "class_name": "BlockConcurrencyDurableObject" }, + { "name": "ASYNC_METHOD_DURABLE_OBJECT", "class_name": "AsyncMethodDurableObject" }, + ], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["BlockConcurrencyDurableObject", "AsyncMethodDurableObject"] }], +} diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 9adc9e5115f0..af2b4481553d 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -10,6 +10,7 @@ import { instrumentDurableObjectHandlers } from './instrumentations/instrumentDu import { instrumentEnv } from './instrumentations/worker/instrumentEnv'; import { getFinalOptions } from './options'; import { instrumentContext } from './utils/instrumentContext'; +import { withConstructionIsolationScope } from './utils/invocationScope'; import { hasRpcMeta } from './utils/rpcMeta'; import { instrumentCloudflareAgent } from './instrumentations/agents'; import type { DefaultEnv, ResolveEnv, StrictCloudflareOptions } from './types'; @@ -67,7 +68,9 @@ export function constructInstrumentedDurableObject // Pass `newTarget` so that subclasses of the instrumented class (e.g. the wrapper classes // created by wrangler's local dev tooling or `@cloudflare/vitest-pool-workers`) keep their // own prototype — otherwise subclass methods disappear and `instanceof` checks break. - const obj = Reflect.construct(target, [context, instrumentedEnv], newTarget) as T; + const obj = withConstructionIsolationScope( + () => Reflect.construct(target, [context, instrumentedEnv], newTarget) as T, + ); const frameworkManagedMethods = resolveFrameworkManagedMethods( prototype, @@ -298,9 +301,10 @@ function createRpcPrototypeWrapper(methodName: string, originalMethod: Unchecked const wrapper = function (this: unknown, ...args: unknown[]): unknown { 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. + // workerd dispatches an incoming RPC call outside any async context, so a call made on any other + // isolation scope comes from the instance itself (`this.helper()` inside `fetch`, `alarm`, + // another RPC method or async work the constructor started). Check that before touching + // per-instance state. if (!traced && getIsolationScope() !== getDefaultIsolationScope()) { return Reflect.apply(originalMethod, this, args); } diff --git a/packages/cloudflare/src/utils/invocationScope.ts b/packages/cloudflare/src/utils/invocationScope.ts index aaaf98fdaa2a..cd0d94aed6db 100644 --- a/packages/cloudflare/src/utils/invocationScope.ts +++ b/packages/cloudflare/src/utils/invocationScope.ts @@ -2,6 +2,27 @@ import { getDefaultIsolationScope, getIsolationScope, type Scope, withIsolationS import type { ExecutionContextCompat } from '../executionContext'; import { setInvocationState } from './invocationContext'; +const constructionScopes = new WeakSet(); + +/** + * Runs a Durable Object constructor on a forked isolation scope. + * + * Async work the constructor starts, such as a `blockConcurrencyWhile` callback, runs after the RPC + * wrappers are installed and inherits this scope, so the calls it makes to the instance's own methods + * are not treated as incoming RPC calls. The scope is not an invocation: it has no invocation state, + * and an instrumented handler that this work calls still forks a scope of its own. + */ +export function withConstructionIsolationScope(callback: () => T): T { + if (getIsolationScope() !== getDefaultIsolationScope()) { + return callback(); + } + + return withIsolationScope(scope => { + constructionScopes.add(scope); + return callback(); + }); +} + /** * Runs `callback` on the isolation scope for the current invocation. * @@ -19,14 +40,15 @@ import { setInvocationState } from './invocationContext'; * * The AsyncLocalStorage strategy hands the default isolation scope back whenever no invocation is in * flight, and a forked one while inside `withIsolationScope`. Reference-comparing against the default - * is therefore enough to tell the two cases apart. The stack fallback does not fork, so it reports the - * default scope even inside an invocation; there the fork degrades to a no-op, which the stack strategy - * tolerates. This matches the approach used by `patchEventHandler` in Nuxt. + * and the construction scopes (see {@link withConstructionIsolationScope}) is therefore enough to tell + * the two cases apart. The stack fallback does not fork, so it reports the default scope even inside an + * invocation; there the fork degrades to a no-op, which the stack strategy tolerates. This matches the + * approach used by `patchEventHandler` in Nuxt. */ export function withInvocationIsolationScope(callback: (scope: Scope) => T, context?: ExecutionContextCompat): T { const isolationScope = getIsolationScope(); - const isEntryPoint = isolationScope === getDefaultIsolationScope(); + const isEntryPoint = isolationScope === getDefaultIsolationScope() || constructionScopes.has(isolationScope); const newIsolationScope = isEntryPoint ? isolationScope.clone() : isolationScope; if (isEntryPoint) { diff --git a/packages/cloudflare/test/durableobject.test.ts b/packages/cloudflare/test/durableobject.test.ts index e6f4e1bcd551..6f7e2f47744a 100644 --- a/packages/cloudflare/test/durableobject.test.ts +++ b/packages/cloudflare/test/durableobject.test.ts @@ -216,6 +216,110 @@ describe('instrumentDurableObjectWithSentry', () => { expect(events[2]?.user).toBeUndefined(); }); + // Work the constructor starts shares one scope, so a handler it calls must not reuse that scope. + it('Built-in handlers called from work the constructor started each get their own isolation scope', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const mockContext = { + waitUntil: vi.fn((promise: Promise) => { + waits.push(promise); + }), + blockConcurrencyWhile: (callback: () => Promise) => Promise.resolve().then(callback), + } as any; + + const testClass = class { + initialized: Promise; + + constructor(ctx: { blockConcurrencyWhile(callback: () => Promise): Promise }) { + this.initialized = ctx.blockConcurrencyWhile(async () => { + await this.webSocketMessage({}, 'seed'); + await this.webSocketMessage({}, 'probe'); + }); + } + + webSocketMessage(_ws: unknown, message: string) { + if (message === 'seed') { + SentryCore.setTag('seeded_tag', 'from-seeding-message'); + SentryCore.setUser({ id: 'user-from-seeding-message' }); + } + + SentryCore.captureMessage(message); + } + }; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event: Event) { + events.push(event); + return null; + }, + }), + testClass as any, + ), + [mockContext, {} as any], + ); + + await obj.initialized; + await Promise.all(waits); + + expect(events.map(event => event.message)).toEqual(['seed', 'probe']); + // Guards the assertions below against passing vacuously. + expect(events[0]?.tags?.seeded_tag).toBe('from-seeding-message'); + expect(events[0]?.user).toEqual({ id: 'user-from-seeding-message' }); + + expect(events[1]?.tags?.seeded_tag).toBeUndefined(); + expect(events[1]?.user).toBeUndefined(); + }); + + // The only scope that all later invocations share is the default isolation scope of the isolate, so + // data set in the constructor stays in the constructor's own scope. + it('does not apply scope data set in the constructor to later invocations', async () => { + const events: Event[] = []; + const waits: Promise[] = []; + const mockContext = { + waitUntil: vi.fn((promise: Promise) => { + waits.push(promise); + }), + } as any; + + const testClass = class { + constructor() { + SentryCore.setTag('constructor_tag', 'from-constructor'); + SentryCore.setUser({ id: 'user-from-constructor' }); + } + + alarm() { + SentryCore.captureMessage('alarm'); + } + + rpcMethod() { + SentryCore.captureMessage('rpc'); + } + }; + const obj = Reflect.construct( + instrumentDurableObjectWithSentry( + () => ({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + beforeSend(event: Event) { + events.push(event); + return null; + }, + }), + testClass as any, + ), + [mockContext, {} as any], + ); + + await obj.alarm(); + obj.rpcMethod(); + await Promise.all(waits); + + expect(events.map(event => event.message)).toEqual(['alarm', 'rpc']); + expect(events.map(event => event.tags?.constructor_tag)).toEqual([undefined, undefined]); + expect(events.map(event => event.user)).toEqual([undefined, undefined]); + }); + it('Built-in durable object methods are always instrumented', () => { const testClass = class { fetch() {} @@ -654,6 +758,9 @@ describe('instrumentDurableObjectWithSentry', () => { const waitUntil = vi.fn((promise: Promise) => { waits.push(promise); }); + // Like workerd, runs the callback after the constructor returns, in the async context of the caller. + const blockConcurrencyWhile = (callback: () => Promise): Promise => + Promise.resolve().then(callback); const instrumented = instrumentDurableObjectWithSentry( () => ({ @@ -675,7 +782,7 @@ describe('instrumentDurableObjectWithSentry', () => { }), testClass as any, ); - const obj = Reflect.construct(instrumented, [{ waitUntil }, {}]) as InstanceType; + const obj = Reflect.construct(instrumented, [{ waitUntil, blockConcurrencyWhile }, {}]) as InstanceType; const settle = async (): Promise => { while (waits.length) { await Promise.all(waits.splice(0)); @@ -769,6 +876,42 @@ describe('instrumentDurableObjectWithSentry', () => { expect(waitUntil).toHaveBeenCalledOnce(); }); + it('does not instrument calls from a blockConcurrencyWhile callback the constructor started', async () => { + const { obj, events, waitUntil, settle } = setup( + class { + failure?: Error; + initialized: Promise; + + constructor(ctx: { blockConcurrencyWhile(callback: () => Promise): Promise }) { + this.initialized = ctx.blockConcurrencyWhile(async () => { + try { + this.init(); + } catch (error) { + this.failure = error as Error; + } + }); + } + + init(): void { + throw new Error('Init failed'); + } + + async ping(): Promise { + const status = this.failure ? 'degraded' : 'ok'; + SentryCore.captureMessage(`ping: ${status}`); + return status; + } + }, + ); + + await obj.initialized; + await expect(obj.ping()).resolves.toBe('degraded'); + await settle(); + + expect(events.map(event => event.message)).toEqual(['ping: degraded']); + expect(waitUntil).toHaveBeenCalledOnce(); + }); + it('continues the caller trace when the call carries trace metadata', async () => { const { obj, events, transactions, settle } = setup( class { From 59b844e736ed2df7a4ddb1ff4d4459dfe3756e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 1 Oct 2026 16:17:33 +0300 Subject: [PATCH 13/57] fix(effect)!: Change peerDependency to v4 and fix currentSpan (#24940) Updating tests to Effect v4 and our dev dependencies of the SDK. The `peerDependency` was updated to v4 stable as the fiber changed. We would have needed a workaround to also support beta and rc versions, which is IMO not worth it just for supporting a beta Breaking because we had to change where the `currentSpan` is coming from. With that we don't support beta versions anymore --- .../effect-4-browser/package.json | 2 +- .../effect-4-node/package.json | 4 +- packages/effect/package.json | 6 +- packages/effect/src/tracer.ts | 2 +- yarn.lock | 131 ++---------------- 5 files changed, 17 insertions(+), 128 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json index f3f429c25b9f..17bd84eba21c 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-browser/package.json @@ -13,7 +13,7 @@ "dependencies": { "@sentry/effect": "latest || *", "@types/node": "^18.19.1", - "effect": "^4.0.0-beta.50", + "effect": "^4.0.0", "typescript": "~5.0.0" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json index 460bfb57ec45..a639956c51fb 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/package.json +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/package.json @@ -12,10 +12,10 @@ "test:assert": "pnpm test" }, "dependencies": { - "@effect/platform-node": "^4.0.0-rc.118", + "@effect/platform-node": "^4.0.0", "@sentry/effect": "file:../../packed/sentry-effect-packed.tgz", "@types/node": "^18.19.1", - "effect": "^4.0.0-rc.118", + "effect": "^4.0.0", "typescript": "~5.0.0" }, "devDependencies": { diff --git a/packages/effect/package.json b/packages/effect/package.json index c1e3b86c2eb4..04a532ceba25 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -50,7 +50,7 @@ "@sentry/node": "11.2.0" }, "peerDependencies": { - "effect": "^3.0.0 || ^4.0.0-beta.50" + "effect": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { "effect": { @@ -58,8 +58,8 @@ } }, "devDependencies": { - "@effect/vitest": "^4.0.0-beta.50", - "effect": "^4.0.0-beta.50" + "@effect/vitest": "^4.0.0", + "effect": "^4.0.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts index 08157a6e3a89..db33a96cc97d 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -265,7 +265,7 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. ); }, context(primitive, fiber) { - const currentSpan = fiber.currentSpan; + const currentSpan = fiber.cache.span; if (currentSpan === undefined || !isSentrySpan(currentSpan)) { return primitive[EFFECT_EVALUATE](fiber); } diff --git a/yarn.lock b/yarn.lock index 004333c9dcd0..17a92d7ec2ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3354,10 +3354,10 @@ dependencies: "@edge-runtime/primitives" "6.0.0" -"@effect/vitest@^4.0.0-beta.50": - version "4.0.0-beta.50" - resolved "https://registry.yarnpkg.com/@effect/vitest/-/vitest-4.0.0-beta.50.tgz#c3945b4a0206fa07160896b641445e16eb5d3214" - integrity sha512-bju/iCLZB8oHsVia1i6olo9ZntkZ5TrqmsINudFsRkZfHhu5UuTR3vjic29wykZpPXXONX1wKO0KZZCk+stcKg== +"@effect/vitest@^4.0.0": + version "4.0.0" + resolved "https://sfw.security.sentry.io/npm/@effect/vitest/-/vitest-4.0.0.tgz#e5b80cfb786a657ce41224618578e3f196f6522b" + integrity sha512-kGElCPFGtP+CgMCwV/HjJWqtE3zYo6I1+8MSA54l+EI24lPU9p2Efa+TBp1jfkg9j711KKzEqgSAy3VzTsb76Q== "@ember-data/rfc395-data@^0.0.4": version "0.0.4" @@ -5643,36 +5643,6 @@ dependencies: sparse-bitfield "^3.0.3" -"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz#9edec61b22c3082018a79f6d1c30289ddf3d9d11" - integrity sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw== - -"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz#33677a275204898ad8acbf62734fc4dc0b6a4855" - integrity sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw== - -"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz#19edf7cdc2e7063ee328403c1d895a86dd28f4bb" - integrity sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg== - -"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz#94fb0543ba2e28766c3fc439cabbe0440ae70159" - integrity sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw== - -"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz#4a0609ab5fe44d07c9c60a11e4484d3c38bbd6e3" - integrity sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg== - -"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz#0aa5502d547b57abfc4ac492de68e2006e417242" - integrity sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ== - "@mswjs/interceptors@^0.41.0": version "0.41.9" resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz#9d90bbd60d1ddc30dbcbb827a9bb2e470493530d" @@ -13851,7 +13821,7 @@ detect-indent@^6.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== -detect-libc@^2.0.0, detect-libc@^2.0.1, detect-libc@^2.0.3, detect-libc@^2.0.4, detect-libc@^2.1.2: +detect-libc@^2.0.0, detect-libc@^2.0.3, detect-libc@^2.0.4, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -14192,21 +14162,10 @@ effect@3.16.12: "@standard-schema/spec" "^1.0.0" fast-check "^3.23.1" -effect@^4.0.0-beta.50: - version "4.0.0-beta.50" - resolved "https://registry.yarnpkg.com/effect/-/effect-4.0.0-beta.50.tgz#c4fbc42adad53428242b8002390bde69b48feb0d" - integrity sha512-UsENighZms6LWDSnF/05F9JinDAewV3sGXHAt9M7+dL3VnoFZIwduFxXvmFc7QJm7iV1s7rB98hv1SD3ALA9qg== - dependencies: - "@standard-schema/spec" "^1.1.0" - fast-check "^4.6.0" - find-my-way-ts "^0.1.6" - ini "^6.0.0" - kubernetes-types "^1.30.0" - msgpackr "^1.11.9" - multipasta "^0.2.7" - toml "^4.1.1" - uuid "^13.0.0" - yaml "^2.8.3" +effect@^4.0.0: + version "4.0.0" + resolved "https://sfw.security.sentry.io/npm/effect/-/effect-4.0.0.tgz#e9bc2a8e73e49492f19a3a536876ba63080ecea7" + integrity sha512-ooc1TG5t+FfzgYnFz2ff6BBKyZ7EwBRVXC7c4RhQUAD6/TZ2gTXXMeb4WX7a19ozQo4J73/QW+S00YAIresoMQ== ejs@5.0.1: version "5.0.1" @@ -15629,13 +15588,6 @@ fast-check@^3.23.1: dependencies: pure-rand "^6.1.0" -fast-check@^4.6.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/fast-check/-/fast-check-4.7.0.tgz#36c0051b9c968965e8970e88e63eee946fe45f8f" - integrity sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ== - dependencies: - pure-rand "^8.0.0" - fast-content-type-parse@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz#5590b6c807cc598be125e6740a9fde589d2b7afb" @@ -15996,11 +15948,6 @@ find-index@^1.1.0: resolved "https://registry.yarnpkg.com/find-index/-/find-index-1.1.1.tgz#4b221f8d46b7f8bea33d8faed953f3ca7a081cbc" integrity sha512-XYKutXMrIK99YMUPf91KX5QVJoG31/OsgftD6YoTPAObfQIxM4ziA9f0J1AsqKhJmo+IeaIPP0CFopTD4bdUBw== -find-my-way-ts@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz#37f7b8433d0f61e7fe7290772240b0c133b0ebf2" - integrity sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA== - find-my-way@^9.6.0: version "9.7.0" resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-9.7.0.tgz#e33ea02bcaf4199a24eea3faa9926985dee4de58" @@ -17562,11 +17509,6 @@ ini@^2.0.0: resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== -ini@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-6.0.0.tgz#efc7642b276f6a37d22fdf56ef50889d7146bf30" - integrity sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== - injection-js@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/injection-js/-/injection-js-2.4.0.tgz#ebe8871b1a349f23294eaa751bbd8209a636e754" @@ -18697,11 +18639,6 @@ koa@^2.15.2: type-is "^1.6.16" vary "^1.1.2" -kubernetes-types@^1.30.0: - version "1.30.0" - resolved "https://registry.yarnpkg.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz#f686cacb08ffc5f7e89254899c2153c723420116" - integrity sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q== - kuler@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" @@ -20366,27 +20303,6 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -msgpackr-extract@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz#e9d87023de39ce714872f9e9504e3c1996d61012" - integrity sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA== - dependencies: - node-gyp-build-optional-packages "5.2.2" - optionalDependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.3" - "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3" - -msgpackr@^1.11.9: - version "1.11.9" - resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.9.tgz#1aa99ed379a066374ac82b62f8ad70723bbd3a59" - integrity sha512-FkoAAyyA6HM8wL882EcEyFZ9s7hVADSwG9xrVx3dxxNQAtgADTrJoEWivID82Iv1zWDsv/OtbrrcZAzGzOMdNw== - optionalDependencies: - msgpackr-extract "^3.0.2" - muggle-string@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328" @@ -20410,11 +20326,6 @@ multicast-dns@^7.2.5: dns-packet "^5.2.2" thunky "^1.0.2" -multipasta@^0.2.7: - version "0.2.7" - resolved "https://registry.yarnpkg.com/multipasta/-/multipasta-0.2.7.tgz#fa8fb38be65eb951fa57cad9e8e758107946eee9" - integrity sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA== - mustache@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" @@ -20788,13 +20699,6 @@ node-forge@^1, node-forge@^1.3.1, node-forge@^1.4.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== -node-gyp-build-optional-packages@5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz#522f50c2d53134d7f3a76cd7255de4ab6c96a3a4" - integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== - dependencies: - detect-libc "^2.0.1" - node-gyp-build@^4.2.2: version "4.6.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" @@ -23358,11 +23262,6 @@ pure-rand@^6.1.0: resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== -pure-rand@^8.0.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-8.4.0.tgz#1d9e26e9c0555486e08ae300d02796af8dec1cd0" - integrity sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A== - qs@^6.14.0, qs@^6.15.2: version "6.15.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.3.tgz#76852132a58ed5c7c0ef67e4441b9bb5d6061b3b" @@ -26392,11 +26291,6 @@ token-types@^6.1.1: "@tokenizer/token" "^0.3.0" ieee754 "^1.2.1" -toml@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/toml/-/toml-4.3.0.tgz#10e2ff83296ec17d8935bf02364e4969cfeaae67" - integrity sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A== - totalist@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.0.tgz#4ef9c58c5f095255cdc3ff2a0a55091c57a3a1bd" @@ -27310,11 +27204,6 @@ uuid@^11.1.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== -uuid@^13.0.0: - version "13.0.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8" - integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== - uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" @@ -28505,7 +28394,7 @@ yallist@^5.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== -yaml@2.9.0, yaml@^2.8.2, yaml@^2.8.3: +yaml@2.9.0, yaml@^2.8.2: version "2.9.0" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== From 289fb98e686bdd4311e0afc5c5a7e2be41edd314 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 1 Oct 2026 14:25:06 +0100 Subject: [PATCH 14/57] fix(bundler-plugins): Stabilize debug IDs for Rolldown and Vite 8 builds (#24919) Rolldown calls `renderChunk` before file names and hashes are final, so the chunk code still holds hash placeholders. Deriving the debug ID from that code made unchanged chunks get a different ID, and so a different content hash, on every build. For Rolldown the plugin now injects a fixed-width placeholder in `renderChunk` and swaps in the real ID in a `pre` `generateBundle` hook, derived from the final chunk code. The swap runs before other plugins' `generateBundle` hooks, so integrity hashes see the final code. Rollup builds keep the existing content-based IDs. The Nuxt source map e2e check now skips chunks that Rolldown emits without a source map (Vue's export helper). It passed before only because the server build held an identical chunk with the same content-based ID. This continues #23665 by @chen-anders with the original commits kept. The Vite source map re-stamping from that PR is split out into its own PR. Every Rolldown and Vite 8 build gets new debug IDs for unchanged chunks after upgrading; this is harmless. Fixes #23448 --------- Co-authored-by: Anders Chen Co-authored-by: OpenAI Codex --- .../debugids-already-injected.test.ts | 16 +- .../vite8/debugids-already-injected.test.ts | 16 +- .../nuxt-4-sourcemaps/assert-build.ts | 28 +++- .../src/rollup/debug-id-injection.ts | 70 +++++++++ packages/bundler-plugins/src/rollup/index.ts | 37 +++-- .../test/rollup/debug-id-injection.test.ts | 100 +++++++++++++ .../test/rollup/disable-upload.test.ts | 18 ++- .../test/rollup/rolldown-determinism.test.ts | 139 ++++++++++++++++++ 8 files changed, 395 insertions(+), 29 deletions(-) create mode 100644 packages/bundler-plugins/src/rollup/debug-id-injection.ts create mode 100644 packages/bundler-plugins/test/rollup/debug-id-injection.test.ts create mode 100644 packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts index c5a04f975b7c..950873a28a70 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/rolldown/debugids-already-injected.test.ts @@ -2,14 +2,24 @@ import { expect } from "vitest"; import { readAllFiles } from "../utils"; import { test } from "./utils"; +const normalizeDebugIds = (value: string): string => + value.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "00000000-0000-0000-0000-000000000000"); + test(import.meta.url, ({ runBundler, createTempDir }) => { const tempDir = createTempDir(); runBundler({ SENTRY_TEST_OVERRIDE_TEMP_DIR: tempDir }); - const files = readAllFiles(tempDir); + // Rolldown debug IDs are derived from the final chunk text, so they change with every Rolldown + // release. Zero them in file names and source maps to keep the snapshot stable. + const files = Object.fromEntries( + Object.entries(readAllFiles(tempDir, normalizeDebugIds)).map(([name, content]) => [ + normalizeDebugIds(name), + content, + ]) + ); expect(files).toMatchInlineSnapshot(` { - "b699d9c1-b033-4536-aa25-233c92609b54-0.js": "//#region src/basic.js + "00000000-0000-0000-0000-000000000000-0.js": "//#region src/basic.js (function() { try { var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {}; @@ -23,7 +33,7 @@ test(import.meta.url, ({ runBundler, createTempDir }) => { //# debugId=00000000-0000-0000-0000-000000000000 //# sourceMappingURL=basic.js.map", - "b699d9c1-b033-4536-aa25-233c92609b54-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,cAAc","debugId":"b699d9c1-b033-4536-aa25-233c92609b54","debug_id":"b699d9c1-b033-4536-aa25-233c92609b54"}", + "00000000-0000-0000-0000-000000000000-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,cAAc","debugId":"00000000-0000-0000-0000-000000000000","debug_id":"00000000-0000-0000-0000-000000000000"}", } `); }); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts index 89189b0338dc..67c935d746f7 100644 --- a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/debugids-already-injected.test.ts @@ -2,14 +2,24 @@ import { expect } from "vitest"; import { readAllFiles } from "../utils"; import { test } from "./utils"; +const normalizeDebugIds = (value: string): string => + value.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "00000000-0000-0000-0000-000000000000"); + test(import.meta.url, ({ runBundler, createTempDir }) => { const tempDir = createTempDir(); runBundler({ SENTRY_TEST_OVERRIDE_TEMP_DIR: tempDir }); - const files = readAllFiles(tempDir); + // Rolldown debug IDs are derived from the final chunk text, so they change with every Rolldown + // release. Zero them in file names and source maps to keep the snapshot stable. + const files = Object.fromEntries( + Object.entries(readAllFiles(tempDir, normalizeDebugIds)).map(([name, content]) => [ + normalizeDebugIds(name), + content, + ]) + ); expect(files).toMatchInlineSnapshot(` { - "b699d9c1-b033-4536-aa25-233c92609b54-0.js": "//#region src/basic.js + "00000000-0000-0000-0000-000000000000-0.js": "//#region src/basic.js (function() { try { var e = "undefined" != typeof window ? window : "undefined" != typeof global ? global : "undefined" != typeof globalThis ? globalThis : "undefined" != typeof self ? self : {}; @@ -23,7 +33,7 @@ test(import.meta.url, ({ runBundler, createTempDir }) => { //# debugId=00000000-0000-0000-0000-000000000000 //# sourceMappingURL=basic.js.map", - "b699d9c1-b033-4536-aa25-233c92609b54-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,aAAa","debugId":"b699d9c1-b033-4536-aa25-233c92609b54","debug_id":"b699d9c1-b033-4536-aa25-233c92609b54"}", + "00000000-0000-0000-0000-000000000000-0.js.map": "{"version":3,"file":"basic.js","names":[],"sources":["../../src/basic.js"],"sourcesContent":["// eslint-disable-next-line no-console\\nconsole.log(\\"hello world\\");\\n"],"mappings":";;;;;;;;;AACA,QAAQ,IAAI,aAAa","debugId":"00000000-0000-0000-0000-000000000000","debug_id":"00000000-0000-0000-0000-000000000000"}", } `); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index bd74c74d5997..14d64149cefc 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -20,6 +20,8 @@ const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; const CLIENT_OUTPUT = path.join('.output', 'public'); +/** Vite's own output directory. Nitro copies it to `CLIENT_OUTPUT`, and Sentry deletes no maps here. */ +const CLIENT_BUILD_OUTPUT = path.join('.nuxt', 'dist', 'client'); const SERVER_OUTPUT = path.join('.output', 'server'); /** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ @@ -36,6 +38,26 @@ function filesContaining(dir: string, needle: string): string[] { .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); } +/** + * Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper. + * Such a chunk still gets a debug ID, but there is no map to upload for it. + */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + const debugIds = new Set(); + + for (const file of filesContaining(dir, 'sentry-dbid-')) { + if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) { + continue; + } + + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + debugIds.add((match[1] as string).toLowerCase()); + } + } + + return debugIds; +} + console.log( `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, @@ -139,11 +161,15 @@ assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be // An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting // the upload alone cannot show this. +const debugIdsWithoutSourceMap = debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_OUTPUT); + for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId)); + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), + ); assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s), all uploaded`); diff --git a/packages/bundler-plugins/src/rollup/debug-id-injection.ts b/packages/bundler-plugins/src/rollup/debug-id-injection.ts new file mode 100644 index 000000000000..b9340b98ae05 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/debug-id-injection.ts @@ -0,0 +1,70 @@ +import { stringToUUID } from '../core'; + +export const ROLLDOWN_DEBUG_ID_PLACEHOLDER = 'SENTRY_DEBUG_ID_PLACEHOLDER_00000000'; + +type GeneratedChunk = { + type: 'chunk'; + fileName: string; + code: string; +}; + +type GeneratedAsset = { + type: 'asset'; + fileName: string; +}; + +export type GeneratedBundle = Record; + +const SENTRY_DEBUG_ID_IDENTIFIER = '_sentryDebugIdIdentifier'; +const SENTRY_DEBUG_ID_IDENTIFIER_PREFIX = 'sentry-dbid-'; + +export function hasExistingDebugID(code: string): boolean { + const chunkStartSnippet = code.slice(0, 6000); + const chunkEndSnippet = code.slice(-500); + + return chunkStartSnippet.includes(SENTRY_DEBUG_ID_IDENTIFIER) || chunkEndSnippet.includes('//# debugId='); +} + +export function getDebugIdForChunk(code: string, isRolldown: boolean): string { + return isRolldown ? ROLLDOWN_DEBUG_ID_PLACEHOLDER : stringToUUID(code); +} + +function replaceAt(code: string, start: number, search: string, replacement: string): string { + return `${code.slice(0, start)}${replacement}${code.slice(start + search.length)}`; +} + +export function finalizeRolldownDebugIds(bundle: GeneratedBundle): void { + for (const [fileName, output] of Object.entries(bundle)) { + if (output.type !== 'chunk') { + continue; + } + + const identifier = `${SENTRY_DEBUG_ID_IDENTIFIER_PREFIX}${ROLLDOWN_DEBUG_ID_PLACEHOLDER}`; + const identifierPropertyStart = output.code.indexOf(SENTRY_DEBUG_ID_IDENTIFIER); + const identifierStart = output.code.indexOf( + identifier, + identifierPropertyStart + SENTRY_DEBUG_ID_IDENTIFIER.length, + ); + if (identifierStart === -1) { + continue; + } + + const identifierPlaceholderStart = identifierStart + SENTRY_DEBUG_ID_IDENTIFIER_PREFIX.length; + const debugIdsPlaceholderStart = output.code.lastIndexOf(ROLLDOWN_DEBUG_ID_PLACEHOLDER, identifierStart - 1); + if (debugIdsPlaceholderStart === -1) { + throw new Error(`Failed to locate the Sentry debug ID placeholder for chunk \`${fileName}\`.`); + } + + // The fixed-width replacement happens after Rolldown computes [hash], so the emitted file name + // still matches the placeholder-bearing chunk. Hashing only the code keeps byte-identical chunks + // on one ID, as Rollup builds do. + const debugId = stringToUUID(output.code); + const codeWithIdentifier = replaceAt( + output.code, + identifierPlaceholderStart, + ROLLDOWN_DEBUG_ID_PLACEHOLDER, + debugId, + ); + output.code = replaceAt(codeWithIdentifier, debugIdsPlaceholderStart, ROLLDOWN_DEBUG_ID_PLACEHOLDER, debugId); + } +} diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index d9bbb517daad..935163f961cc 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -6,7 +6,6 @@ import { isJsFile, shouldSkipCodeInjection, getDebugIdSnippet, - stringToUUID, createDebugIdUploadFunction, globFiles, createComponentNameAnnotateHooks, @@ -20,6 +19,7 @@ import type { SourceMap } from 'magic-string'; import MagicString from 'magic-string'; import * as path from 'node:path'; import { createRequire } from 'node:module'; +import { finalizeRolldownDebugIds, getDebugIdForChunk, hasExistingDebugID } from './debug-id-injection'; // The subset of Rollup's `TransformResult` that this plugin's `transform` // hook actually returns. Defined locally instead of imported from `rollup` @@ -41,19 +41,10 @@ type ViteParseAstAsync = NonNullable; let viteParseAstAsyncPromise: Promise | undefined; -const JS_MODULE_ID_FILTER = /\.[cm]?[jt]sx?(?:[?#].*)?$/; - -function hasExistingDebugID(code: string): boolean { - // Check if a debug ID has already been injected to avoid duplicate injection (e.g. by another plugin or Sentry CLI) - const chunkStartSnippet = code.slice(0, 6000); - const chunkEndSnippet = code.slice(-500); +// Rolldown sets `meta.rolldownVersion` on the plugin context. +type PluginContext = { meta?: { rolldownVersion?: string } }; - if (chunkStartSnippet.includes('_sentryDebugIdIdentifier') || chunkEndSnippet.includes('//# debugId=')) { - return true; // Debug ID already present, skip injection - } - - return false; -} +const JS_MODULE_ID_FILTER = /\.[cm]?[jt]sx?(?:[?#].*)?$/; function getRollupMajorVersion(): string | undefined { try { @@ -195,6 +186,7 @@ export function _rollupPluginInternal( } function renderChunk( + this: PluginContext | undefined, code: string, chunk: { fileName: string; facadeModuleId?: string | null }, _?: unknown, @@ -215,7 +207,7 @@ export function _rollupPluginInternal( const injectCode = staticInjectionCode.clone(); if (sourcemapsEnabled && !hasExistingDebugID(code)) { - const debugId = stringToUUID(code); // generate a deterministic debug ID + const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion); injectCode.append(getDebugIdSnippet(debugId)); } @@ -242,14 +234,23 @@ export function _rollupPluginInternal( } /** - * Stamps debug IDs into the emitted chunks and source maps. + * Resolves Rolldown's placeholder debug IDs, then stamps debug IDs into the emitted chunks and + * source maps. * * `disable-upload` skips the upload routine (which stamps debug IDs into temp copies), so the emitted * artifacts get stamped here instead. Not in `renderChunk`: minifiers running after it would strip the * comment. Rollup computes `[hash]` file names before this hook, so only plugins that hash the final * assets afterwards (e.g. subresource integrity) see the stamped content. */ - function generateBundle(_outputOptions: unknown, bundle: OutputBundle): void { + function generateBundle(this: PluginContext | undefined, _outputOptions: unknown, bundle: OutputBundle): void { + if (this?.meta?.rolldownVersion) { + finalizeRolldownDebugIds(bundle); + } + + if (options.sourcemaps?.disable !== 'disable-upload') { + return; + } + for (const output of Object.values(bundle)) { if (output.type !== 'chunk' || !isJsFile(output.fileName)) { continue; @@ -320,9 +321,7 @@ export function _rollupPluginInternal( buildStart, ...(shouldTransform ? { transform: transformHook } : {}), renderChunk, - ...(options.sourcemaps?.disable === 'disable-upload' - ? { generateBundle: { order: 'pre' as const, handler: generateBundle } } - : {}), + generateBundle: { order: 'pre' as const, handler: generateBundle }, writeBundle, }; } diff --git a/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts b/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts new file mode 100644 index 000000000000..be5b87f2da9f --- /dev/null +++ b/packages/bundler-plugins/test/rollup/debug-id-injection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + finalizeRolldownDebugIds, + getDebugIdForChunk, + hasExistingDebugID, + ROLLDOWN_DEBUG_ID_PLACEHOLDER, + type GeneratedBundle, +} from '../../src/rollup/debug-id-injection'; + +const UUID_PATTERN = /[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}/g; + +function provisionalCode(userCode = 'console.log("test");'): string { + return [ + `globalThis._sentryDebugIds[stack]="${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`, + `globalThis._sentryDebugIdIdentifier="sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`, + userCode, + ].join(''); +} + +function finalize(code: string, fileName = 'bundle.js'): string { + const bundle: GeneratedBundle = { + [fileName]: { type: 'chunk', fileName, code }, + }; + + finalizeRolldownDebugIds(bundle); + const output = bundle[fileName]; + expect(output?.type).toBe('chunk'); + + return output?.type === 'chunk' ? output.code : ''; +} + +function extractDebugId(code: string): string { + const debugIds = code.match(UUID_PATTERN); + expect(debugIds).toHaveLength(2); + expect(new Set(debugIds)).toHaveLength(1); + + return debugIds?.[0] ?? ''; +} + +describe('debug ID injection', () => { + it('uses a placeholder for Rolldown and a deterministic UUID for Rollup', () => { + expect(getDebugIdForChunk('code', true)).toBe(ROLLDOWN_DEBUG_ID_PLACEHOLDER); + expect(getDebugIdForChunk('code', false)).toMatch(UUID_PATTERN); + expect(getDebugIdForChunk('code', false)).toBe(getDebugIdForChunk('code', false)); + }); + + it('detects existing inline and comment debug IDs at chunk boundaries', () => { + expect(hasExistingDebugID('globalThis._sentryDebugIdIdentifier="sentry-dbid-existing";')).toBe(true); + expect(hasExistingDebugID('console.log("test");\n//# debugId=existing')).toBe(true); + expect(hasExistingDebugID('console.log("test");')).toBe(false); + }); + + it('generates stable IDs from finalized code and filenames', () => { + const firstBuild = finalize(provisionalCode()); + const secondBuild = finalize(provisionalCode()); + + expect(extractDebugId(firstBuild)).toBe(extractDebugId(secondBuild)); + }); + + it('changes the ID when the code changes and keeps it across file names', () => { + const baseline = extractDebugId(finalize(provisionalCode('first'))); + + expect(extractDebugId(finalize(provisionalCode('second')))).not.toBe(baseline); + expect(extractDebugId(finalize(provisionalCode('first'), 'other.js'))).toBe(baseline); + }); + + it('does not replace placeholder-shaped user strings', () => { + const userCode = `console.log("${ROLLDOWN_DEBUG_ID_PLACEHOLDER}");`; + + expect(finalize(provisionalCode(userCode))).toContain(userCode); + }); + + it('does not replace marker-shaped strings before the injected identifier', () => { + const userCode = `// sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}\n`; + const finalizedCode = finalize(`${userCode}${provisionalCode()}`); + + expect(finalizedCode).toContain(userCode); + expect(extractDebugId(finalizedCode)).not.toBe(''); + }); + + it('fails when the injected placeholder is incomplete', () => { + const code = `globalThis._sentryDebugIdIdentifier="sentry-dbid-${ROLLDOWN_DEBUG_ID_PLACEHOLDER}";`; + + expect(() => finalize(code)).toThrow('Failed to locate the Sentry debug ID placeholder for chunk `bundle.js`.'); + }); + + it('ignores non-chunk assets and chunks without provisional IDs', () => { + const bundle: GeneratedBundle = { + 'asset.js': { type: 'asset', fileName: 'asset.js' }, + 'chunk.js': { type: 'chunk', fileName: 'chunk.js', code: 'console.log("test");' }, + }; + + finalizeRolldownDebugIds(bundle); + + expect(bundle).toEqual({ + 'asset.js': { type: 'asset', fileName: 'asset.js' }, + 'chunk.js': { type: 'chunk', fileName: 'chunk.js', code: 'console.log("test");' }, + }); + }); +}); diff --git a/packages/bundler-plugins/test/rollup/disable-upload.test.ts b/packages/bundler-plugins/test/rollup/disable-upload.test.ts index 23b9db3cf5a4..be868126501f 100644 --- a/packages/bundler-plugins/test/rollup/disable-upload.test.ts +++ b/packages/bundler-plugins/test/rollup/disable-upload.test.ts @@ -77,10 +77,22 @@ describe('sourcemaps.disable: "disable-upload"', () => { expect(chunk).toContain(`//# debugId=${debugId}`); }); - it('does not register the generateBundle hook when uploading is enabled', () => { + it('does not stamp the emitted files when uploading is enabled', () => { const [plugin] = sentryRollupPlugin({ telemetry: false }); - - expect(plugin).not.toHaveProperty('generateBundle'); + const code = 'globalThis._sentryDebugIdIdentifier="sentry-dbid-e3ee452d-c255-448c-aa62-da0fd4c49e46";'; + const bundle = { + 'entry.js': { type: 'chunk', fileName: 'entry.js', code }, + 'entry.js.map': { + type: 'asset', + fileName: 'entry.js.map', + source: JSON.stringify({ version: 3, sources: [], names: [], mappings: '' }), + }, + }; + + plugin.generateBundle.handler.call({ meta: {} }, {}, bundle); + + expect(bundle['entry.js'].code).toBe(code); + expect(JSON.parse(bundle['entry.js.map'].source)).not.toHaveProperty('debug_id'); }); it('stamps the chunk when the source map is inlined into the chunk', async () => { diff --git a/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts b/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts new file mode 100644 index 000000000000..cefed166d764 --- /dev/null +++ b/packages/bundler-plugins/test/rollup/rolldown-determinism.test.ts @@ -0,0 +1,139 @@ +import { rolldown } from 'rolldown'; +import { describe, expect, it } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; + +const virtualModules: Record = { + 'virtual:app': `export async function loadShared() { return import('virtual:shared'); }`, + 'virtual:shared': `export const shared = 'shared';`, + 'virtual:unrelated': `console.log('unrelated');`, +}; + +type SourceMapMode = boolean | 'inline' | 'hidden'; +type OutputFormat = 'esm' | 'cjs'; + +const outputCases: [OutputFormat, SourceMapMode][] = [ + ['esm', true], + ['esm', false], + ['esm', 'inline'], + ['esm', 'hidden'], + ['cjs', true], + ['cjs', false], + ['cjs', 'inline'], + ['cjs', 'hidden'], +]; + +async function createBuild(includeUnrelatedEntry: boolean, observeBundle?: (codes: string[]) => void) { + const input: Record = includeUnrelatedEntry + ? { unrelated: 'virtual:unrelated', app: 'virtual:app' } + : { app: 'virtual:app' }; + const options = { + input, + plugins: [ + { + name: 'integrity-observer', + generateBundle(_outputOptions: unknown, bundle: Record) { + observeBundle?.( + Object.values(bundle).flatMap(output => (output.type === 'chunk' ? [output.code ?? ''] : [])), + ); + }, + }, + { + name: 'virtual-modules', + resolveId(id: string) { + return id in virtualModules ? id : null; + }, + load(id: string) { + return virtualModules[id] ?? null; + }, + }, + ...sentryRollupPlugin({ + release: { inject: false }, + sourcemaps: { disable: 'disable-upload' }, + telemetry: false, + }), + ], + }; + + return rolldown(options); +} + +async function build( + includeUnrelatedEntry: boolean, + sourcemap: SourceMapMode = true, + format: OutputFormat = 'esm', + observeBundle?: (codes: string[]) => void, +) { + const bundle = await createBuild(includeUnrelatedEntry, observeBundle); + + try { + const { output } = await bundle.generate({ + format, + sourcemap, + entryFileNames: '[name]-[hash].js', + chunkFileNames: '[name]-[hash].js', + }); + + return output.filter(outputFile => outputFile.type === 'chunk'); + } finally { + await bundle.close(); + } +} + +function expectFinalizedDebugId(code: string): void { + expect(code).not.toContain('SENTRY_DEBUG_ID_PLACEHOLDER_00000000'); + const debugIds = code.match(/[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}/g); + expect(debugIds?.length).toBeGreaterThanOrEqual(2); + expect(new Set(debugIds)).toHaveLength(1); +} + +describe('Rolldown debug ID determinism', () => { + it('finalizes and stamps chunks before later bundle observers run', async () => { + let observedCodes: string[] = []; + + const chunks = await build(false, true, 'esm', codes => { + observedCodes = codes; + }); + + expect(observedCodes).toHaveLength(chunks.length); + expect(observedCodes).toEqual(chunks.map(chunk => chunk.code)); + for (const code of observedCodes) { + expect(code).toContain('//# debugId='); + expectFinalizedDebugId(code); + } + }); + + it.each(outputCases)( + 'produces identical %s chunks in repeated builds with sourcemap=%s', + async (format, sourcemap) => { + const firstBuild = await build(false, sourcemap, format); + const secondBuild = await build(false, sourcemap, format); + + const comparableOutput = (chunks: typeof firstBuild) => + chunks.map(chunk => ({ + fileName: chunk.fileName, + code: chunk.code, + map: chunk.map?.toString(), + })); + expect(comparableOutput(secondBuild)).toEqual(comparableOutput(firstBuild)); + for (const chunk of firstBuild) { + expectFinalizedDebugId(chunk.code); + } + }, + ); + + it('keeps existing chunks stable when an unrelated entry changes placeholder allocation', async () => { + const firstBuild = await build(false); + const secondBuild = await build(true); + + for (const facadeModuleId of ['virtual:app', 'virtual:shared']) { + const firstChunk = firstBuild.find(chunk => chunk.facadeModuleId === facadeModuleId); + const secondChunk = secondBuild.find(chunk => chunk.facadeModuleId === facadeModuleId); + + expect(firstChunk).toBeDefined(); + expect(secondChunk).toBeDefined(); + expect(secondChunk?.fileName).toBe(firstChunk?.fileName); + expect(secondChunk?.code).toBe(firstChunk?.code); + expectFinalizedDebugId(firstChunk?.code ?? ''); + } + }); +}); From 811af68c280f3005ea955bf16cb6158d10ba3f0c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 1 Oct 2026 15:33:31 +0200 Subject: [PATCH 15/57] feat(remix): Add Remix 3 browser SDK with a bundled client entry (#24802) Remix 3 has no bundler, so the browser gets whatever module graph the client entry reaches and nothing can be eliminated. The entry is a named export list rather than a wildcard re-export, and it is bundled once at publish time. Unbundled that costs an app 255 requests and about 364 KB gzipped; bundled it is 2 requests and 54 KB. The size budget is here rather than in a later pull request because one wildcard re-export silently undoes all of it. Navigation tracing is the SDK's own, because `remix/ui` intercepts links through the Navigation API and never touches History, so the upstream handler would emit nothing. Page loads are ordinary document loads and stay upstream. Client error capture is a separate pull request. Part of #24665 Co-authored-by: Claude Opus 5 --- .size-limit.js | 13 ++ .../remix-v3/app/actions/controller.tsx | 20 ++- .../remix-v3/app/actions/public/entry.ts | 14 ++- .../test-applications/remix-v3/app/assets.ts | 7 ++ .../remix-v3/tests/client.test.ts | 35 ++++++ packages/remix/package.json | 2 +- packages/remix/rollup.npm.config.mjs | 36 ++++++ .../v3/client/browserTracingIntegration.ts | 106 ++++++++++++++++ packages/remix/src/v3/client/sdk.ts | 28 +++++ packages/remix/src/v3/index.client.ts | 80 +++++++++++- .../test/v3/browserTracingIntegration.test.ts | 119 ++++++++++++++++++ packages/remix/test/v3/sdk.test.ts | 71 +++++++++++ 12 files changed, 521 insertions(+), 10 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts create mode 100644 packages/remix/src/v3/client/browserTracingIntegration.ts create mode 100644 packages/remix/src/v3/client/sdk.ts create mode 100644 packages/remix/test/v3/browserTracingIntegration.test.ts create mode 100644 packages/remix/test/v3/sdk.test.ts diff --git a/.size-limit.js b/.size-limit.js index 80ad8c9f5f4f..b5f7ee9fc0f1 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -225,6 +225,19 @@ module.exports = [ limit: '35 KB', disablePlugins: ['@size-limit/esbuild'], }, + // Remix 3 browser SDK (ESM) + { + // Every export, not a named import: Remix 3 has no bundler, so an app ships every byte of this + // file. The budget is what keeps `src/v3/index.client.ts` a named list. One added + // `export * from '@sentry/browser'` measures 134 KB here, and more on the wire, because a real app + // has no bundler to shake it. + name: '@sentry/remix (Remix 3 client bundle)', + path: 'packages/remix/build/esm/v3/client-bundle.js', + import: '*', + gzip: true, + limit: '56 KB', + disablePlugins: ['@size-limit/esbuild'], + }, // Browser CDN bundles { name: 'CDN Bundle', diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 756115577e68..4999bd4127cc 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -17,6 +17,24 @@ function HomePage(handle: Handle>) {

Sentry Remix 3

+ {/* No `data-rmx-document`, so the runtime intercepts this through the Navigation API. */} + + User + + + + ); +} + +function UserPage(handle: Handle<{ id?: string }>) { + return () => ( + + + + User + + +

User {handle.props.id}

); @@ -31,7 +49,7 @@ export default createController(routes, { return context.render(); }, user(context) { - return Response.json({ id: context.params.id }); + return context.render(); }, teapot() { return new Response("I'm a teapot", { status: 418 }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index 5f0ebdc1248e..d4cc91b051d5 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,9 +1,19 @@ +import * as Sentry from '@sentry/remix/v3/client'; import { run } from 'remix/ui'; -// No Sentry here yet: `@sentry/remix/v3/client` does not export `init` until the browser SDK lands. +// Not Node. The asset server substitutes this when it compiles the module, from the `define` map in +// `app/assets.ts`. +declare const process: { env: Record }; + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + tunnel: 'http://localhost:3061/', + tracesSampleRate: 1.0, +}); + export const app = run({ async loadModule(moduleUrl, exportName) { - let mod = await import(moduleUrl); + const mod = await import(moduleUrl); return mod[exportName]; }, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index a3c153578aa6..7c89e32313c7 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -7,6 +7,13 @@ export const assets = createAssetServer({ allowPackages: ['remix', '@sentry/remix'], minify: true, watch: false, + scripts: { + // No bundler means no build time env inlining, so `define` is the only way to get configuration + // into a browser module. The asset server substitutes these when it compiles. + define: { + 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN), + }, + }, }); const entry = 'app/actions/public/entry.ts'; diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts new file mode 100644 index 000000000000..60cc59e24379 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +const APP_NAME = 'remix-v3'; + +test('sends a pageload span', async ({ page }) => { + const spanPromise = waitForStreamedSpan(APP_NAME, span => getSpanOp(span) === 'pageload' && span.is_segment === true); + + await page.goto('/'); + + const span = await spanPromise; + // Ordinary document load, so this span comes from the upstream integration and takes the low + // cardinality streaming name. + expect(span.name).toBe('Pageload'); + expect(span.attributes?.['sentry.origin']?.value).toBe('auto.pageload.browser'); +}); + +test('sends a navigation span for a link the runtime intercepts', async ({ page }) => { + await page.goto('/'); + + // Selected by origin, not just op. `remix/ui` never touches History, so a span from the upstream + // handler would mean this SDK did not produce it. + const spanPromise = waitForStreamedSpan( + APP_NAME, + span => + getSpanOp(span) === 'navigation' && span.attributes?.['sentry.origin']?.value === 'auto.navigation.remix_v3', + ); + + await page.locator('#to-user').click(); + await expect(page.locator('#user')).toBeVisible(); + + const span = await spanPromise; + expect(span.is_segment).toBe(true); + expect(span.name).toBe('Navigation'); +}); diff --git a/packages/remix/package.json b/packages/remix/package.json index 930d812dbd70..aa6114626354 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -61,7 +61,7 @@ "./v3/client": { "import": { "types": "./build/types/v3/index.client.d.ts", - "default": "./build/esm/v3/index.client.js" + "default": "./build/esm/v3/client-bundle.js" } }, "./v3/node": { diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index d6992abc3302..e4938499f1ec 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -1,3 +1,5 @@ +/* eslint-disable import/no-named-as-default */ +import nodeResolve from '@rollup/plugin-node-resolve'; import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOrchestrionLoader } from '@sentry-internal/rollup-utils'; @@ -8,6 +10,39 @@ const v3NodeEntry = defineConfig({ output: { format: 'esm', file: 'build/v3-node.mjs' }, }); +/** + * The Remix 3 browser entry, bundled into a single self contained ES module. + * + * Remix 3 has no bundler. Its asset server serves one HTTP request per module and never drops dead + * code, because it only ever looks at one module at a time. The ordinary `preserveModules` output + * therefore costs an app 255 requests and about 364 KB gzipped of `@sentry/*`; bundled here it is 2 + * requests and 54 KB. Publish time is the only place that reduction can happen. + * + * Runs over `build/esm/v3/index.client.js` rather than the TypeScript source, to reuse the transpilation + * the main config already did. That is why it has to come last in this array. + * + * Left unminified and without a source map on purpose. The asset server minifies what it serves and + * builds its own map chain, so a pre-minified file with its own map would add a second chain for + * nothing. + */ +const v3ClientBundle = defineConfig({ + input: 'build/esm/v3/index.client.js', + external: id => id === 'remix' || id.startsWith('@remix-run/'), + treeshake: { moduleSideEffects: false, propertyReadSideEffects: false }, + plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })], + // Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to + // the channel shim, which only resolves from there. + output: { file: 'build/esm/v3/client-bundle.js', format: 'esm' }, + onwarn(warning, warn) { + // `this` is undefined in the bundled output of some dependencies, and the graph has cycles. Both + // are harmless here and would otherwise bury real warnings. + if (warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'CIRCULAR_DEPENDENCY') { + return; + } + warn(warning); + }, +}); + // We rely on esbuild's defaults for JSX (`jsx: 'transform'` = classic runtime, no // __self/__source attributes). React 19 prefers the new automatic transform, but switching // to it would break React 17 support — so we intentionally stay on classic for now. @@ -39,4 +74,5 @@ export default [ }), ), ...makeOrchestrionLoader('./build'), + v3ClientBundle, ]; diff --git a/packages/remix/src/v3/client/browserTracingIntegration.ts b/packages/remix/src/v3/client/browserTracingIntegration.ts new file mode 100644 index 000000000000..c9832db5c4cd --- /dev/null +++ b/packages/remix/src/v3/client/browserTracingIntegration.ts @@ -0,0 +1,106 @@ +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, + WINDOW, +} from '@sentry/browser'; +import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { + type Client, + hasSpanStreamingEnabled, + type Integration, + NAVIGATION_SPAN_NAME_FALLBACK, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '@sentry/core'; + +type Options = Parameters[0]; + +/** + * Browser tracing for Remix 3. + * + * Page loads stay with the upstream integration, because they are ordinary document loads. Navigations + * do not: `remix/ui` intercepts links and form submissions through the Navigation API and never touches + * History, so the upstream handler never fires. + */ +export function browserTracingIntegration(options: Options = {}): Integration { + const integration = originalBrowserTracingIntegration({ ...options, instrumentNavigation: false }); + + return { + ...integration, + afterAllSetup(client) { + integration.afterAllSetup(client); + + if (options.instrumentNavigation !== false) { + instrumentNavigationApi(client); + } + }, + }; +} + +function instrumentNavigationApi(client: Client): void { + const navigation = (WINDOW as WindowWithNavigation).navigation; + if (!navigation) { + // Without the Navigation API the runtime does full document loads, which are page loads already. + return; + } + + navigation.addEventListener('navigate', event => { + const url = event.destination?.url; + if (!url || !isRuntimeNavigation(event, url)) { + return; + } + + startBrowserTracingNavigationSpan( + client, + { + // Remix 3 gives the browser no route to name this after: `remix/ui` exposes no matched route + // and never matches client side. Passing the server's pattern down is tracked in (#24872). + name: hasSpanStreamingEnabled(client) ? NAVIGATION_SPAN_NAME_FALLBACK : pathnameOf(url) || '/', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.remix_v3', + }, + }, + // The span needs the destination: `location` still points at the previous page until the + // navigation finishes. + { url }, + ); + }); +} + +/** + * Whether the runtime will keep this navigation inside the current document. + * + * The same three conditions `startNavigationListener` in `@remix-run/ui` checks before intercepting. + * What it declines becomes a new document load, which already gets a page load span, so a navigation + * span here would count the same click twice. + */ +function isRuntimeNavigation(event: NavigateEventLike, url: string): boolean { + // `'remix-document-reload'` is the `info` value the runtime tags its own document reloads with. + return event.canIntercept && event.info !== 'remix-document-reload' && isSameOrigin(url); +} + +function isSameOrigin(url: string): boolean { + try { + return new URL(url).origin === WINDOW.location?.origin; + } catch { + return false; + } +} + +function pathnameOf(url: string): string | undefined { + try { + return new URL(url).pathname; + } catch { + return undefined; + } +} + +interface NavigateEventLike extends Event { + canIntercept: boolean; + info?: unknown; + destination?: { url: string }; +} + +type WindowWithNavigation = typeof WINDOW & { + navigation?: { addEventListener(type: 'navigate', listener: (event: NavigateEventLike) => void): void }; +}; diff --git a/packages/remix/src/v3/client/sdk.ts b/packages/remix/src/v3/client/sdk.ts new file mode 100644 index 000000000000..463de70e504b --- /dev/null +++ b/packages/remix/src/v3/client/sdk.ts @@ -0,0 +1,28 @@ +import type { BrowserOptions } from '@sentry/browser'; +import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browserInit } from '@sentry/browser'; +import { applySdkMetadata, type Client, type Integration } from '@sentry/core'; + +import { browserTracingIntegration } from './browserTracingIntegration'; + +/** + * Default integrations for the Remix 3 client SDK. + * + * Browser tracing is added here rather than left to the app, because the Navigation API variant is the + * only one that reports anything in Remix 3. Everything else is plain `@sentry/browser`. Nothing from + * `@sentry/react` applies: `remix/ui` is its own runtime, with no React and no reconciler to hook. + */ +export function getDefaultIntegrations(options: BrowserOptions): Integration[] { + return [...getBrowserDefaultIntegrations(options), browserTracingIntegration()]; +} + +/** Initialize the Sentry Remix 3 SDK in the browser. */ +export function init(options: BrowserOptions): Client | undefined { + const opts = { + ...options, + defaultIntegrations: options.defaultIntegrations ?? getDefaultIntegrations(options), + }; + + applySdkMetadata(opts, 'remix', ['remix', 'browser']); + + return browserInit(opts); +} diff --git a/packages/remix/src/v3/index.client.ts b/packages/remix/src/v3/index.client.ts index 7cdc7036be5a..761d76bd0e54 100644 --- a/packages/remix/src/v3/index.client.ts +++ b/packages/remix/src/v3/index.client.ts @@ -1,8 +1,76 @@ -// A named list rather than `export * from '@sentry/browser'`: Remix 3 has no bundler, so a wildcard -// makes every export reachable and nothing can be tree shaken out of the served module graph. +// A named list rather than `export * from '@sentry/browser'`. // -// `init` is withheld until the Remix 3 browser SDK lands. Re-exporting `@sentry/browser`'s would give -// an app History based tracing, which yields no navigation spans in Remix 3, so it would look -// configured while reporting nothing. -export { captureException, captureMessage } from '@sentry/browser'; +// Remix 3 has no bundler, so this list is what gets tree shaken. A wildcard re-export would reach every +// export of `@sentry/browser`, including Replay, Feedback and the full attribute tables, and none of it +// could be dropped. Add whatever is needed, but keep it a named list. The size limit budget for +// `client-bundle.js` is what enforces that. +// +// Everything comes from `@sentry/browser`, never `@sentry/core`, even where `@sentry/browser` only +// passes a symbol through. The two packages export different `startSpan`, `startInactiveSpan` and +// `startSpanManual`, and only `@sentry/browser`'s installs span streaming on first use. +export { + addBreadcrumb, + addEventProcessor, + addIntegration, + breadcrumbsIntegration, + browserApiErrorsIntegration, + BrowserClient, + captureEvent, + captureException, + captureFeedback, + captureMessage, + captureSession, + close, + continueTrace, + dedupeIntegration, + defaultStackParser, + endSession, + eventFiltersIntegration, + flush, + functionToStringIntegration, + getActiveSpan, + getClient, + getCurrentScope, + getGlobalScope, + getIsolationScope, + getRootSpan, + getTraceData, + globalHandlersIntegration, + httpContextIntegration, + isEnabled, + isInitialized, + lastEventId, + linkedErrorsIntegration, + logger, + makeFetchTransport, + metrics, + parameterize, + Scope, + SDK_VERSION, + setAttribute, + setAttributes, + setContext, + setCurrentClient, + setExtra, + setExtras, + setTag, + setTags, + setUser, + spanToJSON, + spanToTraceHeader, + startInactiveSpan, + startSession, + startSpan, + startSpanManual, + suppressTracing, + updateSpanName, + WINDOW, + withActiveSpan, + withIsolationScope, + withScope, +} from '@sentry/browser'; + export type { BrowserOptions } from '@sentry/browser'; + +export { getDefaultIntegrations, init } from './client/sdk'; +export { browserTracingIntegration } from './client/browserTracingIntegration'; diff --git a/packages/remix/test/v3/browserTracingIntegration.test.ts b/packages/remix/test/v3/browserTracingIntegration.test.ts new file mode 100644 index 000000000000..2a78829b1107 --- /dev/null +++ b/packages/remix/test/v3/browserTracingIntegration.test.ts @@ -0,0 +1,119 @@ +import type { Client } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const startBrowserTracingNavigationSpan = vi.fn(); +const upstreamIntegration = { name: 'BrowserTracing', afterAllSetup: vi.fn() }; +const upstreamOptions: { instrumentNavigation?: boolean }[] = []; + +vi.mock('@sentry/browser', () => ({ + browserTracingIntegration: (options: { instrumentNavigation?: boolean }) => { + upstreamOptions.push(options); + return upstreamIntegration; + }, + startBrowserTracingNavigationSpan: (...args: unknown[]) => startBrowserTracingNavigationSpan(...args), + WINDOW: globalThis, +})); + +const { browserTracingIntegration } = await import('../../src/v3/client/browserTracingIntegration'); + +interface FakeNavigateEvent { + canIntercept: boolean; + info?: unknown; + destination?: { url: string }; +} + +const client = { getOptions: () => ({ traceLifecycle: 'stream' }) } as unknown as Client; + +/** Sets up the integration over a fake Navigation API and returns a way to fire `navigate` events. */ +function setup(options = {}): (event: FakeNavigateEvent) => void { + let listener: ((event: FakeNavigateEvent) => void) | undefined; + + Object.assign(globalThis, { + location: { origin: 'https://app.test', pathname: '/' }, + navigation: { + addEventListener: (_type: string, fn: (event: FakeNavigateEvent) => void) => { + listener = fn; + }, + }, + }); + + browserTracingIntegration(options).afterAllSetup?.(client); + + return event => listener?.(event); +} + +describe('browserTracingIntegration', () => { + beforeEach(() => { + startBrowserTracingNavigationSpan.mockClear(); + upstreamOptions.length = 0; + }); + + it('leaves page loads to the upstream integration and takes over navigation', () => { + setup(); + + expect(upstreamOptions[0]).toEqual({ instrumentNavigation: false }); + }); + + it('starts a navigation span for an intercepted navigation', () => { + const navigate = setup(); + + navigate({ canIntercept: true, destination: { url: 'https://app.test/users/12345' } }); + + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + client, + { + // Low cardinality, because the browser has no route patterns. + name: 'Navigation', + attributes: { + 'sentry.segment.name.source': 'url', + 'sentry.origin': 'auto.navigation.remix_v3', + }, + }, + { url: 'https://app.test/users/12345' }, + ); + }); + + // Each of these becomes a new document load, which the upstream integration reports as a page load, + // so a navigation span would count the same action twice. + it.each([ + ['the runtime cannot intercept it', { canIntercept: false, destination: { url: 'https://app.test/x' } }], + [ + "it is the runtime's own document reload", + { canIntercept: true, info: 'remix-document-reload', destination: { url: 'https://app.test/x' } }, + ], + ['it leaves the origin', { canIntercept: true, destination: { url: 'https://elsewhere.test/x' } }], + ['there is no destination', { canIntercept: true }], + ])('starts no navigation span when %s', (_why, event) => { + const navigate = setup(); + + navigate(event as FakeNavigateEvent); + + expect(startBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + }); + + it('names the span after the path when span streaming is off', () => { + const staticClient = { getOptions: () => ({ traceLifecycle: 'static' }) } as unknown as Client; + let listener: ((event: FakeNavigateEvent) => void) | undefined; + Object.assign(globalThis, { + location: { origin: 'https://app.test', pathname: '/' }, + navigation: { addEventListener: (_t: string, fn: typeof listener) => void (listener = fn) }, + }); + browserTracingIntegration().afterAllSetup?.(staticClient); + + listener?.({ canIntercept: true, destination: { url: 'https://app.test/users/12345?q=1' } }); + + expect(startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + staticClient, + expect.objectContaining({ name: '/users/12345' }), + { url: 'https://app.test/users/12345?q=1' }, + ); + }); + + it('starts no navigation span when instrumentNavigation is off', () => { + const navigate = setup({ instrumentNavigation: false }); + + navigate({ canIntercept: true, destination: { url: 'https://app.test/users/1' } }); + + expect(startBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/remix/test/v3/sdk.test.ts b/packages/remix/test/v3/sdk.test.ts new file mode 100644 index 000000000000..1436966ddb91 --- /dev/null +++ b/packages/remix/test/v3/sdk.test.ts @@ -0,0 +1,71 @@ +import type * as SentryBrowser from '@sentry/browser'; +import type { BrowserOptions } from '@sentry/browser'; +import { describe, expect, it, vi } from 'vitest'; + +const browserInit = vi.fn(); + +// Only `init` is replaced. Everything else stays real, so the integration assertion below is checked +// against the actual `@sentry/browser` defaults. +vi.mock('@sentry/browser', async importOriginal => ({ + ...(await importOriginal()), + init: (options: BrowserOptions) => browserInit(options), +})); + +const { getDefaultIntegrations, init } = await import('../../src/v3/client/sdk'); + +describe('getDefaultIntegrations', () => { + // `@sentry/browser`'s own defaults do not include browser tracing today, so this list adds it. If + // that ever changes upstream, a Remix 3 app would quietly end up with two browser tracing + // integrations, one of which reports no navigations at all. + it('adds exactly one browser tracing integration', () => { + const integrations = getDefaultIntegrations({}); + + expect(integrations.filter(integration => integration.name === 'BrowserTracing')).toHaveLength(1); + }); +}); + +describe('init', () => { + // The Remix 3 code ships inside `@sentry/remix`, so that is the package events have to name. Any + // other name is not installable from npm. + it('reports installable package names', () => { + init({}); + + expect(browserInit).toHaveBeenCalledWith( + expect.objectContaining({ + _metadata: { + sdk: { + name: 'sentry.javascript.remix', + version: expect.any(String), + packages: [ + { name: 'npm:@sentry/remix', version: expect.any(String) }, + { name: 'npm:@sentry/browser', version: expect.any(String) }, + ], + }, + }, + }), + ); + }); +}); + +describe('the client entry', () => { + // For the span start APIs the two packages export different implementations, and only + // `@sentry/browser`'s installs span streaming on first use. Taking one from `@sentry/core` is + // invisible until an app replaces the default integrations, because browser tracing installs span + // streaming anyway. + it('re-exports @sentry/browser, never a @sentry/core lookalike', async () => { + const [entry, browser] = await Promise.all([ + import('../../src/v3/index.client'), + vi.importActual('@sentry/browser'), + ]); + + // The three the Remix 3 SDK deliberately replaces. + const overridden = ['init', 'getDefaultIntegrations', 'browserTracingIntegration']; + const shared = Object.keys(entry).filter(name => name in browser && !overridden.includes(name)); + const wrongSource = shared.filter( + name => entry[name as keyof typeof entry] !== browser[name as keyof typeof browser], + ); + + expect(shared.length).toBeGreaterThan(20); + expect(wrongSource).toEqual([]); + }); +}); From e4d6f6460f8db0f89c4cd8fde5706dfe57d1b4e0 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 1 Oct 2026 14:57:47 +0100 Subject: [PATCH 16/57] fix(bundler-plugins): Keep debug IDs on Vite source maps after the preload rewrite (#24920) With `sourcemaps.disable: 'disable-upload'`, Vite builds shipped some source maps without a debug ID. Vite's own `generateBundle` step rewrites every chunk with a dynamic import to add module preloading, regenerates that chunk's source map and overwrites the `.map` asset. This runs after the Sentry plugin's `pre` hook stamped the map, so the stamp is lost. Sentry then cannot match the map to the chunk. `sentryVitePlugin()` now also returns a `post` plugin that re-stamps the maps in a `post` `generateBundle` hook. It leaves the chunk code alone, so file hashes stay valid. Reproduced on Vite 7.3 and 8.0, and the new integration fixture fails without the fix. Split out of #23665 by @chen-anders. --------- Co-authored-by: Anders Chen --- .../sourcemaps-disable-upload.config.d.ts | 2 ++ .../sourcemaps-disable-upload.config.js | 6 ++++ .../vite7/sourcemaps-disable-upload.config.ts | 19 +++++++++++ .../vite7/sourcemaps-disable-upload.test.ts | 25 +++++++++++++++ .../fixtures/vite7/src/dynamic-import.js | 5 +++ .../vite8/sourcemaps-disable-upload.config.ts | 19 +++++++++++ .../vite8/sourcemaps-disable-upload.test.ts | 25 +++++++++++++++ .../fixtures/vite8/src/dynamic-import.js | 5 +++ .../src/rollup/debug-id-stamping.ts | 32 +++++++++++++++++++ packages/bundler-plugins/src/rollup/index.ts | 31 ++---------------- packages/bundler-plugins/src/vite/index.ts | 27 ++++++++++++++-- 11 files changed, 165 insertions(+), 31 deletions(-) create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.d.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.config.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/vite7/src/dynamic-import.js create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.config.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.test.ts create mode 100644 dev-packages/bundler-plugin-integration-tests/fixtures/vite8/src/dynamic-import.js create mode 100644 packages/bundler-plugins/src/rollup/debug-id-stamping.ts diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.d.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.d.ts new file mode 100644 index 000000000000..192b614bc29b --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.d.ts @@ -0,0 +1,2 @@ +import type { SentryRollupPluginOptions } from "@sentry/bundler-plugins/rollup"; +export declare const sentryConfig: SentryRollupPluginOptions; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.js b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.js new file mode 100644 index 000000000000..91b4c2a30c83 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/configs/sourcemaps-disable-upload.config.js @@ -0,0 +1,6 @@ +export const sentryConfig = { + telemetry: false, + sourcemaps: { + disable: "disable-upload", + }, +}; diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.config.ts new file mode 100644 index 000000000000..aec427a21800 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.config.ts @@ -0,0 +1,19 @@ +import { sentryVitePlugin } from "@sentry/bundler-plugins/vite"; +import { defineConfig } from "vite"; +import { sentryConfig } from "../configs/sourcemaps-disable-upload.config.js"; + +export default defineConfig({ + build: { + minify: false, + sourcemap: true, + rollupOptions: { + input: "src/dynamic-import.js", + output: { + dir: "out/sourcemaps-disable-upload", + entryFileNames: "[name].js", + chunkFileNames: "[name].js", + }, + }, + }, + plugins: [sentryVitePlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.test.ts new file mode 100644 index 000000000000..a3c65f8e7755 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/sourcemaps-disable-upload.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { test } from "./utils"; + +// Vite rewrites the source map of chunks with dynamic imports after the Sentry plugin stamps it. +// The debug ID must survive that for every chunk. Files are read raw here because +// `readOutputFiles` zeroes debug IDs, which would hide a mismatch between chunk and map. +test(import.meta.url, ({ runBundler, outDir }) => { + runBundler(); + + const entry = readFileSync(join(outDir, "dynamic-import.js"), "utf-8"); + expect(entry).toContain("__vitePreload"); + + for (const fileName of ["dynamic-import.js", "import.js"]) { + const chunk = readFileSync(join(outDir, fileName), "utf-8"); + const debugId = chunk.match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + const map = JSON.parse(readFileSync(join(outDir, `${fileName}.map`), "utf-8")); + + expect(debugId).toBeDefined(); + expect(chunk).toContain(`//# debugId=${debugId}`); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + } +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/src/dynamic-import.js b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/src/dynamic-import.js new file mode 100644 index 000000000000..717d73bf4fe3 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite7/src/dynamic-import.js @@ -0,0 +1,5 @@ +export async function load() { + return import("./import.js"); +} + +load(); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.config.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.config.ts new file mode 100644 index 000000000000..aec427a21800 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.config.ts @@ -0,0 +1,19 @@ +import { sentryVitePlugin } from "@sentry/bundler-plugins/vite"; +import { defineConfig } from "vite"; +import { sentryConfig } from "../configs/sourcemaps-disable-upload.config.js"; + +export default defineConfig({ + build: { + minify: false, + sourcemap: true, + rollupOptions: { + input: "src/dynamic-import.js", + output: { + dir: "out/sourcemaps-disable-upload", + entryFileNames: "[name].js", + chunkFileNames: "[name].js", + }, + }, + }, + plugins: [sentryVitePlugin(sentryConfig)], +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.test.ts b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.test.ts new file mode 100644 index 000000000000..a3c65f8e7755 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/sourcemaps-disable-upload.test.ts @@ -0,0 +1,25 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { test } from "./utils"; + +// Vite rewrites the source map of chunks with dynamic imports after the Sentry plugin stamps it. +// The debug ID must survive that for every chunk. Files are read raw here because +// `readOutputFiles` zeroes debug IDs, which would hide a mismatch between chunk and map. +test(import.meta.url, ({ runBundler, outDir }) => { + runBundler(); + + const entry = readFileSync(join(outDir, "dynamic-import.js"), "utf-8"); + expect(entry).toContain("__vitePreload"); + + for (const fileName of ["dynamic-import.js", "import.js"]) { + const chunk = readFileSync(join(outDir, fileName), "utf-8"); + const debugId = chunk.match(/sentry-dbid-([0-9a-f-]{36})/)?.[1]; + const map = JSON.parse(readFileSync(join(outDir, `${fileName}.map`), "utf-8")); + + expect(debugId).toBeDefined(); + expect(chunk).toContain(`//# debugId=${debugId}`); + expect(map.debug_id).toBe(debugId); + expect(map.debugId).toBe(debugId); + } +}); diff --git a/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/src/dynamic-import.js b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/src/dynamic-import.js new file mode 100644 index 000000000000..717d73bf4fe3 --- /dev/null +++ b/dev-packages/bundler-plugin-integration-tests/fixtures/vite8/src/dynamic-import.js @@ -0,0 +1,5 @@ +export async function load() { + return import("./import.js"); +} + +load(); diff --git a/packages/bundler-plugins/src/rollup/debug-id-stamping.ts b/packages/bundler-plugins/src/rollup/debug-id-stamping.ts new file mode 100644 index 000000000000..f2d3d959df97 --- /dev/null +++ b/packages/bundler-plugins/src/rollup/debug-id-stamping.ts @@ -0,0 +1,32 @@ +import { isJsFile, stampDebugId } from '../core'; + +// The subset of Rollup's `OutputBundle` the stamping hooks read. +export type OutputBundle = Record< + string, + | { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null } + | { type: 'asset'; fileName: string; source: string | Uint8Array } +>; + +export function stampDebugIds(bundle: OutputBundle, updateChunkCode: boolean): void { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !isJsFile(output.fileName)) { + continue; + } + + const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`]; + const sourceMapSource = + sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string' ? sourceMapAsset.source : undefined; + + const stamped = stampDebugId(output.code, sourceMapSource); + if (!stamped) { + continue; + } + + if (updateChunkCode) { + output.code = stamped.bundleSource; + } + if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') { + sourceMapAsset.source = stamped.sourceMapSource; + } + } +} diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index 935163f961cc..b3cc6861f55e 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -11,7 +11,6 @@ import { createComponentNameAnnotateHooks, replaceBooleanFlagsInCode, CodeInjection, - stampDebugId, getCodeInjectionPosition, } from '../core'; import type { ComponentAnnotationTransformMeta } from '../core/component-annotation-oxc'; @@ -20,19 +19,13 @@ import MagicString from 'magic-string'; import * as path from 'node:path'; import { createRequire } from 'node:module'; import { finalizeRolldownDebugIds, getDebugIdForChunk, hasExistingDebugID } from './debug-id-injection'; +import { stampDebugIds, type OutputBundle } from './debug-id-stamping'; // The subset of Rollup's `TransformResult` that this plugin's `transform` // hook actually returns. Defined locally instead of imported from `rollup` // because `rollup` is an optional dependency. type TransformResult = { code: string; map?: SourceMap | string | { mappings: string } | null } | null | undefined; -// The subset of Rollup's `OutputBundle` the stamping hook reads. -type OutputBundle = Record< - string, - | { type: 'chunk'; fileName: string; code: string; sourcemapFileName?: string | null } - | { type: 'asset'; fileName: string; source: string | Uint8Array } ->; - type ViteModule = { parseAstAsync?: (code: string, options: { lang: 'jsx' | 'tsx' }) => Promise; }; @@ -251,27 +244,7 @@ export function _rollupPluginInternal( return; } - for (const output of Object.values(bundle)) { - if (output.type !== 'chunk' || !isJsFile(output.fileName)) { - continue; - } - - const sourceMapAsset = bundle[output.sourcemapFileName ?? `${output.fileName}.map`]; - const sourceMapSource = - sourceMapAsset?.type === 'asset' && typeof sourceMapAsset.source === 'string' - ? sourceMapAsset.source - : undefined; - - const stamped = stampDebugId(output.code, sourceMapSource); - if (!stamped) { - continue; - } - - output.code = stamped.bundleSource; - if (stamped.sourceMapSource !== undefined && sourceMapAsset?.type === 'asset') { - sourceMapAsset.source = stamped.sourceMapSource; - } - } + stampDebugIds(bundle, true); } async function writeBundle( diff --git a/packages/bundler-plugins/src/vite/index.ts b/packages/bundler-plugins/src/vite/index.ts index ea24f64e5d73..d9b06efd9c25 100644 --- a/packages/bundler-plugins/src/vite/index.ts +++ b/packages/bundler-plugins/src/vite/index.ts @@ -1,10 +1,15 @@ import type { SentryRollupPluginOptions } from '../rollup'; import { _rollupPluginInternal } from '../rollup'; import { createRequire } from 'node:module'; +import { stampDebugIds, type OutputBundle } from '../rollup/debug-id-stamping'; interface SentryVitePlugin { name: string; - enforce: 'pre'; + enforce: 'pre' | 'post'; + generateBundle?: { + order: 'pre' | 'post'; + handler: (_outputOptions: unknown, bundle: OutputBundle) => void; + }; } function getViteMajorVersion(): string | undefined { @@ -22,12 +27,30 @@ function getViteMajorVersion(): string | undefined { } export const sentryVitePlugin = (options?: SentryRollupPluginOptions): SentryVitePlugin[] => { - return [ + const plugins: SentryVitePlugin[] = [ { enforce: 'pre', ..._rollupPluginInternal(options, 'vite', getViteMajorVersion()), }, ]; + + if (!options?.disable && options?.sourcemaps?.disable === 'disable-upload') { + plugins.push({ + name: 'sentry-vite-debug-id-sourcemaps', + enforce: 'post', + generateBundle: { + order: 'post', + handler(_outputOptions, bundle) { + // Vite's module preload rewrite in `generateBundle` regenerates the source map of every + // chunk with a dynamic import, which drops the debug ID the main plugin stamped in its + // `pre` hook. Restore it without touching the chunk code, so file hashes stay valid. + stampDebugIds(bundle, false); + }, + }, + }); + } + + return plugins; }; export type { Options as SentryVitePluginOptions } from '../core'; From fe68d49ad89b79714421835a4313ff0d88c74f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 1 Oct 2026 17:17:50 +0300 Subject: [PATCH 17/57] fix(cloudflare): Skip binding instrumentation work when spans are not sent (#24655) The Durable Object SQL instrumentation sanitized and summarized each query, also when the span could not be sent: tracing not configured, or an unsampled parent. On SQL-heavy Durable Objects, this was a large CPU cost with `tracesSampleRate: 0`. In these cases, the query is not sanitized now. `startSpan` still runs with the name `exec` and the static attributes, so an unsampled span still records its `sample_rate` outcome. A query that can target a `cf_` table takes the full path, because an internal query must not start a span. Under span streaming with an unsampled parent, an `ignoreSpans` rule that matches the span name or `db.query.text` does not match now. Thus the dropped span is reported as `sample_rate`, not `ignored`. The number of dropped spans does not change. Rules that match `op: 'db.query'` are not affected. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: isaacs --- .../instrumentations/instrumentSqlStorage.ts | 51 +++++++++-- .../cloudflare/src/utils/internalSqlQuery.ts | 10 +++ .../instrumentDurableObjectStorage.test.ts | 3 + .../test/instrumentSqlStorage.test.ts | 89 ++++++++++++++++++- packages/cloudflare/test/testUtils.ts | 27 +++++- .../test/utils/internalSqlQuery.test.ts | 22 ++++- 6 files changed, 191 insertions(+), 11 deletions(-) diff --git a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts index e7ae5e5d173f..706960562f88 100644 --- a/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts +++ b/packages/cloudflare/src/instrumentations/instrumentSqlStorage.ts @@ -1,10 +1,24 @@ import type { SqlStorage } from '@cloudflare/workers-types'; import { SENTRY_OP } from '@sentry/conventions/attributes'; import { DB_QUERY } from '@sentry/conventions/op'; -import { getClient, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; +import { + getActiveSpan, + getClient, + hasSpansEnabled, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + spanIsSampled, + startSpan, +} from '@sentry/core'; import { getSqlQuerySummary, sanitizeSqlQuery } from '@sentry/server-utils'; import type { CloudflareClientOptions } from '../client'; -import { targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; +import { mayTargetCloudflareInternalTable, targetsCloudflareInternalTable } from '../utils/internalSqlQuery'; + +const SPAN_ATTRIBUTES = { + [SENTRY_OP]: DB_QUERY, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', +}; /** * Instruments the Durable Object SqlStorage `exec` method with Sentry spans. @@ -22,8 +36,19 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { } return function (this: unknown, ...args: unknown[]) { + const callOriginal = (): ReturnType => + (original as (...a: unknown[]) => ReturnType).apply(target, args); + const [query, ...bindings] = args as [string, ...unknown[]]; + if (childSpanWillNotBeRecorded() && !mayTargetCloudflareInternalTable(query)) { + // This span is never sent, so skip the costly sanitize and summary + // steps. We still start the span so it records its dropped span + // outcome. A query that may target a `cf_` table takes the full path, + // because an internal query must start no span. + return startSpan({ name: 'exec', attributes: SPAN_ATTRIBUTES }, callOriginal); + } + const sanitizedQuery = sanitizeSqlQuery(query); const querySummary = getSqlQuerySummary(sanitizedQuery); @@ -32,25 +57,35 @@ export function instrumentSqlStorage(sql: SqlStorage): SqlStorage { ?.durableObjectSqlSpanAllowlist; if (targetsCloudflareInternalTable(querySummary, allowlist, sanitizedQuery)) { - return (original as (...a: unknown[]) => ReturnType).apply(target, args); + return callOriginal(); } return startSpan( { name: querySummary || sanitizedQuery, attributes: { - [SENTRY_OP]: DB_QUERY, - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', - 'db.system.name': 'cloudflare-durable-object-sql', - 'db.operation.name': 'exec', + ...SPAN_ATTRIBUTES, 'db.query.text': sanitizedQuery, 'db.query.summary': querySummary, 'cloudflare.durable_object.query.bindings': bindings.length, }, }, - () => (original as (...a: unknown[]) => ReturnType).apply(target, args), + callOriginal, ); }; }, }); } + +/** + * Returns `true` when `startSpan` creates a non-recording span: spans are disabled, or the active span + * is not sampled. Must agree with `createChildOrRootSpan` and `_startChildSpan` in `@sentry/core`. + */ +function childSpanWillNotBeRecorded(): boolean { + if (!hasSpansEnabled()) { + return true; + } + + const activeSpan = getActiveSpan(); + return !!activeSpan && !spanIsSampled(activeSpan); +} diff --git a/packages/cloudflare/src/utils/internalSqlQuery.ts b/packages/cloudflare/src/utils/internalSqlQuery.ts index 7c67415d4da9..70d45a5af9d8 100644 --- a/packages/cloudflare/src/utils/internalSqlQuery.ts +++ b/packages/cloudflare/src/utils/internalSqlQuery.ts @@ -37,6 +37,16 @@ export function targetsCloudflareInternalTable( return tables.some(table => isCloudflareInternalTable(table, allowlist)); } +/** + * Returns `false` when the raw `query` has no word that starts with `cf_`, so it cannot target a + * Cloudflare internal table. Needs no sanitizing, which makes it cheap enough to run on every query. + */ +export function mayTargetCloudflareInternalTable(query: string): boolean { + return CF_PREFIX_RE.test(query); +} + +const CF_PREFIX_RE = /\bcf_/i; + // `CREATE [UNIQUE] INDEX [IF NOT EXISTS] ON ` — the IF EXISTS shape mirrors DDL_RE // in @sentry/core. const CREATE_INDEX_TABLE_RE = diff --git a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts index 27d393ddb6ee..af5161e5bcde 100644 --- a/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts +++ b/packages/cloudflare/test/instrumentDurableObjectStorage.test.ts @@ -3,6 +3,7 @@ import * as sentryCore from '@sentry/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { instrumentDurableObjectStorage } from '../src/instrumentations/instrumentDurableObjectStorage'; import * as traceLinks from '../src/utils/traceLinks'; +import { initTestClient, resetSdk } from './testUtils'; vi.mock('../src/utils/traceLinks', async importOriginal => { const actual = await importOriginal(); @@ -15,6 +16,7 @@ vi.mock('../src/utils/traceLinks', async importOriginal => { describe('instrumentDurableObjectStorage', () => { afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); describe('get', () => { @@ -301,6 +303,7 @@ describe('instrumentDurableObjectStorage', () => { }); it('instruments sql exec', () => { + initTestClient({ tracesSampleRate: 1 }); const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); const mockStorage = createMockStorage(); const instrumented = instrumentDurableObjectStorage(mockStorage); diff --git a/packages/cloudflare/test/instrumentSqlStorage.test.ts b/packages/cloudflare/test/instrumentSqlStorage.test.ts index 3436d0eb9032..8d76316460f3 100644 --- a/packages/cloudflare/test/instrumentSqlStorage.test.ts +++ b/packages/cloudflare/test/instrumentSqlStorage.test.ts @@ -1,11 +1,18 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import * as sentryCore from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as serverUtils from '@sentry/server-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { instrumentSqlStorage } from '../src/instrumentations/instrumentSqlStorage'; +import { initTestClient, resetSdk } from './testUtils'; describe('instrumentSqlStorage', () => { + beforeEach(() => { + initTestClient({ tracesSampleRate: 1 }); + }); + afterEach(() => { vi.restoreAllMocks(); + resetSdk(); }); it('instruments exec with summary as span name and sanitized query as db.query.text', () => { @@ -259,6 +266,86 @@ describe('instrumentSqlStorage', () => { }); }); }); + + it('starts a span without sanitizing the query when tracing is not configured', () => { + initTestClient(); + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const sanitizeSpy = vi.spyOn(serverUtils, 'sanitizeSqlQuery'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(startSpanSpy).toHaveBeenCalledWith( + { + name: 'exec', + attributes: { + 'sentry.op': 'db.query', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.cloudflare.durable_object.sql', + 'db.system.name': 'cloudflare-durable-object-sql', + 'db.operation.name': 'exec', + }, + }, + expect.any(Function), + ); + expect(sanitizeSpy).not.toHaveBeenCalled(); + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', 42); + }); + + describe('inside a parent span', () => { + it('with tracesSampleRate 1, sanitizes the query and names the span after it', () => { + const sanitizeSpy = vi.spyOn(serverUtils, 'sanitizeSqlQuery'); + const instrumented = instrumentSqlStorage(createMockSqlStorage()); + + sentryCore.startSpan({ name: 'parent' }, () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'SELECT users' }), + expect.any(Function), + ); + }); + + expect(sanitizeSpy).toHaveBeenCalledTimes(1); + }); + + it('with tracesSampleRate 0, records the dropped span without sanitizing the query', () => { + const client = initTestClient({ tracesSampleRate: 0 }); + const sanitizeSpy = vi.spyOn(serverUtils, 'sanitizeSqlQuery'); + const mockSql = createMockSqlStorage(); + const instrumented = instrumentSqlStorage(mockSql); + + sentryCore.startSpan({ name: 'parent' }, () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + instrumented.exec('SELECT * FROM users WHERE id = ?', 42); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(recordDroppedEventSpy).toHaveBeenCalledWith('sample_rate', 'span'); + }); + + expect(sanitizeSpy).not.toHaveBeenCalled(); + expect(mockSql.exec).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', 42); + }); + + it('with tracesSampleRate 0, records no dropped span for Cloudflare-internal queries', () => { + const client = initTestClient({ tracesSampleRate: 0 }); + const instrumented = instrumentSqlStorage(createMockSqlStorage()); + + sentryCore.startSpan({ name: 'parent' }, () => { + const startSpanSpy = vi.spyOn(sentryCore, 'startSpan'); + const recordDroppedEventSpy = vi.spyOn(client, 'recordDroppedEvent'); + + instrumented.exec('SELECT * FROM cf_agents_state WHERE id = ?', 'foo'); + + expect(startSpanSpy).not.toHaveBeenCalled(); + expect(recordDroppedEventSpy).not.toHaveBeenCalled(); + }); + }); + }); }); /** diff --git a/packages/cloudflare/test/testUtils.ts b/packages/cloudflare/test/testUtils.ts index 79bdfa403365..5956935f8eba 100644 --- a/packages/cloudflare/test/testUtils.ts +++ b/packages/cloudflare/test/testUtils.ts @@ -1,5 +1,7 @@ import { context, propagation, trace } from '@opentelemetry/api'; -import { getMainCarrier } from '@sentry/core'; +import { getMainCarrier, setCurrentClient } from '@sentry/core'; +import { vi } from 'vitest'; +import { CloudflareClient, type CloudflareClientOptions } from '../src/client'; import { _clearGlobalClientCache } from '../src/clientCache'; function resetGlobals(): void { @@ -18,3 +20,26 @@ export function resetSdk(): void { cleanupOtel(); _clearGlobalClientCache(); } + +/** + * Resets the SDK and sets a `CloudflareClient` with a DSN and a mock transport as the current client. + * `options` are passed to the client, for example `tracesSampleRate`. + */ +export function initTestClient(options: Partial = {}): CloudflareClient { + resetSdk(); + + const client = new CloudflareClient({ + dsn: 'https://123@sentry.io/42', + stackParser: () => [], + integrations: [], + transport: () => ({ + send: vi.fn().mockResolvedValue({}), + flush: vi.fn().mockResolvedValue(true), + }), + ...options, + }); + setCurrentClient(client); + client.init(); + + return client; +} diff --git a/packages/cloudflare/test/utils/internalSqlQuery.test.ts b/packages/cloudflare/test/utils/internalSqlQuery.test.ts index 72d01d58ad9e..0c9d6c641dde 100644 --- a/packages/cloudflare/test/utils/internalSqlQuery.test.ts +++ b/packages/cloudflare/test/utils/internalSqlQuery.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; +import { mayTargetCloudflareInternalTable, targetsCloudflareInternalTable } from '../../src/utils/internalSqlQuery'; // Behavioural coverage of the filter lives in `instrumentSqlStorage.test.ts`, which drives real // queries through the instrumented `exec`. What remains here are the signature-level contracts that @@ -23,3 +23,23 @@ describe('targetsCloudflareInternalTable', () => { expect(targetsCloudflareInternalTable('CREATE INDEX idx_agents_state_id')).toBe(false); }); }); + +describe('mayTargetCloudflareInternalTable', () => { + it.each([ + ['a cf_ table', 'SELECT * FROM cf_agents_state WHERE id = ?'], + ['an uppercase CF_ table', 'select * from CF_AGENTS_STATE'], + ['a quoted cf_ table', 'SELECT * FROM "cf_agents_state"'], + ['a schema-qualified cf_ table', 'SELECT * FROM main.cf_agents_state'], + ['a cf_ table in a CREATE INDEX ON clause', 'CREATE INDEX idx_agents_state_id ON cf_agents_state (id)'], + ])('returns true for %s', (_label, query) => { + expect(mayTargetCloudflareInternalTable(query)).toBe(true); + }); + + it.each([ + ['a user table', 'SELECT * FROM users WHERE id = ?'], + ['a table with cf in the middle', 'SELECT * FROM my_cf_table'], + ['a table starting with cfg', 'SELECT * FROM cfg_settings'], + ])('returns false for %s', (_label, query) => { + expect(mayTargetCloudflareInternalTable(query)).toBe(false); + }); +}); From 3c8ff896f0e66ba47492c7f57fca3ee71bedb465 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Thu, 1 Oct 2026 16:18:12 +0200 Subject: [PATCH 18/57] test(deno): Port integration tests to span streaming (#24870) Deno integration suites still pin static tracing and miss the SDK's default span-streaming behavior. Exercise streaming with span-envelope capture and streamed span assertions, while retaining the GraphQL suite as static lifecycle coverage. Fixes #24004 Fixes #24133 Co-authored-by: GPT-6 --- .../deno-integration-tests/src/index.ts | 9 ++ .../suites/deno-serve-request-bodies/test.ts | 146 ++++++++++-------- .../suites/direct-client-acs/scenario.mjs | 31 ++-- .../suites/orchestrion-amqplib/test.ts | 35 +++-- .../suites/orchestrion-anthropic/test.ts | 38 +++-- .../suites/orchestrion-aws/test.ts | 38 +++-- .../suites/orchestrion-express/test.ts | 38 +++-- .../suites/orchestrion-fastify/test.ts | 3 +- .../suites/orchestrion-firebase/test.ts | 38 +++-- .../suites/orchestrion-generic-pool/test.ts | 33 ++-- .../suites/orchestrion-google-genai/test.ts | 38 +++-- .../suites/orchestrion-graphql/test.ts | 2 +- .../suites/orchestrion-hapi/test.ts | 33 ++-- .../suites/orchestrion-kafkajs/test.ts | 37 +++-- .../suites/orchestrion-koa/test.ts | 31 ++-- .../suites/orchestrion-langchain/test.ts | 37 +++-- .../suites/orchestrion-langgraph/test.ts | 32 ++-- .../suites/orchestrion-lru-memoizer/test.ts | 27 ++-- .../suites/orchestrion-mongo/test.ts | 42 ++--- .../suites/orchestrion-mongoose/test.ts | 42 ++--- .../suites/orchestrion-mysql/test.ts | 38 +++-- .../suites/orchestrion-mysql2/test.ts | 40 ++--- .../suites/orchestrion-openai/test.ts | 38 +++-- .../suites/orchestrion-postgres/test.ts | 38 +++-- .../suites/orchestrion-postgresjs/test.ts | 34 ++-- .../suites/orchestrion-tedious/test.ts | 40 ++--- .../suites/orchestrion-vercel-ai/test.ts | 38 +++-- 27 files changed, 567 insertions(+), 429 deletions(-) diff --git a/dev-packages/deno-integration-tests/src/index.ts b/dev-packages/deno-integration-tests/src/index.ts index 5268dfd42a8a..3c18c1154838 100644 --- a/dev-packages/deno-integration-tests/src/index.ts +++ b/dev-packages/deno-integration-tests/src/index.ts @@ -72,6 +72,11 @@ export function transactionSink(): { }; } +export function getSpanOp(span: SerializedStreamedSpan): string | undefined { + const attribute = span.attributes['sentry.op']; + return attribute?.type === 'string' ? attribute.value : undefined; +} + /** * A `beforeSend` hook that records every error and lets a test * `await` the first one matching a predicate. `waitFor` resolves immediately if @@ -98,18 +103,22 @@ export function withTimeout(p: Promise, ms: number, what: string): Promise * (returning null is disallowed), so intercepting them needs a transport rather than a hook. */ export function spanSink(): { + spans: SerializedStreamedSpan[]; waitFor: (predicate: (span: SerializedStreamedSpan) => boolean) => Promise; transport: () => Transport; } { + const spans: SerializedStreamedSpan[] = []; const sink = eventSink(); return { + spans, waitFor: sink.waitFor, transport: () => ({ send: (envelope: Envelope) => { for (const [header, payload] of envelope[1]) { if (header.type === 'span') { for (const span of (payload as SerializedStreamedSpanContainer).items) { + spans.push(span); sink.beforeSend(span); } } diff --git a/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts b/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts index 49e732281bde..53f0c55877f8 100644 --- a/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts +++ b/dev-packages/deno-integration-tests/suites/deno-serve-request-bodies/test.ts @@ -1,18 +1,17 @@ // -import { denoServeIntegration, init } from '@sentry/deno'; +import { denoServeIntegration, flush, init } from '@sentry/deno'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('Deno.serve captures incoming request bodies by default', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const requestBody = 'captured-by-default'; @@ -25,31 +24,37 @@ Deno.test('Deno.serve captures incoming request bodies by default', async () => }); await listening; - const transactionPromise = withTimeout( - sink.waitFor(event => event.request?.url?.endsWith('/default') === true), - 5_000, - 'transaction for /default', - ); - const response = await fetch(`http://localhost:${server.addr.port}/default`, { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - body: requestBody, - }); - assertEquals(await response.text(), 'OK'); - - const transaction = await transactionPromise; - assertEquals(transaction.request?.data, requestBody); - - abortController.abort(); - await server.finished; + try { + const spanPromise = withTimeout( + sink.waitFor( + span => + span.is_segment && getSpanOp(span) === 'http.server' && span.attributes['url.path']?.value === '/default', + ), + 5_000, + 'segment span for /default', + ); + const response = await fetch(`http://localhost:${server.addr.port}/default`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + await flush(); + const span = await spanPromise; + assertEquals(span.name, 'POST'); + assertEquals(span.attributes['http.request.body.data']?.value, requestBody); + } finally { + abortController.abort(); + await server.finished; + } }); Deno.test('Deno.serve explicit small overrides disabled incoming request body collection', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, dataCollection: { httpBodies: [] }, @@ -57,7 +62,7 @@ Deno.test('Deno.serve explicit small overrides disabled incoming request body co ...integrations.filter(integration => integration.name !== 'DenoServe'), denoServeIntegration({ maxRequestBodySize: 'small' }), ], - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const requestBody = 'a'.repeat(1_001); @@ -71,31 +76,39 @@ Deno.test('Deno.serve explicit small overrides disabled incoming request body co }); await listening; - const transactionPromise = withTimeout( - sink.waitFor(event => event.request?.url?.endsWith('/explicit-small') === true), - 5_000, - 'transaction for /explicit-small', - ); - const response = await fetch(`http://localhost:${server.addr.port}/explicit-small`, { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - body: requestBody, - }); - assertEquals(await response.text(), 'OK'); - - const transaction = await transactionPromise; - assertEquals(transaction.request?.data, expectedBody); - - abortController.abort(); - await server.finished; + try { + const spanPromise = withTimeout( + sink.waitFor( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/explicit-small', + ), + 5_000, + 'segment span for /explicit-small', + ); + const response = await fetch(`http://localhost:${server.addr.port}/explicit-small`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: requestBody, + }); + assertEquals(await response.text(), 'OK'); + + await flush(); + const span = await spanPromise; + assertEquals(span.name, 'POST'); + assertEquals(span.attributes['http.request.body.data']?.value, expectedBody); + } finally { + abortController.abort(); + await server.finished; + } }); Deno.test('Deno.serve explicit none overrides enabled incoming request body collection', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, dataCollection: { httpBodies: ['incomingRequest'] }, @@ -103,7 +116,7 @@ Deno.test('Deno.serve explicit none overrides enabled incoming request body coll ...integrations.filter(integration => integration.name !== 'DenoServe'), denoServeIntegration({ maxRequestBodySize: 'none' }), ], - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const abortController = new AbortController(); @@ -115,21 +128,30 @@ Deno.test('Deno.serve explicit none overrides enabled incoming request body coll }); await listening; - const transactionPromise = withTimeout( - sink.waitFor(event => event.request?.url?.endsWith('/explicit-none') === true), - 5_000, - 'transaction for /explicit-none', - ); - const response = await fetch(`http://localhost:${server.addr.port}/explicit-none`, { - method: 'POST', - headers: { 'content-type': 'text/plain' }, - body: 'do-not-capture', - }); - assertEquals(await response.text(), 'OK'); - - const transaction = await transactionPromise; - assertEquals(transaction.request?.data, undefined); - - abortController.abort(); - await server.finished; + try { + const spanPromise = withTimeout( + sink.waitFor( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/explicit-none', + ), + 5_000, + 'segment span for /explicit-none', + ); + const response = await fetch(`http://localhost:${server.addr.port}/explicit-none`, { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'do-not-capture', + }); + assertEquals(await response.text(), 'OK'); + + await flush(); + const span = await spanPromise; + assertEquals(span.name, 'POST'); + assertEquals(span.attributes['http.request.body.data']?.value, undefined); + } finally { + abortController.abort(); + await server.finished; + } }); diff --git a/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs b/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs index 1dc48ebb60c2..3b045ec0829e 100644 --- a/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs +++ b/dev-packages/deno-integration-tests/suites/direct-client-acs/scenario.mjs @@ -12,22 +12,24 @@ import { nodeStackLineParser } from '@sentry/core/server'; import { DenoClient, getCurrentScope, getDefaultIntegrations, startSpan } from '@sentry/deno'; import { tracingChannel } from 'node:diagnostics_channel'; -let nested = false; +const spans = []; const client = new DenoClient({ dsn: 'https://username@domain/123', tracesSampleRate: 1, - traceLifecycle: 'static', integrations: getDefaultIntegrations({ tracesSampleRate: 1 }), stackParser: createStackParser(nodeStackLineParser()), - beforeSendTransaction(event) { - const spans = event.spans ?? []; - if (spans.some(s => s.op === 'db' && s.data?.['sentry.origin'] === 'auto.db.mysql')) { - nested = true; - } - return null; - }, - transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + transport: () => ({ + send(envelope) { + for (const [header, payload] of envelope[1]) { + if (header.type === 'span') { + spans.push(...payload.items); + } + } + return Promise.resolve({}); + }, + flush: () => Promise.resolve(true), + }), }); client.init(); @@ -50,5 +52,14 @@ startSpan({ name: 'parent', op: 'test' }, () => { await client.flush(2000); +const parent = spans.find(span => span.is_segment && span.name === 'parent'); +const nested = spans.some( + span => + span.attributes['sentry.op']?.value === 'db' && + span.attributes['sentry.origin']?.value === 'auto.db.mysql' && + span.parent_span_id === parent?.span_id && + span.trace_id === parent?.trace_id, +); + // eslint-disable-next-line no-console console.log(`SCENARIO nested=${nested}`); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts index d059cb76971c..9c1031ed9ee3 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-amqplib/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('amqplib instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('amqplib instrumentation: included in default integrations (Deno 2.8.0 Deno.test('amqplib instrumentation: orchestrion:amqplib:publish channel produces a nested queue.publish span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:amqplib:publish'); @@ -47,16 +45,25 @@ Deno.test('amqplib instrumentation: orchestrion:amqplib:publish channel produces }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const publishSpan = parent.spans?.find(s => s.op === 'queue.publish'); - assertExists(publishSpan, `expected a queue.publish child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(publishSpan!.description, 'publish my-exchange'); - assertEquals(publishSpan!.data?.['messaging.destination.name'], 'my-exchange'); - assertEquals(publishSpan!.data?.['messaging.system'], 'rabbitmq'); - assertEquals(publishSpan!.data?.['sentry.origin'], 'auto.amqplib.publisher'); + const publishSpan = children.find(s => getSpanOp(s) === 'queue.publish'); + assertExists( + publishSpan, + `expected a queue.publish child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(publishSpan.name, 'send my-exchange'); + assertEquals(publishSpan.attributes['messaging.destination.name']?.value, 'my-exchange'); + assertEquals(publishSpan.attributes['messaging.system']?.value, 'rabbitmq'); + assertEquals(publishSpan.attributes['sentry.origin']?.value, 'auto.amqplib.publisher'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts index 9372fd70934f..d4dc830311fc 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-anthropic/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('anthropic instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('anthropic instrumentation: included in default integrations (Deno 2.8 Deno.test('anthropic instrumentation: orchestrion @anthropic-ai/sdk:chat channel produces a nested gen_ai span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@anthropic-ai/sdk:chat'); @@ -46,19 +44,25 @@ Deno.test('anthropic instrumentation: orchestrion @anthropic-ai/sdk:chat channel channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.chat'); - assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(aiSpan!.description, 'chat claude-3-5-sonnet-latest'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'anthropic'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'chat'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'claude-3-5-sonnet-latest'); - assertEquals(aiSpan!.data?.['gen_ai.response.model'], 'claude-3-5-sonnet-20241022'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.anthropic'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.chat'); + assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(aiSpan.name, 'chat claude-3-5-sonnet-latest'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'anthropic'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'chat'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'claude-3-5-sonnet-latest'); + assertEquals(aiSpan.attributes['gen_ai.response.model']?.value, 'claude-3-5-sonnet-20241022'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.anthropic'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts index b07fb3da9b31..1beb142f3313 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-aws/test.ts @@ -3,17 +3,16 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { Span } from '@sentry/core'; import type { DenoClient } from '@sentry/deno'; -import { init, spanToJSON, startSpan } from '@sentry/deno'; +import { flush, init, spanToJSON, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; import { SENTRY_OP } from '@sentry/conventions/attributes'; Deno.test('aws-sdk instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -29,17 +28,16 @@ Deno.test('aws-sdk instrumentation: included in default integrations (Deno 2.8.0 // the parent span is held open until the `spanEnd` hook reports the child ended. Deno.test('aws-sdk instrumentation: orchestrion @smithy/smithy-client:send channel produces a nested rpc span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }) as DenoClient; // The rpc span ends only after the deferred region backfill settles. Wait on the // concrete `spanEnd` signal rather than a timer, so the parent stays open until the - // child has actually ended and can be captured on the transaction. + // child has actually ended before flushing the span buffer. const rpcSpanEnded = new Promise(resolve => { client.on('spanEnd', (span: Span) => { if (spanToJSON(span).attributes[SENTRY_OP] === 'rpc') { @@ -71,18 +69,24 @@ Deno.test('aws-sdk instrumentation: orchestrion @smithy/smithy-client:send chann await rpcSpanEnded; }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const awsSpan = parent.spans?.find(s => s.op === 'rpc'); - assertExists(awsSpan, `expected an rpc child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(awsSpan!.description, 'CloudWatch.DescribeAlarms'); - assertEquals(awsSpan!.data?.['rpc.system'], 'aws-api'); - assertEquals(awsSpan!.data?.['rpc.service'], 'CloudWatch'); - assertEquals(awsSpan!.data?.['rpc.method'], 'DescribeAlarms'); - assertEquals(awsSpan!.data?.['cloud.region'], 'us-east-1'); - assertEquals(awsSpan!.data?.['sentry.origin'], 'auto.aws.aws_sdk'); + const awsSpan = children.find(s => getSpanOp(s) === 'rpc'); + assertExists(awsSpan, `expected an rpc child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(awsSpan.name, 'CloudWatch.DescribeAlarms'); + assertEquals(awsSpan.attributes['rpc.system']?.value, 'aws-api'); + assertEquals(awsSpan.attributes['rpc.service']?.value, 'CloudWatch'); + assertEquals(awsSpan.attributes['rpc.method']?.value, 'DescribeAlarms'); + assertEquals(awsSpan.attributes['cloud.region']?.value, 'us-east-1'); + assertEquals(awsSpan.attributes['sentry.origin']?.value, 'auto.aws.aws_sdk'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts index 7fb2aafd0bf6..1b480f847fae 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-express/test.ts @@ -3,27 +3,26 @@ import { EventEmitter } from 'node:events'; import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('express instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Express'), `Express should be in defaults, got ${names.join(', ')}`); }); Deno.test('express instrumentation: orchestrion:express:handle channel produces a nested middleware span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:express:handle'); @@ -40,16 +39,25 @@ Deno.test('express instrumentation: orchestrion:express:handle channel produces channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); - - const expressSpan = parent.spans?.find(s => s.op === 'middleware'); - assertExists(expressSpan, `expected an express middleware span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(expressSpan!.description, 'myMiddleware'); - assertEquals(expressSpan!.data?.['express.name'], 'myMiddleware'); - assertEquals(expressSpan!.data?.['express.type'], 'middleware'); - assertEquals(expressSpan!.data?.['sentry.origin'], 'auto.http.express'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const expressSpan = children.find(s => getSpanOp(s) === 'middleware'); + assertExists( + expressSpan, + `expected an express middleware span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(expressSpan.name, 'myMiddleware'); + assertEquals(expressSpan.attributes['express.name']?.value, 'myMiddleware'); + assertEquals(expressSpan.attributes['express.type']?.value, 'middleware'); + assertEquals(expressSpan.attributes['sentry.origin']?.value, 'auto.http.express'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts index 276a74eda91c..a6f0f4fbdcd1 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-fastify/test.ts @@ -10,7 +10,7 @@ import { errorSink, resetGlobals, withTimeout } from '../../src/index.ts'; Deno.test('fastify instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Fastify'), `Fastify should be in defaults, got ${names.join(', ')}`); }); @@ -19,7 +19,6 @@ Deno.test('fastify instrumentation: tracing:fastify.request.handler:error channe resetGlobals(); const sink = errorSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', beforeSend: sink.beforeSend, }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts index 6d4d3e6ff87a..4f6440b8a677 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-firebase/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('firebase instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('firebase instrumentation: included in default integrations (Deno 2.8. Deno.test('firebase instrumentation: orchestrion @firebase/firestore:add-doc channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@firebase/firestore:add-doc'); @@ -52,19 +50,25 @@ Deno.test('firebase instrumentation: orchestrion @firebase/firestore:add-doc cha channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const fsSpan = parent.spans?.find(s => s.op === 'db.query'); - assertExists(fsSpan, `expected a db.query child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(fsSpan!.description, 'addDoc users'); - assertEquals(fsSpan!.data?.['db.operation.name'], 'addDoc'); - assertEquals(fsSpan!.data?.['db.collection.name'], 'users'); - assertEquals(fsSpan!.data?.['db.namespace'], '[DEFAULT]'); - assertEquals(fsSpan!.data?.['db.system.name'], 'firebase.firestore'); - assertEquals(fsSpan!.data?.['firebase.firestore.options.projectId'], 'demo-project'); - assertEquals(fsSpan!.data?.['sentry.origin'], 'auto.firebase.firestore'); + const fsSpan = children.find(s => getSpanOp(s) === 'db.query'); + assertExists(fsSpan, `expected a db.query child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(fsSpan.name, 'addDoc users'); + assertEquals(fsSpan.attributes['db.operation.name']?.value, 'addDoc'); + assertEquals(fsSpan.attributes['db.collection.name']?.value, 'users'); + assertEquals(fsSpan.attributes['db.namespace']?.value, '[DEFAULT]'); + assertEquals(fsSpan.attributes['db.system.name']?.value, 'firebase.firestore'); + assertEquals(fsSpan.attributes['firebase.firestore.options.projectId']?.value, 'demo-project'); + assertEquals(fsSpan.attributes['sentry.origin']?.value, 'auto.firebase.firestore'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts index 2f5258935c00..4733848fe2e3 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-generic-pool/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('generic-pool instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('generic-pool instrumentation: included in default integrations (Deno Deno.test('generic-pool instrumentation: orchestrion:generic-pool:acquire channel produces a nested span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:generic-pool:acquire'); @@ -43,17 +41,20 @@ Deno.test('generic-pool instrumentation: orchestrion:generic-pool:acquire channe }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", - ); - - const poolSpan = parent.spans?.find(s => s.description === 'generic-pool.acquire'); - assertExists( - poolSpan, - `expected a generic-pool.acquire span, got descriptions: ${parent.spans?.map(s => s.description).join(', ')}`, + "'parent' segment span", ); - assertEquals(poolSpan!.data?.['sentry.origin'], 'auto.db.generic_pool'); - assertEquals(poolSpan!.op, 'db'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const poolSpan = children.find(s => s.name === 'generic-pool.acquire'); + assertExists(poolSpan, `expected a generic-pool.acquire span, got names: ${children.map(s => s.name).join(', ')}`); + assertEquals(poolSpan.attributes['sentry.origin']?.value, 'auto.db.generic_pool'); + assertEquals(getSpanOp(poolSpan), 'db'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts index db193197e0f5..e28930b42161 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-google-genai/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('google-genai instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('google-genai instrumentation: included in default integrations (Deno Deno.test('google-genai instrumentation: orchestrion @google/genai:generate-content channel produces a nested gen_ai span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@google/genai:generate-content'); @@ -45,22 +43,28 @@ Deno.test('google-genai instrumentation: orchestrion @google/genai:generate-cont channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.generate_content'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.generate_content'); assertExists( aiSpan, - `expected a gen_ai.generate_content child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`, + `expected a gen_ai.generate_content child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, ); - assertEquals(aiSpan!.description, 'generate_content gemini-1.5-flash'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'google_genai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'generate_content'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'gemini-1.5-flash'); - assertEquals(aiSpan!.data?.['gen_ai.response.model'], 'gemini-1.5-flash-002'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.google_genai'); + assertEquals(aiSpan.name, 'generate_content gemini-1.5-flash'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'google_genai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'generate_content'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gemini-1.5-flash'); + assertEquals(aiSpan.attributes['gen_ai.response.model']?.value, 'gemini-1.5-flash-002'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.google_genai'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts index a352bd20b79d..2497b4f6eef9 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-graphql/test.ts @@ -16,6 +16,7 @@ async function assertParseSpan(channelName: string): Promise { resetGlobals(); const sink = transactionSink(); init({ + // Keep one integration suite on the static lifecycle as a regression guard. traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, @@ -49,7 +50,6 @@ async function assertParseSpan(channelName: string): Promise { Deno.test('graphql instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts index af0a9c92a0c4..28a1c2e49f06 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-hapi/test.ts @@ -2,27 +2,26 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('hapi instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Hapi'), `Hapi should be in defaults, got ${names.join(', ')}`); }); Deno.test('hapi instrumentation: orchestrion:@hapi/hapi:route channel wraps the route handler into a span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); // `start` wraps the route's `handler` in place; the span opens when that @@ -36,16 +35,22 @@ Deno.test('hapi instrumentation: orchestrion:@hapi/hapi:route channel wraps the wrappedRoute.handler({}, {}); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const hapiSpan = parent.spans?.find(s => s.op === 'router'); - assertExists(hapiSpan, `expected a router span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(hapiSpan!.description, 'GET /hello'); - assertEquals(hapiSpan!.data?.['hapi.type'], 'router'); - assertEquals(hapiSpan!.data?.['http.route'], '/hello'); - assertEquals(hapiSpan!.data?.['sentry.origin'], 'auto.http.hapi'); + const hapiSpan = children.find(s => getSpanOp(s) === 'router'); + assertExists(hapiSpan, `expected a router span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(hapiSpan.name, '/hello'); + assertEquals(hapiSpan.attributes['hapi.type']?.value, 'router'); + assertEquals(hapiSpan.attributes['http.route']?.value, '/hello'); + assertEquals(hapiSpan.attributes['sentry.origin']?.value, 'auto.http.hapi'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts index d94839209bd1..0265468722a3 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-kafkajs/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('kafkajs instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('kafkajs instrumentation: included in default integrations (Deno 2.8.0 Deno.test('kafkajs instrumentation: orchestrion:kafkajs:send_batch channel produces a nested producer span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:kafkajs:send_batch'); @@ -39,16 +37,25 @@ Deno.test('kafkajs instrumentation: orchestrion:kafkajs:send_batch channel produ channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); - - const kafkaSpan = parent.spans?.find(s => s.op === 'queue.publish'); - assertExists(kafkaSpan, `expected a queue.publish child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(kafkaSpan!.description, 'send my-topic'); - assertEquals(kafkaSpan!.data?.['messaging.system'], 'kafka'); - assertEquals(kafkaSpan!.data?.['messaging.destination.name'], 'my-topic'); - assertEquals(kafkaSpan!.data?.['sentry.origin'], 'auto.kafkajs.producer'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const kafkaSpan = children.find(s => getSpanOp(s) === 'queue.publish'); + assertExists( + kafkaSpan, + `expected a queue.publish child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(kafkaSpan.name, 'send my-topic'); + assertEquals(kafkaSpan.attributes['messaging.system']?.value, 'kafka'); + assertEquals(kafkaSpan.attributes['messaging.destination.name']?.value, 'my-topic'); + assertEquals(kafkaSpan.attributes['sentry.origin']?.value, 'auto.kafkajs.producer'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts index e084e4cd136a..d7bb9b689efa 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-koa/test.ts @@ -2,27 +2,26 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('koa instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); - const client = init({ traceLifecycle: 'static', dsn: 'https://username@domain/123' }) as DenoClient; + const client = init({ dsn: 'https://username@domain/123' }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('Koa'), `Koa should be in defaults, got ${names.join(', ')}`); }); Deno.test('koa instrumentation: orchestrion:koa:use channel wraps middleware into a span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); function myMiddleware(_context: unknown, next: () => Promise): Promise { @@ -38,14 +37,20 @@ Deno.test('koa instrumentation: orchestrion:koa:use channel wraps middleware int await wrappedMiddleware({}, () => Promise.resolve()); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); - - const koaSpan = parent.spans?.find(s => s.op === 'middleware'); - assertExists(koaSpan, `expected a koa middleware child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(koaSpan!.description, 'myMiddleware'); - assertEquals(koaSpan!.data?.['sentry.origin'], 'auto.http.koa'); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); + + const koaSpan = children.find(s => getSpanOp(s) === 'middleware'); + assertExists(koaSpan, `expected a koa middleware child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(koaSpan.name, 'myMiddleware'); + assertEquals(koaSpan.attributes['sentry.origin']?.value, 'auto.http.koa'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts index 26d5da42cb0f..a0b7782f2779 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-langchain/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('langchain instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('langchain instrumentation: included in default integrations (Deno 2.8 Deno.test('langchain instrumentation: orchestrion @langchain/openai:embedQuery channel produces a nested embeddings span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@langchain/openai:embedQuery'); @@ -43,17 +41,26 @@ Deno.test('langchain instrumentation: orchestrion @langchain/openai:embedQuery c channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.embeddings'); - assertExists(aiSpan, `expected a gen_ai.embeddings child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(aiSpan!.description, 'embeddings text-embedding-3-small'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'openai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'embeddings'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'text-embedding-3-small'); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.langchain'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.embeddings'); + assertExists( + aiSpan, + `expected a gen_ai.embeddings child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, + ); + assertEquals(aiSpan.name, 'embeddings text-embedding-3-small'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'openai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'embeddings'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'text-embedding-3-small'); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.langchain'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts index e1045407beb5..b10c4f1ed48d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-langgraph/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('langgraph instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('langgraph instrumentation: included in default integrations (Deno 2.8 Deno.test('langgraph instrumentation: orchestrion stateGraphCompile channel wraps the compiled graph invoke', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:@langchain/langgraph:stateGraphCompile'); @@ -44,19 +42,25 @@ Deno.test('langgraph instrumentation: orchestrion stateGraphCompile channel wrap await compiledGraph.invoke(); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const invokeAgentSpan = parent.spans?.find(s => s.op === 'gen_ai.invoke_agent'); + const invokeAgentSpan = children.find(s => getSpanOp(s) === 'gen_ai.invoke_agent'); assertExists( invokeAgentSpan, - `expected a gen_ai.invoke_agent child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`, + `expected a gen_ai.invoke_agent child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, ); - assertEquals(invokeAgentSpan!.description, 'invoke_agent my-agent'); - assertEquals(invokeAgentSpan!.data?.['gen_ai.operation.name'], 'invoke_agent'); - assertEquals(invokeAgentSpan!.data?.['gen_ai.agent.name'], 'my-agent'); - assertEquals(invokeAgentSpan!.data?.['sentry.origin'], 'auto.ai.langgraph'); + assertEquals(invokeAgentSpan.name, 'invoke_agent my-agent'); + assertEquals(invokeAgentSpan.attributes['gen_ai.operation.name']?.value, 'invoke_agent'); + assertEquals(invokeAgentSpan.attributes['gen_ai.agent.name']?.value, 'my-agent'); + assertEquals(invokeAgentSpan.attributes['sentry.origin']?.value, 'auto.ai.langgraph'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts index acff0649c8a7..9c2ba2971f11 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-lru-memoizer/test.ts @@ -3,16 +3,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { Span } from '@sentry/core'; import type { DenoClient } from '@sentry/deno'; -import { getActiveSpan, init, startSpan, startSpanManual } from '@sentry/deno'; +import { flush, getActiveSpan, init, startSpan, startSpanManual } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('lru-memoizer instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -28,12 +27,11 @@ Deno.test('lru-memoizer instrumentation: included in default integrations (Deno // new trace; with it, the parent is active again and a span nests under it. Deno.test('lru-memoizer instrumentation: restores the caller scope onto the memoized callback', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:lru-memoizer:load'); @@ -61,16 +59,19 @@ Deno.test('lru-memoizer instrumentation: restores the caller scope onto the memo // The callback saw the caller's span restored. assertEquals(restoredActive, parentSpan); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); // The span created in the restored callback nested under the caller, not a new trace. - const child = parent.spans?.find(s => s.description === 'memoized-work'); - assertExists( - child, - `expected memoized-work nested under parent, got: ${parent.spans?.map(s => s.description).join(', ')}`, - ); + const child = children.find(s => s.name === 'memoized-work'); + assertExists(child, `expected memoized-work nested under parent, got: ${children.map(s => s.name).join(', ')}`); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts index f9693fc07ef9..d7a482dbdaeb 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mongo/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mongodb instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('mongodb instrumentation: included in default integrations (Deno 2.8.0 Deno.test('mongodb instrumentation: orchestrion:mongodb:command channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mongodb:command'); @@ -50,21 +48,27 @@ Deno.test('mongodb instrumentation: orchestrion:mongodb:command channel produces }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mongoSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mongoSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mongoSpan!.description, '{"find":"?","filter":{"name":"?"}}'); - assertEquals(mongoSpan!.data?.['db.system.name'], 'mongodb'); - assertEquals(mongoSpan!.data?.['db.query.text'], '{"find":"?","filter":{"name":"?"}}'); - assertEquals(mongoSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(mongoSpan!.data?.['db.collection.name'], 'users'); - assertEquals(mongoSpan!.data?.['db.operation.name'], 'find'); - assertEquals(mongoSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mongoSpan!.data?.['server.port'], 27017); - assertEquals(mongoSpan!.data?.['sentry.origin'], 'auto.db.mongo'); + const mongoSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mongoSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mongoSpan.name, 'find users'); + assertEquals(mongoSpan.attributes['db.system.name']?.value, 'mongodb'); + assertEquals(mongoSpan.attributes['db.query.text']?.value, '{"find":"?","filter":{"name":"?"}}'); + assertEquals(mongoSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(mongoSpan.attributes['db.collection.name']?.value, 'users'); + assertEquals(mongoSpan.attributes['db.operation.name']?.value, 'find'); + assertEquals(mongoSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mongoSpan.attributes['server.port']?.value, 27017); + assertEquals(mongoSpan.attributes['sentry.origin']?.value, 'auto.db.mongo'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts index 46854c29b07a..1d3cb1033562 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mongoose/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mongoose instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('mongoose instrumentation: included in default integrations (Deno 2.8. Deno.test('mongoose instrumentation: orchestrion:mongoose:model_save channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mongoose:model_save'); @@ -51,21 +49,27 @@ Deno.test('mongoose instrumentation: orchestrion:mongoose:model_save channel pro }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mongooseSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mongooseSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mongooseSpan!.description, 'mongoose.BlogPost.save'); - assertEquals(mongooseSpan!.data?.['db.system.name'], 'mongodb'); - assertEquals(mongooseSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(mongooseSpan!.data?.['db.collection.name'], 'blogposts'); - assertEquals(mongooseSpan!.data?.['db.operation.name'], 'save'); - assertEquals(mongooseSpan!.data?.['db.user'], 'root'); - assertEquals(mongooseSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mongooseSpan!.data?.['server.port'], 27017); - assertEquals(mongooseSpan!.data?.['sentry.origin'], 'auto.db.mongoose'); + const mongooseSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mongooseSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mongooseSpan.name, 'save blogposts'); + assertEquals(mongooseSpan.attributes['db.system.name']?.value, 'mongodb'); + assertEquals(mongooseSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(mongooseSpan.attributes['db.collection.name']?.value, 'blogposts'); + assertEquals(mongooseSpan.attributes['db.operation.name']?.value, 'save'); + assertEquals(mongooseSpan.attributes['db.user']?.value, 'root'); + assertEquals(mongooseSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mongooseSpan.attributes['server.port']?.value, 27017); + assertEquals(mongooseSpan.attributes['sentry.origin']?.value, 'auto.db.mongoose'); }); 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 c263bdace328..6b46f548debb 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mysql instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -54,12 +53,11 @@ Deno.test('@sentry/deno/import: transforms mysql so it publishes the orchestrion Deno.test('mysql instrumentation: orchestrion:mysql:query channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mysql:query'); @@ -84,19 +82,25 @@ Deno.test('mysql instrumentation: orchestrion:mysql:query channel produces a nes }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mysqlSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mysqlSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mysqlSpan!.description, 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['db.system.name'], 'mysql'); - assertEquals(mysqlSpan!.data?.['db.query.text'], 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mysqlSpan!.data?.['server.port'], 3306); - assertEquals(mysqlSpan!.data?.['db.user'], 'root'); - assertEquals(mysqlSpan!.data?.['sentry.origin'], 'auto.db.mysql'); + const mysqlSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mysqlSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mysqlSpan.name, 'SELECT'); + assertEquals(mysqlSpan.attributes['db.system.name']?.value, 'mysql'); + assertEquals(mysqlSpan.attributes['db.query.text']?.value, 'SELECT ? AS solution'); + assertEquals(mysqlSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mysqlSpan.attributes['server.port']?.value, 3306); + assertEquals(mysqlSpan.attributes['db.user']?.value, 'root'); + assertEquals(mysqlSpan.attributes['sentry.origin']?.value, 'auto.db.mysql'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts index 1c8efe1364e9..85a992fe89ab 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql2/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('mysql2 instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('mysql2 instrumentation: included in default integrations (Deno 2.8.0+ Deno.test('mysql2 instrumentation: orchestrion:mysql2:query channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:mysql2:query'); @@ -46,20 +44,26 @@ Deno.test('mysql2 instrumentation: orchestrion:mysql2:query channel produces a n }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const mysqlSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(mysqlSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(mysqlSpan!.description, 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['db.system.name'], 'mysql'); - assertEquals(mysqlSpan!.data?.['db.query.text'], 'SELECT ? AS solution'); - assertEquals(mysqlSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(mysqlSpan!.data?.['db.user'], 'root'); - assertEquals(mysqlSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(mysqlSpan!.data?.['server.port'], 3306); - assertEquals(mysqlSpan!.data?.['sentry.origin'], 'auto.db.mysql2'); + const mysqlSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(mysqlSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(mysqlSpan.name, 'SELECT'); + assertEquals(mysqlSpan.attributes['db.system.name']?.value, 'mysql'); + assertEquals(mysqlSpan.attributes['db.query.text']?.value, 'SELECT ? AS solution'); + assertEquals(mysqlSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(mysqlSpan.attributes['db.user']?.value, 'root'); + assertEquals(mysqlSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(mysqlSpan.attributes['server.port']?.value, 3306); + assertEquals(mysqlSpan.attributes['sentry.origin']?.value, 'auto.db.mysql2'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts index 2d1c47b2e30b..98a47dcdf06d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-openai/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('openai instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('openai instrumentation: included in default integrations (Deno 2.8.0+ Deno.test('openai instrumentation: orchestrion:openai:chat channel produces a nested gen_ai span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:openai:chat'); @@ -46,19 +44,25 @@ Deno.test('openai instrumentation: orchestrion:openai:chat channel produces a ne channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.chat'); - assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(aiSpan!.description, 'chat gpt-4o'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'openai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'chat'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'gpt-4o'); - assertEquals(aiSpan!.data?.['gen_ai.response.model'], 'gpt-4o-2024-08-06'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.ai.openai'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.chat'); + assertExists(aiSpan, `expected a gen_ai.chat child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(aiSpan.name, 'chat gpt-4o'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'openai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'chat'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gpt-4o'); + assertEquals(aiSpan.attributes['gen_ai.response.model']?.value, 'gpt-4o-2024-08-06'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.ai.openai'); }); 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 81012e6fb979..26781f3084c5 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-postgres/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('pg instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -54,12 +53,11 @@ Deno.test('@sentry/deno/import: transforms pg so it publishes the orchestrion ch Deno.test('pg instrumentation: orchestrion:pg:query channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:pg:query'); @@ -84,19 +82,25 @@ Deno.test('pg instrumentation: orchestrion:pg:query channel produces a nested db }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const pgSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(pgSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(pgSpan!.description, 'SELECT ? AS solution'); - assertEquals(pgSpan!.data?.['db.system.name'], 'postgresql'); - assertEquals(pgSpan!.data?.['db.query.text'], 'SELECT ? AS solution'); - assertEquals(pgSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(pgSpan!.data?.['server.port'], 5432); - assertEquals(pgSpan!.data?.['db.user'], 'root'); - assertEquals(pgSpan!.data?.['sentry.origin'], 'auto.db.postgres'); + const pgSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(pgSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(pgSpan.name, 'SELECT'); + assertEquals(pgSpan.attributes['db.system.name']?.value, 'postgresql'); + assertEquals(pgSpan.attributes['db.query.text']?.value, 'SELECT ? AS solution'); + assertEquals(pgSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(pgSpan.attributes['server.port']?.value, 5432); + assertEquals(pgSpan.attributes['db.user']?.value, 'root'); + assertEquals(pgSpan.attributes['sentry.origin']?.value, 'auto.db.postgres'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts index 640e51733173..7e3454f2f019 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-postgresjs/test.ts @@ -2,16 +2,15 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('postgres.js instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -21,12 +20,11 @@ Deno.test('postgres.js instrumentation: included in default integrations (Deno 2 Deno.test('postgres.js instrumentation: orchestrion:postgres:handle channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:postgres:handle'); @@ -48,18 +46,24 @@ Deno.test('postgres.js instrumentation: orchestrion:postgres:handle channel prod query.resolve({ command: 'SELECT' }); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const pgSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(pgSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(pgSpan!.description, 'SELECT name FROM users'); - assertEquals(pgSpan!.data?.['db.system.name'], 'postgres'); - assertEquals(pgSpan!.data?.['db.query.text'], 'SELECT name FROM users'); + const pgSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(pgSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(pgSpan.name, 'SELECT users'); + assertEquals(pgSpan.attributes['db.system.name']?.value, 'postgres'); + assertEquals(pgSpan.attributes['db.query.text']?.value, 'SELECT name FROM users'); // Set by the resolve wrapper from the `command` passed to `query.resolve`. - assertEquals(pgSpan!.data?.['db.operation.name'], 'SELECT'); - assertEquals(pgSpan!.data?.['sentry.origin'], 'auto.db.postgresjs'); + assertEquals(pgSpan.attributes['db.operation.name']?.value, 'SELECT'); + assertEquals(pgSpan.attributes['sentry.origin']?.value, 'auto.db.postgresjs'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts index 7e9f2d6ec34c..e904576b8248 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-tedious/test.ts @@ -3,16 +3,15 @@ import { EventEmitter } from 'node:events'; import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; 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 { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('tedious instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, }) as DenoClient; @@ -22,12 +21,11 @@ Deno.test('tedious instrumentation: included in default integrations (Deno 2.8.0 Deno.test('tedious instrumentation: orchestrion:tedious:execSql channel produces a nested db span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); // The connection and request are `EventEmitter`s; the subscriber only traces @@ -49,20 +47,26 @@ Deno.test('tedious instrumentation: orchestrion:tedious:execSql channel produces request.callback(); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const tediousSpan = parent.spans?.find(s => s.op === 'db'); - assertExists(tediousSpan, `expected a db child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`); - assertEquals(tediousSpan!.description, 'SELECT ?'); - assertEquals(tediousSpan!.data?.['db.system.name'], 'mssql'); - assertEquals(tediousSpan!.data?.['db.namespace'], 'mydb'); - assertEquals(tediousSpan!.data?.['db.user'], 'sa'); - assertEquals(tediousSpan!.data?.['db.query.text'], 'SELECT ?'); - assertEquals(tediousSpan!.data?.['server.address'], '127.0.0.1'); - assertEquals(tediousSpan!.data?.['server.port'], 1433); - assertEquals(tediousSpan!.data?.['sentry.origin'], 'auto.db.tedious'); + const tediousSpan = children.find(s => getSpanOp(s) === 'db'); + assertExists(tediousSpan, `expected a db child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`); + assertEquals(tediousSpan.name, 'SELECT'); + assertEquals(tediousSpan.attributes['db.system.name']?.value, 'mssql'); + assertEquals(tediousSpan.attributes['db.namespace']?.value, 'mydb'); + assertEquals(tediousSpan.attributes['db.user']?.value, 'sa'); + assertEquals(tediousSpan.attributes['db.query.text']?.value, 'SELECT ?'); + assertEquals(tediousSpan.attributes['server.address']?.value, '127.0.0.1'); + assertEquals(tediousSpan.attributes['server.port']?.value, 1433); + assertEquals(tediousSpan.attributes['sentry.origin']?.value, 'auto.db.tedious'); }); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts index 8c7cc42b5fc2..fdd20fd3239d 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-vercel-ai/test.ts @@ -2,18 +2,17 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { DenoClient } from '@sentry/deno'; -import { init, startSpan } from '@sentry/deno'; +import { flush, init, startSpan } from '@sentry/deno'; import { assert } from 'https://deno.land/std@0.212.0/assert/assert.ts'; import { assertExists } from 'https://deno.land/std@0.212.0/assert/assert_exists.ts'; import { assertEquals } from 'https://deno.land/std@0.212.0/assert/assert_equals.ts'; -import { resetGlobals, transactionSink, withTimeout } from '../../src/index.ts'; +import { getSpanOp, resetGlobals, spanSink, withTimeout } from '../../src/index.ts'; Deno.test('vercel-ai instrumentation: included in default integrations (Deno 2.8.0+)', () => { resetGlobals(); const client = init({ dsn: 'https://username@domain/123', tracesSampleRate: 1, - traceLifecycle: 'static', }) as DenoClient; const names = client.getOptions().integrations.map(i => i.name); assert(names.includes('VercelAI'), `VercelAI should be in defaults, got ${names.join(', ')}`); @@ -21,12 +20,11 @@ Deno.test('vercel-ai instrumentation: included in default integrations (Deno 2.8 Deno.test('vercel-ai instrumentation: orchestrion:ai:generateText channel produces a nested invoke_agent span', async () => { resetGlobals(); - const sink = transactionSink(); + const sink = spanSink(); init({ - traceLifecycle: 'static', dsn: 'https://username@domain/123', tracesSampleRate: 1, - beforeSendTransaction: sink.beforeSendTransaction, + transport: sink.transport, }); const channel = tracingChannel('orchestrion:ai:generateText'); @@ -45,22 +43,28 @@ Deno.test('vercel-ai instrumentation: orchestrion:ai:generateText channel produc channel.asyncEnd.publish(ctx); }); + await flush(); + const parent = await withTimeout( - sink.waitFor(t => t.transaction === 'parent'), + sink.waitFor(span => span.is_segment && span.name === 'parent'), 5000, - "'parent' transaction", + "'parent' segment span", ); + const children = sink.spans.filter(span => span.parent_span_id === parent.span_id); + assertEquals(children.length, 1); + assertEquals(children[0]!.trace_id, parent.trace_id); + assertEquals(children[0]!.is_segment, false); - const aiSpan = parent.spans?.find(s => s.op === 'gen_ai.invoke_agent'); + const aiSpan = children.find(s => getSpanOp(s) === 'gen_ai.invoke_agent'); assertExists( aiSpan, - `expected a gen_ai.invoke_agent child span, got ops: ${parent.spans?.map(s => s.op).join(', ')}`, + `expected a gen_ai.invoke_agent child span, got ops: ${children.map(s => getSpanOp(s)).join(', ')}`, ); - assertEquals(aiSpan!.description, 'invoke_agent'); - assertEquals(aiSpan!.data?.['gen_ai.provider.name'], 'openai'); - assertEquals(aiSpan!.data?.['gen_ai.operation.name'], 'invoke_agent'); - assertEquals(aiSpan!.data?.['gen_ai.request.model'], 'gpt-4o'); - assertEquals(aiSpan!.data?.['vercel.ai.operationId'], 'ai.generateText'); - assertEquals(aiSpan!.data?.['gen_ai.usage.total_tokens'], 15); - assertEquals(aiSpan!.data?.['sentry.origin'], 'auto.vercelai.channel'); + assertEquals(aiSpan.name, 'invoke_agent'); + assertEquals(aiSpan.attributes['gen_ai.provider.name']?.value, 'openai'); + assertEquals(aiSpan.attributes['gen_ai.operation.name']?.value, 'invoke_agent'); + assertEquals(aiSpan.attributes['gen_ai.request.model']?.value, 'gpt-4o'); + assertEquals(aiSpan.attributes['vercel.ai.operationId']?.value, 'ai.generateText'); + assertEquals(aiSpan.attributes['gen_ai.usage.total_tokens']?.value, 15); + assertEquals(aiSpan.attributes['sentry.origin']?.value, 'auto.vercelai.channel'); }); From fb43c0b8e5b067963a04aef02226047b2d9f139a Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Thu, 1 Oct 2026 16:20:01 +0200 Subject: [PATCH 19/57] ref(server-utils): Clarify API promise helper naming and references (#24928) Follow up on #24783 and #24902: align the shared helper name with `wrapStreamResult` and pin the OpenAI and Anthropic APIPromise source links to immutable commits. Co-authored-by: GPT-6 --- packages/server-utils/src/ai/core/apiPromise.ts | 5 +++-- packages/server-utils/src/integrations/anthropic.ts | 4 ++-- packages/server-utils/src/integrations/openai-compatible.ts | 4 ++-- packages/server-utils/src/integrations/openai.ts | 4 ++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/server-utils/src/ai/core/apiPromise.ts b/packages/server-utils/src/ai/core/apiPromise.ts index 245a8f4bd9ec..00e2769afea5 100644 --- a/packages/server-utils/src/ai/core/apiPromise.ts +++ b/packages/server-utils/src/ai/core/apiPromise.ts @@ -13,8 +13,9 @@ function isApiPromise(value: unknown): value is ApiPromise { // OpenAI and Anthropic return an APIPromise that extends the native promise objects and redefines .then() in a way that internally triggers body parsing. // This can lead to double parsing if our instrumentation triggers .then on this promise. // Instead, we need to avoid triggering .then on the APIPromise and instead observe the internal parsing process to get the response body. -// APIPromise implementation: https://github.com/openai/openai-node/blob/main/src/core/api-promise.ts -export function onApiPromiseResponse( +// OpenAI APIPromise: https://github.com/openai/openai-node/blob/71d24120c4cc4e5897a767f16d25f2804fa4ad7c/src/core/api-promise.ts +// Anthropic APIPromise: https://github.com/anthropics/anthropic-sdk-typescript/blob/d49bdab458000bcdffe77bd84b03293f31824fb3/src/core/api-promise.ts +export function wrapApiPromiseResponse( result: unknown, onResponse: (response: unknown) => void, onError: (error: unknown) => void, diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts index be7421fb3bcd..5c19248ced6e 100644 --- a/packages/server-utils/src/integrations/anthropic.ts +++ b/packages/server-utils/src/integrations/anthropic.ts @@ -10,7 +10,7 @@ import { startInactiveSpan, } from '@sentry/core'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; -import { onApiPromiseResponse } from '../ai/core/apiPromise'; +import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { addPrivateRequestAttributes, addResponseAttributes, extractRequestAttributes } from '../ai/anthropic-ai'; import { instrumentAsyncIterableStream, instrumentMessageStream } from '../ai/anthropic-ai/streaming'; import type { AnthropicAiOptions, AnthropicAiResponse } from '../ai/anthropic-ai/types'; @@ -65,7 +65,7 @@ function instrumentAnthropic(options: AnthropicAiOptions): void { ); }, deferSpanEnd: ({ span, data, end }) => - onApiPromiseResponse( + wrapApiPromiseResponse( data.result, response => { data.result = response; diff --git a/packages/server-utils/src/integrations/openai-compatible.ts b/packages/server-utils/src/integrations/openai-compatible.ts index fd0b0ccbc918..be98c3de7a9e 100644 --- a/packages/server-utils/src/integrations/openai-compatible.ts +++ b/packages/server-utils/src/integrations/openai-compatible.ts @@ -9,7 +9,7 @@ import { } from '@sentry/core'; import { GEN_AI_PROVIDER_NAME } from '@sentry/conventions/attributes'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; -import { onApiPromiseResponse } from '../ai/core/apiPromise'; +import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; import { instrumentStream } from '../ai/openai/streaming'; import type { OpenAiOptions } from '../ai/openai/types'; @@ -68,7 +68,7 @@ export function createOpenAiCompatibleIntegration - onApiPromiseResponse( + wrapApiPromiseResponse( data.result, response => { data.result = response; diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index cf16af786953..ada6e60411a2 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -10,7 +10,7 @@ import { } from '@sentry/core'; import { getGenAiSpanOp, resolveAIRecordingOptions } from '../ai/core/utils'; import { addRequestAttributes, extractRequestAttributes } from '../ai/openai'; -import { onApiPromiseResponse } from '../ai/core/apiPromise'; +import { wrapApiPromiseResponse } from '../ai/core/apiPromise'; import { instrumentStream } from '../ai/openai/streaming'; import type { OpenAiOptions } from '../ai/openai/types'; import { addResponseAttributes } from '../ai/openai/utils'; @@ -60,7 +60,7 @@ function instrumentOpenai(options: OpenAiOptions): void { addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); }, deferSpanEnd: ({ span, data, end }) => - onApiPromiseResponse( + wrapApiPromiseResponse( data.result, response => { data.result = response; From ed31852e19ec238c2f1bc7e16d41ee34cc8651d3 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Thu, 1 Oct 2026 17:37:48 +0300 Subject: [PATCH 20/57] fix(server-utils): Match the Anthropic stream helper header regardless of case (#24855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `messages.stream()` calls the instrumented `messages.create({ stream: true })` underneath and tags that call with a helper-method header. The integration uses that header to skip the nested `create`, so a streamed message gets one span. The SDK sent the header as `X-Stainless-Helper-Method` up to 0.105 and as lowercase `x-stainless-helper-method` since 0.106. The check compared the exact casing, so on a current SDK every `messages.stream()` produced two nested `gen_ai.chat` spans, both with the full attributes. Header names are case-insensitive, so the check now matches without regard to case. **Second commit, from review.** Matching the header alone was not enough: `beta.messages.stream()` and the streaming tool runner tag their internal `create` with the same header, but only the non-beta helper is on the `messages-stream` channel, so nothing covers them and skipping left them with no span at all. The skip now only applies while a stream-helper span this integration opened is the active span. The regular helper is still deduped; the beta helper and the tool runner keep their span. **Found by** the send-to-sentry e2e app for Anthropic (#24748, PR #24856) in its `latest` variant. On 0.63 the stream helper gives one span; on 0.129 it gave two. **Tests.** - `suites/tracing/anthropic/v0.129` (Node), pinned to that SDK version the way the openai v7 suite is: one span for `messages.stream()`, and one span each for `beta.messages.stream()` and the eager streaming tool runner. The beta scenario is from isaacs' review. - `suites/tracing/anthropic-ai-stream-helper` (Cloudflare), built through the Sentry Vite plugin so the calls go through the channel integration on workerd, where the active span is the core span rather than an OpenTelemetry one: one span for the regular helper, one for the beta helper. Each suite fails without its half of the fix and passes with it. The existing Anthropic suites on 0.63 still pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: isaacs --- .../node-suites/excludes.ts | 1 + .../anthropic-ai-stream-helper/index.ts | 60 ++++++++++++ .../instrument.server.ts | 10 ++ .../anthropic-ai-stream-helper/test.ts | 40 ++++++++ .../vite.config.mts | 7 ++ .../anthropic-ai-stream-helper/wrangler.jsonc | 7 ++ .../node-suites/excludes.ts | 1 + .../v0.129/instrument-default-lifecycle.mjs | 10 ++ .../tracing/anthropic/v0.129/instrument.mjs | 18 ++++ .../v0.129/scenario-beta-stream-helpers.mjs | 92 +++++++++++++++++++ .../v0.129/scenario-stream-helper.mjs | 76 +++++++++++++++ .../suites/tracing/anthropic/v0.129/test.ts | 74 +++++++++++++++ .../src/integrations/anthropic.ts | 53 ++++++++++- 13 files changed, 444 insertions(+), 5 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/wrangler.jsonc create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-beta-stream-helpers.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-stream-helper.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index c9a89b180612..20124be5a264 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -83,6 +83,7 @@ export const NO_AUTO_INSTRUMENTATION = [ 'suites/pino/test.ts', 'suites/tracing/amqplib/test.ts', 'suites/tracing/anthropic/test.ts', + 'suites/tracing/anthropic/v0.129/test.ts', 'suites/tracing/apollo-graphql/**', 'suites/tracing/dataloader/test.ts', 'suites/tracing/fastify/test.ts', diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/index.ts new file mode 100644 index 000000000000..947315e12a8e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/index.ts @@ -0,0 +1,60 @@ +import Anthropic from '@anthropic-ai/sdk'; + +// The Sentry Vite plugin instruments this worker at build time, so the `@anthropic-ai/sdk` calls below +// go through the diagnostics-channel integration (not `instrumentAnthropicAiClient`), the way an app +// built with the plugin does. + +/** A canned SSE body for a streamed message, named after the prompt so a test can tell the calls apart. */ +function streamedMessage(id: string, model: string): string { + const events = [ + { + type: 'message_start', + message: { id, type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } }, + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello!' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 15 } }, + { type: 'message_stop' }, + ]; + return events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''); +} + +const mockFetch: typeof fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { model: string; messages: { content: string }[] }; + return new Response(streamedMessage(`msg_${body.messages[0]!.content}`, body.model), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); +}; + +export default { + async fetch(request) { + const client = new Anthropic({ apiKey: 'mock-api-key', fetch: mockFetch }); + const url = new URL(request.url); + + // `messages.stream()` calls `messages.create({ stream: true })` underneath, tagged with a helper + // header. Its own channel covers the call, so the nested create must not get a second span. + if (url.pathname === '/stream') { + const message = await client.messages + .stream({ model: 'claude-3-haiku-20240307', max_tokens: 10, messages: [{ role: 'user', content: 'stream' }] }) + .finalMessage(); + return Response.json(message); + } + + // `beta.messages.stream()` sends the same header, but no channel covers the beta helper, so its + // nested create is the only chance for a span and must keep it. + if (url.pathname === '/beta-stream') { + const message = await client.beta.messages + .stream({ + model: 'claude-3-haiku-20240307', + max_tokens: 10, + messages: [{ role: 'user', content: 'beta_stream' }], + }) + .finalMessage(); + return Response.json(message); + } + + return new Response('not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/instrument.server.ts new file mode 100644 index 000000000000..2659d1a477a6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/instrument.server.ts @@ -0,0 +1,10 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; +} + +export default defineCloudflareOptions((env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/test.ts new file mode 100644 index 000000000000..09c194076af4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/test.ts @@ -0,0 +1,40 @@ +import { GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_STREAMING } from '@sentry/conventions/attributes'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; +import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; + +// The stream helpers of the `@anthropic-ai/sdk` tag their internal `create` with a helper header, and +// the integration skips that create only while one of its own `messages.stream()` spans is active. +// This runs that check on workerd, where the active span is the core span rather than an +// OpenTelemetry one, through the Vite plugin's build-time instrumentation. + +it('emits one span for messages.stream(), not a second one for its internal create', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + + expect(genAiSpans).toHaveLength(1); + expect(genAiSpans[0]!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'msg_stream', type: 'string' }); + expect(genAiSpans[0]!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({ value: true, type: 'boolean' }); + }) + .start(signal); + await runner.makeRequest('get', '/stream'); + await runner.completed(); +}); + +it('emits one span for beta.messages.stream(), whose internal create is the only span', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + + expect(genAiSpans).toHaveLength(1); + expect(genAiSpans[0]!.attributes[GEN_AI_RESPONSE_ID]).toEqual({ value: 'msg_beta_stream', type: 'string' }); + }) + .start(signal); + await runner.makeRequest('get', '/beta-stream'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/vite.config.mts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/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/anthropic-ai-stream-helper/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/wrangler.jsonc new file mode 100644 index 000000000000..25b432ac5751 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai-stream-helper/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-anthropic-ai-stream-helper", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts index e2b5e0cd5b01..36098da333df 100644 --- a/dev-packages/deno-integration-tests/node-suites/excludes.ts +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -49,6 +49,7 @@ const REQUIRE_OF_ESM_ONLY_DEPENDENCY = [ // version) and pass on Deno 2.9.0. const NOT_TRIAGED = [ 'suites/tracing/anthropic/test.ts', + 'suites/tracing/anthropic/v0.129/test.ts', 'suites/tracing/apollo-graphql/**', 'suites/tracing/fastify/test.ts', 'suites/tracing/flue/test.ts', diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.mjs new file mode 100644 index 000000000000..cc192fb89834 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument-default-lifecycle.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/anthropic/v0.129/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs new file mode 100644 index 000000000000..9b15a0c635e8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/instrument.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + traceLifecycle: 'static', + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + dataCollection: { genAI: { inputs: false, outputs: false } }, + transport: loggingTransport, + beforeSendTransaction: event => { + // Filter out mock express server transactions + if (event.transaction.includes('/anthropic/v1/')) { + return null; + } + return event; + }, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-beta-stream-helpers.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-beta-stream-helpers.mjs new file mode 100644 index 000000000000..49b2be73ce7a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-beta-stream-helpers.mjs @@ -0,0 +1,92 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/anthropic/v1/messages', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const model = req.body.model; + // The response id names the caller, so the test can find the span for each helper. + const id = `msg_${req.body.messages[0].content}`; + const events = [ + { + type: 'message_start', + message: { id, type: 'message', role: 'assistant', model, content: [], usage: { input_tokens: 10 } }, + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello!' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 15 } }, + { type: 'message_stop' }, + ]; + events.forEach((event, index) => { + setTimeout(() => { + res.write(`event: ${event.type}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + if (index === events.length - 1) { + res.end(); + } + }, index * 10); + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + await client.beta.messages + .stream({ + model: 'claude-3-haiku-20240307', + max_tokens: 10, + messages: [{ role: 'user', content: 'beta_stream' }], + }) + .finalMessage(); + + // With `runToolsEagerly`, the runner calls `beta.messages.create()` directly and not through + // `beta.messages.stream()`, but it still sends the stream helper header. + const runner = client.beta.messages.toolRunner({ + model: 'claude-3-haiku-20240307', + max_tokens: 10, + messages: [{ role: 'user', content: 'tool_runner_eager' }], + tools: [ + { + name: 'weather', + description: 'Get the weather by city', + input_schema: { type: 'object', properties: { city: { type: 'string' } } }, + run: () => 'sunny', + }, + ], + stream: true, + runToolsEagerly: true, + }); + for await (const stream of runner) { + await stream.finalMessage(); + } + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-stream-helper.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-stream-helper.mjs new file mode 100644 index 000000000000..7ad1f0395fbf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/scenario-stream-helper.mjs @@ -0,0 +1,76 @@ +import Anthropic from '@anthropic-ai/sdk'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/anthropic/v1/messages', (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + const model = req.body.model; + const events = [ + { + type: 'message_start', + message: { + id: 'msg_stream_1', + type: 'message', + role: 'assistant', + model, + content: [], + usage: { input_tokens: 10 }, + }, + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello!' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 15 } }, + { type: 'message_stop' }, + ]; + events.forEach((event, index) => { + setTimeout(() => { + res.write(`event: ${event.type}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + if (index === events.length - 1) { + res.end(); + } + }, index * 10); + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new Anthropic({ + apiKey: 'mock-api-key', + baseURL: `http://localhost:${server.address().port}/anthropic`, + }); + + // The stream helper delegates to `messages.create({ stream: true })` underneath. + await client.messages + .stream({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Stream this please' }], + }) + .finalMessage(); + }); + + await Sentry.flush(2000); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts new file mode 100644 index 000000000000..04f9cf75d98a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/v0.129/test.ts @@ -0,0 +1,74 @@ +import { GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_STREAMING } from '@sentry/conventions/attributes'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +// The attribute extraction is version-independent and covered by the suite pinned to 0.63. What a +// newer SDK puts at risk is how its internals are recognised: since 0.106 the `messages.stream()` +// helper tags its internal `create` call with a lowercase `x-stainless-helper-method` header (it +// was `X-Stainless-Helper-Method` up to 0.105), and the dedup of that call keyed on the exact casing. +describe('Anthropic integration (0.129)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + createEsmAndCjsTests( + __dirname, + 'scenario-stream-helper.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('emits one span for messages.stream(), not a second one for its internal create', async () => { + await createRunner() + .expect({ transaction: { transaction: 'main' } }) + .expect({ + span: container => { + const streamingSpans = container.items.filter( + span => span.attributes[GEN_AI_RESPONSE_ID]?.value === 'msg_stream_1', + ); + expect(streamingSpans).toHaveLength(1); + expect(streamingSpans[0]!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); + expect(streamingSpans[0]!.attributes[GEN_AI_RESPONSE_STREAMING]).toEqual({ + type: 'boolean', + value: true, + }); + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + '@anthropic-ai/sdk': '0.129.0', + }, + }, + ); + + // These helpers send the same header, but no `messages-stream` span covers them. So their + // internal `create` must still get a span. + createEsmAndCjsTests( + __dirname, + 'scenario-beta-stream-helpers.mjs', + 'instrument-default-lifecycle.mjs', + (createRunner, test) => { + test('emits one span each for beta.messages.stream() and the eager streaming tool runner', async () => { + await createRunner() + .expect({ + span: container => { + for (const id of ['msg_beta_stream', 'msg_tool_runner_eager']) { + const spans = container.items.filter(span => span.attributes[GEN_AI_RESPONSE_ID]?.value === id); + expect(spans, id).toHaveLength(1); + expect(spans[0]!.attributes['sentry.op']).toEqual({ type: 'string', value: 'gen_ai.chat' }); + } + }, + }) + .start() + .completed(); + }); + }, + { + additionalDependencies: { + '@anthropic-ai/sdk': '0.129.0', + }, + }, + ); +}); diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts index 5c19248ced6e..33517d4730c2 100644 --- a/packages/server-utils/src/integrations/anthropic.ts +++ b/packages/server-utils/src/integrations/anthropic.ts @@ -4,6 +4,7 @@ import type { IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; import { _INTERNAL_shouldSkipAiProviderWrapping, defineIntegration, + getActiveSpan, getClient, hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -55,7 +56,7 @@ function instrumentAnthropic(options: AnthropicAiOptions): void { for (const { channel, operation, stream } of INSTRUMENTED_CHANNELS) { bindTracingChannelToSpan( diagnosticsChannel.tracingChannel(channel), - data => createGenAiSpan(data, operation, options), + data => createGenAiSpan(data, operation, options, stream), { beforeSpanEnd: (span, data) => { addResponseAttributes( @@ -88,6 +89,7 @@ function createGenAiSpan( data: AnthropicChannelContext, operation: string, options: AnthropicAiOptions, + stream: StreamMode, ): Span | undefined { const args = data.arguments ?? []; @@ -98,10 +100,12 @@ function createGenAiSpan( } // `messages.stream()` internally calls the instrumented `messages.create({ stream: true })` tagged with - // an `X-Stainless-Helper-Method: 'stream'` header. The messages-stream channel already covers it, so skip - // the nested create to avoid a duplicate span. - const requestOptions = args[1] as { headers?: Record } | undefined; - if (requestOptions?.headers?.['X-Stainless-Helper-Method'] === 'stream') { + // a `stream` helper-method header. The messages-stream channel already covers it, so skip the nested + // create to avoid a duplicate span. Only the non-beta helper is on that channel, though: + // `beta.messages.stream()` and the streaming tool runner send the same header with no span covering + // them, so the header alone is not enough. Skip only while a stream-helper span of ours is active. + const requestOptions = args[1] as { headers?: unknown } | undefined; + if (isStreamHelperRequest(requestOptions?.headers) && isInsideStreamHelperSpan()) { return undefined; } @@ -125,9 +129,48 @@ function createGenAiSpan( addPrivateRequestAttributes(span, params); } + if (stream === 'message-stream') { + streamHelperSpans.add(span); + } + return span; } +const STREAM_HELPER_METHOD_HEADER = 'x-stainless-helper-method'; + +/** The spans opened for the messages-stream channel, i.e. for `messages.stream()` calls. */ +const streamHelperSpans = new WeakSet(); + +/** + * Whether the active span is one this integration opened for `messages.stream()`. The helper's + * internal `create` runs inside that span, so this is what tells it apart from the beta helper and + * the tool runner, which send the same header but are not on the messages-stream channel. + */ +function isInsideStreamHelperSpan(): boolean { + const activeSpan = getActiveSpan(); + return !!activeSpan && streamHelperSpans.has(activeSpan); +} + +/** + * Whether request options carry the header the SDK's `messages.stream()` helper puts on its internal + * `create` call. The SDK sent it as `X-Stainless-Helper-Method` up to 0.105 and lowercase since 0.106, and + * HTTP header names are case-insensitive either way, so match without regard to case. The headers + * arrive as a plain object; a `Headers` instance is handled for completeness. + */ +function isStreamHelperRequest(headers: unknown): boolean { + if (!headers || typeof headers !== 'object') { + return false; + } + + if (typeof (headers as Headers).get === 'function') { + return (headers as Headers).get(STREAM_HELPER_METHOD_HEADER) === 'stream'; + } + + return Object.entries(headers as Record).some( + ([name, value]) => name.toLowerCase() === STREAM_HELPER_METHOD_HEADER && value === 'stream', + ); +} + type AsyncIterableStream = { [Symbol.asyncIterator]: () => AsyncIterator }; type MessageStreamEmitter = { on: (...args: unknown[]) => void }; From 11c49368c8de1e3f1edbafc40c3c445b8d48f5a4 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Thu, 1 Oct 2026 17:55:47 +0300 Subject: [PATCH 21/57] test(e2e): Add node-anthropic-send-to-sentry test app (#24856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An e2e app that uses the Anthropic integration the way a user does and sends the data to a real Sentry project, per #24748. Same shape as the OpenAI one in #24824. **Stacked on #24855.** The `latest` variant needs that fix; this PR targets its branch and retargets to `develop` once it merges. **The app.** A plain `Sentry.init` on an express app, preloaded with `node --import`, no tunnel. The stock `@anthropic-ai/sdk` client talks to OpenRouter's Anthropic-compatible endpoint. Four routes make one real request each: a message, a streamed message, one through the `messages.stream()` helper, and a forced tool use. **The tests.** Each one reads the request's `gen_ai.chat` span back through the Sentry API, like the other `*-send-to-sentry` apps, and checks op, origin, status, model, token usage, and that the prompts and answers arrived. The helper test also checks there is exactly one `gen_ai.chat` span, which is what caught the duplicate fixed in #24855. `@anthropic-ai/sdk` is pinned to 0.63.0, the version the integration suite uses, and a `(latest)` variant runs the same tests against `@anthropic-ai/sdk@latest`. Both are optional, like the other send-to-sentry apps. Closes #24748 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 Co-authored-by: Jan Peer Stöcklmair --- .../node-anthropic-send-to-sentry/.gitignore | 5 + .../package.json | 40 +++++ .../playwright.config.ts | 28 ++++ .../node-anthropic-send-to-sentry/src/app.ts | 132 +++++++++++++++ .../src/instrument.ts | 10 ++ .../tests/send-to-sentry.test.ts | 152 ++++++++++++++++++ .../tests/utils/sentry-api.ts | 131 +++++++++++++++ .../tsconfig.json | 13 ++ 8 files changed, 511 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore new file mode 100644 index 000000000000..795f6b6d40d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/.gitignore @@ -0,0 +1,5 @@ +node_modules +pnpm-lock.yaml +dist +test-results +playwright-report diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json new file mode 100644 index 000000000000..ef1b771dd545 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/package.json @@ -0,0 +1,40 @@ +{ + "name": "node-anthropic-send-to-sentry", + "description": "The Anthropic integration as a user runs it: a plain Sentry.init on an express app, real chat, streaming, stream-helper and tool-call requests through OpenRouter, and the gen_ai spans read back from a real Sentry project", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node --import ./dist/instrument.js dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add @anthropic-ai/sdk@latest && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@anthropic-ai/sdk": "0.63.0", + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^4.21.2" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@types/express": "^4.17.21", + "@types/node": "^24.0.0", + "typescript": "~5.9.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-anthropic-send-to-sentry (latest)" + } + ] + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..ef4764ce8d73 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from '@playwright/test'; + +const port = 3030; + +export default defineConfig({ + testDir: './tests', + /* + * Spans take ~2min to become queryable via the trace endpoint, and each poll has its own 180s + * budget. The first test polls twice in a row (the model span, then its parent), so the ceiling + * has to hold two polls back to back. + */ + timeout: 400_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', + use: { + baseURL: `http://localhost:${port}`, + }, + webServer: { + command: 'pnpm start', + port, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts new file mode 100644 index 000000000000..7ad878cdff26 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/app.ts @@ -0,0 +1,132 @@ +// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here. +import * as Sentry from '@sentry/node'; +import Anthropic from '@anthropic-ai/sdk'; +import express from 'express'; + +const apiKey = process.env.E2E_OPENROUTER_API_KEY; +if (!apiKey) { + throw new Error('E2E_OPENROUTER_API_KEY is not set'); +} + +// OpenRouter serves an Anthropic-compatible `/api/v1/messages`, so the stock client only needs a +// different base URL. It authenticates with a bearer token, so the key goes in `authToken` +// (Authorization: Bearer) rather than `apiKey` (x-api-key). The model is incidental: what is under +// test is the SDK's own request/response code path, which is what Sentry instruments. +const client = new Anthropic({ authToken: apiKey, baseURL: '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` forces the call. +const WEATHER_PROMPT = `What is the weather in Paris? ${SHORT_ANSWER}`; +const SYSTEM = 'You are a helpful assistant used by an automated test.'; + +const WEATHER_TOOL = { + name: 'get_weather', + description: 'Get the current weather for a city.', + input_schema: { + type: 'object' as const, + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + }, +}; + +const app = express(); + +/** The trace this request is recorded under, so the test can look its spans up in Sentry. */ +function currentTraceId(): string | undefined { + return Sentry.getActiveSpan()?.spanContext().traceId; +} + +/** The text of a message's first text block. */ +function textOf(message: Anthropic.Message): string { + const first = message.content[0]; + return first?.type === 'text' ? first.text : ''; +} + +// Each route makes one call through the `@anthropic-ai/sdk` client and answers with the trace id, so +// the test can find the request's spans in Sentry. + +app.get('/chat', async (_req, res, next) => { + try { + const message = await client.messages.create({ + model: MODEL, + max_tokens: 32, + temperature: 0, + system: SYSTEM, + messages: [{ role: 'user', content: CHAT_PROMPT }], + }); + res.send({ traceId: currentTraceId(), answer: textOf(message) }); + } catch (error) { + next(error); + } +}); + +// `messages.create({ stream: true })` returns an async iterable of events the caller drains. +app.get('/chat-stream', async (_req, res, next) => { + try { + const stream = await client.messages.create({ + model: MODEL, + max_tokens: 32, + temperature: 0, + system: SYSTEM, + messages: [{ role: 'user', content: CHAT_PROMPT }], + stream: true, + }); + + let answer = ''; + for await (const event of stream) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + answer += event.delta.text; + } + } + res.send({ traceId: currentTraceId(), answer }); + } catch (error) { + next(error); + } +}); + +// `messages.stream()` is the SDK's streaming helper: it accumulates the events into the final +// message, and the integration instruments it separately from `create`. +app.get('/stream-helper', async (_req, res, next) => { + try { + const message = await client.messages + .stream({ + model: MODEL, + max_tokens: 32, + temperature: 0, + system: SYSTEM, + messages: [{ role: 'user', content: CHAT_PROMPT }], + }) + .finalMessage(); + res.send({ traceId: currentTraceId(), answer: textOf(message) }); + } catch (error) { + next(error); + } +}); + +app.get('/tools', async (_req, res, next) => { + try { + const message = await client.messages.create({ + model: MODEL, + max_tokens: 64, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [WEATHER_TOOL], + tool_choice: { type: 'tool', name: 'get_weather' }, + }); + res.send({ traceId: currentTraceId(), toolUse: message.content.filter(block => block.type === 'tool_use') }); + } catch (error) { + next(error); + } +}); + +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(`node-anthropic-send-to-sentry listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts new file mode 100644 index 000000000000..7d031b0e6d8e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/src/instrument.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +// The setup from the docs, nothing more: preloaded with `node --import`, no tunnel, so the spans go to +// the real Sentry project behind `E2E_TEST_DSN`, where the tests read them back. The Anthropic integration +// is on by default; its runtime channel injection is what picks up the `@anthropic-ai/sdk` client in app.ts. +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + environment: 'qa', // dynamic sampling bias to keep transactions + tracesSampleRate: 1, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..44831a6d1a59 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from './utils/sentry-api'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findSpanInTrace, + flattenTrace, +} from './utils/sentry-api'; + +// Mirrors src/app.ts. +const MODEL = 'openai/gpt-4o-mini'; + +type Attributes = Record; + +/** Sends one request to the app and returns the trace its request span was recorded under. */ +async function requestTrace(baseURL: string | undefined, path: string): Promise { + const response = await fetch(`${baseURL}${path}`); + const body = await response.text(); + expect(response.status, `${path} answered ${response.status}: ${body}`).toBe(200); + + const { traceId } = JSON.parse(body) as { traceId?: string }; + expect(traceId, `${path} did not report a trace id: ${body}`).toMatch(/^[0-9a-f]{32}$/); + + console.log(`${path}: https://${process.env.E2E_TEST_SENTRY_ORG_SLUG}.sentry.io/explore/traces/trace/${traceId}/`); + return traceId!; +} + +/** + * Polls Sentry until the trace holds a span with `op`, then returns that span and its attributes. The + * trace endpoint only carries the span's op, name and place in the tree; the attributes need a second + * lookup, which is polled as well since the two can land apart. + */ +async function waitForSpan(traceId: string, op: string): Promise<{ span: TraceItem; attributes: Attributes }> { + let found: { span: TraceItem; attributes: Attributes } | undefined; + + await expect + .poll(async () => { + const span = await findSpanInTrace(traceId, op); + const attributes = span?.event_id ? await fetchSpanAttributes(traceId, span.event_id) : undefined; + found = span && attributes ? { span, attributes } : undefined; + return found; + }, EVENT_POLLING_OPTIONS) + .toBeDefined(); + + return found!; +} + +/** Whether the model-call span sits somewhere below the request span (`http.server`) of the trace. */ +async function isModelSpanUnderRequestSpan(traceId: string, modelSpanId: string): Promise { + const requestSpan = flattenTrace(await fetchTrace(traceId)).find(item => item.op === 'http.server'); + return flattenTrace(requestSpan?.children ?? []).some(item => item.event_id === modelSpanId); +} + +/** + * The attributes every successful model-call span carries once Sentry has stored it, whatever the + * model answers. Model-dependent values (token counts, response id and model) are checked for shape, + * not content. Note the names are the stored ones: `origin` and `span.status` rather than the + * `sentry.*` attributes the SDK sends. + */ +function expectModelCallAttributes(span: TraceItem, attributes: Attributes): void { + expect(span.description).toBe(`chat ${MODEL}`); + expect(attributes).toMatchObject({ + 'span.op': 'gen_ai.chat', + 'span.status': 'ok', + origin: 'auto.ai.anthropic', + 'gen_ai.operation.name': 'chat', + 'gen_ai.provider.name': 'anthropic', + 'gen_ai.request.model': MODEL, + }); + expect(typeof attributes['gen_ai.response.id']).toBe('string'); + expect(typeof attributes['gen_ai.response.model']).toBe('string'); + expect(attributes['gen_ai.usage.output_tokens']).toBeGreaterThan(0); + expect(attributes['gen_ai.usage.total_tokens']).toBeGreaterThan(0); +} + +/** The prompt and the answer, which the SDK records by default and which have to survive the trip. */ +function expectRecordedConversation(attributes: Attributes): void { + // The answer is sent as `gen_ai.response.text` and stored as `gen_ai.output.messages`. + expect(attributes['gen_ai.system_instructions']).toContain('automated test'); + expect(attributes['gen_ai.input.messages']).toContain('capital of France'); + expect(typeof attributes['gen_ai.output.messages']).toBe('string'); +} + +/** `gen_ai.response.finish_reasons` is a JSON array, only recorded for streamed calls. */ +function finishReasons(attributes: Attributes): string[] { + const raw = attributes['gen_ai.response.finish_reasons']; + expect(typeof raw, 'gen_ai.response.finish_reasons').toBe('string'); + return JSON.parse(raw as string); +} + +test('Sends a message to Sentry as a gen_ai.chat span under the request span', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/chat'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expectModelCallAttributes(span, attributes); + expect(attributes['gen_ai.usage.input_tokens']).toBeGreaterThan(0); + expect(attributes['gen_ai.request.temperature']).toBe(0); + expect(attributes['gen_ai.response.streaming']).toBeUndefined(); + expectRecordedConversation(attributes); + + // The request span is the segment; it ends last, so it can land after its children. + await expect.poll(() => isModelSpanUnderRequestSpan(traceId, span.event_id!), EVENT_POLLING_OPTIONS).toBe(true); +}); + +test('Sends a streamed message to Sentry with its token usage', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/chat-stream'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expectModelCallAttributes(span, attributes); + expect(attributes['gen_ai.response.streaming']).toBe(true); + expect(finishReasons(attributes)).toContain('end_turn'); + expectRecordedConversation(attributes); + // The input usage is not asserted: the instrumentation takes it from `message_start`, where + // OpenRouter reports 0, and ignores the real count OpenRouter (and the Anthropic API) put in + // `message_delta`. Output usage comes from `message_delta` and is covered by the shared checks. +}); + +test('Sends a message made with the stream helper to Sentry', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/stream-helper'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + // `messages.stream()` calls `create` underneath, which must not produce a second span. The parent + // lands last, so count after it; the short poll only rides out a dropped connection. + await expect.poll(() => isModelSpanUnderRequestSpan(traceId, span.event_id!), EVENT_POLLING_OPTIONS).toBe(true); + await expect + .poll(async () => flattenTrace(await fetchTrace(traceId)).filter(item => item.op === 'gen_ai.chat').length, { + timeout: 30_000, + intervals: [5_000], + }) + .toBe(1); + expectModelCallAttributes(span, attributes); + expect(attributes['gen_ai.response.streaming']).toBe(true); + expect(finishReasons(attributes)).toContain('end_turn'); + expectRecordedConversation(attributes); +}); + +test('Sends a message that returned a tool use to Sentry', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/tools'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expectModelCallAttributes(span, attributes); + expect(attributes['gen_ai.usage.input_tokens']).toBeGreaterThan(0); + expect(attributes['gen_ai.tool.definitions']).toContain('get_weather'); + expect(attributes['gen_ai.input.messages']).toContain('weather in Paris'); + // The SDK also sends the returned tool use as `gen_ai.response.tool_calls`, but that attribute is + // not stored under that name, and stop reasons are only recorded for streamed calls. +}); diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts new file mode 100644 index 000000000000..521b830eefcb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tests/utils/sentry-api.ts @@ -0,0 +1,131 @@ +const authToken = process.env.E2E_TEST_AUTH_TOKEN; +const sentryTestOrgSlug = process.env.E2E_TEST_SENTRY_ORG_SLUG; +const sentryTestProject = process.env.E2E_TEST_SENTRY_PROJECT; + +/** + * 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 the organization trace endpoint. 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; + /** On a span this is the span name. */ + description?: string; + children?: TraceItem[]; + errors?: TraceItem[]; + occurrences?: TraceItem[]; +} + +/** + * One request to the Sentry API with the E2E token. Returns `undefined` when the connection + * fails or drops mid-response: over the minutes a test polls, the API closes the odd + * connection ("TypeError: terminated"), and an error thrown inside `expect.poll` fails the + * test instead of letting it retry, so a network error is treated like a "not there yet". + */ +async function fetchSentryApi(what: string, path: string): Promise<{ status: number; body: string } | undefined> { + try { + const response = await fetch(`https://sentry.io/api/0/${path}`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + return { status: response.status, body: await response.text() }; + } catch (error) { + console.log(`${what} failed: ${error}`); + return undefined; + } +} + +export async function fetchTrace(traceId: string): Promise { + const what = `Trace lookup for ${traceId}`; + const response = await fetchSentryApi(what, `organizations/${sentryTestOrgSlug}/trace/${traceId}/?statsPeriod=1h`); + if (!response) { + return []; + } + + // The trace endpoint is org scoped, so the auth token needs `org:read` on top of the + // project scopes the other assertions rely on. That never resolves by waiting, so fail + // loudly instead of polling until the timeout and reporting it as a missing event. + if (response.status === 401 || response.status === 403) { + throw new Error( + `${what} was rejected with ${response.status}: ${response.body}. ` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } + + // Empty traces and the occasional rate limit are expected while polling, so treat anything + // else that is not a success as "not there yet" -- but log it, since a rejected request and + // a trace that has not landed are otherwise indistinguishable. + if (response.status !== 200) { + console.log(`${what} returned ${response.status}: ${response.body}`); + return []; + } + + return JSON.parse(response.body); +} + +/** + * 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 ?? []), + ]); +} + +export async function findSpanInTrace(traceId: string, op: string): Promise { + return flattenTrace(await fetchTrace(traceId)).find(item => item.op === op); +} + +/** + * The attributes of one span, keyed by name, as Sentry stores them. The trace endpoint only carries + * a span's op, name and place in the tree; the attributes come from the project trace-items + * endpoint. Returns `undefined` while the span has not landed. + * + * Ingest normalises what the SDK sent: `sentry.origin` comes back as `origin`, `sentry.op` as + * `span.op`, `gen_ai.response.text` as `gen_ai.output.messages`, and some numbers are indexed as + * tags (`tags[,number]`). Integers are returned as strings and are converted here; everything + * else is passed through as is. + */ +export async function fetchSpanAttributes( + traceId: string, + spanId: string, +): Promise | undefined> { + const what = `Span lookup for ${spanId}`; + const response = await fetchSentryApi( + what, + `projects/${sentryTestOrgSlug}/${sentryTestProject}/trace-items/${spanId}/?trace_id=${traceId}&item_type=spans`, + ); + if (!response) { + return undefined; + } + + if (response.status === 401 || response.status === 403) { + throw new Error( + `${what} was rejected with ${response.status}: ${response.body}. ` + + 'E2E_TEST_AUTH_TOKEN needs the `org:read` scope.', + ); + } + + if (response.status !== 200) { + if (response.status !== 404) { + console.log(`${what} returned ${response.status}: ${response.body}`); + } + return undefined; + } + + const { attributes } = JSON.parse(response.body) as { attributes?: { name: string; type: string; value: unknown }[] }; + return Object.fromEntries( + (attributes ?? []).map(({ name, type, value }) => [name, type === 'int' ? Number(value) : value]), + ); +} diff --git a/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..795574fd9df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-anthropic-send-to-sentry/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} From 563e333a2591bad304eaf2f77bd009acc31d82c8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 1 Oct 2026 17:09:19 +0200 Subject: [PATCH 22/57] feat(remix): Capture Remix 3 component render errors (#24873) The `remix/ui` runtime sends every render, scheduler, frame and hydration error to the event target `run()` returns, and dispatching an event does not rethrow. So `window.onerror` and `unhandledrejection` never see them, and the default browser integrations report nothing from the component layer. Removing the listener added here makes the new e2e test fail. Costs 0.27 KB gzipped in the client bundle, inside the existing budget. Fixes #24665 Co-authored-by: Claude Opus 5 --- .../remix-v3/app/actions/controller.tsx | 3 + .../remix-v3/app/actions/public/entry.ts | 18 ++- .../remix-v3/tests/client.test.ts | 15 ++- packages/remix/rollup.npm.config.mjs | 5 +- .../src/v3/client/diagnosticsChannelShim.ts | 113 ++++++++++++++++++ packages/remix/src/v3/client/errors.ts | 74 ++++++++++++ packages/remix/src/v3/client/sdk.ts | 9 +- packages/remix/src/v3/index.client.ts | 1 + packages/remix/test/v3/errors.test.ts | 63 ++++++++++ 9 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 packages/remix/src/v3/client/diagnosticsChannelShim.ts create mode 100644 packages/remix/src/v3/client/errors.ts create mode 100644 packages/remix/test/v3/errors.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 4999bd4127cc..59d6567f28bb 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -21,6 +21,9 @@ function HomePage(handle: Handle>) { User + ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index d4cc91b051d5..f558bda2b048 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/remix/v3/client'; -import { run } from 'remix/ui'; +import { createElement, run } from 'remix/ui'; // Not Node. The asset server substitutes this when it compiles the module, from the `define` map in // `app/assets.ts`. @@ -17,3 +17,19 @@ export const app = run({ return mod[exportName]; }, }); + +// The runtime sends a render error to the event target `run()` returns and does not rethrow, so +// `window.onerror` never sees it. This listener is the only way the SDK learns about it. +Sentry.captureRuntimeErrors(app); + +function Boom(): () => never { + return () => { + throw new Error('Component render failed'); + }; +} + +document.addEventListener('click', event => { + if ((event.target as HTMLElement | null)?.id === 'component-error') { + void app.frames.top.replace(createElement(Boom, {})); + } +}); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts index 60cc59e24379..79d36cf682c0 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/client.test.ts @@ -1,5 +1,5 @@ import { expect, test } from '@playwright/test'; -import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; const APP_NAME = 'remix-v3'; @@ -33,3 +33,16 @@ test('sends a navigation span for a link the runtime intercepts', async ({ page expect(span.is_segment).toBe(true); expect(span.name).toBe('Navigation'); }); + +test('captures a component render error the runtime never rethrows', async ({ page }) => { + await page.goto('/'); + + const errorPromise = waitForError(APP_NAME, event => { + return !event.type && event.exception?.values?.[0]?.value === 'Component render failed'; + }); + + await page.locator('#component-error').click(); + + const error = await errorPromise; + expect(error.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.ui.remix_v3' }); +}); diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index e4938499f1ec..0abd34e17818 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -27,7 +27,10 @@ const v3NodeEntry = defineConfig({ */ const v3ClientBundle = defineConfig({ input: 'build/esm/v3/index.client.js', - external: id => id === 'remix' || id.startsWith('@remix-run/'), + // The channel shim has to stay external. Orchestrion's browser transform imports that file by URL + // into every instrumented module, so an inlined copy would leave the page with two shims and two + // separate subscriber registries, and instrumentation would quietly do nothing. + external: id => /diagnosticsChannelShim/.test(id) || id === 'remix' || id.startsWith('@remix-run/'), treeshake: { moduleSideEffects: false, propertyReadSideEffects: false }, plugins: [nodeResolve({ browser: true, exportConditions: ['browser', 'import', 'default'] })], // Emitted inside `build/esm/v3/`, not at `build/`: the one import it keeps is the relative path to diff --git a/packages/remix/src/v3/client/diagnosticsChannelShim.ts b/packages/remix/src/v3/client/diagnosticsChannelShim.ts new file mode 100644 index 000000000000..410447e8054d --- /dev/null +++ b/packages/remix/src/v3/client/diagnosticsChannelShim.ts @@ -0,0 +1,113 @@ +/** + * A browser stand-in for `node:diagnostics_channel`. + * + * Orchestrion's transform injects `import dc from ''` into every instrumented module and + * drives it through Node's `tracingChannel` API. The browser has no `node:diagnostics_channel`, so + * without this shim the transform cannot run on browser code. + * + * This implements exactly what the injected code calls, which is not the `traceSync` helpers but the + * per-event channels under them: + * + * ```js + * // The gate is true when ANY of the five event channels has a subscriber, so subscribing to `end` + * // alone is enough to get the call wrapped. + * if (!(ch.start.hasSubscribers || ch.end.hasSubscribers || ch.asyncStart.hasSubscribers + * || ch.asyncEnd.hasSubscribers || ch.error.hasSubscribers)) return traced(); + * return ch.start.runStores(ctx, () => { + * try { ctx.result = traced(); } + * catch (err) { ctx.error = err; ch.error.publish(ctx); throw err; } + * finally { ch.end.publish(ctx); } + * }); + * ``` + * + * The browser has no `AsyncLocalStorage`, so `runStores` publishes and then calls the callback directly + * instead of entering a store. The injected code does not need a store: it passes state along on the + * `context` object. + */ + +type Handler = (context: unknown) => void; + +interface Channel { + readonly hasSubscribers: boolean; + publish(context: unknown): void; + runStores(context: unknown, fn: (...args: unknown[]) => T, thisArg?: unknown, ...args: unknown[]): T; + subscribe(handler: Handler): void; + unsubscribe(handler: Handler): void; +} + +const EVENTS = ['start', 'end', 'asyncStart', 'asyncEnd', 'error'] as const; +type EventName = (typeof EVENTS)[number]; + +export type TracingChannelSubscribers = Partial>; + +export interface BrowserTracingChannel extends Record { + subscribe(subscribers: TracingChannelSubscribers): void; + unsubscribe(subscribers: TracingChannelSubscribers): void; +} + +function createChannel(): Channel { + const handlers = new Set(); + + return { + get hasSubscribers(): boolean { + return handlers.size > 0; + }, + publish(context) { + // Snapshot: a subscriber may unsubscribe itself while being notified. + for (const handler of Array.from(handlers)) { + try { + handler(context); + } catch { + // A throwing subscriber must never break the instrumented library. + } + } + }, + runStores(context, fn, thisArg, ...args) { + this.publish(context); + return fn.apply(thisArg, args); + }, + subscribe(handler) { + handlers.add(handler); + }, + unsubscribe(handler) { + handlers.delete(handler); + }, + }; +} + +const registry = new Map(); + +/** Create (or look up) a tracing channel by name. */ +export function tracingChannel(name: string): BrowserTracingChannel { + const existing = registry.get(name); + if (existing) { + return existing; + } + + const channels = Object.fromEntries(EVENTS.map(event => [event, createChannel()])) as Record; + + const channel: BrowserTracingChannel = { + ...channels, + subscribe(subscribers) { + for (const event of EVENTS) { + const handler = subscribers[event]; + if (handler) { + channels[event].subscribe(handler); + } + } + }, + unsubscribe(subscribers) { + for (const event of EVENTS) { + const handler = subscribers[event]; + if (handler) { + channels[event].unsubscribe(handler); + } + } + }, + }; + + registry.set(name, channel); + return channel; +} + +export default { tracingChannel }; diff --git a/packages/remix/src/v3/client/errors.ts b/packages/remix/src/v3/client/errors.ts new file mode 100644 index 000000000000..fd636cacba27 --- /dev/null +++ b/packages/remix/src/v3/client/errors.ts @@ -0,0 +1,74 @@ +import { captureException } from '@sentry/browser'; +import { tracingChannel } from './diagnosticsChannelShim'; + +/** The `AppRuntime` returned by `run()` from `remix/ui`, an EventTarget emitting `error`. */ +export interface AppRuntimeLike { + addEventListener(type: 'error', listener: (event: Event) => void): void; +} + +const MECHANISM_TYPE = 'auto.ui.remix_v3'; + +// The channel orchestrion will inject into `@remix-run/ui`'s `run()` once the asset server transforms +// browser modules. Nothing publishes to it yet, so the subscription below is dormant. The name is +// written out because the channel names live in `@sentry/server-utils`, which must not reach the +// browser. +const UI_RUN_CHANNEL = 'orchestrion:@remix-run/ui:run'; + +const attached = new WeakSet(); + +// `subscribe()` takes a fresh object literal and the channel keys handlers by identity, so a second +// call would add a second handler rather than replace the first. +let subscribed = false; + +/** + * Report a Remix 3 client runtime's errors to Sentry. + * + * The runtime sends every render, scheduler, frame and hydration error to the event target `run()` + * returns. Dispatching an event does not rethrow, so `window.onerror` and `unhandledrejection` never + * see them, and without this listener the SDK sees nothing from the component layer. + * + * Called by {@link instrumentClientRuntime}, and exported for apps that do not serve their browser + * modules through an instrumented asset server. + */ +export function captureRuntimeErrors(app: AppRuntimeLike): void { + if (attached.has(app)) { + return; + } + attached.add(app); + + app.addEventListener('error', event => { + captureException(readComponentError(event), { + mechanism: { handled: false, type: MECHANISM_TYPE }, + }); + }); +} + +/** + * Attach to every `run()` call automatically, so the app never has to call `captureRuntimeErrors()` + * itself. A no-op when the browser module was not transformed, because the channel never fires. + */ +export function instrumentClientRuntime(): void { + if (subscribed) { + return; + } + subscribed = true; + + tracingChannel(UI_RUN_CHANNEL).subscribe({ + end(context) { + const app = (context as { result?: unknown }).result; + if (isAppRuntime(app)) { + captureRuntimeErrors(app); + } + }, + }); +} + +function isAppRuntime(value: unknown): value is AppRuntimeLike { + return typeof (value as AppRuntimeLike | undefined)?.addEventListener === 'function'; +} + +// The runtime puts the thrown value on `error`, and it may be any value, not just an `Error`. +function readComponentError(event: Event): unknown { + const error = (event as ErrorEvent).error; + return error !== undefined ? error : event; +} diff --git a/packages/remix/src/v3/client/sdk.ts b/packages/remix/src/v3/client/sdk.ts index 463de70e504b..cbf5560c1296 100644 --- a/packages/remix/src/v3/client/sdk.ts +++ b/packages/remix/src/v3/client/sdk.ts @@ -3,6 +3,7 @@ import { getDefaultIntegrations as getBrowserDefaultIntegrations, init as browse import { applySdkMetadata, type Client, type Integration } from '@sentry/core'; import { browserTracingIntegration } from './browserTracingIntegration'; +import { instrumentClientRuntime } from './errors'; /** * Default integrations for the Remix 3 client SDK. @@ -24,5 +25,11 @@ export function init(options: BrowserOptions): Client | undefined { applySdkMetadata(opts, 'remix', ['remix', 'browser']); - return browserInit(opts); + const client = browserInit(opts); + + // Subscribed before the app calls `run()`, so an app served through an instrumented asset server + // reports component errors without writing any Sentry code itself. + instrumentClientRuntime(); + + return client; } diff --git a/packages/remix/src/v3/index.client.ts b/packages/remix/src/v3/index.client.ts index 761d76bd0e54..ffe5c04d3175 100644 --- a/packages/remix/src/v3/index.client.ts +++ b/packages/remix/src/v3/index.client.ts @@ -74,3 +74,4 @@ export type { BrowserOptions } from '@sentry/browser'; export { getDefaultIntegrations, init } from './client/sdk'; export { browserTracingIntegration } from './client/browserTracingIntegration'; +export { captureRuntimeErrors, instrumentClientRuntime } from './client/errors'; diff --git a/packages/remix/test/v3/errors.test.ts b/packages/remix/test/v3/errors.test.ts new file mode 100644 index 000000000000..c5224d7fd77e --- /dev/null +++ b/packages/remix/test/v3/errors.test.ts @@ -0,0 +1,63 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +const captureException = vi.fn(); +vi.mock('@sentry/browser', () => ({ captureException: (...args: unknown[]) => captureException(...args) })); + +const { captureRuntimeErrors, instrumentClientRuntime } = await import('../../src/v3/client/errors'); +const { tracingChannel } = await import('../../src/v3/client/diagnosticsChannelShim'); + +/** Stands in for the `AppRuntime` that `run()` returns. */ +function fakeApp(): EventTarget { + return new EventTarget(); +} + +// There is no `ErrorEvent` in this environment. The SDK only reads `.error` off the event, which is +// where the runtime puts the thrown value. +function errorEvent(error: unknown): Event { + return Object.assign(new Event('error'), { error }); +} + +describe('captureRuntimeErrors', () => { + beforeAll(() => { + // Called twice because `init()` may run more than once. Two guards make that safe, the subscribe + // once flag and the `attached` WeakSet; this pins the outcome, not either one on its own. + instrumentClientRuntime(); + instrumentClientRuntime(); + }); + + it('reports one error per dispatch, however often instrumentation was set up', () => { + captureException.mockClear(); + const app = fakeApp(); + tracingChannel('orchestrion:@remix-run/ui:run').end.publish({ result: app }); + + const error = new Error('render failed'); + app.dispatchEvent(errorEvent(error)); + + expect(captureException).toHaveBeenCalledTimes(1); + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.ui.remix_v3' }, + }); + }); + + it('attaches only once to the same app', () => { + captureException.mockClear(); + const app = fakeApp(); + captureRuntimeErrors(app); + captureRuntimeErrors(app); + + app.dispatchEvent(errorEvent(new Error('render failed'))); + + expect(captureException).toHaveBeenCalledTimes(1); + }); + + it('falls back to the event when it carries no error', () => { + captureException.mockClear(); + const app = fakeApp(); + captureRuntimeErrors(app); + + const event = new Event('error'); + app.dispatchEvent(event); + + expect(captureException).toHaveBeenCalledWith(event, expect.anything()); + }); +}); From 2cbe188369a32069df08faa97ea9ea21fadd93be Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 1 Oct 2026 17:09:19 +0200 Subject: [PATCH 23/57] feat(remix): Inject debug IDs through the Remix 3 asset server (#24761) Remix 3 has no build step, so there is no bundler plugin to inject debug IDs. The asset server compiles each module on request, and this patches `createAssetServer` to add a loader that injects the debug ID snippet into every compiled module. The ID is a hash of the compiled source and the module URL, so it is stable across requests and the source map upload can arrive at the same ID in another process. The minifier drops comments and the asset server rebuilds source maps after the loaders ran. So the `//# debugId=` comment and the `debugId` field in the map, which is what `sentry-cli` reads, are added to the served response instead. Source maps follow the other meta framework SDKs. Left out, they are generated but hidden: modules do not reference them and `.map` requests are not served. `sourceMaps: false` keeps them off, with a warning that stack traces stay minified. `getDebugId` and the snippet mirror `@sentry/bundler-plugins/core` rather than importing it, because that entry loads the whole build plugin into the server. Fixes #24667 Co-authored-by: Claude Opus 5.5 --- .../remix-v3/app/actions/controller.tsx | 3 + .../remix-v3/app/actions/public/entry.ts | 8 +- .../app/actions/public/throw-error.ts | 3 + .../test-applications/remix-v3/app/assets.ts | 3 + .../remix-v3/tests/debug-ids.test.ts | 71 ++++++ packages/remix/src/v3/assetServer.ts | 217 ++++++++++++++++ packages/remix/src/v3/debugId.ts | 135 ++++++++++ packages/remix/src/v3/index.server.ts | 1 + packages/remix/src/v3/node.mjs | 8 +- packages/remix/test/v3/assetServer.test.ts | 241 ++++++++++++++++++ packages/remix/test/v3/debugId.test.ts | 93 +++++++ .../src/orchestrion/config/remix-v3.ts | 8 + 12 files changed, 787 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts create mode 100644 packages/remix/src/v3/assetServer.ts create mode 100644 packages/remix/src/v3/debugId.ts create mode 100644 packages/remix/test/v3/assetServer.test.ts create mode 100644 packages/remix/test/v3/debugId.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 59d6567f28bb..8b6dd2cf9214 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -24,6 +24,9 @@ function HomePage(handle: Handle>) { + ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index f558bda2b048..d9b03fe3d561 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -1,6 +1,8 @@ import * as Sentry from '@sentry/remix/v3/client'; import { createElement, run } from 'remix/ui'; +import { throwError } from './throw-error.ts'; + // Not Node. The asset server substitutes this when it compiles the module, from the `define` map in // `app/assets.ts`. declare const process: { env: Record }; @@ -29,7 +31,11 @@ function Boom(): () => never { } document.addEventListener('click', event => { - if ((event.target as HTMLElement | null)?.id === 'component-error') { + const id = (event.target as HTMLElement | null)?.id; + if (id === 'component-error') { void app.frames.top.replace(createElement(Boom, {})); } + if (id === 'throw-error') { + throwError(); + } }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts new file mode 100644 index 000000000000..d8540df85001 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/throw-error.ts @@ -0,0 +1,3 @@ +export function throwError(): never { + throw new Error('Remix 3 client error'); +} diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts index 7c89e32313c7..3cb55a9ab90d 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/assets.ts @@ -6,6 +6,9 @@ export const assets = createAssetServer({ allowFiles: ['app/routes.ts', 'app/**/public/**'], allowPackages: ['remix', '@sentry/remix'], minify: true, + scripts: { + define: { 'process.env.E2E_TEST_DSN': JSON.stringify(process.env.E2E_TEST_DSN) }, + }, watch: false, scripts: { // No bundler means no build time env inlining, so `define` is the only way to get configuration diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts new file mode 100644 index 000000000000..b91955111279 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/debug-ids.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const DEBUG_ID = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'; + +function getDebugId(code: string): string | undefined { + return code.match(new RegExp(`\\n//# debugId=(${DEBUG_ID})$`))?.[1]; +} + +test('every served browser module carries a debug ID', async ({ page, baseURL }) => { + const moduleUrls: string[] = []; + page.on('response', response => { + if (response.request().resourceType() === 'script' && response.url().startsWith(`${baseURL}/assets/`)) { + moduleUrls.push(response.url()); + } + }); + + // `load` fires once every module script and modulepreload has executed, which is what is collected. + await page.goto('/', { waitUntil: 'load' }); + + expect(moduleUrls).toContainEqual(expect.stringContaining('/assets/app/actions/public/entry.ts')); + + for (const url of moduleUrls) { + const code = await (await fetch(url)).text(); + const debugId = getDebugId(code); + + expect(debugId, `${url} has no debugId comment`).toBeDefined(); + expect(code).toContain(`sentry-dbid-${debugId}`); + } +}); + +// The app does not configure source maps, so they are hidden, like the other meta framework SDKs do. +test('source maps are not exposed when the app did not ask for them', async ({ baseURL }) => { + const url = `${baseURL}/assets/app/actions/public/entry.ts`; + + const code = await (await fetch(url)).text(); + const sourceMapResponse = await fetch(`${url}.map`); + + expect(code).not.toContain('//# sourceMappingURL='); + expect(sourceMapResponse.status).toBe(404); +}); + +test('the debug ID of a module is stable across requests', async ({ baseURL }) => { + const url = `${baseURL}/assets/app/actions/public/throw-error.ts`; + + const first = getDebugId(await (await fetch(url)).text()); + const second = getDebugId(await (await fetch(url)).text()); + + expect(first).toMatch(new RegExp(`^${DEBUG_ID}$`)); + expect(second).toBe(first); +}); + +test('a client error carries the debug IDs of the modules in its stack trace', async ({ page, baseURL }) => { + const errorPromise = waitForError('remix-v3', event => { + return !event.type && event.exception?.values?.[0]?.value === 'Remix 3 client error'; + }); + + await page.goto('/'); + await page.locator('#throw-error').click(); + + const errorEvent = await errorPromise; + + const moduleUrl = `${baseURL}/assets/app/actions/public/throw-error.ts`; + const debugId = getDebugId(await (await fetch(moduleUrl)).text()); + + expect(errorEvent.debug_meta?.images).toContainEqual({ + type: 'sourcemap', + code_file: moduleUrl, + debug_id: debugId, + }); +}); diff --git a/packages/remix/src/v3/assetServer.ts b/packages/remix/src/v3/assetServer.ts new file mode 100644 index 000000000000..3e7580854f64 --- /dev/null +++ b/packages/remix/src/v3/assetServer.ts @@ -0,0 +1,217 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { consoleSandbox } from '@sentry/core'; +import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; +import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from './debugId'; + +// The subset of `@remix-run/assets` types used here. `remix` is an optional peer dependency, so +// they are restated rather than imported. +interface ModuleLoadContext { + moduleUrl?: string; + [key: string]: unknown; +} + +interface ModuleLoadResult { + format: string | null | undefined; + shortCircuit?: boolean; + source?: string | ArrayBuffer | ArrayBufferView; +} + +type ModuleLoader = ( + url: string, + context: ModuleLoadContext, + nextLoad: (url: string, context?: Partial) => ModuleLoadResult, +) => ModuleLoadResult; + +interface AssetServerOptions { + // `false` is not in Remix's type, but is how an app tells Sentry it wants no source maps at all, + // since leaving the option out now means hidden source maps. + sourceMaps?: 'inline' | 'external' | false; + scripts?: { loaders?: readonly ModuleLoader[]; [key: string]: unknown }; + [key: string]: unknown; +} + +interface AssetServer { + fetch: (request: Request) => Promise; +} + +interface CreateAssetServerContext { + arguments: unknown[]; + result?: unknown; + _sentryHideSourceMaps?: boolean; +} + +// The asset server appends it after minification, so it is always the last line. +const SOURCE_MAPPING_URL_REGEX = /\n\/\/# sourceMappingURL=\S+\s*$/; + +const MAX_STAMPED_BODIES = 2000; + +let instrumented = false; + +/** + * Makes every Remix 3 asset server created from now on serve browser modules that carry debug IDs. + * + * Source maps follow the other meta framework SDKs: + * - `sourceMaps: false` keeps them off, with a warning that stack traces stay minified. + * - `'inline'` or `'external'` is kept as the app configured it. + * - Left out, they are generated but hidden: modules do not reference them and `.map` requests are + * not served, so the source only reaches Sentry. + * + * Has to run before the app's first `createAssetServer()` call, which usually happens while its + * modules are imported. `Sentry.init()` is too late for that, so the `@sentry/remix/v3/node` entry + * calls this. + */ +export function instrumentAssetServer(): void { + if (instrumented) { + return; + } + instrumented = true; + + // Node rethrows anything a channel subscriber throws as an uncaught exception, which would kill an + // app that runs fine without Sentry. Frozen options or an unexpected server shape reach here, so + // the asset server is left as it was instead. + diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER).subscribe({ + start(data) { + try { + const context = data as CreateAssetServerContext; + const options = context.arguments[0] as AssetServerOptions | undefined; + context._sentryHideSourceMaps = options?.sourceMaps === undefined; + context.arguments[0] = withDebugIdOptions(options); + } catch { + // Ignored on purpose. + } + }, + end(data) { + try { + const { result, _sentryHideSourceMaps } = data as CreateAssetServerContext; + if (result) { + stampServedAssets(result as AssetServer, { hideSourceMaps: Boolean(_sentryHideSourceMaps) }); + } + } catch { + // Ignored on purpose. + } + }, + asyncStart() {}, + asyncEnd() {}, + error() {}, + }); +} + +/** + * Injects the debug ID snippet into every module the asset server compiles. + * + * Loaders run after the TypeScript transform and before minification, so the snippet is minified + * along with the module, and its ID is a hash of the compiled source. The module URL is part of the + * hash so two identical files get IDs of their own, since their source maps differ. + */ +export const debugIdLoader: ModuleLoader = (url, context, nextLoad) => { + const result = nextLoad(url, context); + if (result.format !== 'module' || typeof result.source !== 'string') { + return result; + } + + const debugId = getDebugId(`${context.moduleUrl ?? url}\n${result.source}`); + return { ...result, source: injectDebugIdSnippet(result.source, debugId) }; +}; + +function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServerOptions | undefined { + if (!options) { + return options; + } + + if (options.sourceMaps === false) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] Source maps are disabled in your asset server (`sourceMaps: false`). Sentry will not override this, so client stack traces stay minified.', + ); + }); + } + + const loaders = options.scripts?.loaders ?? []; + + return { + ...options, + // Hidden unless the app chose otherwise, see `stampServedAssets`. + sourceMaps: options.sourceMaps ?? 'external', + scripts: { + ...options.scripts, + // Last, so the ID also covers whatever the app's own loaders changed. + loaders: loaders.includes(debugIdLoader) ? loaders : [...loaders, debugIdLoader], + }, + }; +} + +/** + * The minifier drops comments and the asset server rebuilds source maps after the loaders ran, so + * the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads, + * are added to the served response instead. + */ +function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void { + const fetchAsset = server.fetch; + // The asset server memoizes compiled modules and identifies each version by its ETag, so the + // stamped body is memoized the same way. Without this every hit copied the module body again. + const stamped = new Map(); + + server.fetch = async request => { + const response = await fetchAsset(request); + if (response?.status !== 200 || request.method !== 'GET') { + return response; + } + + const url = new URL(request.url); + const contentType = response.headers.get('content-type') ?? ''; + const etag = response.headers.get('etag'); + + if (contentType.includes('javascript')) { + return withBody(response, await remember(etag, async () => stampModule(await response.text()))); + } + + if (url.pathname.endsWith('.map')) { + if (hideSourceMaps) { + // What the asset server returns for a path it does not serve. + return null; + } + + const body = await remember(etag, async () => { + // A source map does not contain the ID of its module, so it is read from the module itself. + url.pathname = url.pathname.slice(0, -'.map'.length); + const moduleResponse = await fetchAsset(new Request(url)); + const debugId = moduleResponse?.ok ? findDebugId(await moduleResponse.text()) : undefined; + const map = await response.text(); + return debugId ? addDebugIdToSourceMap(map, debugId) : map; + }); + return withBody(response, body); + } + + return response; + }; + + function stampModule(served: string): string { + const code = hideSourceMaps ? served.replace(SOURCE_MAPPING_URL_REGEX, '') : served; + const debugId = findDebugId(code); + return debugId ? `${code}\n//# debugId=${debugId}` : code; + } + + async function remember(etag: string | null, compute: () => Promise): Promise { + if (etag === null) { + return compute(); + } + const cached = stamped.get(etag); + if (cached !== undefined) { + return cached; + } + const body = await compute(); + // Bounded, because in watch mode every edit is a new ETag. + if (stamped.size >= MAX_STAMPED_BODIES) { + stamped.delete(stamped.keys().next().value as string); + } + stamped.set(etag, body); + return body; + } +} + +function withBody(response: Response, body: string): Response { + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(body, { status: response.status, statusText: response.statusText, headers }); +} diff --git a/packages/remix/src/v3/debugId.ts b/packages/remix/src/v3/debugId.ts new file mode 100644 index 000000000000..92094a9fef4c --- /dev/null +++ b/packages/remix/src/v3/debugId.ts @@ -0,0 +1,135 @@ +import { createHash } from 'node:crypto'; + +// Mirrors `stringToUUID` and `getDebugIdSnippet` in `@sentry/bundler-plugins/core`. Importing that +// entry at runtime would load the whole build plugin (a native parser, the Sentry CLI) into the server. + +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +const DEBUG_ID_IDENTIFIER_REGEX = /sentry-dbid-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/; + +const INLINE_SOURCE_MAP_REGEX = /\n?\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/; + +interface SourceMap { + mappings: string; + sources?: string[]; + [key: string]: unknown; +} + +/** + * Hashes `source` into a UUID shaped ID. It has to be deterministic, because the source map upload + * runs in a different process than the server and must arrive at the same ID. + */ +export function getDebugId(source: string): string { + const hash = createHash('sha256').update(source).digest('hex'); + // RFC 4122 section 4.4: the variant nibble is one of 8, 9, a, b. + const variant = ['8', '9', 'a', 'b'][hash.charCodeAt(16) % 4] as string; + + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-${variant}${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} + +/** The snippet that registers a module's debug ID in `globalThis._sentryDebugIds`, keyed by its stack. */ +export function getDebugIdSnippet(debugId: string): string { + return `!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{};var n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="${debugId}",e._sentryDebugIdIdentifier="sentry-dbid-${debugId}");}catch(e){}}();`; +} + +/** Reads back the debug ID that {@link getDebugIdSnippet} put into a module, which survives minification. */ +export function findDebugId(code: string): string | undefined { + return code.match(DEBUG_ID_IDENTIFIER_REGEX)?.[1]; +} + +/** + * Puts the debug ID snippet on its own line at the top of `source`, so it runs before the module + * body and registers the ID even when that body throws. + * + * `source` may end in an inline source map, which is how the asset server hands a module to its + * loaders. The returned map then maps back to `source` and the asset server composes it with its + * own, so it only has to shift every line down by one. + */ +export function injectDebugIdSnippet(source: string, debugId: string): string { + const snippet = getDebugIdSnippet(debugId); + const inlineSourceMap = source.match(INLINE_SOURCE_MAP_REGEX); + + if (!inlineSourceMap?.[1]) { + return `${snippet}\n${source}`; + } + + const code = source.slice(0, inlineSourceMap.index); + const map = JSON.parse(Buffer.from(inlineSourceMap[1], 'base64').toString('utf8')) as SourceMap; + const shiftedMap = { + version: 3, + sources: map.sources?.slice(0, 1) ?? [], + names: [], + mappings: getShiftedIdentityMappings(map.mappings), + }; + + return `${snippet}\n${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(shiftedMap)).toString('base64')}`; +} + +/** Adds the debug ID to a source map, under both field names the upload tooling reads. */ +export function addDebugIdToSourceMap(sourceMap: string, debugId: string): string { + const map = JSON.parse(sourceMap) as SourceMap; + map.debug_id = debugId; + map.debugId = debugId; + return JSON.stringify(map); +} + +/** + * Maps line `n + 1` of the output to line `n` of the input, at exactly the columns the input map + * has segments for. Composition only keeps positions both maps know about, so a coarser identity + * map would lose precision. + */ +function getShiftedIdentityMappings(mappings: string): string { + let previousLine = 0; + let previousColumn = 0; + + const lines = mappings.split(';').map((line, lineIndex) => { + let column = 0; + let previousGeneratedColumn = 0; + + return line + .split(',') + .filter(Boolean) + .map(segment => { + column += decodeFirstVlq(segment); + const encoded = `${encodeVlq(column - previousGeneratedColumn)}A${encodeVlq(lineIndex - previousLine)}${encodeVlq(column - previousColumn)}`; + previousGeneratedColumn = column; + previousLine = lineIndex; + previousColumn = column; + return encoded; + }) + .join(','); + }); + + return `;${lines.join(';')}`; +} + +// A segment's first field is its generated column, relative to the previous segment on the line. +function decodeFirstVlq(segment: string): number { + let value = 0; + let factor = 1; + let index = 0; + let digit: number; + + do { + digit = BASE64_CHARS.indexOf(segment.charAt(index++)); + value += (digit % 32) * factor; + factor *= 32; + } while (digit >= 32); + + // The lowest bit is the sign. + return value % 2 ? -(value - 1) / 2 : value / 2; +} + +function encodeVlq(value: number): string { + let vlq = value < 0 ? -value * 2 + 1 : value * 2; + let encoded = ''; + + do { + const digit = vlq % 32; + vlq = Math.floor(vlq / 32); + // Sets the continuation bit. + encoded += BASE64_CHARS.charAt(vlq > 0 ? digit + 32 : digit); + } while (vlq > 0); + + return encoded; +} diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts index 870ab16899cb..4765039207d4 100644 --- a/packages/remix/src/v3/index.server.ts +++ b/packages/remix/src/v3/index.server.ts @@ -4,3 +4,4 @@ export { getDefaultIntegrations, init } from './server/sdk'; export { remixV3Integration } from './server/integration'; export { sentryRemixMiddleware } from './server/middleware'; export { instrumentRemixV3 } from './server/instrument'; +export { instrumentAssetServer } from './assetServer'; diff --git a/packages/remix/src/v3/node.mjs b/packages/remix/src/v3/node.mjs index 59d77f99fd19..df700b229103 100644 --- a/packages/remix/src/v3/node.mjs +++ b/packages/remix/src/v3/node.mjs @@ -1,8 +1,9 @@ // Replaces `--import remix/node-tsx` rather than adding a second flag. // // This has to happen here, not in `Sentry.init()`: the module hook must be in place before -// `@remix-run/fetch-router` is imported, and the subscription before `createRouter()` runs, which is -// while the app's own modules are still being imported. +// `@remix-run/fetch-router` and `@remix-run/assets` are imported, and the subscriptions before +// `createRouter()` and `createAssetServer()` run, which is while the app's own modules are still +// being imported. import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; registerDiagnosticsChannelInjection(); @@ -10,8 +11,9 @@ registerDiagnosticsChannelInjection(); // A failure to load the SDK must not stop the app: this runs before anything of the app has, and an // uncaught error here means Remix never starts. try { - const { instrumentRemixV3 } = await import('@sentry/remix/v3'); + const { instrumentRemixV3, instrumentAssetServer } = await import('@sentry/remix/v3'); instrumentRemixV3(); + instrumentAssetServer(); } catch (error) { // oxlint-disable-next-line no-console console.warn('[Sentry] Could not load @sentry/remix/v3. The app starts without Sentry instrumentation.', error); diff --git a/packages/remix/test/v3/assetServer.test.ts b/packages/remix/test/v3/assetServer.test.ts new file mode 100644 index 000000000000..7a57c53e2df0 --- /dev/null +++ b/packages/remix/test/v3/assetServer.test.ts @@ -0,0 +1,241 @@ +import * as diagnosticsChannel from 'node:diagnostics_channel'; +import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { debugIdLoader, instrumentAssetServer } from '../../src/v3/assetServer'; +import { getDebugId, getDebugIdSnippet } from '../../src/v3/debugId'; + +type Options = Record; +type FakeFetch = (request: Request) => Promise; + +const channel = diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ASSET_SERVER); + +// Stands in for orchestrion's transform, which calls the original with the channel context's +// `arguments`, so subscribers can replace them. +function createAssetServer(options: Options, fetch: FakeFetch = async () => null) { + const context = { arguments: [options] as unknown[] }; + let receivedOptions: Options | undefined; + + const server = channel.traceSync(() => { + receivedOptions = context.arguments[0] as Options; + return { fetch }; + }, context); + + return { server, receivedOptions: receivedOptions as Options }; +} + +function javascript(body: string): Response { + return new Response(body, { headers: { 'content-type': 'application/javascript; charset=utf-8', etag: 'W/"a"' } }); +} + +describe('instrumentAssetServer', () => { + beforeAll(() => { + instrumentAssetServer(); + }); + + describe('options', () => { + it('generates source maps when the app did not configure them', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets' }); + + expect(receivedOptions.sourceMaps).toBe('external'); + }); + + it('keeps source maps the app configured', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: 'inline' }); + + expect(receivedOptions.sourceMaps).toBe('inline'); + }); + + it('keeps source maps off when the app disabled them, and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { receivedOptions } = createAssetServer({ basePath: '/assets', sourceMaps: false }); + + expect(receivedOptions.sourceMaps).toBe(false); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Source maps are disabled in your asset server')); + warn.mockRestore(); + }); + + it('adds the debug ID loader after the app loaders', () => { + const appLoader = vi.fn(); + const { receivedOptions } = createAssetServer({ scripts: { loaders: [appLoader], define: { a: 'b' } } }); + + expect(receivedOptions.scripts).toEqual({ loaders: [appLoader, debugIdLoader], define: { a: 'b' } }); + }); + + it('does not mutate the options the app passed', () => { + const options = { basePath: '/assets' }; + createAssetServer(options); + + expect(options).toEqual({ basePath: '/assets' }); + }); + + it('adds the loader once when the same options are reused', () => { + const first = createAssetServer({}).receivedOptions; + const { receivedOptions } = createAssetServer(first); + + expect(receivedOptions.scripts.loaders).toEqual([debugIdLoader]); + }); + }); + + describe('debugIdLoader', () => { + it('injects a snippet whose ID hashes the module URL and its compiled source', () => { + const result = debugIdLoader('file:///app/entry.ts', { moduleUrl: '/assets/app/entry.ts' }, () => ({ + format: 'module', + source: 'export const a = 1;', + })); + + const debugId = getDebugId('/assets/app/entry.ts\nexport const a = 1;'); + expect(result.source).toBe(`${getDebugIdSnippet(debugId)}\nexport const a = 1;`); + }); + + it('gives identical modules at different URLs different IDs', () => { + const load = () => ({ format: 'module', source: 'export const a = 1;' }); + + const first = debugIdLoader('file:///app/a.ts', { moduleUrl: '/assets/app/a.ts' }, load); + const second = debugIdLoader('file:///app/b.ts', { moduleUrl: '/assets/app/b.ts' }, load); + + expect(first.source).not.toBe(second.source); + }); + }); + + describe('served assets', () => { + const debugId = getDebugId('module'); + const moduleCode = `${getDebugIdSnippet(debugId)}export const a=1;\n//# sourceMappingURL=/assets/app/entry.ts.map`; + + function createServer(options: Options = { sourceMaps: 'external' }) { + const fetch = vi.fn(async request => { + const { pathname } = new URL(request.url); + if (pathname === '/assets/app/entry.ts') { + return javascript(moduleCode); + } + if (pathname === '/assets/app/entry.ts.map') { + return new Response('{"version":3,"mappings":"AAAA"}', { headers: { 'content-type': 'application/json' } }); + } + if (pathname === '/assets/app/plain.js') { + return javascript('export const b=1;'); + } + return null; + }); + + return { server: createAssetServer(options, fetch).server, fetch }; + } + + it('appends the debugId comment to modules', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${moduleCode}\n//# debugId=${debugId}`); + expect(response?.headers.get('etag')).toBe('W/"a"'); + }); + + it('adds the debug ID of the module to its source map', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + + expect(await response?.json()).toEqual({ version: 3, mappings: 'AAAA', debugId, debug_id: debugId }); + }); + + describe('when the app did not configure source maps', () => { + it('does not reference the source map from modules', async () => { + const { server } = createServer({}); + + const response = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await response?.text()).toBe(`${getDebugIdSnippet(debugId)}export const a=1;\n//# debugId=${debugId}`); + }); + + it('does not serve source maps', async () => { + const { server } = createServer({}); + + expect(await server.fetch(new Request('http://localhost/assets/app/entry.ts.map'))).toBeNull(); + }); + }); + + describe('memoizes by ETag', () => { + // The asset server serves the same compiled module under the same ETag until the file changes, + // so the stamped body is computed once per version, not copied on every hit. + function createRecordingServer(bodies: string[] = [moduleCode, moduleCode]) { + const served: Response[] = []; + let n = 0; + const fetch: FakeFetch = async request => { + const { pathname } = new URL(request.url); + if (pathname === '/assets/app/entry.ts') { + const body = bodies[Math.min(n++, bodies.length - 1)] as string; + // As the real server does: a new body gets a new ETag, the same body keeps its ETag. + const response = new Response(body, { + headers: { 'content-type': 'application/javascript', etag: `W/"${bodies.indexOf(body)}"` }, + }); + served.push(response); + return response; + } + if (pathname === '/assets/app/entry.ts.map') { + return new Response('{"version":3,"mappings":"AAAA"}', { + headers: { 'content-type': 'application/json', etag: 'W/"m"' }, + }); + } + return null; + }; + const fetchSpy = vi.fn(fetch); + return { server: createAssetServer({ sourceMaps: 'external' }, fetchSpy).server, served, fetchSpy }; + } + + it('reads a module body once per ETag', async () => { + const { server, served } = createRecordingServer(); + + const first = await (await server.fetch(new Request('http://localhost/assets/app/entry.ts')))?.text(); + const second = await (await server.fetch(new Request('http://localhost/assets/app/entry.ts')))?.text(); + + expect(second).toBe(first); + expect(served.map(response => response.bodyUsed)).toEqual([true, false]); + }); + + it('reads the module once for a source map served under the same ETag', async () => { + const { server, fetchSpy } = createRecordingServer(); + + await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + await server.fetch(new Request('http://localhost/assets/app/entry.ts.map')); + + // Two map requests, and the module looked up only for the first. + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + + it('stamps a changed module again', async () => { + const changedId = getDebugId('changed'); + const changed = `${getDebugIdSnippet(changedId)}export const a=2;`; + const { server, served } = createRecordingServer([moduleCode, changed]); + + await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + const second = await server.fetch(new Request('http://localhost/assets/app/entry.ts')); + + expect(await second?.text()).toBe(`${changed}\n//# debugId=${changedId}`); + expect(served.map(response => response.bodyUsed)).toEqual([true, true]); + }); + }); + + it('leaves modules without a snippet unchanged', async () => { + const { server } = createServer(); + + const response = await server.fetch(new Request('http://localhost/assets/app/plain.js')); + + expect(await response?.text()).toBe('export const b=1;'); + }); + + it('passes through requests the asset server does not handle', async () => { + const { server } = createServer(); + + expect(await server.fetch(new Request('http://localhost/other'))).toBeNull(); + }); + + it('leaves HEAD requests alone', async () => { + const { server, fetch } = createServer(); + const request = new Request('http://localhost/assets/app/entry.ts', { method: 'HEAD' }); + + await server.fetch(request); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith(request); + }); + }); +}); diff --git a/packages/remix/test/v3/debugId.test.ts b/packages/remix/test/v3/debugId.test.ts new file mode 100644 index 000000000000..6c813590b835 --- /dev/null +++ b/packages/remix/test/v3/debugId.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from '../../src/v3/debugId'; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function withInlineSourceMap(code: string, map: object): string { + return `${code}\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(map)).toString('base64')}`; +} + +function readInlineSourceMap(source: string): Record { + const encoded = source.match(/sourceMappingURL=data:application\/json;base64,(.+)$/)?.[1] ?? ''; + return JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')); +} + +describe('getDebugId', () => { + it('returns a UUID v4 shaped ID', () => { + expect(getDebugId('export const a = 1;')).toMatch(UUID_REGEX); + }); + + it('is derived from the source only', () => { + expect(getDebugId('export const a = 1;')).toBe(getDebugId('export const a = 1;')); + expect(getDebugId('export const a = 1;')).not.toBe(getDebugId('export const a = 2;')); + }); + + it('matches the ID the bundler plugins derive from the same input', () => { + // `stringToUUID('')` in `@sentry/bundler-plugins/core`. + expect(getDebugId('')).toBe('e3b0c442-98fc-4c14-9afb-f4c8996fb924'); + }); +}); + +describe('injectDebugIdSnippet', () => { + const debugId = getDebugId('module'); + + it('puts the snippet on the first line', () => { + const output = injectDebugIdSnippet('export const a = 1;', debugId); + + expect(output.split('\n')).toEqual([expect.stringContaining(`sentry-dbid-${debugId}`), 'export const a = 1;']); + }); + + it('registers the ID in `_sentryDebugIds` keyed by the stack', () => { + const globalObject = globalThis as { _sentryDebugIds?: Record }; + delete globalObject._sentryDebugIds; + + // oxlint-disable-next-line typescript/no-implied-eval + new Function(injectDebugIdSnippet('', debugId))(); + + expect(Object.values(globalObject._sentryDebugIds ?? {})).toEqual([debugId]); + delete globalObject._sentryDebugIds; + }); + + it('returns a source map that shifts the incoming positions down one line', () => { + const input = withInlineSourceMap('import a from "a";\nexport const b = a;', { + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Line 0 has segments at columns 0 and 4, line 1 at columns 0 and 6. + mappings: 'AAAA,IAAI;AACA,MAAM', + }); + + const output = injectDebugIdSnippet(input, debugId); + + expect(output.split('\n').slice(1, 3)).toEqual(['import a from "a";', 'export const b = a;']); + expect(readInlineSourceMap(output)).toEqual({ + version: 3, + sources: ['/assets/app/entry.ts'], + names: [], + // Identity at the same columns, one line lower. The first segment of the second line steps + // back from column 4 to 0, hence `J` (-4). + mappings: ';AAAA,IAAI;AACJ,MAAM', + }); + }); +}); + +describe('findDebugId', () => { + it('reads the ID back out of minified code', () => { + const debugId = getDebugId('module'); + const minified = injectDebugIdSnippet('', debugId).replace(/"/g, '`'); + + expect(findDebugId(minified)).toBe(debugId); + }); + + it('returns undefined for code without a snippet', () => { + expect(findDebugId('export const a = 1;')).toBeUndefined(); + }); +}); + +describe('addDebugIdToSourceMap', () => { + it('sets both field names and keeps the rest of the map', () => { + const map = JSON.parse(addDebugIdToSourceMap('{"version":3,"mappings":"AAAA"}', 'abc')); + + expect(map).toEqual({ version: 3, mappings: 'AAAA', debugId: 'abc', debug_id: 'abc' }); + }); +}); diff --git a/packages/server-utils/src/orchestrion/config/remix-v3.ts b/packages/server-utils/src/orchestrion/config/remix-v3.ts index ee30e3ffdcf7..831232dcd276 100644 --- a/packages/server-utils/src/orchestrion/config/remix-v3.ts +++ b/packages/server-utils/src/orchestrion/config/remix-v3.ts @@ -19,8 +19,16 @@ export const remixV3Config: InstrumentationConfig[] = [ }, functionQuery: { functionName: 'createRouter', kind: 'Sync' }, }, + // The subscriber rewrites the options before the asset server reads them and wraps the server it + // returns, so browser modules carry debug IDs without any config from the app. + { + channelName: 'createAssetServer', + module: { name: '@remix-run/assets', versionRange: '>=0.6.0 <1', filePath: 'dist/lib/asset-server.js' }, + functionQuery: { functionName: 'createAssetServer', kind: 'Sync' }, + }, ]; export const remixV3Channels = { REMIX_V3_CREATE_ROUTER: 'orchestrion:@remix-run/fetch-router:createRouter', + REMIX_V3_CREATE_ASSET_SERVER: 'orchestrion:@remix-run/assets:createAssetServer', } as const; From ca0837de891e37ccbc5d77976ae1bed1041f2761 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 1 Oct 2026 17:09:20 +0200 Subject: [PATCH 24/57] feat(remix): Complete Remix 3 server error handling (#24878) Covers the error paths the router middleware alone does not see, and stops reporting things that are not faults. `createRequestListener` in `@remix-run/node-fetch-server` is patched to install an `onError` that chains to the app's own. This is the only hook that covers an app whose fetch handler is not a router. It needs no abort guard: the listener checks `isRequestAbortError` itself and returns before calling `onError`. The middleware captures too, so an event carries the route and request already on the scope. That path does need the abort guard, because `raceRequestAbort` rejects with `signal.reason` when the client disconnects. Without it every user navigating away mid request creates an issue. Removing the guard makes the new e2e test fail. `shouldHandleError` follows `@sentry/hono`: skip 3xx and 4xx errors carrying a numeric `status`, capture the rest. Fixes #24664 --------- Co-authored-by: Claude Fable 5.1 --- .../remix-v3/app/actions/controller.tsx | 15 +++ .../test-applications/remix-v3/app/routes.ts | 3 + .../test-applications/remix-v3/server.ts | 11 ++- .../remix-v3/tests/server-errors.test.ts | 45 +++++++++ packages/remix/src/v3/index.server.ts | 2 + packages/remix/src/v3/server/errorFilter.ts | 60 ++++++++++++ packages/remix/src/v3/server/instrument.ts | 76 ++++++++++++++-- packages/remix/src/v3/server/integration.ts | 16 +++- packages/remix/src/v3/server/middleware.ts | 12 ++- packages/remix/src/v3/types.ts | 5 + packages/remix/test/v3/errorFilter.test.ts | 91 +++++++++++++++++++ packages/remix/test/v3/instrument.test.ts | 69 +++++++++++++- .../src/orchestrion/config/remix-v3.ts | 11 +++ 13 files changed, 396 insertions(+), 20 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/server-errors.test.ts create mode 100644 packages/remix/src/v3/server/errorFilter.ts create mode 100644 packages/remix/test/v3/errorFilter.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx index 8b6dd2cf9214..9d52a907bcbe 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/controller.tsx @@ -46,6 +46,8 @@ function UserPage(handle: Handle<{ id?: string }>) { ); } +let slowStarted = false; + export default createController(routes, { actions: { async assets(context) { @@ -60,5 +62,18 @@ export default createController(routes, { teapot() { return new Response("I'm a teapot", { status: 418 }); }, + boom() { + throw new Error('Route handler failed'); + }, + // Long enough for a test to disconnect mid request. `/slow-started` tells the test when the handler + // is running, so the disconnect lands inside it rather than before it. + async slow() { + slowStarted = true; + await new Promise(resolve => setTimeout(resolve, 3000)); + return new Response('slow'); + }, + slowStarted() { + return new Response(slowStarted ? '1' : '0'); + }, }, }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts index 77b32281374b..37c442cc6c82 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/routes.ts @@ -5,4 +5,7 @@ export const routes = route({ home: '/', user: get('/users/:id'), teapot: get('/teapot'), + boom: get('/boom'), + slow: get('/slow'), + slowStarted: get('/slow-started'), }); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts index 56408af2bbe9..c9ac4f00d087 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/server.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/server.ts @@ -13,12 +13,13 @@ Sentry.init({ const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3060; const server = http.createServer( - createRequestListener(async request => { - try { - return await router.fetch(request); - } catch { - return new Response('Internal Server Error', { status: 500 }); + createRequestListener(request => { + // Handled outside the router on purpose: the only hook that can see this is the listener's + // `onError`, which is the case the router middleware cannot cover. + if (new URL(request.url).pathname === '/plain-throw') { + throw new Error('Plain handler failed'); } + return router.fetch(request); }), ); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-errors.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-errors.test.ts new file mode 100644 index 000000000000..01e9c2c5810f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/server-errors.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +const APP_NAME = 'remix-v3'; + +test('reports an error thrown in a route handler, with its route', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Route handler failed'); + + await fetch(`${baseURL}/boom`); + + const event = await errorPromise; + expect(event.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.remix_v3.middleware' }); + expect(event.transaction).toBe('GET /boom'); +}); + +test('reports an error from a fetch handler that is not the router', async ({ baseURL }) => { + const errorPromise = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Plain handler failed'); + + const response = await fetch(`${baseURL}/plain-throw`); + + expect(response.status).toBe(500); + const event = await errorPromise; + expect(event.exception?.values?.[0]?.mechanism).toEqual({ handled: false, type: 'auto.http.remix_v3.on_error' }); +}); + +test('does not report a request the client aborted', async ({ baseURL }) => { + const seen: string[] = []; + void waitForError(APP_NAME, event => { + seen.push(event.exception?.values?.[0]?.value ?? ''); + return false; + }); + + const controller = new AbortController(); + const slow = fetch(`${baseURL}/slow`, { signal: controller.signal }).catch(() => undefined); + await expect.poll(async () => (await fetch(`${baseURL}/slow-started`)).text()).toBe('1'); + controller.abort(); + await slow; + + // A later real error proves the pipeline is live, so an abort error would have arrived before it. + const sentinel = waitForError(APP_NAME, event => event.exception?.values?.[0]?.value === 'Route handler failed'); + await fetch(`${baseURL}/boom`); + await sentinel; + + expect(seen.filter(value => value !== 'Route handler failed')).toEqual([]); +}); diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts index 4765039207d4..422e5b31e7f6 100644 --- a/packages/remix/src/v3/index.server.ts +++ b/packages/remix/src/v3/index.server.ts @@ -5,3 +5,5 @@ export { remixV3Integration } from './server/integration'; export { sentryRemixMiddleware } from './server/middleware'; export { instrumentRemixV3 } from './server/instrument'; export { instrumentAssetServer } from './assetServer'; +export { defaultShouldHandleError } from './server/errorFilter'; +export type { ShouldHandleError } from './server/errorFilter'; diff --git a/packages/remix/src/v3/server/errorFilter.ts b/packages/remix/src/v3/server/errorFilter.ts new file mode 100644 index 000000000000..9e98a2874a27 --- /dev/null +++ b/packages/remix/src/v3/server/errorFilter.ts @@ -0,0 +1,60 @@ +import { captureException } from '@sentry/core'; + +/** Decides whether a thrown value should become a Sentry issue. */ +export type ShouldHandleError = (error: unknown) => boolean; + +/** + * Skip 3xx and 4xx errors, capture everything else. A numeric `status` in that range is an expected + * outcome, not a fault, and the request still produces a span. + */ +export function defaultShouldHandleError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return true; + } + + const status = (error as { status?: unknown }).status; + + return !(typeof status === 'number' && status >= 300 && status < 500); +} + +// Configured at `Sentry.init()`, but read by subscribers installed earlier by the `--import` entry, so +// it lives in module scope rather than being captured before it exists. +let shouldHandleError: ShouldHandleError = defaultShouldHandleError; + +/** @internal Set by the integration during `Sentry.init()`. */ +export function setShouldHandleError(filter: ShouldHandleError | undefined): void { + shouldHandleError = filter ?? defaultShouldHandleError; +} + +/** + * Report an error unless it is an aborted request or filtered out. + * + * A router failure can arrive twice, from the middleware and again from `onError`. `captureException` + * marks the object `__sentry_captured__` and drops the second report; only a thrown primitive cannot + * be marked and may be reported twice. + */ +export function captureRequestError(error: unknown, request: Request | undefined, mechanism: string): boolean { + // The app's filter can throw. Letting that out would replace the request's own error and skip the + // app's `onError`, so reporting is best effort. + try { + if (request && isRequestAbort(error, request)) { + return false; + } + if (!shouldHandleError(error)) { + return false; + } + captureException(error, { mechanism: { handled: false, type: mechanism } }); + return true; + } catch { + return false; + } +} + +/** + * Whether a rejection is the client giving up rather than the app failing. The router rejects with + * `signal.reason` when the connection drops; without this check every user navigating away mid + * request creates an issue. + */ +export function isRequestAbort(error: unknown, request: Request): boolean { + return request.signal.aborted && error === request.signal.reason; +} diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index 8fc254055588..d16fff4746af 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -2,7 +2,8 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; -import type { MatcherLike, RouterOptionsLike } from '../types'; +import type { MatcherLike, RequestListenerOptionsLike, RouterOptionsLike } from '../types'; +import { captureRequestError } from './errorFilter'; import { sentryRemixMiddleware } from './middleware'; const NOOP = (): void => {}; @@ -34,6 +35,11 @@ export function instrumentRemixV3(): void { } subscribed = true; + subscribeToCreateRouter(); + subscribeToCreateRequestListener(); +} + +function subscribeToCreateRouter(): void { diagnosticsChannel.tracingChannel(remixV3Channels.REMIX_V3_CREATE_ROUTER).subscribe({ start(data) { // Node rethrows anything this handler throws as an uncaught exception, which would kill an app @@ -52,32 +58,82 @@ export function instrumentRemixV3(): void { }); } +/** + * Report errors from any fetch handler, router or not. The middleware only sees failures inside a + * router; this is the only hook that covers a plain handler. No abort guard is needed, because the + * listener drops aborted requests before calling `onError`. + */ +function subscribeToCreateRequestListener(): void { + diagnosticsChannel + .tracingChannel(remixV3Channels.REMIX_V3_CREATE_REQUEST_LISTENER) + .subscribe({ + start(data) { + try { + injectOnError(ensureOptions(data.arguments, 1)); + } catch { + // Ignored on purpose. + } + }, + end: NOOP, + asyncStart: NOOP, + asyncEnd: NOOP, + error: NOOP, + }); +} + +function injectOnError(raw: Record | undefined): void { + if (!raw || markInjected(raw)) { + return; + } + + const options = raw as RequestListenerOptionsLike; + const appOnError = options.onError; + + options.onError = error => { + captureRequestError(error, undefined, 'auto.http.remix_v3.on_error'); + + if (appOnError) { + // Chained so the app keeps its own response. + return appOnError(error); + } + + // Setting `onError` replaced the listener's default handler, which logs the error. + // oxlint-disable-next-line no-console + console.error(error); + return undefined; + }; +} + /** * The options object, created when the caller omitted it. `undefined` when the caller passed something * that is not an options object, which must not be overwritten. */ -function ensureOptions(args: unknown[]): Record | undefined { - const existing = args[0]; +function ensureOptions(args: unknown[], index = 0): Record | undefined { + const existing = args[index]; if (existing === undefined || existing === null) { const created: Record = {}; - args[0] = created; + args[index] = created; return created; } return typeof existing === 'object' ? (existing as Record) : undefined; } -function injectRouterMiddleware(raw: Record | undefined): void { - if (!raw) { - return; - } - +/** Whether this object was already injected into, marking it when it was not. */ +function markInjected(raw: Record): boolean { const marker = raw as { [INJECTED]?: boolean }; if (marker[INJECTED]) { - return; + return true; } marker[INJECTED] = true; + return false; +} + +function injectRouterMiddleware(raw: Record | undefined): void { + if (!raw || markInjected(raw)) { + return; + } const options = raw as RouterOptionsLike; diff --git a/packages/remix/src/v3/server/integration.ts b/packages/remix/src/v3/server/integration.ts index 84af41b67156..3ceabf6acbd4 100644 --- a/packages/remix/src/v3/server/integration.ts +++ b/packages/remix/src/v3/server/integration.ts @@ -1,13 +1,24 @@ import { defineIntegration, type IntegrationFn } from '@sentry/core'; +import { setShouldHandleError, type ShouldHandleError } from './errorFilter'; import { instrumentRemixV3 } from './instrument'; const INTEGRATION_NAME = 'RemixV3' as const; -const _remixV3Integration = (() => { +interface RemixV3IntegrationOptions { + /** + * Decides which thrown values become issues. Return `false` to drop one. The default skips a numeric + * `status` from 300 to 499. Aborted requests never reach this. + */ + shouldHandleError?: ShouldHandleError; +} + +const _remixV3Integration = ((options: RemixV3IntegrationOptions = {}) => { return { name: INTEGRATION_NAME, setupOnce() { + setShouldHandleError(options.shouldHandleError); + // Usually a no-op: `createRouter()` runs while the app's modules are imported, before any // `init()`, so `--import @sentry/remix/v3/node` has already subscribed. This covers setups that // register the module hook from `init()` instead. @@ -17,6 +28,7 @@ const _remixV3Integration = (() => { }) satisfies IntegrationFn; /** - * Names the `http.server` spans `@sentry/node` opens after the matched Remix 3 route. + * Names the `http.server` spans `@sentry/node` opens after the matched Remix 3 route, and reports the + * errors the spans alone would not show. */ export const remixV3Integration = defineIntegration(_remixV3Integration); diff --git a/packages/remix/src/v3/server/middleware.ts b/packages/remix/src/v3/server/middleware.ts index 0409d48f992e..541adc696214 100644 --- a/packages/remix/src/v3/server/middleware.ts +++ b/packages/remix/src/v3/server/middleware.ts @@ -11,6 +11,7 @@ import { } from '@sentry/core'; import type { MatcherLike, MiddlewareLike, NextFunctionLike, RequestContextLike } from '../types'; +import { captureRequestError } from './errorFilter'; import { resolveRoutePattern } from './route'; /** @@ -28,7 +29,16 @@ export function sentryRemixMiddleware(matcher: MatcherLike): MiddlewareLike { // Applied before `next()` so anything captured while the handler runs already carries the route. applyRoute(isolationScope, matcher, context); - const response = await next(); + let response; + try { + response = await next(); + } catch (error) { + // Captured here as well as in `onError`, so the event carries the route this middleware just put + // on the scope. + captureRequestError(error, context.request, 'auto.http.remix_v3.middleware'); + throw error; + } + setResponseStatus(response); return response; diff --git a/packages/remix/src/v3/types.ts b/packages/remix/src/v3/types.ts index 87dff35d797c..a5bcbe96e017 100644 --- a/packages/remix/src/v3/types.ts +++ b/packages/remix/src/v3/types.ts @@ -37,3 +37,8 @@ export interface RouterOptionsLike { middleware?: MiddlewareLike[]; matcher?: MatcherLike; } + +/** `createRequestListener`'s options, of which only the error hook is touched. */ +export interface RequestListenerOptionsLike { + onError?: (error: unknown) => void | Response | Promise; +} diff --git a/packages/remix/test/v3/errorFilter.test.ts b/packages/remix/test/v3/errorFilter.test.ts new file mode 100644 index 000000000000..f307f8bdd4e4 --- /dev/null +++ b/packages/remix/test/v3/errorFilter.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const captureException = vi.fn(); +vi.mock('@sentry/core', () => ({ captureException: (...args: unknown[]) => captureException(...args) })); + +const { captureRequestError, defaultShouldHandleError, isRequestAbort, setShouldHandleError } = + await import('../../src/v3/server/errorFilter'); + +/** A request whose connection has dropped, rejecting with `signal.reason` as the router does. */ +function abortedRequest(reason: unknown): Request { + const controller = new AbortController(); + const request = new Request('http://x/', { signal: controller.signal }); + controller.abort(reason); + return request; +} + +describe('defaultShouldHandleError', () => { + it.each([300, 302, 404, 499])('skips status %i', status => { + expect(defaultShouldHandleError({ status })).toBe(false); + }); + + it.each([200, 500, 503])('captures status %i', status => { + expect(defaultShouldHandleError({ status })).toBe(true); + }); + + it.each([ + ['a plain error', new Error('boom')], + ['a thrown string', 'boom'], + ['null', null], + ['a non-numeric status', { status: '404' }], + ])('captures %s', (_what, error) => { + expect(defaultShouldHandleError(error)).toBe(true); + }); +}); + +describe('isRequestAbort', () => { + it('matches the reason the request was aborted with', () => { + const reason = new Error('aborted'); + expect(isRequestAbort(reason, abortedRequest(reason))).toBe(true); + }); + + it('does not match a different error on an aborted request', () => { + expect(isRequestAbort(new Error('boom'), abortedRequest(new Error('aborted')))).toBe(false); + }); + + it('does not match on a live request', () => { + expect(isRequestAbort(new Error('boom'), new Request('http://x/'))).toBe(false); + }); +}); + +describe('captureRequestError', () => { + beforeEach(() => { + captureException.mockClear(); + setShouldHandleError(undefined); + }); + + it('captures with the mechanism it was given', () => { + const error = new Error('boom'); + + expect(captureRequestError(error, new Request('http://x/'), 'auto.test')).toBe(true); + expect(captureException).toHaveBeenCalledWith(error, { mechanism: { handled: false, type: 'auto.test' } }); + }); + + it('drops an aborted request', () => { + const reason = new Error('aborted'); + + expect(captureRequestError(reason, abortedRequest(reason), 'auto.test')).toBe(false); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('drops what the filter rejects', () => { + setShouldHandleError(() => false); + + expect(captureRequestError(new Error('boom'), new Request('http://x/'), 'auto.test')).toBe(false); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('swallows a throwing filter so the request keeps its own error', () => { + setShouldHandleError(() => { + throw new Error('filter broke'); + }); + + expect(() => captureRequestError(new Error('boom'), new Request('http://x/'), 'auto.test')).not.toThrow(); + expect(captureException).not.toHaveBeenCalled(); + }); + + it('still captures without a request, as the onError hook has none', () => { + expect(captureRequestError(new Error('boom'), undefined, 'auto.test')).toBe(true); + expect(captureException).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index 7eddc88c84d3..1fa4e14d0513 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -1,16 +1,30 @@ import { channel } from 'node:diagnostics_channel'; +import type * as SentryCore from '@sentry/core'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { instrumentRemixV3 } from '../../src/v3/server/instrument'; +const captureException = vi.fn(); + +// Only `captureException` is replaced; the middleware needs the rest for real. +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + captureException: (...args: unknown[]) => captureException(...args), +})); + +const { instrumentRemixV3 } = await import('../../src/v3/server/instrument'); const startChannel = channel(`tracing:${remixV3Channels.REMIX_V3_CREATE_ROUTER}:start`); +const listenerStartChannel = channel(`tracing:${remixV3Channels.REMIX_V3_CREATE_REQUEST_LISTENER}:start`); /** What orchestrion's transform publishes: the call's arguments, collected into a real array. */ function publishCreateRouter(args: unknown[]): void { startChannel.publish({ arguments: args }); } +function publishCreateRequestListener(args: unknown[]): void { + listenerStartChannel.publish({ arguments: args }); +} + describe('instrumentRemixV3', () => { beforeAll(() => { // Called twice on purpose: the `--import` entry and `setupOnce()` both call it, and a second set @@ -68,3 +82,54 @@ describe('instrumentRemixV3', () => { expect(options.middleware).toEqual([]); }); }); + +describe('the createRequestListener error hook', () => { + beforeAll(() => { + instrumentRemixV3(); + }); + + beforeEach(() => { + captureException.mockClear(); + }); + + it("captures and then calls the app's own onError", async () => { + const appResponse = new Response('handled', { status: 500 }); + const appOnError = vi.fn(() => appResponse); + const options: Record = { onError: appOnError }; + + publishCreateRequestListener([() => new Response(), options]); + const error = new Error('boom'); + const returned = await (options.onError as (e: unknown) => Promise)(error); + + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.remix_v3.on_error' }, + }); + expect(appOnError).toHaveBeenCalledWith(error); + expect(returned).toBe(appResponse); + }); + + it('keeps the default console logging when the app passed no onError', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const args: unknown[] = [() => new Response()]; + + publishCreateRequestListener(args); + const error = new Error('boom'); + const returned = await (args[1] as { onError: (e: unknown) => unknown }).onError(error); + + expect(captureException).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.http.remix_v3.on_error' }, + }); + // What the listener's own default handler does, which the hook replaced. + expect(consoleError).toHaveBeenCalledWith(error); + expect(returned).toBeUndefined(); + consoleError.mockRestore(); + }); + + it('installs a hook when the app passed no options at all', () => { + const args: unknown[] = [() => new Response()]; + + publishCreateRequestListener(args); + + expect(args[1]).toEqual(expect.objectContaining({ onError: expect.any(Function) })); + }); +}); diff --git a/packages/server-utils/src/orchestrion/config/remix-v3.ts b/packages/server-utils/src/orchestrion/config/remix-v3.ts index 831232dcd276..51ed8a14adef 100644 --- a/packages/server-utils/src/orchestrion/config/remix-v3.ts +++ b/packages/server-utils/src/orchestrion/config/remix-v3.ts @@ -26,9 +26,20 @@ export const remixV3Config: InstrumentationConfig[] = [ module: { name: '@remix-run/assets', versionRange: '>=0.6.0 <1', filePath: 'dist/lib/asset-server.js' }, functionQuery: { functionName: 'createAssetServer', kind: 'Sync' }, }, + // The only error hook that covers an app whose fetch handler is not a router. + { + channelName: 'createRequestListener', + module: { + name: '@remix-run/node-fetch-server', + versionRange: '>=0.14.0 <1', + filePath: 'dist/lib/request-listener.js', + }, + functionQuery: { functionName: 'createRequestListener', kind: 'Sync' }, + }, ]; export const remixV3Channels = { REMIX_V3_CREATE_ROUTER: 'orchestrion:@remix-run/fetch-router:createRouter', REMIX_V3_CREATE_ASSET_SERVER: 'orchestrion:@remix-run/assets:createAssetServer', + REMIX_V3_CREATE_REQUEST_LISTENER: 'orchestrion:@remix-run/node-fetch-server:createRequestListener', } as const; From df2b796832b0fcedde078f7c0bf1f01c3adf86ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 1 Oct 2026 18:19:47 +0300 Subject: [PATCH 25/57] feat(effect): Add opt-in for external span parents and isolate root spans (#23900) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@sentry/effect` parented every Effect span on the active Sentry span. A `Tracer.ExternalSpan` parent was dropped, so a persisted trace continued with `Tracer.externalSpan` started a disconnected trace, and a `root: true` span or a span leaked from another fiber through the async context could become the parent of an unrelated span. On the server, every parentless span also shared the process-wide propagation context, so a long-lived process put all of its work into one trace. - A parentless span only nests under a foreign active Sentry span (an `http.server` span from the Node SDK, a pageload), never under a span this tracer created. - The server tracer starts a new trace for every parentless span, unless the user set up the current scope (`continueTrace`, `withScope`, an isolation scope). The client tracer keeps parentless spans in the page trace. - An external parent is ignored by default, and the span nests where Effect would have put it without the `parent` option: under the fiber's current span, or parentless. - The new `SentryEffectExternalSpanLayer` opts into continuing external parents: the span becomes a new root span with the external span as `parent_span_id`. No dynamic sampling context is frozen, so the SDK builds one from the client. Next to the tracer layer it applies to the whole runtime, with `Effect.provide` to a single effect only there. ### Decisions **External parents are opt-in.** A `Tracer.externalSpan` parent bridges a trace the SDK did not start, for example an OpenTelemetry span of another app in the same process, or trace state persisted with a queue message. Joining such a trace silently would re-parent spans into a trace the SDK cannot vouch for, so the user adds the layer to ask for it. Incoming trace headers on the Effect HTTP server are out of scope here and get their own PR. **The opt-in is a layer, not a second tracer.** Two layers that set the tracer race inside `Layer.mergeAll`, and a wrapping tracer only sees the Sentry one when nested with `Layer.provide`. A flag in the fiber context composes in `Layer.mergeAll` like `Layer.setTracer` does, and also works per effect. It is a plain service in both Effect versions, because v4 has no `FiberRef` and v3 has no `Context.Reference`. **How the tracer reads the flag.** Effect's `span` hook gets no fiber in either version, only the span's own context or annotations. The `context` hook does get the fiber and wraps every operation the fiber evaluates, and `span` runs synchronously inside it, so the hook keeps the fiber in a module variable for that extent, the same way it already sets the active Sentry span there. The flag is read only when an external parent shows up. These fixes apply to both trace lifecycles. One related limitation stays and is specific to `traceLifecycle: 'static'`: a child span that ends after its root span is dropped with the transaction. Long-running Effect fibers, such as a background agent that outlives the request that started it, lose those children unless `traceLifecycle: 'stream'` is used, which sends every span on its own end and is the default since v11. The effect-3-node and effect-4-node e2e apps cover a `Tracer.externalSpan` parent continued through the layer and a `root: true` span inside a request. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- .../effect-3-node/src/app.ts | 26 ++ .../effect-3-node/tests/spans.test.ts | 38 +++ .../effect-4-node/src/app.ts | 27 ++ .../effect-4-node/tests/spans.test.ts | 38 +++ packages/effect/src/client/tracer.ts | 2 +- packages/effect/src/index.client.ts | 1 + packages/effect/src/index.server.ts | 1 + packages/effect/src/server/tracer.ts | 6 +- packages/effect/src/tracer.ts | 286 ++++++++++++++---- packages/effect/test/tracer.test.ts | 161 +++++++++- 10 files changed, 531 insertions(+), 55 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts index 9de243f3cab3..3d6739e329f1 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/src/app.ts @@ -6,6 +6,7 @@ import * as Cause from 'effect/Cause'; import * as Layer from 'effect/Layer'; import * as Logger from 'effect/Logger'; import * as LogLevel from 'effect/LogLevel'; +import * as Tracer from 'effect/Tracer'; import { createServer } from 'http'; const SentryLive = Layer.mergeAll( @@ -42,6 +43,31 @@ const router = HttpRouter.empty.pipe( }), ), + HttpRouter.get( + '/test-root-span', + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker')); + yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true })); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + + HttpRouter.get( + '/test-external-parent', + Effect.gen(function* () { + yield* Effect.sleep('10 millis').pipe( + Effect.withSpan('continued-span', { + parent: Tracer.externalSpan({ + traceId: 'fedcba0987654321fedcba0987654321', + spanId: '0987654321fedcba', + sampled: true, + }), + }), + ); + return yield* HttpServerResponse.json({ status: 'ok' }); + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), + ), + HttpRouter.get( '/test-error', Effect.gen(function* () { diff --git a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts index 9f689d35018b..4025bacdbfba 100644 --- a/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-3-node/tests/spans.test.ts @@ -65,3 +65,41 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL expect(child.trace_id).toBe(segment.trace_id); } }); + +test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => { + const requestSpansPromise = collectStreamedSpans( + 'effect-3-node', + spans => + spans.some( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/test-root-span', + ) && spans.some(span => span.name === 'root-span-request-marker'), + ); + const detachedSpanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'detached-root-span'); + + await fetch(`${baseURL}/test-root-span`); + + const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); + const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!; + expect(segment.name).toBe('http.server GET'); + expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); + + expect(detachedSpan.is_segment).toBe(true); + expect(detachedSpan.parent_span_id).toBeUndefined(); + expect(detachedSpan.trace_id).not.toBe(segment.trace_id); +}); + +test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('effect-3-node', span => span.name === 'continued-span'); + + await fetch(`${baseURL}/test-external-parent`); + + const span = await spanPromise; + expect(span).toMatchObject({ + is_segment: true, + trace_id: 'fedcba0987654321fedcba0987654321', + parent_span_id: '0987654321fedcba', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts index db5b1fae44eb..0bcc70088359 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/src/app.ts @@ -101,6 +101,33 @@ const Routes = Layer.mergeAll( }), ), + HttpRouter.add( + 'GET', + '/test-root-span', + Effect.gen(function* () { + yield* Effect.void.pipe(Effect.withSpan('root-span-request-marker')); + yield* Effect.sleep('10 millis').pipe(Effect.withSpan('detached-root-span', { root: true })); + return yield* HttpServerResponse.json({ status: 'ok' }); + }), + ), + + HttpRouter.add( + 'GET', + '/test-external-parent', + Effect.gen(function* () { + yield* Effect.sleep('10 millis').pipe( + Effect.withSpan('continued-span', { + parent: Tracer.externalSpan({ + traceId: 'fedcba0987654321fedcba0987654321', + spanId: '0987654321fedcba', + sampled: true, + }), + }), + ); + return yield* HttpServerResponse.json({ status: 'ok' }); + }).pipe(Effect.provide(Sentry.SentryEffectExternalSpanLayer)), + ), + HttpRouter.add( 'GET', '/test-error', diff --git a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts index 2a468928c798..395f5263f286 100644 --- a/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts +++ b/dev-packages/e2e-tests/test-applications/effect-4-node/tests/spans.test.ts @@ -65,3 +65,41 @@ test('Sends Effect spans with correct parent-child structure', async ({ baseURL expect(child.trace_id).toBe(segment.trace_id); } }); + +test('Sends a root: true span as its own segment in a new trace', async ({ baseURL }) => { + const requestSpansPromise = collectStreamedSpans( + 'effect-4-node', + spans => + spans.some( + span => + span.is_segment && + getSpanOp(span) === 'http.server' && + span.attributes['url.path']?.value === '/test-root-span', + ) && spans.some(span => span.name === 'root-span-request-marker'), + ); + const detachedSpanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'detached-root-span'); + + await fetch(`${baseURL}/test-root-span`); + + const [requestSpans, detachedSpan] = await Promise.all([requestSpansPromise, detachedSpanPromise]); + const segment = requestSpans.find(span => span.is_segment && getSpanOp(span) === 'http.server')!; + expect(segment.name).toBe('http.server GET'); + expect(requestSpans.filter(span => !span.is_segment).map(span => span.name)).toEqual(['root-span-request-marker']); + + expect(detachedSpan.is_segment).toBe(true); + expect(detachedSpan.parent_span_id).toBeUndefined(); + expect(detachedSpan.trace_id).not.toBe(segment.trace_id); +}); + +test('Continues the trace of a Tracer.externalSpan parent with the external span layer', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan('effect-4-node', span => span.name === 'continued-span'); + + await fetch(`${baseURL}/test-external-parent`); + + const span = await spanPromise; + expect(span).toMatchObject({ + is_segment: true, + trace_id: 'fedcba0987654321fedcba0987654321', + parent_span_id: '0987654321fedcba', + }); +}); diff --git a/packages/effect/src/client/tracer.ts b/packages/effect/src/client/tracer.ts index e824286ddf60..e4313b7fa927 100644 --- a/packages/effect/src/client/tracer.ts +++ b/packages/effect/src/client/tracer.ts @@ -8,4 +8,4 @@ import { makeSentryTracer } from '../tracer'; * keeps the browser variant substitutable (mocks, bundler interop) exactly as it was when the tracer * called it directly. */ -export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options)); +export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), false); diff --git a/packages/effect/src/index.client.ts b/packages/effect/src/index.client.ts index 76aba91e5d04..14ad2597abdf 100644 --- a/packages/effect/src/index.client.ts +++ b/packages/effect/src/index.client.ts @@ -7,5 +7,6 @@ export { effectLayer, init } from './client/index'; export type { EffectClientLayerOptions } from './client/index'; export { SentryEffectTracer } from './client/tracer'; +export { SentryEffectExternalSpanLayer } from './tracer'; export { SentryEffectLogger } from './logger'; export { SentryEffectMetricsLayer } from './metrics'; diff --git a/packages/effect/src/index.server.ts b/packages/effect/src/index.server.ts index 01d9272ce6a9..6ceec3346390 100644 --- a/packages/effect/src/index.server.ts +++ b/packages/effect/src/index.server.ts @@ -4,5 +4,6 @@ export { effectLayer, init } from './server/index'; export type { EffectServerLayerOptions } from './server/index'; export { SentryEffectTracer } from './server/tracer'; +export { SentryEffectExternalSpanLayer } from './tracer'; export { SentryEffectLogger } from './logger'; export { SentryEffectMetricsLayer } from './metrics'; diff --git a/packages/effect/src/server/tracer.ts b/packages/effect/src/server/tracer.ts index d4b622c2ba5c..0814db123343 100644 --- a/packages/effect/src/server/tracer.ts +++ b/packages/effect/src/server/tracer.ts @@ -8,6 +8,10 @@ import { makeSentryTracer } from '../tracer'; * every span start with a `getClient()` lookup to lazily install `spanStreamingIntegration`, which on * the server is pure overhead — `ServerRuntimeClient` already installs it eagerly. * + * Every parentless Effect span starts a new trace. A server process forks no propagation context on + * its own, so without this every request, reactor and background job of the process would land in + * one trace. + * * See `./client/tracer.ts` for why the call is wrapped in an arrow. */ -export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options)); +export const SentryEffectTracer = makeSentryTracer(options => startInactiveSpan(options), true); diff --git a/packages/effect/src/tracer.ts b/packages/effect/src/tracer.ts index db33a96cc97d..2e72e3c61fc0 100644 --- a/packages/effect/src/tracer.ts +++ b/packages/effect/src/tracer.ts @@ -1,9 +1,23 @@ +/* oxlint-disable max-lines */ import { SENTRY_OP } from '@sentry/conventions/attributes'; import { HTTP_CLIENT, HTTP_SERVER } from '@sentry/conventions/op'; import type { Span, StartSpanOptions } from '@sentry/core'; -import { isObjectLike, getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, withActiveSpan } from '@sentry/core'; -import type * as Context from 'effect/Context'; +import { + _INTERNAL_safeMathRandom, + addNonEnumerableProperty, + getActiveSpan, + getCurrentScope, + getDefaultCurrentScope, + isObjectLike, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + startNewTrace, + withActiveSpan, + withScope, +} from '@sentry/core'; +import * as Context from 'effect/Context'; import * as Exit from 'effect/Exit'; +import type * as EffectLayer from 'effect/Layer'; +import { succeed as succeedLayer } from 'effect/Layer'; import * as Option from 'effect/Option'; import * as EffectTracer from 'effect/Tracer'; @@ -15,6 +29,20 @@ function deriveOrigin(name: string): string | undefined { return undefined; } +const EFFECT_SPAN_SYMBOL = Symbol.for('@sentry/effect.EffectSpan'); + +function markEffectSpan(span: Span): void { + addNonEnumerableProperty(span, EFFECT_SPAN_SYMBOL, true); +} + +/** + * Whether this tracer created the span. A brand rather than an attribute check, because an unsampled + * span keeps no attributes. + */ +function isEffectSpan(span: Span): boolean { + return (span as { [EFFECT_SPAN_SYMBOL]?: boolean })[EFFECT_SPAN_SYMBOL] === true; +} + /** * Effect span names are chosen by whoever calls `Effect.withSpan`, so the name is the only signal * available. `@effect/platform` names its HTTP spans `http.server`/`http.client`, which map onto the @@ -173,42 +201,12 @@ class SentrySpanWrapper implements SentrySpanLike { } /** - * The client and the server entry differ only in which `startInactiveSpan` they hand to - * {@link makeSentryTracer}: the browser one from `@sentry/core`, which installs the span - * streaming integration on first use, and the plain one from `@sentry/core`, which does not. Nothing - * else about the tracer is platform-specific. + * The client and the server entry hand different `startInactiveSpan` functions to + * {@link makeSentryTracer}: the browser one from `@sentry/core/browser`, which installs the span + * streaming integration on first use, and the plain one from `@sentry/core`, which does not. */ export type StartInactiveSpan = (options: StartSpanOptions) => Span; -function createSentrySpan( - startInactiveSpan: StartInactiveSpan, - name: string, - parent: Option.Option, - context: Context.Context, - links: ReadonlyArray, - startTime: bigint, - kind: EffectTracer.SpanKind, -): SentrySpanLike { - const parentSentrySpan = - Option.isSome(parent) && isSentrySpan(parent.value) ? parent.value.sentrySpan : (getActiveSpan() ?? null); - - const op = deriveOp(name); - const origin = deriveOrigin(name); - - const newSpan = startInactiveSpan({ - name, - startTime: nanosToHrTime(startTime), - // Setting these to `undefined` would strip the core defaults instead of leaving them in place. - attributes: { - ...(op && { [SENTRY_OP]: op }), - ...(origin && { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }), - }, - ...(parentSentrySpan ? { parentSpan: parentSentrySpan } : {}), - }); - - return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); -} - // Check if we're running Effect v4 by checking the Exit/Cause structure // In v4, causes have a 'reasons' array // In v3, causes have '_tag' directly on the cause object @@ -226,7 +224,187 @@ const isEffectV4 = (() => { } })(); -const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +const EXTERNAL_SPAN_KEY = '@sentry/effect/ExternalSpan'; + +interface ContextExports { + Reference: typeof Context.Reference; + /** Only Effect v3 exports it. */ + GenericTag(key: string): Context.Key; +} + +// Effect v4 has no `Context.GenericTag` and Effect v3 before 3.11 has no `Context.Reference`. Both are read +// through a separate binding because bundlers fail the build on a namespace member the installed version lacks. +const contextExports = Context as unknown as ContextExports; + +/** + * Whether the tracer continues the trace of a `Tracer.externalSpan` parent. Effect v4 has no `FiberRef` + * and v3 before 3.11 has no `Context.Reference`, so the flag is a plain service in both. The cast is safe: + * the tracer reads it with `getRef` only on a v4 fiber, where it is a reference, and with + * `Context.getOption` on v3. + */ +const ExternalSpanFlag = ( + isEffectV4 + ? contextExports.Reference(EXTERNAL_SPAN_KEY, { defaultValue: () => false }) + : contextExports.GenericTag(EXTERNAL_SPAN_KEY) +) as Context.Reference; + +/** + * Makes the tracer continue the trace of a `Tracer.externalSpan` parent instead of ignoring it. Provide it + * next to the tracer layer to continue every external span in the runtime, or provide it to a single + * effect with `Effect.provide` to continue only that one. + */ +export const SentryEffectExternalSpanLayer: EffectLayer.Layer = succeedLayer(ExternalSpanFlag, true); + +interface FiberLike { + /** The span the fiber runs in. Only Effect v3 fibers have it. */ + readonly currentSpan?: EffectTracer.AnySpan | undefined; + /** Holds `span`, the span the fiber runs in. Only Effect v4 fibers have it. */ + readonly cache?: { readonly span?: EffectTracer.AnySpan | undefined }; + /** Reads a reference with its default. Only Effect v4 fibers have it. */ + readonly getRef?: (ref: Context.Reference) => A; + /** The services of the fiber. Only Effect v3 fibers have it. */ + readonly currentContext?: Context.Context; +} + +/** + * The fiber whose operation is being evaluated. Effect hands the fiber to the `context` hook but not to + * `span`, which runs synchronously inside it, so the hook keeps the fiber here for that extent. + */ +let currentFiber: FiberLike | undefined; + +function continuesExternalSpans(fiber: FiberLike): boolean { + if (fiber.getRef) { + return fiber.getRef(ExternalSpanFlag); + } + + return ( + fiber.currentContext !== undefined && + Option.getOrElse(Context.getOption(fiber.currentContext, ExternalSpanFlag), () => false) + ); +} + +function getFiberSpan(fiber: FiberLike): EffectTracer.AnySpan | undefined { + return isEffectV4 ? fiber.cache?.span : fiber.currentSpan; +} + +function withFiberContext(fiber: FiberLike, execution: () => X): X { + const previousFiber = currentFiber; + currentFiber = fiber; + try { + const currentSpan = getFiberSpan(fiber); + if (currentSpan === undefined || !isSentrySpan(currentSpan)) { + return execution(); + } + return withActiveSpan(currentSpan.sentrySpan, execution); + } finally { + currentFiber = previousFiber; + } +} + +/** + * Starts the Sentry span for an Effect span, rooted or parented the way Effect asked for. + * + * - A parent this tracer created becomes the Sentry parent. + * - Any other parent (`Tracer.externalSpan` bridging a trace this SDK did not start, such as an + * OpenTelemetry span of another app in the same process or persisted trace state) is ignored unless + * {@link SentryEffectExternalSpanLayer} is provided, so a span joins a foreign trace only when the user + * asked for it. The span then nests where Effect would have put it without the `parent` option: under + * the fiber's current span, or parentless. With the layer, the span continues that trace: it is a root + * span whose `parent_span_id` is the external span. No dynamic sampling context is frozen, so the SDK + * builds one from the client the way it does for a head-of-trace span. + * - Without a parent, the span nests under a foreign active Sentry span (an `http.server` span from the + * Node SDK, a pageload in the browser) but never under a span this tracer created: Effect's own parent + * tracking is authoritative for those, so an active one is an enclosing `root: true` span or a span + * leaked from another fiber through the async context. Effect reports `root: true` for every + * parentless span, so the flag adds nothing and is not consulted. + * - A root span starts a new trace when `newTraceForRootSpans` is set, unless the user set up the + * current scope (`Sentry.continueTrace`, `Sentry.withScope`, an HTTP request's isolation scope). + */ +function startSentrySpan( + startInactiveSpan: StartInactiveSpan, + options: StartSpanOptions, + parent: Option.Option, + newTraceForRootSpans: boolean, +): Span { + if (Option.isSome(parent)) { + const parentSpan = parent.value; + + if (isSentrySpan(parentSpan)) { + return startInactiveSpan({ ...options, parentSpan: parentSpan.sentrySpan }); + } + + if (currentFiber !== undefined && continuesExternalSpans(currentFiber)) { + return withScope(scope => { + scope.setPropagationContext({ + traceId: parentSpan.traceId, + parentSpanId: parentSpan.spanId, + sampled: parentSpan.sampled, + sampleRand: _INTERNAL_safeMathRandom(), + }); + return withActiveSpan(null, () => startInactiveSpan(options)); + }); + } + + const enclosingSpan = currentFiber && getFiberSpan(currentFiber); + if (enclosingSpan !== undefined && isSentrySpan(enclosingSpan)) { + return startInactiveSpan({ ...options, parentSpan: enclosingSpan.sentrySpan }); + } + } + + const activeSpan = getActiveSpan(); + if (activeSpan && !isEffectSpan(activeSpan)) { + return startInactiveSpan({ ...options, parentSpan: activeSpan }); + } + + // A scope the user forked (`continueTrace`, `withScope`, an isolation scope) carries its own trace id. + // The scopes this tracer forks in `context()` clone the propagation context they were forked from, so + // even when one leaks into another fiber through the async context it still carries the process-wide + // trace id of the default scope. + const isProcessTrace = + getCurrentScope().getPropagationContext().traceId === getDefaultCurrentScope().getPropagationContext().traceId; + if (newTraceForRootSpans && isProcessTrace) { + return startNewTrace(() => startInactiveSpan(options)); + } + + return withActiveSpan(null, () => startInactiveSpan(options)); +} + +function createSentrySpan( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, + name: string, + parent: Option.Option, + context: Context.Context, + links: ReadonlyArray, + startTime: bigint, + kind: EffectTracer.SpanKind, +): SentrySpanLike { + const op = deriveOp(name); + const origin = deriveOrigin(name); + + const newSpan = startSentrySpan( + startInactiveSpan, + { + name, + startTime: nanosToHrTime(startTime), + // Setting these to `undefined` would strip the core defaults instead of leaving them in place. + attributes: { + ...(op && { [SENTRY_OP]: op }), + ...(origin && { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin }), + }, + }, + parent, + newTraceForRootSpans, + ); + markEffectSpan(newSpan); + + return new SentrySpanWrapper(name, parent, context, links, startTime, kind, newSpan); +} + +const makeSentryTracerV3 = ( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer => { // Effect v3 API: span(name, parent, context, links, startTime, kind) return EffectTracer.make({ span( @@ -237,25 +415,25 @@ const makeSentryTracerV3 = (startInactiveSpan: StartInactiveSpan): EffectTracer. startTime: bigint, kind: EffectTracer.SpanKind, ) { - return createSentrySpan(startInactiveSpan, name, parent, context, links, startTime, kind); + return createSentrySpan(startInactiveSpan, newTraceForRootSpans, name, parent, context, links, startTime, kind); }, - context(execution: () => unknown, fiber: { currentSpan?: EffectTracer.AnySpan }) { - const currentSpan = fiber.currentSpan; - if (currentSpan === undefined || !isSentrySpan(currentSpan)) { - return execution(); - } - return withActiveSpan(currentSpan.sentrySpan, execution); + context(execution: () => unknown, fiber: FiberLike) { + return withFiberContext(fiber, execution); }, } as unknown as EffectTracer.Tracer); }; -const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer => { +const makeSentryTracerV4 = ( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer => { const EFFECT_EVALUATE = '~effect/Effect/evaluate' as const; return EffectTracer.make({ span(options) { return createSentrySpan( startInactiveSpan, + newTraceForRootSpans, options.name, options.parent, options.annotations, @@ -265,11 +443,7 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. ); }, context(primitive, fiber) { - const currentSpan = fiber.cache.span; - if (currentSpan === undefined || !isSentrySpan(currentSpan)) { - return primitive[EFFECT_EVALUATE](fiber); - } - return withActiveSpan(currentSpan.sentrySpan, () => primitive[EFFECT_EVALUATE](fiber)); + return withFiberContext(fiber, () => primitive[EFFECT_EVALUATE](fiber)); }, }); }; @@ -279,7 +453,17 @@ const makeSentryTracerV4 = (startInactiveSpan: StartInactiveSpan): EffectTracer. * * Use the `SentryEffectTracer` exported from `@sentry/effect` rather than calling this directly — the * client and server entries each bind the right `startInactiveSpan` for their platform. + * + * `newTraceForRootSpans` is the one behavioural difference between the platforms: Effect gives every + * parentless span a fresh trace id, and on a long-lived server nothing else forks the propagation + * context, so the server tracer follows Effect and starts a new trace. In the browser the page trace is + * the intended home of every span, so the client tracer keeps parentless spans in it. */ -export function makeSentryTracer(startInactiveSpan: StartInactiveSpan): EffectTracer.Tracer { - return isEffectV4 ? makeSentryTracerV4(startInactiveSpan) : makeSentryTracerV3(startInactiveSpan); +export function makeSentryTracer( + startInactiveSpan: StartInactiveSpan, + newTraceForRootSpans: boolean, +): EffectTracer.Tracer { + return isEffectV4 + ? makeSentryTracerV4(startInactiveSpan, newTraceForRootSpans) + : makeSentryTracerV3(startInactiveSpan, newTraceForRootSpans); } diff --git a/packages/effect/test/tracer.test.ts b/packages/effect/test/tracer.test.ts index 90429b3a8bcd..a7aace022282 100644 --- a/packages/effect/test/tracer.test.ts +++ b/packages/effect/test/tracer.test.ts @@ -2,10 +2,13 @@ import { describe, expect, it } from '@effect/vitest'; import * as sentryCore from '@sentry/core'; import * as sentryCoreBrowser from '@sentry/core/browser'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { ServerRuntimeClient } from '@sentry/core/server'; import { Effect } from 'effect'; -import { afterEach, vi } from 'vitest'; +import * as Tracer from 'effect/Tracer'; +import { afterEach, beforeEach, vi } from 'vitest'; import { SentryEffectTracer as clientTracer } from '../src/client/tracer'; import { SentryEffectTracer as serverTracer } from '../src/server/tracer'; +import { SentryEffectExternalSpanLayer } from '../src/tracer'; // The two variants differ only in which module they start spans through, so spying on `spanApi` also // asserts that wiring: the client tracer must go through `@sentry/core/browser` (which installs @@ -31,7 +34,7 @@ function mockSpan(overrides: Record = {}): sentryCore.Span { } as unknown as sentryCore.Span; } -describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ tracer, spanApi }) => { +describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ variant, tracer, spanApi }) => { const withSentryTracer = (effect: Effect.Effect) => Effect.withTracer(effect, tracer); afterEach(() => { @@ -229,4 +232,158 @@ describe.each(VARIANTS)('SentryEffectTracer ($variant)', ({ tracer, spanApi }) = expect(result).toBe('with-tracer'); }).pipe(Effect.withTracer(tracer)), ); + + describe('trace structure', () => { + const traceId = 'a'.repeat(32); + const spanId = 'b'.repeat(16); + + beforeEach(() => { + const client = new ServerRuntimeClient({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [], + transport: () => sentryCore.createTransport({ recordDroppedEvent: () => undefined }, () => Promise.resolve({})), + stackParser: () => [], + tracesSampleRate: 1, + traceLifecycle: 'static', + }); + sentryCore.getCurrentScope().setClient(client); + client.init(); + }); + + afterEach(() => { + sentryCore.getCurrentScope().setClient(undefined); + }); + + const currentSentrySpan = Effect.map( + Effect.currentSpan, + span => (span as unknown as { sentrySpan: sentryCore.Span }).sentrySpan, + ); + + const run = (effect: Effect.Effect): A => Effect.runSync(Effect.withTracer(effect, tracer)); + + it('treats an external parent as no parent without the external span layer', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const span = run(Effect.withSpan('reactor', { parent })(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).trace_id).not.toBe(traceId); + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + + it('nests a span with an ignored external parent under the enclosing Effect span', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const [outer, inner] = run( + Effect.withSpan('outer')( + Effect.all([currentSentrySpan, Effect.withSpan('inner', { parent })(currentSentrySpan)]), + ), + ); + + expect(sentryCore.spanToJSON(inner)).toMatchObject({ + trace_id: sentryCore.spanToJSON(outer).trace_id, + parent_span_id: outer.spanContext().spanId, + }); + }); + + it('continues the trace of an external parent as a new root span with the external span layer', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: true }); + const span = run( + Effect.withSpan('reactor', { parent })(currentSentrySpan).pipe(Effect.provide(SentryEffectExternalSpanLayer)), + ); + + expect(sentryCore.spanToJSON(span)).toMatchObject({ trace_id: traceId, parent_span_id: spanId }); + expect(sentryCore.spanIsSampled(span)).toBe(true); + expect(sentryCore.getDynamicSamplingContextFromSpan(span)).toMatchObject({ + trace_id: traceId, + public_key: 'public', + sampled: 'true', + }); + }); + + it('honors the sampling decision of an external parent', () => { + const parent = Tracer.externalSpan({ traceId, spanId, sampled: false }); + const span = run( + Effect.withSpan('reactor', { parent })(currentSentrySpan).pipe(Effect.provide(SentryEffectExternalSpanLayer)), + ); + + expect(sentryCore.spanToJSON(span).trace_id).toBe(traceId); + expect(sentryCore.spanIsSampled(span)).toBe(false); + }); + + it('does not nest a root: true span under the enclosing Effect span', () => { + const [outer, inner] = run( + Effect.withSpan('outer')( + Effect.all([currentSentrySpan, Effect.withSpan('inner', { root: true })(currentSentrySpan)]), + ), + ); + + expect(sentryCore.spanToJSON(inner).parent_span_id).toBeUndefined(); + if (variant === 'server') { + expect(sentryCore.spanToJSON(inner).trace_id).not.toBe(sentryCore.spanToJSON(outer).trace_id); + } else { + expect(sentryCore.spanToJSON(inner).trace_id).toBe(sentryCore.spanToJSON(outer).trace_id); + } + }); + + it('nests a parentless Effect span under a foreign active Sentry span', () => { + sentryCore.startSpan({ name: 'http.server' }, request => { + const span = run(Effect.withSpan('handler')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).parent_span_id).toBe(request.spanContext().spanId); + expect(sentryCore.spanToJSON(span).trace_id).toBe(request.spanContext().traceId); + }); + }); + + it('does not parent a parentless Effect span on a span leaked from another fiber', () => { + const leaked = run(Effect.withSpan('other-fiber')(currentSentrySpan)); + + sentryCore.withActiveSpan(leaked, () => { + const span = run(Effect.withSpan('root')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + }); + + it('does not parent a parentless Effect span on an unsampled span leaked from another fiber', () => { + const leaked = sentryCore.withScope(scope => { + scope.setPropagationContext({ traceId, sampled: false, sampleRand: 0.5 }); + return run(Effect.withSpan('unsampled-fiber')(currentSentrySpan)); + }); + expect(sentryCore.spanIsSampled(leaked)).toBe(false); + + sentryCore.withActiveSpan(leaked, () => { + const span = run(Effect.withSpan('root')(currentSentrySpan)); + + expect(sentryCore.spanIsSampled(span)).toBe(true); + expect(sentryCore.spanToJSON(span).parent_span_id).toBeUndefined(); + }); + }); + + it('keeps a parentless Effect span in a trace the user continued', () => { + sentryCore.continueTrace({ sentryTrace: `${traceId}-${spanId}-1`, baggage: undefined }, () => { + const span = run(Effect.withSpan('handler')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(span)).toMatchObject({ trace_id: traceId, parent_span_id: spanId }); + }); + }); + + if (variant === 'server') { + it('starts a new trace for every parentless Effect span', () => { + const processTraceId = sentryCore.getCurrentScope().getPropagationContext().traceId; + const first = run(Effect.withSpan('first')(currentSentrySpan)); + const second = run(Effect.withSpan('second')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(first).trace_id).not.toBe(processTraceId); + expect(sentryCore.spanToJSON(second).trace_id).not.toBe(processTraceId); + expect(sentryCore.spanToJSON(first).trace_id).not.toBe(sentryCore.spanToJSON(second).trace_id); + }); + } else { + it('keeps parentless Effect spans in the page trace', () => { + const pageTraceId = sentryCore.getCurrentScope().getPropagationContext().traceId; + const first = run(Effect.withSpan('first')(currentSentrySpan)); + const second = run(Effect.withSpan('second')(currentSentrySpan)); + + expect(sentryCore.spanToJSON(first).trace_id).toBe(pageTraceId); + expect(sentryCore.spanToJSON(second).trace_id).toBe(pageTraceId); + }); + } + }); }); From b75aa9ed021be489ada3c500d8171561b2c3d90d Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 1 Oct 2026 16:46:55 +0100 Subject: [PATCH 26/57] fix(profiling-node): Send profile chunks with `client.sendEnvelope` (#24896) The continuous profiler sent `profile_chunk` envelopes straight to the transport, so the `beforeEnvelope` hook never ran for them. It now uses `client.sendEnvelope()`, as the browser `UIProfiler` does. - Ref. https://github.com/getsentry/sentry-electron/pull/1445 --- packages/profiling-node/src/integration.ts | 8 +----- .../profiling-node/test/integration.test.ts | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 0766de3ba065..a158019ee036 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -275,18 +275,12 @@ class ContinuousProfiler { return; } - const transport = this._client.getTransport(); - if (!transport) { - DEBUG_BUILD && debug.log('[Profiling] No transport available to send profile chunk.'); - return; - } - const dsn = this._client.getDsn(); const metadata = this._client.getSdkMetadata(); const tunnel = this._client.getOptions().tunnel; const envelope = makeProfileChunkEnvelope('node', chunk, metadata?.sdk, tunnel, dsn); - transport.send(envelope).then(null, reason => { + this._client.sendEnvelope(envelope).then(null, reason => { DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); }); } diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 80a47ec05212..b7c24525419d 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -145,6 +145,32 @@ describe('ProfilingIntegration', () => { }), }); }); + + it('emits the `beforeEnvelope` hook for profile chunks', async () => { + const [client, transport] = makeSpanProfilingClient({ + profileLifecycle: 'manual', + profileSessionSampleRate: 1, + }); + + Sentry.setCurrentClient(client); + client.init(); + + vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + + const beforeEnvelope = vi.fn(); + client.on('beforeEnvelope', beforeEnvelope); + + Sentry.profiler.startProfiler(); + await wait(1000); + Sentry.profiler.stopProfiler(); + + await Sentry.flush(1000); + + expect(beforeEnvelope).toHaveBeenCalledTimes(1); + expect(beforeEnvelope.mock.calls[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ + type: 'profile_chunk', + }); + }); }); }); From dd93cce82c7aa14c58b9aeeac0b605f74ab7baf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Thu, 1 Oct 2026 20:14:50 +0300 Subject: [PATCH 27/57] license: Add cli FSL notice (#24956) --- LICENSE | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/LICENSE b/LICENSE index 84d8d8c065fc..32c62f9869e3 100644 --- a/LICENSE +++ b/LICENSE @@ -19,3 +19,13 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Third-Party Dependency Notice + +The MIT License above applies to the code in this repository. Some packages +published from this repository have the Sentry CLI (the `sentry` package on npm, +https://github.com/getsentry/cli) as a direct or transitive dependency. The +Sentry CLI is not part of this repository and is not covered by the MIT License +above. It is licensed under the Functional Source License, Version 1.1, Apache +2.0 Future License (FSL-1.1-Apache-2.0): +https://github.com/getsentry/cli/blob/main/LICENSE.md From 90db3194a6fbe0c918c88e062480b2e64083d79b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:46:44 +0000 Subject: [PATCH 28/57] test(e2e): Avoid rate limits when polling Sentry in send-to-sentry tests (#24957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The send-to-sentry E2E tests often fail with `429 Too Many Requests` ("Limit is 40 requests in 1 seconds"). Sentry rate limits GET requests per token owner, and every endpoint used here shares the default bucket. So all E2E jobs across all PRs share one 40 req/s budget. Most of the load came from `sentry trace view --json`. The `--json` flag makes the CLI fetch details for every span, one request each (15 at a time), on top of a project lookup and the trace request. Every 5s poll turned into dozens of requests. `fetchTrace` now calls the trace endpoint directly through `sentry api`, which is one request per poll. The tests only read fields that the raw trace response already includes (`event_id`, `parent_span_id`, `event_type`, `op`, `description`, `children`, `errors`, `occurrences`). All the polling helpers now also treat a 429 as "not there yet" and keep polling until the existing timeout, instead of failing on the first rate-limited response. via **Jan Peer Stöcklmair**. -- [View Junior Session](https://junior-prod.sentry.dev/conversations/slack%3ACUHS29QJ0%3A1790866806.848059) [[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3ACUHS29QJ0%3A1790866806.848059/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Jan Peer Stöcklmair --- dev-packages/test-utils/src/cli.ts | 54 +++++++++++++++++++----------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/dev-packages/test-utils/src/cli.ts b/dev-packages/test-utils/src/cli.ts index 7093ce4ce7a1..7e0d92dded70 100644 --- a/dev-packages/test-utils/src/cli.ts +++ b/dev-packages/test-utils/src/cli.ts @@ -8,7 +8,7 @@ import type { SpawnSyncReturns } from 'node:child_process'; 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 + * A node of the span tree returned by the trace endpoint. Spans, errors and occurrences all * share this shape and are discriminated by `event_type`. */ export interface TraceItem { @@ -59,37 +59,49 @@ function runSentryCli(args: string[]): SpawnSyncReturns { return result; } +/** + * The API rate limit is shared by every GET request of the E2E token's user, so concurrent jobs can + * exhaust it. The callers poll anyway, so a rate limited request is treated like data that has not + * landed yet instead of failing the test. + */ +function isRateLimited(result: SpawnSyncReturns): boolean { + return /\b429\b|too frequently/i.test(`${result.stdout}${result.stderr}`); +} + /** * 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. + * + * This calls the trace endpoint directly instead of `sentry trace view --json`, which also looks up + * the project and fetches the details of every span, one request each. That turns every poll into + * dozens of requests and gets the E2E token rate limited. */ export function fetchTrace(traceId: string): TraceItem[] { - const target = traceTarget(traceId); - const result = runSentryCli(['trace', 'view', target, '--json', '--fresh']); + const params = new URLSearchParams({ project: '-1', statsPeriod: '1h', limit: '10000' }); + const path = `/organizations/${process.env['E2E_TEST_SENTRY_ORG_SLUG']}/trace/${traceId}/?${params}`; + const result = runSentryCli(['api', path]); if (result.status === 0) { - return (JSON.parse(result.stdout) as { spans?: TraceItem[] }).spans ?? []; + return JSON.parse(result.stdout) as TraceItem[]; + } + + if (isRateLimited(result)) { + return []; } - // 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. + // Exit codes 10-19 are auth errors, and a rejected token also surfaces as an API error with a 401 + // or 403 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)) { + if (isAuthError || /\b40[13]\b/.test(`${result.stdout}${result.stderr}`)) { throw new Error( - `sentry trace view ${target} failed with exit code ${result.status}: ${result.stderr}` + + `sentry api ${path} failed with exit code ${result.status}: ${result.stdout}${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}`); + throw new Error(`sentry api ${path} exited with ${result.status}: ${result.stdout}${result.stderr}`); } /** @@ -110,7 +122,7 @@ export function fetchSpanAttributes(traceId: string, spanId: string): Record [name, value])); } - if (result.stdout.includes('"Not found."')) { + if (result.stdout.includes('"Not found."') || isRateLimited(result)) { return undefined; } @@ -136,7 +148,7 @@ export function fetchEvent(eventId: string): ApiEvent | undefined { return JSON.parse(result.stdout) as ApiEvent; } - if (result.stdout.includes('"Event not found"')) { + if (result.stdout.includes('"Event not found"') || isRateLimited(result)) { return undefined; } @@ -165,6 +177,10 @@ export function findTraceIdOfSpan(query: string): string | undefined { return (JSON.parse(result.stdout) as { data: { trace: string }[] }).data[0]?.trace; } + if (isRateLimited(result)) { + return undefined; + } + throw new Error(`sentry api ${path} exited with ${result.status}: ${result.stdout}${result.stderr}`); } From c1e182cd416008f24caeb4dd55b6793171041d6f Mon Sep 17 00:00:00 2001 From: Shubham Padkonde Date: Thu, 1 Oct 2026 23:47:47 +0530 Subject: [PATCH 29/57] fix(hono): Return the existing client on repeated init in Bun and Deno (#24520) On Bun and Deno, `init()` warned when Sentry was already initialized but then initialized again, replacing the client. Anything buffered on the first client was dropped and its integrations stayed installed against it. Both now keep the warning and return the existing client, as the Node entry already does. Node's behavior is unchanged (it only logs in debug mode, since it is initialized from an instrument file); Cloudflare initializes per request through `withSentry` and is not affected. Closes #24049 Co-authored-by: Claude Opus 5 --- packages/hono/src/bun/sdk.ts | 5 ++++- packages/hono/src/deno/sdk.ts | 5 ++++- packages/hono/test/bun/middleware.test.ts | 12 ++++++++++-- packages/hono/test/deno/middleware.test.ts | 12 ++++++++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/hono/src/bun/sdk.ts b/packages/hono/src/bun/sdk.ts index c62dae003f27..26f9c987348b 100644 --- a/packages/hono/src/bun/sdk.ts +++ b/packages/hono/src/bun/sdk.ts @@ -13,13 +13,16 @@ import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactio * When manually calling `init`, add the `honoIntegration` to the `integrations` array to set up the Hono integration. */ export function init(options: HonoBunOptions): Client | undefined { - if (getClient()) { + const existingClient = getClient(); + if (existingClient) { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( '[Sentry] Sentry is already initialized. Sentry should only be initialized once, through the `sentry()` middleware. Remove the `Sentry.init()` call, if one exists.', ); }); + // Re-initializing would replace the client and drop anything buffered on it + return existingClient; } applySdkMetadata(options, 'hono', ['hono', 'bun']); diff --git a/packages/hono/src/deno/sdk.ts b/packages/hono/src/deno/sdk.ts index 43083981a3c3..1c40bdb3bdbf 100644 --- a/packages/hono/src/deno/sdk.ts +++ b/packages/hono/src/deno/sdk.ts @@ -13,13 +13,16 @@ import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactio * When manually calling `init`, add the `honoIntegration` to the `integrations` array to set up the Hono integration. */ export function init(options: HonoDenoOptions): Client | undefined { - if (getClient()) { + const existingClient = getClient(); + if (existingClient) { consoleSandbox(() => { // eslint-disable-next-line no-console console.warn( '[Sentry] Sentry is already initialized. Sentry should only be initialized once, through the `sentry()` middleware. Remove the `Sentry.init()` call, if one exists.', ); }); + // Re-initializing would replace the client and drop anything buffered on it + return existingClient; } applySdkMetadata(options, 'hono', ['hono', 'deno']); diff --git a/packages/hono/test/bun/middleware.test.ts b/packages/hono/test/bun/middleware.test.ts index 50f70a77883e..f2ef49fa19a8 100644 --- a/packages/hono/test/bun/middleware.test.ts +++ b/packages/hono/test/bun/middleware.test.ts @@ -171,14 +171,22 @@ describe('Hono Bun Middleware', () => { }); describe('double-init guard', () => { - it('still calls init even when Sentry is already initialized', () => { + it('does not re-initialize when Sentry is already initialized', () => { const fakeClient = { getOptions: () => ({}) }; getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); const app = new Hono(); sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); - expect(initBunMock).toHaveBeenCalledTimes(1); + expect(initBunMock).not.toHaveBeenCalled(); + }); + + it('returns the existing client when Sentry is already initialized', () => { + const fakeClient = { getOptions: () => ({}) }; + getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); + + expect(init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' })).toBe(fakeClient); + expect(initBunMock).not.toHaveBeenCalled(); }); it('emits a console.warn directing to remove the duplicate init call when Sentry is already initialized', () => { diff --git a/packages/hono/test/deno/middleware.test.ts b/packages/hono/test/deno/middleware.test.ts index a6caa2d44455..84506ab494fa 100644 --- a/packages/hono/test/deno/middleware.test.ts +++ b/packages/hono/test/deno/middleware.test.ts @@ -171,14 +171,22 @@ describe('Hono Deno Middleware', () => { }); describe('double-init guard', () => { - it('still calls init even when Sentry is already initialized', () => { + it('does not re-initialize when Sentry is already initialized', () => { const fakeClient = { getOptions: () => ({}) }; getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); const app = new Hono(); sentry(app, { dsn: 'https://public@dsn.ingest.sentry.io/1337' }); - expect(initDenoMock).toHaveBeenCalledTimes(1); + expect(initDenoMock).not.toHaveBeenCalled(); + }); + + it('returns the existing client when Sentry is already initialized', () => { + const fakeClient = { getOptions: () => ({}) }; + getClientMock.mockReturnValue(fakeClient as unknown as SentryCore.Client); + + expect(init({ dsn: 'https://public@dsn.ingest.sentry.io/1337' })).toBe(fakeClient); + expect(initDenoMock).not.toHaveBeenCalled(); }); it('emits a console.warn directing to remove the duplicate init call when Sentry is already initialized', () => { From 1de4f8716a2f37869b659799380f0efa070b2ea8 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:51:03 +0200 Subject: [PATCH 30/57] chore: Add external contributor to CHANGELOG.md (#24959) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #24520 Co-authored-by: isaacs <9287+isaacs@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb763cf84b8..c079adf708a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @Shubham-Padkonde. Thank you for your contribution! + - **fix(cloudflare)**: Durable Object constructors now run in their own isolation scope. When work that the constructor starts, for example a `blockConcurrencyWhile` callback, calls a method of the instance, that call is no longer treated as an incoming RPC call, so an error the instance catches itself is no longer reported as unhandled. As a result, `Sentry.setTag()`, `setUser()` and `setContext()` in a constructor now apply only to that constructor work. They no longer reach later `fetch`, RPC or `alarm` invocations, because the scope they used to write to is shared by every Durable Object and handler in the isolate. Use `initialScope` for static data, and set per-instance data in each handler. ## 11.2.0 From e2e6e335cb3e66c0b2a5bf1a5422f4a19ff48b4a Mon Sep 17 00:00:00 2001 From: isaacs Date: Thu, 1 Oct 2026 20:57:21 -0700 Subject: [PATCH 31/57] fix(core, node, bun, deno): Align server span client address with event IP (#24767) Server spans in Node, Bun and Deno took `client.address` from the first `X-Forwarded-For` entry, with no check that the value is an IP. Events use `getClientIPAddress` for `user.ip_address`. That function checks the value and also reads other forwarding headers (`X-Real-IP`, `CF-Connecting-IP`, `Forwarded`, and more). So a span and the event for the same request could report different client IPs. Spans now use `getClientIPAddress` too. It is exported from `@sentry/core/server`. When the address comes from a forwarding header, the socket port belongs to the proxy, not to the client. `client.port` is now unset in that case. (It was previously the incorrect data, now it is blank, as it should be.) `network.peer.*` in Node still reports the socket values. Bun and Deno now pass the socket address to `sdkProcessingMetadata.ipAddress`, as Node does. Error events get `user.ip_address` when no forwarding header is present, and the `sentry.is_localhost` check works for direct loopback requests. The vendored `getClientIPAddress` (from `remix-utils`) accepts only bare addresses, and it stays unchanged. A wrapper in `packages/core/src/utils/clientIPAddress.ts` normalizes the IP header values first. It strips a port (`203.0.113.7:4711`, `[2001:db8::1]:4711`), which Azure App Service sends in `X-Forwarded-For`. In `Forwarded`, it removes quotes and matches `for=` in any case. Spans, events and the localhost check all use the wrapper, so they still agree. Behavior changes: - Behind a proxy, server spans no longer have `client.port`. - A request with both `X-Client-IP` and `X-Forwarded-For` now reports the `X-Client-IP` value as `client.address`, the same as `user.ip_address` on events. This addresses the follow-up comments raised in #24523. Co-authored-by: Claude Opus 5.5 (1M context) --- .../deno/tests/streamed/transactions.test.ts | 4 + .../suites/tracing/httpIntegration/test.ts | 21 +++ packages/bun/src/integrations/bunserver.ts | 43 +++--- .../bun/test/integrations/bunserver.test.ts | 134 ++++++++++++++++-- .../integrations/http/server-subscription.ts | 18 +-- packages/core/src/integrations/requestdata.ts | 3 +- packages/core/src/server.ts | 1 + packages/core/src/utils/clientIPAddress.ts | 47 ++++++ .../http/server-subscription.test.ts | 22 ++- .../test/lib/utils/clientIPAddress.test.ts | 39 +++++ .../deno/src/wrap-deno-request-handler.ts | 23 ++- packages/deno/test/deno-serve.test.ts | 111 ++++++++++++++- .../http/httpServerSpansIntegration.ts | 25 ++-- 13 files changed, 427 insertions(+), 64 deletions(-) create mode 100644 packages/core/src/utils/clientIPAddress.ts create mode 100644 packages/core/test/lib/utils/clientIPAddress.test.ts diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts index cb9bb624f6bd..83bf22f3d6dc 100644 --- a/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/deno/tests/streamed/transactions.test.ts @@ -146,6 +146,10 @@ const SEGMENT_SPAN = { type: 'string', value: 'http:', }, + 'user.ip_address': { + type: 'string', + value: expect.any(String), + }, 'user_agent.original': { type: 'string', value: 'node', diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index afd5cbe927a6..3cbe232bb061 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -172,6 +172,27 @@ describe('httpIntegration', () => { runner.makeRequest('post', '/test?a=1&b=2#hash', { data: 'test body' }); await runner.completed(); }); + + test('prefers the forwarded client address, without the socket port', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + const data = transaction.contexts?.trace?.data; + expect(data).toEqual( + expect.objectContaining({ + 'client.address': '203.0.113.7', + 'network.peer.address': '::1', + 'network.peer.port': expect.any(Number), + }), + ); + expect(data).not.toHaveProperty('client.port'); + }, + }) + .start(); + + runner.makeRequest('get', '/test', { headers: { 'X-Forwarded-For': '203.0.113.7, 10.0.0.1' } }); + await runner.completed(); + }); }); // Deno: the requests sometimes get a 500 response when `server.emit` is overwritten. diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 11977876d040..3cc5406fbc05 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -21,6 +21,7 @@ import { filterCollectedUrl, filterCollectedUrlQuery, } from '@sentry/core'; +import { getClientIPAddress } from '@sentry/core/server'; import type { Server, ServeOptions } from 'bun'; import { CLIENT_ADDRESS, @@ -245,30 +246,35 @@ function wrapRequestHandler( const client = getClient(); const dataCollection = client?.getDataCollectionOptions(); - if (dataCollection?.userInfo) { - // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. - const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); + let socketAddress: { address: string; port: number } | undefined; + if (dataCollection) { + const headers = request.headers.toJSON(); // Bun passes the `Server` as the second argument to both `fetch` and route handlers, except // when the handler runs through `server.fetch()`. - const socketAddress = getRequestIP(args[1], request); - if (forwardedFor || socketAddress?.address) { - attributes[CLIENT_ADDRESS] = forwardedFor || socketAddress?.address; - } - if (socketAddress?.port) { - attributes[CLIENT_PORT] = socketAddress.port; + socketAddress = getRequestIP(args[1], request); + + if (dataCollection.userInfo) { + // `client.address` is the originating client, so a forwarding header wins over the socket, which + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. + const forwardedAddress = getClientIPAddress(headers); + if (forwardedAddress) { + attributes[CLIENT_ADDRESS] = forwardedAddress; + } else if (socketAddress) { + attributes[CLIENT_ADDRESS] = socketAddress.address; + attributes[CLIENT_PORT] = socketAddress.port; + } } + + Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); } // describes the OSI application-layer protocol (http), not the scheme (might be https) attributes[NETWORK_PROTOCOL_NAME] = 'http'; - if (dataCollection) { - Object.assign(attributes, httpHeadersToSpanAttributes(request.headers.toJSON(), dataCollection)); - } - isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(request), + ipAddress: socketAddress?.address, }); if (client && dataCollection) { @@ -329,12 +335,15 @@ function wrapRequestHandler( }); } -function getRequestIP(server: unknown, request: Request): { address: string; port: number } | undefined { - if (typeof (server as Partial | undefined)?.requestIP !== 'function') { +function getRequestIP( + server: Partial> | undefined, + request: Request, +): { address: string; port: number } | undefined { + if (typeof server?.requestIP !== 'function') { return undefined; } try { - return (server as Server).requestIP(request) ?? undefined; + return server.requestIP(request) ?? undefined; } catch { // Defensive: never let a failed lookup break the user's handler. return undefined; diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index d3ed401e095c..391b3c981c59 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -570,7 +570,7 @@ describe('Bun Serve Integration', () => { ); }); - test('prefers the first x-forwarded-for address over the socket address', async () => { + test('prefers the first x-forwarded-for address over the socket address, without the socket port', async () => { const server = Bun.serve({ async fetch(_req) { return new Response('Bun!'); @@ -593,6 +593,97 @@ describe('Bun Serve Integration', () => { }), expect.any(Function), ); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.not.objectContaining({ + 'client.port': expect.anything(), + }), + }), + expect.any(Function), + ); + }); + + test('reads the client address from other forwarding headers', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Real-IP': '203.0.113.8' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': '203.0.113.8', + }), + }), + expect.any(Function), + ); + }); + + test('falls back to the socket address when the forwarding header is not an IP', async () => { + const server = Bun.serve({ + async fetch(_req) { + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`, { + headers: { 'X-Forwarded-For': 'unknown' }, + }); + + await server.stop(); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'client.address': expect.stringMatching(/^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/), + 'client.port': expect.any(Number), + }), + }), + expect.any(Function), + ); + }); + + test('sets the socket address as the user IP on error events', async () => { + const events: SentryCore.Event[] = []; + setupClient({ + integrations: [SentryCore.requestDataIntegration()], + beforeSend: event => { + events.push(event); + return null; + }, + }); + + const server = Bun.serve({ + async fetch(_req) { + SentryCore.captureException(new Error('Boom')); + return new Response('Bun!'); + }, + port, + }); + + await fetch(`http://localhost:${port}/`); + + await server.stop(); + await SentryCore.flush(); + + expect(events).toEqual([ + expect.objectContaining({ + user: expect.objectContaining({ + ip_address: expect.stringMatching(/^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/), + }), + }), + ]); }); test('does not capture client address when userInfo collection is disabled', async () => { @@ -680,8 +771,14 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(startSpanSpy).toHaveBeenCalledTimes(1); - const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; - expect(attributes?.['http.request.header.x-forwarded-for']).toEqual(['203.0.113.7']); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'http.request.header.x-forwarded-for': ['203.0.113.7'], + }), + }), + expect.any(Function), + ); }); test('filters request headers according to the dataCollection deny list', async () => { @@ -703,9 +800,15 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(startSpanSpy).toHaveBeenCalledTimes(1); - const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; - expect(attributes?.['http.request.header.x-internal']).toEqual(['[Filtered]']); - expect(attributes?.['http.request.header.x-public']).toEqual(['public-value']); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'http.request.header.x-internal': ['[Filtered]'], + 'http.request.header.x-public': ['public-value'], + }), + }), + expect.any(Function), + ); }); test('filters always-sensitive request headers even when collection is permissive', async () => { @@ -725,8 +828,14 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(startSpanSpy).toHaveBeenCalledTimes(1); - const attributes = startSpanSpy.mock.calls[0]?.[0]?.attributes; - expect(attributes?.['http.request.header.authorization']).toEqual(['[Filtered]']); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: expect.objectContaining({ + 'http.request.header.authorization': ['[Filtered]'], + }), + }), + expect.any(Function), + ); }); test('applies the dataCollection response header collection behavior', async () => { @@ -746,9 +855,12 @@ describe('Bun Serve Integration', () => { await server.stop(); expect(setAttributesSpy).toHaveBeenCalledTimes(1); - const responseAttributes = setAttributesSpy.mock.calls[0]?.[0]; - expect(responseAttributes?.['http.response.header.x-internal']).toEqual(['[Filtered]']); - expect(responseAttributes?.['http.response.header.x-public']).toEqual(['public-value']); + expect(setAttributesSpy).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.response.header.x-internal': ['[Filtered]'], + 'http.response.header.x-public': ['public-value'], + }), + ); }); }); diff --git a/packages/core/src/integrations/http/server-subscription.ts b/packages/core/src/integrations/http/server-subscription.ts index bc09da8ba6d9..3ef8edbf0a49 100644 --- a/packages/core/src/integrations/http/server-subscription.ts +++ b/packages/core/src/integrations/http/server-subscription.ts @@ -69,6 +69,7 @@ import { } from '@sentry/conventions/attributes'; import { HTTP_SERVER } from '@sentry/conventions/op'; import { filterCollectedUrl, filterCollectedUrlQuery } from '../../utils/data-collection/filterCollectedUrl'; +import { getClientIPAddress } from '../../utils/clientIPAddress'; // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -306,7 +307,6 @@ function buildServerSpanWrap( : `${method} ${httpTargetWithoutQueryFragment}`; const headers = request.headers; const userAgent = headers['user-agent']; - const ips = headers['x-forwarded-for']; const httpVersion = request.httpVersion; const host = headers.host as undefined | string; const hostname = host?.replace(/^(.*)(:[0-9]{1,5})/, '$1') || 'localhost'; @@ -315,8 +315,10 @@ function buildServerSpanWrap( const { localAddress, localPort, remoteAddress, remotePort } = socket ?? {}; const collectClientAddress = client.getDataCollectionOptions().userInfo; // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. `network.peer.address` keeps the socket value. - const clientAddress = getForwardedClientAddress(ips) ?? remoteAddress; + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. `network.peer.*` keeps the socket values. + const forwardedAddress = getClientIPAddress(headers); + const clientAddress = forwardedAddress || remoteAddress; return startSpanManual( { @@ -333,7 +335,7 @@ function buildServerSpanWrap( [NETWORK_LOCAL_ADDRESS]: localAddress, [NETWORK_LOCAL_PORT]: localPort, [CLIENT_ADDRESS]: collectClientAddress ? clientAddress : undefined, - [CLIENT_PORT]: remotePort, + [CLIENT_PORT]: forwardedAddress ? undefined : remotePort, [NETWORK_PEER_ADDRESS]: collectClientAddress ? remoteAddress : undefined, [NETWORK_PEER_PORT]: remotePort, [SENTRY_HTTP_PREFETCH]: isKnownPrefetchRequest(request) || undefined, @@ -392,14 +394,6 @@ function buildServerSpanWrap( }; } -/** - * First entry of `X-Forwarded-For`: the client as seen by the outermost proxy. - * https://opentelemetry.io/docs/specs/semconv/registry/attributes/client/#client-address - */ -function getForwardedClientAddress(forwardedFor: string | string[] | undefined): string | undefined { - return typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() || undefined : undefined; -} - function shouldIgnoreSpansForIncomingRequest( request: HttpIncomingMessage, { diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index a8e0dc14e0a2..8c641f666169 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -16,7 +16,8 @@ import { filterQueryParams } from '../utils/data-collection/filterQueryParams'; import { filterUrlQuery } from '../utils/data-collection/filterUrlQuery'; import { filterCookiePairs, httpHeadersToSpanAttributes } from '../utils/request'; import { getUrlQuery } from '../utils/url'; -import { getClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; +import { getClientIPAddress } from '../utils/clientIPAddress'; +import { ipHeaderNames } from '../vendor/getIpAddress'; import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan'; import { SENTRY_IS_LOCALHOST, URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index e48e0ea1de5f..a34657db8d6a 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -23,6 +23,7 @@ export { processHttpServerTransactionEvent, } from './integrations/http/server-transaction-event'; export { recordRequestSession } from './integrations/http/record-request-session'; +export { getClientIPAddress } from './utils/clientIPAddress'; export { addOutgoingRequestBreadcrumb } from './integrations/http/add-outgoing-request-breadcrumb'; export { getRequestUrl, diff --git a/packages/core/src/utils/clientIPAddress.ts b/packages/core/src/utils/clientIPAddress.ts new file mode 100644 index 000000000000..eb16facb032f --- /dev/null +++ b/packages/core/src/utils/clientIPAddress.ts @@ -0,0 +1,47 @@ +import { getClientIPAddress as getBareClientIPAddress, ipHeaderNames } from '../vendor/getIpAddress'; + +const IP_HEADER_NAMES = new Set(ipHeaderNames.map(name => name.toLowerCase())); + +/** + * Get the IP address of the client sending a request, from its forwarding headers. + * + * The vendored implementation accepts only bare addresses, so the header values are + * normalized first. Some proxies (for example Azure App Service) add the client port, + * and RFC 7239 allows a quoted `Forwarded` value and any case for `for=`. + */ +export function getClientIPAddress(headers: { [key: string]: string | string[] | undefined }): string | null { + const normalized: { [key: string]: string } = {}; + + for (const [key, value] of Object.entries(headers)) { + const name = key.toLowerCase(); + if (value === undefined || !IP_HEADER_NAMES.has(name)) { + continue; + } + + const joined = Array.isArray(value) ? value.join(',') : value; + normalized[key] = + name === 'forwarded' + ? normalizeForwardedHeader(joined) + : joined + .split(',') + .map(ip => stripPort(ip.trim())) + .join(','); + } + + return getBareClientIPAddress(normalized); +} + +// The vendored parser reads the first `for=` pair only, so only that pair is kept. +function normalizeForwardedHeader(value: string): string { + const forPair = value + .split(/[,;]/) + .map(part => part.trim()) + .find(part => part.slice(0, 4).toLowerCase() === 'for='); + + return forPair ? `for=${stripPort(forPair.slice(4).replace(/^"(.*)"$/, '$1'))}` : ''; +} + +// `203.0.113.7:4711` -> `203.0.113.7`, `[2001:db8::1]:4711` -> `2001:db8::1` +function stripPort(ip: string): string { + return ip.match(/^\[([^\]]+)\](?::\d+)?$/)?.[1] ?? ip.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/)?.[1] ?? ip; +} diff --git a/packages/core/test/lib/integrations/http/server-subscription.test.ts b/packages/core/test/lib/integrations/http/server-subscription.test.ts index 3b749134bd9d..8f8addf7e78a 100644 --- a/packages/core/test/lib/integrations/http/server-subscription.test.ts +++ b/packages/core/test/lib/integrations/http/server-subscription.test.ts @@ -153,12 +153,32 @@ describe('getHttpServerSubscriptions', () => { await makeRequest('/users/42', 'GET', { 'X-Forwarded-For': '203.0.113.7, 198.51.100.1' }); const transaction = await waitForTransaction(); - expect(transaction.contexts?.trace?.data).toEqual( + const data = transaction.contexts?.trace?.data; + expect(data).toEqual( expect.objectContaining({ // the originating client, as reported by the outermost proxy [CLIENT_ADDRESS]: '203.0.113.7', // the immediate peer stays the socket, i.e. the proxy itself [NETWORK_PEER_ADDRESS]: '127.0.0.1', + [NETWORK_PEER_PORT]: expect.any(Number), + }), + ); + // the socket port belongs to the proxy, not the forwarded client + expect(data).not.toHaveProperty(CLIENT_PORT); + }); + + it('falls back to the socket for `client.address` when the forwarding header is not an IP', async () => { + server = http.createServer((_req, res) => res.end('ok')); + await new Promise(resolve => server.listen(0, '127.0.0.1', () => resolve())); + instrument(true); + + await makeRequest('/users/42', 'GET', { 'X-Forwarded-For': 'unknown' }); + const transaction = await waitForTransaction(); + + expect(transaction.contexts?.trace?.data).toEqual( + expect.objectContaining({ + [CLIENT_ADDRESS]: '127.0.0.1', + [CLIENT_PORT]: expect.any(Number), }), ); }); diff --git a/packages/core/test/lib/utils/clientIPAddress.test.ts b/packages/core/test/lib/utils/clientIPAddress.test.ts new file mode 100644 index 000000000000..c19c090752ec --- /dev/null +++ b/packages/core/test/lib/utils/clientIPAddress.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { getClientIPAddress } from '../../../src/utils/clientIPAddress'; + +describe('getClientIPAddress', () => { + it.each([ + ['203.0.113.7', '203.0.113.7'], + ['203.0.113.7:4711', '203.0.113.7'], + ['unknown, 203.0.113.7:4711', '203.0.113.7'], + ['[2001:db8::1]:4711', '2001:db8::1'], + ['[2001:db8::1]', '2001:db8::1'], + ['2001:db8::1', '2001:db8::1'], + ['unknown', null], + ])('parses the X-Forwarded-For value %s', (headerValue, expectedIP) => { + expect(getClientIPAddress({ 'X-Forwarded-For': headerValue })).toBe(expectedIP); + }); + + it.each([ + ['for=203.0.113.7', '203.0.113.7'], + ['For=203.0.113.7', '203.0.113.7'], + ['proto=https;for=203.0.113.7', '203.0.113.7'], + ['for="203.0.113.7:4711"', '203.0.113.7'], + ['for="[2001:db8::1]:4711"', '2001:db8::1'], + ['for=203.0.113.7;proto=https, for=198.51.100.1', '203.0.113.7'], + ['proto=https', null], + ['for=_hidden', null], + ])('parses the Forwarded value %s', (headerValue, expectedIP) => { + expect(getClientIPAddress({ Forwarded: headerValue })).toBe(expectedIP); + }); + + it('joins array header values', () => { + expect(getClientIPAddress({ 'x-forwarded-for': ['unknown', '203.0.113.7:4711'] })).toBe('203.0.113.7'); + }); + + it('keeps the header priority order', () => { + expect(getClientIPAddress({ 'X-Real-IP': '198.51.100.1', 'X-Forwarded-For': '203.0.113.7:4711' })).toBe( + '203.0.113.7', + ); + }); +}); diff --git a/packages/deno/src/wrap-deno-request-handler.ts b/packages/deno/src/wrap-deno-request-handler.ts index 1d53cca0db27..1ab390d3790b 100644 --- a/packages/deno/src/wrap-deno-request-handler.ts +++ b/packages/deno/src/wrap-deno-request-handler.ts @@ -24,6 +24,7 @@ import { winterCGRequestToRequestData, withIsolationScope, } from '@sentry/core'; +import { getClientIPAddress } from '@sentry/core/server'; import { streamResponse } from './utils/streaming'; export type RequestHandlerWrapperOptions = { @@ -93,24 +94,32 @@ export const wrapDenoRequestHandler = ( assignIfSet(attributes, 'http.request.body.size', contentLength && parseInt(contentLength, 10)); assignIfSet(attributes, 'user_agent.original', request.headers.get('user-agent')); + const headers = winterCGHeadersToDict(request.headers); + const socketIp = (info?.remoteAddr as Deno.NetAddr)?.hostname; const dataCollection = client.getDataCollectionOptions(); if (dataCollection.userInfo) { // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. - const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(); - const socketAddress = (info?.remoteAddr as Deno.NetAddr)?.hostname ?? (info?.remoteAddr as Deno.UnixAddr)?.path; - const clientPort = (info?.remoteAddr as Deno.NetAddr)?.port; - assignIfSet(attributes, CLIENT_ADDRESS, forwardedFor || socketAddress); - assignIfSet(attributes, CLIENT_PORT, clientPort); + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. + const forwardedAddress = getClientIPAddress(headers); + assignIfSet( + attributes, + CLIENT_ADDRESS, + forwardedAddress || socketIp || (info?.remoteAddr as Deno.UnixAddr)?.path, + ); + if (!forwardedAddress) { + assignIfSet(attributes, CLIENT_PORT, (info?.remoteAddr as Deno.NetAddr)?.port); + } } // describes the OSI application-layer protocol (http), not the scheme (might be https) attributes[NETWORK_PROTOCOL_NAME] = 'http'; - Object.assign(attributes, httpHeadersToSpanAttributes(winterCGHeadersToDict(request.headers), dataCollection)); + Object.assign(attributes, httpHeadersToSpanAttributes(headers, dataCollection)); attributes[SENTRY_OP] = HTTP_SERVER; isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(request), + ipAddress: socketIp, }); const configuredBodySize = client.getIntegrationByName( diff --git a/packages/deno/test/deno-serve.test.ts b/packages/deno/test/deno-serve.test.ts index 933aded91ee8..f85e596e631a 100644 --- a/packages/deno/test/deno-serve.test.ts +++ b/packages/deno/test/deno-serve.test.ts @@ -2,10 +2,12 @@ import type { ErrorEvent, TransactionEvent } from '@sentry/core'; import { getMainCarrier } from '@sentry/core'; -import { assertEquals, assertExists, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; +import { assertEquals, assertExists, assertMatch, assertNotEquals } from 'https://deno.land/std@0.212.0/assert/mod.ts'; import type { DenoClient } from '../build/esm/index.js'; import { captureException, captureMessage, init, denoServeIntegration, setTag, setUser } from '../build/esm/index.js'; +const LOOPBACK_ADDRESS = /^(127\.0\.0\.1|::1|::ffff:127\.0\.0\.1)$/; + function resetGlobals(): void { getMainCarrier().__SENTRY__ = undefined; } @@ -552,6 +554,113 @@ Deno.test('Deno.serve should capture client address and port by default', async assertExists(transaction?.contexts?.trace?.data?.['client.port']); }); +Deno.test('Deno.serve should prefer the forwarded client address, without the socket port', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + return new Response('OK'); + }); + await p; + + const res = await fetch(`http://localhost:${server.addr.port}/test`, { + headers: { 'X-Forwarded-For': '203.0.113.7, 10.0.0.1' }, + }); + assertEquals(await res.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + const [transaction] = transactionEvents; + + assertEquals(transaction?.contexts?.trace?.data?.['client.address'], '203.0.113.7'); + assertEquals(transaction?.contexts?.trace?.data?.['client.port'], undefined); +}); + +Deno.test('Deno.serve should fall back to the socket address when the forwarding header is not an IP', async () => { + resetGlobals(); + const transactionEvents: TransactionEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSendTransaction: (event: TransactionEvent) => { + transactionEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + return new Response('OK'); + }); + await p; + + const res = await fetch(`http://localhost:${server.addr.port}/test`, { + headers: { 'X-Forwarded-For': 'unknown' }, + }); + assertEquals(await res.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(transactionEvents.length, 1); + const [transaction] = transactionEvents; + + assertMatch(String(transaction?.contexts?.trace?.data?.['client.address']), LOOPBACK_ADDRESS); + assertEquals(typeof transaction?.contexts?.trace?.data?.['client.port'], 'number'); +}); + +Deno.test('Deno.serve should set the socket address as the user IP on error events', async () => { + resetGlobals(); + const errorEvents: ErrorEvent[] = []; + + init({ + dsn: 'https://username@domain/123', + tracesSampleRate: 1, + traceLifecycle: 'static', + beforeSend: (event: ErrorEvent) => { + errorEvents.push(event); + return null; + }, + }) as DenoClient; + + const abortController = new AbortController(); + let onListen: ((_: unknown) => void) | undefined = undefined; + const p = new Promise(resolve => (onListen = resolve)); + const server = Deno.serve({ port: 0, signal: abortController.signal, onListen }, () => { + captureException(new Error('Boom')); + return new Response('OK'); + }); + await p; + + const res = await fetch(`http://localhost:${server.addr.port}/test`); + assertEquals(await res.text(), 'OK'); + + abortController.abort(); + await server.finished; + + assertEquals(errorEvents.length, 1); + assertMatch(String(errorEvents[0]?.user?.ip_address), LOOPBACK_ADDRESS); +}); + Deno.test('Deno.serve should not capture client address when userInfo collection is disabled', async () => { resetGlobals(); const transactionEvents: TransactionEvent[] = []; diff --git a/packages/node/src/integrations/http/httpServerSpansIntegration.ts b/packages/node/src/integrations/http/httpServerSpansIntegration.ts index a00d048591a0..4712bdd61e7a 100644 --- a/packages/node/src/integrations/http/httpServerSpansIntegration.ts +++ b/packages/node/src/integrations/http/httpServerSpansIntegration.ts @@ -28,7 +28,11 @@ import { import { HTTP_SERVER } from '@sentry/conventions/op'; import type { Event, Integration, IntegrationFn, Span, SpanAttributes, SpanStatus } from '@sentry/core'; import type { HttpIncomingMessage, HttpServerResponse } from '@sentry/core/server'; -import { DEFAULT_IGNORE_STATUS_CODES, processHttpServerTransactionEvent } from '@sentry/core/server'; +import { + DEFAULT_IGNORE_STATUS_CODES, + getClientIPAddress, + processHttpServerTransactionEvent, +} from '@sentry/core/server'; import { debug, getSpanStatusFromHttpCode, @@ -320,14 +324,6 @@ function shouldIgnoreSpansForIncomingRequest( return false; } -/** - * First entry of `X-Forwarded-For`: the client as seen by the outermost proxy. - * https://opentelemetry.io/docs/specs/semconv/registry/attributes/client/#client-address - */ -function getForwardedClientAddress(forwardedFor: string | string[] | undefined): string | undefined { - return typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() || undefined : undefined; -} - function getIncomingRequestAttributesOnResponse( request: HttpIncomingMessage, response: HttpServerResponse, @@ -343,11 +339,12 @@ function getIncomingRequestAttributesOnResponse( 'http.response.status_text': statusMessage?.toUpperCase(), }; + // `client.address` is the originating client, so a forwarding header wins over the socket, which + // behind a proxy holds the proxy's address. The socket port is the proxy's too, so `client.port` + // stays unset then. `network.peer.*` below keeps the socket values. + const forwardedAddress = getClientIPAddress(request.headers); if (collectClientAddress) { - // `client.address` is the originating client, so a forwarding header wins over the socket, which - // behind a proxy holds the proxy's address. `network.peer.address` below keeps the socket value. - newAttributes[CLIENT_ADDRESS] = - getForwardedClientAddress(request.headers['x-forwarded-for']) ?? socket?.remoteAddress; + newAttributes[CLIENT_ADDRESS] = forwardedAddress || socket?.remoteAddress; } if (socket) { @@ -355,7 +352,7 @@ function getIncomingRequestAttributesOnResponse( newAttributes[SERVER_PORT] = localPort; newAttributes[NETWORK_LOCAL_ADDRESS] = localAddress; newAttributes[NETWORK_LOCAL_PORT] = localPort; - newAttributes[CLIENT_PORT] = remotePort; + newAttributes[CLIENT_PORT] = forwardedAddress ? undefined : remotePort; newAttributes[NETWORK_PEER_ADDRESS] = collectClientAddress ? remoteAddress : undefined; newAttributes[NETWORK_PEER_PORT] = remotePort; } From 0e00ec1f365a5d826150052c4527302e3f6d89a8 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:49:20 +0200 Subject: [PATCH 32/57] feat(cloudflare): Trace TypeSafe Jev calls in Workers AI as evaluate spans (#24833) ## What Workers AI calls to TypeSafe models (e.g. `typesafe/jev`) now create `evaluate` spans with the same input, output, and token attributes as the TypeSafe integration. ## Why Before, these calls showed up as `chat` spans with no inputs or answers recorded. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: JPeer264 --- .../suites/tracing/workers-ai/index.ts | 8 ++++ .../suites/tracing/workers-ai/mocks.ts | 11 +++++ .../suites/tracing/workers-ai/test.ts | 47 +++++++++++++++++++ .../src/index.ts | 7 +++ .../tests/send-to-sentry.test.ts | 21 +++++++++ .../server-utils/src/ai/typesafe/index.ts | 18 +++---- .../server-utils/src/ai/workers-ai/index.ts | 4 +- .../server-utils/src/ai/workers-ai/utils.ts | 36 ++++++++++++-- .../test/ai/lib/tracing/workers-ai.test.ts | 43 +++++++++++++++++ .../ai/lib/utils/workers-ai-utils.test.ts | 28 +++++++++++ 10 files changed, 209 insertions(+), 14 deletions(-) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts index e48428c9c732..42d2d895fea0 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/index.ts @@ -34,6 +34,14 @@ export default Sentry.withSentry( return new Response(JSON.stringify(result)); } + if (url.pathname === '/evaluate') { + const result = await ai.run('typesafe/jev', { + state: 'Help! My payouts have been failing for 3 days.', + questions: { is_urgent: { type: 'noul', instructions: 'Does this convey urgency?' } }, + }); + return new Response(JSON.stringify(result)); + } + if (url.pathname === '/stream') { const stream = (await ai.run('@cf/meta/llama-3.1-8b-instruct', { messages: [{ role: 'user', content: 'What is the capital of France?' }], diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts index bce096d4ae75..e0a26d02bf86 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/mocks.ts @@ -23,6 +23,17 @@ export class MockAi { throw error; } + if (model === 'typesafe/jev') { + return { + state: 'Completed', + result: { + model: 'jev-1.13.0', + answers: { is_urgent: { type: 'noul', noul: 0.97 } }, + usage: { input_tokens: 426, output_tokens: 73 }, + }, + }; + } + if (inputs?.stream === true) { return createSseStream([ '{"response":"The capital "}', diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index 726f91a5e467..4eaf940ef9c7 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -5,6 +5,7 @@ import { GEN_AI_REQUEST_MAX_TOKENS, GEN_AI_REQUEST_MODEL, GEN_AI_REQUEST_TEMPERATURE, + GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_STREAMING, GEN_AI_RESPONSE_TEXT, GEN_AI_USAGE_INPUT_TOKENS, @@ -132,6 +133,52 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = await runner.completed(); }); +it('traces a TypeSafe Jev evaluation like the TypeSafe integration', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect(envelope => { + const spans = getSpansFromEnvelope(envelope); + const segmentSpan = spans.find(span => span.is_segment); + + const genAiSpans = spans.filter(span => getSpanOp(span)?.startsWith('gen_ai.')); + expect(genAiSpans).toHaveLength(1); + + expect(genAiSpans[0]).toEqual( + expect.objectContaining({ + name: 'evaluate typesafe/jev', + status: 'ok', + is_segment: false, + attributes: { + 'sentry.origin': { value: 'auto.ai.cloudflare.workers_ai', type: 'string' }, + 'sentry.op': { value: 'gen_ai.evaluate', type: 'string' }, + [GEN_AI_PROVIDER_NAME]: { value: 'cloudflare.workers_ai', type: 'string' }, + [GEN_AI_OPERATION_NAME]: { value: 'evaluate', type: 'string' }, + [GEN_AI_REQUEST_MODEL]: { value: 'typesafe/jev', type: 'string' }, + [GEN_AI_RESPONSE_MODEL]: { value: 'jev-1.13.0', type: 'string' }, + [GEN_AI_USAGE_INPUT_TOKENS]: { value: 426, type: 'integer' }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 73, type: 'integer' }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 499, type: 'integer' }, + // collect only output messages + [GEN_AI_OUTPUT_MESSAGES]: { + type: 'string', + value: '[{"type":"evaluation","answers":{"is_urgent":{"type":"noul","noul":0.97}}}]', + }, + 'sentry.is_localhost': { value: true, type: 'boolean' }, + [SENTRY_TRACE_LIFECYCLE]: { value: 'stream', type: 'string' }, + [SENTRY_SEGMENT_NAME]: { value: segmentSpan!.name, type: 'string' }, + [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, + [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, + [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, + [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + }, + }), + ); + }) + .start(signal); + await runner.makeRequest('get', '/evaluate'); + await runner.completed(); +}); + // The Workers AI integration deliberately does not call `captureException` itself. // When a `run` call fails, the error must bubble up out of the fetch handler and be // reported by the top-level Cloudflare instrumentation instead — so it shows up in 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 index 324c9161cf69..87ae6c9cff41 100644 --- 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 @@ -31,6 +31,13 @@ export default { await env.AI.run('@cf/meta/llama-3.2-1b-instruct', { prompt: 'Say hi', max_tokens: 5 }); return Response.json({ traceId: spanContext?.traceId }); } + case '/test-workers-ai-jev': { + await env.AI.run('typesafe/jev', { + state: 'Help! My payouts have been failing for 3 days.', + questions: { is_urgent: { type: 'noul', instructions: 'Does this convey urgency?' } }, + }); + return Response.json({ traceId: spanContext?.traceId }); + } case '/test-span': return Response.json({ spanId: spanContext?.spanId, traceId: spanContext?.traceId }); case '/test-workflow-sleep': { 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 index 2397db37089f..b391f2f8244b 100644 --- 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 @@ -93,3 +93,24 @@ test('Sends a Workers AI gen_ai span to Sentry', async () => { 'gen_ai.output.messages': expect.stringContaining('"role":"assistant"'), }); }); + +test('Sends a Workers AI gen_ai.evaluate span for a TypeSafe Jev call to Sentry', async () => { + const { traceId }: { traceId: string } = JSON.parse(await fetchFromWorker(`${workerUrl}/test-workers-ai-jev`, 200)); + + console.log(`Polling for gen_ai.evaluate span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, 'gen_ai.evaluate')?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + await expect + .poll(() => fetchSpanAttributes(traceId, spanId!), EVENT_POLLING_OPTIONS) + .toMatchObject({ + 'gen_ai.operation.name': 'evaluate', + 'gen_ai.request.model': 'typesafe/jev', + 'gen_ai.response.model': expect.stringMatching(/^jev-/), + 'gen_ai.input.messages': expect.stringContaining('My payouts have been failing'), + 'gen_ai.output.messages': expect.stringContaining('"is_urgent"'), + }); +}); diff --git a/packages/server-utils/src/ai/typesafe/index.ts b/packages/server-utils/src/ai/typesafe/index.ts index b8fe48878024..328853cf5b02 100644 --- a/packages/server-utils/src/ai/typesafe/index.ts +++ b/packages/server-utils/src/ai/typesafe/index.ts @@ -49,17 +49,19 @@ function getRequestAttributes( [GEN_AI_OPERATION_NAME]: 'evaluate', [GEN_AI_PROVIDER_NAME]: TYPESAFE_PROVIDER_NAME, ...(model ? { [GEN_AI_REQUEST_MODEL]: model } : {}), - ...(recordInputs - ? { - [GEN_AI_INPUT_MESSAGES]: stringify([ - { type: 'evaluation', state: request.state, questions: request.questions }, - ]), - } - : {}), + ...(recordInputs ? { [GEN_AI_INPUT_MESSAGES]: getEvaluationInputMessages(request) } : {}), }; } -/** Add the response model, token usage and (optionally) the answers of a `systemOne` result. */ +/** Serialize the `state` and `questions` of an evaluation request. Also used for TypeSafe models on Workers AI. */ +export function getEvaluationInputMessages(request: Record): string | undefined { + return stringify([{ type: 'evaluation', state: request.state, questions: request.questions }]); +} + +/** + * Add the response model, token usage and (optionally) the answers of a `systemOne` result. + * Also used for TypeSafe models on Workers AI. + */ export function addResponseAttributes(span: Span, result: unknown, recordOutputs: boolean): void { if (!isObjectLike(result)) { return; diff --git a/packages/server-utils/src/ai/workers-ai/index.ts b/packages/server-utils/src/ai/workers-ai/index.ts index c02222cc1877..eb3a7c5407cb 100644 --- a/packages/server-utils/src/ai/workers-ai/index.ts +++ b/packages/server-utils/src/ai/workers-ai/index.ts @@ -89,7 +89,7 @@ function instrumentRun( } // The model did not actually return a stream — finalize the span eagerly. - addResponseAttributes(span, result, options.recordOutputs); + addResponseAttributes(span, result, options.recordOutputs, operationName); span.end(); return result; }, handleError); @@ -105,7 +105,7 @@ function instrumentRun( return originalResult.then(result => { if (!returnsRawResponse) { - addResponseAttributes(span, result, options.recordOutputs); + addResponseAttributes(span, result, options.recordOutputs, operationName); } return result; }); diff --git a/packages/server-utils/src/ai/workers-ai/utils.ts b/packages/server-utils/src/ai/workers-ai/utils.ts index 33908290be72..67645a456988 100644 --- a/packages/server-utils/src/ai/workers-ai/utils.ts +++ b/packages/server-utils/src/ai/workers-ai/utils.ts @@ -16,10 +16,16 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS, } from '@sentry/conventions/attributes'; import { GEN_AI_CHAT, GEN_AI_EMBEDDINGS } from '@sentry/conventions/op'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stringify } from '@sentry/core'; +import { isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, stringify } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../core/gen-ai-attributes'; -import { extractSystemInstructions, setOutputMessagesAttribute, setTokenUsageAttributes } from '../core/utils'; +import { + extractSystemInstructions, + getGenAiSpanOp, + setOutputMessagesAttribute, + setTokenUsageAttributes, +} from '../core/utils'; +import { addResponseAttributes as addEvaluateResponseAttributes, getEvaluationInputMessages } from '../typesafe'; // Re-exported so `workers-ai/streaming.ts` keeps importing it from this module. export { setOutputMessagesAttribute }; import { WORKERS_AI_ORIGIN, WORKERS_AI_PROVIDER_NAME } from './constants'; @@ -29,15 +35,20 @@ import type { WorkersAiInput, WorkersAiOutput } from './types'; * Determine the gen_ai operation name from the inputs passed to `AI.run`. * Workers AI exposes a single `run` method, so we infer the operation from the input shape. */ -export type WorkersAiOperationName = 'chat' | 'embeddings'; +export type WorkersAiOperationName = 'chat' | 'embeddings' | 'evaluate'; export const WORKERS_AI_OPERATION_SPAN_OPS: Record = { chat: GEN_AI_CHAT, embeddings: GEN_AI_EMBEDDINGS, + evaluate: getGenAiSpanOp('evaluate'), }; export function getOperationName(inputs: unknown): WorkersAiOperationName { if (inputs && typeof inputs === 'object') { + // TypeSafe evaluation models (e.g. `typesafe/jev`) + if ('state' in inputs && 'questions' in inputs) { + return 'evaluate'; + } if ('messages' in inputs || 'prompt' in inputs) { return 'chat'; } @@ -102,6 +113,11 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: } const params = inputs as WorkersAiInput; + if (operationName === 'evaluate') { + span.setAttribute(GEN_AI_INPUT_MESSAGES, getEvaluationInputMessages(inputs as Record)); + return; + } + // Store embeddings input on a separate attribute if (operationName === 'embeddings') { const text = params.text; @@ -132,7 +148,19 @@ export function addRequestAttributes(span: Span, inputs: unknown, operationName: /** * Record the response attributes (token usage, response text, tool calls) on the span. */ -export function addResponseAttributes(span: Span, result: unknown, recordOutputs: boolean): void { +export function addResponseAttributes( + span: Span, + result: unknown, + recordOutputs: boolean, + operationName?: WorkersAiOperationName, +): void { + if (operationName === 'evaluate') { + // The binding wraps the TypeSafe response as `{ state, result }`, but the docs show it unwrapped. + const body = isObjectLike(result) && isObjectLike(result.result) ? result.result : result; + addEvaluateResponseAttributes(span, body, recordOutputs); + return; + } + if ( !result || typeof result !== 'object' || diff --git a/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts b/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts index 4baced963ec2..bc282e0348bc 100644 --- a/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts @@ -5,8 +5,12 @@ import { GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MODEL, + GEN_AI_RESPONSE_MODEL, 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -160,6 +164,45 @@ describe('instrumentWorkersAiClient', () => { }); }); + it('records TypeSafe models as evaluate spans, like the TypeSafe integration', async () => { + const client = new TestClient( + getDefaultTestClientOptions({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tracesSampleRate: 1 }), + ); + setCurrentClient(client); + client.init(); + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + + const questions = { is_urgent: { type: 'noul', instructions: 'Does this convey urgency?' } }; + const answers = { is_urgent: { type: 'noul', noul: 0.97 } }; + const ai = { + run: vi.fn().mockResolvedValue({ + state: 'Completed', + result: { model: 'jev-1.13.0', answers, usage: { input_tokens: 426, output_tokens: 73 } }, + }), + }; + + await instrumentWorkersAiClient(ai).run('typesafe/jev', { state: 'Help!', questions }); + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.description).toBe('evaluate typesafe/jev'); + expect(span.data).toEqual({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.cloudflare.workers_ai', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.evaluate', + [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1, + [SENTRY_SEGMENT_NAME_SOURCE]: 'custom', + [GEN_AI_PROVIDER_NAME]: 'cloudflare.workers_ai', + [GEN_AI_OPERATION_NAME]: 'evaluate', + [GEN_AI_REQUEST_MODEL]: 'typesafe/jev', + [GEN_AI_RESPONSE_MODEL]: 'jev-1.13.0', + [GEN_AI_USAGE_INPUT_TOKENS]: 426, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 73, + [GEN_AI_USAGE_TOTAL_TOKENS]: 499, + [GEN_AI_INPUT_MESSAGES]: JSON.stringify([{ type: 'evaluation', state: 'Help!', questions }]), + [GEN_AI_OUTPUT_MESSAGES]: JSON.stringify([{ type: 'evaluation', answers }]), + }); + }); + describe('span names', () => { function setupClient(traceLifecycle: 'static' | 'stream'): Span[] { const client = new TestClient( diff --git a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts index 70878dc1a96e..8d9f9d15b257 100644 --- a/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts +++ b/packages/server-utils/test/ai/lib/utils/workers-ai-utils.test.ts @@ -14,6 +14,7 @@ import { GEN_AI_REQUEST_TOP_K, GEN_AI_REQUEST_TOP_P, GEN_AI_OUTPUT_MESSAGES, + GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_TEXT, GEN_AI_RESPONSE_TOOL_CALLS, GEN_AI_SYSTEM_INSTRUCTIONS, @@ -59,6 +60,10 @@ describe('workers-ai utils', () => { expect(getOperationName({ text: 'embed me' })).toBe('embeddings'); }); + it('returns "evaluate" for TypeSafe state and questions inputs', () => { + expect(getOperationName({ state: 'Help!', questions: {} })).toBe('evaluate'); + }); + it('prefers "chat" when both messages and text are present', () => { expect(getOperationName({ messages: [{ role: 'user', content: 'Hi' }], text: 'embed me' })).toBe('chat'); }); @@ -185,6 +190,29 @@ describe('workers-ai utils', () => { }); describe('addResponseAttributes', () => { + it.each([ + { name: 'wrapped in { state, result }', wrap: (body: object) => ({ state: 'Completed', result: body }) }, + { name: 'unwrapped', wrap: (body: object) => body }, + ])('records evaluate responses $name', ({ wrap }) => { + const { span, attributes } = createMockSpan(); + const answers = { is_urgent: { type: 'noul', noul: 0.95 } }; + + addResponseAttributes( + span, + wrap({ model: 'jev-1.13.0', answers, usage: { input_tokens: 366, output_tokens: 50 } }), + true, + 'evaluate', + ); + + expect(attributes).toEqual({ + [GEN_AI_RESPONSE_MODEL]: 'jev-1.13.0', + [GEN_AI_USAGE_INPUT_TOKENS]: 366, + [GEN_AI_USAGE_OUTPUT_TOKENS]: 50, + [GEN_AI_USAGE_TOTAL_TOKENS]: 416, + [GEN_AI_OUTPUT_MESSAGES]: JSON.stringify([{ type: 'evaluation', answers }]), + }); + }); + it('sets token usage and computes the total from input and output tokens', () => { const { span, attributes } = createMockSpan(); From f555a141a127197fee147727fc199e6b356cac71 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 09:06:47 +0200 Subject: [PATCH 33/57] fix(nitro): Capture errors only through the Nitro error hook (#24952) Capture errors through Nitro's error hook so ordinary thrown errors are reported once and 3xx/4xx HTTP errors stay filtered. The tracing channel continues recording span error status without sending a separate error event. Adds E2E coverage for an explicitly thrown 400 and updates the standard error test to expect the hook event. Fixes #24939 --------- Co-authored-by: OpenAI Codex --- .../nitro-3/server/api/flush.ts | 7 +++ .../nitro-3/server/api/test-error-400.ts | 5 ++ .../nitro-3/tests/errors.test.ts | 57 +++++++++++++------ .../src/runtime/hooks/captureTracingEvents.ts | 6 -- 4 files changed, 52 insertions(+), 23 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/flush.ts create mode 100644 dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error-400.ts diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/flush.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/flush.ts new file mode 100644 index 000000000000..1bb06c6e1a6d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/flush.ts @@ -0,0 +1,7 @@ +import { flush } from '@sentry/nitro'; +import { defineHandler } from 'nitro/h3'; + +export default defineHandler(async () => { + await flush(); + return { status: 'ok' }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error-400.ts b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error-400.ts new file mode 100644 index 000000000000..760a6ad17852 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nitro-3/server/api/test-error-400.ts @@ -0,0 +1,5 @@ +import { defineHandler, HTTPError } from 'nitro/h3'; + +export default defineHandler(() => { + throw new HTTPError({ status: 400, message: 'Explicit 400 test error' }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts index 1441ecd5ecab..18cbd7ca6709 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts @@ -1,16 +1,10 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; test('Sends an error event to Sentry', async ({ request }) => { - // The thrown error is reported twice: once via the h3 tracing channel and once via Nitro's `error` - // hook (which sees it wrapped in an `HTTPError`). Match on the mechanism so we deterministically - // await the event under test instead of whichever arrives first. const errorEventPromise = waitForError('nitro-3', event => { - return ( - !event.type && - !!event.exception?.values?.some( - v => v.value === 'This is a test error' && v.mechanism?.type === 'auto.http.nitro.onTraceError', - ) + return !!event.exception?.values?.some( + v => v.value === 'This is a test error' && v.mechanism?.type === 'auto.function.nitro.captureErrorHook', ); }); @@ -20,16 +14,45 @@ test('Sends an error event to Sentry', async ({ request }) => { const errorEvent = await errorEventPromise; - expect(errorEvent.exception?.values).toHaveLength(1); - - expect(errorEvent.exception?.values?.[0]?.type).toBe('Error'); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is a test error'); - expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual( + expect(errorEvent.exception?.values).toEqual([ + expect.objectContaining({ + type: 'Error', + value: 'This is a test error', + }), expect.objectContaining({ - handled: false, - type: 'auto.http.nitro.onTraceError', + type: 'HTTPError', + value: 'This is a test error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.function.nitro.captureErrorHook', + }), }), - ); + ]); +}); + +test('Does not send an explicitly thrown 400 error to Sentry', async ({ request }) => { + let errorReceived = false; + + void waitForError('nitro-3', event => { + if (event.exception?.values?.some(v => v.value === 'Explicit 400 test error')) { + errorReceived = true; + return true; + } + return false; + }); + + const flushSpanPromise = waitForStreamedSpan('nitro-3', span => { + return span.is_segment && span.name === 'GET /api/flush'; + }); + + const response = await request.get('/api/test-error-400'); + expect(response.status()).toBe(400); + + const flushResponse = await request.get('/api/flush'); + expect(flushResponse.status()).toBe(200); + await flushSpanPromise; + + expect(errorReceived).toBe(false); }); test('Does not send 404 errors to Sentry', async ({ request }) => { diff --git a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts index bcfad9b3679b..da1f517acc7c 100644 --- a/packages/nitro/src/runtime/hooks/captureTracingEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureTracingEvents.ts @@ -131,12 +131,6 @@ function setupH3TracingChannels(): void { return span; }, { - captureError: () => ({ - mechanism: { - handled: false, - type: 'auto.http.nitro.onTraceError', - }, - }), beforeSpanEnd(span, data) { applyResponseStatus(span, data); From 020a2d090849012b650ad9c2faa4ffa6b4579366 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 09:24:47 +0200 Subject: [PATCH 34/57] chore(deps): Bump @sentry/conventions to 0.25.0 (#24958) Bumps conventions to 0.25.0 and uses the newly released attribute: - `SENTRY_LINK_TYPE` --------- Co-authored-by: GPT-6 --- dev-packages/browser-integration-tests/package.json | 2 +- .../consistent-sampling/default/test.ts | 5 +++-- .../tracesSampler-precedence/test.ts | 6 +++--- .../linked-traces-streamed/custom-trace/test.ts | 6 +++--- .../linked-traces-streamed/default/test.ts | 6 +++--- .../linked-traces-streamed/interaction-spans/test.ts | 4 ++-- .../linked-traces-streamed/meta/test.ts | 4 ++-- .../negatively-sampled/test.ts | 4 ++-- .../linked-traces-streamed/session-storage/test.ts | 4 ++-- .../consistent-sampling/default/test.ts | 10 +++------- .../linked-traces/custom-trace/test.ts | 6 +++--- .../linked-traces/default/test.ts | 6 +++--- .../linked-traces/interaction-spans/test.ts | 4 ++-- .../linked-traces/meta/test.ts | 4 ++-- .../linked-traces/negatively-sampled/test.ts | 4 ++-- .../linked-traces/session-storage/test.ts | 4 ++-- .../cloudflare-integration-tests/package.json | 2 +- dev-packages/node-integration-tests/package.json | 2 +- packages/angular/package.json | 2 +- packages/astro/package.json | 2 +- packages/aws-serverless/package.json | 2 +- packages/browser-utils/package.json | 2 +- packages/browser/package.json | 2 +- packages/browser/src/tracing/linkedTraces.ts | 5 ++--- packages/bun/package.json | 2 +- packages/cloudflare/package.json | 2 +- packages/cloudflare/src/utils/traceLinks.ts | 5 +++-- packages/core/package.json | 2 +- packages/core/src/semanticAttributes.ts | 1 + packages/core/src/types/link.ts | 3 ++- packages/core/test/lib/utils/spanUtils.test.ts | 12 ++++++------ packages/deno/package.json | 2 +- packages/effect/package.json | 2 +- packages/elysia/package.json | 2 +- packages/ember/package.json | 2 +- packages/google-cloud-serverless/package.json | 2 +- packages/hono/package.json | 2 +- packages/nestjs/package.json | 2 +- packages/nextjs/package.json | 2 +- .../nextjs/src/server/useCacheInstrumentation.ts | 4 ++-- packages/nitro/package.json | 2 +- packages/node/package.json | 2 +- packages/nuxt/package.json | 2 +- packages/opentelemetry/package.json | 2 +- packages/react-router/package.json | 2 +- packages/react/package.json | 2 +- packages/remix/package.json | 2 +- packages/server-utils/package.json | 2 +- packages/solid/package.json | 2 +- packages/solidstart/package.json | 2 +- packages/svelte/package.json | 2 +- packages/sveltekit/package.json | 2 +- packages/tanstackstart-react/package.json | 2 +- packages/vue/package.json | 2 +- yarn.lock | 8 ++++---- 55 files changed, 90 insertions(+), 91 deletions(-) diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index 30ef30f621b4..b89832568047 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -63,7 +63,7 @@ "@sentry/browser": "11.2.0", "@sentry/replay": "11.2.0", "@sentry/opentelemetry": "11.2.0", - "@sentry/conventions": "0.24.0", + "@sentry/conventions": "0.25.0", "@supabase/supabase-js": "2.109.0", "axios": "1.20.0", "babel-loader": "^10.1.1", diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts index 70b6f2c66fd4..3ef7ddefca6d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/default/test.ts @@ -1,5 +1,6 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; -import { extractTraceparentData, parseBaggageHeader, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { shouldSkipTracingTest, waitForTracingHeadersOnUrl } from '../../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpanEnvelope } from '../../../../../../utils/spanUtils'; @@ -66,7 +67,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { span_id: customTraceSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts index 32d62350d6b4..37e3b204fe88 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/consistent-sampling/tracesSampler-precedence/test.ts @@ -1,7 +1,7 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/browser'; import type { ClientReport } from '@sentry/core'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { envelopeRequestParser, @@ -103,7 +103,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { expect(customTrace2Span.links).toEqual([ { attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, @@ -136,7 +136,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { expect(navigationSpan.links).toEqual([ { attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts index 571f4a6d8b5e..7bb7a5546910 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/custom-trace/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -27,7 +27,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, @@ -52,7 +52,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: customTraceSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts index c8d7af8246be..5fb19844a3ce 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/default/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -33,7 +33,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, @@ -52,7 +52,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: navigation1Span.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts index 1e5666e116e8..bfc974fc953a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/interaction-spans/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -52,7 +52,7 @@ sentryTest( span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts index 141e5a9505dc..732547954091 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/meta/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -37,7 +37,7 @@ sentryTest( span_id: pageloadSpan.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts index 8b9b3d044dce..4bc05cb95737 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/negatively-sampled/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -26,7 +26,7 @@ sentryTest('includes a span link to a previously negatively sampled span', async span_id: expect.stringMatching(/[a-f\d]{16}/), sampled: false, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts index 5ed1c8021d4a..81d1d31e10e3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces-streamed/session-storage/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -30,7 +30,7 @@ sentryTest('adds link between hard page reloads when opting into sessionStorage' span_id: pageload1Span.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts index 6787b23e6db8..4d13371bc693 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/consistent-sampling/default/test.ts @@ -1,10 +1,6 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; -import { - extractTraceparentData, - parseBaggageHeader, - SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, -} from '@sentry/core'; +import { extractTraceparentData, parseBaggageHeader, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE } from '@sentry/core'; import { sentryTest } from '../../../../../../utils/fixtures'; import { eventAndTraceHeaderRequestParser, @@ -76,7 +72,7 @@ sentryTest.describe('When `consistentTraceSampling` is `true`', () => { span_id: customTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts index b8d4f43cf03b..c4637dc25018 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/custom-trace/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -31,7 +31,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); @@ -54,7 +54,7 @@ sentryTest('manually started custom traces are linked correctly in the chain', a span_id: customTrace1Context?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts index 96302d731e5e..7399c2bb26c3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/default/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -43,7 +43,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); @@ -58,7 +58,7 @@ sentryTest("navigation spans link back to previous trace's root span", async ({ span_id: navigation1TraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts index f8d01ab4a9d7..24a1e2c59072 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/interaction-spans/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -61,7 +61,7 @@ sentryTest( span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts index 123f44e8db85..a232a5a0f6e7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/meta/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -44,7 +44,7 @@ sentryTest( span_id: pageloadTraceContext?.span_id, sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts index b84aefb8887d..894fe0d5929a 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/negatively-sampled/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -28,7 +28,7 @@ sentryTest('includes a span link to a previously negatively sampled span', async span_id: expect.stringMatching(/[a-f\d]{16}/), sampled: false, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts index 19f34eef73f3..f7e0d288de5b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/linked-traces/session-storage/test.ts @@ -1,5 +1,5 @@ import { expect } from '@playwright/test'; -import { SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE } from '@sentry/core'; +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../../utils/fixtures'; import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../../utils/helpers'; @@ -31,7 +31,7 @@ sentryTest('adds link between hard page reloads when opting into sessionStorage' trace_id: pageload1TraceContext?.trace_id, span_id: pageload1TraceContext?.span_id, sampled: true, - attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace' }, + attributes: { [SENTRY_LINK_TYPE]: 'previous_trace' }, }, ]); diff --git a/dev-packages/cloudflare-integration-tests/package.json b/dev-packages/cloudflare-integration-tests/package.json index 162e0a8136d9..2482616b6aa9 100644 --- a/dev-packages/cloudflare-integration-tests/package.json +++ b/dev-packages/cloudflare-integration-tests/package.json @@ -33,7 +33,7 @@ "@cloudflare/workers-types": "^4.20260426.0", "@cloudflare/workers-types-v5": "npm:@cloudflare/workers-types@5.20260710.1", "@sentry-internal/test-utils": "11.2.0", - "@sentry/conventions": "0.24.0", + "@sentry/conventions": "0.25.0", "eslint-plugin-regexp": "^3.1.0", "prisma": "6.15.0", "vite": "^8.3.1", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index df82e5d1bc93..5cdcc184745e 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -108,7 +108,7 @@ }, "devDependencies": { "@opentelemetry/instrumentation-http": "0.220.0", - "@sentry/conventions": "0.24.0", + "@sentry/conventions": "0.25.0", "@sentry-internal/test-utils": "11.2.0", "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", diff --git a/packages/angular/package.json b/packages/angular/package.json index 284741e566f6..09cfab24f655 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -23,7 +23,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "tslib": "^2.4.1" }, "devDependencies": { diff --git a/packages/astro/package.json b/packages/astro/package.json index 8fa161700399..4da67d9aca52 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -53,7 +53,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", "@sentry/server-utils": "11.2.0", diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 4a23683cbe79..9ff9fe7c4f0f 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -56,7 +56,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", diff --git a/packages/browser-utils/package.json b/packages/browser-utils/package.json index 946dfa09567e..257f4e6cfa8d 100644 --- a/packages/browser-utils/package.json +++ b/packages/browser-utils/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "web-vitals": "^6.1.1" }, "scripts": { diff --git a/packages/browser/package.json b/packages/browser/package.json index a86649cfa040..40b01b363e36 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@sentry/browser-utils": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/feedback": "11.2.0", "@sentry/replay": "11.2.0", "@sentry/replay-canvas": "11.2.0", diff --git a/packages/browser/src/tracing/linkedTraces.ts b/packages/browser/src/tracing/linkedTraces.ts index 178305da0016..741212cf2065 100644 --- a/packages/browser/src/tracing/linkedTraces.ts +++ b/packages/browser/src/tracing/linkedTraces.ts @@ -5,12 +5,11 @@ import { getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_PREVIOUS_TRACE_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanToJSON, } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../exports'; -import { SENTRY_OP } from '@sentry/conventions/attributes'; +import { SENTRY_LINK_TYPE, SENTRY_OP } from '@sentry/conventions/attributes'; export interface PreviousTraceInfo { /** @@ -188,7 +187,7 @@ export function addPreviousTraceSpanLink( span.addLink({ context: previousTraceSpanCtx, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }); diff --git a/packages/bun/package.json b/packages/bun/package.json index a8834c56f014..f21a1d8fa017 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -43,7 +43,7 @@ }, "dependencies": { "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/node": "11.2.0", "@sentry/server-utils": "11.2.0" }, diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 4440768bd111..e7b9cfc28d13 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -53,7 +53,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/opentelemetry": "11.2.0", "@sentry/server-utils": "11.2.0", diff --git a/packages/cloudflare/src/utils/traceLinks.ts b/packages/cloudflare/src/utils/traceLinks.ts index c016243d02c4..805d8058483b 100644 --- a/packages/cloudflare/src/utils/traceLinks.ts +++ b/packages/cloudflare/src/utils/traceLinks.ts @@ -1,7 +1,8 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import type { DurableObjectStorage } from '@cloudflare/workers-types'; import { TraceFlags } from '@opentelemetry/api'; import type { SpanLink } from '@sentry/core'; -import { debug, getActiveSpan, SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanIsSampled } from '@sentry/core'; +import { debug, getActiveSpan, spanIsSampled } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; /** Storage key prefix for the span context that links consecutive method invocations */ @@ -74,7 +75,7 @@ export function buildSpanLinks(storedContext: StoredSpanContext): SpanLink[] { traceFlags: storedContext.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE, }, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ]; diff --git a/packages/core/package.json b/packages/core/package.json index 3524a2a51c11..c67dc3007ccc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -94,6 +94,6 @@ "zod": "^3.24.1" }, "dependencies": { - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" } } diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 40d8e009a293..4c1ce43c42cd 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -116,6 +116,7 @@ export const SEMANTIC_ATTRIBUTE_URL_FULL = 'url.full'; * - `next_trace`: The span links to the frontend root span of the next trace. (Not set by the SDK) * * Other values may be set as appropriate. + * @deprecated Use `SENTRY_LINK_TYPE` from `@sentry/conventions/attributes` instead. * @see https://develop.sentry.dev/sdk/telemetry/traces/span-links/#link-types */ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; diff --git a/packages/core/src/types/link.ts b/packages/core/src/types/link.ts index 9a117258200b..574d7b6337cf 100644 --- a/packages/core/src/types/link.ts +++ b/packages/core/src/types/link.ts @@ -1,10 +1,11 @@ +import type { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import type { SpanAttributeValue, SpanContextData } from './span'; type SpanLinkAttributes = { /** * Setting the link type to 'previous_trace' helps the Sentry product linking to the previous trace */ - 'sentry.link.type'?: string | 'previous_trace'; + [SENTRY_LINK_TYPE]?: string | 'previous_trace'; } & Record; export interface SpanLink { diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index c31d4e432b97..a71b8732f866 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -1,3 +1,4 @@ +import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, test } from 'vitest'; import { convertSpanLinksForEnvelope, @@ -6,7 +7,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, SentryNonRecordingSpan, SentrySpan, setCurrentClient, @@ -498,7 +498,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -598,7 +598,7 @@ describe('spanToStaticSpanJSON', () => { traceFlags: TRACE_FLAG_SAMPLED, }, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -627,7 +627,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -693,7 +693,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: 'previous_trace', + [SENTRY_LINK_TYPE]: 'previous_trace', }, }, ], @@ -722,7 +722,7 @@ describe('spanToStaticSpanJSON', () => { trace_id: 'trace1', sampled: true, attributes: { - [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: { type: 'string', value: 'previous_trace' }, + [SENTRY_LINK_TYPE]: { type: 'string', value: 'previous_trace' }, }, }, ], diff --git a/packages/deno/package.json b/packages/deno/package.json index 854e8bd2d386..57bcedb91703 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -28,7 +28,7 @@ ], "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", "@sentry/server-utils": "11.2.0" diff --git a/packages/effect/package.json b/packages/effect/package.json index 04a532ceba25..b04c7c72e252 100644 --- a/packages/effect/package.json +++ b/packages/effect/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0" }, diff --git a/packages/elysia/package.json b/packages/elysia/package.json index c230bf482f06..7241be71f888 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -34,7 +34,7 @@ "dependencies": { "@sentry/bun": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "elysia": "^1.4.0" diff --git a/packages/ember/package.json b/packages/ember/package.json index 3a916153229e..60a75afb08dc 100644 --- a/packages/ember/package.json +++ b/packages/ember/package.json @@ -45,7 +45,7 @@ "@embroider/addon-shim": "^1.10.2", "@embroider/macros": "^1.20.2", "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "decorator-transforms": "^2.3.2" }, diff --git a/packages/google-cloud-serverless/package.json b/packages/google-cloud-serverless/package.json index 1af87d31b945..02cfe5320b59 100644 --- a/packages/google-cloud-serverless/package.json +++ b/packages/google-cloud-serverless/package.json @@ -36,7 +36,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", diff --git a/packages/hono/package.json b/packages/hono/package.json index 9599e0735040..2b31930606a0 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -74,7 +74,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/server-utils": "11.2.0" }, "peerDependencies": { diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 07baaee4f367..c12ea6bde10c 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -44,7 +44,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-utils": "11.2.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index fb210e6dfac8..2b2177b39a33 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -76,7 +76,7 @@ "@rollup/plugin-commonjs": "28.0.1", "@sentry/browser-utils": "11.2.0", "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/opentelemetry": "11.2.0", diff --git a/packages/nextjs/src/server/useCacheInstrumentation.ts b/packages/nextjs/src/server/useCacheInstrumentation.ts index 399b00722877..ac0a32373790 100644 --- a/packages/nextjs/src/server/useCacheInstrumentation.ts +++ b/packages/nextjs/src/server/useCacheInstrumentation.ts @@ -6,6 +6,7 @@ import { CACHE_OPERATION, CACHE_TAGS, CACHE_TTL, + SENTRY_LINK_TYPE, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT } from '@sentry/conventions/op'; @@ -20,7 +21,6 @@ import { hasSpanStreamingEnabled, hasSpansEnabled, LRUMap, - SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE, spanIsSampled, startSpan, timestampInSeconds, @@ -184,7 +184,7 @@ function linkCacheOrigin(span: Span, originKey: string, entry: unknown): void { if (origin && origin.entryTimestamp === timestamp) { span.addLink({ context: origin.context, - attributes: { [SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, + attributes: { [SENTRY_LINK_TYPE]: CACHE_ORIGIN_LINK_TYPE }, }); } } diff --git a/packages/nitro/package.json b/packages/nitro/package.json index 8b5cb7d83e2c..530b17fd36c3 100644 --- a/packages/nitro/package.json +++ b/packages/nitro/package.json @@ -36,7 +36,7 @@ }, "dependencies": { "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", diff --git a/packages/node/package.json b/packages/node/package.json index d449acfef618..8a7526570a31 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -78,7 +78,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/opentelemetry": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index b8bb5eb1570f..14e100f637ec 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -56,7 +56,7 @@ "@nuxt/kit": "^4.5.2", "@sentry/browser": "11.2.0", "@sentry/cloudflare": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/bundler-plugins": "11.2.0", diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index 7a0462fc93b5..018bd403c64c 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -27,7 +27,7 @@ "access": "public" }, "dependencies": { - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0" }, "peerDependencies": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 92802dc6477c..ac1d3c05160f 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -52,7 +52,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", diff --git a/packages/react/package.json b/packages/react/package.json index 9b0db4e74711..fcc90a1fc7ae 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -59,7 +59,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "react": "17.x || 18.x || 19.x", diff --git a/packages/remix/package.json b/packages/remix/package.json index aa6114626354..cc806185cf02 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -77,7 +77,7 @@ "@remix-run/router": "^1.23.4", "@sentry/browser": "11.2.0", "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index ca5a49171ea6..0b007583bbb7 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -89,7 +89,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0" }, "devDependencies": { diff --git a/packages/solid/package.json b/packages/solid/package.json index 2f351e908b40..9446d62923f9 100644 --- a/packages/solid/package.json +++ b/packages/solid/package.json @@ -56,7 +56,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "@solidjs/router": ">=0.13.4 <2.0.0-0", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 02dd5a5a67c5..4797918df22d 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -87,7 +87,7 @@ "@sentry/server-utils": "11.2.0", "@sentry/solid": "11.2.0", "@sentry/bundler-plugins": "11.2.0", - "@sentry/conventions": "0.24.0" + "@sentry/conventions": "0.25.0" }, "devDependencies": { "@solidjs/router": "^1.0.0", diff --git a/packages/svelte/package.json b/packages/svelte/package.json index 366f38fb4cd1..e9e17c934a0b 100644 --- a/packages/svelte/package.json +++ b/packages/svelte/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@sentry/browser": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "magic-string": "~0.30.0" }, diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index d1d290d618f6..9b7fb5b56bab 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -73,7 +73,7 @@ "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/node": "11.2.0", "@sentry/server-runtime-injection": "11.2.0", "@sentry/server-utils": "11.2.0", diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 9b583fab5387..589380f2cb94 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -53,7 +53,7 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@sentry/browser-utils": "11.2.0", - "@sentry/conventions": "^0.24.0", + "@sentry/conventions": "^0.25.0", "@sentry/core": "11.2.0", "@sentry/node": "11.2.0", "@sentry/react": "11.2.0", diff --git a/packages/vue/package.json b/packages/vue/package.json index 5c370f0d9ef9..aa524f3cd0fd 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -46,7 +46,7 @@ "dependencies": { "@sentry/browser": "11.2.0", "@sentry/core": "11.2.0", - "@sentry/conventions": "^0.24.0" + "@sentry/conventions": "^0.25.0" }, "peerDependencies": { "@tanstack/vue-router": "^1.64.0", diff --git a/yarn.lock b/yarn.lock index 17a92d7ec2ed..dc1e6437f698 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7988,10 +7988,10 @@ "@angular-devkit/schematics" "14.2.13" jsonc-parser "3.1.0" -"@sentry/conventions@0.24.0", "@sentry/conventions@^0.24.0": - version "0.24.0" - resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.24.0.tgz#b65931682a7150ffe7914e34af908b7afb2b87ab" - integrity sha512-pmmF2mLy7A23z8gnLRG87lYu4SSPIBColtfqu9cfkh1omn/0CI3w0/AqZ/uf/VGAtM/wJMN61J58Ks/IWBCHpQ== +"@sentry/conventions@0.25.0", "@sentry/conventions@^0.25.0": + version "0.25.0" + resolved "https://sfw.security.sentry.io/npm/@sentry/conventions/-/conventions-0.25.0.tgz#62c276fe8c608e533a42f0dba2ebc58c62b97b14" + integrity sha512-7SouCAX3qx+AzLsxb6gbl3dKVWNhp2wFjdYQ/T90sGwqPPQUoZVA9YwsWwvx07hqcCiSqFW9mUAzyUftASnuhQ== "@sentry/node-cpu-profiler@^2.4.4": version "2.4.4" From bc5889c963c3c7526acf5dccaa5f8a427e742084 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 09:49:56 +0200 Subject: [PATCH 35/57] ref(core): Use conversation ID constant from conventions (#24966) Uses `GEN_AI_CONVERSATION_ID` from `@sentry/conventions/attributes` in the conversation-ID integration and its tests. Deprecates the existing core constant while preserving its public export and the identical `gen_ai.conversation.id` key. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- .../core/src/integrations/conversationId.ts | 4 ++-- packages/core/src/semanticAttributes.ts | 1 + .../lib/integrations/conversationId.test.ts | 20 +++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/core/src/integrations/conversationId.ts b/packages/core/src/integrations/conversationId.ts index 4cbcded45be5..31b4de5f092c 100644 --- a/packages/core/src/integrations/conversationId.ts +++ b/packages/core/src/integrations/conversationId.ts @@ -1,7 +1,7 @@ +import { GEN_AI_CONVERSATION_ID } from '@sentry/conventions/attributes'; import type { Client } from '../client'; import { getCurrentScope, getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; -import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../semanticAttributes'; import type { IntegrationFn } from '../types/integration'; import type { Span } from '../types/span'; import { spanToStaticSpanJSON } from '../utils/spanUtils'; @@ -29,7 +29,7 @@ const _conversationIdIntegration = (() => { return; } - span.setAttribute(GEN_AI_CONVERSATION_ID_ATTRIBUTE, conversationId); + span.setAttribute(GEN_AI_CONVERSATION_ID, conversationId); } }); }, diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 4c1ce43c42cd..59b90e9ad61a 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -133,5 +133,6 @@ export const SEMANTIC_LINK_ATTRIBUTE_LINK_TYPE = 'sentry.link.type'; * The conversation ID for linking messages across API calls. * For OpenAI Assistants API: thread_id * For LangGraph: configurable.thread_id + * @deprecated Use `GEN_AI_CONVERSATION_ID` from `@sentry/conventions/attributes` instead. */ export const GEN_AI_CONVERSATION_ID_ATTRIBUTE = 'gen_ai.conversation.id'; diff --git a/packages/core/test/lib/integrations/conversationId.test.ts b/packages/core/test/lib/integrations/conversationId.test.ts index 3cd97a2d4b42..fa3bc639be62 100644 --- a/packages/core/test/lib/integrations/conversationId.test.ts +++ b/packages/core/test/lib/integrations/conversationId.test.ts @@ -1,7 +1,7 @@ +import { GEN_AI_CONVERSATION_ID } from '@sentry/conventions/attributes'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getCurrentScope, getIsolationScope, setCurrentClient, startSpan } from '../../../src'; import { conversationIdIntegration } from '../../../src/integrations/conversationId'; -import { GEN_AI_CONVERSATION_ID_ATTRIBUTE } from '../../../src/semanticAttributes'; import { spanToStaticSpanJSON } from '../../../src/utils/spanUtils'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; @@ -28,7 +28,7 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_test_123'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_test_123'); }); }); @@ -37,7 +37,7 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_isolation_456'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_isolation_456'); }); }); @@ -47,14 +47,14 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_current_789'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_current_789'); }); }); it('does not apply conversation ID when not set in scope', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBeUndefined(); }); }); @@ -64,7 +64,7 @@ describe('ConversationId', () => { startSpan({ name: 'test-span', op: 'gen_ai.chat' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBeUndefined(); }); }); @@ -74,7 +74,7 @@ describe('ConversationId', () => { startSpan({ name: 'parent-span', op: 'gen_ai.invoke_agent' }, () => { startSpan({ name: 'child-span', op: 'gen_ai.chat' }, childSpan => { const childJSON = spanToStaticSpanJSON(childSpan); - expect(childJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_nested_abc'); + expect(childJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_nested_abc'); }); }); }); @@ -87,12 +87,12 @@ describe('ConversationId', () => { name: 'test-span', op: 'gen_ai.chat', attributes: { - [GEN_AI_CONVERSATION_ID_ATTRIBUTE]: 'conv_explicit', + [GEN_AI_CONVERSATION_ID]: 'conv_explicit', }, }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBe('conv_from_scope'); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBe('conv_from_scope'); }, ); }); @@ -102,7 +102,7 @@ describe('ConversationId', () => { startSpan({ name: 'db-query', op: 'db.query' }, span => { const spanJSON = spanToStaticSpanJSON(span); - expect(spanJSON.data[GEN_AI_CONVERSATION_ID_ATTRIBUTE]).toBeUndefined(); + expect(spanJSON.data[GEN_AI_CONVERSATION_ID]).toBeUndefined(); }); }); }); From b470acad32b3bb239c8f8d6735ede45abd50bccf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:51:09 +0200 Subject: [PATCH 36/57] chore(deps-dev): bump @vercel/nft from 1.5.0 to 1.11.0 (#24954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@vercel/nft](https://github.com/vercel/nft) from 1.5.0 to 1.11.0.
Release notes

Sourced from @​vercel/nft's releases.

1.11.0

1.11.0 (2026-08-18)

Features

  • resolve package import wildcard trailers (#604) (7d6e6e4)

1.10.2

1.10.2 (2026-05-28)

Bug Fixes

1.10.0

1.10.0 (2026-05-27)

Features

1.9.0

1.9.0 (2026-05-27)

Features

Commits
  • 7d6e6e4 feat: resolve package import wildcard trailers (#604)
  • c997b4d chore: Updating vm2 package to latest version (#593)
  • 8cc0067 chore(deps): Bump the npm_and_yarn group across 1 directory with 3 updates (#...
  • 3528b76 chore: upgrade sharp to latest v0.35.3 (#594)
  • 282d1f3 chore(deps): Bump the npm_and_yarn group across 1 directory with 3 updates (#...
  • 4e32836 chore(ci): disable phantomjs-prebuilt postinstall that breaks CI installs (#597)
  • 8673d63 fix: publish using node@24 (#591)
  • 0ceef3f fix: lock semantic-release publish dependencies (#590)
  • e2f2185 feat: add module-sync catchall tracing (#584)
  • 6bc496f feat: remove badge from readme (#589)
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for @​vercel/nft since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@vercel/nft&package-manager=npm_and_yarn&previous-version=1.5.0&new-version=1.11.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore 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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/aws-serverless/package.json | 2 +- packages/server-runtime-injection/package.json | 2 +- yarn.lock | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 9ff9fe7c4f0f..f90a28d73af8 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -65,7 +65,7 @@ }, "devDependencies": { "@types/node": "^18.19.1", - "@vercel/nft": "^1.3.0" + "@vercel/nft": "^1.11.0" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/server-runtime-injection/package.json b/packages/server-runtime-injection/package.json index 969f1ab79ccf..a60a35c2280b 100644 --- a/packages/server-runtime-injection/package.json +++ b/packages/server-runtime-injection/package.json @@ -52,7 +52,7 @@ "@apm-js-collab/code-transformer": "^0.18.1", "@apm-js-collab/tracing-hooks": "^0.13.3", "@types/node": "^18.19.1", - "@vercel/nft": "^1.3.0", + "@vercel/nft": "^1.11.0", "meriyah": "^6.1.4" }, "scripts": { diff --git a/yarn.lock b/yarn.lock index dc1e6437f698..f5b509c4e72d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9986,10 +9986,10 @@ hookable "^6.0.1" unhead "2.1.17" -"@vercel/nft@^1.3.0", "@vercel/nft@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@vercel/nft/-/nft-1.5.0.tgz#37dbededa3023d9e44d1292b594786ab1653536f" - integrity sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA== +"@vercel/nft@^1.11.0", "@vercel/nft@^1.5.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@vercel/nft/-/nft-1.11.0.tgz#ed2db36a90f54087f36c2d8aff9e91e289cdb3c0" + integrity sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA== dependencies: "@mapbox/node-pre-gyp" "^2.0.0" "@rollup/pluginutils" "^5.1.3" @@ -10001,7 +10001,7 @@ glob "^13.0.0" graceful-fs "^4.2.9" node-gyp-build "^4.2.2" - picomatch "^4.0.2" + picomatch "^4.0.4" resolve-from "^5.0.0" "@vercel/oidc@3.2.0": From 4ff3673adf0791e84037f45bcb6ae56cd22781b5 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:53:23 +0200 Subject: [PATCH 37/57] chore(deps): dev dependency security fixes (#24275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Batched **dev** dependency security fixes. One commit per vulnerability. ### Fixes - `next` 15.5.21 → 15.5.24 — GHSA-p293-qw3h-jr36 (CVE-2026-75604) (critical) — https://github.com/getsentry/sentry-javascript/security/dependabot/2526 - `next` 15.5.21 → 15.5.24 — GHSA-2xp9-vwfh-vxw4 (critical) — https://github.com/getsentry/sentry-javascript/security/dependabot/2527 - `astro` 7.2.4 → 7.2.8 — GHSA-26w7-cxv4-gfx2 (critical) — https://github.com/getsentry/sentry-javascript/security/dependabot/2524 Both `next` advisories are patched by the same release (15.5.24), so they share one commit. The `next` bump covers the `packages/nextjs` dev dependency (plus the root `yarn.lock`) and the four Next 15 e2e test apps. For `nextjs-15-basepath` and `nextjs-15-t3` the existing carets (`^15`, `^15.5.13`) already resolved to a patched version at install time, but the range floors were raised to `^15.5.24` so the manifests themselves are no longer in the advisory range. `packages/nextjs`'s `next` **peerDependency** range (`^14.0 || ^15.0.0-rc.0 || ^16.0.0-0`) is deliberately left untouched — `yarn-update-dependency` rewrites it to a single pinned range, which would silently drop Next 14 and 16 support for SDK users. Only the dev dependency was bumped. No `resolutions` entries were added. ### Skipped — needs human - `astro` in `packages/astro` (dev dependency `^4.16.19`) — 7.2.8 is the only patched release, so fixing requires a major bump 4 → 7 — https://github.com/getsentry/sentry-javascript/security/dependabot/2524 - `astro` in the `astro-4`, `astro-5`, `astro-5-cf-workers`, `astro-6` and `astro-6-cf-workers` e2e apps — each app exists to test that specific major; the only patched release is 7.2.8, so the fix would mean bumping away from the major under test — https://github.com/getsentry/sentry-javascript/security/dependabot/2524 - `next` in the `nextjs-14`, `nextjs-app-dir`, `nextjs-pages-dir`, `supabase-nextjs` and `nextjs-orpc` e2e apps (pinned `14.2.35`) — the patched release is 15.5.24, so a major bump 14 → 15 would be needed and these apps exist to test Next 14 — https://github.com/getsentry/sentry-javascript/security/dependabot/2526, https://github.com/getsentry/sentry-javascript/security/dependabot/2527 These are all dev/CI-only manifests, so the remaining exposure is confined to CI and never reaches shipped SDK code. They likely want a dismissal (`tolerable_risk`) rather than a bump, but that call is left to a human. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .../e2e-tests/test-applications/astro-7-static/package.json | 2 +- dev-packages/e2e-tests/test-applications/astro-7/package.json | 2 +- .../e2e-tests/test-applications/nextjs-15-basepath/package.json | 2 +- .../e2e-tests/test-applications/nextjs-15-intl/package.json | 2 +- .../e2e-tests/test-applications/nextjs-15-t3/package.json | 2 +- dev-packages/e2e-tests/test-applications/nextjs-15/package.json | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json index 35c051bee12d..ce0452bf7e37 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-static/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-static/package.json @@ -19,7 +19,7 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", - "astro": "^7.2.4", + "astro": "^7.2.8", "ioredis": "5.10.1", "mysql": "^2.18.1" }, diff --git a/dev-packages/e2e-tests/test-applications/astro-7/package.json b/dev-packages/e2e-tests/test-applications/astro-7/package.json index acd09a130916..33126242d1f6 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -19,7 +19,7 @@ "@playwright/test": "~1.63.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", - "astro": "^7.2.4", + "astro": "^7.2.8", "ioredis": "5.10.1", "mysql": "^2.18.1" }, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json index 5f574018e1e0..87d584397cd9 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-basepath/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "^15", + "next": "^15.5.24", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json index f3bd1c6360f6..32b395b278a5 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -15,7 +15,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.5.21", + "next": "15.5.24", "next-intl": "^4.3.12", "react": "latest", "react-dom": "latest", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json index b03150a518a4..6c8a74f4b432 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/package.json @@ -20,7 +20,7 @@ "@trpc/client": "~11.8.0", "@trpc/react-query": "~11.8.0", "@trpc/server": "~11.8.0", - "next": "^15.5.13", + "next": "^15.5.24", "react": "^19.1.0", "react-dom": "^19.1.0", "server-only": "^0.0.1", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 1ca1e3f1e6ed..f6013d99cd95 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -20,7 +20,7 @@ "@types/react": "18.0.26", "@types/react-dom": "18.0.9", "ai": "^3.0.0", - "next": "15.5.21", + "next": "15.5.24", "react": "latest", "react-dom": "latest", "typescript": "~5.0.0", From 4ceab6cbcd3621fc928bd71d82be21db6ca93341 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 10:04:02 +0200 Subject: [PATCH 38/57] ref(core): Use status message constant from conventions (#24967) Uses `SENTRY_STATUS_MESSAGE` from `@sentry/conventions/attributes` in span serialization and existing constant-based tests. Deprecates the existing core constant while preserving its public export and the identical `sentry.status.message` key. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- .../public-api/startSpan/streamed/test.ts | 4 +- packages/core/src/semanticAttributes.ts | 1 + packages/core/src/utils/spanUtils.ts | 7 ++- packages/core/test/lib/tracing/errors.test.ts | 13 ++---- .../core/test/lib/tracing/idleSpan.test.ts | 43 +++++-------------- .../core/test/lib/tracing/spanstatus.test.ts | 7 +-- .../core/test/lib/utils/spanUtils.test.ts | 21 +++++---- 7 files changed, 35 insertions(+), 61 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index 62d8bd9c6366..31cc5c7d3853 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -6,7 +6,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -19,6 +18,7 @@ import { SENTRY_SDK_VERSION, SENTRY_TRACE_LIFECYCLE, USER_AGENT_ORIGINAL, + SENTRY_STATUS_MESSAGE, } from '@sentry/conventions/attributes'; sentryTest( @@ -195,7 +195,7 @@ sentryTest( type: 'string', value: 'production', }, - [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: { + [SENTRY_STATUS_MESSAGE]: { type: 'string', value: 'Connection Refused', }, diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 59b90e9ad61a..8ba796d0ce1b 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -31,6 +31,7 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin'; * Streamed (v2) span statuses are reduced to `ok`/`error`, so we preserve the * message as an attribute instead of dropping it. This mirrors the attribute * Sentry's OTLP ingestion uses for the same purpose. + * @deprecated Use `SENTRY_STATUS_MESSAGE` from `@sentry/conventions/attributes` instead. */ export const SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE = 'sentry.status.message'; diff --git a/packages/core/src/utils/spanUtils.ts b/packages/core/src/utils/spanUtils.ts index 9fe769198f45..2bf38cdb5864 100644 --- a/packages/core/src/utils/spanUtils.ts +++ b/packages/core/src/utils/spanUtils.ts @@ -5,12 +5,11 @@ import { serializeAttributes } from '../attributes'; import { getMainCarrier } from '../carrier'; import { getCurrentScope } from '../currentScopes'; import type { Scope } from '../scope'; -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, } from '../semanticAttributes'; import type { SentrySpan } from '../tracing/sentrySpan'; import { isStatusErrorMessageValid, SPAN_STATUS_OK, SPAN_STATUS_UNSET } from '../tracing/spanstatus'; @@ -346,7 +345,7 @@ export function getSimpleStatus(status: SpanStatus | undefined): 'ok' | 'error' } /** - * Returns the span's attributes with the SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE attribute added + * Returns the span's attributes with the SENTRY_STATUS_MESSAGE attribute added * if the span has an error status message worth preserving. * * An explicitly set attribute is never overwritten. @@ -357,7 +356,7 @@ export function addStatusMessageAttribute( ): RawAttributes> { const statusMessage = getSimpleStatus(status) === 'error' ? status?.message : undefined; return { - ...(statusMessage && { [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: statusMessage }), + ...(statusMessage && { [SENTRY_STATUS_MESSAGE]: statusMessage }), ...attributes, }; } diff --git a/packages/core/test/lib/tracing/errors.test.ts b/packages/core/test/lib/tracing/errors.test.ts index 97afa005410d..b09bb3132732 100644 --- a/packages/core/test/lib/tracing/errors.test.ts +++ b/packages/core/test/lib/tracing/errors.test.ts @@ -1,11 +1,6 @@ +import { SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, - setCurrentClient, - spanToJSON, - startInactiveSpan, - startSpan, -} from '../../../src'; +import { setCurrentClient, spanToJSON, startInactiveSpan, startSpan } from '../../../src'; import * as globalErrorModule from '../../../src/instrument/globalError'; import * as globalUnhandledRejectionModule from '../../../src/instrument/globalUnhandledRejection'; import { _resetErrorsInstrumented, registerSpanErrorInstrumentation } from '../../../src/tracing/errors'; @@ -67,7 +62,7 @@ describe('registerErrorHandlers()', () => { mockErrorCallback({} as HandlerDataError); const { status, attributes } = spanToJSON(span); expect(status).toBe('error'); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('internal_error'); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); }); }); @@ -78,7 +73,7 @@ describe('registerErrorHandlers()', () => { mockUnhandledRejectionCallback({}); const { status, attributes } = spanToJSON(span); expect(status).toBe('error'); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('internal_error'); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); }); }); }); diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index 83f72e45c42f..d6c49bfb097b 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -8,7 +8,6 @@ import { getIsolationScope, getTraceData, SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, SentryNonRecordingSpan, SentrySpan, setCurrentClient, @@ -23,7 +22,7 @@ import type { Event } from '../../../src/types/event'; import type { Span } from '../../../src/types/span'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; import { resetGlobals } from '../../testutils'; -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; const dsn = 'https://123@sentry.io/42'; @@ -677,23 +676,17 @@ describe('startIdleSpan', () => { // Start any span to cancel idle timeout startInactiveSpan({ name: 'span' }); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait some time vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait for timeout to exceed vi.advanceTimersByTime(1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeDefined(); }); @@ -704,41 +697,31 @@ describe('startIdleSpan', () => { // Start any span to cancel idle timeout startInactiveSpan({ name: 'span' }); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait some time vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // New span resets the timeout startInactiveSpan({ name: 'span' }); vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // New span resets the timeout startInactiveSpan({ name: 'span' }); vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // Wait for timeout to exceed vi.advanceTimersByTime(1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeDefined(); }); @@ -751,9 +734,7 @@ describe('startIdleSpan', () => { // Wait some time vi.advanceTimersByTime(TRACING_DEFAULTS.childSpanTimeout - 1000); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeUndefined(); // new standalone span should not reset the timeout @@ -762,9 +743,7 @@ describe('startIdleSpan', () => { // Wait for timeout to exceed vi.advanceTimersByTime(1001); - expect(spanToJSON(idleSpan).attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).not.toEqual( - 'deadline_exceeded', - ); + expect(spanToJSON(idleSpan).attributes[SENTRY_STATUS_MESSAGE]).not.toEqual('deadline_exceeded'); expect(spanToJSON(idleSpan).end_timestamp).toBeDefined(); }); }); diff --git a/packages/core/test/lib/tracing/spanstatus.test.ts b/packages/core/test/lib/tracing/spanstatus.test.ts index 9d37b033a654..3abb6ee84619 100644 --- a/packages/core/test/lib/tracing/spanstatus.test.ts +++ b/packages/core/test/lib/tracing/spanstatus.test.ts @@ -1,5 +1,6 @@ +import { SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { describe, expect, it } from 'vitest'; -import { SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, SentrySpan, setHttpStatus, spanToJSON } from '../../../src/index'; +import { SentrySpan, setHttpStatus, spanToJSON } from '../../../src/index'; describe('setHttpStatus', () => { it.each([ @@ -24,7 +25,7 @@ describe('setHttpStatus', () => { const { status: spanStatus, attributes } = spanToJSON(span); expect(spanStatus).toBe(status); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe(statusMessage); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe(statusMessage); expect(attributes).toMatchObject({ 'http.response.status_code': code }); }); @@ -36,7 +37,7 @@ describe('setHttpStatus', () => { const { status: spanStatus, attributes } = spanToJSON(span); expect(spanStatus).toBe('error'); - expect(attributes[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('internal_error'); + expect(attributes[SENTRY_STATUS_MESSAGE]).toBe('internal_error'); expect(attributes).toMatchObject({ 'http.response.status_code': 600 }); }); }); diff --git a/packages/core/test/lib/utils/spanUtils.test.ts b/packages/core/test/lib/utils/spanUtils.test.ts index a71b8732f866..e8ae4379b86f 100644 --- a/packages/core/test/lib/utils/spanUtils.test.ts +++ b/packages/core/test/lib/utils/spanUtils.test.ts @@ -1,4 +1,4 @@ -import { SENTRY_LINK_TYPE } from '@sentry/conventions/attributes'; +import { SENTRY_LINK_TYPE, SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; import { beforeEach, describe, expect, it, test } from 'vitest'; import { convertSpanLinksForEnvelope, @@ -6,7 +6,6 @@ import { Scope, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE, SentryNonRecordingSpan, SentrySpan, setCurrentClient, @@ -510,7 +509,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('error'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); }); it('does not set a status message for ok spans', () => { @@ -519,7 +518,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('ok'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); it('does not set a status message for error spans without a message', () => { @@ -528,7 +527,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('error'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); it('treats a cancelled status as ok and does not set a status message', () => { @@ -537,18 +536,18 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('ok'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); it('does not overwrite an explicitly set sentry.status.message attribute', () => { const span = new SentrySpan({ name: 'test name', - attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: 'explicit message' }, + attributes: { [SENTRY_STATUS_MESSAGE]: 'explicit message' }, }); span.setStatus({ code: SPAN_STATUS_ERROR, message: 'Connection Refused' }); const json = spanToJSON(span); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('explicit message'); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBe('explicit message'); }); }); describe('OpenTelemetry Span', () => { @@ -619,7 +618,7 @@ describe('spanToStaticSpanJSON', () => { attr2: 2, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'test op', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto', - [SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]: 'unknown_error', + [SENTRY_STATUS_MESSAGE]: 'unknown_error', }, links: [ { @@ -647,7 +646,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('error'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBe('Connection Refused'); }); it('does not set a status message for ok/unset spans', () => { @@ -663,7 +662,7 @@ describe('spanToStaticSpanJSON', () => { const json = spanToJSON(span); expect(json.status).toBe('ok'); - expect(json.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE]).toBeUndefined(); + expect(json.attributes?.[SENTRY_STATUS_MESSAGE]).toBeUndefined(); }); }); }); From 7932813028bbe6c3e660bf704a8c3089f4c6f14e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 2 Oct 2026 10:32:50 +0200 Subject: [PATCH 39/57] feat(remix): Run the orchestrion transform on Remix 3 browser modules (#24894) Remix 3 has no bundler, so the build time plugin the other SDKs use has nothing to attach to. The asset server compiles each module on request through a loader chain, and that is the only compile time hook the browser has. This adds a loader there that applies orchestrion's transform, and patches `run` in `@remix-run/ui` so component render errors are captured with no application code. `@sentry/server-utils` gains a `load-hook` entry for callers with a file path and its source and no bundler in between. It is bundled like the webpack loader, so `@sentry/remix` does not need the transformer as a dependency. The transform imports the browser `diagnostics_channel` shim by public URL, encoded the way the asset server encodes imports and listed in `scripts.external`. A mismatched URL would load two shims and no instrumentation. The shim is now an explicit rollup entry, because nothing in the module graph uses its default export and rollup dropped it. Fixes #24677 Co-authored-by: Claude Fable 5.1 --- .../remix-v3/app/actions/public/entry.ts | 4 - .../remix-v3/tests/browser-transform.test.ts | 20 +++ packages/remix/rollup.npm.config.mjs | 2 + packages/remix/src/v3/assetServer.ts | 41 ++++++- .../src/v3/client/diagnosticsChannelShim.ts | 10 +- packages/remix/src/v3/orchestrionLoader.ts | 114 ++++++++++++++++++ packages/remix/test/v3-exports.test.ts | 9 ++ packages/remix/test/v3/assetServer.test.ts | 82 ++++++++++++- .../test/v3/diagnosticsChannelShim.test.ts | 18 +++ .../remix/test/v3/orchestrionLoader.test.ts | 22 ++++ packages/server-utils/package.json | 8 ++ packages/server-utils/rollup.npm.config.mjs | 1 + .../src/orchestrion/bundler/load-hook.ts | 41 +++++++ .../src/orchestrion/config/index.ts | 5 +- .../src/orchestrion/config/remix-v3.ts | 8 ++ .../test/orchestrion/load-hook.test.ts | 46 +++++++ 16 files changed, 414 insertions(+), 17 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/browser-transform.test.ts create mode 100644 packages/remix/src/v3/orchestrionLoader.ts create mode 100644 packages/remix/test/v3/diagnosticsChannelShim.test.ts create mode 100644 packages/remix/test/v3/orchestrionLoader.test.ts create mode 100644 packages/server-utils/src/orchestrion/bundler/load-hook.ts create mode 100644 packages/server-utils/test/orchestrion/load-hook.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts index d9b03fe3d561..c18e0f0a8967 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts +++ b/dev-packages/e2e-tests/test-applications/remix-v3/app/actions/public/entry.ts @@ -20,10 +20,6 @@ export const app = run({ }, }); -// The runtime sends a render error to the event target `run()` returns and does not rethrow, so -// `window.onerror` never sees it. This listener is the only way the SDK learns about it. -Sentry.captureRuntimeErrors(app); - function Boom(): () => never { return () => { throw new Error('Component render failed'); diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/browser-transform.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/browser-transform.test.ts new file mode 100644 index 000000000000..08b73e2ceaad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/browser-transform.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test'; + +test('the served remix/ui runtime is instrumented', async ({ page, baseURL }) => { + const served: string[] = []; + page.on('response', response => { + if (response.request().resourceType() === 'script') { + served.push(response.url()); + } + }); + + await page.goto('/', { waitUntil: 'load' }); + + const runModule = served.find(url => /@remix-run\/ui\/dist\/runtime\/run\.js/.test(decodeURIComponent(url))); + expect(runModule, 'run.js was not served').toBeDefined(); + + const code = await (await fetch(runModule as string)).text(); + expect(code).toMatch(/diagnosticsChannelShim\.js/); + // A CommonJS `require` here would mean the transform emitted the wrong module type. + expect(code).not.toMatch(/\brequire\(/); +}); diff --git a/packages/remix/rollup.npm.config.mjs b/packages/remix/rollup.npm.config.mjs index 0abd34e17818..40b06766961c 100644 --- a/packages/remix/rollup.npm.config.mjs +++ b/packages/remix/rollup.npm.config.mjs @@ -63,6 +63,8 @@ export default [ 'src/vite/index.ts', 'src/v3/index.server.ts', 'src/v3/index.client.ts', + // An explicit entry keeps its default export, which only orchestrion's injected import uses. + 'src/v3/client/diagnosticsChannelShim.ts', ], packageSpecificConfig: { external: ['react-router', 'react-router-dom', 'react', 'react/jsx-runtime'], diff --git a/packages/remix/src/v3/assetServer.ts b/packages/remix/src/v3/assetServer.ts index 3e7580854f64..103692d34db2 100644 --- a/packages/remix/src/v3/assetServer.ts +++ b/packages/remix/src/v3/assetServer.ts @@ -2,6 +2,7 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { consoleSandbox } from '@sentry/core'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; import { addDebugIdToSourceMap, findDebugId, getDebugId, injectDebugIdSnippet } from './debugId'; +import { getShimUrl, isOrchestrionLoader, orchestrionLoader, resolveShimPath } from './orchestrionLoader'; // The subset of `@remix-run/assets` types used here. `remix` is an optional peer dependency, so // they are restated rather than imported. @@ -23,10 +24,14 @@ type ModuleLoader = ( ) => ModuleLoadResult; interface AssetServerOptions { + allowPackages?: readonly string[]; + basePath?: string; + rootDir?: string; + mounts?: Readonly>; // `false` is not in Remix's type, but is how an app tells Sentry it wants no source maps at all, // since leaving the option out now means hidden source maps. sourceMaps?: 'inline' | 'external' | false; - scripts?: { loaders?: readonly ModuleLoader[]; [key: string]: unknown }; + scripts?: { loaders?: readonly ModuleLoader[]; external?: readonly string[]; [key: string]: unknown }; [key: string]: unknown; } @@ -44,6 +49,7 @@ interface CreateAssetServerContext { const SOURCE_MAPPING_URL_REGEX = /\n\/\/# sourceMappingURL=\S+\s*$/; const MAX_STAMPED_BODIES = 2000; +const SDK_PACKAGE = '@sentry/remix'; let instrumented = false; @@ -127,16 +133,43 @@ function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServe }); } - const loaders = options.scripts?.loaders ?? []; + // No allowed packages means no `node_modules` module is served, so nothing to transform. Otherwise + // the shim must be allowed too, or its injected import 404s and takes the client runtime down. + const servesPackages = (options.allowPackages?.length ?? 0) > 0; + const allowPackages = + servesPackages && !options.allowPackages?.includes(SDK_PACKAGE) + ? [...(options.allowPackages ?? []), SDK_PACKAGE] + : options.allowPackages; + + const shimUrl = servesPackages + ? getShimUrl(options.basePath ?? '/', options.rootDir ?? process.cwd(), options.mounts, resolveShimPath()) + : undefined; + if (servesPackages && shimUrl === undefined) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + "[Sentry] The browser channel shim is outside the asset server's node_modules mount, so browser modules are served without instrumentation. Component render errors need `captureRuntimeErrors(app)`.", + ); + }); + } + const loaders = (options.scripts?.loaders ?? []).filter( + loader => loader !== debugIdLoader && !isOrchestrionLoader(loader), + ); + const external = (options.scripts?.external ?? []).filter(entry => entry !== shimUrl); return { ...options, + ...(allowPackages !== options.allowPackages && { allowPackages }), // Hidden unless the app chose otherwise, see `stampServedAssets`. sourceMaps: options.sourceMaps ?? 'external', scripts: { ...options.scripts, - // Last, so the ID also covers whatever the app's own loaders changed. - loaders: loaders.includes(debugIdLoader) ? loaders : [...loaders, debugIdLoader], + // The transform first, the debug ID last, so the ID covers the transformed module. + loaders: + shimUrl === undefined ? [...loaders, debugIdLoader] : [...loaders, orchestrionLoader(shimUrl), debugIdLoader], + // Kept as a URL by the compiler. A bare specifier would not resolve from inside an instrumented + // package under pnpm. + external: shimUrl === undefined ? external : [shimUrl, ...external], }, }; } diff --git a/packages/remix/src/v3/client/diagnosticsChannelShim.ts b/packages/remix/src/v3/client/diagnosticsChannelShim.ts index 410447e8054d..bd3b415bb456 100644 --- a/packages/remix/src/v3/client/diagnosticsChannelShim.ts +++ b/packages/remix/src/v3/client/diagnosticsChannelShim.ts @@ -75,7 +75,15 @@ function createChannel(): Channel { }; } -const registry = new Map(); +declare global { + // `var`, because only a `var` here becomes a property of `globalThis`. + // oxlint-disable-next-line no-var + var __SENTRY_REMIX_DC_REGISTRY__: Map | undefined; +} + +// On `globalThis`, so a second copy of this module loaded under another URL shares the subscribers +// instead of splitting them. Keyed by the channel names the transform compiles in, so it stays small. +const registry = (globalThis.__SENTRY_REMIX_DC_REGISTRY__ ??= new Map()); /** Create (or look up) a tracing channel by name. */ export function tracingChannel(name: string): BrowserTracingChannel { diff --git a/packages/remix/src/v3/orchestrionLoader.ts b/packages/remix/src/v3/orchestrionLoader.ts new file mode 100644 index 000000000000..9cf2255fc986 --- /dev/null +++ b/packages/remix/src/v3/orchestrionLoader.ts @@ -0,0 +1,114 @@ +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createLoadHookTransform } from '@sentry/server-utils/orchestrion/load-hook'; +import { remixV3Config } from '@sentry/server-utils/orchestrion/config'; + +/** Node's synchronous `load` hook shape, which `@remix-run/assets` reuses for `scripts.loaders`. */ +export interface ModuleLoadContext { + moduleUrl?: string; + [key: string]: unknown; +} + +export interface ModuleLoadResult { + format: string | null | undefined; + shortCircuit?: boolean; + source?: string | ArrayBuffer | ArrayBufferView; +} + +export type ModuleLoader = ( + url: string, + context: ModuleLoadContext, + nextLoad: (url: string, context?: Partial) => ModuleLoadResult, +) => ModuleLoadResult; + +/** + * An asset server loader that applies orchestrion's transform to browser modules. Remix 3 has no + * bundler, so the asset server's loader chain is the only compile time hook there is. + * + * `dcModuleUrl`, the shim's public URL, must also be in `scripts.external`, so the compiler leaves + * the injected import alone. + */ +export function orchestrionLoader(dcModuleUrl: string): ModuleLoader { + const cached = loaders.get(dcModuleUrl); + if (cached) { + return cached; + } + + // Only the Remix 3 configs. The others target server packages, and their injected snippet imports + // `@sentry/server-utils`, which cannot resolve in a browser. + const transform = createLoadHookTransform({ dcModule: dcModuleUrl, instrumentations: remixV3Config }); + + const loader: ModuleLoader = (url, context, nextLoad) => { + const result = nextLoad(url, context); + if (result.format !== 'module' || typeof result.source !== 'string') { + return result; + } + + // An uninstrumented module is better than one the asset server cannot serve at all. + try { + const transformed = transform(fileURLToPath(url), result.source); + return transformed === undefined ? result : { ...result, source: transformed }; + } catch { + return result; + } + }; + + loaders.set(dcModuleUrl, loader); + return loader; +} + +// One loader per shim URL, so options that pass through `withDebugIdOptions` twice keep one copy. +const loaders = new Map(); + +export function isOrchestrionLoader(loader: unknown): boolean { + for (const known of loaders.values()) { + if (known === loader) { + return true; + } + } + return false; +} + +/** + * Public URL of the shipped shim under the asset server's `node_modules` mount, encoded per segment as + * the asset server encodes imports. `@sentry` and `%40sentry` are different modules to a browser; a + * mismatch loads two shims and instrumentation quietly does nothing. + */ +export function getShimUrl( + basePath: string, + rootDir: string, + mounts: Readonly> | undefined, + shimPath: string, +): string | undefined { + const nodeModulesRoot = path.join(rootDir, 'node_modules'); + const relativePath = path.relative(nodeModulesRoot, shimPath); + // Outside the mount, as with a `link:` install or another Windows drive, the asset server cannot + // serve it. Checked before splitting: a drive prefix is only absolute as part of the whole path. + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + return undefined; + } + const encoded = relativePath + .split(path.sep) + .map(segment => encodeURIComponent(segment)) + .join('/'); + return `${basePath.replace(/\/$/, '')}/${npmMount(mounts)}/${encoded}`; +} + +// The default mounts are `{ app: 'app', npm: 'node_modules' }`, but an app can rename them. +function npmMount(mounts: Readonly> | undefined): string { + const entry = mounts && Object.entries(mounts).find(([, dir]) => dir === 'node_modules' || dir === './node_modules'); + return entry ? entry[0] : 'npm'; +} + +// The shim is built next to this module, so its path follows from this module's own under any +// install layout. A package self reference would not work: the `./v3` subpaths are import only. +export function resolveShimPath(): string { + let here: string; + /*! rollup-include-cjs-only */ + here = __filename; + /*! rollup-include-cjs-only-end */ + /*! rollup-include-esm-only */ + here = fileURLToPath(import.meta.url); + /*! rollup-include-esm-only-end */ + return path.join(path.dirname(here), 'client', 'diagnosticsChannelShim.js'); +} diff --git a/packages/remix/test/v3-exports.test.ts b/packages/remix/test/v3-exports.test.ts index 96e2c033ae76..a549611f10b8 100644 --- a/packages/remix/test/v3-exports.test.ts +++ b/packages/remix/test/v3-exports.test.ts @@ -63,4 +63,13 @@ describe('Remix 3 exports', () => { expect(packageJson.peerDependenciesMeta, `${name} should be optional`).toHaveProperty([name, 'optional'], true); } }); + + it('ships the browser channel shim with the default export the transform imports', () => { + // Only the explicit rollup entry keeps the default export the injected import needs. Losing it + // fails silently: the build stays green and the browser gets no instrumentation. + const shim = fs.readFileSync(path.join(packageRoot, 'build/esm/v3/client/diagnosticsChannelShim.js'), 'utf8'); + + // Either spelling rollup may pick for a default export. + expect(shim).toMatch(/^export (?:default |\{[^}]*\bas default\b)/m); + }); }); diff --git a/packages/remix/test/v3/assetServer.test.ts b/packages/remix/test/v3/assetServer.test.ts index 7a57c53e2df0..f7f7aa7eda36 100644 --- a/packages/remix/test/v3/assetServer.test.ts +++ b/packages/remix/test/v3/assetServer.test.ts @@ -1,7 +1,27 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; import { beforeAll, describe, expect, it, vi } from 'vitest'; +import * as path from 'node:path'; import { debugIdLoader, instrumentAssetServer } from '../../src/v3/assetServer'; +import type * as OrchestrionLoader from '../../src/v3/orchestrionLoader'; + +// In the repo the shim sits in `src/`, which no asset server mount could serve. Place it where an +// installed copy would be, so the URL logic runs the way it does in an app. +vi.mock('../../src/v3/orchestrionLoader', async importOriginal => ({ + ...(await importOriginal()), + resolveShimPath: () => + path.join( + process.cwd(), + 'node_modules', + '@sentry', + 'remix', + 'build', + 'esm', + 'v3', + 'client', + 'diagnosticsChannelShim.js', + ), +})); import { getDebugId, getDebugIdSnippet } from '../../src/v3/debugId'; type Options = Record; @@ -55,11 +75,31 @@ describe('instrumentAssetServer', () => { warn.mockRestore(); }); - it('adds the debug ID loader after the app loaders', () => { + it('adds the transform and then the debug ID loader after the app loaders', () => { const appLoader = vi.fn(); - const { receivedOptions } = createAssetServer({ scripts: { loaders: [appLoader], define: { a: 'b' } } }); + const { receivedOptions } = createAssetServer({ + allowPackages: ['remix'], + scripts: { loaders: [appLoader], define: { a: 'b' } }, + }); + + expect(receivedOptions.scripts).toEqual({ + loaders: [appLoader, expect.any(Function), debugIdLoader], + define: { a: 'b' }, + external: [expect.stringContaining('/client/diagnosticsChannelShim.js')], + }); + }); + + it('points the transform at the shim by an encoded public URL under the node_modules mount', () => { + const { receivedOptions } = createAssetServer({ + allowPackages: ['remix'], + basePath: '/assets', + mounts: { app: 'app', deps: 'node_modules' }, + }); - expect(receivedOptions.scripts).toEqual({ loaders: [appLoader, debugIdLoader], define: { a: 'b' } }); + const [shimUrl] = receivedOptions.scripts.external; + expect(shimUrl).toMatch(/^\/assets\/deps\//); + // `@` must arrive as `%40`: the browser treats the two spellings as different modules. + expect(shimUrl).not.toContain('@'); }); it('does not mutate the options the app passed', () => { @@ -69,11 +109,41 @@ describe('instrumentAssetServer', () => { expect(options).toEqual({ basePath: '/assets' }); }); - it('adds the loader once when the same options are reused', () => { - const first = createAssetServer({}).receivedOptions; - const { receivedOptions } = createAssetServer(first); + it('serves modules uninstrumented, with a warning, when the shim is outside the node_modules mount', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // A rootDir the shim cannot be under, as with a `link:` install. + const { receivedOptions } = createAssetServer({ + allowPackages: ['remix'], + basePath: '/assets', + rootDir: '/nowhere/near', + }); + + expect(receivedOptions.scripts.loaders).toEqual([debugIdLoader]); + expect(receivedOptions.scripts.external).toEqual([]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('outside the asset server')); + warn.mockRestore(); + }); + + it('allows the SDK package, so the injected shim import can be served', () => { + const { receivedOptions } = createAssetServer({ allowPackages: ['remix'] }); + expect(receivedOptions.allowPackages).toEqual(['remix', '@sentry/remix']); + }); + + it('injects nothing when the app serves no packages, since no module could import the shim', () => { + const { receivedOptions } = createAssetServer({ basePath: '/assets' }); + + expect(receivedOptions.allowPackages).toBeUndefined(); expect(receivedOptions.scripts.loaders).toEqual([debugIdLoader]); + expect(receivedOptions.scripts.external).toEqual([]); + }); + + it('adds each loader once when the same options are reused', () => { + const first = createAssetServer({ allowPackages: ['remix'] }).receivedOptions; + const { receivedOptions } = createAssetServer(first); + + expect(receivedOptions.scripts.loaders).toEqual([expect.any(Function), debugIdLoader]); + expect(receivedOptions.scripts.external).toHaveLength(1); }); }); diff --git a/packages/remix/test/v3/diagnosticsChannelShim.test.ts b/packages/remix/test/v3/diagnosticsChannelShim.test.ts new file mode 100644 index 000000000000..73b305197b1a --- /dev/null +++ b/packages/remix/test/v3/diagnosticsChannelShim.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from 'vitest'; + +describe('diagnosticsChannelShim', () => { + it('shares one registry between two loaded copies of the module', async () => { + const first = await import('../../src/v3/client/diagnosticsChannelShim'); + const seen: unknown[] = []; + first.tracingChannel('orchestrion:test:run').subscribe({ end: context => void seen.push(context) }); + + // A second module instance, as a browser gets when the same file is loaded under two URLs. + vi.resetModules(); + const second = await import('../../src/v3/client/diagnosticsChannelShim'); + expect(second.tracingChannel).not.toBe(first.tracingChannel); + + second.tracingChannel('orchestrion:test:run').end.publish({ result: 1 }); + + expect(seen).toEqual([{ result: 1 }]); + }); +}); diff --git a/packages/remix/test/v3/orchestrionLoader.test.ts b/packages/remix/test/v3/orchestrionLoader.test.ts new file mode 100644 index 000000000000..b076cad0a18b --- /dev/null +++ b/packages/remix/test/v3/orchestrionLoader.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { orchestrionLoader } from '../../src/v3/orchestrionLoader'; + +describe('orchestrionLoader', () => { + const loader = orchestrionLoader('/assets/npm/shim.js'); + const compiled = { format: 'module', source: 'export const a = 1;' }; + + it('serves a module it cannot locate on disk unchanged, rather than failing the compile', () => { + // Not a `file:` URL, so there is no path to read a package name from. + expect(loader('data:text/javascript,export%20const%20a=1', {}, () => compiled)).toBe(compiled); + }); + + it('serves a module outside node_modules unchanged', () => { + expect(loader('file:///app/entry.ts', {}, () => compiled)).toBe(compiled); + }); + + it('leaves non module results alone', () => { + const css = { format: 'css', source: 'a{}' }; + expect(loader('file:///app/a.css', {}, () => css)).toBe(css); + }); +}); diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 0b007583bbb7..3f4d3bf245d4 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -51,6 +51,11 @@ "import": "./build/esm/orchestrion/bundler/webpack-loader.js", "require": "./build/cjs/orchestrion/bundler/webpack-loader.js" }, + "./orchestrion/load-hook": { + "types": "./build/types/orchestrion/bundler/load-hook.d.ts", + "import": "./build/esm/orchestrion/bundler/load-hook.js", + "require": "./build/cjs/orchestrion/bundler/load-hook.js" + }, "./orchestrion/esbuild": { "types": "./build/types/orchestrion/bundler/esbuild.d.ts", "import": "./build/esm/orchestrion/bundler/esbuild.js", @@ -81,6 +86,9 @@ ], "orchestrion/bun": [ "build/types/orchestrion/bundler/bun.d.ts" + ], + "orchestrion/load-hook": [ + "build/types/orchestrion/bundler/load-hook.d.ts" ] } }, diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index c313a9f1cc4c..20bfde7d0216 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -103,6 +103,7 @@ export default [ 'src/orchestrion/bundler/rollup.ts', 'src/orchestrion/bundler/webpack.ts', 'src/orchestrion/bundler/webpack-loader.ts', + 'src/orchestrion/bundler/load-hook.ts', 'src/orchestrion/bundler/esbuild.ts', 'src/orchestrion/bundler/bun.ts', ], diff --git a/packages/server-utils/src/orchestrion/bundler/load-hook.ts b/packages/server-utils/src/orchestrion/bundler/load-hook.ts new file mode 100644 index 000000000000..7473254eb9d3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/load-hook.ts @@ -0,0 +1,41 @@ +// The transform for a caller with a file path and its source and no bundler in between. Bundled like +// the webpack loader: the `@apm-js-collab` packages are devDependencies, not resolvable on user installs. +import { createCodeTransformer } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; +import type { InstrumentationConfig } from '../apmTypes'; + +export interface LoadHookTransformOptions { + /** + * Module specifier the transformed code imports its `tracingChannel` from, in place of + * `node:diagnostics_channel`. In a browser this is a URL the module loader can fetch. + */ + dcModule: string; + /** Defaults to every built in instrumentation. */ + instrumentations?: InstrumentationConfig[]; +} + +/** + * Instruments `code` when `filePath` is in an instrumented package, read from the nearest + * `package.json`. Returns `undefined` when it is not, or the transform fails, so the caller serves the + * module as is. + */ +export function createLoadHookTransform( + options: LoadHookTransformOptions, +): (filePath: string, code: string) => string | undefined { + const { transform } = createCodeTransformer({ + instrumentations: options.instrumentations ?? SENTRY_INSTRUMENTATIONS, + dcModule: options.dcModule, + }); + + return (filePath, code) => { + // Only third party modules carry instrumentation configs. + if (!/[\\/]node_modules[\\/]/.test(filePath)) { + return undefined; + } + try { + return transform(code, filePath)?.code; + } catch { + return undefined; + } + }; +} diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index 9ed93c98c0bf..f8dded8a6a6d 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -195,5 +195,6 @@ export function withoutInstrumentedExternals( export { nestjsChannels } from './nestjs'; // This is exported so that the remix package can use it to subscribe to the channels. export { remixChannels } from './remix'; -// This is exported so the remix package can subscribe to the Remix 3 channels. -export { remixV3Channels } from './remix-v3'; +// Exported so the remix package can subscribe to the Remix 3 channels, and hand only these configs to +// the browser transform. +export { remixV3Channels, remixV3Config } from './remix-v3'; diff --git a/packages/server-utils/src/orchestrion/config/remix-v3.ts b/packages/server-utils/src/orchestrion/config/remix-v3.ts index 51ed8a14adef..8dfc20daf5b5 100644 --- a/packages/server-utils/src/orchestrion/config/remix-v3.ts +++ b/packages/server-utils/src/orchestrion/config/remix-v3.ts @@ -36,10 +36,18 @@ export const remixV3Config: InstrumentationConfig[] = [ }, functionQuery: { functionName: 'createRequestListener', kind: 'Sync' }, }, + // Browser side, applied by the asset server's loader chain. The only way to see component render + // errors without application code. + { + channelName: 'run', + module: { name: '@remix-run/ui', versionRange: '>=0.8.0 <1', filePath: 'dist/runtime/run.js' }, + functionQuery: { functionName: 'run', kind: 'Sync' }, + }, ]; export const remixV3Channels = { REMIX_V3_CREATE_ROUTER: 'orchestrion:@remix-run/fetch-router:createRouter', REMIX_V3_CREATE_ASSET_SERVER: 'orchestrion:@remix-run/assets:createAssetServer', REMIX_V3_CREATE_REQUEST_LISTENER: 'orchestrion:@remix-run/node-fetch-server:createRequestListener', + REMIX_V3_UI_RUN: 'orchestrion:@remix-run/ui:run', } as const; diff --git a/packages/server-utils/test/orchestrion/load-hook.test.ts b/packages/server-utils/test/orchestrion/load-hook.test.ts new file mode 100644 index 000000000000..6a4d9af73ba6 --- /dev/null +++ b/packages/server-utils/test/orchestrion/load-hook.test.ts @@ -0,0 +1,46 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { remixV3Config } from '../../src/orchestrion/config'; +import { createLoadHookTransform } from '../../src/orchestrion/bundler/load-hook'; + +// The transform reads the package name and version from disk, so the fixture is a real, minimal +// install layout in a temp directory. +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'load-hook-')); +const pkgDir = path.join(root, 'node_modules', '@remix-run', 'ui'); +fs.mkdirSync(path.join(pkgDir, 'dist', 'runtime'), { recursive: true }); +fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@remix-run/ui', version: '0.11.0' })); +const runFile = path.join(pkgDir, 'dist', 'runtime', 'run.js'); +const source = 'export function run(init) {\n return init;\n}\n'; +fs.writeFileSync(runFile, source); + +afterAll(() => fs.rmSync(root, { recursive: true, force: true })); + +describe('createLoadHookTransform', () => { + const transform = createLoadHookTransform({ dcModule: '/assets/npm/shim.js', instrumentations: remixV3Config }); + + it('instruments a matching module and imports the channel from dcModule', () => { + const code = transform(runFile, source); + + expect(code).toMatch(/from ["']\/assets\/npm\/shim\.js["']/); + expect(code).toContain('orchestrion:@remix-run/ui:run'); + // Emitted as an ES module, which is the only thing the asset server can serve. + expect(code).not.toMatch(/\brequire\(/); + }); + + it('leaves a file that is not in an instrumented package alone', () => { + const other = path.join(root, 'node_modules', 'left-pad', 'index.js'); + fs.mkdirSync(path.dirname(other), { recursive: true }); + fs.writeFileSync( + path.join(path.dirname(other), 'package.json'), + JSON.stringify({ name: 'left-pad', version: '1.0.0' }), + ); + + expect(transform(other, 'export const x = 1;')).toBeUndefined(); + }); + + it('leaves application code alone', () => { + expect(transform(path.join(root, 'app', 'entry.js'), source)).toBeUndefined(); + }); +}); From 69de761196b5b0435f21cc63bf9d643b1953f7c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:32:14 +0000 Subject: [PATCH 40/57] feat(deps): bump devalue from 5.9.2 to 5.9.4 (#24964) Bumps [devalue](https://github.com/sveltejs/devalue) from 5.9.2 to 5.9.4.
Release notes

Sourced from devalue's releases.

v5.9.4

Patch Changes

  • 067b125: perf: annotate module-level Object.freeze calls as pure so unused operation tables tree-shake

v5.9.3

Patch Changes

  • 6861dbb: fix: avoid scanning sparse array holes in uneval traversal and shared-array population
  • 9ec5130: fix: reject non-string null-prototype object keys in parse and unflatten to prevent bypassing the __proto__ check
  • dae8153: fix: prevent unhandled internal rejections in stringifyAsync when serializing multiple promises
  • 84f6f67: fix: prevent quadratic uneval output expansion for repeated strings and bigints
  • 6861dbb: fix: avoid eager allocation when evaluating sparse arrays emitted by uneval
  • 8f8d78e: fix: validate revived backing buffers before constructing typed arrays
  • 46dc877: fix: serialize only the visible bytes of Node Buffers in stringify, stringifyAsync and uneval, preventing disclosure of unrelated data from their shared allocation pool
Changelog

Sourced from devalue's changelog.

5.9.4

Patch Changes

  • 067b125: perf: annotate module-level Object.freeze calls as pure so unused operation tables tree-shake

5.9.3

Patch Changes

  • 6861dbb: fix: avoid scanning sparse array holes in uneval traversal and shared-array population
  • 9ec5130: fix: reject non-string null-prototype object keys in parse and unflatten to prevent bypassing the __proto__ check
  • dae8153: fix: prevent unhandled internal rejections in stringifyAsync when serializing multiple promises
  • 84f6f67: fix: prevent quadratic uneval output expansion for repeated strings and bigints
  • 6861dbb: fix: avoid eager allocation when evaluating sparse arrays emitted by uneval
  • 8f8d78e: fix: validate revived backing buffers before constructing typed arrays
  • 46dc877: fix: serialize only the visible bytes of Node Buffers in stringify, stringifyAsync and uneval, preventing disclosure of unrelated data from their shared allocation pool
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=devalue&package-manager=npm_and_yarn&previous-version=5.9.2&new-version=5.9.4)](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 f5b509c4e72d..ef776d7290d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13915,9 +13915,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.2" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.2.tgz#2a3a8ad21904c6a630bf7bb160acb7ca2fa1e467" - integrity sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w== + version "5.9.4" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.9.4.tgz#f6ccb639568f50233936c8c48b92a690cfb22c34" + integrity sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg== devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" From b9290e98be3d4b9b5ed53ab800ea2b5930e4fde9 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:33:33 +0000 Subject: [PATCH 41/57] chore(size-limit): weekly auto-bump (#24969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Size limit auto-bump | Entry | Old limit | New limit | Δ | | --- | --- | --- | --- | | @sentry/browser (incl. Tracing, Replay) | 96 KB | 97 KB | +1 KB | | @sentry/browser (incl. Tracing, Replay) - with treeshaking flags | 85 KB | 86 KB | +1 KB | | @sentry/browser (incl. Feedback) | 52 KB | 53 KB | +1 KB | | @sentry/react | 36 KB | 37 KB | +1 KB | | @sentry/vue | 42 KB | 43 KB | +1 KB | | @sentry/vue (incl. Tracing) | 59 KB | 60 KB | +1 KB | | @sentry/remix (Remix 3 client bundle) | 56 KB | 61 KB | +5 KB | | CDN Bundle | 36 KB | 37 KB | +1 KB | | CDN Bundle (incl. Replay, Logs, Metrics) | 79 KB | 80 KB | +1 KB | | CDN Bundle - uncompressed | 97 KB | 98 KB | +1 KB | | CDN Bundle (incl. Tracing) - uncompressed | 159 KB | 160 KB | +1 KB | | CDN Bundle (incl. Logs, Metrics) - uncompressed | 103 KB | 104 KB | +1 KB | | CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed | 165 KB | 166 KB | +1 KB | | CDN Bundle (incl. Replay, Logs, Metrics) - uncompressed | 233 KB | 234 KB | +1 KB | | CDN Bundle (incl. Tracing, Replay) - uncompressed | 279 KB | 280 KB | +1 KB | | CDN Bundle (incl. Tracing, Replay, Logs, Metrics) - uncompressed | 285 KB | 286 KB | +1 KB | | @sentry/nextjs (client) | 61 KB | 62 KB | +1 KB | | @sentry/core/server | 45 KB | 46 KB | +1 KB | | @sentry/node | 145 KB | 150 KB | +5 KB | | @sentry/node/import (ESM hook with diagnostics-channel injection) | 88 KB | 89 KB | +1 KB | | @sentry/node - without tracing | 96 KB | 99 KB | +3 KB | | @sentry/node - without channel injection | 123 KB | 128 KB | +5 KB | | @sentry/aws-serverless | 104 KB | 107 KB | +3 KB | | @sentry/cloudflare (withSentry) - minified | 208 KiB | 209 KiB | +1 KiB | | @sentry/cloudflare (withSentry) | 507 KiB | 512 KiB | +5 KiB | Co-authored-by: chargome <20254395+chargome@users.noreply.github.com> --- .size-limit.js | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index b5f7ee9fc0f1..220fa9c349e8 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -89,7 +89,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '96 KB', + limit: '97 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -97,7 +97,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration'), gzip: true, - limit: '85 KB', + limit: '86 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { const webpack = require('webpack'); @@ -137,7 +137,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/prod/index.js', import: createImport('init', 'feedbackIntegration'), gzip: true, - limit: '52 KB', + limit: '53 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -187,7 +187,7 @@ module.exports = [ import: createImport('init', 'ErrorBoundary'), ignore: ['react/jsx-runtime'], gzip: true, - limit: '36 KB', + limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -205,7 +205,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '42 KB', + limit: '43 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -213,7 +213,7 @@ module.exports = [ path: 'packages/vue/build/esm/index.js', import: createImport('init', 'browserTracingIntegration'), gzip: true, - limit: '59 KB', + limit: '60 KB', disablePlugins: ['@size-limit/esbuild'], }, // Svelte SDK (ESM) @@ -235,7 +235,7 @@ module.exports = [ path: 'packages/remix/build/esm/v3/client-bundle.js', import: '*', gzip: true, - limit: '56 KB', + limit: '61 KB', disablePlugins: ['@size-limit/esbuild'], }, // Browser CDN bundles @@ -243,7 +243,7 @@ module.exports = [ name: 'CDN Bundle', path: createCDNPath('bundle.min.js'), gzip: true, - limit: '36 KB', + limit: '37 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -271,7 +271,7 @@ module.exports = [ name: 'CDN Bundle (incl. Replay, Logs, Metrics)', path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: true, - limit: '79 KB', + limit: '80 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -308,7 +308,7 @@ module.exports = [ path: createCDNPath('bundle.min.js'), gzip: false, brotli: false, - limit: '97 KB', + limit: '98 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -316,7 +316,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.min.js'), gzip: false, brotli: false, - limit: '159 KB', + limit: '160 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -324,7 +324,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '103 KB', + limit: '104 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -332,7 +332,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '165 KB', + limit: '166 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -340,7 +340,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '233 KB', + limit: '234 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -348,7 +348,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.min.js'), gzip: false, brotli: false, - limit: '279 KB', + limit: '280 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -356,7 +356,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '285 KB', + limit: '286 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -382,7 +382,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '61 KB', + limit: '62 KB', disablePlugins: ['@size-limit/esbuild'], }, // SvelteKit SDK (ESM) @@ -401,7 +401,7 @@ module.exports = [ path: 'packages/core/build/esm/server.js', import: '*', gzip: true, - limit: '45 KB', + limit: '46 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -419,7 +419,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '145 KB', + limit: '150 KB', disablePlugins: ['@size-limit/esbuild'], }, { @@ -427,7 +427,7 @@ module.exports = [ path: ['packages/server-runtime-injection/build/esm/hook.js', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '88 KB', + limit: '89 KB', disablePlugins: ['@size-limit/esbuild'], modifyWebpackConfig: function (config) { // Both packages declare `sideEffects: false`, which lets webpack @@ -443,7 +443,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'), gzip: true, - limit: '96 KB', + limit: '99 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -465,7 +465,7 @@ module.exports = [ path: 'packages/node/build/esm/index.js', import: createImport('init'), gzip: true, - limit: '123 KB', + limit: '128 KB', disablePlugins: ['@size-limit/esbuild'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], modifyWebpackConfig: function (config) { @@ -486,7 +486,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '104 KB', + limit: '107 KB', disablePlugins: ['@size-limit/esbuild'], }, // Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output @@ -497,7 +497,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '208 KiB', + limit: '209 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { @@ -517,7 +517,7 @@ module.exports = [ ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: false, brotli: false, - limit: '507 KiB', + limit: '512 KiB', disablePlugins: ['@size-limit/webpack'], webpack: false, modifyEsbuildConfig: function (config) { From 8198603c7895656af00606f31f6020ce14206d74 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 11:43:13 +0200 Subject: [PATCH 42/57] ref(core): Use idle span finish reason constant from conventions (#24970) Uses `SENTRY_IDLE_SPAN_FINISH_REASON` from `@sentry/conventions/attributes` in idle-span handling and existing constant-based tests. Deprecates the existing core constant while preserving its public export and the identical `sentry.idle_span_finish_reason` key. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- .../reportPageLoaded-streamed/default/test.ts | 5 ++-- .../suites/tracing/interactions/spans/test.ts | 4 +-- packages/core/src/semanticAttributes.ts | 5 +++- packages/core/src/tracing/idleSpan.ts | 6 ++-- .../core/test/lib/tracing/idleSpan.test.ts | 29 +++++++------------ 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts index 706166ef0dc4..3d3d4e69069c 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/reportPageLoaded-streamed/default/test.ts @@ -1,11 +1,10 @@ -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_IDLE_SPAN_FINISH_REASON, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { expect } from '@playwright/test'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, } from '@sentry/browser'; -import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/core'; import { sentryTest } from '../../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../../utils/helpers'; import { getSpanOp, waitForStreamedSpan } from '../../../../../utils/spanUtils'; @@ -30,7 +29,7 @@ sentryTest( [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: expect.objectContaining({ value: 1 }), [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'url' }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'pageload' }, - [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, + [SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'reportPageLoaded' }, }); // We wait for 2.5 seconds before calling Sentry.reportPageLoaded(). The span starts at navigation start, diff --git a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts index 0f6c7f4d772c..d0d3231f622d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts @@ -2,12 +2,12 @@ import { expect } from '@playwright/test'; import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { + SENTRY_IDLE_SPAN_FINISH_REASON, SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_SEGMENT_NAME_SOURCE, @@ -76,7 +76,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: '/index.html', }, - [SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]: { + [SENTRY_IDLE_SPAN_FINISH_REASON]: { type: 'string', value: 'idleTimeout', }, diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 8ba796d0ce1b..b67f1f74cd6f 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -35,7 +35,10 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin'; */ export const SEMANTIC_ATTRIBUTE_SENTRY_STATUS_MESSAGE = 'sentry.status.message'; -/** The reason why an idle span finished. */ +/** + * The reason why an idle span finished. + * @deprecated Use `SENTRY_IDLE_SPAN_FINISH_REASON` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON = 'sentry.idle_span_finish_reason'; /** The unit of a measurement, which may be stored as a TimedEvent. */ diff --git a/packages/core/src/tracing/idleSpan.ts b/packages/core/src/tracing/idleSpan.ts index c837b9dcccc6..32e23ee357ba 100644 --- a/packages/core/src/tracing/idleSpan.ts +++ b/packages/core/src/tracing/idleSpan.ts @@ -1,6 +1,6 @@ +import { SENTRY_IDLE_SPAN_FINISH_REASON } from '@sentry/conventions/attributes'; import { getClient, getCurrentScope, getIsolationScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON } from '../semanticAttributes'; import type { Span } from '../types/span'; import type { StartSpanOptions } from '../types/startSpanOptions'; import { debug } from '../utils/debug-logger'; @@ -303,8 +303,8 @@ export function startIdleSpan(startSpanOptions: StartSpanOptions, options: Parti } const attributes = spanJSON.data; - if (!attributes[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]) { - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason); + if (!attributes[SENTRY_IDLE_SPAN_FINISH_REASON]) { + span.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, _finishReason); } // Set span status to 'ok' if it hasn't been explicitly set to an error status diff --git a/packages/core/test/lib/tracing/idleSpan.test.ts b/packages/core/test/lib/tracing/idleSpan.test.ts index d6c49bfb097b..3809db713668 100644 --- a/packages/core/test/lib/tracing/idleSpan.test.ts +++ b/packages/core/test/lib/tracing/idleSpan.test.ts @@ -7,7 +7,6 @@ import { getDynamicSamplingContextFromSpan, getIsolationScope, getTraceData, - SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, SentryNonRecordingSpan, SentrySpan, setCurrentClient, @@ -22,7 +21,11 @@ import type { Event } from '../../../src/types/event'; import type { Span } from '../../../src/types/span'; import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; import { resetGlobals } from '../../testutils'; -import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_STATUS_MESSAGE } from '@sentry/conventions/attributes'; +import { + SENTRY_IDLE_SPAN_FINISH_REASON, + SENTRY_SEGMENT_NAME_SOURCE, + SENTRY_STATUS_MESSAGE, +} from '@sentry/conventions/attributes'; const dsn = 'https://123@sentry.io/42'; @@ -517,9 +520,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'externalFinish', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('externalFinish'); }); it('sets finish reason when span ends', () => { @@ -538,9 +539,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'idleTimeout', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('idleTimeout'); }); it('sets finish reason when span ends via expired heartbeat timeout', () => { @@ -559,9 +558,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'heartbeatFailed', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('heartbeatFailed'); }); it('sets finish reason when span ends via final timeout', () => { @@ -589,9 +586,7 @@ describe('startIdleSpan', () => { vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'finalTimeout', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('finalTimeout'); }); it('uses finish reason set outside when span ends', () => { @@ -606,14 +601,12 @@ describe('startIdleSpan', () => { client.init(); const span = startIdleSpan({ name: 'foo' }); - span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON, 'custom reason'); + span.setAttribute(SENTRY_IDLE_SPAN_FINISH_REASON, 'custom reason'); startSpan({ name: 'inner' }, () => {}); vi.runOnlyPendingTimers(); expect(beforeSendTransaction).toHaveBeenCalledTimes(1); - expect(transaction?.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual( - 'custom reason', - ); + expect(transaction?.contexts?.trace?.data?.[SENTRY_IDLE_SPAN_FINISH_REASON]).toEqual('custom reason'); }); describe('idleTimeout', () => { From 425a304da030cdeeb9e6e1eba7a83fdcee328b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Fri, 2 Oct 2026 12:51:39 +0300 Subject: [PATCH 43/57] fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages (#24866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in `@sentry/react-router`, found by running the `react-router-8-framework` e2e suite on workerd and Bun. Cloudflare builds resolve with both the `worker` and the `browser` condition (the Vite plugin sets `workerd, worker, module, browser`, `wrangler` sets `workerd, worker, browser`), and the exports map listed `browser` before `worker`, so `worker` was never reached. `import * as Sentry from '@sentry/react-router'` in a route got the client build, and `Sentry.startSpan()` / `Sentry.flush()` compiled to `undefined`: loaders and middleware that call them threw `TypeError: (void 0) is not a function`. A `workerd` key now comes first and resolves the Cloudflare entry. Only Cloudflare sets `workerd`, so nothing else changes: moving `worker` above `browser` would also have switched webpack builds with `target: 'webworker'`, which set `worker` and `browser`, to the Cloudflare entry. On Cloudflare, `react-router build` prerenders pages by running them through the wrapped Worker, so `injectTraceMetaTags` wrote a build-time `sentry-trace` and `baggage` into the static HTML. Every visitor of a prerendered page then continued that one trace with its sampling decision. Such a path never reaches the handler in production (it is served as a static file), so a render of a path from the build's `prerender` list is the build-time prerender, and it now gets no meta tags. For that, the server build capture also records `prerender`, and `wrapSentryHandleRequest` registers the capture: on Cloudflare only the Node server integration did that before, so the build was never captured there. An index route has no `path` of its own, so `wrapSentryHandleRequest` never gave it a route name. On Node, Express names the span first and hid this. On runtimes without that layer (Bun, and a React Router app on any non-Express server) the index page kept a URL-sourced name, so its baggage had no `sentry-transaction`. An index route now takes its nearest ancestor's path, or `/`. On Node this changes the transaction of an index page from Express's catch-all `GET *` to `GET /`, which is why `react-router-7-framework-static` now expects that name. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../performance/trace-propagation.test.ts | 2 +- packages/react-router/package.json | 5 +++ packages/react-router/src/cloudflare/index.ts | 5 +++ .../react-router/src/server/serverBuild.ts | 31 ++++++++++++++- .../src/server/wrapSentryHandleRequest.ts | 13 ++++++- .../src/vite/makeServerBuildCapturePlugin.ts | 5 ++- .../test/server/serverBuild.test.ts | 35 ++++++++++++++++- .../server/wrapSentryHandleRequest.test.ts | 39 +++++++++++++++++++ .../vite/makeServerBuildCapturePlugin.test.ts | 2 +- 9 files changed, 128 insertions(+), 9 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts index b62e784ff3e3..bcdb5e2f34fa 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts @@ -19,7 +19,7 @@ test.describe('Trace propagation', () => { test('should have trace connection', async ({ page }) => { const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET *'; + return transactionEvent.transaction === 'GET /'; }); const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ac1d3c05160f..4c3263e2b579 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -20,6 +20,11 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.types.d.ts", + "workerd": { + "import": "./build/esm/cloudflare/index.js", + "require": "./build/cjs/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" + }, "browser": { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index e1e349306043..67db950b59fb 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -1,4 +1,5 @@ import { getTraceMetaTags } from '@sentry/core'; +import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; @@ -11,6 +12,10 @@ export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; * @returns A new ReadableStream with Sentry trace meta tags injected into the head section */ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { + if (isPrerenderRequest()) { + return body; + } + const headClosingTag = ''; const reader = body.getReader(); diff --git a/packages/react-router/src/server/serverBuild.ts b/packages/react-router/src/server/serverBuild.ts index 3b46c745b4b7..1dd606f3ed1c 100644 --- a/packages/react-router/src/server/serverBuild.ts +++ b/packages/react-router/src/server/serverBuild.ts @@ -1,7 +1,7 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ, parseStringToURLObject } from '@sentry/core'; /** - * Subset of ServerBuild shape for middleware name lookup. + * Subset of ServerBuild shape for middleware name lookup and prerender detection. * The official React Router types don't expose `middleware` on route modules yet. * @internal */ @@ -14,6 +14,8 @@ interface ServerBuildLike { }; } >; + /** The paths React Router prerenders to static HTML at build time. */ + prerender?: string[]; } /** @internal */ @@ -53,6 +55,31 @@ export function getMiddlewareName(routeId: string, index: number): string | unde return middlewareFn?.name || undefined; } +function withoutTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + +/** + * Whether the current request renders a path from the build's `prerender` list. + * + * In production these paths are served as static files and never reach the request handler, so a + * render of one is the build-time prerender. Trace meta tags must not be written into that HTML: + * every visitor of the static page would continue the same trace. + * + * @internal + */ +export function isPrerenderRequest(): boolean { + const prerender = _serverBuild?.prerender; + if (!prerender?.length) return false; + + const url = getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url; + const pathname = url ? parseStringToURLObject(url)?.pathname : undefined; + if (!pathname) return false; + + const requestPath = withoutTrailingSlash(pathname); + return prerender.some(path => withoutTrailingSlash(path) === requestPath); +} + /** @internal */ export function registerServerBuildGlobal(): void { (GLOBAL_OBJ as GlobalObjWithBuildCapture)[GLOBAL_KEY] = setServerBuild; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 8a4af55ce62e..f40d56cad735 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -8,6 +8,7 @@ import { } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; +import { registerServerBuildGlobal } from './serverBuild'; import { isInstrumentationApiUsed } from './serverGlobals'; type OriginalHandleRequestWithoutMiddleware = ( @@ -53,6 +54,11 @@ export function wrapSentryHandleRequest( export function wrapSentryHandleRequest( originalHandle: OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware, ): OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware { + // `entry.server` is evaluated before the server build module, so the build's capture call at the + // end of that module finds this. Runtimes without the Node server integration (Cloudflare) only + // register it here. + registerServerBuildGlobal(); + return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, @@ -60,8 +66,11 @@ export function wrapSentryHandleRequest( routerContext: EntryContext, loadContext: AppLoadContext | RouterContextProvider, ) { - const parameterizedPath = - routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; + const matches = routerContext?.staticHandlerContext?.matches; + // An index route has no `path` of its own and renders at its nearest ancestor's path, or at `/`. + const parameterizedPath = matches?.length + ? ([...matches].reverse().find(match => match.route.path)?.route.path ?? '/') + : undefined; const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; diff --git a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts index b22fc715d782..8811dd13780a 100644 --- a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts +++ b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts @@ -23,10 +23,11 @@ export function makeServerBuildCapturePlugin(): Plugin { return null; } - // `routes` is a module-scope export in the virtual:react-router/server-build module + // `routes` and `prerender` are module-scope exports in the virtual:react-router/server-build + // module. `prerender` is guarded with `typeof` because older React Router versions don't declare it. const injectedCode = `${code} if (typeof globalThis !== 'undefined' && typeof globalThis["${GLOBAL_KEY}"] === 'function') { - globalThis["${GLOBAL_KEY}"]({ routes }); + globalThis["${GLOBAL_KEY}"]({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined }); } `; diff --git a/packages/react-router/test/server/serverBuild.test.ts b/packages/react-router/test/server/serverBuild.test.ts index 80eb7e2028b7..d18f9d40f799 100644 --- a/packages/react-router/test/server/serverBuild.test.ts +++ b/packages/react-router/test/server/serverBuild.test.ts @@ -1,8 +1,9 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { _resetServerBuild, getMiddlewareName, + isPrerenderRequest, isServerBuildLike, registerServerBuildGlobal, setServerBuild, @@ -61,6 +62,38 @@ describe('serverBuild', () => { }); }); + describe('isPrerenderRequest', () => { + afterEach(() => { + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: undefined }); + }); + + it('is true when the current request path is in the prerender list, with or without a trailing slash', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static', '/about/'] }); + + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static/' }, + }); + expect(isPrerenderRequest()).toBe(true); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/about' } }); + expect(isPrerenderRequest()).toBe(true); + }); + + it('is false for other paths, without a prerender list, or without a request', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static'] }); + expect(isPrerenderRequest()).toBe(false); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/performance' } }); + expect(isPrerenderRequest()).toBe(false); + + setServerBuild({ routes: {} }); + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static' }, + }); + expect(isPrerenderRequest()).toBe(false); + }); + }); + describe('registerServerBuildGlobal', () => { it('should register a global callback that calls setServerBuild', () => { registerServerBuildGlobal(); diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index c48bedef8181..6fa1f2ef1026 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -74,6 +74,45 @@ describe('wrapSentryHandleRequest', () => { }); }); + test('should name an index route after its nearest ancestor path, or `/`', async () => { + const mockRootSpan = { setAttributes: vi.fn() }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue({}); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + + const wrappedHandler = wrapSentryHandleRequest(vi.fn().mockResolvedValue('test')); + + const rootIndexContext = { + staticHandlerContext: { matches: [{ route: { path: '' } }, { route: { index: true } }] }, + } as any; + await wrappedHandler(new Request('https://nacho.queso/'), 200, new Headers(), rootIndexContext, {} as any); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + + const nestedIndexContext = { + staticHandlerContext: { + matches: [{ route: { path: '' } }, { route: { path: 'dashboard' } }, { route: { index: true } }], + }, + } as any; + await wrappedHandler( + new Request('https://nacho.queso/dashboard'), + 200, + new Headers(), + nestedIndexContext, + {} as any, + ); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/dashboard', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + }); + test('should not set span attributes when parameterized path does not exist', async () => { const mockActiveSpan = {}; const mockRootSpan = { setAttributes: vi.fn() }; diff --git a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts index ab77da695dc0..f1adc360477f 100644 --- a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts +++ b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts @@ -45,7 +45,7 @@ describe('makeServerBuildCapturePlugin', () => { expect(result).not.toBeNull(); expect(result.code).toContain(SERVER_BUILD_CODE); expect(result.code).toContain('__sentrySetServerBuild'); - expect(result.code).toContain('({ routes })'); + expect(result.code).toContain("({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined })"); expect(result.map).toBeNull(); }); }); From 6753777a1dc8c04ea6aee2ed5b364a7e97469a5c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:56:39 +0200 Subject: [PATCH 44/57] ci(deps): bump pnpm/action-setup from 6.0.10 to 6.1.0 (#24948) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6.0.10 to 6.1.0.
Release notes

Sourced from pnpm/action-setup's releases.

v6.1.0

What's Changed

Full Changelog: https://github.com/pnpm/action-setup/compare/v6.0.10...v6.1.0

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pnpm/action-setup&package-manager=github_actions&previous-version=6.0.10&new-version=6.1.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore 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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 6 +++--- .github/workflows/canary.yml | 2 +- .github/workflows/lighthouse.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 13bfa91520fc..f680c6c3c630 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -886,7 +886,7 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} # The fixtures install the locally-packed `@sentry/bundler-plugins` tarball with pnpm. - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 9.15.9 - name: Set up Node @@ -1046,7 +1046,7 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} - name: Set up Node @@ -1186,7 +1186,7 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} - name: Set up Node diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index a24354e3497b..fd9c8d6ce524 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -117,7 +117,7 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 9.15.9 - name: Set up Node diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 6df0c712f2bd..6928d5d4073e 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,7 +19,7 @@ jobs: VITE_E2E_TEST_DSN: 'https://username@domain/123' steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 with: version: 9.15.9 - uses: actions/setup-node@v7 From 760fa5060c75a6b37fe4dd1f77b375383c601337 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:57:03 +0200 Subject: [PATCH 45/57] ci(deps): bump anthropics/claude-code-action from 1.0.210 to 1.0.236 (#24950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.210 to 1.0.236.
Release notes

Sourced from anthropics/claude-code-action's releases.

v1.0.236

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.235...v1.0.236

v1.0.235

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.234...v1.0.235

v1.0.234

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.233...v1.0.234

v1.0.233

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.232...v1.0.233

v1.0.232

What's Changed

New Contributors

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.231...v1.0.232

v1.0.231

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.230...v1.0.231

v1.0.230

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.229...v1.0.230

v1.0.229

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.228...v1.0.229

v1.0.228

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.227...v1.0.228

v1.0.227

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.226...v1.0.227

v1.0.226

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.225...v1.0.226

v1.0.225

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.224...v1.0.225

v1.0.224

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.223...v1.0.224

v1.0.223

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.222...v1.0.223

v1.0.222

Full Changelog: https://github.com/anthropics/claude-code-action/compare/v1.0.221...v1.0.222

... (truncated)

Commits
  • 8ce9314 chore: bump Claude Code to 2.1.284 and Agent SDK to 0.3.284
  • 756cc22 chore: bump Claude Code to 2.1.283 and Agent SDK to 0.3.283
  • 9171db3 chore: bump Claude Code to 2.1.282 and Agent SDK to 0.3.282
  • 8cf3482 chore: bump Claude Code to 2.1.281 and Agent SDK to 0.3.281
  • 46a42b4 ci: pin the integration tests to claude-opus-5 (#1853)
  • 9ca9355 chore: bump Claude Code to 2.1.280 and Agent SDK to 0.3.280
  • cfc3eb2 chore: bump Claude Code to 2.1.278 and Agent SDK to 0.3.278
  • 4036a18 chore: bump Claude Code to 2.1.277 and Agent SDK to 0.3.277
  • a4f54ef chore: bump Claude Code to 2.1.276 and Agent SDK to 0.3.276
  • 2261fcf chore: bump Claude Code to 2.1.275 and Agent SDK to 0.3.275
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=anthropics/claude-code-action&package-manager=github_actions&previous-version=1.0.210&new-version=1.0.236)](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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-fix-issue.yml | 2 +- .github/workflows/dependabot-auto-triage.yml | 4 ++-- .github/workflows/track-framework-updates.yml | 2 +- .github/workflows/triage-issue.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/auto-fix-issue.yml b/.github/workflows/auto-fix-issue.yml index 245ffc18b3f4..559f255e3e98 100644 --- a/.github/workflows/auto-fix-issue.yml +++ b/.github/workflows/auto-fix-issue.yml @@ -77,7 +77,7 @@ jobs: - name: Try to fix the issue with Claude id: triage - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: diff --git a/.github/workflows/dependabot-auto-triage.yml b/.github/workflows/dependabot-auto-triage.yml index 2d10df4fb2be..3a94bd400aa9 100644 --- a/.github/workflows/dependabot-auto-triage.yml +++ b/.github/workflows/dependabot-auto-triage.yml @@ -160,7 +160,7 @@ jobs: git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" - name: Open batched runtime fix PR via skill - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: @@ -249,7 +249,7 @@ jobs: git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" - name: Open batched dev fix PR via skill - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: diff --git a/.github/workflows/track-framework-updates.yml b/.github/workflows/track-framework-updates.yml index 723d1661c0d2..0a74bad0b923 100644 --- a/.github/workflows/track-framework-updates.yml +++ b/.github/workflows/track-framework-updates.yml @@ -43,7 +43,7 @@ jobs: - name: Run Claude digest id: digest - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml index 9994c56b9900..78ec8b6586e5 100644 --- a/.github/workflows/triage-issue.yml +++ b/.github/workflows/triage-issue.yml @@ -54,7 +54,7 @@ jobs: - name: Run Claude triage id: triage - uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1 + uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1 env: ANTHROPIC_BASE_URL: https://openrouter.ai/api with: From d672d69410d52ce50ca07967c4ea56f20ac7ec0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:57:27 +0200 Subject: [PATCH 46/57] ci(deps): bump getsentry/github-workflows/validate-pr from 4013fc6e1aeb1be1f9d3b4d232624f0ec1afa613 to 36c729264d2edc29ebae61950c50e1e9f043ad7e (#24949) Bumps [getsentry/github-workflows/validate-pr](https://github.com/getsentry/github-workflows) from 4013fc6e1aeb1be1f9d3b4d232624f0ec1afa613 to 36c729264d2edc29ebae61950c50e1e9f043ad7e.
Changelog

Sourced from getsentry/github-workflows/validate-pr's changelog.

Changelog

3.4.1

Fixes

  • Danger - Add skip-checkout to preserve a caller's prepared workspace, including generated custom Dangerfiles. Checkout remains enabled by default. (#175)
  • Updater - Preserve CMake and submodule pins ahead of the selected release, while reporting divergent histories and Git errors (#174)
  • Danger - Harden extra-install-packages handling: pass the package list into the container via env var instead of host-shell string interpolation (defense in depth) (#169)
  • Updater - Avoid cleanup races in temporary ancestry repositories by disabling background Git maintenance; preserve original Git errors if cleanup also fails (#177)
  • Sentry-CLI integration test action - Skip the reverse DNS lookup on server start, which made each start take ~35 s on macOS (#178)

3.4.0

Features

  • Validate PR - Action is advisory: it posts a single friendly comment on community PRs that don't reference an issue with maintainer discussion. PRs are not closed and no labels are applied. Recommended trigger is types: [opened].
  • Validate PR - Skip validation for PRs with fewer than 100 lines changed, excluding common lock files (Cargo.lock, yarn.lock, package-lock.json, Pipfile.lock, etc.). Tiny PRs no longer go through the issue-discussion loop.
  • Add validate-pr composite action for validating non-maintainer PRs against contribution guidelines (#153)

Fixes

  • Complete script injection hardening across all actions: move remaining step outputs to env vars, validate Danger version against semver (#152)
  • Updater - Trigger CI for new PRs without changelog updates (#166)
  • Updater - Select the first branch when multiple branches point at HEAD (#165)

Dependencies

3.3.0

Features

  • Updater - Support CMake GIT_TAG with variable references like ${FOO_REF}, resolving and updating the corresponding set() definition (#149)

3.2.1

Fixes

  • Sentry-CLI integration test action - Accept chunked ProGuard uploads for compatibility with Sentry CLI 3.x (#140)

3.2.0

Features

  • Danger - Add support for repository-specific dangerfiles (#129)
    • Add extra-dangerfile input parameter to run custom Danger checks alongside shared workflow checks

... (truncated)

Commits
  • 36c7292 Merge remote-tracking branch 'remotes/origin/release/3.4.1'
  • 959162c release: 3.4.1
  • 573d8af fix(sentry-cli): Skip reverse DNS lookup on dummy server start (#178)
  • 6651df0 fix(updater): prevent maintenance from racing ancestry cleanup (#177)
  • 45c8e3a chore: cleanup changelog (#176)
  • 229617d fix(danger): preserve prepared workspaces with optional checkout (#175)
  • 14b30d6 fix(updater): distinguish newer pins from divergent history (#174)
  • See full diff in compare view

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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/validate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml index 7ccbd41bf16d..9e29641264dc 100644 --- a/.github/workflows/validate-pr.yml +++ b/.github/workflows/validate-pr.yml @@ -10,7 +10,7 @@ jobs: permissions: pull-requests: write steps: - - uses: getsentry/github-workflows/validate-pr@4013fc6e1aeb1be1f9d3b4d232624f0ec1afa613 + - uses: getsentry/github-workflows/validate-pr@36c729264d2edc29ebae61950c50e1e9f043ad7e with: app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} From 89ac4c26b655734257a0ae75578d651dee3a00f7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:59:43 +0200 Subject: [PATCH 47/57] ci(deps): bump getsentry/craft from 2.30.1 to 2.31.2 (#24951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [getsentry/craft](https://github.com/getsentry/craft) from 2.30.1 to 2.31.2.
Release notes

Sourced from getsentry/craft's releases.

2.31.2

Bug Fixes 🐛

  • (deps) Remediate open security alerts by @​BYK in #881
  • (github) Filter artifacts by name when fetching revision artifact by @​itaybre in #880

2.31.1

Bug Fixes 🐛

Internal Changes 🔧

Deps

Deps Dev

2.31.0

New Features ✨

  • (config) Top-level workspaces schema + --workspace selector by @​BYK in #848
  • Propagate release workspaces by @​BYK in #872

Bug Fixes 🐛

Changelog

Sourced from getsentry/craft's changelog.

Changelog

2.31.2

Bug Fixes 🐛

  • (deps) Remediate open security alerts by @​BYK in #881
  • (github) Filter artifacts by name when fetching revision artifact by @​itaybre in #880

2.31.1

Bug Fixes 🐛

Internal Changes 🔧

Deps

Deps Dev

2.31.0

New Features ✨

  • (config) Top-level workspaces schema + --workspace selector by @​BYK in #848
  • Propagate release workspaces by @​BYK in #872

Bug Fixes 🐛

2.30.1

Bug Fixes 🐛

  • (vercel) Pass prebuilt output directory by @​BYK in #868

2.30.0

New Features ✨

  • (vercel) Allow project ID in target config by @​BYK in #867

... (truncated)

Commits
  • 25028d0 release: 2.31.2
  • bba2a96 fix(deps): remediate open security alerts (#881)
  • 7137f20 fix(github): Filter artifacts by name when fetching revision artifact (#880)
  • 960a1c7 meta: Bump new development version
  • 7dab3b4 Merge remote-tracking branch 'remotes/origin/release/2.31.1'
  • b5451aa release: 2.31.1
  • 05953a0 fix(npm): Show publish stderr at info level (#877)
  • c17a278 build(deps-dev): bump vitest from 4.1.8 to 4.1.11 (#875)
  • a2e0589 build(deps): bump sharp from 0.35.0 to 0.35.4 in /docs (#874)
  • 12f3b02 build(deps-dev): bump js-yaml from 4.3.1 to 4.3.2 (#876)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=getsentry/craft&package-manager=github_actions&previous-version=2.30.1&new-version=2.31.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)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index d6c654de029a..8a2e73d10327 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -51,7 +51,7 @@ jobs: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@cd1e8294061fd970b40d98b77aaa109cb1e00e78 # v2.30.1 + uses: getsentry/craft@25028d0646040cc0a669ae3314cbf884f4c4347a # v2.31.2 if: github.event.pull_request.merged == true && steps.version-regex.outputs.match != '' && steps.get_version.outputs.version != '' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c3d5d08c93c..7840b35f677a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: with: node-version-file: 'package.json' - name: Prepare release - uses: getsentry/craft@cd1e8294061fd970b40d98b77aaa109cb1e00e78 # v2.30.1 + uses: getsentry/craft@25028d0646040cc0a669ae3314cbf884f4c4347a # v2.31.2 env: GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: From 7f4df485f2966aa0eae6225d7eac962b20cc2016 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 12:38:39 +0200 Subject: [PATCH 48/57] ref(core): Use exclusive time constant from conventions (#24971) Uses `SENTRY_EXCLUSIVE_TIME` from `@sentry/conventions/attributes` in span serialization, transaction conversion, and web-vital spans. Deprecates the existing core constant while preserving its public export and the identical `sentry.exclusive_time` key; calculations, values, and serialization behavior remain unchanged. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- packages/browser-utils/src/web-vitals/emitSpan.ts | 4 ++-- packages/browser-utils/src/web-vitals/spans.ts | 4 ++-- packages/core/src/semanticAttributes.ts | 3 +++ packages/core/src/tracing/sentrySpan.ts | 5 ++--- packages/core/src/utils/transactionEvent.ts | 7 ++++--- packages/core/test/lib/utils/transactionEvent.test.ts | 9 +++++---- 6 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/browser-utils/src/web-vitals/emitSpan.ts b/packages/browser-utils/src/web-vitals/emitSpan.ts index 7294b5c8d4a5..ba56bf783f57 100644 --- a/packages/browser-utils/src/web-vitals/emitSpan.ts +++ b/packages/browser-utils/src/web-vitals/emitSpan.ts @@ -3,13 +3,13 @@ import { getClient, getCurrentScope, getRootSpan, - SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; import { startInactiveSpan } from '@sentry/core/browser'; import { + SENTRY_EXCLUSIVE_TIME, BROWSER_NAVIGATION_ID, BROWSER_NAVIGATION_TYPE, SENTRY_REPLAY_ID, @@ -93,7 +93,7 @@ export function _emitWebVitalSpan(options: WebVitalSpanOptions): void { const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: origin, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: op, - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 0, + [SENTRY_EXCLUSIVE_TIME]: 0, [`browser.web_vital.${metricName}.value`]: value, // oxlint-disable-next-line typescript-eslint/no-deprecated [SENTRY_TRANSACTION]: segmentName, diff --git a/packages/browser-utils/src/web-vitals/spans.ts b/packages/browser-utils/src/web-vitals/spans.ts index 424180427141..69bee44f19a3 100644 --- a/packages/browser-utils/src/web-vitals/spans.ts +++ b/packages/browser-utils/src/web-vitals/spans.ts @@ -7,7 +7,6 @@ import { getClient, getRootSpan, hasSpanStreamingEnabled, - SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, timestampInSeconds, @@ -31,6 +30,7 @@ import { } from '../instrumentation/performanceObserver'; import type { LargestContentfulPaint, LayoutShift } from './emitSpan'; import { + SENTRY_EXCLUSIVE_TIME, BROWSER_NAVIGATION_TYPE, BROWSER_WEB_VITAL_INP_INTERACTION_TYPE, BROWSER_WEB_VITAL_INP_TARGET, @@ -435,7 +435,7 @@ export function _sendInpSpan( const name = hasSpanStreaming ? componentName || fallbackName : (selector ?? 'Interaction to next paint'); const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, + [SENTRY_EXCLUSIVE_TIME]: entry?.duration ?? inpValue, }; // The span's name and op always have a value, even for an INP without an entry, so they can't diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index b67f1f74cd6f..f132738be6b9 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -97,6 +97,9 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME = 'sentry.custom_span_na */ export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'sentry.profile_id'; +/** + * @deprecated Use `SENTRY_EXCLUSIVE_TIME` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME = 'sentry.exclusive_time'; export const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index 8cb9bc95cbf8..e0eb0fbec9b5 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -1,9 +1,8 @@ /* eslint-disable max-lines */ import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_EXCLUSIVE_TIME, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { - SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_PROFILE_ID, SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -281,7 +280,7 @@ export class SentrySpan implements Span { trace_id: this._traceId, origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, - exclusive_time: this._attributes[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME] as number | undefined, + exclusive_time: this._attributes[SENTRY_EXCLUSIVE_TIME] as number | undefined, measurements: timedEventsToMeasurements(this._events), links: convertSpanLinksForEnvelope(this._links), }; diff --git a/packages/core/src/utils/transactionEvent.ts b/packages/core/src/utils/transactionEvent.ts index 0ab07b478786..80561b12c51c 100644 --- a/packages/core/src/utils/transactionEvent.ts +++ b/packages/core/src/utils/transactionEvent.ts @@ -1,4 +1,5 @@ -import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../semanticAttributes'; +import { SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; +import { SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../semanticAttributes'; import type { TransactionEvent } from '../types/event'; import type { SpanJSON } from '../types/span'; @@ -20,7 +21,7 @@ export function convertTransactionEventToSpanJson(event: TransactionEvent): Span trace_id: trace_id ?? '', origin, profile_id: data?.[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, - exclusive_time: data?.[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME] as number | undefined, + exclusive_time: data?.[SENTRY_EXCLUSIVE_TIME] as number | undefined, measurements: event.measurements, is_segment: true, }; @@ -46,7 +47,7 @@ export function convertSpanJsonToTransactionEvent(span: SpanJSON): TransactionEv data: { ...span.data, ...(span.profile_id && { [SEMANTIC_ATTRIBUTE_PROFILE_ID]: span.profile_id }), - ...(span.exclusive_time && { [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: span.exclusive_time }), + ...(span.exclusive_time && { [SENTRY_EXCLUSIVE_TIME]: span.exclusive_time }), }, }, }, diff --git a/packages/core/test/lib/utils/transactionEvent.test.ts b/packages/core/test/lib/utils/transactionEvent.test.ts index 64a772a9b67a..3bd9acc8c0f8 100644 --- a/packages/core/test/lib/utils/transactionEvent.test.ts +++ b/packages/core/test/lib/utils/transactionEvent.test.ts @@ -1,5 +1,6 @@ +import { SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; import { describe, expect, it } from 'vitest'; -import { SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../../../src/semanticAttributes'; +import { SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../../../src/semanticAttributes'; import type { TransactionEvent } from '../../../src/types/event'; import type { SpanJSON } from '../../../src/types/span'; import { @@ -45,7 +46,7 @@ describe('convertTransactionEventToSpanJson', () => { op: 'http', data: { [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 123.45, + [SENTRY_EXCLUSIVE_TIME]: 123.45, other: 'value', }, }, @@ -60,7 +61,7 @@ describe('convertTransactionEventToSpanJson', () => { expect(convertTransactionEventToSpanJson(event)).toEqual({ data: { [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 123.45, + [SENTRY_EXCLUSIVE_TIME]: 123.45, other: 'value', }, description: 'Test Transaction', @@ -163,7 +164,7 @@ describe('convertSpanJsonToTransactionEvent', () => { data: { other: 'value', [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', - [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 123.45, + [SENTRY_EXCLUSIVE_TIME]: 123.45, }, }, }, From bd1012a3c4a0749fe31182adda90ba0b51903349 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 12:38:56 +0200 Subject: [PATCH 49/57] ref(core): Use cache attribute constants from conventions (#24973) Uses `CACHE_HIT`, `CACHE_KEY`, and `CACHE_ITEM_SIZE` from `@sentry/conventions/attributes`. Deprecates the existing core constants while preserving their public exports and identical attribute keys; emitted values and cache behavior remain unchanged. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- packages/core/src/semanticAttributes.ts | 9 +++++++++ .../nitro/src/runtime/hooks/captureStorageEvents.ts | 8 +++----- packages/nuxt/src/runtime/utils/instrumentStorage.ts | 8 +++----- .../src/integrations/redis/redis-cache.ts | 12 ++++++------ 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index f132738be6b9..6382a916c3ee 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -102,10 +102,19 @@ export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'sentry.profile_id'; */ export const SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME = 'sentry.exclusive_time'; +/** + * @deprecated Use `CACHE_HIT` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; +/** + * @deprecated Use `CACHE_KEY` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; +/** + * @deprecated Use `CACHE_ITEM_SIZE` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size'; /** TODO: Remove these once we update to latest semantic conventions */ diff --git a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts index 066ec79bad45..de1b9399efdf 100644 --- a/packages/nitro/src/runtime/hooks/captureStorageEvents.ts +++ b/packages/nitro/src/runtime/hooks/captureStorageEvents.ts @@ -1,5 +1,5 @@ import * as dc from 'node:diagnostics_channel'; -import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; +import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { CACHE_OPERATION_NAMES, @@ -7,8 +7,6 @@ import { GLOBAL_OBJ, hasSpanStreamingEnabled, isObjectLike, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; @@ -98,7 +96,7 @@ function setupStorageTracingChannel(operation: TracedOperation): void { [SENTRY_OP]: OPERATION_SPAN_OPS[operation], [CACHE_OPERATION]: cacheOperationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0], + [CACHE_KEY]: cacheKeys.length > 1 ? cacheKeys : cacheKeys[0], 'db.operation.name': operation, 'db.collection.name': mountBase(data), 'db.system.name': data.driver?.name ?? 'unknown', @@ -112,7 +110,7 @@ function setupStorageTracingChannel(operation: TracedOperation): void { if (!('error' in data)) { const result = (data as { result?: unknown }).result; if (CACHE_HIT_OPERATIONS.has(operation)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, resolveCacheHit(operation, data.keys?.[0], result)); + span.setAttribute(CACHE_HIT, resolveCacheHit(operation, data.keys?.[0], result)); } } diff --git a/packages/nuxt/src/runtime/utils/instrumentStorage.ts b/packages/nuxt/src/runtime/utils/instrumentStorage.ts index 621dc3024c1f..7d6cc98177fc 100644 --- a/packages/nuxt/src/runtime/utils/instrumentStorage.ts +++ b/packages/nuxt/src/runtime/utils/instrumentStorage.ts @@ -1,4 +1,4 @@ -import { CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; +import { CACHE_HIT, CACHE_KEY, CACHE_OPERATION, SENTRY_OP } from '@sentry/conventions/attributes'; import { CACHE_GET, CACHE_PUT, CACHE_REMOVE } from '@sentry/conventions/op'; import { CACHE_OPERATION_NAMES, @@ -7,8 +7,6 @@ import { getClient, hasSpanStreamingEnabled, isObjectLike, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, SPAN_STATUS_OK, @@ -180,7 +178,7 @@ function createMethodWrapper( span.setStatus({ code: SPAN_STATUS_OK }); if (CACHE_HIT_METHODS.has(methodName)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, resolveCacheHit(methodName, args[0], result)); + span.setAttribute(CACHE_HIT, resolveCacheHit(methodName, args[0], result)); } return result; @@ -242,7 +240,7 @@ function createSpanStartOptions( [SENTRY_OP]: cacheOperation, [CACHE_OPERATION]: cacheOperationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + [CACHE_KEY]: keys.length > 1 ? keys : keys[0], 'db.operation.name': methodName, 'db.collection.name': mountBase.replace(/:$/, ''), 'db.system.name': driver.name ?? 'unknown', diff --git a/packages/server-utils/src/integrations/redis/redis-cache.ts b/packages/server-utils/src/integrations/redis/redis-cache.ts index 428facdb01c6..ac74bee358f4 100644 --- a/packages/server-utils/src/integrations/redis/redis-cache.ts +++ b/packages/server-utils/src/integrations/redis/redis-cache.ts @@ -1,4 +1,7 @@ import { + CACHE_HIT, + CACHE_ITEM_SIZE, + CACHE_KEY, CACHE_OPERATION, NETWORK_PEER_ADDRESS, NETWORK_PEER_PORT, @@ -11,9 +14,6 @@ import { CACHE_OPERATION_NAMES, getClient, hasSpanStreamingEnabled, - SEMANTIC_ATTRIBUTE_CACHE_HIT, - SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, - SEMANTIC_ATTRIBUTE_CACHE_KEY, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON, truncate, @@ -175,7 +175,7 @@ export function getRedisCacheAttributes( const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: cacheOperation, - [SEMANTIC_ATTRIBUTE_CACHE_KEY]: safeKey, + [CACHE_KEY]: safeKey, [CACHE_OPERATION]: CACHE_OPERATION_NAMES[cacheOperation], }; @@ -216,11 +216,11 @@ export function applyCacheResponseAttributes(span: Span, response: unknown): voi const cacheItemSize = calculateCacheItemSize(response); if (cacheItemSize) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE, cacheItemSize); + span.setAttribute(CACHE_ITEM_SIZE, cacheItemSize); } if (op === CACHE_GET && cacheItemSize !== undefined) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, cacheItemSize > 0); + span.setAttribute(CACHE_HIT, cacheItemSize > 0); } } From 9e77e9d9b3c216e3c218dc4defe800ddf699ce4e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 12:39:11 +0200 Subject: [PATCH 50/57] ref(core): Use user attribute constants from conventions (#24974) Uses `USER_ID`, `USER_EMAIL`, `USER_IP_ADDRESS`, and `USER_NAME` from `@sentry/conventions/attributes`. Deprecates the existing core constants while preserving their public exports and identical attribute keys; emitted values and data-collection behavior remain unchanged. `USER_NAME` still maps the existing username to `user.name`. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- packages/core/src/integrations/requestdata.ts | 5 ++-- packages/core/src/semanticAttributes.ts | 20 ++++++++++++---- .../core/src/tracing/spans/captureSpan.ts | 16 ++++++------- .../lib/tracing/spans/captureSpan.test.ts | 24 +++++++++---------- 4 files changed, 38 insertions(+), 27 deletions(-) diff --git a/packages/core/src/integrations/requestdata.ts b/packages/core/src/integrations/requestdata.ts index 8c641f666169..12d9d99d433c 100644 --- a/packages/core/src/integrations/requestdata.ts +++ b/packages/core/src/integrations/requestdata.ts @@ -1,7 +1,6 @@ import type { Client } from '../client'; import { getIsolationScope } from '../currentScopes'; import { defineIntegration } from '../integration'; -import { SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS } from '../semanticAttributes'; import type { SdkProcessingMetadata } from '../scope'; import type { CollectBehavior, ResolvedDataCollection } from '../types/datacollection'; import type { Event } from '../types/event'; @@ -19,7 +18,7 @@ import { getUrlQuery } from '../utils/url'; import { getClientIPAddress } from '../utils/clientIPAddress'; import { ipHeaderNames } from '../vendor/getIpAddress'; import { safeSetSpanJSONAttributes } from '../tracing/spans/captureSpan'; -import { SENTRY_IS_LOCALHOST, URL_FULL, URL_QUERY } from '@sentry/conventions/attributes'; +import { SENTRY_IS_LOCALHOST, URL_FULL, URL_QUERY, USER_IP_ADDRESS } from '@sentry/conventions/attributes'; type RequestDataIncludeOptions = { cookies?: boolean; @@ -262,7 +261,7 @@ function addNormalizedRequestDataToSpan( if (include.ip) { const ip = (normalizedRequest.headers && getClientIPAddress(normalizedRequest.headers)) || ipAddress || undefined; if (ip) { - safeSetSpanJSONAttributes(span, { [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: ip }); + safeSetSpanJSONAttributes(span, { [USER_IP_ADDRESS]: ip }); } } } diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 6382a916c3ee..b5f12aee5e49 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -74,13 +74,25 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; /** The list of integrations enabled in the Sentry SDK (e.g., ["EventFilters", "BrowserTracing"]) */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS = 'sentry.sdk.integrations'; -/** The user ID */ +/** + * The user ID + * @deprecated Use `USER_ID` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_ID = 'user.id'; -/** The user email */ +/** + * The user email + * @deprecated Use `USER_EMAIL` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_EMAIL = 'user.email'; -/** The user IP address */ +/** + * The user IP address + * @deprecated Use `USER_IP_ADDRESS` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS = 'user.ip_address'; -/** The user username */ +/** + * The user username + * @deprecated Use `USER_NAME` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_USER_USERNAME = 'user.name'; /** diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 24f09d2aa9e2..7ac1c693c9f5 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -5,10 +5,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, - SEMANTIC_ATTRIBUTE_USER_EMAIL, - SEMANTIC_ATTRIBUTE_USER_ID, - SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, - SEMANTIC_ATTRIBUTE_USER_USERNAME, } from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { getCombinedScopeData } from '../../utils/scopeData'; @@ -29,6 +25,10 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + USER_EMAIL, + USER_ID, + USER_IP_ADDRESS, + USER_NAME, } from '@sentry/conventions/attributes'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { @@ -138,10 +138,10 @@ function commonSpanAttributes( [SENTRY_SDK_VERSION]: sdk?.sdk?.version, [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment || DEFAULT_ENVIRONMENT, - [SEMANTIC_ATTRIBUTE_USER_ID]: scopeData.user?.id, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: scopeData.user?.email, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: scopeData.user?.ip_address, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: scopeData.user?.username, + [USER_ID]: scopeData.user?.id, + [USER_EMAIL]: scopeData.user?.email, + [USER_IP_ADDRESS]: scopeData.user?.ip_address, + [USER_NAME]: scopeData.user?.username, ...(includeScopeAttributes ? scopeData.attributes : undefined), }; } diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 24a45b0eb561..23b0847ed0de 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -9,10 +9,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, - SEMANTIC_ATTRIBUTE_USER_EMAIL, - SEMANTIC_ATTRIBUTE_USER_ID, - SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS, - SEMANTIC_ATTRIBUTE_USER_USERNAME, spanStreamingIntegration, startInactiveSpan, startSpan, @@ -33,6 +29,10 @@ import { SENTRY_SEGMENT_NAME, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_TRACE_LIFECYCLE, + USER_EMAIL, + USER_ID, + USER_IP_ADDRESS, + USER_NAME, } from '@sentry/conventions/attributes'; describe('captureSpan', () => { @@ -109,19 +109,19 @@ describe('captureSpan', () => { value: 'staging', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_ID]: { + [USER_ID]: { value: '123', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + [USER_EMAIL]: { value: 'user@example.com', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + [USER_NAME]: { value: 'testuser', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + [USER_IP_ADDRESS]: { value: '127.0.0.1', type: 'string', }, @@ -217,19 +217,19 @@ describe('captureSpan', () => { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_ID]: { + [USER_ID]: { value: '123', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_EMAIL]: { + [USER_EMAIL]: { value: 'user@example.com', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_USERNAME]: { + [USER_NAME]: { value: 'testuser', type: 'string', }, - [SEMANTIC_ATTRIBUTE_USER_IP_ADDRESS]: { + [USER_IP_ADDRESS]: { value: '127.0.0.1', type: 'string', }, From c65ec66ad26873aed711b0a362ba3a50ba1b87b6 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 12:42:52 +0200 Subject: [PATCH 51/57] ref(core): Use HTTP method constant from conventions (#24977) Uses `HTTP_REQUEST_METHOD` from `@sentry/conventions/attributes`. Deprecates the existing core constant while preserving its public export and identical `http.request.method` key; emitted values, method normalization, defaults, and the GraphQL fallback to `http.method` remain unchanged. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- packages/astro/src/server/middleware.ts | 4 ++-- packages/astro/test/server/middleware.test.ts | 6 +++--- packages/browser/src/integrations/graphqlClient.ts | 4 ++-- packages/bun/src/integrations/bunserver.ts | 4 ++-- packages/core/src/semanticAttributes.ts | 4 +++- packages/core/src/utils/url.ts | 5 +++-- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/astro/src/server/middleware.ts b/packages/astro/src/server/middleware.ts index f63807df9d8c..4116085ffb41 100644 --- a/packages/astro/src/server/middleware.ts +++ b/packages/astro/src/server/middleware.ts @@ -2,6 +2,7 @@ import { SENTRY_SEGMENT_NAME_SOURCE, HTTP_ROUTE, + HTTP_REQUEST_METHOD, SENTRY_OP, URL_FRAGMENT, URL_FULL, @@ -19,7 +20,6 @@ import { hasSpanStreamingEnabled, HTTP_SPAN_NAME_FALLBACK, objectify, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, spanToJSON, winterCGRequestToRequestData, filterCollectedUrl, @@ -222,7 +222,7 @@ async function instrumentRequestStartHttpServerSpan( [SENTRY_OP]: HTTP_SERVER, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.astro', [SENTRY_SEGMENT_NAME_SOURCE]: source, - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: method, + [HTTP_REQUEST_METHOD]: method, // This is here for backwards compatibility, we used to set this here before method, [URL_FULL]: filterCollectedUrl(ctx.url.href), diff --git a/packages/astro/test/server/middleware.test.ts b/packages/astro/test/server/middleware.test.ts index f61ae7a56ff6..88a21e303f10 100644 --- a/packages/astro/test/server/middleware.test.ts +++ b/packages/astro/test/server/middleware.test.ts @@ -1,4 +1,4 @@ -import { SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; +import { HTTP_REQUEST_METHOD, SENTRY_SEGMENT_NAME_SOURCE, URL_FULL, URL_PATH } from '@sentry/conventions/attributes'; import type { Client, Span } from '@sentry/core'; import * as SentryCore from '@sentry/core'; @@ -128,7 +128,7 @@ describe('sentryMiddleware', () => { [URL_FULL]: 'https://mydomain.io/users/123/details', [URL_PATH]: '/users/123/details', [SENTRY_SEGMENT_NAME_SOURCE]: 'route', - [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', + [HTTP_REQUEST_METHOD]: 'GET', 'http.route': '/users/[id]/details', }, name: 'GET /users/[id]/details', @@ -230,7 +230,7 @@ describe('sentryMiddleware', () => { [URL_FULL]: 'http://localhost:1234/a%xx', [URL_PATH]: 'a%xx', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', - [SentryCore.SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: 'GET', + [HTTP_REQUEST_METHOD]: 'GET', }, name: 'GET a%xx', }, diff --git a/packages/browser/src/integrations/graphqlClient.ts b/packages/browser/src/integrations/graphqlClient.ts index 643ced098f0f..b01bf26f10f7 100644 --- a/packages/browser/src/integrations/graphqlClient.ts +++ b/packages/browser/src/integrations/graphqlClient.ts @@ -4,7 +4,6 @@ import { hasSpanStreamingEnabled, isObjectLike, isString, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, spanToJSON, stringMatchesSomePattern, } from '@sentry/core'; @@ -15,6 +14,7 @@ import { GRAPHQL_OPERATION_NAME, GRAPHQL_OPERATION_TYPE, HTTP_METHOD, + HTTP_REQUEST_METHOD, SENTRY_OP, URL_FULL, } from '@sentry/conventions/attributes'; @@ -77,7 +77,7 @@ function _updateSpanWithGraphQLData(client: Client, options: GraphQLClientOption const httpUrl = spanAttributes[URL_FULL]; // oxlint-disable-next-line typescript/no-deprecated - const httpMethod = spanAttributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] || spanAttributes[HTTP_METHOD]; + const httpMethod = spanAttributes[HTTP_REQUEST_METHOD] || spanAttributes[HTTP_METHOD]; if (!isString(httpUrl) || !isString(httpMethod)) { return; diff --git a/packages/bun/src/integrations/bunserver.ts b/packages/bun/src/integrations/bunserver.ts index 3cc5406fbc05..080251c7dba3 100644 --- a/packages/bun/src/integrations/bunserver.ts +++ b/packages/bun/src/integrations/bunserver.ts @@ -12,7 +12,6 @@ import { HTTP_SPAN_NAME_FALLBACK, isURLObjectRelative, parseStringToURLObject, - SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setHttpStatus, startSpan, @@ -26,6 +25,7 @@ import type { Server, ServeOptions } from 'bun'; import { CLIENT_ADDRESS, CLIENT_PORT, + HTTP_REQUEST_METHOD, NETWORK_PROTOCOL_NAME, SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, @@ -356,7 +356,7 @@ function getSpanAttributesFromParsedUrl( ): SpanAttributes { const attributes: SpanAttributes = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.bun.serve', - [SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD]: request.method || 'GET', + [HTTP_REQUEST_METHOD]: request.method || 'GET', [SENTRY_SEGMENT_NAME_SOURCE]: 'url', }; diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index b5f12aee5e49..5a3274023e39 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -129,7 +129,9 @@ export const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; */ export const SEMANTIC_ATTRIBUTE_CACHE_ITEM_SIZE = 'cache.item_size'; -/** TODO: Remove these once we update to latest semantic conventions */ +/** + * @deprecated Use `HTTP_REQUEST_METHOD` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD = 'http.request.method'; /** * @deprecated Use `URL_FULL` `@sentry/conventions/attributes` instead. diff --git a/packages/core/src/utils/url.ts b/packages/core/src/utils/url.ts index 63ecad6dc4d8..d7f45141a342 100644 --- a/packages/core/src/utils/url.ts +++ b/packages/core/src/utils/url.ts @@ -1,4 +1,5 @@ import { + HTTP_REQUEST_METHOD, HTTP_ROUTE, SERVER_ADDRESS, URL_DOMAIN, @@ -11,7 +12,7 @@ import { URL_SCHEME, URL_TEMPLATE, } from '@sentry/conventions/attributes'; -import { SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; import type { Client } from '../client'; import type { SpanAttributes } from '../types/span'; import type { TransactionSource } from '../types/transaction'; @@ -221,7 +222,7 @@ export function getHttpSpanDetailsFromUrlObject( } if (request?.method) { - attributes[SEMANTIC_ATTRIBUTE_HTTP_REQUEST_METHOD] = request.method.toUpperCase(); + attributes[HTTP_REQUEST_METHOD] = request.method.toUpperCase(); } if (urlObject) { From 9c106411b0113c619577e0c028c2a32d56b8c22b Mon Sep 17 00:00:00 2001 From: Tobias Schnabel Date: Fri, 2 Oct 2026 12:49:54 +0200 Subject: [PATCH 52/57] fix(server-utils): Skip Fastify 4xx errors raised before the reply status is set (#24924) Since v11, `fastifyIntegration` always registers an `onError` hook (#23460). Fastify runs `onError` hooks before it applies the error's status to the reply, so for errors raised outside the route handler, such as a request body that fails to parse (`FST_ERR_CTP_EMPTY_JSON_BODY`, `FST_ERR_CTP_INVALID_JSON_BODY`), `defaultShouldHandleError` still reads `reply.statusCode === 200` and captures what is sent to the client as a 400. #18418 fixed the same symptom for route handler errors upstream in Fastify 5.7.0, but only on the diagnostics channel path. When `reply.statusCode` is still the default 200, the default `shouldHandleError` now resolves the status the same way Fastify's `setErrorStatusCode` does: the error's `statusCode` or `status` if it is at least 400, otherwise 500. This predicts the status Fastify is about to send, and errors without a status still count as 500, so they are still captured. A status already set on the reply takes precedence, as before. A custom `shouldHandleError` still receives `reply.statusCode === 200` on this path. I left that alone, since changing what callbacks receive is a separate decision. Fixes #24926 --------- Co-authored-by: Claude Opus 5.5 --- .../suites/tracing/fastify/scenario.mjs | 9 +++ .../suites/tracing/fastify/test.ts | 26 ++++++++ .../src/integrations/fastify/utils.ts | 27 +++++++- .../test/integrations/fastify/utils.test.ts | 66 +++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 packages/server-utils/test/integrations/fastify/utils.test.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs index fa654dd7cfb6..83870c07569d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/fastify/scenario.mjs @@ -26,6 +26,15 @@ app.get('/test-exception/:id', async request => { throw new Error(`This is an exception with id ${request.params.id}`); }); +app.post('/test-body', async () => { + return {}; +}); + +app.get('/flush', async () => { + await Sentry.flush(); + return {}; +}); + app.get('/test-inbound-headers/:id', async request => { return { headers: request.headers, id: request.params.id }; }); diff --git a/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts b/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts index 7ace423c9745..aa54d6195b7e 100644 --- a/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/fastify/test.ts @@ -182,5 +182,31 @@ describe('fastify v5 auto-instrumentation', () => { runner.makeRequest('get', '/test-exception/456', { expectError: true }); await runner.completed(); }); + + test('does not capture 4xx errors raised before the reply status is set', async () => { + const runner = createRunner() + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'This is an exception with id 789', + }, + ], + }, + }, + }) + .start(); + // Fastify rejects an empty JSON body with a 400 and runs `onError` hooks before setting that status. + await runner.makeRequest('post', '/test-body', { + headers: { 'content-type': 'application/json' }, + expectError: true, + }); + // Flush so an event for the 400 would be sent before the exception below, and fail the test. + await runner.makeRequest('get', '/flush'); + await runner.makeRequest('get', '/test-exception/789', { expectError: true }); + await runner.completed(); + }); }); }); diff --git a/packages/server-utils/src/integrations/fastify/utils.ts b/packages/server-utils/src/integrations/fastify/utils.ts index e560590d9234..fe6f6a9235ec 100644 --- a/packages/server-utils/src/integrations/fastify/utils.ts +++ b/packages/server-utils/src/integrations/fastify/utils.ts @@ -1,3 +1,4 @@ +import { isObjectLike } from '@sentry/core'; import type { FastifyReply, FastifyRequest } from './types'; export const INTEGRATION_NAME = 'Fastify' as const; @@ -7,8 +8,30 @@ export const INTEGRATION_NAME = 'Fastify' as const; * * 3xx and 4xx errors are not sent by default. */ -export function defaultShouldHandleError(_error: Error, _request: FastifyRequest, reply: FastifyReply): boolean { - const statusCode = reply.statusCode; +export function defaultShouldHandleError(error: Error, _request: FastifyRequest, reply: FastifyReply): boolean { + const statusCode = getReplyStatusCode(error, reply); // 3xx and 4xx errors are not sent by default. return statusCode >= 500 || statusCode <= 299; } + +/** + * Fastify runs `onError` hooks before it applies the error's status to the reply + * (e.g. for a request body that fails to parse), so `reply.statusCode` can still be + * the default 200. In that case, resolve the status the same way Fastify will. + */ +function getReplyStatusCode(error: Error, reply: FastifyReply): number { + if (reply.statusCode !== 200) { + return reply.statusCode; + } + + const errorStatusCode = Number(getErrorStatusCode(error)); + return errorStatusCode >= 400 ? errorStatusCode : 500; +} + +function getErrorStatusCode(error: unknown): unknown { + if (!isObjectLike(error)) { + return undefined; + } + + return ('statusCode' in error && error.statusCode) || ('status' in error && error.status); +} diff --git a/packages/server-utils/test/integrations/fastify/utils.test.ts b/packages/server-utils/test/integrations/fastify/utils.test.ts new file mode 100644 index 000000000000..24719807c62d --- /dev/null +++ b/packages/server-utils/test/integrations/fastify/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { FastifyReply, FastifyRequest } from '../../../src/integrations/fastify/types'; +import { defaultShouldHandleError } from '../../../src/integrations/fastify/utils'; + +const request: FastifyRequest = { method: 'POST', routeOptions: { url: '/' } }; + +function reply(statusCode: number): FastifyReply { + return { statusCode } as FastifyReply; +} + +function errorWith(props: { statusCode?: number; status?: number }): Error { + return Object.assign(new Error('test error'), props); +} + +describe('defaultShouldHandleError', () => { + // Fastify runs `onError` hooks before it applies the error's status to the reply, + // e.g. for a body that fails to parse, so `reply.statusCode` is still the default 200 there. + describe('when the reply status has not been set yet', () => { + it('skips errors carrying a 4xx `statusCode`', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 400 }), request, reply(200))).toBe(false); + }); + + it('skips errors carrying a 4xx `status`', () => { + expect(defaultShouldHandleError(errorWith({ status: 404 }), request, reply(200))).toBe(false); + }); + + it('skips errors carrying a 4xx status as a string', () => { + expect( + defaultShouldHandleError(Object.assign(new Error('test error'), { statusCode: '404' }), request, reply(200)), + ).toBe(false); + }); + + it('captures errors carrying a 5xx status', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 503 }), request, reply(200))).toBe(true); + }); + + it('captures errors without a status', () => { + expect(defaultShouldHandleError(new Error('test error'), request, reply(200))).toBe(true); + }); + + it.each([null, undefined, 'a string', 404])( + 'captures a thrown non-object (%s), which Fastify sends as a 500', + error => { + expect(defaultShouldHandleError(error as unknown as Error, request, reply(200))).toBe(true); + }, + ); + + it('captures errors carrying a status below 400, which Fastify sends as a 500', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 302 }), request, reply(200))).toBe(true); + }); + }); + + describe('when the reply status has been set', () => { + it('skips errors on a 4xx reply', () => { + expect(defaultShouldHandleError(new Error('test error'), request, reply(404))).toBe(false); + }); + + it('skips errors on a 3xx reply', () => { + expect(defaultShouldHandleError(new Error('test error'), request, reply(302))).toBe(false); + }); + + it('captures errors on a 5xx reply', () => { + expect(defaultShouldHandleError(errorWith({ statusCode: 400 }), request, reply(500))).toBe(true); + }); + }); +}); From ef90627b85968b7b7b3b726925624bae17f78967 Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:56:20 +0200 Subject: [PATCH 53/57] chore: Add external contributor to CHANGELOG.md (#24980) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #24924 Co-authored-by: nicohrubec <29484629+nicohrubec@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c079adf708a1..3d51009f73a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @Shubham-Padkonde. Thank you for your contribution! +Work in this release was contributed by @Shubham-Padkonde and @tobias-schnabel. Thank you for your contributions! - **fix(cloudflare)**: Durable Object constructors now run in their own isolation scope. When work that the constructor starts, for example a `blockConcurrencyWhile` callback, calls a method of the instance, that call is no longer treated as an incoming RPC call, so an error the instance catches itself is no longer reported as unhandled. As a result, `Sentry.setTag()`, `setUser()` and `setContext()` in a constructor now apply only to that constructor work. They no longer reach later `fetch`, RPC or `alarm` invocations, because the scope they used to write to is shared by every Durable Object and handler in the isolate. Use `initialScope` for static data, and set per-instance data in each handler. From ea5690e4d279b297bb29e0c726f9b964a3b4b460 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 13:03:57 +0200 Subject: [PATCH 54/57] ref(core): Use profile ID constant from conventions (#24976) Uses `SENTRY_PROFILE_ID` from `@sentry/conventions/attributes`. Deprecates the existing core constant while preserving its public export and identical `sentry.profile_id` key; emitted values and span/transaction serialization remain unchanged. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- packages/core/src/semanticAttributes.ts | 1 + packages/core/src/tracing/sentrySpan.ts | 5 ++--- packages/core/src/utils/transactionEvent.ts | 7 +++---- packages/core/test/lib/utils/transactionEvent.test.ts | 9 ++++----- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 5a3274023e39..2a8fd22cbf4e 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -106,6 +106,7 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME = 'sentry.custom_span_na /** * The id of the profile that this span occurred in. + * @deprecated Use `SENTRY_PROFILE_ID` from `@sentry/conventions/attributes` instead. */ export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'sentry.profile_id'; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index e0eb0fbec9b5..abd27b52edf1 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -1,9 +1,8 @@ /* eslint-disable max-lines */ import { getClient, getCurrentScope } from '../currentScopes'; import { DEBUG_BUILD } from '../debug-build'; -import { SENTRY_EXCLUSIVE_TIME, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; +import { SENTRY_PROFILE_ID, SENTRY_EXCLUSIVE_TIME, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { - SEMANTIC_ATTRIBUTE_PROFILE_ID, SEMANTIC_ATTRIBUTE_SENTRY_CUSTOM_SPAN_NAME, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, @@ -279,7 +278,7 @@ export class SentrySpan implements Span { timestamp: this._endTime, trace_id: this._traceId, origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, - profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, + profile_id: this._attributes[SENTRY_PROFILE_ID] as string | undefined, exclusive_time: this._attributes[SENTRY_EXCLUSIVE_TIME] as number | undefined, measurements: timedEventsToMeasurements(this._events), links: convertSpanLinksForEnvelope(this._links), diff --git a/packages/core/src/utils/transactionEvent.ts b/packages/core/src/utils/transactionEvent.ts index 80561b12c51c..97bf3580ce8c 100644 --- a/packages/core/src/utils/transactionEvent.ts +++ b/packages/core/src/utils/transactionEvent.ts @@ -1,5 +1,4 @@ -import { SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; -import { SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../semanticAttributes'; +import { SENTRY_PROFILE_ID, SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; import type { TransactionEvent } from '../types/event'; import type { SpanJSON } from '../types/span'; @@ -20,7 +19,7 @@ export function convertTransactionEventToSpanJson(event: TransactionEvent): Span timestamp: event.timestamp, trace_id: trace_id ?? '', origin, - profile_id: data?.[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, + profile_id: data?.[SENTRY_PROFILE_ID] as string | undefined, exclusive_time: data?.[SENTRY_EXCLUSIVE_TIME] as number | undefined, measurements: event.measurements, is_segment: true, @@ -46,7 +45,7 @@ export function convertSpanJsonToTransactionEvent(span: SpanJSON): TransactionEv origin: span.origin, data: { ...span.data, - ...(span.profile_id && { [SEMANTIC_ATTRIBUTE_PROFILE_ID]: span.profile_id }), + ...(span.profile_id && { [SENTRY_PROFILE_ID]: span.profile_id }), ...(span.exclusive_time && { [SENTRY_EXCLUSIVE_TIME]: span.exclusive_time }), }, }, diff --git a/packages/core/test/lib/utils/transactionEvent.test.ts b/packages/core/test/lib/utils/transactionEvent.test.ts index 3bd9acc8c0f8..03f1e231d975 100644 --- a/packages/core/test/lib/utils/transactionEvent.test.ts +++ b/packages/core/test/lib/utils/transactionEvent.test.ts @@ -1,6 +1,5 @@ -import { SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; +import { SENTRY_PROFILE_ID, SENTRY_EXCLUSIVE_TIME } from '@sentry/conventions/attributes'; import { describe, expect, it } from 'vitest'; -import { SEMANTIC_ATTRIBUTE_PROFILE_ID } from '../../../src/semanticAttributes'; import type { TransactionEvent } from '../../../src/types/event'; import type { SpanJSON } from '../../../src/types/span'; import { @@ -45,7 +44,7 @@ describe('convertTransactionEventToSpanJson', () => { origin: 'manual', op: 'http', data: { - [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', + [SENTRY_PROFILE_ID]: 'profile123', [SENTRY_EXCLUSIVE_TIME]: 123.45, other: 'value', }, @@ -60,7 +59,7 @@ describe('convertTransactionEventToSpanJson', () => { expect(convertTransactionEventToSpanJson(event)).toEqual({ data: { - [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', + [SENTRY_PROFILE_ID]: 'profile123', [SENTRY_EXCLUSIVE_TIME]: 123.45, other: 'value', }, @@ -163,7 +162,7 @@ describe('convertSpanJsonToTransactionEvent', () => { origin: 'manual', data: { other: 'value', - [SEMANTIC_ATTRIBUTE_PROFILE_ID]: 'profile123', + [SENTRY_PROFILE_ID]: 'profile123', [SENTRY_EXCLUSIVE_TIME]: 123.45, }, }, From 4b8c140c80bec0e8a36f6a1a6c19665497616362 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 2 Oct 2026 13:36:47 +0200 Subject: [PATCH 55/57] feat(remix): Add source map upload for Remix 3 (#24943) Remix 3 has no build step and no output directory, so there is nothing on disk for a source map upload to read. `emitAssets()` rebuilds the output by driving the app's own asset server: `getPreloads()` forces the browser module graph, and each compiled module and its map is written out. The debug IDs are hashed from the compiled source, so the emitted IDs match what the running server serves. The instrumented server hides source maps by default, so the emitter reads them through the server's own fetch, which the stamping wrapper now keeps reachable. `sentry-remix-v3-upload-sourcemaps` is a separate bin from the Remix 2 `sentry-upload-sourcemaps`. It must run under `--import @sentry/remix/v3/node`, which provides the TypeScript loader and patches the asset server. Without it the emitted modules carry no debug IDs, so the command fails on that rather than uploading an unusable set. This is far from optimal but until we found a better solution this works. The e2e test runs the bin against the app and checks that every module has a map, each pair shares a debug ID, and the emitted entry's ID equals the served one. It uses `--dry-run`: the CLI needs authentication even for `inject`, so the upload itself is verified separately in #24685. Fixes #24678 --------- Co-authored-by: Claude Fable 5.1 --- .../remix-v3/tests/sourcemap-upload.test.ts | 60 +++++++++ packages/remix/package.json | 3 +- .../sentry-remix-v3-upload-sourcemaps.mjs | 121 ++++++++++++++++++ packages/remix/src/v3/assetServer.ts | 8 ++ packages/remix/src/v3/emitAssets.ts | 90 +++++++++++++ packages/remix/src/v3/index.server.ts | 2 + packages/remix/src/v3/server/instrument.ts | 10 +- packages/remix/test/v3/emitAssets.test.ts | 121 ++++++++++++++++++ packages/remix/test/v3/instrument.test.ts | 2 + 9 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/remix-v3/tests/sourcemap-upload.test.ts create mode 100644 packages/remix/scripts/sentry-remix-v3-upload-sourcemaps.mjs create mode 100644 packages/remix/src/v3/emitAssets.ts create mode 100644 packages/remix/test/v3/emitAssets.test.ts diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/tests/sourcemap-upload.test.ts b/dev-packages/e2e-tests/test-applications/remix-v3/tests/sourcemap-upload.test.ts new file mode 100644 index 000000000000..7eacfe71d8d8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/remix-v3/tests/sourcemap-upload.test.ts @@ -0,0 +1,60 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { expect, test } from '@playwright/test'; + +const DEBUG_ID = /\/\/# debugId=([0-9a-f-]{36})/; + +// Not a listed subpath, so it is reached from the package root. +const script = path.join( + path.dirname(createRequire(import.meta.url).resolve('@sentry/remix/package.json')), + 'scripts', + 'sentry-remix-v3-upload-sourcemaps.mjs', +); + +test('emits the module graph with one map per module, all sharing the served debug IDs', async ({ baseURL }) => { + const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'remix-v3-upload-')); + + const output = execFileSync( + 'node', + [ + '--import', + '@sentry/remix/v3/node', + script, + '--entry', + 'app/actions/public/entry.ts', + '--dry-run', + '--out-dir', + outDir, + ], + { cwd: process.cwd(), env: { ...process.env, NODE_ENV: 'production' }, encoding: 'utf8' }, + ); + expect(output).toMatch(/emitted \d+ modules/); + + const modules = walk(outDir).filter(file => !file.endsWith('.map')); + expect(modules.length).toBeGreaterThan(1); + + for (const file of modules) { + const debugId = fs.readFileSync(file, 'utf8').match(DEBUG_ID)?.[1]; + expect(debugId, `${file} has no debug ID`).toBeDefined(); + const map = JSON.parse(fs.readFileSync(`${file}.map`, 'utf8')) as { debugId?: string }; + expect(map.debugId, `${file}.map does not match its module`).toBe(debugId); + } + + // The IDs are hashed from the compiled source, so the emitted entry must match what the server + // serves right now. That is what lets an upload made in CI symbolicate a running app. + const emittedEntry = modules.find(file => file.endsWith(path.join('app', 'actions', 'public', 'entry.ts'))); + const served = await (await fetch(`${baseURL}/assets/app/actions/public/entry.ts`)).text(); + expect(fs.readFileSync(emittedEntry as string, 'utf8').match(DEBUG_ID)?.[1]).toBe(served.match(DEBUG_ID)?.[1]); + + fs.rmSync(outDir, { recursive: true, force: true }); +}); + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; + }); +} diff --git a/packages/remix/package.json b/packages/remix/package.json index cc806185cf02..42006d9a9271 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -7,7 +7,8 @@ "author": "Sentry", "license": "MIT", "bin": { - "sentry-upload-sourcemaps": "scripts/sentry-upload-sourcemaps.js" + "sentry-upload-sourcemaps": "scripts/sentry-upload-sourcemaps.js", + "sentry-remix-v3-upload-sourcemaps": "scripts/sentry-remix-v3-upload-sourcemaps.mjs" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" diff --git a/packages/remix/scripts/sentry-remix-v3-upload-sourcemaps.mjs b/packages/remix/scripts/sentry-remix-v3-upload-sourcemaps.mjs new file mode 100644 index 000000000000..8cc963fd57ea --- /dev/null +++ b/packages/remix/scripts/sentry-remix-v3-upload-sourcemaps.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +// Emits a Remix 3 app's browser module graph to a directory and uploads it to Sentry. +// +// Must run under the Sentry entry, which provides Remix's TypeScript loader and patches the asset +// server so the emitted modules carry debug IDs: +// +// node --import @sentry/remix/v3/node ./node_modules/.bin/sentry-remix-v3-upload-sourcemaps \ +// --entry app/actions/public/entry.ts --release "$RELEASE" +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parseArgs } from 'node:util'; + +class UsageError extends Error {} + +let outDir; +let keepOutput = false; + +try { + const values = parseOptions(); + keepOutput = Boolean(values['keep-output'] || values['out-dir']); + + const { emitAssets } = await import('@sentry/remix/v3'); + + let assetsModule; + try { + assetsModule = await import(pathToFileURL(path.resolve(values['assets-module'])).href); + } catch (error) { + if (error?.code === 'ERR_UNKNOWN_FILE_EXTENSION') { + fail(`could not load ${values['assets-module']}. Run this command with \`node --import @sentry/remix/v3/node\`.`); + } + throw error; + } + const assets = assetsModule[values['assets-export']]; + if (typeof assets?.getPreloads !== 'function' || typeof assets?.fetch !== 'function') { + fail(`${values['assets-module']} does not export an asset server named "${values['assets-export']}"`); + } + + outDir = values['out-dir'] ?? fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-remix-v3-assets-')); + const emitted = await emitAssets({ assets, entries: values.entry, outDir }); + const maps = emitted.filter(asset => asset.mapFile).length; + const debugIds = emitted.filter(asset => asset.debugId).length; + console.log( + `[sentry] emitted ${emitted.length} modules, ${maps} source maps, ${debugIds} debug IDs${keepOutput ? ` to ${outDir}` : ''}`, + ); + + if (emitted.length > 0 && debugIds === 0) { + // The one failure that is otherwise silent: an unpatched asset server emits plain modules. + fail( + 'no debug IDs were emitted. Run this command with `node --import @sentry/remix/v3/node` so the asset server is instrumented.', + ); + } + if (debugIds < emitted.length || maps < emitted.length) { + // A warning, not a failure: `sourceMaps: false` yields no maps on purpose. + console.warn( + `[sentry] ${emitted.length - debugIds} modules without a debug ID and ${emitted.length - maps} without a source map; those will not symbolicate.`, + ); + } + + if (values['dry-run']) { + console.log('[sentry] --dry-run, skipping upload'); + } else { + const { createSentrySDK } = await import('sentry'); + const sentry = createSentrySDK({ url: values.url, org: values.org, project: values.project }); + const release = values.release ?? (await sentry.release['propose-version']()).version; + try { + // The project has to be named here; the SDK default does not reach release creation. + await sentry.release.create({ orgVersion: release, project: values.project }); + // Modules keep their served names (`.ts`, `.tsx`), which the upload skips by default. Only module + // extensions go here: the upload pairs each module with the `.map` sibling `emitAssets` wrote. + const ext = [...new Set(emitted.map(asset => path.extname(asset.file)))].join(','); + await sentry.sourcemap.upload({ directory: outDir, release, ext }); + await sentry.release.finalize({ orgVersion: release }); + } catch (error) { + fail(error?.message ?? String(error)); + } + console.log(`[sentry] uploaded release ${release}`); + } +} catch (error) { + if (!(error instanceof UsageError)) { + throw error; + } + console.error(`[sentry] ${error.message}`); + process.exitCode = 1; +} finally { + if (outDir && !keepOutput) { + fs.rmSync(outDir, { recursive: true, force: true }); + } +} + +function parseOptions() { + let values; + try { + ({ values } = parseArgs({ + options: { + entry: { type: 'string', multiple: true }, + 'assets-module': { type: 'string', default: './app/assets.ts' }, + 'assets-export': { type: 'string', default: 'assets' }, + release: { type: 'string' }, + org: { type: 'string' }, + project: { type: 'string' }, + url: { type: 'string' }, + 'out-dir': { type: 'string' }, + 'keep-output': { type: 'boolean', default: false }, + 'dry-run': { type: 'boolean', default: false }, + }, + })); + } catch (error) { + fail(error.message); + } + if (!values.entry?.length) { + fail('at least one --entry is required, e.g. --entry app/actions/public/entry.ts'); + } + return values; +} + +function fail(message) { + throw new UsageError(message); +} diff --git a/packages/remix/src/v3/assetServer.ts b/packages/remix/src/v3/assetServer.ts index 103692d34db2..88ce5060662a 100644 --- a/packages/remix/src/v3/assetServer.ts +++ b/packages/remix/src/v3/assetServer.ts @@ -179,8 +179,16 @@ function withDebugIdOptions(options: AssetServerOptions | undefined): AssetServe * the `//# debugId=` comment and the source map's `debugId` field, which is what `sentry-cli` reads, * are added to the served response instead. */ +/** + * The asset server's own `fetch`, before stamping. The emitter needs it because stamping hides source + * maps by default, and the upload must still get them. + * @internal + */ +export const UNSTAMPED_FETCH: unique symbol = Symbol.for('sentry.remix.unstampedFetch'); + function stampServedAssets(server: AssetServer, { hideSourceMaps }: { hideSourceMaps: boolean }): void { const fetchAsset = server.fetch; + (server as AssetServer & { [UNSTAMPED_FETCH]?: AssetServer['fetch'] })[UNSTAMPED_FETCH] = fetchAsset; // The asset server memoizes compiled modules and identifies each version by its ETag, so the // stamped body is memoized the same way. Without this every hit copied the module body again. const stamped = new Map(); diff --git a/packages/remix/src/v3/emitAssets.ts b/packages/remix/src/v3/emitAssets.ts new file mode 100644 index 000000000000..03239a9c8cc9 --- /dev/null +++ b/packages/remix/src/v3/emitAssets.ts @@ -0,0 +1,90 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { UNSTAMPED_FETCH } from './assetServer'; +import { addDebugIdToSourceMap, findDebugId } from './debugId'; + +/** The subset of `@remix-run/assets`' `AssetServer` this needs. */ +export interface AssetServerLike { + fetch(request: Request): Promise; + getPreloads(filePath: string | readonly string[]): Promise; + [UNSTAMPED_FETCH]?: (request: Request) => Promise; +} + +export interface EmitAssetsOptions { + /** The app's own asset server. Driving the real one is what makes the debug IDs match production. */ + assets: AssetServerLike; + /** Browser entry paths, e.g. `['app/actions/public/entry.ts']`. */ + entries: readonly string[]; + outDir: string; +} + +export interface EmittedAsset { + /** Public URL the module is served from. */ + url: string; + file: string; + /** Written when the asset server produced a source map for the module. */ + mapFile?: string; + debugId?: string; +} + +/** + * Compile a Remix 3 app's browser module graph to disk, for a source map upload. + * + * Remix 3 has no build step, so there is no output directory to upload. This drives the app's own + * asset server instead: same config, so the same compiled output and the same debug IDs the running + * server serves. `getPreloads()` is what forces compilation; the server compiles lazily, so without + * it only the modules something happened to request would exist. + */ +export async function emitAssets(options: EmitAssetsOptions): Promise { + const { assets, entries, outDir } = options; + // Source maps are hidden by default, so they have to be read from the unstamped server. + const fetchMap: AssetServerLike['fetch'] = assets[UNSTAMPED_FETCH] ?? (request => assets.fetch(request)); + + const emitted: EmittedAsset[] = []; + + for (const url of await assets.getPreloads([...entries])) { + const response = await assets.fetch(new Request(`http://asset-server${url}`)); + if (response?.status !== 200) { + continue; + } + + const source = await response.text(); + const debugId = findDebugId(source); + const file = path.join(outDir, toRelativePath(url)); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, source); + + const asset: EmittedAsset = { url, file, debugId }; + + const mapResponse = await fetchMap(new Request(`http://asset-server${toMapUrl(url)}`)); + if (mapResponse?.status === 200) { + const map = await mapResponse.text(); + asset.mapFile = `${file}.map`; + fs.writeFileSync(asset.mapFile, debugId ? addDebugIdToSourceMap(map, debugId) : map); + } + + emitted.push(asset); + } + + return emitted; +} + +// The server resolves the map from the pathname, so `.map` goes before any query. +function toMapUrl(url: string): string { + const queryIndex = url.indexOf('?'); + return queryIndex === -1 ? `${url}.map` : `${url.slice(0, queryIndex)}.map${url.slice(queryIndex)}`; +} + +// Decoded, so the tree on disk mirrors the module layout rather than the asset server's encoding. +// Debug ID matching does not depend on these paths. `..` is dropped so the file stays under `outDir`. +function toRelativePath(url: string): string { + const pathname = url.split('?')[0] ?? url; + return path.join( + ...pathname + .replace(/^\//, '') + .split('/') + .map(segment => decodeURIComponent(segment).replace(/[/\\]/g, '_')) + .filter(segment => segment !== '..'), + ); +} diff --git a/packages/remix/src/v3/index.server.ts b/packages/remix/src/v3/index.server.ts index 422e5b31e7f6..75b7a65b2cc2 100644 --- a/packages/remix/src/v3/index.server.ts +++ b/packages/remix/src/v3/index.server.ts @@ -5,5 +5,7 @@ export { remixV3Integration } from './server/integration'; export { sentryRemixMiddleware } from './server/middleware'; export { instrumentRemixV3 } from './server/instrument'; export { instrumentAssetServer } from './assetServer'; +export { emitAssets } from './emitAssets'; +export type { EmitAssetsOptions, EmittedAsset } from './emitAssets'; export { defaultShouldHandleError } from './server/errorFilter'; export type { ShouldHandleError } from './server/errorFilter'; diff --git a/packages/remix/src/v3/server/instrument.ts b/packages/remix/src/v3/server/instrument.ts index d16fff4746af..2fee25db560b 100644 --- a/packages/remix/src/v3/server/instrument.ts +++ b/packages/remix/src/v3/server/instrument.ts @@ -1,5 +1,6 @@ import * as diagnosticsChannel from 'node:diagnostics_channel'; import { createMultiMatcher } from 'remix/route-pattern/match'; +import { consoleSandbox } from '@sentry/core'; import { remixV3Channels } from '@sentry/server-utils/orchestrion/config'; import type { MatcherLike, RequestListenerOptionsLike, RouterOptionsLike } from '../types'; @@ -97,9 +98,12 @@ function injectOnError(raw: Record | undefined): void { return appOnError(error); } - // Setting `onError` replaced the listener's default handler, which logs the error. - // oxlint-disable-next-line no-console - console.error(error); + // Setting `onError` replaced the listener's default handler, which logs the error. Sandboxed so + // the SDK's console instrumentation does not report it a second time. + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.error(error); + }); return undefined; }; } diff --git a/packages/remix/test/v3/emitAssets.test.ts b/packages/remix/test/v3/emitAssets.test.ts new file mode 100644 index 000000000000..451b71a3f94d --- /dev/null +++ b/packages/remix/test/v3/emitAssets.test.ts @@ -0,0 +1,121 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { UNSTAMPED_FETCH } from '../../src/v3/assetServer'; +import { getDebugId, getDebugIdSnippet } from '../../src/v3/debugId'; +import { emitAssets } from '../../src/v3/emitAssets'; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function outDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'emit-assets-')); + dirs.push(dir); + return dir; +} + +/** What an instrumented asset server looks like: stamped modules, maps only through the unstamped fetch. */ +function fakeServer(modules: Record) { + const stamped = async (request: Request): Promise => { + const { pathname } = new URL(request.url); + const source = modules[pathname]; + if (source === undefined) { + return null; + } + const id = getDebugId(source); + return new Response(`${getDebugIdSnippet(id)}\n${source}\n//# debugId=${id}`, { + headers: { 'content-type': 'application/javascript' }, + }); + }; + const unstamped = async (request: Request): Promise => { + const { pathname } = new URL(request.url); + if (!pathname.endsWith('.map') || modules[pathname.slice(0, -4)] === undefined) { + return null; + } + return new Response('{"version":3,"mappings":"AAAA"}', { headers: { 'content-type': 'application/json' } }); + }; + return { + fetch: stamped, + [UNSTAMPED_FETCH]: unstamped, + getPreloads: async () => Object.keys(modules), + }; +} + +describe('emitAssets', () => { + it('writes every module in the graph with a map, and both carry the same debug ID', async () => { + const dir = outDir(); + const assets = fakeServer({ + '/assets/app/entry.ts': 'export const a = 1;', + '/assets/npm/%40remix-run/ui/run.js': 'export const b = 2;', + }); + + const emitted = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: dir }); + + expect(emitted).toHaveLength(2); + for (const asset of emitted) { + expect(asset.debugId).toBeDefined(); + expect(asset.mapFile).toBeDefined(); + expect(fs.readFileSync(asset.file, 'utf8')).toContain(`//# debugId=${asset.debugId}`); + expect(JSON.parse(fs.readFileSync(asset.mapFile as string, 'utf8'))).toMatchObject({ debugId: asset.debugId }); + } + // Decoded on disk, so the tree mirrors the module layout. + expect(emitted[1]?.file).toBe(path.join(dir, 'assets', 'npm', '@remix-run', 'ui', 'run.js')); + }); + + it('reads source maps through the unstamped fetch, since stamping hides them', async () => { + const assets = fakeServer({ '/assets/app/entry.ts': 'export const a = 1;' }); + // What the stamped server answers for a hidden map. + expect(await assets.fetch(new Request('http://x/assets/app/entry.ts.map'))).toBeNull(); + + const [asset] = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: outDir() }); + + expect(asset?.mapFile).toBeDefined(); + }); + + it('asks for the map on the pathname when the URL carries a query', async () => { + const urls: string[] = []; + const assets = { + fetch: async (request: Request) => { + urls.push(request.url); + return request.url.includes('.map') ? new Response('{"version":3}') : new Response('export const a = 1;'); + }, + getPreloads: async () => ['/assets/app/entry.ts?transform=x'], + }; + + const [asset] = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: outDir() }); + + expect(urls).toContain('http://asset-server/assets/app/entry.ts.map?transform=x'); + expect(asset?.mapFile).toBeDefined(); + }); + + it('keeps every file under outDir, whatever the URL looks like', async () => { + const dir = outDir(); + const assets = { + fetch: async (request: Request) => (request.url.endsWith('.map') ? null : new Response('export const a = 1;')), + getPreloads: async () => ['/assets/%2e%2e/%2e%2e/escaped.ts'], + }; + + const [asset] = await emitAssets({ assets, entries: ['escaped.ts'], outDir: dir }); + + expect(asset?.file).toBe(path.join(dir, 'assets', 'escaped.ts')); + }); + + it('emits a module without a debug ID as served, so the caller can tell', async () => { + const assets = { + fetch: async (request: Request) => + new URL(request.url).pathname.endsWith('.map') ? null : new Response('export const a = 1;'), + getPreloads: async () => ['/assets/app/entry.ts'], + }; + + const [asset] = await emitAssets({ assets, entries: ['app/entry.ts'], outDir: outDir() }); + + expect(asset?.debugId).toBeUndefined(); + expect(asset?.mapFile).toBeUndefined(); + }); +}); diff --git a/packages/remix/test/v3/instrument.test.ts b/packages/remix/test/v3/instrument.test.ts index 1fa4e14d0513..1a564d1f1758 100644 --- a/packages/remix/test/v3/instrument.test.ts +++ b/packages/remix/test/v3/instrument.test.ts @@ -9,6 +9,8 @@ const captureException = vi.fn(); vi.mock('@sentry/core', async importOriginal => ({ ...(await importOriginal()), captureException: (...args: unknown[]) => captureException(...args), + // Runs the callback as is, so the spy on `console.error` below sees the call. + consoleSandbox: (callback: () => unknown) => callback(), })); const { instrumentRemixV3 } = await import('../../src/v3/server/instrument'); From d880cf06412fa5c797267ad39c18e8d6524751d7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Fri, 2 Oct 2026 13:36:50 +0200 Subject: [PATCH 56/57] ref(core): Use SDK metadata constants from conventions (#24978) Uses `SENTRY_RELEASE`, `SENTRY_ENVIRONMENT`, and `SENTRY_SDK_INTEGRATIONS` from `@sentry/conventions/attributes`. Deprecates the existing core constants while preserving their public exports and identical attribute keys; emitted values, defaults, and serialization remain unchanged. Part of #24961 / [JS-3868](https://linear.app/getsentry/issue/JS-3868). Co-authored-by: GPT-6 --- .../public-api/startSpan/streamed/test.ts | 14 +++++----- .../navigation-streamed/test.ts | 8 +++--- .../pageload-streamed/test.ts | 8 +++--- .../suites/tracing/interactions/spans/test.ts | 16 ++++------- .../suites/public-api/startSpan/test.ts | 28 +++++++++---------- .../suites/tracing/anthropic-ai/test.ts | 5 ++-- .../suites/tracing/d1/test.ts | 10 ++----- .../suites/tracing/google-genai/test.ts | 9 +++--- .../suites/tracing/langchain/test.ts | 5 ++-- .../suites/tracing/openai/test.ts | 5 ++-- .../suites/tracing/workers-ai/test.ts | 9 +++--- .../startSpan/basic-usage-streamed/test.ts | 24 ++++++++-------- packages/core/src/semanticAttributes.ts | 15 ++++++++-- .../core/src/tracing/spans/captureSpan.ts | 14 ++++------ .../lib/tracing/spans/captureSpan.test.ts | 26 ++++++++--------- 15 files changed, 100 insertions(+), 96 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts index 31cc5c7d3853..289a5716746f 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/startSpan/streamed/test.ts @@ -1,11 +1,9 @@ import { expect } from '@playwright/test'; import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -19,6 +17,8 @@ import { SENTRY_TRACE_LIFECYCLE, USER_AGENT_ORIGINAL, SENTRY_STATUS_MESSAGE, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; sentryTest( @@ -101,7 +101,7 @@ sentryTest( type: 'string', value: 'test-span', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, @@ -146,7 +146,7 @@ sentryTest( type: 'string', value: 'test-span', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, @@ -191,7 +191,7 @@ sentryTest( type: 'string', value: 'test-span', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, @@ -260,7 +260,7 @@ sentryTest( type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['SpanStreaming']), }, @@ -276,7 +276,7 @@ sentryTest( type: 'string', value: 'custom', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts index b99e676afaae..5d298cb1e9cd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-streamed/test.ts @@ -1,11 +1,9 @@ import { expect } from '@playwright/test'; import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -13,6 +11,8 @@ import { URL_FULL, URL_PATH, USER_AGENT_ORIGINAL, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -148,7 +148,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['BrowserTracing', 'SpanStreaming']), }, @@ -164,7 +164,7 @@ sentryTest('starts a streamed navigation span on page navigation', async ({ brow type: 'string', value: 'url', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts index 680b868b4659..1fdaaadce2bd 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-streamed/test.ts @@ -1,11 +1,9 @@ import { expect } from '@playwright/test'; import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { BROWSER_NAVIGATION_TYPE, @@ -18,6 +16,8 @@ import { URL_FULL, URL_PATH, USER_AGENT_ORIGINAL, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -156,7 +156,7 @@ sentryTest( type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['BrowserTracing', 'SpanStreaming']), }, @@ -172,7 +172,7 @@ sentryTest( type: 'string', value: 'url', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts index d0d3231f622d..2a6123aa2aa9 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/interactions/spans/test.ts @@ -1,11 +1,5 @@ import { expect } from '@playwright/test'; -import { - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, -} from '@sentry/core'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { SENTRY_IDLE_SPAN_FINISH_REASON, SENTRY_SEGMENT_ID, @@ -16,6 +10,8 @@ import { SENTRY_TRACE_LIFECYCLE, USER_AGENT_ORIGINAL, URL_PATH, + SENTRY_ENVIRONMENT, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { sentryTest } from '../../../../utils/fixtures'; import { shouldSkipTracingTest } from '../../../../utils/helpers'; @@ -96,7 +92,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: SDK_VERSION, }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['BrowserTracing', 'SpanStreaming']), }, @@ -112,7 +108,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: 'custom', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, @@ -165,7 +161,7 @@ sentryTest('captures streamed interaction span tree. @firefox', async ({ browser type: 'string', value: 'Click', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production', }, diff --git a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts index cb73392fce05..f5a787bbc91e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/public-api/startSpan/test.ts @@ -1,11 +1,8 @@ import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -14,6 +11,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; @@ -88,9 +88,9 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, name: 'test-child-span', is_segment: false, @@ -113,8 +113,8 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, links: [ { @@ -150,8 +150,8 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, name: 'test-manual-span', is_segment: false, @@ -172,9 +172,9 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, }, name: 'test-span', is_segment: false, @@ -192,18 +192,18 @@ it('sends a streamed span envelope with correct spans for a manually started spa [SENTRY_TRACE_LIFECYCLE]: { type: 'string', value: 'stream' }, [SENTRY_SDK_NAME]: { type: 'string', value: CLOUDFLARE_SDK }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['SpanStreaming']), }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.http.cloudflare' }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentName }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'http.server' }, [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'route' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, 'server.address': { type: 'string', value: 'localhost', diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index b68a41258aa0..854c492fc890 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -17,8 +17,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -71,7 +72,7 @@ it('traces a basic message creation request with the anthropic SDK', async ({ si [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts index 5b301a9f29ae..2cd081b06b51 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/d1/test.ts @@ -1,17 +1,13 @@ import { expect, it } from 'vitest'; import type { Envelope, SerializedStreamedSpan } from '@sentry/core'; -import { - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, -} from '@sentry/core'; +import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { SENTRY_SDK_NAME, SENTRY_SDK_VERSION, SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -38,7 +34,7 @@ function commonAttributes(segmentSpan: SerializedStreamedSpan): SerializedStream [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpan.span_id }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: segmentSpan.name }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_OP]: { type: 'string', value: 'db.query' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'auto.db.cloudflare.d1' }, 'db.system.name': { type: 'string', value: 'cloudflare-d1' }, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index 9e8f12d922ba..27fc478b4667 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -24,8 +24,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -98,7 +99,7 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -138,7 +139,7 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); @@ -164,7 +165,7 @@ it('traces Google GenAI chat, generateContent, and embedContent calls', async ({ [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts index 7b4de87424a4..e260bfeff533 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/langchain/test.ts @@ -16,8 +16,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -67,7 +68,7 @@ it('traces a LangChain chat model invocation', async ({ signal }) => { [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index 0a031031a7d7..fafce0225c1e 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -17,8 +17,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { createRunner } from '../../../runner'; import { getSpanOp, getSpansFromEnvelope } from '../../../spanUtils'; @@ -74,7 +75,7 @@ it('traces a basic chat completion request with the openai SDK', async ({ signal [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }); }) diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts index 4eaf940ef9c7..6ed31514dfe4 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/workers-ai/test.ts @@ -16,8 +16,9 @@ import { SENTRY_SEGMENT_ID, SENTRY_SEGMENT_NAME, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, } from '@sentry/conventions/attributes'; -import { SDK_VERSION, SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT } from '@sentry/core'; +import { SDK_VERSION } from '@sentry/core'; import { expect, it } from 'vitest'; import { GEN_AI_REQUEST_STREAM_ATTRIBUTE } from '../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { createRunner } from '../../../runner'; @@ -69,7 +70,7 @@ it('traces a basic Workers AI text generation request', async ({ signal }) => { [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -123,7 +124,7 @@ it('traces a streaming Workers AI text generation request', async ({ signal }) = [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); @@ -169,7 +170,7 @@ it('traces a TypeSafe Jev evaluation like the TypeSafe integration', async ({ si [SENTRY_SEGMENT_ID]: { value: segmentSpan!.span_id, type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.cloudflare', type: 'string' }, [SENTRY_SDK_VERSION]: { value: SDK_VERSION, type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string' }, }, }), ); diff --git a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts index 1d2bd3cd8bfc..2eb452d43386 100644 --- a/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/startSpan/basic-usage-streamed/test.ts @@ -1,11 +1,8 @@ import { SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, } from '@sentry/core'; import { SENTRY_SEGMENT_NAME_SOURCE, @@ -14,6 +11,9 @@ import { SENTRY_SDK_NAME, SENTRY_SDK_VERSION, SENTRY_TRACE_LIFECYCLE, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; import { expect, test } from 'vitest'; import { createRunner } from '../../../../utils/runner'; @@ -68,8 +68,8 @@ test('sends a streamed span envelope with correct spans for a manually started s [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, }, name: 'test-child-span', @@ -92,8 +92,8 @@ test('sends a streamed span envelope with correct spans for a manually started s [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, }, links: [ @@ -129,8 +129,8 @@ test('sends a streamed span envelope with correct spans for a manually started s [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, }, name: 'test-manual-span', @@ -150,14 +150,14 @@ test('sends a streamed span envelope with correct spans for a manually started s [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: { type: 'integer', value: 1 }, [SENTRY_SDK_NAME]: { type: 'string', value: EXPECTED_SDK_NAME }, [SENTRY_SDK_VERSION]: { type: 'string', value: SDK_VERSION }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: expect.arrayContaining(['SpanStreaming']), }, [SENTRY_SEGMENT_ID]: { type: 'string', value: segmentSpanId }, [SENTRY_SEGMENT_NAME]: { type: 'string', value: 'test-span' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, + [SENTRY_RELEASE]: { type: 'string', value: '1.0.0' }, + [SENTRY_ENVIRONMENT]: { type: 'string', value: 'production' }, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: { type: 'string', value: 'manual' }, [SENTRY_SEGMENT_NAME_SOURCE]: { type: 'string', value: 'custom' }, 'process.runtime.engine.name': { type: 'string', value: 'v8' }, diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index 2a8fd22cbf4e..f77eb63b8f56 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -47,9 +47,15 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; -/** The release version of the application */ +/** + * The release version of the application + * @deprecated Use `SENTRY_RELEASE` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_RELEASE = 'sentry.release'; -/** The environment name (e.g., "production", "staging", "development") */ +/** + * The environment name (e.g., "production", "staging", "development") + * @deprecated Use `SENTRY_ENVIRONMENT` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT = 'sentry.environment'; /** * The segment name (e.g., "GET /users") @@ -71,7 +77,10 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_NAME = 'sentry.sdk.name'; * @deprecated Use `SENTRY_SDK_VERSION` `@sentry/conventions/attributes` instead. */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_VERSION = 'sentry.sdk.version'; -/** The list of integrations enabled in the Sentry SDK (e.g., ["EventFilters", "BrowserTracing"]) */ +/** + * The list of integrations enabled in the Sentry SDK (e.g., ["EventFilters", "BrowserTracing"]) + * @deprecated Use `SENTRY_SDK_INTEGRATIONS` from `@sentry/conventions/attributes` instead. + */ export const SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS = 'sentry.sdk.integrations'; /** diff --git a/packages/core/src/tracing/spans/captureSpan.ts b/packages/core/src/tracing/spans/captureSpan.ts index 7ac1c693c9f5..60a68697ae58 100644 --- a/packages/core/src/tracing/spans/captureSpan.ts +++ b/packages/core/src/tracing/spans/captureSpan.ts @@ -1,11 +1,6 @@ import type { RawAttributes } from '../../attributes'; import type { Client } from '../../client'; import type { ScopeData } from '../../scope'; -import { - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, -} from '../../semanticAttributes'; import type { SerializedStreamedSpan, Span, SpanAttributeValue, SpanJSON, StreamedSpanJSON } from '../../types/span'; import { getCombinedScopeData } from '../../utils/scopeData'; import { @@ -29,6 +24,9 @@ import { USER_ID, USER_IP_ADDRESS, USER_NAME, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; export type SerializedStreamedSpanWithSegmentSpan = SerializedStreamedSpan & { @@ -115,7 +113,7 @@ function applySdkMetadataToSegmentSpan(segmentSpanJSON: StreamedSpanJSON, client if (!integrationNames.length) return; safeSetSpanJSONAttributes(segmentSpanJSON, { - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: integrationNames, + [SENTRY_SDK_INTEGRATIONS]: integrationNames, }); } @@ -136,8 +134,8 @@ function commonSpanAttributes( [SENTRY_SEGMENT_ID]: serializedSegmentSpan.span_id, [SENTRY_SDK_NAME]: sdk?.sdk?.name, [SENTRY_SDK_VERSION]: sdk?.sdk?.version, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: release, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: environment || DEFAULT_ENVIRONMENT, + [SENTRY_RELEASE]: release, + [SENTRY_ENVIRONMENT]: environment || DEFAULT_ENVIRONMENT, [USER_ID]: scopeData.user?.id, [USER_EMAIL]: scopeData.user?.email, [USER_IP_ADDRESS]: scopeData.user?.ip_address, diff --git a/packages/core/test/lib/tracing/spans/captureSpan.test.ts b/packages/core/test/lib/tracing/spans/captureSpan.test.ts index 23b0847ed0de..06f83bea6b0a 100644 --- a/packages/core/test/lib/tracing/spans/captureSpan.test.ts +++ b/packages/core/test/lib/tracing/spans/captureSpan.test.ts @@ -3,12 +3,9 @@ import type { Contexts, Span, StreamedSpanJSON } from '../../../../src'; import { captureSpan, debug, - SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_RELEASE, SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, - SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS, spanStreamingIntegration, startInactiveSpan, startSpan, @@ -33,6 +30,9 @@ import { USER_ID, USER_IP_ADDRESS, USER_NAME, + SENTRY_ENVIRONMENT, + SENTRY_RELEASE, + SENTRY_SDK_INTEGRATIONS, } from '@sentry/conventions/attributes'; describe('captureSpan', () => { @@ -101,11 +101,11 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string', }, @@ -197,11 +197,11 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string', }, @@ -293,11 +293,11 @@ describe('captureSpan', () => { value: 'custom', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string', }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { + [SENTRY_ENVIRONMENT]: { value: 'production', type: 'string', }, @@ -356,11 +356,11 @@ describe('captureSpan', () => { [SENTRY_SEGMENT_NAME]: { value: 'my-span', type: 'string' }, [SENTRY_SEGMENT_ID]: { value: span.spanContext().spanId, type: 'string' }, [SENTRY_SEGMENT_NAME_SOURCE]: { value: 'custom', type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_RELEASE]: { value: '1.0.0', type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string' }, + [SENTRY_RELEASE]: { value: '1.0.0', type: 'string' }, + [SENTRY_ENVIRONMENT]: { value: 'staging', type: 'string' }, [SENTRY_SDK_NAME]: { value: 'sentry.javascript.browser', type: 'string' }, [SENTRY_SDK_VERSION]: { value: '9.0.0', type: 'string' }, - [SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]: { + [SENTRY_SDK_INTEGRATIONS]: { type: 'array', value: ['EventFilters', 'BrowserTracing'], }, @@ -389,7 +389,7 @@ describe('captureSpan', () => { }); expect(serializedChild.is_segment).toBe(false); - expect(serializedChild.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SDK_INTEGRATIONS]).toBeUndefined(); + expect(serializedChild.attributes[SENTRY_SDK_INTEGRATIONS]).toBeUndefined(); }); describe('client hooks', () => { From 1c7308d8e76253d63b16bfbbccc731322bed407e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 2 Oct 2026 13:46:25 +0200 Subject: [PATCH 57/57] meta(changelog): Update changelog for 11.3.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d51009f73a1..8f710752af09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,86 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott -Work in this release was contributed by @Shubham-Padkonde and @tobias-schnabel. Thank you for your contributions! +## 11.3.0 + +### Important Changes + +- **Remix 3 support (alpha, may break in minor releases)** + + - feat(remix): Add Remix 3 browser SDK with a bundled client entry ([#24802](https://github.com/getsentry/sentry-javascript/pull/24802)) + - feat(remix): Add source map upload for Remix 3 ([#24943](https://github.com/getsentry/sentry-javascript/pull/24943)) + - feat(remix): Capture Remix 3 component render errors ([#24873](https://github.com/getsentry/sentry-javascript/pull/24873)) + - feat(remix): Complete Remix 3 server error handling ([#24878](https://github.com/getsentry/sentry-javascript/pull/24878)) + - feat(remix): Inject debug IDs through the Remix 3 asset server ([#24761](https://github.com/getsentry/sentry-javascript/pull/24761)) + - feat(remix): Run the orchestrion transform on Remix 3 browser modules ([#24894](https://github.com/getsentry/sentry-javascript/pull/24894)) + +- **feat(nextjs): Add `cache_origin` span links to `use cache` hit spans ([#24821](https://github.com/getsentry/sentry-javascript/pull/24821))** + + A `cache.get` span for a `"use cache"` hit now carries a span link (`sentry.link.type: 'cache_origin'`) to the `cache.put` span of the request that filled the cache entry, connecting the trace that reads a cached value to the trace that produced it. + +- **fix(cloudflare): Don't treat DO constructor work as incoming RPC calls ([#24829](https://github.com/getsentry/sentry-javascript/pull/24829))** + + Durable Object constructors now run in their own isolation scope. When work that the constructor starts, for example a `blockConcurrencyWhile` callback, calls a method of the instance, that call is no longer treated as an incoming RPC call, so an error the instance catches itself is no longer reported as unhandled. As a result, `Sentry.setTag()`, `setUser()` and `setContext()` in a constructor now apply only to that constructor work. They no longer reach later `fetch`, RPC or `alarm` invocations, because the scope they used to write to is shared by every Durable Object and handler in the isolate. Use `initialScope` for static data, and set per-instance data in each handler. + +- **fix(effect)!: Change peerDependency to v4 and fix currentSpan ([#24940](https://github.com/getsentry/sentry-javascript/pull/24940))** + + `@sentry/effect` now requires a stable Effect v4 release. Effect v4 beta and release candidate versions are no longer supported. `@sentry/effect` is in alpha, so this ships in a minor release. + +### Other Changes -- **fix(cloudflare)**: Durable Object constructors now run in their own isolation scope. When work that the constructor starts, for example a `blockConcurrencyWhile` callback, calls a method of the instance, that call is no longer treated as an incoming RPC call, so an error the instance catches itself is no longer reported as unhandled. As a result, `Sentry.setTag()`, `setUser()` and `setContext()` in a constructor now apply only to that constructor work. They no longer reach later `fetch`, RPC or `alarm` invocations, because the scope they used to write to is shared by every Durable Object and handler in the isolate. Use `initialScope` for static data, and set per-instance data in each handler. +- feat(astro): Register astro route provider ([#23792](https://github.com/getsentry/sentry-javascript/pull/23792)) +- feat(cloudflare): Trace TypeSafe Jev calls in Workers AI as evaluate spans ([#24833](https://github.com/getsentry/sentry-javascript/pull/24833)) +- feat(core): Add route provider API for parameterized route resolution ([#23551](https://github.com/getsentry/sentry-javascript/pull/23551)) +- feat(deps): bump devalue from 5.9.2 to 5.9.4 ([#24964](https://github.com/getsentry/sentry-javascript/pull/24964)) +- feat(effect): Add opt-in for external span parents and isolate root spans ([#23900](https://github.com/getsentry/sentry-javascript/pull/23900)) +- feat(nextjs): Register nextjs route provider ([#23552](https://github.com/getsentry/sentry-javascript/pull/23552)) +- feat(nuxt): Register nuxt route provider ([#23794](https://github.com/getsentry/sentry-javascript/pull/23794)) +- feat(remix): Register remix route provider ([#23791](https://github.com/getsentry/sentry-javascript/pull/23791)) +- feat(vue): Register Vue route provider ([#23793](https://github.com/getsentry/sentry-javascript/pull/23793)) +- fix(bundler-plugins): Keep debug IDs on Vite source maps after the preload rewrite ([#24920](https://github.com/getsentry/sentry-javascript/pull/24920)) +- fix(bundler-plugins): Stabilize debug IDs for Rolldown and Vite 8 builds ([#24919](https://github.com/getsentry/sentry-javascript/pull/24919)) +- fix(cloudflare): Skip binding instrumentation work when spans are not sent ([#24655](https://github.com/getsentry/sentry-javascript/pull/24655)) +- fix(core, node, bun, deno): Align server span client address with event IP ([#24767](https://github.com/getsentry/sentry-javascript/pull/24767)) +- fix(hono): Return the existing client on repeated init in Bun and Deno ([#24520](https://github.com/getsentry/sentry-javascript/pull/24520)) +- fix(nitro): Capture errors only through the Nitro error hook ([#24952](https://github.com/getsentry/sentry-javascript/pull/24952)) +- fix(profiling-node): Send profile chunks with `client.sendEnvelope` ([#24896](https://github.com/getsentry/sentry-javascript/pull/24896)) +- fix(react-router): Avoid duplicating Vite config arrays ([#24930](https://github.com/getsentry/sentry-javascript/pull/24930)) +- fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages ([#24866](https://github.com/getsentry/sentry-javascript/pull/24866)) +- fix(server-utils): Match the Anthropic stream helper header regardless of case ([#24855](https://github.com/getsentry/sentry-javascript/pull/24855)) +- fix(server-utils): Skip Fastify 4xx errors raised before the reply status is set ([#24924](https://github.com/getsentry/sentry-javascript/pull/24924)) +- fix(tanstackstart-react): Avoid duplicating Vite config arrays ([#24929](https://github.com/getsentry/sentry-javascript/pull/24929)) + +
+ Internal Changes + +- chore(deps-dev): bump @vercel/nft from 1.5.0 to 1.11.0 ([#24954](https://github.com/getsentry/sentry-javascript/pull/24954)) +- chore(deps): Bump @sentry/conventions to 0.25.0 ([#24958](https://github.com/getsentry/sentry-javascript/pull/24958)) +- chore(deps): dev dependency security fixes ([#24275](https://github.com/getsentry/sentry-javascript/pull/24275)) +- chore(size-limit): weekly auto-bump ([#24969](https://github.com/getsentry/sentry-javascript/pull/24969)) +- ci(deps): bump anthropics/claude-code-action from 1.0.210 to 1.0.236 ([#24950](https://github.com/getsentry/sentry-javascript/pull/24950)) +- ci(deps): bump getsentry/craft from 2.30.1 to 2.31.2 ([#24951](https://github.com/getsentry/sentry-javascript/pull/24951)) +- ci(deps): bump getsentry/github-workflows/validate-pr from 4013fc6e1aeb1be1f9d3b4d232624f0ec1afa613 to 36c729264d2edc29ebae61950c50e1e9f043ad7e ([#24949](https://github.com/getsentry/sentry-javascript/pull/24949)) +- ci(deps): bump pnpm/action-setup from 6.0.10 to 6.1.0 ([#24948](https://github.com/getsentry/sentry-javascript/pull/24948)) +- license: Add cli FSL notice ([#24956](https://github.com/getsentry/sentry-javascript/pull/24956)) +- ref(core): Use cache attribute constants from conventions ([#24973](https://github.com/getsentry/sentry-javascript/pull/24973)) +- ref(core): Use conversation ID constant from conventions ([#24966](https://github.com/getsentry/sentry-javascript/pull/24966)) +- ref(core): Use exclusive time constant from conventions ([#24971](https://github.com/getsentry/sentry-javascript/pull/24971)) +- ref(core): Use HTTP method constant from conventions ([#24977](https://github.com/getsentry/sentry-javascript/pull/24977)) +- ref(core): Use idle span finish reason constant from conventions ([#24970](https://github.com/getsentry/sentry-javascript/pull/24970)) +- ref(core): Use profile ID constant from conventions ([#24976](https://github.com/getsentry/sentry-javascript/pull/24976)) +- ref(core): Use SDK metadata constants from conventions ([#24978](https://github.com/getsentry/sentry-javascript/pull/24978)) +- ref(core): Use status message constant from conventions ([#24967](https://github.com/getsentry/sentry-javascript/pull/24967)) +- ref(core): Use user attribute constants from conventions ([#24974](https://github.com/getsentry/sentry-javascript/pull/24974)) +- ref(server-utils): Clarify API promise helper naming and references ([#24928](https://github.com/getsentry/sentry-javascript/pull/24928)) +- test(cloudflare): Add Flue E2E test that deploys a real Worker and sends to Sentry ([#24816](https://github.com/getsentry/sentry-javascript/pull/24816)) +- test(cloudflare): Match the expected error in the WebSocket e2e tests ([#24923](https://github.com/getsentry/sentry-javascript/pull/24923)) +- test(deno): Port integration tests to span streaming ([#24870](https://github.com/getsentry/sentry-javascript/pull/24870)) +- test(e2e): Add node-anthropic-send-to-sentry test app ([#24856](https://github.com/getsentry/sentry-javascript/pull/24856)) +- test(e2e): Avoid rate limits when polling Sentry in send-to-sentry tests ([#24957](https://github.com/getsentry/sentry-javascript/pull/24957)) + +
+ +Work in this release was contributed by @Shubham-Padkonde and @tobias-schnabel. Thank you for your contributions! ## 11.2.0 @@ -95,12 +172,6 @@ Work in this release was contributed by @Shubham-Padkonde and @tobias-schnabel. Work in this release was contributed by @nabi-noor, @LuccaRebelloToledo, @andasan, @breken-ai, @EmileBrunelle, and @diobriggs. Thank you for your contributions! -### Important Changes - -- **feat(nextjs): Add `cache_origin` span links to `use cache` hit spans ([#24821](https://github.com/getsentry/sentry-javascript/pull/24821))** - - A `cache.get` span for a `"use cache"` hit now carries a span link (`sentry.link.type: 'cache_origin'`) to the `cache.put` span of the request that filled the cache entry, connecting the trace that reads a cached value to the trace that produced it. - ## 11.1.0 ### Important Changes