From 094d57dbac99af6adee1a9ce2ed5a3989184607e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 12 Oct 2026 16:48:28 +0200 Subject: [PATCH 01/37] fix(hono): Expose runtime-specific SDK options (#25290) Hono forwards SDK options to each runtime SDK, but its public types only expose core options. This makes TypeScript reject supported settings such as `enableOpenTelemetrySetup`, Node transport options, and Cloudflare RPC bindings. Inherit the Node, Bun, Deno, and Cloudflare option types while retaining Hono's middleware options. Extend the existing option-preservation tests to check that these settings compile and are forwarded. Co-authored-by: Codex --- packages/hono/src/bun/middleware.ts | 5 +++-- packages/hono/src/cloudflare/middleware.ts | 6 +++--- packages/hono/src/deno/middleware.ts | 5 +++-- packages/hono/src/node/middleware.ts | 5 +++-- packages/hono/test/bun/middleware.test.ts | 6 ++++-- packages/hono/test/cloudflare/middleware.test.ts | 8 ++++++-- packages/hono/test/deno/middleware.test.ts | 6 ++++-- packages/hono/test/node/sdk.test.ts | 4 ++++ 8 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/hono/src/bun/middleware.ts b/packages/hono/src/bun/middleware.ts index 20c922c32c0c..0ca1a4e92def 100644 --- a/packages/hono/src/bun/middleware.ts +++ b/packages/hono/src/bun/middleware.ts @@ -1,10 +1,11 @@ -import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import type { BunOptions } from '@sentry/bun'; +import { debug } from '@sentry/core'; import { init } from './sdk'; import { getConnInfo } from 'hono/bun'; import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -export interface HonoBunOptions extends Options, SentryHonoMiddlewareOptions {} +export interface HonoBunOptions extends BunOptions, SentryHonoMiddlewareOptions {} /** * Sentry middleware for Hono running in a Bun runtime environment. diff --git a/packages/hono/src/cloudflare/middleware.ts b/packages/hono/src/cloudflare/middleware.ts index df009ccfb945..ef5349c7d7d6 100644 --- a/packages/hono/src/cloudflare/middleware.ts +++ b/packages/hono/src/cloudflare/middleware.ts @@ -1,5 +1,5 @@ -import { withSentry } from '@sentry/cloudflare'; -import { applySdkMetadata, type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import { withSentry, type CloudflareOptions } from '@sentry/cloudflare'; +import { applySdkMetadata, debug } from '@sentry/core'; import { getConnInfo } from 'hono/cloudflare-workers'; import { applyHonoPatches, @@ -10,7 +10,7 @@ import type { Env, Hono, MiddlewareHandler } from 'hono'; import { buildFilteredIntegrations } from '../shared/buildFilteredIntegrations'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../shared/lowQualityTransactionPatterns'; -export interface HonoCloudflareOptions extends Options, SentryHonoMiddlewareOptions {} +export interface HonoCloudflareOptions extends CloudflareOptions, SentryHonoMiddlewareOptions {} /** * Sentry middleware for Hono on Cloudflare Workers. diff --git a/packages/hono/src/deno/middleware.ts b/packages/hono/src/deno/middleware.ts index 1e63c24d9b65..8a39fc6ecbea 100644 --- a/packages/hono/src/deno/middleware.ts +++ b/packages/hono/src/deno/middleware.ts @@ -1,4 +1,5 @@ -import { type BaseTransportOptions, debug, type Options } from '@sentry/core'; +import { debug } from '@sentry/core'; +import type { DenoOptions } from '@sentry/deno'; import { init } from './sdk'; import { getConnInfo } from 'hono/deno'; import { @@ -8,7 +9,7 @@ import { } from '@sentry/server-utils/no-diagnostic-channels'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -export interface HonoDenoOptions extends Options, SentryHonoMiddlewareOptions {} +export interface HonoDenoOptions extends DenoOptions, SentryHonoMiddlewareOptions {} /** * Sentry middleware for Hono running in a Deno runtime environment. diff --git a/packages/hono/src/node/middleware.ts b/packages/hono/src/node/middleware.ts index 0b4688ef72f4..0d052c6d72ed 100644 --- a/packages/hono/src/node/middleware.ts +++ b/packages/hono/src/node/middleware.ts @@ -1,9 +1,10 @@ -import { type BaseTransportOptions, consoleSandbox, debug, getClient, type Options } from '@sentry/core'; +import { consoleSandbox, debug, getClient } from '@sentry/core'; +import type { NodeOptions } from '@sentry/node'; import { getConnInfo } from '@hono/node-server/conninfo'; import { applyHonoPatches, createHonoRequestMiddleware, type SentryHonoMiddlewareOptions } from '@sentry/server-utils'; import type { Env, Hono, MiddlewareHandler } from 'hono'; -export interface HonoNodeOptions extends Options {} +export interface HonoNodeOptions extends NodeOptions {} /** * Sentry middleware for Hono applications running in a Node.js environment. diff --git a/packages/hono/test/bun/middleware.test.ts b/packages/hono/test/bun/middleware.test.ts index a8ce3baac746..ca8fe21d1b45 100644 --- a/packages/hono/test/bun/middleware.test.ts +++ b/packages/hono/test/bun/middleware.test.ts @@ -2,7 +2,7 @@ import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { sentry } from '../../src/bun/middleware'; +import { sentry, type HonoBunOptions } from '../../src/bun/middleware'; import { init } from '../../src/bun/sdk'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; @@ -102,7 +102,8 @@ describe('Hono Bun Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, - }; + enableOpenTelemetrySetup: true, + } satisfies HonoBunOptions; sentry(app, options); @@ -113,6 +114,7 @@ describe('Hono Bun Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, }), ); }); diff --git a/packages/hono/test/cloudflare/middleware.test.ts b/packages/hono/test/cloudflare/middleware.test.ts index ea09300f3d96..7c50b2823dfa 100644 --- a/packages/hono/test/cloudflare/middleware.test.ts +++ b/packages/hono/test/cloudflare/middleware.test.ts @@ -3,7 +3,7 @@ import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { sentry } from '../../src/cloudflare/middleware'; +import { sentry, type HonoCloudflareOptions } from '../../src/cloudflare/middleware'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; vi.mock('@sentry/cloudflare', { spy: true }); @@ -82,7 +82,9 @@ describe('Hono Cloudflare Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, - }; + enableOpenTelemetrySetup: true, + rpcTracePropagationBindings: ['ORDERS'], + } satisfies HonoCloudflareOptions; sentry(app, options); @@ -95,6 +97,8 @@ describe('Hono Cloudflare Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, + rpcTracePropagationBindings: ['ORDERS'], }); }); diff --git a/packages/hono/test/deno/middleware.test.ts b/packages/hono/test/deno/middleware.test.ts index 3da909433d72..bf19b51804b4 100644 --- a/packages/hono/test/deno/middleware.test.ts +++ b/packages/hono/test/deno/middleware.test.ts @@ -2,7 +2,7 @@ import * as SentryCore from '@sentry/core'; import { SDK_VERSION } from '@sentry/core'; import { Hono } from 'hono'; import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; -import { sentry } from '../../src/deno/middleware'; +import { sentry, type HonoDenoOptions } from '../../src/deno/middleware'; import { init } from '../../src/deno/sdk'; import { LOW_QUALITY_TRANSACTION_PATTERNS } from '../../src/shared/lowQualityTransactionPatterns'; @@ -102,7 +102,8 @@ describe('Hono Deno Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, - }; + enableOpenTelemetrySetup: false, + } satisfies HonoDenoOptions; sentry(app, options); @@ -113,6 +114,7 @@ describe('Hono Deno Middleware', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: false, }), ); }); diff --git a/packages/hono/test/node/sdk.test.ts b/packages/hono/test/node/sdk.test.ts index d5045d7ff343..f9b90c8f6dfd 100644 --- a/packages/hono/test/node/sdk.test.ts +++ b/packages/hono/test/node/sdk.test.ts @@ -82,6 +82,8 @@ describe('Hono Node SDK – init()', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, + transportOptions: { proxy: 'http://proxy.example.com:8080' }, }); expect(initNodeMock).toHaveBeenCalledWith( @@ -90,6 +92,8 @@ describe('Hono Node SDK – init()', () => { sampleRate: 0.5, tracesSampleRate: 1.0, debug: true, + enableOpenTelemetrySetup: true, + transportOptions: { proxy: 'http://proxy.example.com:8080' }, }), ); }); From 882d561b8b2081806a93343c33f31f3404c66d9c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 10:23:17 +0200 Subject: [PATCH 02/37] test(node): Cover ioredis v6 in the diagnostics_channel suite (#25300) ioredis 6.0.0 keeps the same `ioredis:command` / `ioredis:connect` channels and payload as 5.11, so `redisIntegration` already works with it; this adds an `ioredis-6` alias and runs the existing `ioredis-dc` expectations against both. Co-authored-by: Claude Fable 5.1 --- .../node-integration-tests/package.json | 1 + .../ioredis-dc/scenario-ioredis-5-11.mjs | 4 +- .../tracing/ioredis-dc/scenario-ioredis-6.mjs | 33 +++++ .../suites/tracing/ioredis-dc/test.ts | 134 ++++++++++-------- yarn.lock | 17 +++ 5 files changed, 124 insertions(+), 65 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-6.mjs diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index ce38c3e936f8..39fb5d4d34cf 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -77,6 +77,7 @@ "http-terminator": "^3.2.0", "ioredis": "5.10.1", "ioredis-5": "npm:ioredis@^5.11.0", + "ioredis-6": "npm:ioredis@^6.0.0", "kafkajs": "2.2.4", "knex": "^2.5.1", "koa": "^2.15.2", diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs index 499bff7fb206..402adb6f364f 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-5-11.mjs @@ -8,8 +8,8 @@ async function run() { await Sentry.startSpan( { - name: 'Test Span IORedis 5.11 DC', - op: 'test-span-ioredis-5-11-dc', + name: 'Test Span IORedis DC', + op: 'test-span-ioredis-dc', }, async () => { try { diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-6.mjs b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-6.mjs new file mode 100644 index 000000000000..994c679d9d3a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/scenario-ioredis-6.mjs @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/node'; + +async function run() { + const { default: Redis } = await import('ioredis-6'); + const redisClient = new Redis({ host: '127.0.0.1', port: 6382, lazyConnect: true }); + + await redisClient.connect(); + + await Sentry.startSpan( + { + name: 'Test Span IORedis DC', + op: 'test-span-ioredis-dc', + }, + async () => { + try { + await redisClient.set('dc-test-key', 'test-value'); + await redisClient.set('dc-cache:test-key', 'test-value'); + + await redisClient.set('dc-cache:test-key-ex', 'test-value', 'EX', 10); + + await redisClient.get('dc-test-key'); + await redisClient.get('dc-cache:test-key'); + await redisClient.get('dc-cache:unavailable-data'); + + await redisClient.mget('dc-test-key', 'dc-cache:test-key', 'dc-cache:unavailable-data'); + } finally { + await redisClient.disconnect(); + } + }, + ); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts index d854e0386637..71db70a5425a 100644 --- a/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/ioredis-dc/test.ts @@ -27,7 +27,7 @@ import { EXPECTED_SDK_NAME } from '../../../utils'; import { cleanupChildProcesses, createEsmAndCjsTests, describeWithDockerCompose } from '../../../utils/runner'; describeWithDockerCompose( - 'ioredis v5.11 diagnostics_channel auto instrumentation', + 'ioredis >=5.11 diagnostics_channel auto instrumentation', { workingDirectory: [__dirname] }, () => { afterAll(() => { @@ -35,7 +35,7 @@ describeWithDockerCompose( }); const ORIGIN = 'auto.db.redis.diagnostic_channel'; - const SEGMENT_NAME = 'Test Span IORedis 5.11 DC'; + const SEGMENT_NAME = 'Test Span IORedis DC'; const HOST = '127.0.0.1'; const PORT = 6382; @@ -89,69 +89,77 @@ describeWithDockerCompose( attributes: Record, ): unknown => streamedSpan(op, op, { ...PEER, [CACHE_OPERATION]: op.slice('cache.'.length), ...attributes }); - createEsmAndCjsTests(__dirname, 'scenario-ioredis-5-11.mjs', 'instrument.mjs', (createTestRunner, test) => { - test('creates spans for ioredis v5.11 commands via diagnostics_channel', { timeout: 75_000 }, async () => { - await createTestRunner() - .expect({ - span: container => { - // The connect span opens its own segment but shares the trace with the test span, - // so both segments arrive in the same container. - expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ - 'redis-connect', - SEGMENT_NAME, - ]); + const clients = [ + { label: 'v5.11', scenario: 'scenario-ioredis-5-11.mjs' }, + // v6 defaults to RESP3 and adds a `hello` handshake; the channel payload is unchanged. + { label: 'v6', scenario: 'scenario-ioredis-6.mjs' }, + ]; - // ioredis' own handshake commands (`client SETINFO`, `info`) are emitted on the - // channel too, but belong to the connect segment — the test span's children are the - // commands the scenario issues. - const spans = container.items.filter( - item => !item.is_segment && item.attributes[SENTRY_SEGMENT_NAME]?.value === SEGMENT_NAME, - ); + for (const { label, scenario } of clients) { + createEsmAndCjsTests(__dirname, scenario, 'instrument.mjs', (createTestRunner, test) => { + test(`creates spans for ioredis ${label} commands via diagnostics_channel`, { timeout: 75_000 }, async () => { + await createTestRunner() + .expect({ + span: container => { + // The connect span opens its own segment but shares the trace with the test span, + // so both segments arrive in the same container. + expect(container.items.filter(item => item.is_segment).map(item => item.name)).toEqual([ + 'redis-connect', + SEGMENT_NAME, + ]); - expect(spans).toEqual([ - streamedSpan(`set ${HOST}:${PORT}`, DB_QUERY, { - [DB_OPERATION_NAME]: 'set', - [DB_QUERY_TEXT]: 'set dc-test-key ?', - }), - cacheSpan(CACHE_PUT, { - [DB_OPERATION_NAME]: 'set', - [DB_QUERY_TEXT]: 'set dc-cache:test-key ?', - [CACHE_KEY]: ['dc-cache:test-key'], - [CACHE_ITEM_SIZE]: 2, - }), - cacheSpan(CACHE_PUT, { - [DB_OPERATION_NAME]: 'set', - [DB_QUERY_TEXT]: 'set dc-cache:test-key-ex ? ? ?', - [CACHE_KEY]: ['dc-cache:test-key-ex'], - [CACHE_ITEM_SIZE]: 2, - }), - streamedSpan(`get ${HOST}:${PORT}`, DB_QUERY, { - [DB_OPERATION_NAME]: 'get', - [DB_QUERY_TEXT]: 'get dc-test-key', - }), - cacheSpan(CACHE_GET, { - [DB_OPERATION_NAME]: 'get', - [DB_QUERY_TEXT]: 'get dc-cache:test-key', - [CACHE_KEY]: ['dc-cache:test-key'], - [CACHE_HIT]: true, - [CACHE_ITEM_SIZE]: 10, - }), - cacheSpan(CACHE_GET, { - [DB_OPERATION_NAME]: 'get', - [DB_QUERY_TEXT]: 'get dc-cache:unavailable-data', - [CACHE_KEY]: ['dc-cache:unavailable-data'], - [CACHE_HIT]: false, - }), - streamedSpan(`mget ${HOST}:${PORT}`, DB_QUERY, { - [DB_OPERATION_NAME]: 'mget', - [DB_QUERY_TEXT]: 'mget ? ? ?', - }), - ]); - }, - }) - .start() - .completed(); + // ioredis' own handshake commands (`client SETINFO`, `info`) are emitted on the + // channel too, but belong to the connect segment — the test span's children are the + // commands the scenario issues. + const spans = container.items.filter( + item => !item.is_segment && item.attributes[SENTRY_SEGMENT_NAME]?.value === SEGMENT_NAME, + ); + + expect(spans).toEqual([ + streamedSpan(`set ${HOST}:${PORT}`, DB_QUERY, { + [DB_OPERATION_NAME]: 'set', + [DB_QUERY_TEXT]: 'set dc-test-key ?', + }), + cacheSpan(CACHE_PUT, { + [DB_OPERATION_NAME]: 'set', + [DB_QUERY_TEXT]: 'set dc-cache:test-key ?', + [CACHE_KEY]: ['dc-cache:test-key'], + [CACHE_ITEM_SIZE]: 2, + }), + cacheSpan(CACHE_PUT, { + [DB_OPERATION_NAME]: 'set', + [DB_QUERY_TEXT]: 'set dc-cache:test-key-ex ? ? ?', + [CACHE_KEY]: ['dc-cache:test-key-ex'], + [CACHE_ITEM_SIZE]: 2, + }), + streamedSpan(`get ${HOST}:${PORT}`, DB_QUERY, { + [DB_OPERATION_NAME]: 'get', + [DB_QUERY_TEXT]: 'get dc-test-key', + }), + cacheSpan(CACHE_GET, { + [DB_OPERATION_NAME]: 'get', + [DB_QUERY_TEXT]: 'get dc-cache:test-key', + [CACHE_KEY]: ['dc-cache:test-key'], + [CACHE_HIT]: true, + [CACHE_ITEM_SIZE]: 10, + }), + cacheSpan(CACHE_GET, { + [DB_OPERATION_NAME]: 'get', + [DB_QUERY_TEXT]: 'get dc-cache:unavailable-data', + [CACHE_KEY]: ['dc-cache:unavailable-data'], + [CACHE_HIT]: false, + }), + streamedSpan(`mget ${HOST}:${PORT}`, DB_QUERY, { + [DB_OPERATION_NAME]: 'mget', + [DB_QUERY_TEXT]: 'mget ? ? ?', + }), + ]); + }, + }) + .start() + .completed(); + }); }); - }); + } }, ); diff --git a/yarn.lock b/yarn.lock index fa2ba2499ab2..a34482b44765 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5334,6 +5334,11 @@ resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.5.1.tgz#a0a3449993b10c7aeb91ecb0d5f1a23692297e51" integrity sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw== +"@ioredis/commands@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-2.0.0.tgz#7207c56e0be51fd9f2ec2505ebdb9372a3ceb76d" + integrity sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -17632,6 +17637,18 @@ invariant@^2.2.1, invariant@^2.2.2: redis-parser "3.0.0" standard-as-callback "2.1.0" +"ioredis-6@npm:ioredis@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-6.0.0.tgz#b757c590547c43a5e18ea9c312d05c5c46c7e6fa" + integrity sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w== + dependencies: + "@ioredis/commands" "2.0.0" + cluster-key-slot "1.1.1" + debug "4.4.3" + denque "2.1.0" + redis-errors "1.2.0" + standard-as-callback "2.1.0" + ioredis@5.10.1, ioredis@^5.10.1: version "5.10.1" resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.10.1.tgz#6082781d8aec8d51ee4936bf81d0610404db1e3d" From 8b46546e63d0b72619f2f2e51462b9bb6f3abd20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 13 Oct 2026 10:43:38 +0200 Subject: [PATCH 03/37] fix(tanstackstart-react): Keep parametrized route names in Nitro 3 builds (#25307) closes #25305 closes [JS-3920](https://linear.app/getsentry/issue/JS-3920) ## Description This makes the Vite plugin bundle `@sentry/tanstackstart-react` into server builds, so server spans keep parametrized names like `GET /param/$id` with the current Nitro 3. It also fixes the e2e builds that broke with TanStack Start 1.169: - The `tanstackstart-react (latest)` canary build now also installs the latest Nitro, Vite and Vite plugins. - `tanstackstart-react-cloudflare` moves to Vite 8 (as this test is using the latest minor). ## Why TanStack Start 1.169, the first stable release, needs Vite 8, so builds on Vite 7.3.2 failed. With Vite 8, the current Nitro copies the SDK into `.output/server/node_modules` instead of bundling it, so the route patterns that our plugin writes into the SDK file never reach the server. ## Test decisions The normal `tanstackstart-react` run keeps TanStack Start 1.168 with Vite 7, so only the nightly canary runs the new Nitro. We can move its pins to 1.169 at any time, to test the fix on every PR, but that drops the 1.168 coverage. ## Sentry traces Before, server span named `GET`: https://sentry-sdks.sentry.io/explore/traces/trace/0e6c6d8167f14675a7ff464b6e20cc0d/?node=span-850e1cc693d955b7&project=4510555608449024&source=traces&targetId=850e1cc693d955b7×tamp=1791873042 After, server span named `GET /param/$id`: https://sentry-sdks.sentry.io/explore/traces/trace/715f58fb9cf3470eaa235a2ad7abff73/?node=span-b4454064fab142bc&project=4510555608449024&source=traces&targetId=b4454064fab142bc×tamp=1791873013 Co-authored-by: Claude Opus 5.5 --- .../package.json | 6 ++--- .../tanstackstart-react/package.json | 2 +- .../src/vite/routePatterns.ts | 10 ++++++++ .../test/vite/routePatterns.test.ts | 25 ++++++++++++++++++- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json index 5cf6accf0cd1..688094b3d237 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-cloudflare/package.json @@ -28,10 +28,10 @@ "@sentry-internal/test-utils": "link:../../../test-utils", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@vitejs/plugin-react": "^4.5.0", + "@vitejs/plugin-react": "^6.1.2", "typescript": "^5.9.0", - "vite": "7.3.2", - "vite-tsconfig-paths": "^5.1.4", + "vite": "8.3.4", + "vite-tsconfig-paths": "^6.1.1", "wrangler": "^4.68.1" }, "volta": { diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json index 89dad11c3985..410f41baecbc 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/package.json @@ -14,7 +14,7 @@ "test:build:tunnel-static": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=static E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", "test:build:tunnel-custom": "pnpm install && E2E_TEST_CUSTOM_TUNNEL_ROUTE=1 pnpm build", "test:build:tunnel-object": "pnpm install && E2E_TEST_TUNNEL_ROUTE_MODE=object E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm build", - "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest && pnpm install && pnpm build", + "test:build-latest": "pnpm add @tanstack/react-start@latest @tanstack/react-router@latest nitro@latest vite@latest @vitejs/plugin-react-swc@latest vite-tsconfig-paths@latest && pnpm install && pnpm build", "test:assert:proxy": "pnpm test && TEST_ENV=development pnpm playwright test db-drivers", "test:assert": "pnpm test:assert:proxy", "test:assert:tunnel-generated": "E2E_TEST_TUNNEL_ROUTE_MODE=dynamic E2E_TEST_DSN=http://public@localhost:3031/1337 pnpm test", diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts index 5ffbdaa83c52..82a187d08e70 100644 --- a/packages/tanstackstart-react/src/vite/routePatterns.ts +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -19,6 +19,16 @@ export function makeRoutePatternPlugin(): Plugin { name: 'sentry-tanstackstart-route-patterns', enforce: 'post', + // The transform below must see the SDK's own `wrapFetchWithSentry` file. Vite externalizes dependencies in + // server builds, and Nitro 3 copies external dependencies into its output instead of bundling them. + configEnvironment(name, config, env) { + if (env.command === 'serve' || (config.consumer ?? (name === 'client' ? 'client' : 'server')) !== 'server') { + return null; + } + + return { resolve: { noExternal: ['@sentry/tanstackstart-react'] } }; + }, + configResolved(config) { resolvedRoot = config.root || process.cwd(); }, diff --git a/packages/tanstackstart-react/test/vite/routePatterns.test.ts b/packages/tanstackstart-react/test/vite/routePatterns.test.ts index 413b29adafe1..abc5c92527d9 100644 --- a/packages/tanstackstart-react/test/vite/routePatterns.test.ts +++ b/packages/tanstackstart-react/test/vite/routePatterns.test.ts @@ -2,7 +2,30 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { extractRoutePatterns, findRouteTreeFile } from '../../src/vite/routePatterns'; +import { extractRoutePatterns, findRouteTreeFile, makeRoutePatternPlugin } from '../../src/vite/routePatterns'; + +describe('makeRoutePatternPlugin', () => { + const configEnvironment = makeRoutePatternPlugin().configEnvironment as ( + name: string, + config: { consumer?: 'client' | 'server' }, + env: { command: 'build' | 'serve'; mode: string }, + ) => { resolve: { noExternal: string[] } } | null; + + it('bundles the SDK into server environments of a build', () => { + const build = { command: 'build', mode: 'production' } as const; + + expect(configEnvironment('ssr', {}, build)).toEqual({ resolve: { noExternal: ['@sentry/tanstackstart-react'] } }); + expect(configEnvironment('nitro', { consumer: 'server' }, build)).toEqual({ + resolve: { noExternal: ['@sentry/tanstackstart-react'] }, + }); + }); + + it('does not change client environments or the dev server', () => { + expect(configEnvironment('client', {}, { command: 'build', mode: 'production' })).toBeNull(); + expect(configEnvironment('ssr', { consumer: 'client' }, { command: 'build', mode: 'production' })).toBeNull(); + expect(configEnvironment('ssr', {}, { command: 'serve', mode: 'development' })).toBeNull(); + }); +}); describe('extractRoutePatterns', () => { it('extracts full path patterns from single-line fullPaths union', () => { From c39d672fde6cb8b10b24fc3142e47948f7f791de Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 13 Oct 2026 10:47:01 +0200 Subject: [PATCH 04/37] ref(tanstackstart-react): Reuse shared component annotation options (#25313) Reuse `ReactComponentAnnotationOptions` from `@sentry/core` so TanStack Start's annotation options share the same definition as the other React SDKs. Co-authored-by: GPT-6 --- .../src/vite/sentryTanstackStart.ts | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 0dd7f9f12fda..c3359c077d1b 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,4 +1,4 @@ -import type { BuildTimeOptionsBase } from '@sentry/core'; +import type { BuildTimeOptionsBase, ReactComponentAnnotationOptions } from '@sentry/core'; import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; @@ -31,17 +31,7 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * This will unlock the capability to search for Replays in Sentry by component name, as well as see component names in breadcrumbs and performance monitoring. * Please note that this feature is not currently supported by the esbuild bundler plugins, and will only annotate React components */ - reactComponentAnnotation?: { - /** - * Whether the component name annotate plugin should be enabled or not. - */ - enabled?: boolean; - - /** - * A list of strings representing the names of components to ignore. The plugin will not apply `data-sentry` annotations on the DOM element for these components. - */ - ignoredComponents?: string[]; - }; + reactComponentAnnotation?: ReactComponentAnnotationOptions; /** * Configures a framework-managed same-origin tunnel route for Sentry envelopes. From 73e06f9b0a9650420c8454d237cd5c205365c077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 13 Oct 2026 11:01:10 +0200 Subject: [PATCH 05/37] fix(server-utils): Read pi-durable 1.2.0 tool results from `output` (#25312) Note: this is now backwards compatible, we can also make it pointing to the latest version only This fixes the pi-durable tests for pi-durable 1.2.0, and makes the SDK read tool results from the new `output` field. On 1.2.0, a tool call outside a tool task phase (for example, a tool that calls another tool) lost its result. ## Why pi-durable 1.2.0 (released 2026-10-12) renamed the tool result field `content` to `output`. The test tools still return `{ content }`, so since that release all pi-durable integration tests and both `(latest)` e2e variants fail on `develop`. ## Test decisions The integration suite now installs `^1.2.0` and its tools return `output`. The e2e apps keep the 1.0.0 pin and the latest variant, so their tools set both fields. The alternative is to set both fields in the integration scenarios too and keep `^1.0.0`. ## Known issues - Nested calls through the new `api.executeTool()` of pi-durable 1.2.0 are not tested yet. --------- Co-authored-by: Claude Opus 5.5 --- .../cloudflare-pi-durable/package.json | 4 ++-- .../cloudflare-pi-durable/src/assistant.ts | 4 ++-- .../test-applications/node-pi-durable/package.json | 6 +++--- .../test-applications/node-pi-durable/src/server.mjs | 8 ++++---- .../tracing/pi-durable/scenario-interrupted.mjs | 2 +- .../suites/tracing/pi-durable/scenario-retention.mjs | 2 +- .../suites/tracing/pi-durable/scenario-subagent.mjs | 8 ++++---- .../suites/tracing/pi-durable/scenario-tools.mjs | 6 ++---- .../suites/tracing/pi-durable/scenario.mjs | 2 +- .../suites/tracing/pi-durable/test.ts | 6 +++--- packages/server-utils/src/ai/pi-durable/tools.ts | 2 +- packages/server-utils/src/ai/pi-durable/types.ts | 3 +++ .../test/ai/lib/tracing/pi-durable.test.ts | 11 +++++++++++ 13 files changed, 38 insertions(+), 26 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json index 6699912023e6..80c16c600e11 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json @@ -12,8 +12,8 @@ "test:assert": "playwright test" }, "dependencies": { - "@earendil-works/pi-ai": "1.0.0", - "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/pi-ai": "1.2.0", + "@earendil-works/pi-durable": "1.2.0", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "agents": "0.26.0" }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts index bd0d25a8c5b3..93e1236190cf 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts @@ -47,7 +47,7 @@ export class Assistant extends Agent { // The manual span should nest under the SDK's `execute_tool` span. execute: async args => Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ - content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], + output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], })), }), defineTool({ @@ -67,7 +67,7 @@ export class Assistant extends Agent { execute: async args => { const marker = `crashed:${args.job}`; if (await this.ctx.storage.get(marker)) { - return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; + return { output: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; } await this.ctx.storage.put(marker, true); await this.ctx.storage.sync(); diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json index eb9822667b3a..33bc22b4e00f 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json @@ -11,9 +11,9 @@ "test:assert": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test" }, "dependencies": { - "@earendil-works/chord": "1.0.0", - "@earendil-works/pi-ai": "1.0.0", - "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/chord": "1.2.0", + "@earendil-works/pi-ai": "1.2.0", + "@earendil-works/pi-durable": "1.2.0", "@sentry/node": "file:../../packed/sentry-node-packed.tgz" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs index 79892991f03d..c71c9f4096bf 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs @@ -38,7 +38,7 @@ const tools = [ // The manual span should nest under the SDK's `execute_tool` span. execute: async args => Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ - content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], + output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], })), }), defineTool({ @@ -60,7 +60,7 @@ const tools = [ try { writeFileSync(`${dataDir}crashed-${args.job}`, 'crashed once', { flag: 'wx' }); } catch { - return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; + return { output: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; } process.exit(CRASH_EXIT_CODE); }, @@ -80,14 +80,14 @@ const tools = [ const request = { type: 'input', content: args.task, requestId: `delegate:${api.taskId}` }; const settled = await (await child.submit(request, toolContext)).wait(toolContext); if (settled.status !== 'done') { - return { isError: true, content: [{ type: 'text', text: `Subagent ended: ${settled.reason}` }] }; + return { isError: true, output: [{ type: 'text', text: `Subagent ended: ${settled.reason}` }] }; } const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), toolContext); const text = (answer?.model?.[0]?.content ?? []) .filter(part => part.type === 'text') .map(part => part.text) .join(''); - return { content: [{ type: 'text', text }] }; + return { output: [{ type: 'text', text }] }; }, }), ]; diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs index f0efbea32483..9d15ed6f3a47 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs @@ -65,7 +65,7 @@ registry.install( await new Promise((_, reject) => toolContext.abortSignal.addEventListener('abort', () => reject(new Error('tool aborted'))), ); - return { content: [] }; + return { output: [] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs index dc84d6387ba9..8887587a07c2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs @@ -46,7 +46,7 @@ registry.install( tx => tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } }), toolContext, ); - return { content: [{ type: 'text', text: 'Subagent created.' }] }; + return { output: [{ type: 'text', text: 'Subagent created.' }] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs index da80b508f252..7e6f3413d3dd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs @@ -70,7 +70,7 @@ registry.install( const settled = await ( await child.submit({ type: 'input', content: args.task }, toolContext) ).wait(toolContext); - return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; + return { output: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; }, }), defineTool({ @@ -89,14 +89,14 @@ registry.install( const settled = await ( await child.submit({ type: 'input', content: 'Forked task.' }, toolContext) ).wait(toolContext); - return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; + return { output: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; }, }), defineTool({ name: 'inner', description: 'A plain tool.', parameters: Type.Object({}), - execute: async () => ({ content: [{ type: 'text', text: 'INNER RESULT' }] }), + execute: async () => ({ output: [{ type: 'text', text: 'INNER RESULT' }] }), }), defineTool({ name: 'batch', @@ -105,7 +105,7 @@ registry.install( execute: async (_args, api, toolContext) => { const inner = (await api.agent(toolContext)).tools.find(tool => tool.name === 'inner'); await inner.execute({}, api, toolContext); - return { content: [{ type: 'text', text: 'OUTER RESULT' }] }; + return { output: [{ type: 'text', text: 'OUTER RESULT' }] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs index fc42ebf2091f..fd8a977149fc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs @@ -73,7 +73,7 @@ registry.install( name: 'secret', description: 'Returns a secret.', parameters: Type.Object({}), - execute: async () => ({ content: [{ type: 'text', text: 'original secret value' }] }), + execute: async () => ({ output: [{ type: 'text', text: 'original secret value' }] }), }), defineTool({ name: 'fail_now', @@ -87,9 +87,7 @@ registry.install( hooks: [ hook(ToolTask, { afterTool: (call, result) => - call.name === 'secret' - ? { ...result, content: [{ type: 'text', text: 'redacted by afterTool' }] } - : undefined, + call.name === 'secret' ? { ...result, output: [{ type: 'text', text: 'redacted by afterTool' }] } : undefined, }), ], }), diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs index 42309d5ba2d4..4c2dc1c87172 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs @@ -38,7 +38,7 @@ registry.install( name: 'get_weather', description: 'Get the current weather for a city.', parameters: Type.Object({ city: Type.String() }), - execute: async args => ({ content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }] }), + execute: async args => ({ output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }] }), }), defineTool({ name: 'broken', diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts index 4333c9e0f415..068b389c60d2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts @@ -30,9 +30,9 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // instead, guarded by the `min: 22` skip below. const PI_DURABLE_DEPENDENCIES = { additionalDependencies: { - '@earendil-works/pi-durable': '^1.0.0', - '@earendil-works/pi-ai': '^1.0.0', - '@earendil-works/chord': '^1.0.0', + '@earendil-works/pi-durable': '^1.2.0', + '@earendil-works/pi-ai': '^1.2.0', + '@earendil-works/chord': '^1.2.0', }, }; diff --git a/packages/server-utils/src/ai/pi-durable/tools.ts b/packages/server-utils/src/ai/pi-durable/tools.ts index 5d19f583dc14..8ccf21155549 100644 --- a/packages/server-utils/src/ai/pi-durable/tools.ts +++ b/packages/server-utils/src/ai/pi-durable/tools.ts @@ -138,7 +138,7 @@ export function instrumentTool(tool: PiTool, runs: PiRuns, options: GenAiOptions call.endTimestamp = timestampInSeconds(); if (!calls) { // No result entry follows a call outside a tool task phase, nor a nested call. - finishToolCall(call, { content: result?.content, isError: result?.isError }); + finishToolCall(call, { content: result?.output ?? result?.content, isError: result?.isError }); } return result; } catch (error) { diff --git a/packages/server-utils/src/ai/pi-durable/types.ts b/packages/server-utils/src/ai/pi-durable/types.ts index 6244b18af1e5..7c87317eb26a 100644 --- a/packages/server-utils/src/ai/pi-durable/types.ts +++ b/packages/server-utils/src/ai/pi-durable/types.ts @@ -50,6 +50,9 @@ export interface PiModels { } export interface PiToolExecutionResult { + /** The content blocks the tool returns for the model, in pi-durable 1.2.0 and later. */ + output?: unknown[]; + /** The content blocks the tool returns for the model, in pi-durable versions before 1.2.0. */ content?: unknown[]; isError?: boolean; } diff --git a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts index c2b8f6e9b68e..d23b3310785e 100644 --- a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts @@ -617,6 +617,17 @@ describe('instrumentPiDurableHarnessOptions', () => { }); }); + it('reads the result of a call outside a tool phase from `output`', async () => { + const tool: PiTool = { + name: 'get_weather', + execute: async () => ({ output: [{ type: 'text', text: 'sunny' }] }), + }; + + await instrumentTool(tool, createRuns(), {}).execute({}, { callId: 'call_1' }, undefined); + + expect(spanToJSON(endedSpans[0]!).attributes[GEN_AI_TOOL_CALL_RESULT]).toBe('sunny'); + }); + it.each([ ['no content blocks', []], ['an empty text block', [{ type: 'text', text: '' }]], From 8017de6775400148b5535b6d6288dfa7ae92abe1 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:15:44 +0200 Subject: [PATCH 06/37] ci: Install the pnpm version that Volta pins (#25287) Some E2E apps need a different pnpm version than the root. The `install-pnpm` action now reads `volta.pnpm` with the same lookup the E2E runner uses for overrides. Apps keep their versions, and no workflow hard-codes pnpm anymore. The test `nuxt-5` uses pnpm 11, because pnpm 9.15.9 hangs while installing Nuxt 5 nightlies (fixed issue in #22017). pnpm 9 also rejects these apps' `pnpm-workspace.yaml` (`packages field missing or empty`). CI chose 11.10.0 only for the exact name `nuxt-5`, so `nuxt-5-sourcemaps` (#25268) got 9.15.9 and failed. The canary job ignored app pins entirely. --- .agents/skills/e2e/SKILL.md | 1 + .github/actions/install-pnpm/action.yml | 30 +++++++++++++++++++++ .github/workflows/build.yml | 15 ++++++----- .github/workflows/canary.yml | 5 ++-- .github/workflows/lighthouse.yml | 5 ++-- dev-packages/e2e-tests/README.md | 3 +++ dev-packages/e2e-tests/lib/pnpmOverrides.ts | 1 + 7 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 .github/actions/install-pnpm/action.yml diff --git a/.agents/skills/e2e/SKILL.md b/.agents/skills/e2e/SKILL.md index bc9f3efe42f4..aa7dcde7ad5a 100644 --- a/.agents/skills/e2e/SKILL.md +++ b/.agents/skills/e2e/SKILL.md @@ -118,6 +118,7 @@ All tests completed successfully. Your SDK changes work correctly with this test - **Test app not found**: List available apps and ask user to clarify - **Packed tarballs missing**: Run `yarn build:tarball` at the repo root, then `yarn test:prepare` in `dev-packages/e2e-tests` - **Build failures**: Fix build errors before running tests +- **pnpm errors such as `packages field missing or empty`**: The app needs the pnpm version in its `volta.pnpm`. Run with Volta, which installs that version ## Common Test Applications diff --git a/.github/actions/install-pnpm/action.yml b/.github/actions/install-pnpm/action.yml new file mode 100644 index 000000000000..d2d28a09dbb4 --- /dev/null +++ b/.github/actions/install-pnpm/action.yml @@ -0,0 +1,30 @@ +name: 'Install pnpm' +description: 'Installs the pnpm version that Volta pins for a package.' +inputs: + package-json: + description: 'The package.json to read `volta.pnpm` from. `volta.extends` is followed until a version is found.' + default: 'package.json' + +runs: + using: 'composite' + steps: + - name: Resolve pnpm version + id: pnpm-version + shell: bash + env: + PACKAGE_JSON: ${{ inputs.package-json }} + # Keep in sync with `getPnpmMajor` in `dev-packages/e2e-tests/lib/pnpmOverrides.ts`. It writes the overrides in + # the format of the version it finds, so a different lookup breaks the install. + run: | + package_json="$PACKAGE_JSON" + until version=$(jq -er '.volta.pnpm' "$package_json"); do + extends=$(jq -er '.volta.extends' "$package_json") || { + echo "::error::No volta.pnpm in $PACKAGE_JSON or the files it extends" + exit 1 + } + package_json="$(dirname "$package_json")/$extends" + done + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: ${{ steps.pnpm-version.outputs.version }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfe174349582..55dcd184d04c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -892,9 +892,8 @@ jobs: with: ref: ${{ env.HEAD_COMMIT }} # The fixtures install the locally-packed `@sentry/bundler-plugins` tarball with pnpm. - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - with: - version: 9.15.9 + - name: Install pnpm + uses: ./.github/actions/install-pnpm - name: Set up Node uses: actions/setup-node@v7 with: @@ -1056,9 +1055,10 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - name: Install pnpm + uses: ./.github/actions/install-pnpm with: - version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} + package-json: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Node uses: actions/setup-node@v7 with: @@ -1211,9 +1211,10 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - name: Install pnpm + uses: ./.github/actions/install-pnpm with: - version: ${{ matrix.test-application == 'nuxt-5' && '11.10.0' || '9.15.9' }} + package-json: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Node uses: actions/setup-node@v7 with: diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index fd9c8d6ce524..f3ddfbf71bd8 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -117,9 +117,10 @@ jobs: uses: actions/checkout@v7 with: ref: ${{ env.HEAD_COMMIT }} - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + - name: Install pnpm + uses: ./.github/actions/install-pnpm with: - version: 9.15.9 + package-json: 'dev-packages/e2e-tests/test-applications/${{ matrix.test-application }}/package.json' - name: Set up Node uses: actions/setup-node@v7 with: diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 6928d5d4073e..e67fa7c7cd66 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -19,9 +19,8 @@ jobs: VITE_E2E_TEST_DSN: 'https://username@domain/123' steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - with: - version: 9.15.9 + - name: Install pnpm + uses: ./.github/actions/install-pnpm - uses: actions/setup-node@v7 with: node-version-file: 'package.json' diff --git a/dev-packages/e2e-tests/README.md b/dev-packages/e2e-tests/README.md index 824784c63492..1c1da7180faf 100644 --- a/dev-packages/e2e-tests/README.md +++ b/dev-packages/e2e-tests/README.md @@ -118,6 +118,9 @@ 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. +Apps use the root pnpm version. To use another one, set `volta.pnpm` in the app's `package.json`. Volta (locally) and +CI (`.github/actions/install-pnpm`) both install that version. + ### 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: diff --git a/dev-packages/e2e-tests/lib/pnpmOverrides.ts b/dev-packages/e2e-tests/lib/pnpmOverrides.ts index 51fa793f5eae..e9f79d05f71e 100644 --- a/dev-packages/e2e-tests/lib/pnpmOverrides.ts +++ b/dev-packages/e2e-tests/lib/pnpmOverrides.ts @@ -57,6 +57,7 @@ export async function addPnpmOverrides(tmpDirPath: string, packedDirPath: string console.log(`Added ${packageNames.length} internal Sentry package overrides`); } +// Keep in sync with `.github/actions/install-pnpm`, which installs the version this finds in CI. async function getPnpmMajor( packageJsonPath: string, packageJson: { volta?: { extends?: string; pnpm?: string } }, From ea7efab943bba096fe1237041a5293ef9aa4147e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 13 Oct 2026 11:32:46 +0200 Subject: [PATCH 07/37] fix(node): Instrument Prisma 8 CollectionBase from rc.15 (#25301) Prisma 8.0.0-rc.15 renamed `CollectionImpl` to `CollectionBase`, which silently dropped all Prisma operation spans, so this targets the new class and moves the tests to rc.17 (older RCs are no longer supported). Co-authored-by: Claude Opus 5.5 --- .../test-applications/node-prisma-8/init.sql | 22 ++-- .../node-prisma-8/package.json | 2 +- .../node-prisma-8/src/prisma/contract.d.ts | 104 +++++++++++------- .../node-prisma-8/src/prisma/contract.json | 23 ++-- .../node-prisma-8/tests/prisma.test.ts | 8 +- .../prisma-orm-v8/prisma/contract.json | 23 ++-- .../tracing/prisma-orm-v8/prisma/init.sql | 22 ++-- .../suites/tracing/prisma-orm-v8/test.ts | 12 +- .../src/orchestrion/config/prisma.ts | 7 +- 9 files changed, 132 insertions(+), 91 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql b/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql index 655d447ca485..ac93565c65b2 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/init.sql @@ -34,31 +34,31 @@ CREATE TABLE prisma_contract.marker ( meta jsonb DEFAULT '{}'::jsonb NOT NULL, invariants text[] DEFAULT '{}'::text[] NOT NULL ); -CREATE TABLE public."user" ( +CREATE TABLE public."User" ( email text NOT NULL, id integer NOT NULL, name text ); -CREATE SEQUENCE public.user_id_seq +CREATE SEQUENCE public."User_id_seq" AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; -ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; +ALTER SEQUENCE public."User_id_seq" OWNED BY public."User".id; ALTER TABLE ONLY prisma_contract.ledger ALTER COLUMN id SET DEFAULT nextval('prisma_contract.ledger_id_seq'::regclass); -ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); -INSERT INTO prisma_contract.contract VALUES ('f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '2026-09-09 09:15:28.369559+00', '{"meta": {}, "roots": {"user": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": false}}, "storage": {"table": "user", "fields": {"id": {"column": "id"}, "name": {"column": "name"}, "email": {"column": "email"}}, "namespaceId": "public"}, "relations": {}}}}}}, "target": "postgres", "storage": {"namespaces": {"public": {"id": "public", "entries": {"table": {"user": {"columns": {"id": {"codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "nullable": false, "nativeType": "int4"}, "name": {"codecId": "pg/text@1", "nullable": true, "nativeType": "text"}, "email": {"codecId": "pg/text@1", "nullable": false, "nativeType": "text"}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lateral": true, "returning": true, "scalarList": true, "checkConstraint": true, "defaultInInsert": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true}}, "targetFamily": "sql"}'); -INSERT INTO prisma_contract.ledger VALUES (1, '2026-09-09 09:15:28.369559+00', 'app', '', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '', NULL, 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', NULL, '[{"id": "table.user", "label": "Create table \"user\"", "target": {"id": "postgres", "details": {"name": "user", "schema": "public", "objectType": "table"}}, "execute": [{"sql": "CREATE TABLE \"public\".\"user\" (\n \"email\" text NOT NULL,\n \"id\" SERIAL NOT NULL,\n \"name\" text,\n PRIMARY KEY (\"id\")\n)", "params": [], "description": "create table \"user\""}], "summary": "Creates table \"user\"", "precheck": [{"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "ensure table \"user\" does not exist"}], "postcheck": [{"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "verify table \"user\" exists"}], "operationClass": "additive"}, {"id": "unique.user.user_email_key", "label": "Add unique constraint on \"user\" (email)", "target": {"id": "postgres", "details": {"name": "user_email_key", "table": "user", "schema": "public", "objectType": "unique"}}, "execute": [{"sql": "ALTER TABLE \"public\".\"user\" ADD CONSTRAINT \"user_email_key\" UNIQUE (\"email\")", "description": "add unique constraint \"user_email_key\""}], "precheck": [{"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "ensure constraint \"user_email_key\" does not exist"}], "postcheck": [{"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "verify constraint \"user_email_key\" exists"}], "operationClass": "additive"}]'); -INSERT INTO prisma_contract.marker VALUES ('app', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-09-09 09:15:28.369559+00', NULL, '{}', '{}'); +ALTER TABLE ONLY public."User" ALTER COLUMN id SET DEFAULT nextval('public."User_id_seq"'::regclass); +INSERT INTO prisma_contract.contract VALUES ('a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '2026-10-08 14:56:22.624077+00', '{"meta": {}, "roots": {"User": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"many": false, "type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"many": false, "type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"many": false, "type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": false}}, "storage": {"table": "User", "fields": {"id": {"column": "id"}, "name": {"column": "name"}, "email": {"column": "email"}}, "namespaceId": "public"}, "relations": {}}}}}}, "target": "postgres", "storage": {"namespaces": {"public": {"id": "public", "entries": {"table": {"User": {"columns": {"id": {"many": false, "codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "dataType": "pg/int4", "nullable": false}, "name": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": true}, "email": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": false}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lockOf": true, "lateral": true, "forShare": true, "forUpdate": true, "returning": true, "lockNowait": true, "scalarList": true, "lockSkipLocked": true, "checkConstraint": true, "defaultInInsert": true, "insertOnConflictSkip": true, "insertOnConflictWithoutTarget": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true, "forKeyShare": true, "forNoKeyUpdate": true}}, "targetFamily": "sql"}'); +INSERT INTO prisma_contract.ledger VALUES (1, '2026-10-08 14:56:22.624077+00', 'app', '', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '', NULL, 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', NULL, '[{"id": "table.User", "label": "Create table \"User\"", "target": {"id": "postgres", "details": {"name": "User", "schema": "public", "objectType": "table"}}, "execute": [{"sql": "CREATE TABLE \"public\".\"User\" (\n \"email\" text NOT NULL,\n \"id\" SERIAL NOT NULL,\n \"name\" text,\n PRIMARY KEY (\"id\")\n)", "params": [], "description": "create table \"User\""}], "summary": "Creates table \"User\"", "precheck": [{"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", "params": ["\"public\".\"User\""], "description": "ensure table \"User\" does not exist"}], "postcheck": [{"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", "params": ["\"public\".\"User\""], "description": "verify table \"User\" exists"}], "operationClass": "additive"}, {"id": "unique.User.User_email_key", "label": "Add unique constraint on \"User\" (email)", "target": {"id": "postgres", "details": {"name": "User_email_key", "table": "User", "schema": "public", "objectType": "unique"}}, "execute": [{"sql": "ALTER TABLE \"public\".\"User\" ADD CONSTRAINT \"User_email_key\" UNIQUE (\"email\")", "description": "add unique constraint \"User_email_key\""}], "precheck": [{"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["User_email_key", "public", "\"public\".\"User\""], "description": "ensure constraint \"User_email_key\" does not exist"}], "postcheck": [{"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["User_email_key", "public", "\"public\".\"User\""], "description": "verify constraint \"User_email_key\" exists"}], "operationClass": "additive"}]'); +INSERT INTO prisma_contract.marker VALUES ('app', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-10-08 14:56:22.624077+00', NULL, '{}', '{}'); ALTER TABLE ONLY prisma_contract.contract ADD CONSTRAINT contract_pkey PRIMARY KEY (core_hash); ALTER TABLE ONLY prisma_contract.ledger ADD CONSTRAINT ledger_pkey PRIMARY KEY (id); ALTER TABLE ONLY prisma_contract.marker ADD CONSTRAINT marker_pkey PRIMARY KEY (space); -ALTER TABLE ONLY public."user" - ADD CONSTRAINT user_email_key UNIQUE (email); -ALTER TABLE ONLY public."user" - ADD CONSTRAINT user_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public."User" + ADD CONSTRAINT "User_email_key" UNIQUE (email); +ALTER TABLE ONLY public."User" + ADD CONSTRAINT "User_pkey" PRIMARY KEY (id); diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json index a6e8bb692e57..6b42d3ee2e7c 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/package.json @@ -12,7 +12,7 @@ "test:assert": "pnpm test" }, "dependencies": { - "@prisma/orm-postgres": "8.0.0-rc.8", + "@prisma/orm-postgres": "8.0.0-rc.17", "@sentry/node": "file:../../packed/sentry-node-packed.tgz", "@types/express": "^4.17.21", "@types/node": "^20.19.0", diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts index 010a73846882..ecfad3dac136 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.d.ts @@ -1,26 +1,14 @@ // ⚠️ GENERATED FILE - DO NOT EDIT // This file is automatically generated by 'prisma contract emit'. // To regenerate, run: prisma contract emit -import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma/orm-postgres/adapter/operation-types'; -import type { - Bit, - Char, - CodecTypes as PgTypes, - Interval, - JsonValue, - Numeric, - Time, - TimeString, - Timestamp, - TimestampString, - Timestamptz, - TimestamptzString, - Timetz, - VarBit, - Varchar, -} from '@prisma/orm-postgres/target/codec-types'; +import type { CodecTypes as PgTypes } from '@prisma/orm-postgres/target/codec-types'; +import type { QueryOperationTypes as PgTargetQueryOps } from '@prisma/orm-postgres/target/operation-types'; -import type { ContractWithTypeMaps, TypeMaps as TypeMapsType } from '@prisma/orm-postgres/family-contract/types'; +import type { + ContractWithTypeMaps, + RelationKeys, + TypeMaps as TypeMapsType, +} from '@prisma/orm-postgres/family-contract/types'; import type { Contract as ContractType, ExecutionHashBase, @@ -29,13 +17,13 @@ import type { StorageHashBase, } from '@prisma/orm-postgres/contract/types'; -export type StorageHash = StorageHashBase<'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5'>; +export type StorageHash = StorageHashBase<'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = PgTargetQueryOps; export type AggregateTypes = { readonly avg: { readonly byCodec: { @@ -113,6 +101,10 @@ export type AggregateTypes = { readonly output: 'pg/timestamp-temporal@1'; readonly nullable: true; }; + readonly 'pg/timestamptz-date@1': { + readonly output: 'pg/timestamptz-date@1'; + readonly nullable: true; + }; readonly 'pg/timestamptz-string@1': { readonly output: 'pg/timestamptz-string@1'; readonly nullable: true; @@ -169,6 +161,10 @@ export type AggregateTypes = { readonly output: 'pg/timestamp-temporal@1'; readonly nullable: true; }; + readonly 'pg/timestamptz-date@1': { + readonly output: 'pg/timestamptz-date@1'; + readonly nullable: true; + }; readonly 'pg/timestamptz-string@1': { readonly output: 'pg/timestamptz-string@1'; readonly nullable: true; @@ -236,8 +232,8 @@ type DefaultLiteralValue = CodecId extends keyo export type FieldOutputTypes = { readonly public: { readonly User: { - readonly id: CodecTypes['pg/int4@1']['output']; readonly email: CodecTypes['pg/text@1']['output']; + readonly id: CodecTypes['pg/int4@1']['output']; readonly name: CodecTypes['pg/text@1']['output'] | null; }; }; @@ -245,15 +241,15 @@ export type FieldOutputTypes = { export type FieldInputTypes = { readonly public: { readonly User: { - readonly id: CodecTypes['pg/int4@1']['input']; readonly email: CodecTypes['pg/text@1']['input']; + readonly id: CodecTypes['pg/int4@1']['input']; readonly name: CodecTypes['pg/text@1']['input'] | null; }; }; }; export type StorageColumnTypes = { readonly public: { - readonly user: { + readonly User: { readonly email: CodecTypes['pg/text@1']['output']; readonly id: CodecTypes['pg/int4@1']['output']; readonly name: CodecTypes['pg/text@1']['output'] | null; @@ -262,13 +258,29 @@ export type StorageColumnTypes = { }; export type StorageColumnInputTypes = { readonly public: { - readonly user: { + readonly User: { readonly email: CodecTypes['pg/text@1']['input']; readonly id: CodecTypes['pg/int4@1']['input']; readonly name: CodecTypes['pg/text@1']['input'] | null; }; }; }; + +export namespace Models { + export type public_User = { + email: CodecTypes['pg/text@1']['output']; + id: CodecTypes['pg/int4@1']['output']; + name: CodecTypes['pg/text@1']['output'] | null; + readonly [RelationKeys]?: never; + }; +} + +export declare const models: { + public: { + User: Models.public_User; + }; +}; + export type TypeMaps = TypeMapsType< CodecTypes, QueryOperationTypes, @@ -287,26 +299,29 @@ type ContractBase = Omit< readonly kind: 'postgres-schema'; readonly entries: { readonly table: { - readonly user: { + readonly User: { columns: { + readonly email: { + readonly dataType: 'pg/text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + readonly many: false; + }; readonly id: { - readonly nativeType: 'int4'; + readonly dataType: 'pg/int4'; readonly codecId: 'pg/int4@1'; readonly nullable: false; readonly default: { readonly kind: 'function'; readonly expression: 'autoincrement()'; }; - }; - readonly email: { - readonly nativeType: 'text'; - readonly codecId: 'pg/text@1'; - readonly nullable: false; + readonly many: false; }; readonly name: { - readonly nativeType: 'text'; + readonly dataType: 'pg/text'; readonly codecId: 'pg/text@1'; readonly nullable: true; + readonly many: false; }; }; primaryKey: { readonly columns: readonly ['id'] }; @@ -325,7 +340,7 @@ type ContractBase = Omit< readonly target: 'postgres'; readonly targetFamily: 'sql'; readonly roots: { - readonly user: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; + readonly User: { readonly namespace: 'public' & NamespaceId; readonly model: 'User' }; }; readonly domain: { readonly namespaces: { @@ -333,14 +348,14 @@ type ContractBase = Omit< readonly models: { readonly User: { readonly fields: { - readonly id: { - readonly nullable: false; - readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; - }; readonly email: { readonly nullable: false; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; }; + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; readonly name: { readonly nullable: true; readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; @@ -348,11 +363,11 @@ type ContractBase = Omit< }; readonly relations: Record; readonly storage: { - readonly table: 'user'; + readonly table: 'User'; readonly namespaceId: 'public'; readonly fields: { - readonly id: { readonly column: 'id' }; readonly email: { readonly column: 'email' }; + readonly id: { readonly column: 'id' }; readonly name: { readonly column: 'name' }; }; }; @@ -364,6 +379,8 @@ type ContractBase = Omit< readonly capabilities: { readonly postgres: { readonly distinctOn: true; + readonly forKeyShare: true; + readonly forNoKeyUpdate: true; readonly jsonAgg: true; readonly lateral: true; readonly limit: true; @@ -374,7 +391,14 @@ type ContractBase = Omit< readonly checkConstraint: true; readonly defaultInInsert: true; readonly enums: true; + readonly forShare: true; + readonly forUpdate: true; + readonly insertOnConflictSkip: true; + readonly insertOnConflictWithoutTarget: true; readonly lateral: true; + readonly lockNowait: true; + readonly lockOf: true; + readonly lockSkipLocked: true; readonly returning: true; readonly scalarList: true; }; diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json index 3b9090e28776..51b72b543a0b 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/src/prisma/contract.json @@ -4,7 +4,7 @@ "target": "postgres", "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "roots": { - "user": { + "User": { "model": "User", "namespace": "public" } @@ -51,7 +51,7 @@ } }, "namespaceId": "public", - "table": "user" + "table": "User" } } } @@ -63,25 +63,25 @@ "public": { "entries": { "table": { - "user": { + "User": { "columns": { "email": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": false }, "id": { "codecId": "pg/int4@1", + "dataType": "pg/int4", "default": { "expression": "autoincrement()", "kind": "function" }, - "nativeType": "int4", "nullable": false }, "name": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": true } }, @@ -102,11 +102,13 @@ "kind": "postgres-schema" } }, - "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5" + "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889" }, "capabilities": { "postgres": { "distinctOn": true, + "forKeyShare": true, + "forNoKeyUpdate": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -117,7 +119,14 @@ "checkConstraint": true, "defaultInInsert": true, "enums": true, + "forShare": true, + "forUpdate": true, + "insertOnConflictSkip": true, + "insertOnConflictWithoutTarget": true, "lateral": true, + "lockNowait": true, + "lockOf": true, + "lockSkipLocked": true, "returning": true, "scalarList": true } diff --git a/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts b/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts index 81cc629a2ff8..4f581ff6d9ca 100644 --- a/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-prisma-8/tests/prisma.test.ts @@ -43,7 +43,7 @@ test('Prisma 8 ORM calls emit operation spans with the pg queries nested underne 'sentry.origin': { value: 'auto.db.prisma', type: 'string' }, 'sentry.op': { value: 'db', type: 'string' }, 'db.operation.name': { value: span.attributes['method']?.value, type: 'string' }, - 'db.collection.name': { value: 'user', type: 'string' }, + 'db.collection.name': { value: 'User', type: 'string' }, model: { value: 'User', type: 'string' }, name: { value: `User.${span.attributes['method']?.value}`, type: 'string' }, }); @@ -62,11 +62,11 @@ test('Prisma 8 ORM calls emit operation spans with the pg queries nested underne .map(span => span.attributes['db.query.text']?.value); }; expect(queriesUnder('create')).toEqual( - expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."User" /)]), ); - expect(queriesUnder('all')).toEqual([expect.stringMatching(/^SELECT .* FROM "public"\."user"$/)]); + expect(queriesUnder('all')).toEqual([expect.stringMatching(/^SELECT .* FROM "public"\."User"$/)]); expect(queriesUnder('delete')).toEqual( - expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."User" /)]), ); const dbSpans = spans.filter(span => getSpanOp(span) === 'db' && span.attributes['db.query.text']?.value); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.json b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.json index 3b9090e28776..51b72b543a0b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.json +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/contract.json @@ -4,7 +4,7 @@ "target": "postgres", "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "roots": { - "user": { + "User": { "model": "User", "namespace": "public" } @@ -51,7 +51,7 @@ } }, "namespaceId": "public", - "table": "user" + "table": "User" } } } @@ -63,25 +63,25 @@ "public": { "entries": { "table": { - "user": { + "User": { "columns": { "email": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": false }, "id": { "codecId": "pg/int4@1", + "dataType": "pg/int4", "default": { "expression": "autoincrement()", "kind": "function" }, - "nativeType": "int4", "nullable": false }, "name": { "codecId": "pg/text@1", - "nativeType": "text", + "dataType": "pg/text", "nullable": true } }, @@ -102,11 +102,13 @@ "kind": "postgres-schema" } }, - "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5" + "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889" }, "capabilities": { "postgres": { "distinctOn": true, + "forKeyShare": true, + "forNoKeyUpdate": true, "jsonAgg": true, "lateral": true, "limit": true, @@ -117,7 +119,14 @@ "checkConstraint": true, "defaultInInsert": true, "enums": true, + "forShare": true, + "forUpdate": true, + "insertOnConflictSkip": true, + "insertOnConflictWithoutTarget": true, "lateral": true, + "lockNowait": true, + "lockOf": true, + "lockSkipLocked": true, "returning": true, "scalarList": true } diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/init.sql b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/init.sql index 655d447ca485..ac93565c65b2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/init.sql +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/prisma/init.sql @@ -34,31 +34,31 @@ CREATE TABLE prisma_contract.marker ( meta jsonb DEFAULT '{}'::jsonb NOT NULL, invariants text[] DEFAULT '{}'::text[] NOT NULL ); -CREATE TABLE public."user" ( +CREATE TABLE public."User" ( email text NOT NULL, id integer NOT NULL, name text ); -CREATE SEQUENCE public.user_id_seq +CREATE SEQUENCE public."User_id_seq" AS integer START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1; -ALTER SEQUENCE public.user_id_seq OWNED BY public."user".id; +ALTER SEQUENCE public."User_id_seq" OWNED BY public."User".id; ALTER TABLE ONLY prisma_contract.ledger ALTER COLUMN id SET DEFAULT nextval('prisma_contract.ledger_id_seq'::regclass); -ALTER TABLE ONLY public."user" ALTER COLUMN id SET DEFAULT nextval('public.user_id_seq'::regclass); -INSERT INTO prisma_contract.contract VALUES ('f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '2026-09-09 09:15:28.369559+00', '{"meta": {}, "roots": {"user": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": false}}, "storage": {"table": "user", "fields": {"id": {"column": "id"}, "name": {"column": "name"}, "email": {"column": "email"}}, "namespaceId": "public"}, "relations": {}}}}}}, "target": "postgres", "storage": {"namespaces": {"public": {"id": "public", "entries": {"table": {"user": {"columns": {"id": {"codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "nullable": false, "nativeType": "int4"}, "name": {"codecId": "pg/text@1", "nullable": true, "nativeType": "text"}, "email": {"codecId": "pg/text@1", "nullable": false, "nativeType": "text"}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lateral": true, "returning": true, "scalarList": true, "checkConstraint": true, "defaultInInsert": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true}}, "targetFamily": "sql"}'); -INSERT INTO prisma_contract.ledger VALUES (1, '2026-09-09 09:15:28.369559+00', 'app', '', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '', NULL, 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', NULL, '[{"id": "table.user", "label": "Create table \"user\"", "target": {"id": "postgres", "details": {"name": "user", "schema": "public", "objectType": "table"}}, "execute": [{"sql": "CREATE TABLE \"public\".\"user\" (\n \"email\" text NOT NULL,\n \"id\" SERIAL NOT NULL,\n \"name\" text,\n PRIMARY KEY (\"id\")\n)", "params": [], "description": "create table \"user\""}], "summary": "Creates table \"user\"", "precheck": [{"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "ensure table \"user\" does not exist"}], "postcheck": [{"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", "params": ["\"public\".\"user\""], "description": "verify table \"user\" exists"}], "operationClass": "additive"}, {"id": "unique.user.user_email_key", "label": "Add unique constraint on \"user\" (email)", "target": {"id": "postgres", "details": {"name": "user_email_key", "table": "user", "schema": "public", "objectType": "unique"}}, "execute": [{"sql": "ALTER TABLE \"public\".\"user\" ADD CONSTRAINT \"user_email_key\" UNIQUE (\"email\")", "description": "add unique constraint \"user_email_key\""}], "precheck": [{"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "ensure constraint \"user_email_key\" does not exist"}], "postcheck": [{"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["user_email_key", "public", "\"public\".\"user\""], "description": "verify constraint \"user_email_key\" exists"}], "operationClass": "additive"}]'); -INSERT INTO prisma_contract.marker VALUES ('app', 'f4e1954fd8bed87828d13c3f1a02164dc9796ef1af76ed6f98184c01263169c5', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-09-09 09:15:28.369559+00', NULL, '{}', '{}'); +ALTER TABLE ONLY public."User" ALTER COLUMN id SET DEFAULT nextval('public."User_id_seq"'::regclass); +INSERT INTO prisma_contract.contract VALUES ('a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '2026-10-08 14:56:22.624077+00', '{"meta": {}, "roots": {"User": {"model": "User", "namespace": "public"}}, "domain": {"namespaces": {"public": {"models": {"User": {"fields": {"id": {"many": false, "type": {"kind": "scalar", "codecId": "pg/int4@1"}, "nullable": false}, "name": {"many": false, "type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": true}, "email": {"many": false, "type": {"kind": "scalar", "codecId": "pg/text@1"}, "nullable": false}}, "storage": {"table": "User", "fields": {"id": {"column": "id"}, "name": {"column": "name"}, "email": {"column": "email"}}, "namespaceId": "public"}, "relations": {}}}}}}, "target": "postgres", "storage": {"namespaces": {"public": {"id": "public", "entries": {"table": {"User": {"columns": {"id": {"many": false, "codecId": "pg/int4@1", "default": {"kind": "function", "expression": "autoincrement()"}, "dataType": "pg/int4", "nullable": false}, "name": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": true}, "email": {"many": false, "codecId": "pg/text@1", "dataType": "pg/text", "nullable": false}}, "indexes": [], "uniques": [{"columns": ["email"]}], "primaryKey": {"columns": ["id"]}, "foreignKeys": []}}}}}, "storageHash": "a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889"}, "extensions": {}, "profileHash": "3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2", "capabilities": {"sql": {"enums": true, "lockOf": true, "lateral": true, "forShare": true, "forUpdate": true, "returning": true, "lockNowait": true, "scalarList": true, "lockSkipLocked": true, "checkConstraint": true, "defaultInInsert": true, "insertOnConflictSkip": true, "insertOnConflictWithoutTarget": true}, "postgres": {"limit": true, "jsonAgg": true, "lateral": true, "orderBy": true, "returning": true, "distinctOn": true, "forKeyShare": true, "forNoKeyUpdate": true}}, "targetFamily": "sql"}'); +INSERT INTO prisma_contract.ledger VALUES (1, '2026-10-08 14:56:22.624077+00', 'app', '', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '', NULL, 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', NULL, '[{"id": "table.User", "label": "Create table \"User\"", "target": {"id": "postgres", "details": {"name": "User", "schema": "public", "objectType": "table"}}, "execute": [{"sql": "CREATE TABLE \"public\".\"User\" (\n \"email\" text NOT NULL,\n \"id\" SERIAL NOT NULL,\n \"name\" text,\n PRIMARY KEY (\"id\")\n)", "params": [], "description": "create table \"User\""}], "summary": "Creates table \"User\"", "precheck": [{"sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", "params": ["\"public\".\"User\""], "description": "ensure table \"User\" does not exist"}], "postcheck": [{"sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", "params": ["\"public\".\"User\""], "description": "verify table \"User\" exists"}], "operationClass": "additive"}, {"id": "unique.User.User_email_key", "label": "Add unique constraint on \"User\" (email)", "target": {"id": "postgres", "details": {"name": "User_email_key", "table": "User", "schema": "public", "objectType": "unique"}}, "execute": [{"sql": "ALTER TABLE \"public\".\"User\" ADD CONSTRAINT \"User_email_key\" UNIQUE (\"email\")", "description": "add unique constraint \"User_email_key\""}], "precheck": [{"sql": "SELECT NOT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["User_email_key", "public", "\"public\".\"User\""], "description": "ensure constraint \"User_email_key\" does not exist"}], "postcheck": [{"sql": "SELECT EXISTS (SELECT 1 AS \"one\" FROM \"pg_constraint\" AS \"c\" INNER JOIN \"pg_namespace\" AS \"n\" ON \"n\".\"oid\" = \"c\".\"connamespace\" WHERE (\"c\".\"conname\" = $1 AND \"n\".\"nspname\" = $2 AND \"c\".\"conrelid\" = to_regclass($3))) AS \"result\"", "params": ["User_email_key", "public", "\"public\".\"User\""], "description": "verify constraint \"User_email_key\" exists"}], "operationClass": "additive"}]'); +INSERT INTO prisma_contract.marker VALUES ('app', 'a62d15a094e80be7b342d75e0538935972b9699c76dabb5339940dd50d17a889', '3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2', NULL, NULL, '2026-10-08 14:56:22.624077+00', NULL, '{}', '{}'); ALTER TABLE ONLY prisma_contract.contract ADD CONSTRAINT contract_pkey PRIMARY KEY (core_hash); ALTER TABLE ONLY prisma_contract.ledger ADD CONSTRAINT ledger_pkey PRIMARY KEY (id); ALTER TABLE ONLY prisma_contract.marker ADD CONSTRAINT marker_pkey PRIMARY KEY (space); -ALTER TABLE ONLY public."user" - ADD CONSTRAINT user_email_key UNIQUE (email); -ALTER TABLE ONLY public."user" - ADD CONSTRAINT user_pkey PRIMARY KEY (id); +ALTER TABLE ONLY public."User" + ADD CONSTRAINT "User_email_key" UNIQUE (email); +ALTER TABLE ONLY public."User" + ADD CONSTRAINT "User_pkey" PRIMARY KEY (id); diff --git a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts index e27358581b28..d7adfe06da27 100644 --- a/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/prisma-orm-v8/test.ts @@ -18,7 +18,7 @@ function getQuerySpans(container: SerializedStreamedSpanContainer): SerializedSt item => item.attributes['sentry.origin']?.value === 'auto.db.postgres' && item.attributes['db.query.text']?.value, ); expect(querySpans.map(span => span.name)).toEqual( - expect.arrayContaining(['INSERT "public"."user"', 'SELECT "public"."user"', 'DELETE "public"."user"']), + expect.arrayContaining(['INSERT "public"."User"', 'SELECT "public"."User"', 'DELETE "public"."User"']), ); querySpans.forEach(span => { expect(span.attributes['sentry.op']?.value).toBe('db'); @@ -67,7 +67,7 @@ describe('Prisma ORM v8 Tests', () => { 'sentry.origin': { value: 'auto.db.prisma', type: 'string' }, 'sentry.op': { value: 'db', type: 'string' }, 'db.operation.name': { value: method, type: 'string' }, - 'db.collection.name': { value: 'user', type: 'string' }, + 'db.collection.name': { value: 'User', type: 'string' }, model: { value: 'User', type: 'string' }, name: { value: `User.${method}`, type: 'string' }, }); @@ -80,13 +80,13 @@ describe('Prisma ORM v8 Tests', () => { .map(span => span.attributes['db.query.text']?.value); }; expect(queriesByOperation('create')).toEqual( - expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^INSERT INTO "public"\."User" /)]), ); expect(queriesByOperation('all')).toEqual([ - expect.stringMatching(/^SELECT .* FROM "public"\."user"$/), + expect.stringMatching(/^SELECT .* FROM "public"\."User"$/), ]); expect(queriesByOperation('delete')).toEqual( - expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."user" /)]), + expect.arrayContaining([expect.stringMatching(/^DELETE FROM "public"\."User" /)]), ); expect(querySpans.filter(span => span.parent_span_id === segment.span_id)).toEqual([]); }, @@ -126,7 +126,7 @@ describe('Prisma ORM v8 Tests', () => { }, { additionalDependencies: { - '@prisma/orm-postgres': '8.0.0-rc.8', + '@prisma/orm-postgres': '8.0.0-rc.17', }, copyPaths: ['prisma'], }, diff --git a/packages/server-utils/src/orchestrion/config/prisma.ts b/packages/server-utils/src/orchestrion/config/prisma.ts index 73d6b1a77607..281a97bfba43 100644 --- a/packages/server-utils/src/orchestrion/config/prisma.ts +++ b/packages/server-utils/src/orchestrion/config/prisma.ts @@ -2,12 +2,11 @@ import type { InstrumentationConfig } from '../apmTypes'; import { getModuleNames } from './module-names'; // Prisma 8 has no tracing surface, so its ORM terminals are wrapped directly. Apps install -// `@prisma/orm-postgres`, but `CollectionImpl` lives in the transitive `@prisma/orm-family-sql`. +// `@prisma/orm-postgres`, but `CollectionBase` lives in the transitive `@prisma/orm-family-sql`. const MODULE_NAME = '@prisma/orm-family-sql'; const ORM_CLIENT_FILE = 'dist/orm-client.mjs'; -// The range matcher admits every `8.0.0-rc.*`, not just rc.8 and later; the terminals were verified on rc.8. -const VERSION_RANGE = '>=8.0.0-rc.8 <9'; +const VERSION_RANGE = '>=8.0.0-rc.15 <9'; export const PRISMA_ASYNC_TERMINALS = [ 'aggregate', @@ -31,7 +30,7 @@ function terminalConfig(methodName: PrismaTerminal, kind: 'Async' | 'Sync'): Ins return { channelName: methodName, module: { name: MODULE_NAME, versionRange: VERSION_RANGE, filePath: ORM_CLIENT_FILE }, - functionQuery: { className: 'CollectionImpl', methodName, kind }, + functionQuery: { className: 'CollectionBase', methodName, kind }, }; } From e0e5d653b15e515409d5f57f0d6a59559c1932d0 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 13 Oct 2026 12:48:36 +0200 Subject: [PATCH 08/37] test(e2e): Add node-openai-send-to-sentry test app (#24824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An e2e app that uses the OpenAI integration the way a user does and sends the data to a real Sentry project, per #24747. **The app.** A plain `Sentry.init` on an express app, preloaded with `node --import`, no tunnel. The stock `openai` client talks to OpenRouter. Five routes make one real request each: a chat completion, a streamed chat completion, a forced tool call, a Responses API call, and an embeddings call. **The tests.** Each one reads the request's `gen_ai` 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 checks use the attribute names ingest stores, which differ from what the SDK sends (`sentry.origin` is stored as `origin`, `gen_ai.response.text` as `gen_ai.output.messages`). Ingest does not store `gen_ai.response.tool_calls` under that name, so the tool-call test checks the finish reason instead. `openai` is pinned to 7.5.0, the version the v7 integration suite uses, and a `(latest)` variant runs the same tests against `openai@latest`. Both are optional, like the other send-to-sentry apps. Closes #24747 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 --- .../node-openai-send-to-sentry/.gitignore | 5 + .../node-openai-send-to-sentry/package.json | 42 +++++ .../playwright.config.ts | 24 +++ .../node-openai-send-to-sentry/src/app.ts | 151 +++++++++++++++ .../src/instrument.ts | 10 + .../tests/send-to-sentry.test.ts | 172 ++++++++++++++++++ .../node-openai-send-to-sentry/tsconfig.json | 13 ++ 7 files changed, 417 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/.gitignore new file mode 100644 index 000000000000..795f6b6d40d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-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-openai-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json new file mode 100644 index 000000000000..5b1626a09130 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json @@ -0,0 +1,42 @@ +{ + "name": "node-openai-send-to-sentry", + "description": "The OpenAI integration as a user runs it: a plain Sentry.init on an express app, real chat, streaming, tool-call, Responses API and embeddings 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 openai@latest && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^4.21.2", + "openai": "7.5.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/express": "^4.17.21", + "@types/node": "^24.0.0", + "sentry": "~0.44.1", + "typescript": "~5.9.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-openai-send-to-sentry (latest)" + } + ] + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..e7621a23cc8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from '@playwright/test'; + +const port = 3030; + +export default defineConfig({ + testDir: './tests', + /* Each test runs two Sentry polls of up to 180s each, after a live model call. */ + 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-openai-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts new file mode 100644 index 000000000000..ac99c6d18eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/app.ts @@ -0,0 +1,151 @@ +// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here. +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +const apiKey = process.env.E2E_OPENROUTER_API_KEY; +if (!apiKey) { + throw new Error('E2E_OPENROUTER_API_KEY is not set'); +} + +// OpenRouter serves the OpenAI API shape (chat completions, responses, embeddings), so the stock +// client only needs a different base URL. 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 OpenAI({ apiKey, baseURL: 'https://openrouter.ai/api/v1' }); + +const CHAT_MODEL = 'openai/gpt-4o-mini'; +const EMBEDDINGS_MODEL = 'openai/text-embedding-3-small'; + +const SHORT_ANSWER = 'Answer in at most five words.'; +const CHAT_PROMPT = `What is the capital of France? ${SHORT_ANSWER}`; +// Deliberately does not name the tool: `tool_choice: 'required'` forces the call. +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 = { + type: 'function' as const, + function: { + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: { + type: 'object', + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + }, + }, +}; + +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; +} + +// Each route makes one call through the `openai` 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 completion = await client.chat.completions.create({ + model: CHAT_MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + max_tokens: 32, + }); + res.send({ + traceId: currentTraceId(), + answer: completion.choices[0]?.message.content ?? '', + }); + } catch (error) { + next(error); + } +}); + +app.get('/chat-stream', async (_req, res, next) => { + try { + const stream = await client.chat.completions.create({ + model: CHAT_MODEL, + messages: [ + { role: 'system', content: SYSTEM }, + { role: 'user', content: CHAT_PROMPT }, + ], + temperature: 0, + max_tokens: 32, + stream: true, + // Ask for the usage in the final chunk, so the streamed span carries token counts. + stream_options: { include_usage: true }, + }); + + let answer = ''; + for await (const chunk of stream) { + answer += chunk.choices[0]?.delta.content ?? ''; + } + res.send({ traceId: currentTraceId(), answer }); + } catch (error) { + next(error); + } +}); + +app.get('/tools', async (_req, res, next) => { + try { + const completion = await client.chat.completions.create({ + model: CHAT_MODEL, + messages: [{ role: 'user', content: WEATHER_PROMPT }], + tools: [WEATHER_TOOL], + tool_choice: 'required', + max_tokens: 64, + }); + res.send({ + traceId: currentTraceId(), + toolCalls: completion.choices[0]?.message.tool_calls ?? [], + }); + } catch (error) { + next(error); + } +}); + +app.get('/responses', async (_req, res, next) => { + try { + const response = await client.responses.create({ + model: CHAT_MODEL, + instructions: SYSTEM, + input: CHAT_PROMPT, + temperature: 0, + max_output_tokens: 32, + }); + res.send({ traceId: currentTraceId(), answer: response.output_text }); + } catch (error) { + next(error); + } +}); + +app.get('/embeddings', async (_req, res, next) => { + try { + const embedding = await client.embeddings.create({ + model: EMBEDDINGS_MODEL, + input: 'The capital of France is Paris.', + }); + res.send({ + traceId: currentTraceId(), + dimensions: embedding.data[0]?.embedding.length ?? 0, + }); + } catch (error) { + next(error); + } +}); + +Sentry.setupExpressErrorHandler(app); + +app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).send({ message: error.message }); +}); + +const port = Number(process.env.PORT ?? 3030); +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`node-openai-send-to-sentry listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/src/instrument.ts new file mode 100644 index 000000000000..c6c15df960cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-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 OpenAI integration +// is on by default; its runtime channel injection is what picks up the `openai` 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-openai-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..eee3e75aeafb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from '@playwright/test'; +import type { TraceItem } from '@sentry-internal/test-utils/cli'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findSpanInTrace, + flattenTrace, +} from '@sentry-internal/test-utils/cli'; + +// Mirrors src/app.ts. +const CHAT_MODEL = 'openai/gpt-4o-mini'; +const EMBEDDINGS_MODEL = 'openai/text-embedding-3-small'; + +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(() => { + const span = findSpanInTrace(traceId, op); + const attributes = span?.event_id ? fetchSpanAttributes(traceId, span.event_id) : undefined; + found = span && attributes ? { span, attributes } : undefined; + return found; + }, EVENT_POLLING_OPTIONS) + .toBeDefined(); + + return found!; +} + +/** The ids of the spans with `op` below the request span (`http.server`) of the trace. */ +function spanIdsUnderRequestSpan(traceId: string, op: string): string[] { + const requestSpan = flattenTrace(fetchTrace(traceId)).find(item => item.op === 'http.server'); + return flattenTrace(requestSpan?.children ?? []) + .filter(item => item.op === op) + .map(item => item.event_id!); +} + +/** + * The request span is the segment; it ends last, so it can land after its children. Expecting exactly + * one span also catches the call being instrumented twice. + */ +async function expectOnlySpanUnderRequestSpan(traceId: string, op: string, span: TraceItem): Promise { + await expect.poll(() => spanIdsUnderRequestSpan(traceId, op), EVENT_POLLING_OPTIONS).toEqual([span.event_id]); +} + +/** + * 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(attributes: Attributes, operation: 'chat' | 'embeddings', model: string): void { + expect(attributes).toMatchObject({ + 'span.op': `gen_ai.${operation}`, + 'span.status': 'ok', + origin: 'auto.ai.openai', + 'gen_ai.operation.name': operation, + 'gen_ai.provider.name': 'openai', + 'gen_ai.request.model': model, + }); + // OpenAI embeddings responses carry no id, so only chat calls are guaranteed one. + if (operation === 'chat') { + expect(typeof attributes['gen_ai.response.id']).toBe('string'); + } + expect(typeof attributes['gen_ai.response.model']).toBe('string'); + // The Sentry API returns int attributes as strings. + expect(Number(attributes['gen_ai.usage.input_tokens'])).toBeGreaterThan(0); + expect(Number(attributes['gen_ai.usage.total_tokens'])).toBeGreaterThan(0); +} + +/** `gen_ai.response.finish_reasons` is a JSON array; a streamed answer can repeat the reason per chunk. */ +function finishReasons(attributes: Attributes): string[] { + return JSON.parse(attributes['gen_ai.response.finish_reasons'] as string); +} + +test('Sends a chat completion 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'); + + expect(span.description).toBe(`chat ${CHAT_MODEL}`); + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0); + expect(finishReasons(attributes)).toEqual(['stop']); + expect(attributes['gen_ai.response.streaming']).toBeUndefined(); + + // The SDK records prompts and answers by default, and they have to survive the trip to Sentry. 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(attributes['gen_ai.output.messages']).toMatch(/paris/i); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends a streamed chat completion to Sentry with its token usage', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/chat-stream'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expect(span.description).toBe(`chat ${CHAT_MODEL}`); + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(attributes['gen_ai.response.streaming']).toBe(true); + // The usage only arrives in the final chunk, which the streaming instrumentation has to pick up. + expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0); + expect(finishReasons(attributes)).toContain('stop'); + expect(attributes['gen_ai.output.messages']).toMatch(/paris/i); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends a chat completion that returned a tool call to Sentry', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/tools'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(finishReasons(attributes)).toEqual(['tool_calls']); + expect(attributes['gen_ai.tool.definitions']).toContain('get_weather'); + // The SDK also sends the returned call as `gen_ai.response.tool_calls`, but that attribute is not + // stored as such, so the finish reason is what proves the tool call made it. + // TODO: Assert the call in `gen_ai.output.messages` once the OpenAI integration records it there. + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends a Responses API call to Sentry as a gen_ai.chat span', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/responses'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.chat'); + + expect(span.description).toBe(`chat ${CHAT_MODEL}`); + expectModelCallAttributes(attributes, 'chat', CHAT_MODEL); + expect(Number(attributes['gen_ai.usage.output_tokens'])).toBeGreaterThan(0); + expect(finishReasons(attributes)).toEqual(['completed']); + expect(attributes['gen_ai.input.messages']).toContain('capital of France'); + expect(attributes['gen_ai.output.messages']).toMatch(/paris/i); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.chat', span); +}); + +test('Sends an embeddings call to Sentry as a gen_ai.embeddings span', async ({ baseURL }) => { + const traceId = await requestTrace(baseURL, '/embeddings'); + + const { span, attributes } = await waitForSpan(traceId, 'gen_ai.embeddings'); + + expect(span.description).toBe(`embeddings ${EMBEDDINGS_MODEL}`); + expectModelCallAttributes(attributes, 'embeddings', EMBEDDINGS_MODEL); + expect(attributes['gen_ai.embeddings.input']).toContain('Paris'); + + await expectOnlySpanUnderRequestSpan(traceId, 'gen_ai.embeddings', span); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..795574fd9df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-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 b9cd12a89087409a50a5511955eaf51a7eb3ceed Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:35:16 +0200 Subject: [PATCH 09/37] test(e2e): Add nuxt-5-sourcemaps test (#25268) This is a copy of `nuxt-4-sourcemaps` for Nuxt 5 (Nitro 3), set up like `nuxt-5`: it uses the nightly builds and runs as an optional app. It differs from the Nuxt 4 test in two places: - **No intermediate client build:** Nuxt 5 writes the client build straight into `.output/public`. So the check that every client debug ID was uploaded runs only when the maps are kept. - **Existing Nuxt 5 bugs:** three assertions are skipped with TODOs, one for each bug: - The server maps have no `sourcesContent`. - Files with glob characters in the path are not uploaded. - The server maps do not reach the `.vue` files. Merge after this is merged: https://github.com/getsentry/sentry-javascript/pull/25267 and https://github.com/getsentry/sentry-javascript/pull/25287 (CI fix) --- .../nuxt-5-sourcemaps/.gitignore | 23 ++ .../nuxt-5-sourcemaps/app/app.vue | 12 + .../nuxt-5-sourcemaps/app/pages/index.vue | 9 + .../nuxt-5-sourcemaps/assert-build.ts | 313 ++++++++++++++++++ .../nuxt-5-sourcemaps/nuxt.config.ts | 33 ++ .../nuxt-5-sourcemaps/package.json | 47 +++ .../nuxt-5-sourcemaps/pnpm-workspace.yaml | 3 + .../nuxt-5-sourcemaps/sentry.client.config.ts | 7 + .../nuxt-5-sourcemaps/sentry.server.config.ts | 6 + .../server/api/server-error.ts | 8 + .../start-mock-sentry-server.mjs | 3 + .../nuxt-5-sourcemaps/tsconfig.json | 15 + 12 files changed, 479 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore new file mode 100644 index 000000000000..1dca2550f7e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore @@ -0,0 +1,23 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr + +# Logs +logs +*.log + +# Misc +.DS_Store diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue new file mode 100644 index 000000000000..eb66eddd8a33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue @@ -0,0 +1,12 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue new file mode 100644 index 000000000000..bc19868e4488 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..5bf988c300ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts @@ -0,0 +1,313 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findDebugIdsWithConflictingSourcemaps, + findFilesWithForeignSourcemaps, + findFilesWithMultipleDebugIds, + findInjectedDebugIds, + findSourceMapFiles, + findSourceMappingUrlComments, + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, + startNodeServer, +} from '@sentry-internal/test-utils'; + +/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ +const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; + +/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ +const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; + +/** Nuxt 5 writes the client build here directly, so Sentry deletes the maps in Vite's only output directory. */ +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; + +const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; + +function filesContaining(dir: string, needle: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => path.join(entry.parentPath, entry.name)) + .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; +} + +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob +// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. +function debugIdsOfFilesWithGlobCharacters(dir: string): Set { + const debugIds = new Set(); + + for (const file of filesContaining(dir, 'sentry-dbid-')) { + if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { + 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`, +); + +const requests = loadMockServerResults(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); +} +console.log(''); + +// --- The upload reached Sentry --- + +assert.ok( + requests.some(r => r.authorization.includes('fake-auth-token')), + 'Expected requests with the configured auth token', +); + +// Sentry rejects a release that names no project with a 400, and the mock server accepts anything, +// so assert on the body rather than on the call alone. +const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); +assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); +for (const request of releaseCreates) { + const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; + assert.equal( + body?.version, + 'test-release', + `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, + ); + assert.deepEqual( + body?.projects, + ['test-project'], + `Expected the release create body to name the project, got ${JSON.stringify(body)}`, + ); +} + +assert.ok( + requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), + 'Expected a PUT to finalize the release', +); + +assert.ok( + requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), + 'Expected a GET for the chunk-upload options', +); + +const chunkPosts = getChunkUploadPosts(requests); +assert.ok( + chunkPosts.some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', +); + +const assembleRequests = getAssembleRequests(requests); +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); +for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); + assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); + assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); + for (const chunk of request.assembleBody?.chunks ?? []) { + assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); + } +} + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); + +// --- Both bundlers uploaded --- + +const sourcemaps = getSourcemaps(bundles); +assert.ok( + sourcemaps.some(map => map.sourcemap.mappings?.length), + 'Expected at least one uploaded sourcemap with non-empty mappings', +); + +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either +// plugin dropped, so each side is pinned to a marker only that side's source supplies. +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); +// TODO: Nitro 3 builds the server with Rolldown, and the uploaded server maps carry no `sourcesContent`: the +// module's `sourcemapExcludeSources: false` does not reach that build. Assert the server marker when that is fixed: +// assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); + +// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. +const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( + source => source.startsWith('../') || path.isAbsolute(source), +); +assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); + +// --- Debug IDs tie the shipped bundle to the uploaded map --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); +assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); + +// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting +// the upload alone cannot show this. +// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk +// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the +// maps are kept. The upload is the same in all variants. +const outputDirsWithSourceMaps = [ + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), +]; +const debugIdsWithoutSourceMap = new Set( + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), +); +const debugIdsSkippedByUploader = isStaticBuild ? new Set() : debugIdsOfFilesWithGlobCharacters(SERVER_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}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, + ); + + if (outputDirsWithSourceMaps.includes(outputDir)) { + const unuploaded = injectedDebugIds.filter( + debugId => + !uploadedDebugIds.has(debugId) && + !debugIdsWithoutSourceMap.has(debugId) && + !debugIdsSkippedByUploader.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)`); + + // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. + // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 + const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); + assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); + + // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on + // Vite's map even if Nitro uploaded nothing. + const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); + assert.deepEqual( + filesWithForeignSourcemaps, + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); +} + +// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. +const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); +assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); + +// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output +// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach +// `.vue` files. Assert this when the server maps reach the original sources: +// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); +// const serverSources = sourcemaps +// .filter(map => map.debugId && serverDebugIds.has(map.debugId)) +// .flatMap(map => getSourcemapSources(map.sourcemap)); +// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue'))); +console.log(''); + +// --- What the build leaves behind in the client output --- + +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); + +if (keepClientSourceMaps) { + assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); + console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`); +} else { + // This directory is served to the internet, so a surviving `.map` hands out the original source. + assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); + + // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. + const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); + assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); + + // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. + const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); + assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); + + console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); +} + +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. +if (!isStaticBuild) { + const clientChunkUrls = fs + .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.js')) + .map( + entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`, + ); + assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + + const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + + try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => { + const mapUrl = `${chunkUrl}.map`; + const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404; + const { status } = await fetch(`${server.baseUrl}${mapUrl}`); + return { mapUrl, status, expectedStatus }; + }), + ); + const unexpectedStatuses = responses + .filter(({ status, expectedStatus }) => status !== expectedStatus) + .map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`); + assert.deepEqual( + unexpectedStatuses, + [], + 'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404', + ); + + console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`); + } finally { + await server.close(); + } +} + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts new file mode 100644 index 000000000000..f2f14911dde3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts @@ -0,0 +1,33 @@ +// Nuxt defaults `sourcemap.client` to `false`, which the SDK respects as a deliberate opt-out, so +// an app that never mentions `sourcemap`, uploads nothing client-side. `'hidden'` is what the SDK's +// own warning tells users to set, which makes it the setup worth regression-testing. +const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; + +export default defineNuxtConfig({ + compatibilityDate: '2025-06-06', + imports: { autoImport: false }, + + sourcemap: { client: 'hidden' }, + + modules: ['@sentry/nuxt/module'], + + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + + sentry: { + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + // Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should + // upload the maps and leave the emitted files alone. + sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, + debug: true, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json new file mode 100644 index 000000000000..ccc0df377bcd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json @@ -0,0 +1,47 @@ +{ + "name": "nuxt-5-sourcemaps", + "description": "E2E test app asserting what the Nuxt SDK uploads to Sentry and what it leaves behind in `.output` on Nuxt 5 (Nitro 3).", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & nuxt ${NUXT_COMMAND:-build} > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx nuxi cleanup", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts", + "test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build", + "test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert", + "test:build:static": "NUXT_COMMAND=generate pnpm test:build", + "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" + }, + "dependencies": { + "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "nitro": "latest", + "nuxt": "npm:nuxt-nightly@5x" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0", + "ts-node": "10.9.1", + "typescript": "~5.0.0" + }, + "volta": { + "extends": "../../package.json", + "pnpm": "11.10.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build:keep-client-sourcemaps", + "assert-command": "pnpm test:assert:keep-client-sourcemaps", + "label": "nuxt-5-sourcemaps (client source maps kept)" + }, + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "nuxt-5-sourcemaps (static / nuxt generate)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml new file mode 100644 index 000000000000..4210a62a804d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@sentry/cli': true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts new file mode 100644 index 000000000000..795fc10a560c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; +import { useRuntimeConfig } from '#imports'; + +Sentry.init({ + dsn: useRuntimeConfig().public.sentry.dsn, + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts new file mode 100644 index 000000000000..38ed95a35801 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts new file mode 100644 index 000000000000..dba169666ee1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; + +// SOURCEMAP_MARKER_SERVER — the server counterpart of the client marker. Nitro defaults to +// `sourcemapExcludeSources: true`, which would drop this from the uploaded map; the Sentry module +// flips it to `false`, so finding this marker in `sourcesContent` is what proves that still works. +export default defineHandler(() => { + throw new Error('Server error from the Nuxt source map E2E app'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..a2aac493d878 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.vue"], + "exclude": ["node_modules", ".output", ".nuxt"] +} From 36e12ee476f465da5f5ea07e4baca3b52892c611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 13 Oct 2026 15:25:08 +0200 Subject: [PATCH 10/37] test(sveltekit): Pin rollup to 4.64.5 (#25328) Pinning for now to unblock the CI. Will follow up on an actual fix for this. I'm still not sure where the fix should go. --- .../test-applications/sveltekit-2-static/package.json | 10 +++++++++- .../test-applications/sveltekit-2/package.json | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json index 1d04116bde34..95f11c2bd094 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-static/package.json @@ -15,7 +15,10 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db" }, - "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "//": [ + "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "Rollup 4.64.6 keeps SvelteKit's Svelte 5 `$app/state` code in the Svelte 4 server build, which then throws `$state is not defined` on every page render" + ], "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz", "ioredis": "5.10.1", @@ -33,6 +36,11 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, + "pnpm": { + "overrides": { + "rollup": "4.64.5" + } + }, "volta": { "extends": "../../package.json" }, diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json index 75914024924e..73adf2924524 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2/package.json @@ -15,7 +15,10 @@ "test:assert": "pnpm test:prod && pnpm test:dev", "test:dev": "TEST_ENV=development playwright test db" }, - "//": "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "//": [ + "Need to use ioredis 5.10.1 because that's the last version before they support tracing channels", + "Rollup 4.64.6 keeps SvelteKit's Svelte 5 `$app/state` code in the Svelte 4 server build, which then throws `$state is not defined` on every page render" + ], "dependencies": { "@sentry/sveltekit": "file:../../packed/sentry-sveltekit-packed.tgz", "ioredis": "5.10.1", @@ -33,6 +36,11 @@ "typescript": "^5.0.0", "vite": "^5.4.11" }, + "pnpm": { + "overrides": { + "rollup": "4.64.5" + } + }, "volta": { "extends": "../../package.json" }, From 41ea931cbc9d0b41061bd1455f0771b9cfe3bf3c Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 13 Oct 2026 15:25:52 +0200 Subject: [PATCH 11/37] test(node): Cover LangChain model calls made from a tool without config (#25326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@langchain/core@1.2.17` changed which keys of the ambient runnable config reach runs started without a config (langchain-ai/langchainjs#11849). Our conversation id and span parenting for those runs depend on that ambient config, so this adds a test where a streamed runnable calls a tool, and the tool invokes a chain and model with no config. Nothing regressed. The spans are identical on 1.2.16 and 1.2.17, since the release only adds `context` to the keys it passes down. The scenario initializes LangChain's AsyncLocalStorage the way LangGraph does on import. Plain `@langchain/core` keeps a no-op store, so no ambient config would reach the tool without it. Closes #25293 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../v1/scenario-nested-runtime-context.mjs | 96 +++++++++++++++++++ .../suites/tracing/langchain/v1/test.ts | 57 ++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs new file mode 100644 index 000000000000..a5bcb97adc9a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/scenario-nested-runtime-context.mjs @@ -0,0 +1,96 @@ +import { ChatAnthropic } from '@langchain/anthropic'; +import { RunnableLambda, RunnableSequence } from '@langchain/core/runnables'; +import { AsyncLocalStorageProviderSingleton } from '@langchain/core/singletons'; +import { tool } from '@langchain/core/tools'; +import * as Sentry from '@sentry/node'; +import express from 'express'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +// LangGraph does this on import. Plain `@langchain/core` keeps a no-op store, so runs started +// without a config would see no ambient config at all. +AsyncLocalStorageProviderSingleton.initializeGlobalInstance(new AsyncLocalStorage()); + +function startMockAnthropicServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1/messages', (req, res) => { + res.json({ + id: 'msg_nested_test', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Sunny.' }], + model: req.body.model, + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockAnthropicServer(); + const baseUrl = `http://localhost:${server.address().port}`; + + const model = new ChatAnthropic({ + model: 'claude-3-5-sonnet-20241022', + temperature: 0, + maxTokens: 50, + apiKey: 'mock-api-key', + clientOptions: { baseURL: baseUrl }, + }); + + // The nested chain gets no config, so the conversation id and parent run only reach the model + // through LangChain's ambient runnable config. + const lookupWeather = tool( + async ({ city }) => { + const nested = RunnableSequence.from([RunnableLambda.from(c => `Weather in ${c}?`), model]).withConfig({ + runName: 'nested_chain', + }); + const result = await nested.invoke(city); + return result.content; + }, + { + name: 'lookup_weather', + description: 'Look up the weather', + schema: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] }, + }, + ); + + const outer = RunnableLambda.from(input => lookupWeather.invoke({ city: input.city })).withConfig({ + runName: 'outer', + }); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await Sentry.startSpan({ op: 'function', name: 'auto' }, async () => { + const stream = await outer.stream({ city: 'Paris' }, { configurable: { thread_id: 'thread_auto' } }); + for await (const _chunk of stream) { + // drain + } + }); + + await Sentry.startSpan({ op: 'function', name: 'handler' }, async () => { + const stream = await outer.stream( + { city: 'Paris' }, + { + callbacks: [Sentry.createLangChainCallbackHandler()], + configurable: { thread_id: 'thread_handler' }, + }, + ); + for await (const _chunk of stream) { + // drain + } + }); + }); + + await Sentry.flush(2000); + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts index f6de0aecca94..4260da49bdc4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/v1/test.ts @@ -20,7 +20,7 @@ import { SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; -import { GEN_AI_DECIDE } from '@sentry/conventions/op'; +import { GEN_AI_CHAT, GEN_AI_DECIDE } from '@sentry/conventions/op'; import { GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE } from '../../../../../../packages/server-utils/src/ai/core/gen-ai-attributes'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { createEsmTests } from '../../../../utils/runner/createEsmAndCjsTests'; @@ -236,6 +236,61 @@ describe('LangChain integration (v1)', () => { }, ); + createEsmAndCjsTests( + __dirname, + 'scenario-nested-runtime-context.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('keeps the conversation id and trace for a model called from a tool without config', async () => { + const runner = createRunner().ignore('event'); + const spansPromise = runner.collectStreamedSpansUntilSegment('main'); + + await runner.start().completed(); + + const spans = await spansPromise; + const byId = new Map(spans.map(span => [span.span_id, span])); + const ancestorNames = (span: (typeof spans)[number]): string[] => { + const names: string[] = []; + for (let p = byId.get(span.parent_span_id ?? ''); p; p = byId.get(p.parent_span_id ?? '')) { + names.push(p.name); + } + return names; + }; + const chatSpans = spans.filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_CHAT); + + expect(chatSpans).toHaveLength(2); + expect( + chatSpans.map(span => [span.attributes[GEN_AI_CONVERSATION_ID]?.value, ancestorNames(span)[0]]).sort(), + ).toEqual([ + ['thread_auto', 'auto'], + ['thread_handler', 'handler'], + ]); + + const handlerRunSpans = spans.filter( + span => + span.attributes[SENTRY_ORIGIN]?.value === 'auto.ai.langchain' && + span.attributes[SENTRY_OP]?.value !== GEN_AI_CHAT, + ); + expect(handlerRunSpans.map(span => span.name).sort()).toEqual([ + 'execute_tool lookup_weather', + 'invoke_agent RunnableLambda', + 'invoke_agent nested_chain', + 'invoke_agent outer', + ]); + for (const span of handlerRunSpans) { + expect(ancestorNames(span)).toContain('handler'); + } + }); + }, + { + additionalDependencies: { + langchain: '^1.0.0', + '@langchain/core': '^1.0.0', + '@langchain/anthropic': '^1.0.0', + }, + }, + ); + createEsmAndCjsTests( __dirname, 'scenario-typesafe-classifier.mjs', From b8fddd7bc4fdfe37888a6f510bc743ec1feb7885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 13 Oct 2026 17:11:44 +0200 Subject: [PATCH 12/37] license: Add cli FSL notice to package licenses (#25314) follow up to #24956 This adds the Third-Party Dependency Notice from the root LICENSE to the LICENSE of each published package that has the Sentry CLI (`sentry` on npm) as a direct or transitive runtime dependency. It also replaces the BSD 3-Clause text in `packages/bundler-plugins/LICENSE` with the MIT text, which its title, `package.json` and the upstream repo already declare (I think that got it while we moved commits over to this repo) ## Why npm ships the package LICENSE in the tarball, so users of these packages never see the notice in the root LICENSE. ## Scope decisions 20 packages get the notice: `bundler-plugins`, `remix` and `react-router` depend on the CLI directly, and every package built on `@sentry/node` gets it through `@sentry/bundler-plugins`. `@sentry/hono` does not get it, because it reaches the CLI only through optional peer dependencies. Co-authored-by: Claude Opus 5.5 --- packages/astro/LICENSE | 9 +++++ packages/aws-serverless/LICENSE | 9 +++++ packages/bun/LICENSE | 9 +++++ packages/bundler-plugins/LICENSE | 47 ++++++++++++------------ packages/effect/LICENSE | 9 +++++ packages/elysia/LICENSE | 9 +++++ packages/gatsby/LICENSE | 9 +++++ packages/google-cloud-serverless/LICENSE | 9 +++++ packages/nestjs/LICENSE | 9 +++++ packages/nextjs/LICENSE | 9 +++++ packages/nitro/LICENSE | 9 +++++ packages/node-native/LICENSE | 9 +++++ packages/node/LICENSE | 9 +++++ packages/nuxt/LICENSE | 9 +++++ packages/profiling-node/LICENSE | 9 +++++ packages/react-router/LICENSE | 9 +++++ packages/remix/LICENSE | 9 +++++ packages/solidstart/LICENSE | 9 +++++ packages/sveltekit/LICENSE | 9 +++++ packages/tanstackstart-react/LICENSE | 9 +++++ 20 files changed, 195 insertions(+), 23 deletions(-) diff --git a/packages/astro/LICENSE b/packages/astro/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/astro/LICENSE +++ b/packages/astro/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/aws-serverless/LICENSE b/packages/aws-serverless/LICENSE index b956a1944c7b..84c94253c8cf 100644 --- a/packages/aws-serverless/LICENSE +++ b/packages/aws-serverless/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/bun/LICENSE b/packages/bun/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/bun/LICENSE +++ b/packages/bun/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/bundler-plugins/LICENSE b/packages/bundler-plugins/LICENSE index c23a90edc9be..fde36681396d 100644 --- a/packages/bundler-plugins/LICENSE +++ b/packages/bundler-plugins/LICENSE @@ -1,29 +1,30 @@ -# MIT License +MIT License -Copyright (c) 2022-2026, Sentry -All rights reserved. +Copyright (c) 2022 Functional Software, Inc. dba Sentry -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: -- Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -- Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +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. -- Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. +Third-Party Dependency Notice -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +The MIT License above applies to the code in this package. This package has +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 package +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 diff --git a/packages/effect/LICENSE b/packages/effect/LICENSE index fea6013e7dbf..6e06c625cd06 100644 --- a/packages/effect/LICENSE +++ b/packages/effect/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/elysia/LICENSE b/packages/elysia/LICENSE index 0ecae617386e..937729b222b1 100644 --- a/packages/elysia/LICENSE +++ b/packages/elysia/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/gatsby/LICENSE b/packages/gatsby/LICENSE index b956a1944c7b..84c94253c8cf 100644 --- a/packages/gatsby/LICENSE +++ b/packages/gatsby/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/google-cloud-serverless/LICENSE b/packages/google-cloud-serverless/LICENSE index 63e7eb28e19c..48d5f26681c8 100644 --- a/packages/google-cloud-serverless/LICENSE +++ b/packages/google-cloud-serverless/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/nestjs/LICENSE b/packages/nestjs/LICENSE index 63e7eb28e19c..48d5f26681c8 100644 --- a/packages/nestjs/LICENSE +++ b/packages/nestjs/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/nextjs/LICENSE b/packages/nextjs/LICENSE index 917e31f85b7a..24624cb5010b 100644 --- a/packages/nextjs/LICENSE +++ b/packages/nextjs/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/nitro/LICENSE b/packages/nitro/LICENSE index 0ecae617386e..937729b222b1 100644 --- a/packages/nitro/LICENSE +++ b/packages/nitro/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/node-native/LICENSE b/packages/node-native/LICENSE index 0da96cd2f885..a05f045c30fa 100644 --- a/packages/node-native/LICENSE +++ b/packages/node-native/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/node/LICENSE b/packages/node/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/node/LICENSE +++ b/packages/node/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/nuxt/LICENSE b/packages/nuxt/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/nuxt/LICENSE +++ b/packages/nuxt/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/profiling-node/LICENSE b/packages/profiling-node/LICENSE index 293314012679..fde36681396d 100644 --- a/packages/profiling-node/LICENSE +++ b/packages/profiling-node/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/react-router/LICENSE b/packages/react-router/LICENSE index 5251db3eaaca..9a88e71d6cae 100644 --- a/packages/react-router/LICENSE +++ b/packages/react-router/LICENSE @@ -14,3 +14,12 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 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 package. This package has +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 package +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 diff --git a/packages/remix/LICENSE b/packages/remix/LICENSE index 293314012679..fde36681396d 100644 --- a/packages/remix/LICENSE +++ b/packages/remix/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/solidstart/LICENSE b/packages/solidstart/LICENSE index 63e7eb28e19c..48d5f26681c8 100644 --- a/packages/solidstart/LICENSE +++ b/packages/solidstart/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/sveltekit/LICENSE b/packages/sveltekit/LICENSE index b3c4b18a6317..191f8cabb4d8 100644 --- a/packages/sveltekit/LICENSE +++ b/packages/sveltekit/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 diff --git a/packages/tanstackstart-react/LICENSE b/packages/tanstackstart-react/LICENSE index 0da96cd2f885..a05f045c30fa 100644 --- a/packages/tanstackstart-react/LICENSE +++ b/packages/tanstackstart-react/LICENSE @@ -19,3 +19,12 @@ 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 package. This package has +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 package +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 6e73dfc0cd25f4def673f1a4fb6aa157cb599495 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 13 Oct 2026 17:14:19 +0200 Subject: [PATCH 13/37] test(nextjs): Run nextjs-16-tunnel on Cloudflare (#25161) follow up to #24998 ## Description This adds an optional Cloudflare (OpenNext) variant to `nextjs-16-tunnel`, so the tunnel route tests also run on Workers with `withSentry` from `@sentry/nextjs/cloudflare`. ## Why Only unit tests covered that `withSentry` does not sample requests to the tunnel route. The Worker sends its spans to the proxy server, so the existing check for middleware spans of tunnel requests fails when a tunnel request is sampled. Co-authored-by: Claude Opus 5.5 --- .../nextjs-16-tunnel/open-next.config.ts | 3 +++ .../nextjs-16-tunnel/package.json | 18 +++++++++++++++--- .../nextjs-16-tunnel/playwright.config.mjs | 4 ++-- .../nextjs-16-tunnel/worker.ts | 14 ++++++++++++++ .../nextjs-16-tunnel/wrangler.jsonc | 11 +++++++++++ 5 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/open-next.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/open-next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/open-next.config.ts new file mode 100644 index 000000000000..7a3d17172639 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/open-next.config.ts @@ -0,0 +1,3 @@ +import { defineCloudflareConfig } from '@opennextjs/cloudflare'; + +export default defineCloudflareConfig(); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json index a532785bd351..0b8ace0062d4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/package.json @@ -20,11 +20,14 @@ "test:build-latest-webpack": "pnpm install && pnpm add next@latest && pnpm build-webpack", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack", + "test:build:cloudflare": "pnpm install && _SENTRY_TUNNEL_DESTINATION_OVERRIDE=http://localhost:3031/ opennextjs-cloudflare build", + "start:cloudflare": "opennextjs-cloudflare preview" }, "dependencies": { - "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", + "@opennextjs/cloudflare": "^1.14.9", "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "@sentry/nextjs": "file:../../packed/sentry-nextjs-packed.tgz", "ai": "^3.0.0", "import-in-the-middle": "^1", "next": "16.3.8", @@ -41,7 +44,8 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^16", - "typescript": "^5" + "typescript": "^5", + "wrangler": "^4.61.0" }, "volta": { "extends": "../../package.json" @@ -58,6 +62,14 @@ "label": "nextjs-16-tunnel (turbopack)", "assert-command": "pnpm test:assert" } + ], + "optionalVariants": [ + { + "runtime": "cloudflare", + "build-command": "pnpm test:build:cloudflare", + "assert-command": "pnpm test:prod", + "label": "nextjs-16-tunnel (cloudflare)" + } ] } } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs index 797418b8cf7d..4da87c8ca509 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/playwright.config.mjs @@ -1,4 +1,4 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +import { getPlaywrightConfig, getRuntime } from '@sentry-internal/test-utils'; const testEnv = process.env.TEST_ENV; if (!testEnv) { @@ -15,7 +15,7 @@ const getStartCommand = () => { } if (testEnv === 'production') { - return 'pnpm next start -p 3030'; + return getRuntime() === 'cloudflare' ? 'pnpm start:cloudflare --port 3030' : 'pnpm next start -p 3030'; } throw new Error(`Unknown test env: ${testEnv}`); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts new file mode 100644 index 000000000000..522d8c460dfd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/worker.ts @@ -0,0 +1,14 @@ +import * as Sentry from '@sentry/nextjs/cloudflare'; +// @ts-ignore the file exists only after `opennextjs-cloudflare build` +import handler from './.open-next/worker.js'; + +export default Sentry.withSentry( + () => ({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: 'https://public@o12345.ingest.us.sentry.io/67890', + // Sends the server spans to the proxy server, so the tests see whether a tunnel request was sampled. + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + }), + handler, +); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/wrangler.jsonc new file mode 100644 index 000000000000..58acae9276b7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "nextjs-16-tunnel", + "main": "./worker.ts", + "compatibility_date": "2026-02-19", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "binding": "ASSETS", + "directory": ".open-next/assets", + }, +} From 73e13ac06dd2f59a22209a676495439618ed8429 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 13 Oct 2026 16:16:18 +0100 Subject: [PATCH 14/37] feat(bundler-plugins): Move individual bundler plugin packages (#25095) Move the four wrapper packages from `sentry-javascript-bundler-plugins` into `packages/legacy`. Each one re-exports the matching `@sentry/bundler-plugins` subpath and now releases in lockstep with the SDK. --- .craft.yml | 12 ++++ .github/ISSUE_TEMPLATE/bug.yml | 4 ++ .github/workflows/issue-package-label.yml | 12 ++++ CHANGELOG.md | 11 ++++ package.json | 4 ++ packages/esbuild-plugin/.gitignore | 2 + packages/esbuild-plugin/LICENSE | 29 +++++++++ packages/esbuild-plugin/README.md | 45 +++++++++++++ packages/esbuild-plugin/package.json | 56 ++++++++++++++++ packages/esbuild-plugin/rollup.npm.config.mjs | 12 ++++ packages/esbuild-plugin/src/index.ts | 2 + packages/esbuild-plugin/test/index.test.ts | 14 ++++ packages/esbuild-plugin/test/tsconfig.json | 8 +++ packages/esbuild-plugin/tsconfig.json | 5 ++ packages/esbuild-plugin/tsconfig.types.json | 12 ++++ packages/esbuild-plugin/vite.config.ts | 8 +++ packages/rollup-plugin/.gitignore | 2 + packages/rollup-plugin/LICENSE | 29 +++++++++ packages/rollup-plugin/README.md | 46 +++++++++++++ packages/rollup-plugin/package.json | 65 +++++++++++++++++++ packages/rollup-plugin/rollup.npm.config.mjs | 7 ++ packages/rollup-plugin/src/index.ts | 1 + packages/rollup-plugin/test/index.test.ts | 10 +++ packages/rollup-plugin/test/tsconfig.json | 8 +++ packages/rollup-plugin/tsconfig.json | 5 ++ packages/rollup-plugin/tsconfig.types.json | 12 ++++ packages/rollup-plugin/vite.config.ts | 8 +++ packages/vite-plugin/.gitignore | 2 + packages/vite-plugin/LICENSE | 29 +++++++++ packages/vite-plugin/README.md | 47 ++++++++++++++ packages/vite-plugin/package.json | 56 ++++++++++++++++ packages/vite-plugin/rollup.npm.config.mjs | 7 ++ packages/vite-plugin/src/index.ts | 1 + packages/vite-plugin/test/index.test.ts | 10 +++ packages/vite-plugin/test/tsconfig.json | 8 +++ packages/vite-plugin/tsconfig.json | 5 ++ packages/vite-plugin/tsconfig.types.json | 12 ++++ packages/vite-plugin/vite.config.ts | 8 +++ packages/webpack-plugin/.gitignore | 2 + packages/webpack-plugin/LICENSE | 29 +++++++++ packages/webpack-plugin/README.md | 44 +++++++++++++ packages/webpack-plugin/package.json | 64 ++++++++++++++++++ packages/webpack-plugin/rollup.npm.config.mjs | 7 ++ packages/webpack-plugin/src/index.ts | 1 + packages/webpack-plugin/src/webpack5.ts | 1 + packages/webpack-plugin/test/index.test.ts | 15 +++++ packages/webpack-plugin/test/tsconfig.json | 8 +++ packages/webpack-plugin/tsconfig.json | 5 ++ packages/webpack-plugin/tsconfig.types.json | 12 ++++ packages/webpack-plugin/vite.config.ts | 8 +++ 50 files changed, 810 insertions(+) create mode 100644 packages/esbuild-plugin/.gitignore create mode 100644 packages/esbuild-plugin/LICENSE create mode 100644 packages/esbuild-plugin/README.md create mode 100644 packages/esbuild-plugin/package.json create mode 100644 packages/esbuild-plugin/rollup.npm.config.mjs create mode 100644 packages/esbuild-plugin/src/index.ts create mode 100644 packages/esbuild-plugin/test/index.test.ts create mode 100644 packages/esbuild-plugin/test/tsconfig.json create mode 100644 packages/esbuild-plugin/tsconfig.json create mode 100644 packages/esbuild-plugin/tsconfig.types.json create mode 100644 packages/esbuild-plugin/vite.config.ts create mode 100644 packages/rollup-plugin/.gitignore create mode 100644 packages/rollup-plugin/LICENSE create mode 100644 packages/rollup-plugin/README.md create mode 100644 packages/rollup-plugin/package.json create mode 100644 packages/rollup-plugin/rollup.npm.config.mjs create mode 100644 packages/rollup-plugin/src/index.ts create mode 100644 packages/rollup-plugin/test/index.test.ts create mode 100644 packages/rollup-plugin/test/tsconfig.json create mode 100644 packages/rollup-plugin/tsconfig.json create mode 100644 packages/rollup-plugin/tsconfig.types.json create mode 100644 packages/rollup-plugin/vite.config.ts create mode 100644 packages/vite-plugin/.gitignore create mode 100644 packages/vite-plugin/LICENSE create mode 100644 packages/vite-plugin/README.md create mode 100644 packages/vite-plugin/package.json create mode 100644 packages/vite-plugin/rollup.npm.config.mjs create mode 100644 packages/vite-plugin/src/index.ts create mode 100644 packages/vite-plugin/test/index.test.ts create mode 100644 packages/vite-plugin/test/tsconfig.json create mode 100644 packages/vite-plugin/tsconfig.json create mode 100644 packages/vite-plugin/tsconfig.types.json create mode 100644 packages/vite-plugin/vite.config.ts create mode 100644 packages/webpack-plugin/.gitignore create mode 100644 packages/webpack-plugin/LICENSE create mode 100644 packages/webpack-plugin/README.md create mode 100644 packages/webpack-plugin/package.json create mode 100644 packages/webpack-plugin/rollup.npm.config.mjs create mode 100644 packages/webpack-plugin/src/index.ts create mode 100644 packages/webpack-plugin/src/webpack5.ts create mode 100644 packages/webpack-plugin/test/index.test.ts create mode 100644 packages/webpack-plugin/test/tsconfig.json create mode 100644 packages/webpack-plugin/tsconfig.json create mode 100644 packages/webpack-plugin/tsconfig.types.json create mode 100644 packages/webpack-plugin/vite.config.ts diff --git a/.craft.yml b/.craft.yml index c5e537806bcb..a6a739ead8bc 100644 --- a/.craft.yml +++ b/.craft.yml @@ -145,6 +145,18 @@ targets: - name: npm id: '@sentry/bundler-plugins' includeNames: /^sentry-bundler-plugins-\d.*\.tgz$/ + - name: npm + id: '@sentry/esbuild-plugin' + includeNames: /^sentry-esbuild-plugin-\d.*\.tgz$/ + - name: npm + id: '@sentry/rollup-plugin' + includeNames: /^sentry-rollup-plugin-\d.*\.tgz$/ + - name: npm + id: '@sentry/vite-plugin' + includeNames: /^sentry-vite-plugin-\d.*\.tgz$/ + - name: npm + id: '@sentry/webpack-plugin' + includeNames: /^sentry-webpack-plugin-\d.*\.tgz$/ - name: npm id: '@sentry/typescript' includeNames: /^sentry-typescript-\d.*\.tgz$/ diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 499244434f82..5f6a5a8fdf55 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -64,6 +64,10 @@ body: - '@sentry/tanstackstart-react' - '@sentry/vue' - '@sentry/wasm' + - '@sentry/esbuild-plugin' + - '@sentry/rollup-plugin' + - '@sentry/vite-plugin' + - '@sentry/webpack-plugin' - Sentry Browser Loader - Sentry Browser CDN bundle validations: diff --git a/.github/workflows/issue-package-label.yml b/.github/workflows/issue-package-label.yml index fb77747f336f..8ac1c7987801 100644 --- a/.github/workflows/issue-package-label.yml +++ b/.github/workflows/issue-package-label.yml @@ -122,6 +122,18 @@ jobs: "@sentry.wasm": { "label": "WASM" }, + "@sentry.esbuild-plugin": { + "label": "Feature: Source Maps" + }, + "@sentry.rollup-plugin": { + "label": "Feature: Source Maps" + }, + "@sentry.vite-plugin": { + "label": "Feature: Source Maps" + }, + "@sentry.webpack-plugin": { + "label": "Feature: Source Maps" + }, "Sentry.Browser.Loader": { "label": "Browser" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 0727144b61f9..5571f47fb4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +### Important Changes + +- **feat(bundler-plugins): Release `@sentry/webpack-plugin`, `@sentry/vite-plugin`, `@sentry/rollup-plugin` and `@sentry/esbuild-plugin` from the SDK repository ([#25095](https://github.com/getsentry/sentry-javascript/pull/25095))** + + The four standalone bundler plugins now release together with the SDK, so their version jumps from 5.4.1 to the SDK version. Each package re-exports the matching `@sentry/bundler-plugins` entry point, which we recommend for new projects. The plugin options do not change, but the update brings these breaking changes: + + - Node.js 20.19.0 or higher is required. + - `@sentry/webpack-plugin` requires webpack 5.1 or higher. The `@sentry/webpack-plugin/webpack5` entry point still works and exports the same plugin as `@sentry/webpack-plugin`. + - The plugins use the `sentry` CLI SDK instead of `@sentry/cli`. Sentry self-hosted 26.4.2 or higher is supported. Lower versions may continue to work. + - Deploys that the plugins create on Vercel use the value of `VERCEL_TARGET_ENV` (`production`, `preview` or a custom name) as their environment, instead of `vercel-production` or `vercel-preview`. To keep the old value, set `release.deploy.env`. + ## 11.7.0 ### Important Changes diff --git a/package.json b/package.json index d009220ac089..3e0beef30732 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "packages/effect", "packages/ember", "packages/elysia", + "packages/esbuild-plugin", "packages/eslint-config-sdk", "packages/eslint-plugin-sdk", "packages/feedback", @@ -88,6 +89,7 @@ "packages/replay-internal", "packages/replay-canvas", "packages/replay-worker", + "packages/rollup-plugin", "packages/server-runtime-injection", "packages/server-utils", "packages/solid", @@ -97,8 +99,10 @@ "packages/tanstackstart-react", "packages/typescript", "packages/vercel-edge", + "packages/vite-plugin", "packages/vue", "packages/wasm", + "packages/webpack-plugin", "dev-packages/browser-integration-tests", "dev-packages/bundler-plugin-integration-tests", "dev-packages/e2e-tests", diff --git a/packages/esbuild-plugin/.gitignore b/packages/esbuild-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/esbuild-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/esbuild-plugin/LICENSE b/packages/esbuild-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/esbuild-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/esbuild-plugin/README.md b/packages/esbuild-plugin/README.md new file mode 100644 index 000000000000..72ba4460548e --- /dev/null +++ b/packages/esbuild-plugin/README.md @@ -0,0 +1,45 @@ +

+ + Sentry + +

+ +# Sentry esbuild Plugin + +A esbuild plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/esbuild`. + +## Installation + +```bash +npm install @sentry/esbuild-plugin --save-dev +``` + +## Usage + +```js +// build.js +import * as esbuild from 'esbuild'; +import { sentryEsbuildPlugin } from '@sentry/esbuild-plugin'; + +await esbuild.build({ + sourcemap: true, + plugins: [ + sentryEsbuildPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## Support + +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/esbuild-plugin/package.json b/packages/esbuild-plugin/package.json new file mode 100644 index 000000000000..0f85f5c61ef0 --- /dev/null +++ b/packages/esbuild-plugin/package.json @@ -0,0 +1,56 @@ +{ + "name": "@sentry/esbuild-plugin", + "version": "11.7.0", + "description": "Official Sentry esbuild plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/esbuild-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "esbuild", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-esbuild-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/esbuild-plugin/rollup.npm.config.mjs b/packages/esbuild-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..b11c2c4f9148 --- /dev/null +++ b/packages/esbuild-plugin/rollup.npm.config.mjs @@ -0,0 +1,12 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + packageSpecificConfig: { + output: { + exports: 'named', + }, + }, + }), +); diff --git a/packages/esbuild-plugin/src/index.ts b/packages/esbuild-plugin/src/index.ts new file mode 100644 index 000000000000..142a16901164 --- /dev/null +++ b/packages/esbuild-plugin/src/index.ts @@ -0,0 +1,2 @@ +export * from '@sentry/bundler-plugins/esbuild'; +export { default } from '@sentry/bundler-plugins/esbuild'; diff --git a/packages/esbuild-plugin/test/index.test.ts b/packages/esbuild-plugin/test/index.test.ts new file mode 100644 index 000000000000..d92084cfd902 --- /dev/null +++ b/packages/esbuild-plugin/test/index.test.ts @@ -0,0 +1,14 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/esbuild'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/esbuild-plugin', () => { + it('re-exports @sentry/bundler-plugins/esbuild', () => { + expect(plugin.sentryEsbuildPlugin).toBe(bundlerPlugins.sentryEsbuildPlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); + + it('re-exports the default export', () => { + expect(plugin.default).toBe(bundlerPlugins.default); + }); +}); diff --git a/packages/esbuild-plugin/test/tsconfig.json b/packages/esbuild-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/esbuild-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/esbuild-plugin/tsconfig.json b/packages/esbuild-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/esbuild-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/esbuild-plugin/tsconfig.types.json b/packages/esbuild-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/esbuild-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/esbuild-plugin/vite.config.ts b/packages/esbuild-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/esbuild-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/packages/rollup-plugin/.gitignore b/packages/rollup-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/rollup-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/rollup-plugin/LICENSE b/packages/rollup-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/rollup-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/rollup-plugin/README.md b/packages/rollup-plugin/README.md new file mode 100644 index 000000000000..17a8d1c7b2c2 --- /dev/null +++ b/packages/rollup-plugin/README.md @@ -0,0 +1,46 @@ +

+ + Sentry + +

+ +# Sentry Rollup Plugin + +A Rollup plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/rollup`. + +## Installation + +```bash +npm install @sentry/rollup-plugin --save-dev +``` + +## Usage + +```js +// rollup.config.js +import { sentryRollupPlugin } from '@sentry/rollup-plugin'; + +export default { + output: { + sourcemap: true, + }, + plugins: [ + sentryRollupPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}; +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## Support + +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/rollup-plugin/package.json b/packages/rollup-plugin/package.json new file mode 100644 index 000000000000..22252d75e54f --- /dev/null +++ b/packages/rollup-plugin/package.json @@ -0,0 +1,65 @@ +{ + "name": "@sentry/rollup-plugin", + "version": "11.7.0", + "description": "Official Sentry Rollup plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/rollup-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "rollup-plugin", + "Rollup", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-rollup-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "peerDependencies": { + "rollup": ">=3.2.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/rollup-plugin/rollup.npm.config.mjs b/packages/rollup-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..601cc6837ce0 --- /dev/null +++ b/packages/rollup-plugin/rollup.npm.config.mjs @@ -0,0 +1,7 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + }), +); diff --git a/packages/rollup-plugin/src/index.ts b/packages/rollup-plugin/src/index.ts new file mode 100644 index 000000000000..0807365d7f98 --- /dev/null +++ b/packages/rollup-plugin/src/index.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/rollup'; diff --git a/packages/rollup-plugin/test/index.test.ts b/packages/rollup-plugin/test/index.test.ts new file mode 100644 index 000000000000..759af8b27bb6 --- /dev/null +++ b/packages/rollup-plugin/test/index.test.ts @@ -0,0 +1,10 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/rollup'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/rollup-plugin', () => { + it('re-exports @sentry/bundler-plugins/rollup', () => { + expect(plugin.sentryRollupPlugin).toBe(bundlerPlugins.sentryRollupPlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); +}); diff --git a/packages/rollup-plugin/test/tsconfig.json b/packages/rollup-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/rollup-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/rollup-plugin/tsconfig.json b/packages/rollup-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/rollup-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/rollup-plugin/tsconfig.types.json b/packages/rollup-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/rollup-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/rollup-plugin/vite.config.ts b/packages/rollup-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/rollup-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/packages/vite-plugin/.gitignore b/packages/vite-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/vite-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/vite-plugin/LICENSE b/packages/vite-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/vite-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md new file mode 100644 index 000000000000..95264132eb3b --- /dev/null +++ b/packages/vite-plugin/README.md @@ -0,0 +1,47 @@ +

+ + Sentry + +

+ +# Sentry Vite Plugin + +A Vite plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/vite`. + +## Installation + +```bash +npm install @sentry/vite-plugin --save-dev +``` + +## Usage + +```js +// vite.config.js +import { defineConfig } from 'vite'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; + +export default defineConfig({ + build: { + sourcemap: true, + }, + plugins: [ + sentryVitePlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}); +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## Support + +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json new file mode 100644 index 000000000000..c8fa6bf2f241 --- /dev/null +++ b/packages/vite-plugin/package.json @@ -0,0 +1,56 @@ +{ + "name": "@sentry/vite-plugin", + "version": "11.7.0", + "description": "Official Sentry Vite plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/vite-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "Vite", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-vite-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/vite-plugin/rollup.npm.config.mjs b/packages/vite-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..601cc6837ce0 --- /dev/null +++ b/packages/vite-plugin/rollup.npm.config.mjs @@ -0,0 +1,7 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts'], + }), +); diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts new file mode 100644 index 000000000000..cc8533273401 --- /dev/null +++ b/packages/vite-plugin/src/index.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/vite'; diff --git a/packages/vite-plugin/test/index.test.ts b/packages/vite-plugin/test/index.test.ts new file mode 100644 index 000000000000..5cb1c33948c4 --- /dev/null +++ b/packages/vite-plugin/test/index.test.ts @@ -0,0 +1,10 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/vite'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/vite-plugin', () => { + it('re-exports @sentry/bundler-plugins/vite', () => { + expect(plugin.sentryVitePlugin).toBe(bundlerPlugins.sentryVitePlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); +}); diff --git a/packages/vite-plugin/test/tsconfig.json b/packages/vite-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/vite-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/vite-plugin/tsconfig.json b/packages/vite-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/vite-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/vite-plugin/tsconfig.types.json b/packages/vite-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/vite-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/vite-plugin/vite.config.ts b/packages/vite-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/vite-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; diff --git a/packages/webpack-plugin/.gitignore b/packages/webpack-plugin/.gitignore new file mode 100644 index 000000000000..22cfeae206b4 --- /dev/null +++ b/packages/webpack-plugin/.gitignore @@ -0,0 +1,2 @@ +build +*.tgz diff --git a/packages/webpack-plugin/LICENSE b/packages/webpack-plugin/LICENSE new file mode 100644 index 000000000000..c23a90edc9be --- /dev/null +++ b/packages/webpack-plugin/LICENSE @@ -0,0 +1,29 @@ +# MIT License + +Copyright (c) 2022-2026, Sentry +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +- Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/webpack-plugin/README.md b/packages/webpack-plugin/README.md new file mode 100644 index 000000000000..035bc61567ae --- /dev/null +++ b/packages/webpack-plugin/README.md @@ -0,0 +1,44 @@ +

+ + Sentry + +

+ +# Sentry Webpack Plugin + +A Webpack plugin that uploads source maps to Sentry and injects release and Debug ID information into your bundles. + +This package re-exports `@sentry/bundler-plugins/webpack`. + +## Installation + +```bash +npm install @sentry/webpack-plugin --save-dev +``` + +## Usage + +```js +// webpack.config.js +const { sentryWebpackPlugin } = require('@sentry/webpack-plugin'); + +module.exports = { + devtool: 'source-map', + plugins: [ + sentryWebpackPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + ], +}; +``` + +## Documentation + +- [Uploading source maps](https://docs.sentry.io/platforms/javascript/sourcemaps/uploading/) + +## Support + +- [Report a bug](https://github.com/getsentry/sentry-javascript/issues/new/choose) +- [Contributing](https://github.com/getsentry/sentry-javascript/blob/develop/CONTRIBUTING.md) diff --git a/packages/webpack-plugin/package.json b/packages/webpack-plugin/package.json new file mode 100644 index 000000000000..639451d01134 --- /dev/null +++ b/packages/webpack-plugin/package.json @@ -0,0 +1,64 @@ +{ + "name": "@sentry/webpack-plugin", + "version": "11.7.0", + "description": "Official Sentry Webpack plugin", + "repository": "git://github.com/getsentry/sentry-javascript.git", + "homepage": "https://github.com/getsentry/sentry-javascript/tree/develop/packages/webpack-plugin", + "author": "Sentry", + "license": "MIT", + "keywords": [ + "Sentry", + "Webpack", + "bundler", + "plugin" + ], + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "/build" + ], + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" + }, + "./webpack5": { + "types": "./build/types/webpack5.d.ts", + "import": "./build/esm/webpack5.js", + "require": "./build/cjs/webpack5.js" + } + }, + "scripts": { + "build": "run-p build:transpile build:types", + "build:dev": "yarn build", + "build:transpile": "rollup -c rollup.npm.config.mjs", + "build:types": "tsc -p tsconfig.types.json", + "build:watch": "run-p build:transpile:watch", + "build:dev:watch": "yarn build:watch", + "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", + "build:tarball": "npm pack", + "clean": "rimraf build coverage sentry-webpack-plugin-*.tgz", + "lint": "oxlint . --type-aware", + "lint:fix": "oxlint . --fix --type-aware", + "test": "vitest run", + "test:unit": "vitest run" + }, + "dependencies": { + "@sentry/bundler-plugins": "11.7.0" + }, + "peerDependencies": { + "webpack": ">=5.1.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/packages/webpack-plugin/rollup.npm.config.mjs b/packages/webpack-plugin/rollup.npm.config.mjs new file mode 100644 index 000000000000..b4522dc9186d --- /dev/null +++ b/packages/webpack-plugin/rollup.npm.config.mjs @@ -0,0 +1,7 @@ +import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; + +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/webpack5.ts'], + }), +); diff --git a/packages/webpack-plugin/src/index.ts b/packages/webpack-plugin/src/index.ts new file mode 100644 index 000000000000..054e9f40a1f3 --- /dev/null +++ b/packages/webpack-plugin/src/index.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/webpack'; diff --git a/packages/webpack-plugin/src/webpack5.ts b/packages/webpack-plugin/src/webpack5.ts new file mode 100644 index 000000000000..054e9f40a1f3 --- /dev/null +++ b/packages/webpack-plugin/src/webpack5.ts @@ -0,0 +1 @@ +export * from '@sentry/bundler-plugins/webpack'; diff --git a/packages/webpack-plugin/test/index.test.ts b/packages/webpack-plugin/test/index.test.ts new file mode 100644 index 000000000000..6a542d367dab --- /dev/null +++ b/packages/webpack-plugin/test/index.test.ts @@ -0,0 +1,15 @@ +import * as bundlerPlugins from '@sentry/bundler-plugins/webpack'; +import { describe, expect, it } from 'vitest'; +import * as plugin from '../src/index'; + +describe('@sentry/webpack-plugin', () => { + it('re-exports @sentry/bundler-plugins/webpack', () => { + expect(plugin.sentryWebpackPlugin).toBe(bundlerPlugins.sentryWebpackPlugin); + expect(Object.keys(plugin).sort()).toEqual(Object.keys(bundlerPlugins).sort()); + }); + + it('re-exports the plugin from ./webpack5', async () => { + const webpack5 = await import('../src/webpack5'); + expect(webpack5.sentryWebpackPlugin).toBe(bundlerPlugins.sentryWebpackPlugin); + }); +}); diff --git a/packages/webpack-plugin/test/tsconfig.json b/packages/webpack-plugin/test/tsconfig.json new file mode 100644 index 000000000000..76d0c9cff6f2 --- /dev/null +++ b/packages/webpack-plugin/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../tsconfig.json", + "include": ["../src/**/*", "./**/*"], + "compilerOptions": { + "types": ["node"] + } +} diff --git a/packages/webpack-plugin/tsconfig.json b/packages/webpack-plugin/tsconfig.json new file mode 100644 index 000000000000..2044e12308ea --- /dev/null +++ b/packages/webpack-plugin/tsconfig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./package.json"] +} diff --git a/packages/webpack-plugin/tsconfig.types.json b/packages/webpack-plugin/tsconfig.types.json new file mode 100644 index 000000000000..9321489cd9a9 --- /dev/null +++ b/packages/webpack-plugin/tsconfig.types.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "include": ["./src/**/*"], + "compilerOptions": { + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "build/types" + } +} diff --git a/packages/webpack-plugin/vite.config.ts b/packages/webpack-plugin/vite.config.ts new file mode 100644 index 000000000000..f18ec92095bc --- /dev/null +++ b/packages/webpack-plugin/vite.config.ts @@ -0,0 +1,8 @@ +import baseConfig from '../../vite/vite.config'; + +export default { + ...baseConfig, + test: { + ...baseConfig.test, + }, +}; From 51b1946a9947e828931e1466ddefdad9c8a4c8e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20Beteg=C3=B3n?= Date: Tue, 13 Oct 2026 20:58:25 +0200 Subject: [PATCH 15/37] ref(core): use shared MCP attribute conventions (#25059) Use `@sentry/conventions/attributes` for registered MCP keys, including all singleton and indexed result fields. The branch now uses published conventions `0.27.0` from `develop`, so both local result prefixes can be removed. Before, the SDK constructed `mcp.tool.result.0.content` from a local prefix. After, `MCP_TOOL_RESULT_KEY_CONTENT.replace('', '0')` produces that same key. Singleton keys, values, original array indexes, and input/output capture behavior remain unchanged. Deprecated tool-result fields stay emitted for compatibility; this change adds no aliases or backfill. This is the mechanical base for network metadata (#24506), OAuth attribution (#24508), and canonical OpenTelemetry attributes (#25060). The latter separately adds complete serialized results in `gen_ai.tool.call.result`. --- .../mcp-server/attributeExtraction.ts | 59 +++++--- .../src/integrations/mcp-server/attributes.ts | 143 ------------------ .../integrations/mcp-server/correlation.ts | 13 +- .../integrations/mcp-server/methodConfig.ts | 32 ++-- .../integrations/mcp-server/piiFiltering.ts | 4 +- .../mcp-server/resultExtraction.ts | 96 ++++++++---- .../mcp-server/sessionExtraction.ts | 63 ++++---- .../core/src/integrations/mcp-server/spans.ts | 15 +- .../src/integrations/mcp-server/transport.ts | 4 +- 9 files changed, 170 insertions(+), 259 deletions(-) diff --git a/packages/core/src/integrations/mcp-server/attributeExtraction.ts b/packages/core/src/integrations/mcp-server/attributeExtraction.ts index 75449f43ccc9..fff8d76dc46f 100644 --- a/packages/core/src/integrations/mcp-server/attributeExtraction.ts +++ b/packages/core/src/integrations/mcp-server/attributeExtraction.ts @@ -2,15 +2,25 @@ * Core attribute extraction and building functions for MCP server instrumentation */ -import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; import { - MCP_LOGGING_DATA_TYPE_ATTRIBUTE, - MCP_LOGGING_LEVEL_ATTRIBUTE, - MCP_LOGGING_LOGGER_ATTRIBUTE, - MCP_LOGGING_MESSAGE_ATTRIBUTE, - MCP_REQUEST_ID_ATTRIBUTE, - MCP_RESOURCE_URI_ATTRIBUTE, -} from './attributes'; + MCP_CANCELLED_REASON, + MCP_CANCELLED_REQUEST_ID, + MCP_LIFECYCLE_PHASE, + MCP_LOGGING_DATA_TYPE, + MCP_LOGGING_LEVEL, + MCP_LOGGING_LOGGER, + MCP_LOGGING_MESSAGE, + MCP_PROGRESS_CURRENT, + MCP_PROGRESS_MESSAGE, + MCP_PROGRESS_PERCENTAGE, + MCP_PROGRESS_TOKEN, + MCP_PROGRESS_TOTAL, + MCP_PROTOCOL_READY, + MCP_REQUEST_ID, + MCP_RESOURCE_PROTOCOL, + MCP_RESOURCE_URI, +} from '@sentry/conventions/attributes'; +import { isURLObjectRelative, parseStringToURLObject } from '../../utils/url'; import { extractTargetInfo, getRequestArguments } from './methodConfig'; import type { JsonRpcNotification, JsonRpcRequest, McpSpanType } from './types'; @@ -39,59 +49,59 @@ export function getNotificationAttributes( switch (method) { case 'notifications/cancelled': if (params?.requestId) { - attributes['mcp.cancelled.request_id'] = String(params.requestId); + attributes[MCP_CANCELLED_REQUEST_ID] = String(params.requestId); } if (params?.reason) { - attributes['mcp.cancelled.reason'] = String(params.reason); + attributes[MCP_CANCELLED_REASON] = String(params.reason); } break; case 'notifications/message': if (params?.level) { - attributes[MCP_LOGGING_LEVEL_ATTRIBUTE] = String(params.level); + attributes[MCP_LOGGING_LEVEL] = String(params.level); } if (params?.logger) { - attributes[MCP_LOGGING_LOGGER_ATTRIBUTE] = String(params.logger); + attributes[MCP_LOGGING_LOGGER] = String(params.logger); } if (params?.data !== undefined) { - attributes[MCP_LOGGING_DATA_TYPE_ATTRIBUTE] = typeof params.data; + attributes[MCP_LOGGING_DATA_TYPE] = typeof params.data; if (recordInputs) { - attributes[MCP_LOGGING_MESSAGE_ATTRIBUTE] = formatLoggingData(params.data); + attributes[MCP_LOGGING_MESSAGE] = formatLoggingData(params.data); } } break; case 'notifications/progress': if (params?.progressToken) { - attributes['mcp.progress.token'] = String(params.progressToken); + attributes[MCP_PROGRESS_TOKEN] = String(params.progressToken); } if (typeof params?.progress === 'number') { - attributes['mcp.progress.current'] = params.progress; + attributes[MCP_PROGRESS_CURRENT] = params.progress; } if (typeof params?.total === 'number') { - attributes['mcp.progress.total'] = params.total; + attributes[MCP_PROGRESS_TOTAL] = params.total; if (typeof params?.progress === 'number') { - attributes['mcp.progress.percentage'] = (params.progress / params.total) * 100; + attributes[MCP_PROGRESS_PERCENTAGE] = (params.progress / params.total) * 100; } } if (params?.message) { - attributes['mcp.progress.message'] = String(params.message); + attributes[MCP_PROGRESS_MESSAGE] = String(params.message); } break; case 'notifications/resources/updated': if (params?.uri) { - attributes[MCP_RESOURCE_URI_ATTRIBUTE] = String(params.uri); + attributes[MCP_RESOURCE_URI] = String(params.uri); const urlObject = parseStringToURLObject(String(params.uri)); if (urlObject && !isURLObjectRelative(urlObject)) { - attributes['mcp.resource.protocol'] = urlObject.protocol.replace(':', ''); + attributes[MCP_RESOURCE_PROTOCOL] = urlObject.protocol.replace(':', ''); } } break; case 'notifications/initialized': - attributes['mcp.lifecycle.phase'] = 'initialization_complete'; - attributes['mcp.protocol.ready'] = 1; + attributes[MCP_LIFECYCLE_PHASE] = 'initialization_complete'; + attributes[MCP_PROTOCOL_READY] = 1; break; } @@ -117,7 +127,8 @@ export function buildTypeSpecificAttributes( const targetInfo = extractTargetInfo(request.method, params || {}); return { - ...(request.id !== undefined && { [MCP_REQUEST_ID_ATTRIBUTE]: String(request.id) }), + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy request ID attribute for existing consumers. + ...(request.id !== undefined && { [MCP_REQUEST_ID]: String(request.id) }), ...targetInfo.attributes, ...(recordInputs ? getRequestArguments(request.method, params || {}) : {}), }; diff --git a/packages/core/src/integrations/mcp-server/attributes.ts b/packages/core/src/integrations/mcp-server/attributes.ts index 2baa446418d7..b707336b2b95 100644 --- a/packages/core/src/integrations/mcp-server/attributes.ts +++ b/packages/core/src/integrations/mcp-server/attributes.ts @@ -1,146 +1,3 @@ -/** - * Essential MCP attribute constants for Sentry instrumentation - * - * Based on OpenTelemetry MCP semantic conventions - * @see https://github.com/open-telemetry/semantic-conventions/blob/3097fb0af5b9492b0e3f55dc5f6c21a3dc2be8df/docs/gen-ai/mcp.md - */ - -// ============================================================================= -// CORE MCP ATTRIBUTES -// ============================================================================= - -/** The name of the request or notification method */ -export const MCP_METHOD_NAME_ATTRIBUTE = 'mcp.method.name'; - -/** JSON-RPC request identifier for the request. Unique within the MCP session. */ -export const MCP_REQUEST_ID_ATTRIBUTE = 'mcp.request.id'; - -/** Identifies the MCP session */ -export const MCP_SESSION_ID_ATTRIBUTE = 'mcp.session.id'; - -/** Transport method used for MCP communication */ -export const MCP_TRANSPORT_ATTRIBUTE = 'mcp.transport'; - -// ============================================================================= -// CLIENT ATTRIBUTES -// ============================================================================= - -/** Name of the MCP client application */ -export const MCP_CLIENT_NAME_ATTRIBUTE = 'mcp.client.name'; - -/** Display title of the MCP client application */ -export const MCP_CLIENT_TITLE_ATTRIBUTE = 'mcp.client.title'; - -/** Version of the MCP client application */ -export const MCP_CLIENT_VERSION_ATTRIBUTE = 'mcp.client.version'; - -// ============================================================================= -// SERVER ATTRIBUTES -// ============================================================================= - -/** Name of the MCP server application */ -export const MCP_SERVER_NAME_ATTRIBUTE = 'mcp.server.name'; - -/** Display title of the MCP server application */ -export const MCP_SERVER_TITLE_ATTRIBUTE = 'mcp.server.title'; - -/** Version of the MCP server application */ -export const MCP_SERVER_VERSION_ATTRIBUTE = 'mcp.server.version'; - -/** MCP protocol version used in the session */ -export const MCP_PROTOCOL_VERSION_ATTRIBUTE = 'mcp.protocol.version'; - -// ============================================================================= -// METHOD-SPECIFIC ATTRIBUTES -// ============================================================================= - -/** Name of the tool being called */ -export const MCP_TOOL_NAME_ATTRIBUTE = 'mcp.tool.name'; - -/** The resource URI being accessed */ -export const MCP_RESOURCE_URI_ATTRIBUTE = 'mcp.resource.uri'; - -/** Name of the prompt template */ -export const MCP_PROMPT_NAME_ATTRIBUTE = 'mcp.prompt.name'; - -// ============================================================================= -// TOOL RESULT ATTRIBUTES -// ============================================================================= - -/** Whether a tool execution resulted in an error */ -export const MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE = 'mcp.tool.result.is_error'; - -/** Number of content items in the tool result */ -export const MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE = 'mcp.tool.result.content_count'; - -/** Serialized content of the tool result */ -export const MCP_TOOL_RESULT_CONTENT_ATTRIBUTE = 'mcp.tool.result.content'; - -/** Prefix for tool result attributes that contain sensitive content */ -export const MCP_TOOL_RESULT_PREFIX = 'mcp.tool.result'; - -// ============================================================================= -// PROMPT RESULT ATTRIBUTES -// ============================================================================= - -/** Description of the prompt result */ -export const MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE = 'mcp.prompt.result.description'; - -/** Number of messages in the prompt result */ -export const MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE = 'mcp.prompt.result.message_count'; - -/** Role of the message in the prompt result (for single message results) */ -export const MCP_PROMPT_RESULT_MESSAGE_ROLE_ATTRIBUTE = 'mcp.prompt.result.message_role'; - -/** Content of the message in the prompt result (for single message results) */ -export const MCP_PROMPT_RESULT_MESSAGE_CONTENT_ATTRIBUTE = 'mcp.prompt.result.message_content'; - -/** Prefix for prompt result attributes that contain sensitive content */ -export const MCP_PROMPT_RESULT_PREFIX = 'mcp.prompt.result'; - -// ============================================================================= -// REQUEST ARGUMENT ATTRIBUTES -// ============================================================================= - -/** Prefix for MCP request argument prefix for each argument */ -export const MCP_REQUEST_ARGUMENT = 'mcp.request.argument'; - -// ============================================================================= -// LOGGING ATTRIBUTES -// ============================================================================= - -/** Log level for MCP logging operations */ -export const MCP_LOGGING_LEVEL_ATTRIBUTE = 'mcp.logging.level'; - -/** Logger name for MCP logging operations */ -export const MCP_LOGGING_LOGGER_ATTRIBUTE = 'mcp.logging.logger'; - -/** Data type of the logged message */ -export const MCP_LOGGING_DATA_TYPE_ATTRIBUTE = 'mcp.logging.data_type'; - -/** Log message content */ -export const MCP_LOGGING_MESSAGE_ATTRIBUTE = 'mcp.logging.message'; - -// ============================================================================= -// NETWORK ATTRIBUTES (OpenTelemetry Standard) -// ============================================================================= - -/** OSI transport layer protocol */ -export const NETWORK_TRANSPORT_ATTRIBUTE = 'network.transport'; - -/** The version of JSON RPC protocol used */ -export const NETWORK_PROTOCOL_VERSION_ATTRIBUTE = 'network.protocol.version'; - -/** Client address - domain name if available without reverse DNS lookup; otherwise, IP address or Unix domain socket name */ -export const CLIENT_ADDRESS_ATTRIBUTE = 'client.address'; - -/** Client port number */ -export const CLIENT_PORT_ATTRIBUTE = 'client.port'; - -// ============================================================================= -// SENTRY-SPECIFIC MCP ATTRIBUTE VALUES -// ============================================================================= - /** Sentry origin value for MCP function spans */ export const MCP_FUNCTION_ORIGIN_VALUE = 'auto.function.mcp_server'; diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 3b0e8e9a5d0e..4ca42839787d 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -10,10 +10,14 @@ * Falls back to WeakMap by transport instance for stateless transports (no sessionId). */ -import { ERROR_TYPE, RPC_RESPONSE_STATUS_CODE } from '@sentry/conventions/attributes'; +import { + ERROR_TYPE, + MCP_PROTOCOL_VERSION, + MCP_TOOL_RESULT_IS_ERROR, + RPC_RESPONSE_STATUS_CODE, +} from '@sentry/conventions/attributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; import type { Span } from '../../types/span'; -import { MCP_PROTOCOL_VERSION_ATTRIBUTE, MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE } from './attributes'; import { isJsonRpcServerError } from './errorCapture'; import { extractPromptResultAttributes, extractToolResultAttributes } from './resultExtraction'; import { @@ -118,7 +122,7 @@ export function completeSpanWithResults( ...buildServerAttributesFromInfo(responseSessionData.serverInfo), }; if (responseSessionData.protocolVersion) { - responseAttributes[MCP_PROTOCOL_VERSION_ATTRIBUTE] = responseSessionData.protocolVersion; + responseAttributes[MCP_PROTOCOL_VERSION] = responseSessionData.protocolVersion; } if (Object.keys(responseAttributes).length > 0) { span.setAttributes(responseAttributes); @@ -133,7 +137,8 @@ export function completeSpanWithResults( } else if (method === 'tools/call') { const toolAttributes = extractToolResultAttributes(result, spanData.capturePolicy.recordOutputs); span.setAttributes(toolAttributes); - if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] === true) { + // oxlint-disable-next-line typescript/no-deprecated -- Preserve classification based on the existing tool result attribute. + if (toolAttributes[MCP_TOOL_RESULT_IS_ERROR] === true) { span.setAttributes({ [ERROR_TYPE]: 'tool_error' }); span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); } diff --git a/packages/core/src/integrations/mcp-server/methodConfig.ts b/packages/core/src/integrations/mcp-server/methodConfig.ts index 0153adf49bbc..1e885187b83e 100644 --- a/packages/core/src/integrations/mcp-server/methodConfig.ts +++ b/packages/core/src/integrations/mcp-server/methodConfig.ts @@ -2,13 +2,15 @@ * Method configuration and request processing for MCP server instrumentation */ -import { isObjectLike } from '../../utils/is'; import { - MCP_PROMPT_NAME_ATTRIBUTE, - MCP_REQUEST_ARGUMENT, - MCP_RESOURCE_URI_ATTRIBUTE, - MCP_TOOL_NAME_ATTRIBUTE, -} from './attributes'; + MCP_PROMPT_NAME, + MCP_REQUEST_ARGUMENT_KEY_BASE, + MCP_REQUEST_ARGUMENT_NAME, + MCP_REQUEST_ARGUMENT_URI, + MCP_RESOURCE_URI, + MCP_TOOL_NAME, +} from '@sentry/conventions/attributes'; +import { isObjectLike } from '../../utils/is'; import type { MethodConfig } from './types'; /** @@ -18,27 +20,29 @@ import type { MethodConfig } from './types'; const METHOD_CONFIGS: Record = { 'tools/call': { targetField: 'name', - targetAttribute: MCP_TOOL_NAME_ATTRIBUTE, + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy tool name attribute for existing consumers. + targetAttribute: MCP_TOOL_NAME, targetIsLowCardinality: true, captureArguments: true, argumentsField: 'arguments', }, 'resources/read': { targetField: 'uri', - targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + targetAttribute: MCP_RESOURCE_URI, captureUri: true, }, 'resources/subscribe': { targetField: 'uri', - targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + targetAttribute: MCP_RESOURCE_URI, }, 'resources/unsubscribe': { targetField: 'uri', - targetAttribute: MCP_RESOURCE_URI_ATTRIBUTE, + targetAttribute: MCP_RESOURCE_URI, }, 'prompts/get': { targetField: 'name', - targetAttribute: MCP_PROMPT_NAME_ATTRIBUTE, + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy prompt name attribute for existing consumers. + targetAttribute: MCP_PROMPT_NAME, targetIsLowCardinality: true, captureName: true, captureArguments: true, @@ -95,17 +99,17 @@ export function getRequestArguments(method: string, params: Record)) { - args[`${MCP_REQUEST_ARGUMENT}.${key.toLowerCase()}`] = JSON.stringify(value); + args[`${MCP_REQUEST_ARGUMENT_KEY_BASE}.${key.toLowerCase()}`] = JSON.stringify(value); } } } if (config.captureUri && params?.uri) { - args[`${MCP_REQUEST_ARGUMENT}.uri`] = JSON.stringify(params.uri); + args[MCP_REQUEST_ARGUMENT_URI] = JSON.stringify(params.uri); } if (config.captureName && params?.name) { - args[`${MCP_REQUEST_ARGUMENT}.name`] = JSON.stringify(params.name); + args[MCP_REQUEST_ARGUMENT_NAME] = JSON.stringify(params.name); } return args; diff --git a/packages/core/src/integrations/mcp-server/piiFiltering.ts b/packages/core/src/integrations/mcp-server/piiFiltering.ts index 8dc139f2bbf4..fe9b91744cfb 100644 --- a/packages/core/src/integrations/mcp-server/piiFiltering.ts +++ b/packages/core/src/integrations/mcp-server/piiFiltering.ts @@ -5,14 +5,14 @@ * Input/output data (request arguments, tool/prompt results) is controlled * separately via recordInputs/recordOutputs options. */ +import { CLIENT_ADDRESS, CLIENT_PORT, MCP_RESOURCE_URI } from '@sentry/conventions/attributes'; import type { SpanAttributeValue } from '../../types/span'; -import { CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE } from './attributes'; /** * Network PII attributes that should be removed when dataCollection.userInfo is false * @internal */ -const NETWORK_PII_ATTRIBUTES = new Set([CLIENT_ADDRESS_ATTRIBUTE, CLIENT_PORT_ATTRIBUTE, MCP_RESOURCE_URI_ATTRIBUTE]); +const NETWORK_PII_ATTRIBUTES = new Set([CLIENT_ADDRESS, CLIENT_PORT, MCP_RESOURCE_URI]); /** * Checks if an attribute key should be considered network PII. diff --git a/packages/core/src/integrations/mcp-server/resultExtraction.ts b/packages/core/src/integrations/mcp-server/resultExtraction.ts index 58f9ad860083..42fd9d8740c1 100644 --- a/packages/core/src/integrations/mcp-server/resultExtraction.ts +++ b/packages/core/src/integrations/mcp-server/resultExtraction.ts @@ -5,13 +5,34 @@ */ import { - MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE, - MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE, - MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE, - MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE, -} from './attributes'; + MCP_PROMPT_RESULT_DESCRIPTION, + MCP_PROMPT_RESULT_KEY_CONTENT, + MCP_PROMPT_RESULT_KEY_ROLE, + MCP_PROMPT_RESULT_MESSAGE_CONTENT, + MCP_PROMPT_RESULT_MESSAGE_COUNT, + MCP_PROMPT_RESULT_MESSAGE_ROLE, + MCP_TOOL_RESULT_CONTENT, + MCP_TOOL_RESULT_CONTENT_COUNT, + MCP_TOOL_RESULT_CONTENT_TYPE, + MCP_TOOL_RESULT_DATA_SIZE, + MCP_TOOL_RESULT_IS_ERROR, + MCP_TOOL_RESULT_KEY_CONTENT, + MCP_TOOL_RESULT_KEY_CONTENT_TYPE, + MCP_TOOL_RESULT_KEY_DATA_SIZE, + MCP_TOOL_RESULT_KEY_MIME_TYPE, + MCP_TOOL_RESULT_KEY_NAME, + MCP_TOOL_RESULT_KEY_RESOURCE_MIME_TYPE, + MCP_TOOL_RESULT_KEY_RESOURCE_URI, + MCP_TOOL_RESULT_KEY_URI, + MCP_TOOL_RESULT_MIME_TYPE, + MCP_TOOL_RESULT_NAME, + MCP_TOOL_RESULT_RESOURCE_MIME_TYPE, + MCP_TOOL_RESULT_RESOURCE_URI, + MCP_TOOL_RESULT_URI, +} from '@sentry/conventions/attributes'; import { isValidContentItem } from './validation'; +/* oxlint-disable typescript/no-deprecated -- Preserve the legacy result attributes for existing consumers. */ /** * Build attributes for tool result content items * @param content - Array of content items from tool result @@ -23,7 +44,7 @@ function buildAllContentItemAttributes( includeContent: boolean, ): Record { const attributes: Record = { - [MCP_TOOL_RESULT_CONTENT_COUNT_ATTRIBUTE]: content.length, + [MCP_TOOL_RESULT_CONTENT_COUNT]: content.length, }; for (const [i, item] of content.entries()) { @@ -31,41 +52,55 @@ function buildAllContentItemAttributes( continue; } - const prefix = content.length === 1 ? 'mcp.tool.result' : `mcp.tool.result.${i}`; + const single = content.length === 1; + const index = String(i); if (typeof item.type === 'string') { - attributes[`${prefix}.content_type`] = item.type; + const key = single ? MCP_TOOL_RESULT_CONTENT_TYPE : MCP_TOOL_RESULT_KEY_CONTENT_TYPE.replace('', index); + attributes[key] = item.type; } if (includeContent) { const safeSet = (key: string, value: unknown): void => { if (typeof value === 'string') { - attributes[`${prefix}.${key}`] = value; + attributes[key] = value; } }; - safeSet('mime_type', item.mimeType); - safeSet('uri', item.uri); - safeSet('name', item.name); + safeSet( + single ? MCP_TOOL_RESULT_MIME_TYPE : MCP_TOOL_RESULT_KEY_MIME_TYPE.replace('', index), + item.mimeType, + ); + safeSet(single ? MCP_TOOL_RESULT_URI : MCP_TOOL_RESULT_KEY_URI.replace('', index), item.uri); + safeSet(single ? MCP_TOOL_RESULT_NAME : MCP_TOOL_RESULT_KEY_NAME.replace('', index), item.name); if (typeof item.text === 'string') { - attributes[`${prefix}.content`] = item.text; + const attrName = single ? MCP_TOOL_RESULT_CONTENT : MCP_TOOL_RESULT_KEY_CONTENT.replace('', index); + attributes[attrName] = item.text; } if (typeof item.data === 'string') { - attributes[`${prefix}.data_size`] = item.data.length; + const key = single ? MCP_TOOL_RESULT_DATA_SIZE : MCP_TOOL_RESULT_KEY_DATA_SIZE.replace('', index); + attributes[key] = item.data.length; } const resource = item.resource; if (isValidContentItem(resource)) { - safeSet('resource_uri', resource.uri); - safeSet('resource_mime_type', resource.mimeType); + safeSet( + single ? MCP_TOOL_RESULT_RESOURCE_URI : MCP_TOOL_RESULT_KEY_RESOURCE_URI.replace('', index), + resource.uri, + ); + safeSet( + single ? MCP_TOOL_RESULT_RESOURCE_MIME_TYPE : MCP_TOOL_RESULT_KEY_RESOURCE_MIME_TYPE.replace('', index), + resource.mimeType, + ); } } } return attributes; } +/* oxlint-enable typescript/no-deprecated */ /** * Extract tool result attributes for span instrumentation @@ -84,7 +119,8 @@ export function extractToolResultAttributes( const attributes = Array.isArray(result.content) ? buildAllContentItemAttributes(result.content, recordOutputs) : {}; if (typeof result.isError === 'boolean') { - attributes[MCP_TOOL_RESULT_IS_ERROR_ATTRIBUTE] = result.isError; + // oxlint-disable-next-line typescript/no-deprecated -- Preserve the legacy tool result attribute for existing consumers. + attributes[MCP_TOOL_RESULT_IS_ERROR] = result.isError; } return attributes; @@ -106,11 +142,11 @@ export function extractPromptResultAttributes( } if (recordOutputs && typeof result.description === 'string') { - attributes[MCP_PROMPT_RESULT_DESCRIPTION_ATTRIBUTE] = result.description; + attributes[MCP_PROMPT_RESULT_DESCRIPTION] = result.description; } if (Array.isArray(result.messages)) { - attributes[MCP_PROMPT_RESULT_MESSAGE_COUNT_ATTRIBUTE] = result.messages.length; + attributes[MCP_PROMPT_RESULT_MESSAGE_COUNT] = result.messages.length; if (recordOutputs) { const messages = result.messages; @@ -119,21 +155,21 @@ export function extractPromptResultAttributes( continue; } - const prefix = messages.length === 1 ? 'mcp.prompt.result' : `mcp.prompt.result.${i}`; - - const safeSet = (key: string, value: unknown): void => { - if (typeof value === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_${key}` : `${prefix}.${key}`; - attributes[attrName] = value; - } - }; - - safeSet('role', message.role); + if (typeof message.role === 'string') { + const attrName = + messages.length === 1 + ? MCP_PROMPT_RESULT_MESSAGE_ROLE + : MCP_PROMPT_RESULT_KEY_ROLE.replace('', String(i)); + attributes[attrName] = message.role; + } if (isValidContentItem(message.content)) { const content = message.content; if (typeof content.text === 'string') { - const attrName = messages.length === 1 ? `${prefix}.message_content` : `${prefix}.content`; + const attrName = + messages.length === 1 + ? MCP_PROMPT_RESULT_MESSAGE_CONTENT + : MCP_PROMPT_RESULT_KEY_CONTENT.replace('', String(i)); attributes[attrName] = content.text; } } diff --git a/packages/core/src/integrations/mcp-server/sessionExtraction.ts b/packages/core/src/integrations/mcp-server/sessionExtraction.ts index a4de982bf1b4..492674ec16c3 100644 --- a/packages/core/src/integrations/mcp-server/sessionExtraction.ts +++ b/packages/core/src/integrations/mcp-server/sessionExtraction.ts @@ -5,17 +5,20 @@ */ import { - CLIENT_ADDRESS_ATTRIBUTE, - CLIENT_PORT_ATTRIBUTE, - MCP_PROTOCOL_VERSION_ATTRIBUTE, - MCP_SERVER_NAME_ATTRIBUTE, - MCP_SERVER_TITLE_ATTRIBUTE, - MCP_SERVER_VERSION_ATTRIBUTE, - MCP_SESSION_ID_ATTRIBUTE, - MCP_TRANSPORT_ATTRIBUTE, - NETWORK_PROTOCOL_VERSION_ATTRIBUTE, - NETWORK_TRANSPORT_ATTRIBUTE, -} from './attributes'; + CLIENT_ADDRESS, + CLIENT_PORT, + MCP_CLIENT_NAME, + MCP_CLIENT_TITLE, + MCP_CLIENT_VERSION, + MCP_PROTOCOL_VERSION, + MCP_SERVER_NAME, + MCP_SERVER_TITLE, + MCP_SERVER_VERSION, + MCP_SESSION_ID, + MCP_TRANSPORT, + NETWORK_PROTOCOL_VERSION, + NETWORK_TRANSPORT, +} from '@sentry/conventions/attributes'; import { getClientInfoForTransport, getProtocolVersionForTransport, @@ -143,13 +146,13 @@ export function getClientAttributes(transport: MCPTransport): Record = {}; if (clientInfo?.name) { - attributes['mcp.client.name'] = clientInfo.name; + attributes[MCP_CLIENT_NAME] = clientInfo.name; } if (clientInfo?.title) { - attributes['mcp.client.title'] = clientInfo.title; + attributes[MCP_CLIENT_TITLE] = clientInfo.title; } if (clientInfo?.version) { - attributes['mcp.client.version'] = clientInfo.version; + attributes[MCP_CLIENT_VERSION] = clientInfo.version; } return attributes; @@ -164,13 +167,13 @@ export function buildClientAttributesFromInfo(clientInfo?: PartyInfo): Record = {}; if (clientInfo?.name) { - attributes['mcp.client.name'] = clientInfo.name; + attributes[MCP_CLIENT_NAME] = clientInfo.name; } if (clientInfo?.title) { - attributes['mcp.client.title'] = clientInfo.title; + attributes[MCP_CLIENT_TITLE] = clientInfo.title; } if (clientInfo?.version) { - attributes['mcp.client.version'] = clientInfo.version; + attributes[MCP_CLIENT_VERSION] = clientInfo.version; } return attributes; @@ -186,13 +189,13 @@ export function getServerAttributes(transport: MCPTransport): Record = {}; if (serverInfo?.name) { - attributes[MCP_SERVER_NAME_ATTRIBUTE] = serverInfo.name; + attributes[MCP_SERVER_NAME] = serverInfo.name; } if (serverInfo?.title) { - attributes[MCP_SERVER_TITLE_ATTRIBUTE] = serverInfo.title; + attributes[MCP_SERVER_TITLE] = serverInfo.title; } if (serverInfo?.version) { - attributes[MCP_SERVER_VERSION_ATTRIBUTE] = serverInfo.version; + attributes[MCP_SERVER_VERSION] = serverInfo.version; } return attributes; @@ -207,13 +210,13 @@ export function buildServerAttributesFromInfo(serverInfo?: PartyInfo): Record = {}; if (serverInfo?.name) { - attributes[MCP_SERVER_NAME_ATTRIBUTE] = serverInfo.name; + attributes[MCP_SERVER_NAME] = serverInfo.name; } if (serverInfo?.title) { - attributes[MCP_SERVER_TITLE_ATTRIBUTE] = serverInfo.title; + attributes[MCP_SERVER_TITLE] = serverInfo.title; } if (serverInfo?.version) { - attributes[MCP_SERVER_VERSION_ATTRIBUTE] = serverInfo.version; + attributes[MCP_SERVER_VERSION] = serverInfo.version; } return attributes; @@ -288,13 +291,13 @@ export function buildTransportAttributes( const protocolVersion = hasRequestMetadata ? messageData?.protocolVersion : getProtocolVersionForTransport(transport); const attributes = { - ...(sessionId && { [MCP_SESSION_ID_ATTRIBUTE]: sessionId }), - ...(clientInfo.address && { [CLIENT_ADDRESS_ATTRIBUTE]: clientInfo.address }), - ...(clientInfo.port && { [CLIENT_PORT_ATTRIBUTE]: clientInfo.port }), - [MCP_TRANSPORT_ATTRIBUTE]: mcpTransport, - [NETWORK_TRANSPORT_ATTRIBUTE]: networkTransport, - [NETWORK_PROTOCOL_VERSION_ATTRIBUTE]: '2.0', - ...(protocolVersion && { [MCP_PROTOCOL_VERSION_ATTRIBUTE]: protocolVersion }), + ...(sessionId && { [MCP_SESSION_ID]: sessionId }), + ...(clientInfo.address && { [CLIENT_ADDRESS]: clientInfo.address }), + ...(clientInfo.port && { [CLIENT_PORT]: clientInfo.port }), + [MCP_TRANSPORT]: mcpTransport, + [NETWORK_TRANSPORT]: networkTransport, + [NETWORK_PROTOCOL_VERSION]: '2.0', + ...(protocolVersion && { [MCP_PROTOCOL_VERSION]: protocolVersion }), ...clientAttributes, ...serverAttributes, }; diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index b380da55e5a8..9f370a5d1b98 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -5,23 +5,18 @@ * Handles both request and notification spans with attribute extraction. */ -import { getClient } from '../../currentScopes'; -import { SENTRY_OP, SENTRY_SEGMENT_NAME_SOURCE, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { MCP_METHOD_NAME, SENTRY_OP, SENTRY_ORIGIN, SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes'; import { MCP_NOTIFICATION_CLIENT_TO_SERVER, MCP_NOTIFICATION_SERVER_TO_CLIENT, MCP_SERVER, } from '@sentry/conventions/op'; +import { getClient } from '../../currentScopes'; import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled'; import { MCP_NOTIFICATION_SPAN_NAME_FALLBACK, MCP_SERVER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames'; import { startSpan } from '../../tracing/trace'; import { buildTransportAttributes, buildTypeSpecificAttributes } from './attributeExtraction'; -import { - MCP_FUNCTION_ORIGIN_VALUE, - MCP_METHOD_NAME_ATTRIBUTE, - MCP_NOTIFICATION_ORIGIN_VALUE, - MCP_ROUTE_SOURCE_VALUE, -} from './attributes'; +import { MCP_FUNCTION_ORIGIN_VALUE, MCP_NOTIFICATION_ORIGIN_VALUE, MCP_ROUTE_SOURCE_VALUE } from './attributes'; import { extractTargetInfo } from './methodConfig'; import { filterMcpPiiFromSpanData } from './piiFiltering'; import type { @@ -103,7 +98,7 @@ function createMcpSpan(config: McpSpanConfig): unknown { const rawAttributes: Record = { ...buildTransportAttributes(transport, extra, message), - [MCP_METHOD_NAME_ATTRIBUTE]: method, + [MCP_METHOD_NAME]: method, ...buildTypeSpecificAttributes(type, message, params, options?.recordInputs), ...buildSentryAttributes(type), }; @@ -201,7 +196,7 @@ export function buildMcpServerSpanConfig( const rawAttributes: Record = { ...buildTransportAttributes(transport, extra, jsonRpcMessage), - [MCP_METHOD_NAME_ATTRIBUTE]: method, + [MCP_METHOD_NAME]: method, ...buildTypeSpecificAttributes('request', jsonRpcMessage, params, options?.recordInputs), ...buildSentryAttributes('request'), }; diff --git a/packages/core/src/integrations/mcp-server/transport.ts b/packages/core/src/integrations/mcp-server/transport.ts index 599457b01cfd..92788b275f49 100644 --- a/packages/core/src/integrations/mcp-server/transport.ts +++ b/packages/core/src/integrations/mcp-server/transport.ts @@ -5,12 +5,12 @@ * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports */ +import { MCP_PROTOCOL_VERSION } from '@sentry/conventions/attributes'; import { getClient, getIsolationScope, withIsolationScope } from '../../currentScopes'; import { withActiveSpan } from '../../tracing'; import { startInactiveSpan } from '../../tracing/trace'; import { isObjectLike } from '../../utils/is'; import { fill } from '../../utils/object'; -import { MCP_PROTOCOL_VERSION_ATTRIBUTE } from './attributes'; import { cleanupPendingSpansForTransport, completeSpanWithResults, storeSpanForRequest } from './correlation'; import { captureError, isJsonRpcServerError } from './errorCapture'; import { buildClientAttributesFromInfo, extractSessionDataFromInitializeRequest } from './sessionExtraction'; @@ -73,7 +73,7 @@ export function wrapTransportOnMessage(transport: MCPTransport, options: McpServ span.setAttributes({ ...buildClientAttributesFromInfo(messageSessionData.clientInfo), ...(messageSessionData.protocolVersion && { - [MCP_PROTOCOL_VERSION_ATTRIBUTE]: messageSessionData.protocolVersion, + [MCP_PROTOCOL_VERSION]: messageSessionData.protocolVersion, }), }); } From 8c65689b4b323e6812e915ec4c7b6724cfa3c2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 08:55:49 +0200 Subject: [PATCH 16/37] test(bun): Restore @sentry/core spies after the Bun.serve tests (#25276) This restores the `@sentry/core` spies that `bunserver.test.ts` creates (`startSpanManual`, `continueTrace` and `captureException`) in an `afterAll`. ## Why `bun test` runs all test files in one process, in the order in which the filesystem lists them. The spies stayed active for the next files, so when `bunHttpServer.test.ts` ran next, its request handler had no active span and two tests failed. On GitHub runners `bunRuntimeMetrics.test.ts` ran in between, and its `jest.restoreAllMocks()` hid the bug. Other runners list the files in a different order. In that file order on Linux with Bun 1.3.14, 2 of 81 tests failed before this change, and all 81 pass after it. Failed on CI: https://github.com/getsentry/sentry-javascript/actions/runs/38031083791/job/114152141693 Co-authored-by: Claude Opus 5.5 --- packages/bun/test/integrations/bunserver.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/bun/test/integrations/bunserver.test.ts b/packages/bun/test/integrations/bunserver.test.ts index 0a25b655a892..bec2b892ea3a 100644 --- a/packages/bun/test/integrations/bunserver.test.ts +++ b/packages/bun/test/integrations/bunserver.test.ts @@ -1,6 +1,6 @@ import type { RequestEventData } from '@sentry/core'; import * as SentryCore from '@sentry/core'; -import { afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test'; import type { BunOptions } from '../../src'; import { bunServerIntegration, getDefaultIntegrationsWithoutPerformance, init } from '../../src'; import { instrumentBunServe } from '../../src/integrations/bunserver'; @@ -53,6 +53,13 @@ describe('Bun Serve Integration', () => { port += 1; }); + // The spies replace `@sentry/core` exports for all test files that `bun test` runs after this one + afterAll(() => { + captureExceptionSpy.mockRestore(); + continueTraceSpy.mockRestore(); + startSpanSpy.mockRestore(); + }); + test.each(['fetch', 'route'])( 'keeps a streaming %s response span open until the last chunk is consumed', async mode => { From 00d053550c498c1f0e9d865960c7e7245950b9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 08:55:49 +0200 Subject: [PATCH 17/37] test(node): Await the child exit in the "should exit" integration tests (#25277) This adds `exited()` to the integration test runner. It resolves on the `close` event of the child process. The four "should exit" tests in `thread-blocked-native` and `anr` now await it. Before, `thread-blocked-native` waited a fixed 5 s, and `anr` checked every 100 ms. `childHasExited()` had no other users and is removed. ## Why Under CI load the child process sometimes needed more than 5 s to exit, so the test failed although the process exited correctly. The describe timeout still fails a child that never exits. Failed on CI: https://github.com/getsentry/sentry-javascript/actions/runs/38031718055/job/114154033567 Co-authored-by: Claude Opus 5.5 --- .../node-integration-tests/suites/anr/test.ts | 23 ++----------------- .../suites/thread-blocked-native/test.ts | 12 ++-------- .../utils/runner/createRunner.ts | 9 +++++--- 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/anr/test.ts b/dev-packages/node-integration-tests/suites/anr/test.ts index b1aabd2eb001..04907dd8b925 100644 --- a/dev-packages/node-integration-tests/suites/anr/test.ts +++ b/dev-packages/node-integration-tests/suites/anr/test.ts @@ -2,17 +2,6 @@ import type { Event } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; -/** Avoid flakes on slow CI: fixed sleeps can fire before the child process has finished exiting. */ -async function waitForChildExit(childHasExited: () => boolean, timeoutMs = 30_000): Promise { - const start = Date.now(); - while (!childHasExited()) { - if (Date.now() - start > timeoutMs) { - throw new Error('Timed out waiting for child process to exit'); - } - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - const ANR_EVENT = { // Ensure we have context contexts: { @@ -187,19 +176,11 @@ describe('should report ANR when event loop blocked', { timeout: 90_000 }, () => }); test('should exit', async () => { - const runner = createRunner(__dirname, 'should-exit.js').start(); - - await waitForChildExit(() => runner.childHasExited()); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit.js').start().exited(); }); test('should exit forced', async () => { - const runner = createRunner(__dirname, 'should-exit-forced.js').start(); - - await waitForChildExit(() => runner.childHasExited()); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit-forced.js').start().exited(); }); test('With session', async () => { diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index bf39f9d7c1ad..54cfe8b356a0 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -162,19 +162,11 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { }); test('should exit', async () => { - const runner = createRunner(__dirname, 'should-exit.js').start(); - - await new Promise(resolve => setTimeout(resolve, 5_000)); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit.js').start().exited(); }); test('should exit forced', async () => { - const runner = createRunner(__dirname, 'should-exit-forced.js').start(); - - await new Promise(resolve => setTimeout(resolve, 5_000)); - - expect(runner.childHasExited()).toBe(true); + await createRunner(__dirname, 'should-exit-forced.js').start().exited(); }); test('can be disabled with disableBlockDetectionForCallback', async () => { diff --git a/dev-packages/node-integration-tests/utils/runner/createRunner.ts b/dev-packages/node-integration-tests/utils/runner/createRunner.ts index 9a1dcf8ba4a0..d9fd1d5f149b 100644 --- a/dev-packages/node-integration-tests/utils/runner/createRunner.ts +++ b/dev-packages/node-integration-tests/utils/runner/createRunner.ts @@ -98,7 +98,7 @@ type ExpectedEnvelopeHeader = type StartResult = { completed(): Promise; - childHasExited(): boolean; + exited(): Promise; getLogs(): string[]; getPort(): number | undefined; sendSignal(signal: NodeJS.Signals): void; @@ -380,6 +380,8 @@ export function createRunner(...paths: string[]) { const completedDeferred = createDeferred(); // Resolved once the scenario reports its server port, so `makeRequest` can await it directly. const portReady = createDeferred(); + // Resolved when the scenario process closes, so exit tests can await it instead of polling. + const exitedDeferred = createDeferred(); // Vitest stops a test at its own timeout before `completed()` gives up, so print the child // output then too. `completed()` prints it for the failures it reports itself. @@ -666,6 +668,7 @@ export function createRunner(...paths: string[]) { child.on('close', (code, signal) => { hasExited = true; exitStatus = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; + exitedDeferred.resolve(); if (ensureNoErrorOutput) { complete(); @@ -759,8 +762,8 @@ export function createRunner(...paths: string[]) { throw completeError; } }, - childHasExited: function (): boolean { - return hasExited; + exited: function (): Promise { + return exitedDeferred.promise; }, getLogs(): string[] { return logs; From ce6fde8c4d25c1222fc3acf612ef93c31887ae5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 08:55:50 +0200 Subject: [PATCH 18/37] test(node): Log slow and failed Docker Compose starts in integration tests (#25278) This makes slow or failed Docker Compose starts in the Node integration tests visible: - Missing images are pulled in their own step. The pull and start times are logged when they take longer than 60 s. - Each failed `up --wait` attempt logs its output and the container logs before the retry. - The `mssql` healthcheck gets a 60 s start period, so a slow SQL Server start does not cause a restart. ## Why The `beforeAll` hook blocks the event loop with `spawnSync`. When it took longer than 120 s, vitest only reported "Hook timed out in 120000ms" and not what took long. This happened when the runner first had to pull the 1.5 GB `mssql` image. ## Known issues - This does not make a pull without cached images faster. The tedious suite can still reach the 120 s timeout then, but the log shows the pull time. Failed on CI: https://github.com/getsentry/sentry-javascript/actions/runs/38033058956/job/114158377266 Co-authored-by: Claude Opus 5.5 --- .../suites/tracing/tedious/docker-compose.yml | 2 +- .../utils/runner/describeWithDockerCompose.ts | 42 ++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml index ef4db33b7357..f85441012119 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml +++ b/dev-packages/node-integration-tests/suites/tracing/tedious/docker-compose.yml @@ -14,4 +14,4 @@ services: interval: 2s timeout: 3s retries: 30 - start_period: 20s + start_period: 60s diff --git a/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts b/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts index 07a19489ee1a..0ab245e48cb9 100644 --- a/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts +++ b/dev-packages/node-integration-tests/utils/runner/describeWithDockerCompose.ts @@ -6,6 +6,8 @@ import { afterAll, beforeAll, describe } from 'vitest'; type DefineTests = () => void; +const SLOW_START_MS = 60_000; + interface DockerOptions { /** * The working directory to run docker compose in @@ -94,9 +96,28 @@ async function runDockerCompose(options: DockerOptions): Promise { }); }; + const output = (result: ReturnType): string => + `${result.stderr?.toString() ?? ''}${result.stdout?.toString() ?? ''}`; + // Surface container logs to make healthcheck failures easier to diagnose in CI + const containerLogs = (): string => spawnSync('docker', composeArgs('logs'), { cwd }).stdout?.toString() ?? ''; + // ensure we're starting fresh close(); + // Pull on its own, so a slow image pull on a runner without cached images can be told apart from + // a slow container start. + const pullStartedAt = Date.now(); + const pull = spawnSync('docker', composeArgs('pull', '--policy', 'missing', '--quiet'), { + cwd, + stdio: process.env.DEBUG ? 'inherit' : 'pipe', + }); + const pullMs = Date.now() - pullStartedAt; + if (pull.status !== 0) { + // `up` pulls missing images too, so only report this + // eslint-disable-next-line no-console + console.warn(`[docker compose] pull failed after ${pullMs}ms (exit ${pull.status}) in ${cwd}\n${output(pull)}`); + } + const composeUp = (): ReturnType => spawnSync('docker', composeArgs('up', '-d', '--wait'), { cwd, @@ -108,22 +129,33 @@ async function runDockerCompose(options: DockerOptions): Promise { // found" right after the network was created). A clean teardown plus retry // clears these, while genuine healthcheck failures stay red on every attempt. const maxAttempts = 3; + const upStartedAt = Date.now(); + let attemptStartedAt = upStartedAt; let result = composeUp(); for (let attempt = 1; attempt < maxAttempts && result.status !== 0; attempt++) { + // eslint-disable-next-line no-console + console.warn( + `[docker compose] up --wait attempt ${attempt} failed after ${Date.now() - attemptStartedAt}ms ` + + `(exit ${result.status}) in ${cwd}\n${output(result)}\n--- container logs ---\n${containerLogs()}`, + ); close(); + attemptStartedAt = Date.now(); result = composeUp(); } + const upMs = Date.now() - upStartedAt; if (result.status !== 0) { - const stderr = result.stderr?.toString() ?? ''; - const stdout = result.stdout?.toString() ?? ''; - // Surface container logs to make healthcheck failures easier to diagnose in CI - const logs = spawnSync('docker', composeArgs('logs'), { cwd }).stdout?.toString() ?? ''; + const logs = containerLogs(); close(); throw new Error( - `docker compose up --wait failed (exit ${result.status})\n${stderr}${stdout}\n--- container logs ---\n${logs}`, + `docker compose up --wait failed (exit ${result.status})\n${output(result)}\n--- container logs ---\n${logs}`, ); } + if (pullMs + upMs > SLOW_START_MS) { + // eslint-disable-next-line no-console + console.warn(`[docker compose] slow start in ${cwd}: pull ${pullMs}ms, up --wait ${upMs}ms`); + } + return close; } From 5a724d8c04842244e44e1692b0aef03bc35ffa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 08:55:50 +0200 Subject: [PATCH 19/37] test(e2e): Use an external host address for the Lambda DSN on loopback hostnames (#25282) Even if we would stay on GHA runners for now, it is still good to keep this one in as this could also happen locally. The AWS serverless E2E apps point the DSN of the Lambda at the address that the hostname of the runner resolves to. When that address is a loopback address, they now use the first external IPv4 address that is not a Docker bridge. ## Why On Namespace runners the hostname resolves to `127.0.0.1`. Inside the Lambda container, that is the container itself, so no event reached the event proxy, and each test timed out after 180 s. On GitHub runners the hostname resolves to the address of the VM, so nothing changes there. Failed on CI: https://github.com/getsentry/sentry-javascript/actions/runs/38033058956/job/114158845637 Co-authored-by: Claude Opus 5.5 --- .../aws-serverless-layer/src/stack.ts | 23 ++++++++++++++++--- .../aws-serverless/src/stack.ts | 23 ++++++++++++++++--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index df5a076ef56d..6ed4cd16440f 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -118,14 +118,31 @@ export class LocalLambdaStack extends Stack { export async function getHostIp() { if (process.env.GITHUB_ACTIONS) { - const host = await dns.lookup(os.hostname()); - return host.address; + return getExternalHostIp(); } if (platform === 'darwin' || platform === 'win32') { return 'host.docker.internal'; } + return getExternalHostIp(); +} + +/** + * The Lambda container reaches the event proxy on the host through an address of the host's network interface. + * Some runners (e.g. Namespace) map the hostname to a loopback address, which inside the container is the container + * itself, so fall back to the first external IPv4 address that is not a Docker bridge. + */ +async function getExternalHostIp(): Promise { const host = await dns.lookup(os.hostname()); - return host.address; + if (!host.address.startsWith('127.') && host.address !== '::1') { + return host.address; + } + + const external = Object.entries(os.networkInterfaces()) + .filter(([name]) => !name.startsWith('docker') && !name.startsWith('br-') && !name.startsWith('veth')) + .flatMap(([, addresses]) => addresses ?? []) + .find(address => address.family === 'IPv4' && !address.internal); + + return external?.address ?? host.address; } diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index f314643572c2..c4eb3658afd0 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -159,14 +159,31 @@ export class LocalLambdaStack extends Stack { export async function getHostIp() { if (process.env.GITHUB_ACTIONS) { - const host = await dns.lookup(os.hostname()); - return host.address; + return getExternalHostIp(); } if (platform === 'darwin' || platform === 'win32') { return 'host.docker.internal'; } + return getExternalHostIp(); +} + +/** + * The Lambda container reaches the event proxy on the host through an address of the host's network interface. + * Some runners (e.g. Namespace) map the hostname to a loopback address, which inside the container is the container + * itself, so fall back to the first external IPv4 address that is not a Docker bridge. + */ +async function getExternalHostIp(): Promise { const host = await dns.lookup(os.hostname()); - return host.address; + if (!host.address.startsWith('127.') && host.address !== '::1') { + return host.address; + } + + const external = Object.entries(os.networkInterfaces()) + .filter(([name]) => !name.startsWith('docker') && !name.startsWith('br-') && !name.startsWith('veth')) + .flatMap(([, addresses]) => addresses ?? []) + .find(address => address.family === 'IPv4' && !address.internal); + + return external?.address ?? host.address; } From 3e0273906373e5491d07a29ff91c298cef6d632c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 08:55:51 +0200 Subject: [PATCH 20/37] test(nextjs): Switch DEBUG_BUILD with a getter instead of re-importing the SDK (#25279) `conflictingDebugOptions.test.ts` now mocks `DEBUG_BUILD` one time, with a getter over a `vi.hoisted` value, and it imports the client, server and edge entry points one time. Before, each test called `vi.doMock` and `vi.resetModules` and imported all three entry points again, and `beforeAll` "pre-warmed" them one more time. ## Why Under CI load, evaluating the three module graphs again took longer than the 10 s hook timeout. Now the two tests take a few milliseconds (25 ms and 6 ms locally, 1.68 s before). The "vi.unmock is not at the top level" warning is also gone. Failed on CI: https://github.com/getsentry/sentry-javascript/actions/runs/38034725173/job/114166137692 Co-authored-by: Claude Opus 5.5 --- .../config/conflictingDebugOptions.test.ts | 44 ++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/packages/nextjs/test/config/conflictingDebugOptions.test.ts b/packages/nextjs/test/config/conflictingDebugOptions.test.ts index 6356fcc9bfdc..0a877af8958a 100644 --- a/packages/nextjs/test/config/conflictingDebugOptions.test.ts +++ b/packages/nextjs/test/config/conflictingDebugOptions.test.ts @@ -1,8 +1,21 @@ import { JSDOM } from 'jsdom'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import * as client from '../../src/client/index.js'; +import * as edge from '../../src/edge/index.js'; +import * as server from '../../src/server/index.js'; const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; +const debugBuild = vi.hoisted(() => ({ value: true })); + +// The entry points read `DEBUG_BUILD` inside `init()`, so a getter lets each test switch it without +// re-importing the SDK module graphs. +vi.mock('../../src/common/debug-build.js', () => ({ + get DEBUG_BUILD() { + return debugBuild.value; + }, +})); + function didWarnAboutDebugRemoved(warnSpy: ReturnType): boolean { return warnSpy.mock.calls.some(call => call.some( @@ -21,15 +34,7 @@ describe('debug: true + removeDebugLogging warning', () => { let originalAddEventListener: unknown; let originalRemoveEventListener: unknown; - beforeAll(async () => { - // Pre-warm V8 compilation cache for the large SDK module graphs. - // Without this, the first dynamic import after vi.resetModules() can hang - // because vitest needs to compile the entire module graph from scratch. - await import('../../src/client/index.js'); - await import('../../src/server/index.js'); - await import('../../src/edge/index.js'); - vi.resetModules(); - + beforeAll(() => { dom = new JSDOM('', { url: 'https://example.com/' }); originalDocument = (globalThis as any).document; @@ -52,41 +57,30 @@ describe('debug: true + removeDebugLogging warning', () => { afterEach(() => { vi.restoreAllMocks(); - vi.resetModules(); - vi.unmock('../../src/common/debug-build.js'); + debugBuild.value = true; delete process.env.NEXT_OTEL_FETCH_DISABLED; delete process.env.NEXT_PHASE; }); - it('warns on client/server/edge when debug is true but DEBUG_BUILD is false', async () => { - vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: false })); + it('warns on client/server/edge when debug is true but DEBUG_BUILD is false', () => { + debugBuild.value = false; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = await import('../../src/client/index.js'); client.init({ dsn: TEST_DSN, debug: true } as any); - - const server = await import('../../src/server/index.js'); server.init({ dsn: TEST_DSN, debug: true } as any); - - const edge = await import('../../src/edge/index.js'); edge.init({ dsn: TEST_DSN, debug: true } as any); expect(didWarnAboutDebugRemoved(warnSpy)).toBe(true); }); - it('does not emit that warning when DEBUG_BUILD is true', async () => { - vi.doMock('../../src/common/debug-build.js', () => ({ DEBUG_BUILD: true })); + it('does not emit that warning when DEBUG_BUILD is true', () => { + debugBuild.value = true; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = await import('../../src/client/index.js'); client.init({ dsn: TEST_DSN, debug: true } as any); - - const server = await import('../../src/server/index.js'); server.init({ dsn: TEST_DSN, debug: true } as any); - - const edge = await import('../../src/edge/index.js'); edge.init({ dsn: TEST_DSN, debug: true } as any); expect(didWarnAboutDebugRemoved(warnSpy)).toBe(false); From f47c08b68068ddb3a3d1d4e5bd17761bcebf6a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 08:55:51 +0200 Subject: [PATCH 21/37] test(browser): Keep whitespace text out of the handlers-lcp page (#25280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This moves the button of the `handlers-lcp` test page into its own block, so the image is the only LCP candidate. ## Why The space between the inline `` and ` + +
From d57cce732de8bd5262dd6b31b15c2bb48f2d4dde Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 14 Oct 2026 11:07:49 +0200 Subject: [PATCH 22/37] fix(astro): Upload source maps of Astro 7's prerender build (#25309) Astro 7 writes the prerender build to a hidden `.prerender` folder, and our default `/**/*` assets glob does not go into dot folders, so the upload found nothing and logged a warning. This PR adds `/**/.prerender/**/*` to the default globs. These chunks only run at build time, so the gain is small, but it removes the warning and covers errors captured during prerendering. Also adds an `astro-7-sourcemaps` e2e app that checks the uploads for server and static output. Fixes #25304 Co-authored-by: Claude Opus 5.5 (1M context) --- .../astro-7-sourcemaps/.gitignore | 8 ++ .../astro-7-sourcemaps/assert-build.ts | 100 ++++++++++++++++++ .../astro-7-sourcemaps/astro.config.mjs | 54 ++++++++++ .../astro-7-sourcemaps/package.json | 36 +++++++ .../sentry.client.config.js | 5 + .../sentry.server.config.js | 5 + .../astro-7-sourcemaps/src/lib/client.ts | 4 + .../astro-7-sourcemaps/src/lib/prerender.ts | 4 + .../astro-7-sourcemaps/src/lib/server.ts | 4 + .../astro-7-sourcemaps/src/pages/index.astro | 15 +++ .../astro-7-sourcemaps/src/routes/ssr.astro | 11 ++ .../start-mock-sentry-server.mjs | 3 + .../astro-7-sourcemaps/tsconfig.json | 5 + packages/astro/src/integration/index.ts | 18 ++-- packages/astro/test/integration/index.test.ts | 6 +- 15 files changed, 269 insertions(+), 9 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.client.config.js create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.server.config.js create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/client.ts create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/prerender.ts create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/server.ts create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/pages/index.astro create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/routes/ssr.astro create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/.gitignore new file mode 100644 index 000000000000..713fa3d27405 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/.gitignore @@ -0,0 +1,8 @@ +dist/ +.astro/ +node_modules/ +.DS_Store + +# Mock Sentry server artifacts +.tmp_* +/tmp_mock_chunks diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..37f5626e9ad0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts @@ -0,0 +1,100 @@ +import * as assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + findInjectedDebugIds, + findSourceMapFiles, + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, +} from '@sentry-internal/test-utils'; + +const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static'; + +const BUILD_OUTPUT = 'dist'; +const OUTPUT_DIRS = isStatic ? [BUILD_OUTPUT] : [path.join(BUILD_OUTPUT, 'client'), path.join(BUILD_OUTPUT, 'server')]; + +/** All markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; +const PRERENDER_MARKER = 'SOURCEMAP_MARKER_PRERENDER'; + +/** Written by the build, see `recordChunksWithoutSourceMap` in `astro.config.mjs`. */ +const debugIdsWithoutSourceMap = new Set( + fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : [], +); + +const requests = loadMockServerResults(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); +} +console.log(''); + +assert.ok( + requests.some(r => r.authorization.includes('fake-auth-token')), + 'Expected requests with the configured auth token', +); + +assert.ok( + getChunkUploadPosts(requests).some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', +); + +const assembleRequests = getAssembleRequests(requests); +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); +for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); + assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); +} + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); + +const sourcemaps = getSourcemaps(bundles); + +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source'); +if (!isStatic) { + assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source'); +} +// Astro 7 writes the prerender build to a hidden `.prerender` folder, which a plain `dist/**/*` glob skips +assert.ok(containsMarker(PRERENDER_MARKER), 'Expected an uploaded sourcemap carrying the prerendered page source'); + +const buildOutput = ['.tmp_build_stdout', '.tmp_build_stderr'] + .filter(file => fs.existsSync(file)) + .map(file => fs.readFileSync(file, 'utf8')) + .join('\n'); +assert.ok( + !buildOutput.includes("Didn't find any matching sources for debug ID upload"), + 'Expected every Vite build to find sources for the debug ID upload', +); + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +for (const outputDir of OUTPUT_DIRS) { + 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) && !debugIdsWithoutSourceMap.has(debugId), + ); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); +} + +// The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. +// TODO: The default `filesToDeleteAfterUpload` globs miss static output, which writes maps to `dist/_astro`. +if (!isStatic) { + assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); +} + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs new file mode 100644 index 000000000000..3b859eeb1c75 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/astro.config.mjs @@ -0,0 +1,54 @@ +import node from '@astrojs/node'; +import sentry from '@sentry/astro'; +import { defineConfig } from 'astro/config'; +import * as fs from 'node:fs'; + +const isStatic = process.env.E2E_ASTRO_OUTPUT === 'static'; + +// Rolldown emits no map for chunks without mappable source (e.g. virtual modules or its runtime), so their debug +// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build', + enforce: 'post', + writeBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1].toLowerCase()}\n`); + } + } + }, + }; +} + +// Static output allows no on-demand routes, so the SSR page is only added for server output +const ssrRoute = { + name: 'ssr-route', + hooks: { + 'astro:config:setup': ({ injectRoute }) => { + injectRoute({ pattern: '/ssr', entrypoint: './src/routes/ssr.astro' }); + }, + }, +}; + +export default defineConfig({ + integrations: [ + sentry({ + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + debug: true, + }), + ...(isStatic ? [] : [ssrRoute]), + ], + // Keeps Astro from inlining the small client script into the HTML, where it would get no source map + vite: { build: { assetsInlineLimit: 0 }, plugins: [recordChunksWithoutSourceMap()] }, + output: isStatic ? 'static' : 'server', + adapter: isStatic ? undefined : node({ mode: 'standalone' }), +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json new file mode 100644 index 000000000000..20e1e8f4b37b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json @@ -0,0 +1,36 @@ +{ + "name": "astro-7-sourcemaps", + "description": "E2E test app asserting what the Astro SDK uploads to Sentry and what it leaves behind in `dist`.", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "node start-mock-sentry-server.mjs & astro build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "test:build": "pnpm install && pnpm build", + "test:assert": "node assert-build.ts", + "test:build:static": "E2E_ASTRO_OUTPUT=static pnpm test:build", + "test:assert:static": "E2E_ASTRO_OUTPUT=static pnpm test:assert" + }, + "dependencies": { + "@astrojs/node": "^11.1.4", + "@sentry/astro": "file:../../packed/sentry-astro-packed.tgz", + "astro": "^7.2.8" + }, + "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0" + }, + "volta": { + "node": "22.22.0", + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "astro-7-sourcemaps (static output)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.client.config.js b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.client.config.js new file mode 100644 index 000000000000..a372de45b50d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.client.config.js @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/astro'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.server.config.js b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.server.config.js new file mode 100644 index 000000000000..a372de45b50d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/sentry.server.config.js @@ -0,0 +1,5 @@ +import * as Sentry from '@sentry/astro'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/client.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/client.ts new file mode 100644 index 000000000000..92ab780d2a46 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/client.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_CLIENT +document.querySelector('button')?.addEventListener('click', () => { + throw new Error('Client error'); +}); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/prerender.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/prerender.ts new file mode 100644 index 000000000000..28c73e7021fc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/prerender.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_PRERENDER +export function getPrerenderGreeting(): string { + return `Prerendered at ${new Date().toISOString()}`; +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/server.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/server.ts new file mode 100644 index 000000000000..c85840477623 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/lib/server.ts @@ -0,0 +1,4 @@ +// SOURCEMAP_MARKER_SERVER +export function getServerGreeting(): string { + return `Rendered on request at ${new Date().toISOString()}`; +} diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/pages/index.astro b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/pages/index.astro new file mode 100644 index 000000000000..6be5fe86d77a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/pages/index.astro @@ -0,0 +1,15 @@ +--- +import { getPrerenderGreeting } from '../lib/prerender'; + +export const prerender = true; +--- + + + +

{getPrerenderGreeting()}

+ + + + diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/routes/ssr.astro b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/routes/ssr.astro new file mode 100644 index 000000000000..46be8dec4c71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/src/routes/ssr.astro @@ -0,0 +1,11 @@ +--- +import { getServerGreeting } from '../lib/server'; + +export const prerender = false; +--- + + + +

{getServerGreeting()}

+ + diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..8c85ae3329dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org', outputDir: 'tmp_mock_chunks' }); diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..8bf91d3bb997 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index cef232d2f4a6..2d4ae0c152cb 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -109,7 +109,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { release, sourcemaps: { ...sourcemaps, - assets: sourcemaps?.assets ?? [getSourcemapsAssetsGlob(config)], + assets: sourcemaps?.assets ?? getSourcemapsAssetsGlobs(config), filesToDeleteAfterUpload: sourcemaps?.filesToDeleteAfterUpload ?? updatedFilesToDeleteAfterUpload, }, bundleSizeOptimizations: { @@ -273,7 +273,14 @@ function isCloudflarePages(): boolean { return false; } -function getSourcemapsAssetsGlob(config: AstroConfig): string { +function getSourcemapsAssetsGlobs(config: AstroConfig): string[] { + const outDirGlob = getOutDirGlob(config); + // Astro 7 writes the prerender build to a hidden `.prerender` folder, which `**` does not enter. + // Its chunks only run at build time to render static pages, but we still upload them for errors captured then. + return [`${outDirGlob}/**/*`, `${outDirGlob}/**/.prerender/**/*`]; +} + +function getOutDirGlob(config: AstroConfig): string { // The vercel adapter puts the output into its .vercel directory // However, the way this adapter is written, the config.outDir value is update too late for // us to reliably detect it. Also, server files are first temporarily written to /dist and then @@ -281,7 +288,7 @@ function getSourcemapsAssetsGlob(config: AstroConfig): string { // So we glob on both of these directories. // Another case of "it ain't pretty but it works":( if (config.adapter?.name?.startsWith('@astrojs/vercel')) { - return '{.vercel,dist}/**/*'; + return '{.vercel,dist}'; } // paths are stored as "file://" URLs @@ -289,12 +296,11 @@ function getSourcemapsAssetsGlob(config: AstroConfig): string { const rootDirName = path.resolve(config.root?.pathname || process.cwd()); if (outDirPathname) { - const relativePath = path.relative(rootDirName, outDirPathname); - return `${relativePath}/**/*`; + return path.relative(rootDirName, outDirPathname); } // fallback to default output dir - return 'dist/**/*'; + return 'dist'; } /** diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index d3063f14d8bc..19a272db17a7 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -111,7 +111,7 @@ describe('sentryAstro integration', () => { debug: false, bundleSizeOptimizations: {}, sourcemaps: { - assets: ['out/**/*'], + assets: ['out/**/*', 'out/**/.prerender/**/*'], filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], }, _metaOptions: { @@ -142,7 +142,7 @@ describe('sentryAstro integration', () => { debug: false, bundleSizeOptimizations: {}, sourcemaps: { - assets: ['dist/**/*'], + assets: ['dist/**/*', 'dist/**/.prerender/**/*'], filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], }, _metaOptions: { @@ -181,7 +181,7 @@ describe('sentryAstro integration', () => { debug: false, bundleSizeOptimizations: {}, sourcemaps: { - assets: ['{.vercel,dist}/**/*'], + assets: ['{.vercel,dist}/**/*', '{.vercel,dist}/**/.prerender/**/*'], filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], }, _metaOptions: { From e8cc35459c453d29e2f4cccf7b6a1286f2ea0c88 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 14 Oct 2026 11:07:50 +0200 Subject: [PATCH 23/37] fix(astro): Delete generated source maps for static output (#25310) When the SDK turns on hidden source maps, it deletes them after upload with globs for `client` and `server` folders. Static output has neither folder and writes client assets to `/_astro`, so the maps stayed in `dist` and got deployed. This also deletes maps in the `build.assets` folder, and turns the leftover-maps check back on for the static variant of the `astro-7-sourcemaps` e2e app. Found while working on #25304. --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../astro-7-sourcemaps/assert-build.ts | 5 +--- packages/astro/src/integration/index.ts | 11 ++++++-- packages/astro/test/integration/index.test.ts | 28 ++++++++++++++++--- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts index 37f5626e9ad0..aeb76ee19e32 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/assert-build.ts @@ -92,9 +92,6 @@ for (const outputDir of OUTPUT_DIRS) { } // The app does not set `build.sourcemap`, so the SDK enables hidden maps and must delete them after upload. -// TODO: The default `filesToDeleteAfterUpload` globs miss static output, which writes maps to `dist/_astro`. -if (!isStatic) { - assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); -} +assert.deepEqual(findSourceMapFiles({ outputDir: BUILD_OUTPUT }), [], `Expected no source maps in ${BUILD_OUTPUT}`); console.log('All sourcemap assertions passed!'); diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 2d4ae0c152cb..b4e54545a8d3 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -71,8 +71,15 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { typeof sourcemaps?.filesToDeleteAfterUpload === 'undefined' && computedSourceMapSettings.previousUserSourceMapSetting === 'unset' ) { - // This also works for adapters, as the source maps are also copied to e.g. the .vercel folder - updatedFilesToDeleteAfterUpload = ['./dist/**/client/**/*.map', './dist/**/server/**/*.map']; + // This also works for adapters, as the source maps are also copied to e.g. the .vercel folder. + // Static output has no client folder and writes client assets to `/` instead. + // Astro removes the hidden `.prerender` folder itself, but we don't want to rely on that. + updatedFilesToDeleteAfterUpload = [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + `./dist/**/${config.build?.assets ?? '_astro'}/**/*.map`, + './dist/**/.prerender/**/*.map', + ]; debug && logger.info( diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index 19a272db17a7..54b199086c7c 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -112,7 +112,12 @@ describe('sentryAstro integration', () => { bundleSizeOptimizations: {}, sourcemaps: { assets: ['out/**/*', 'out/**/.prerender/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { @@ -143,7 +148,12 @@ describe('sentryAstro integration', () => { bundleSizeOptimizations: {}, sourcemaps: { assets: ['dist/**/*', 'dist/**/.prerender/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { @@ -182,7 +192,12 @@ describe('sentryAstro integration', () => { bundleSizeOptimizations: {}, sourcemaps: { assets: ['{.vercel,dist}/**/*', '{.vercel,dist}/**/.prerender/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { @@ -221,7 +236,12 @@ describe('sentryAstro integration', () => { bundleSizeOptimizations: {}, sourcemaps: { assets: ['dist/server/**/*, dist/client/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], + filesToDeleteAfterUpload: [ + './dist/**/client/**/*.map', + './dist/**/server/**/*.map', + './dist/**/_astro/**/*.map', + './dist/**/.prerender/**/*.map', + ], }, _metaOptions: { telemetry: { From 6dd949c355afc47861e8eab5fd7387c53c58a4c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 14:55:57 +0200 Subject: [PATCH 24/37] fix(server-utils): Transform modules once when two Sentry Vite plugins add orchestrion (#25043) closes #24836 closes [JS-3837](https://linear.app/getsentry/issue/JS-3837/run-the-react-router-e2e-suite-on-bun-deno-and-cloudflare-from-one-app) ## Description This fixes the Vite build when `sentryCloudflareVitePlugin` and a framework's Sentry plugin, for example `sentryReactRouter`, are in the same config. Only the first orchestrion plugin in the build now transforms modules, so apps no longer need `buildTimeInstrumentation: false`. ## Why Both plugins add the orchestrion Vite plugin. Each instrumented module was transformed twice, and the build failed because the injected `orchestrionModuleInjected` snippet was declared twice. ## Dedupe decisions Each plugin object carries an ID property, and an instance skips its work when an earlier plugin in the build has a different ID. The ID also survives SDKs that wrap the plugin with an object spread, like `@sentry/remix`. The alternative is that each framework plugin detects the Cloudflare plugin and turns its own orchestrion off. ## Known issues - When the two instances get different options, for example custom `instrumentations`, only the options of the first one apply. Co-authored-by: Claude Opus 5.5 --- .../vite.cloudflare.config.ts | 3 +- .../src/orchestrion/bundler/vite.ts | 29 ++++++++-- .../test/orchestrion/bundler.test.ts | 54 +++++++++++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts index 8a905ba1e382..002bb3a77fbe 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/vite.cloudflare.config.ts @@ -10,8 +10,7 @@ export default defineConfig(async config => ({ reactRouter(), sentryCloudflareVitePlugin(), ...((await sentryReactRouter( - // Both Sentry plugins inject the orchestrion snippet, and injecting it twice fails the build. - { sourcemaps: { disable: true }, buildTimeInstrumentation: false }, + { sourcemaps: { disable: true } }, config, // eslint-disable-next-line @typescript-eslint/no-explicit-any )) as any[]), diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 0b8f66d961f4..d199f7224385 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -9,6 +9,13 @@ import { resolveOrchestrionRuntimeRequest, SNIPPET_IMPORT_SPECIFIER } from './re type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: boolean }) => unknown; +// Key of a property on the plugin object. Its value is an object that is unique to each `sentryOrchestrionPlugin()` +// call. An SDK that wraps the plugin with an object spread (`@sentry/remix`) copies the property, so `configResolved` +// can find the first instance in a build also through a wrapper. +const INSTANCE_KEY = '__sentryOrchestrionInstance'; + +type OrchestrionVitePlugin = Plugin & { [INSTANCE_KEY]?: object }; + // On Vite >= 6 `applyToEnvironment` (below) keeps the whole plugin out of // client environments. Vite 5 (e.g. Remix v2) ignores that hook, so without // this gate the transform would also run in the CLIENT build — where modules @@ -16,10 +23,10 @@ type TransformHandler = (this: unknown, code: string, id: string, opts?: { ssr?: // snippet's import of the subscriber factories (which import // `node:diagnostics_channel`) breaks against Vite's browser builtin shim. Gate // on the `ssr` flag, which Vite passes on both major versions. -function ssrOnlyTransform(transform: Plugin['transform']): Plugin['transform'] { +function ssrOnlyTransform(transform: Plugin['transform'], isDuplicate: () => boolean): Plugin['transform'] { const gate = (handler: TransformHandler): TransformHandler => function (code, id, opts) { - if (!opts?.ssr) { + if (!opts?.ssr || isDuplicate()) { return null; } return handler.call(this, code, id, opts); @@ -62,9 +69,14 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { '@sentry/server-utils', ]; - return { + const instance = {}; + // `true` when an earlier instance of this plugin is in the same build, which then transforms the modules alone. + let isDuplicate = false; + + const plugin: OrchestrionVitePlugin = { ...upstream, - transform: ssrOnlyTransform(upstream.transform), + [INSTANCE_KEY]: instance, + transform: ssrOnlyTransform(upstream.transform, () => isDuplicate), // The module-injected snippet imports `@sentry/server-utils` from INSIDE // transformed `node_modules` files. Under isolated installs (pnpm) that bare // specifier doesn't resolve from an instrumented package's location, so when @@ -119,8 +131,13 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { return { resolve: { noExternal: noExternalModules() } }; }, configResolved(config: ResolvedConfig): void { + // Two Sentry Vite plugins can each add this plugin to one build, for example `sentryCloudflareVitePlugin` and + // `sentryReactRouter`. A module transformed twice declares the injected snippet twice and fails the build. + const first = (config.plugins as readonly OrchestrionVitePlugin[]).find(p => p[INSTANCE_KEY]); + isDuplicate = first !== undefined && first[INSTANCE_KEY] !== instance; + // Nothing is force-bundled in `serve`, so an externalized module is expected there. - if (config.command === 'serve') { + if (isDuplicate || config.command === 'serve') { return; } @@ -140,4 +157,6 @@ export function sentryOrchestrionPlugin(options: PluginOptions = {}): Plugin { } }, }; + + return plugin; } diff --git a/packages/server-utils/test/orchestrion/bundler.test.ts b/packages/server-utils/test/orchestrion/bundler.test.ts index b04fa7d1f160..59c74f664ef4 100644 --- a/packages/server-utils/test/orchestrion/bundler.test.ts +++ b/packages/server-utils/test/orchestrion/bundler.test.ts @@ -227,6 +227,7 @@ describe('sentryOrchestrionPlugin (vite)', () => { const plugin = vitePlugin(); (plugin.configResolved as (config: unknown) => void)({ command, + plugins: [plugin], ssr: { external: ssrExternal }, logger: { warn }, } as unknown as ResolvedConfig); @@ -324,6 +325,59 @@ describe('sentryOrchestrionPlugin (vite)', () => { expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); }); + it('transforms and warns only in the first instance when two Sentry plugins add it to one build', () => { + const first = vitePlugin(); + const second = vitePlugin(); + const warn = vi.fn(); + const config = { + command: 'build', + plugins: [{ name: 'other' }, first, second], + ssr: { external: ['mysql'] }, + logger: { warn }, + } as unknown as ResolvedConfig; + + (first.configResolved as (config: unknown) => void)(config); + (second.configResolved as (config: unknown) => void)(config); + + const firstTransform = first.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + const secondTransform = second.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + expect(firstTransform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); + expect(secondTransform.call({}, 'code', 'id', { ssr: true })).toBeNull(); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('still transforms when an SDK wraps the plugin with an object spread', () => { + // `@sentry/remix` puts `{ ...orchestrion, configResolved, transform }` into the build and calls the original hooks. + const plugin = vitePlugin(); + const wrapper = { ...plugin, configResolved: vi.fn(), transform: vi.fn() }; + const config = { + command: 'build', + plugins: [wrapper], + ssr: {}, + logger: { warn: vi.fn() }, + } as unknown as ResolvedConfig; + + (plugin.configResolved as (config: unknown) => void)(config); + + const transform = plugin.transform as ( + this: unknown, + code: string, + id: string, + opts?: { ssr?: boolean }, + ) => unknown; + expect(transform.call({}, 'code', 'id', { ssr: true })).toBe('transformed'); + }); + it('gates resolveId on the ssr flag and falls back to self-resolution', async () => { const plugin = vitePlugin(); const resolveId = plugin.resolveId as ( From b18abaeca04d5e9edcfd141b65f03b45a3596508 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 14 Oct 2026 15:02:14 +0200 Subject: [PATCH 25/37] test(node): Unflake captureMessage with_level test (#25358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `with_level` scenario sends six `captureMessage` calls back to back, and the test expected their events to arrive in call order. Events are prepared asynchronously before being sent, so the SDK doesn't guarantee that order, and the test occasionally failed with `info_message` arriving before `debug_message`. The test now uses `.unordered()`, so it still asserts all six messages with their levels but no longer depends on arrival order. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../suites/public-api/captureMessage/with_level/test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts b/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts index a44af6931d1f..2e74a0ef2c80 100644 --- a/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts +++ b/dev-packages/node-integration-tests/suites/public-api/captureMessage/with_level/test.ts @@ -13,6 +13,7 @@ test('should capture with different severity levels', async () => { .expect({ event: { message: 'error_message', level: 'error' } }) .expect({ event: { message: 'fatal_message', level: 'fatal' } }) .expect({ event: { message: 'log_message', level: 'log' } }) + .unordered() .start() .completed(); }); From 4a9ef21f161b90863f7571f590db293f104d849c Mon Sep 17 00:00:00 2001 From: Michael K Date: Wed, 14 Oct 2026 09:36:34 -0400 Subject: [PATCH 26/37] fix(nuxt): Skip 3xx/4xx Nuxt errors in the vue:error hook (#25341) Before submitting a pull request, please take a look at our [Contributing](https://github.com/getsentry/sentry-javascript/blob/master/CONTRIBUTING.md) guidelines and verify: - [x] If you've added code that should be tested, please add tests. - [x] Ensure your code lints and the test suite passes (`yarn lint`) & (`yarn test`). - [ ] Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked. ### Description #15473 stopped the client plugin from reporting Nuxt errors with a 3xx/4xx status in its `app:error` hook. Nuxt also calls `vue:error` for every error that reaches the root component, fatal or not, so a page that throws `createError({ statusCode: 404 })` during setup is still reported, from the `vue:error` handler (mechanism `auto.function.nuxt.vue-error`). On client-side navigation this happens for every such 404. This applies the same check to both hooks through a shared `isIgnoredNuxtError`, and adds unit tests for the client plugin's error hooks: 3xx/4xx Nuxt errors are skipped from both hooks, 5xx Nuxt errors and non-Nuxt errors are still reported. Without the fix, the `vue:error` 4xx test fails. --------- Co-authored-by: mketiku <16418952+mketiku@users.noreply.github.com> --- .../nuxt/src/runtime/plugins/sentry.client.ts | 25 +++++--- .../nuxt/test/runtime/plugins/client.test.ts | 58 +++++++++++++++++++ 2 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 packages/nuxt/test/runtime/plugins/client.test.ts diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 92c9478b7079..423e8c457492 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -36,6 +36,16 @@ interface VueRouter { type VueRouteProviderRouter = ReturnType[0]>; +// 3xx/4xx Nuxt errors are expected control flow (redirects, not-found pages), not bugs. The server skips them too. +function isIgnoredNuxtError(error: unknown): boolean { + if (!isNuxtError(error)) { + return false; + } + // oxlint-disable-next-line typescript/no-deprecated + const statusCode = error?.status || error?.statusCode; + return !!statusCode && statusCode >= 300 && statusCode < 500; +} + // Tree-shakable guard to remove all code related to tracing declare const __SENTRY_TRACING__: boolean; @@ -87,19 +97,18 @@ export default defineNuxtPlugin({ }); nuxtApp.hook('app:error', error => { - if (isNuxtError(error)) { - // oxlint-disable-next-line typescript/no-deprecated - const statusCode = error?.status || error?.statusCode; - - // Do not report if status code is 3xx or 4xx - if (statusCode && statusCode >= 300 && statusCode < 500) { - return; - } + if (isIgnoredNuxtError(error)) { + return; } reportNuxtError({ error }); }); + // Nuxt calls `vue:error` for every error that reaches the root component, so an error thrown during setup + // (e.g. `createError({ statusCode: 404 })`) arrives here too, not only in `app:error`. nuxtApp.hook('vue:error', (error, instance, info) => { + if (isIgnoredNuxtError(error)) { + return; + } reportNuxtError({ error, instance, info }); }); }, diff --git a/packages/nuxt/test/runtime/plugins/client.test.ts b/packages/nuxt/test/runtime/plugins/client.test.ts new file mode 100644 index 000000000000..14b75383d1cb --- /dev/null +++ b/packages/nuxt/test/runtime/plugins/client.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { reportNuxtError } from '../../../src/runtime/utils'; + +vi.mock('nuxt/app', () => ({ + defineNuxtPlugin: (plugin: unknown) => plugin, + isNuxtError: (error: unknown) => !!error && typeof error === 'object' && '__nuxt_error' in error, +})); + +vi.mock('../../../src/runtime/utils', () => ({ + reportNuxtError: vi.fn(), +})); + +type Hook = (...args: unknown[]) => void; + +async function setupHook(hookName: string): Promise { + const { default: plugin } = await import('../../../src/runtime/plugins/sentry.client'); + const hooks: Record = {}; + const nuxtApp = { hook: (name: string, hook: Hook) => (hooks[name] = hook) }; + await (plugin as unknown as { setup: (app: unknown) => Promise }).setup(nuxtApp); + expect(hooks).toHaveProperty(hookName); + return hooks[hookName]!; +} + +function nuxtError(statusCode: number): Error { + return Object.assign(new Error(`status ${statusCode}`), { __nuxt_error: true, statusCode }); +} + +describe('sentry client plugin error hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(['app:error', 'vue:error'])('does not report 3xx/4xx Nuxt errors from %s', async hookName => { + const hook = await setupHook(hookName); + + hook(nuxtError(404)); + hook(nuxtError(301)); + + expect(reportNuxtError).not.toHaveBeenCalled(); + }); + + it.each(['app:error', 'vue:error'])('reports 5xx Nuxt errors from %s', async hookName => { + const hook = await setupHook(hookName); + + hook(nuxtError(503)); + + expect(reportNuxtError).toHaveBeenCalledTimes(1); + }); + + it('reports errors that are not Nuxt errors from vue:error', async () => { + const hook = await setupHook('vue:error'); + const error = Object.assign(new Error('boom'), { statusCode: 404 }); + + hook(error, undefined, 'setup function'); + + expect(reportNuxtError).toHaveBeenCalledWith({ error, instance: undefined, info: 'setup function' }); + }); +}); From ca4c9839e8cbe99a5acf415755c67f3fa4c9e058 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:04:39 +0200 Subject: [PATCH 27/37] fix(tanstackstart-react): Rewrite source paths of split route modules (#25361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TanStack Start splits route files into virtual modules such as `index.tsx?tss-serverfn-split`. The uploaded source maps kept these queries, and their paths stayed relative to the map's directory, so frames did not show the source file. The default `rewriteSources` hook now removes the split queries and makes paths relative to the project root, such as `src/routes/index.tsx`. A user's `sourcemaps.rewriteSources` still takes precedence. First PR of a stack for #21012. Refs #21012 --------- Co-authored-by: Jan Peer Stöcklmair --- .../src/vite/sourceMaps.ts | 28 +++++++- .../test/vite/sourceMaps.test.ts | 67 ++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 817cad432a22..a0cf3f4e84c7 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,9 +1,35 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; +import * as path from 'node:path'; import type { Plugin, UserConfig } from 'vite'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; +/** TanStack Router and Start split route files into virtual modules, e.g. `index.tsx?tss-serverfn-split`. */ +const TANSTACK_SPLIT_QUERY_REGEX = /\?ts[rs]-[^/]*$/; + +/** Sources such as `virtual:#nitro/runtime` are no file paths. Two letters at least, so drive letters, like `C:`, are not matched by the regex. */ +const SCHEME_REGEX = /^[a-z][a-z\d+.-]+:/i; + +/** + * Makes source paths relative to the project root and removes TanStack's split queries, so that client and server + * frames show the path of the source file, such as `src/routes/index.tsx`. + * + * Paths in a source map are relative to the map's directory, which the default hook ignores. + * + * @internal Only exported for testing. + */ +export function rewriteTanstackStartSource(source: string, _map: unknown, context?: { mapDir: string }): string { + const sourcePath = source.replace(TANSTACK_SPLIT_QUERY_REGEX, ''); + + if (SCHEME_REGEX.test(sourcePath)) { + return sourcePath; + } + + const absolutePath = path.resolve(context?.mapDir ?? process.cwd(), sourcePath); + return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); +} + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ @@ -70,7 +96,7 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, - rewriteSources: sourcemaps?.rewriteSources, + rewriteSources: sourcemaps?.rewriteSources ?? rewriteTanstackStartSource, resolveSourceMap: sourcemaps?.resolveSourceMap, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index e5ea14c95b3e..eb4b3740b658 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -2,11 +2,13 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'path'; import { getUpdatedSourceMapSettings, makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin, + rewriteTanstackStartSource, } from '../../src/vite/sourceMaps'; const mockedSentryVitePlugin = { @@ -273,6 +275,16 @@ describe('makeAddSentryVitePlugin()', () => { ); }); + it('rewrites sources with the TanStack Start hook by default', () => { + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ rewriteSources: rewriteTanstackStartSource }), + }), + ); + }); + it('sets the correct metaFramework in telemetry options', () => { makeAddSentryVitePlugin({ org: 'my-org', @@ -353,3 +365,56 @@ describe('getUpdatedSourceMapSettings', () => { expect(result).toBe('hidden'); }); }); + +describe('rewriteTanstackStartSource', () => { + const projectRoot = path.resolve('/projects/shop'); + + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { + label: 'a server function split from a route', + source: '../../../src/routes/index.tsx?tss-serverfn-split', + mapDir: '.output/server/_ssr', + expected: 'src/routes/index.tsx', + }, + { + label: 'a route component split for the client', + source: '../../../src/routes/users.$userId.tsx?tsr-split=component', + mapDir: '.output/public/assets', + expected: 'src/routes/users.$userId.tsx', + }, + { + label: 'a dependency', + source: '../../node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + mapDir: '.output/server', + expected: 'node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + }, + { + label: 'a source that climbs fewer directories than the map is deep', + source: '../shared/format-price.ts', + mapDir: '.output/server/chunks/build', + expected: '.output/server/chunks/shared/format-price.ts', + }, + ])('makes the path of $label relative to the project root', ({ source, mapDir, expected }) => { + expect(rewriteTanstackStartSource(source, {}, { mapDir: path.join(projectRoot, mapDir) })).toBe(expected); + }); + + it('keeps sources that are no file paths', () => { + const mapDir = path.join(projectRoot, '.output/server'); + + expect(rewriteTanstackStartSource('virtual:#nitro/virtual/plugins', {}, { mapDir })).toBe( + 'virtual:#nitro/virtual/plugins', + ); + }); + + it('resolves sources from the project root without a map directory', () => { + expect(rewriteTanstackStartSource('src/routes/index.tsx?tss-serverfn-split', {})).toBe('src/routes/index.tsx'); + }); +}); From 8ec5fe4804cdccbbf252cadc83fbdacfcbeffb42 Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:24:50 +0200 Subject: [PATCH 28/37] feat(server-utils): Instrument the OpenAI decisions API as gen_ai.decide spans (#25285) ## What OpenAI `decisions.create` calls now create `decide ` spans with op `gen_ai.decide`, recorded in the same shape as Jev calls so Sentry renders them the same way: - `input` is recorded as `state`, images included as they are. - Questions and answers are keyed by question name, as the conventions define. - Choice and score question options go in `criteria`, like Jev: choices as `{ [value]: description }`, score levels as their labels. - Choice and score `probabilities` are keyed by option or score value, and score labels go in a `legend`. - `predicate` answers keep OpenAI's `{ type, probability }`, which Sentry renders as yes/no. | | OpenAI sends / returns | Recorded on the span | |---|---|---| | Evaluated input | `input: "I want a refund"` | `state: "I want a refund"` | | Questions | `[{ name: "wants_refund", type: "predicate", ... }]` | `{ wants_refund: { type: "predicate", ... } }` | | Choice question | `choices: [{ value: "billing", description: "Charges and refunds" }, { value: "other" }]` | `criteria: { billing: "Charges and refunds", other: "" }` | | Score question | `levels: [{ label: "low" }, { label: "high", description: "Blocks the customer" }]` | `criteria: ["low", "high"]`, `descriptions: { "1": "Blocks the customer" }` | | Answers | `[{ name: "wants_refund", type: "predicate", ... }]` | `{ wants_refund: { type: "predicate", ... } }` | | Predicate answer | `{ type: "predicate", probability: 0.97 }` | unchanged | | Choice answer | `choice: true`, `probabilities: [{ value: true, probability: 0.91 }, ...]` | `choice: "true"`, `probabilities: { "true": 0.91, ... }` | | Score answer | `probabilities: [{ value: 1, label: "high", probability: 0.95 }, ...]` | `probabilities: { "1": 0.95, ... }`, `legend: { "1": "high", ... }` | ### OpenAI decisions have a few things Jev does not - `predicate` questions and answers instead of `noul`, with the yes probability in `probability` - Score levels can have descriptions, recorded in `descriptions` keyed by score value - A question can come back as a refusal, `{ type: "refusal" }`, with no probability or reason - The input can hold images as base64 data URLs. ## Why OpenAI added a Decisions API that works like Jev, and these calls had no span. Related: #25253 --------- Co-authored-by: Claude Opus 5.5 --- .../openai/v7/instrument-with-recording.mjs | 9 ++ .../tracing/openai/v7/scenario-decisions.mjs | 115 ++++++++++++++++++ .../suites/tracing/openai/v7/test.ts | 105 ++++++++++++++++ .../server-utils/src/ai/openai/constants.ts | 2 + packages/server-utils/src/ai/openai/index.ts | 17 ++- .../server-utils/src/ai/openai/mapping.ts | 102 ++++++++++++++++ packages/server-utils/src/ai/openai/utils.ts | 82 ++++++++----- .../server-utils/src/integrations/openai.ts | 5 +- .../src/orchestrion/config/openai.ts | 7 ++ .../test/ai/lib/tracing/openai.test.ts | 66 ++++++++++ 10 files changed, 476 insertions(+), 34 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-with-recording.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-decisions.mjs create mode 100644 packages/server-utils/src/ai/openai/mapping.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-with-recording.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-with-recording.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/instrument-with-recording.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-decisions.mjs b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-decisions.mjs new file mode 100644 index 000000000000..0934cebcd554 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/scenario-decisions.mjs @@ -0,0 +1,115 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +function startMockServer() { + const app = express(); + app.use(express.json({ limit: '1mb' })); + + app.post('/openai/decisions', (req, res) => { + const { model } = req.body; + + if (model === 'error-model') { + res.status(500).set('x-request-id', 'mock-request-error').end('Internal server error'); + return; + } + + // Shaped like a real `gpt-6-luna` response. Decisions bills input tokens only. + res.send({ + model: 'gpt-6-luna', + answers: [ + { type: 'predicate', name: 'wants_refund', probability: 0.97 }, + { + type: 'choice', + name: 'department', + choice: 'billing', + probabilities: [ + { value: 'billing', probability: 0.82 }, + { value: 'other', probability: 0.18 }, + ], + confidence: 0.82, + }, + { + type: 'score', + name: 'urgency', + score: 0.95, + probabilities: [ + { value: 0, label: 'low', probability: 0.05 }, + { value: 1, label: 'high', probability: 0.95 }, + ], + confidence: 0.93, + }, + ], + usage: { + input_tokens: 382, + input_tokens_details: { cached_tokens: 256, cache_write_tokens: 64 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 382, + }, + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +const questions = [ + { type: 'predicate', name: 'wants_refund', instructions: 'Is a refund requested?' }, + { + type: 'choice', + name: 'department', + instructions: 'Which team should handle this?', + choices: [{ value: 'billing', description: 'Charges and refunds' }, { value: 'other' }], + }, + { + type: 'score', + name: 'urgency', + instructions: 'How urgent is this ticket?', + levels: [{ label: 'low' }, { label: 'high', description: 'Blocks the customer' }], + }, +]; + +async function run() { + const server = await startMockServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new OpenAI({ + baseURL: `http://localhost:${server.address().port}/openai`, + apiKey: 'mock-api-key', + }); + + await client.decisions.create({ + model: 'gpt-6-luna', + input: 'I was charged twice and want a refund.', + questions, + }); + + await client.decisions.create({ + model: 'gpt-6-luna', + input: [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is the package damaged?' }, + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==' }, + ], + }, + ], + questions: [{ type: 'predicate', name: 'damaged', instructions: 'Is there visible damage?' }], + }); + + try { + await client.decisions.create({ model: 'error-model', input: 'Error test', questions }); + } catch { + // Error is expected and handled + } + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts index d24c08130d84..cea32de437ab 100644 --- a/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/openai/v7/test.ts @@ -1,19 +1,25 @@ import type { SerializedStreamedSpanContainer } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { + GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_PROVIDER_NAME, GEN_AI_REQUEST_MODEL, GEN_AI_RESPONSE_FINISH_REASONS, GEN_AI_RESPONSE_ID, GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_STREAMING, + GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS, + GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, SENTRY_OP, SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; +import { GEN_AI_DECIDE } from '@sentry/conventions/op'; import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; import { supports } from '../../../../utils/index'; @@ -158,4 +164,103 @@ describe.runIf(supports({ min: 22 }))('OpenAI integration (V7)', () => { }, }, ); + + // The decisions API (`openai` >= 7.30.0) returns typed answers, so it is recorded like a Jev call. + createEsmAndCjsTests( + __dirname, + 'scenario-decisions.mjs', + 'instrument-with-recording.mjs', + (createRunner, test) => { + test('instruments the decisions API as decide spans', async () => { + const runner = createRunner().ignore('event'); + const spansPromise = runner.collectStreamedSpansUntilSegment('main'); + + await runner.start().completed(); + + const spans = (await spansPromise).filter(span => span.attributes[SENTRY_OP]?.value === GEN_AI_DECIDE); + expect(spans).toHaveLength(3); + + // Both successful spans are named `decide gpt-6-luna` and may arrive in any order, so match on the input. + const inputOf = (span: (typeof spans)[number]): string => String(span.attributes[GEN_AI_INPUT_MESSAGES]?.value); + const textSpan = spans.find(span => inputOf(span).includes('charged twice')); + const imageSpan = spans.find(span => inputOf(span).includes('input_image')); + expect(textSpan!.name).toBe('decide gpt-6-luna'); + expect(textSpan!.attributes).toMatchObject({ + [SENTRY_ORIGIN]: { value: 'auto.ai.openai' }, + [GEN_AI_OPERATION_NAME]: { value: 'decide' }, + [GEN_AI_PROVIDER_NAME]: { value: 'openai' }, + [GEN_AI_REQUEST_MODEL]: { value: 'gpt-6-luna' }, + [GEN_AI_RESPONSE_MODEL]: { value: 'gpt-6-luna' }, + // OpenAI's input tokens already include the cached ones, so they are not added again. + [GEN_AI_USAGE_INPUT_TOKENS]: { value: 382 }, + [GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: { value: 256 }, + [GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: { value: 64 }, + [GEN_AI_USAGE_OUTPUT_TOKENS]: { value: 0 }, + [GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: { value: 0 }, + [GEN_AI_USAGE_TOTAL_TOKENS]: { value: 382 }, + }); + expect(JSON.parse(textSpan!.attributes[GEN_AI_INPUT_MESSAGES]!.value as string)).toEqual([ + { + type: 'evaluation', + state: 'I was charged twice and want a refund.', + questions: { + wants_refund: { type: 'predicate', instructions: 'Is a refund requested?' }, + department: { + type: 'choice', + instructions: 'Which team should handle this?', + // `other` has no description, so it gets an empty one. + criteria: { billing: 'Charges and refunds', other: '' }, + }, + urgency: { + type: 'score', + instructions: 'How urgent is this ticket?', + criteria: ['low', 'high'], + // Keyed by score value; `low` has no description, so it is left out. + descriptions: { 1: 'Blocks the customer' }, + }, + }, + }, + ]); + expect(JSON.parse(textSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]!.value as string)).toEqual([ + { + type: 'evaluation', + answers: { + wants_refund: { type: 'predicate', probability: 0.97 }, + department: { + type: 'choice', + choice: 'billing', + probabilities: { billing: 0.82, other: 0.18 }, + confidence: 0.82, + }, + urgency: { + type: 'score', + score: 0.95, + probabilities: { 0: 0.05, 1: 0.95 }, + legend: { 0: 'low', 1: 'high' }, + confidence: 0.93, + }, + }, + }, + ]); + + expect(JSON.parse(imageSpan!.attributes[GEN_AI_INPUT_MESSAGES]!.value as string)[0].state).toEqual([ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is the package damaged?' }, + { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==' }, + ], + }, + ]); + + const errorSpan = spans.find(span => span.name === 'decide error-model'); + expect(errorSpan!.status).not.toBe('ok'); + }); + }, + { + additionalDependencies: { + openai: '7.30.0', + }, + }, + ); }); diff --git a/packages/server-utils/src/ai/openai/constants.ts b/packages/server-utils/src/ai/openai/constants.ts index af163af81b6a..60bf73f94bf1 100644 --- a/packages/server-utils/src/ai/openai/constants.ts +++ b/packages/server-utils/src/ai/openai/constants.ts @@ -12,6 +12,8 @@ export const OPENAI_METHOD_REGISTRY = { // Conversations API - for conversation state management // https://platform.openai.com/docs/guides/conversation-state 'conversations.create': { operation: 'chat' }, + // https://developers.openai.com/api/docs/guides/decisions + 'decisions.create': { operation: 'decide' }, } as const satisfies InstrumentedMethodRegistry; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index 981da4281ef5..a832688c8cb3 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -31,6 +31,7 @@ import { import { OPENAI_METHOD_REGISTRY } from './constants'; import { instrumentStream } from './streaming'; import type { ChatCompletionChunk, OpenAiOptions, OpenAIStream, ResponseStreamingEvent } from './types'; +import { getDecisionInputMessages } from './mapping'; import { addResponseAttributes, extractRequestParameters } from './utils'; /** @@ -73,12 +74,15 @@ export function extractRequestAttributes( if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { const params = args[0] as Record; - const availableTools = recordInputs ? extractAvailableTools(params) : undefined; - if (availableTools) { - attributes[GEN_AI_TOOL_DEFINITIONS] = availableTools; + if (recordInputs) { + attributes[GEN_AI_TOOL_DEFINITIONS] = extractAvailableTools(params); } Object.assign(attributes, extractRequestParameters(params)); + + if (recordInputs && operationName === 'decide') { + attributes[GEN_AI_INPUT_MESSAGES] = getDecisionInputMessages(params); + } } else { attributes[GEN_AI_REQUEST_MODEL] = 'unknown'; } @@ -88,6 +92,11 @@ export function extractRequestAttributes( // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. export function addRequestAttributes(span: Span, params: Record, operationName: string): void { + // A decision's input is recorded when its span starts, in `extractRequestAttributes`. + if (operationName === 'decide') { + return; + } + // Store embeddings input on a separate attribute if (operationName === 'embeddings' && 'input' in params) { const input = params.input; @@ -203,7 +212,7 @@ function instrumentMethod( } return originalResult.then(result => { - addResponseAttributes(span, result, options.recordOutputs); + addResponseAttributes(span, result, options.recordOutputs, operationName); return result; }); }); diff --git a/packages/server-utils/src/ai/openai/mapping.ts b/packages/server-utils/src/ai/openai/mapping.ts new file mode 100644 index 000000000000..e43237570d6c --- /dev/null +++ b/packages/server-utils/src/ai/openai/mapping.ts @@ -0,0 +1,102 @@ +/** + * Maps OpenAI decisions requests and responses to the format Jev calls are recorded in, so Sentry renders + * both the same way. + */ +import { isObjectLike } from '@sentry/core'; +import { getEvaluationInputMessages, getEvaluationOutputMessages } from '../core/utils'; + +/** + * Serialize a decisions request. Its evidence is `input` where Jev's is `state`; it is recorded as `state` + * so both render the same. + */ +export function getDecisionInputMessages(params: Record): string | undefined { + const questions = Array.isArray(params.questions) ? params.questions.map(toJevQuestionFormat) : params.questions; + return getEvaluationInputMessages({ state: params.input, questions: keyByName(questions) }); +} + +/** Serialize the answers of a decisions response, laid out and keyed like Jev answers. */ +export function getDecisionOutputMessages(answers: unknown[]): string | undefined { + return getEvaluationOutputMessages(keyByName(answers.map(toJevAnswerFormat))); +} + +/** + * Lay out a decisions question like a Jev question, with its options in `criteria`: choices as + * `{ [value]: description }` (an empty description when there is none), and score levels as their labels, + * indexed by score value, with any level descriptions in `descriptions`, keyed by score value. + */ +function toJevQuestionFormat(question: unknown): unknown { + if (!isObjectLike(question)) { + return question; + } + + if (question.type === 'choice' && Array.isArray(question.choices)) { + const { choices, ...rest } = question; + const criteria = Object.fromEntries( + choices + .filter(isObjectLike) + .map(choice => [String(choice.value), typeof choice.description === 'string' ? choice.description : '']), + ); + return { ...rest, criteria }; + } + + if (question.type === 'score' && Array.isArray(question.levels)) { + const { levels, ...rest } = question; + const criteria = levels.map(level => (isObjectLike(level) ? level.label : level)); + // Sentry renders score criteria as a list of labels only, so level descriptions go in their own field, + // keyed by score value like an answer's `legend`. + const descriptions = Object.fromEntries( + levels.flatMap((level, index) => + isObjectLike(level) && typeof level.description === 'string' ? [[String(index), level.description]] : [], + ), + ); + return Object.keys(descriptions).length ? { ...rest, criteria, descriptions } : { ...rest, criteria }; + } + + return question; +} + +/** + * Lay out choice and score answers like Jev answers: probabilities keyed by option or score value, with + * score labels in a `legend`. Predicate answers are already in the shape Sentry renders. + */ +function toJevAnswerFormat(answer: unknown): unknown { + if ( + !isObjectLike(answer) || + (answer.type !== 'choice' && answer.type !== 'score') || + !Array.isArray(answer.probabilities) + ) { + return answer; + } + + const options = answer.probabilities.filter(isObjectLike); + const probabilities = Object.fromEntries(options.map(option => [String(option.value), option.probability])); + if (answer.type === 'choice') { + // Choice values can be booleans, but Sentry renders a choice as a string, like its `probabilities` keys. + return { ...answer, choice: String(answer.choice), probabilities }; + } + + const legend = Object.fromEntries( + options.filter(option => typeof option.label === 'string').map(option => [String(option.value), option.label]), + ); + return { ...answer, probabilities, legend }; +} + +/** + * The decisions API lists questions and answers as arrays with an optional `name`, where the conventions + * key them by question name. An unnamed item is keyed by its position. + */ +function keyByName(items: unknown): unknown { + if (!Array.isArray(items)) { + return items; + } + + return Object.fromEntries( + items.map((item, index) => { + if (!isObjectLike(item)) { + return [String(index), item]; + } + const { name, ...rest } = item; + return [typeof name === 'string' ? name : String(index), rest]; + }), + ); +} diff --git a/packages/server-utils/src/ai/openai/utils.ts b/packages/server-utils/src/ai/openai/utils.ts index 3f6afeac1c44..7c2190585e9a 100644 --- a/packages/server-utils/src/ai/openai/utils.ts +++ b/packages/server-utils/src/ai/openai/utils.ts @@ -3,6 +3,7 @@ import { isObjectLike } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; import { GEN_AI_CONVERSATION_ID, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_REQUEST_FREQUENCY_PENALTY, GEN_AI_REQUEST_MODEL, GEN_AI_REQUEST_PRESENCE_PENALTY, @@ -17,6 +18,7 @@ import { GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS, GEN_AI_USAGE_INPUT_TOKENS, GEN_AI_USAGE_OUTPUT_TOKENS, + GEN_AI_USAGE_REASONING_OUTPUT_TOKENS, GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { @@ -24,6 +26,7 @@ import { GEN_AI_REQUEST_ENCODING_FORMAT_ATTRIBUTE, GEN_AI_REQUEST_STREAM_ATTRIBUTE, } from '../core/gen-ai-attributes'; +import { getDecisionOutputMessages } from './mapping'; import type { ChatCompletionChunk, ResponseStreamingEvent } from './types'; /** @@ -53,9 +56,15 @@ export function isChatCompletionChunk(event: unknown): event is ChatCompletionCh /** * Add response attributes to a span using duck-typing. - * Works for Chat Completions, Responses API, Embeddings, and Conversations API responses. + * Works for Chat Completions, Responses API, Embeddings, and Conversations API responses. Decision + * answers are only recorded when `operationName` is `decide`. */ -export function addResponseAttributes(span: Span, result: unknown, recordOutputs?: boolean): void { +export function addResponseAttributes( + span: Span, + result: unknown, + recordOutputs?: boolean, + operationName?: string, +): void { if (!result || typeof result !== 'object') return; const response = result as Record; @@ -76,32 +85,8 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs attrs[GEN_AI_CONVERSATION_ID] = response.id; } - // Token usage — supports both naming conventions (chat: prompt_tokens/completion_tokens, responses: input_tokens/output_tokens) - if (response.usage && typeof response.usage === 'object') { - const usage = response.usage as Record; - - const inputTokens = usage.prompt_tokens ?? usage.input_tokens; - if (typeof inputTokens === 'number') { - attrs[GEN_AI_USAGE_INPUT_TOKENS] = inputTokens; - } - - const outputTokens = usage.completion_tokens ?? usage.output_tokens; - if (typeof outputTokens === 'number') { - attrs[GEN_AI_USAGE_OUTPUT_TOKENS] = outputTokens; - } - - if (typeof usage.total_tokens === 'number') { - attrs[GEN_AI_USAGE_TOTAL_TOKENS] = usage.total_tokens; - } - const inputDetails = usage.prompt_tokens_details ?? usage.input_tokens_details; - if (isObjectLike(inputDetails)) { - if (typeof inputDetails.cached_tokens === 'number') { - attrs[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] = inputDetails.cached_tokens; - } - if (typeof inputDetails.cache_write_tokens === 'number') { - attrs[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS] = inputDetails.cache_write_tokens; - } - } + if (isObjectLike(response.usage)) { + addUsageAttributes(attrs, response.usage); } // Finish reasons from choices (chat completions) @@ -151,6 +136,14 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs attrs[GEN_AI_RESPONSE_TEXT] = response.output_text; } + // Typed answers (decisions API) + if (operationName === 'decide' && Array.isArray(response.answers)) { + const outputMessages = getDecisionOutputMessages(response.answers); + if (outputMessages) { + attrs[GEN_AI_OUTPUT_MESSAGES] = outputMessages; + } + } + // Tool calls from output array (responses API) if (Array.isArray(response.output) && response.output.length > 0 && !attrs[GEN_AI_RESPONSE_TOOL_CALLS]) { const functionCalls = (response.output as Array>).filter( @@ -165,6 +158,39 @@ export function addResponseAttributes(span: Span, result: unknown, recordOutputs span.setAttributes(attrs); } +/** + * Token usage, in both naming conventions (chat: prompt_tokens/completion_tokens, + * responses and decisions: input_tokens/output_tokens). + */ +function addUsageAttributes(attrs: Record, usage: Record): void { + const inputTokens = usage.prompt_tokens ?? usage.input_tokens; + if (typeof inputTokens === 'number') { + attrs[GEN_AI_USAGE_INPUT_TOKENS] = inputTokens; + } + + const outputTokens = usage.completion_tokens ?? usage.output_tokens; + if (typeof outputTokens === 'number') { + attrs[GEN_AI_USAGE_OUTPUT_TOKENS] = outputTokens; + } + + if (typeof usage.total_tokens === 'number') { + attrs[GEN_AI_USAGE_TOTAL_TOKENS] = usage.total_tokens; + } + const inputDetails = usage.prompt_tokens_details ?? usage.input_tokens_details; + if (isObjectLike(inputDetails)) { + if (typeof inputDetails.cached_tokens === 'number') { + attrs[GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS] = inputDetails.cached_tokens; + } + if (typeof inputDetails.cache_write_tokens === 'number') { + attrs[GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS] = inputDetails.cache_write_tokens; + } + } + const outputDetails = usage.completion_tokens_details ?? usage.output_tokens_details; + if (isObjectLike(outputDetails) && typeof outputDetails.reasoning_tokens === 'number') { + attrs[GEN_AI_USAGE_REASONING_OUTPUT_TOKENS] = outputDetails.reasoning_tokens; + } +} + /** * Extract the Conversations API id (`conversation: "conv_..."`) from request parameters. * `previous_response_id` is deliberately not read: it names the previous response, so it changes on diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index ec7c852e9010..eaa02e867a5d 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -30,6 +30,7 @@ const ORIGIN = 'auto.ai.openai'; const INSTRUMENTED_CHANNELS = [ { channel: CHANNELS.OPENAI_CHAT, operation: 'chat' }, { channel: CHANNELS.OPENAI_EMBEDDINGS, operation: 'embeddings' }, + { channel: CHANNELS.OPENAI_DECIDE, operation: 'decide' }, ] as const; const _openAIIntegration = ((options: OpenAiOptions = {}) => { @@ -48,7 +49,7 @@ function instrumentOpenai(options: OpenAiOptions): void { data => createGenAiSpan(data, operation, options), { beforeSpanEnd: (span, data) => { - addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs); + addResponseAttributes(span, data.result, resolveAIRecordingOptions(options).recordOutputs, operation); }, deferSpanEnd: ({ span, data, end }) => wrapApiPromiseResponse( @@ -134,6 +135,6 @@ function wrapStreamResult(span: Span, data: OrchestrionChannelContext, options: /** * Diagnostics-channel-based OpenAI integration. Subscribes to the `orchestrion:openai:*` * diagnostics_channels injected into `openai`'s `create` methods (chat completions, responses, embeddings, - * conversations), so it requires the Sentry runtime hook or bundler plugin. + * conversations, decisions), so it requires the Sentry runtime hook or bundler plugin. */ export const openAIIntegration = defineIntegration(_openAIIntegration); diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index e68d3a961262..8024cb4b0b67 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -27,6 +27,12 @@ export const openaiConfig = [ module: { name: 'openai', versionRange: '>=4.0.0 <8', filePath }, functionQuery: { className: 'Conversations', methodName: 'create', kind: 'Sync' as const }, })), + // OpenAI decisions API (`openai` >= 7.30.0), which returns typed answers rather than generated text. + ...['resources/decisions.js', 'resources/decisions.mjs'].map(filePath => ({ + channelName: 'decide', + module: { name: 'openai', versionRange: '>=7.30.0 <8', filePath }, + functionQuery: { className: 'Decisions', methodName: 'create', kind: 'Sync' as const }, + })), ] satisfies InstrumentationConfig[]; export const openaiModuleNames = getModuleNames(openaiConfig); @@ -36,4 +42,5 @@ export const openaiChannels = { // identical span handling, so they share one channel. OPENAI_CHAT: 'orchestrion:openai:chat', OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', + OPENAI_DECIDE: 'orchestrion:openai:decide', } as const; diff --git a/packages/server-utils/test/ai/lib/tracing/openai.test.ts b/packages/server-utils/test/ai/lib/tracing/openai.test.ts index 0c6738ce86ac..523d040e8a4c 100644 --- a/packages/server-utils/test/ai/lib/tracing/openai.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/openai.test.ts @@ -1,4 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { GEN_AI_OUTPUT_MESSAGES } from '@sentry/conventions/attributes'; +import { GEN_AI_DECIDE } from '@sentry/conventions/op'; import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; import type { Span } from '@sentry/core'; import { instrumentOpenAiClient } from '../../../../src/ai/openai'; @@ -90,4 +92,68 @@ describe('instrumentOpenAiClient span names', () => { expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat'); }); + + it('records `decisions.create` as a decide span', async () => { + const endedSpans = setupClient('stream'); + const instrumented = instrumentOpenAiClient({ + decisions: { + create: vi.fn().mockResolvedValue({ + model: 'gpt-6-luna', + answers: [{ type: 'predicate', name: 'wants_refund', probability: 0.97 }], + }), + }, + }); + + await instrumented.decisions.create({ + model: 'gpt-6-luna', + input: 'I want a refund', + questions: [{ type: 'predicate', name: 'wants_refund', instructions: 'Is a refund requested?' }], + }); + + const json = spanToStaticSpanJSON(endedSpans[0]!); + expect(json.description).toBe('decide gpt-6-luna'); + expect(json.op).toBe(GEN_AI_DECIDE); + expect(JSON.parse(json.data[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([ + // Keyed by question name, as the conventions define it. + { type: 'evaluation', answers: { wants_refund: { type: 'predicate', probability: 0.97 } } }, + ]); + }); + + it('records a boolean decision choice as a string', async () => { + const endedSpans = setupClient('stream'); + const instrumented = instrumentOpenAiClient({ + decisions: { + create: vi.fn().mockResolvedValue({ + model: 'gpt-6-luna', + answers: [ + { + type: 'choice', + name: 'is_spam', + choice: true, + probabilities: [ + { value: true, probability: 0.91 }, + { value: false, probability: 0.09 }, + ], + confidence: 0.91, + }, + ], + }), + }, + }); + + await instrumented.decisions.create({ + model: 'gpt-6-luna', + input: 'Win a free cruise!', + questions: [{ type: 'choice', name: 'is_spam', choices: [{ value: true }, { value: false }] }], + }); + + expect(JSON.parse(spanToStaticSpanJSON(endedSpans[0]!).data[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + answers: { + is_spam: { type: 'choice', choice: 'true', probabilities: { true: 0.91, false: 0.09 }, confidence: 0.91 }, + }, + }, + ]); + }); }); From f9b8bade6a97d7e75b0bd132735945970fec3c3f Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:24:51 +0200 Subject: [PATCH 29/37] test(e2e): Add node-openai-decisions-send-to-sentry test app (#25331) ## What Adds the `node-openai-decisions-send-to-sentry` e2e app. It calls the real OpenAI Decisions API and checks the `gen_ai.decide` spans that Sentry stores, for a text and an image decision. Only this app gets the new `E2E_OPENAI_DECISIONS_API_KEY` secret. ## Why OpenRouter has no Decisions route, so this app must call OpenAI directly. --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/build.yml | 4 + dev-packages/e2e-tests/.env.example | 4 + .../.gitignore | 5 + .../package.json | 43 +++++ .../playwright.config.ts | 27 +++ .../src/app.ts | 85 ++++++++++ .../src/instrument.ts | 7 + .../tests/send-to-sentry.test.ts | 159 ++++++++++++++++++ .../tsconfig.json | 13 ++ 9 files changed, 347 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55dcd184d04c..99ad92d4515d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1197,6 +1197,10 @@ jobs: E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks' E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests' E2E_OPENROUTER_API_KEY: ${{ secrets.E2E_OPENROUTER_API_KEY }} + # Only this app talks to OpenAI directly (OpenRouter has no Decisions route), so only it gets the key. + E2E_OPENAI_DECISIONS_API_KEY: + ${{ matrix.test-application == 'node-openai-decisions-send-to-sentry' && secrets.E2E_OPENAI_DECISIONS_API_KEY || + '' }} # Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/dev-packages/e2e-tests/.env.example b/dev-packages/e2e-tests/.env.example index 180fb07078a1..3815cae556f0 100644 --- a/dev-packages/e2e-tests/.env.example +++ b/dev-packages/e2e-tests/.env.example @@ -16,6 +16,10 @@ E2E_TEST_SENTRY_PROJECT= # Only needed to run that test app locally. E2E_OPENROUTER_API_KEY= +# An OpenAI API key for the Decisions API (OpenRouter has no Decisions route). +# Only needed to run node-openai-decisions-send-to-sentry locally. +E2E_OPENAI_DECISIONS_API_KEY= + # Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry). # The API token needs "Workers Scripts: Edit" on the account; "Workers KV Storage: Read" additionally silences a # warning when a worker is deleted. Leave it empty to use a `wrangler login` session instead. diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/.gitignore new file mode 100644 index 000000000000..795f6b6d40d1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-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-openai-decisions-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json new file mode 100644 index 000000000000..f0924e7f3193 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/package.json @@ -0,0 +1,43 @@ +{ + "name": "node-openai-decisions-send-to-sentry", + "description": "The OpenAI integration for the Decisions API as a user runs it: a plain Sentry.init on an express app, real decisions.create requests against OpenAI, and the gen_ai.decide 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 openai@latest && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^4.21.2", + "openai": "7.32.0" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/conventions": "0.27.0", + "@types/express": "^4.17.21", + "@types/node": "^24.0.0", + "sentry": "~0.44.1", + "typescript": "~5.9.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build-latest", + "label": "node-openai-decisions-send-to-sentry (latest)" + } + ] + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts new file mode 100644 index 000000000000..af5ea8f0b951 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; + +const port = 3030; + +export default defineConfig({ + testDir: './tests', + /* + * Each Sentry poll can take up to 180s. The first poll also waits the ~2min until the span can be + * queried, so the later polls usually pass at once. This leaves room for two full polls. + */ + 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-openai-decisions-send-to-sentry/src/app.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts new file mode 100644 index 000000000000..f37d3198784c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/app.ts @@ -0,0 +1,85 @@ +// `instrument.ts` is preloaded via `node --import`, so Sentry is already initialised here. +import * as Sentry from '@sentry/node'; +import express from 'express'; +import OpenAI from 'openai'; + +// OpenRouter has no `/decisions` route, so this app calls OpenAI directly. CI only passes this key to +// this app. +const apiKey = process.env.E2E_OPENAI_DECISIONS_API_KEY; +if (!apiKey) { + throw new Error('E2E_OPENAI_DECISIONS_API_KEY is not set'); +} + +const client = new OpenAI({ apiKey }); + +const MODEL = 'gpt-6-luna'; + +// A 1x1 red PNG, so the image request is cheap. +const RED_PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='; + +const app = express(); + +// Each route makes one `decisions.create` call and answers with the trace id, so the test can find +// the request's spans in Sentry. + +app.get('/decide', async (_req, res, next) => { + try { + const decision = await client.decisions.create({ + model: MODEL, + input: 'I was charged twice this month and want a refund.', + questions: [ + { type: 'predicate', name: 'wants_refund', instructions: 'Is a refund requested?' }, + { + type: 'choice', + name: 'department', + instructions: 'Which team should handle this?', + choices: [ + { value: 'billing', description: 'Charges and refunds' }, + { value: 'other', description: 'Anything else' }, + ], + }, + { + type: 'score', + name: 'urgency', + instructions: 'How urgent is this ticket?', + levels: [{ label: 'low' }, { label: 'high' }], + }, + ], + }); + res.send({ traceId: Sentry.getActiveSpan()?.spanContext().traceId, answers: decision.answers }); + } catch (error) { + next(error); + } +}); + +app.get('/decide-image', async (_req, res, next) => { + try { + const decision = await client.decisions.create({ + model: MODEL, + input: [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is this image mostly red?' }, + { type: 'input_image', image_url: RED_PIXEL }, + ], + }, + ], + questions: [{ type: 'predicate', name: 'is_red', instructions: 'Is the image mostly red?' }], + }); + res.send({ traceId: Sentry.getActiveSpan()?.spanContext().traceId, answers: decision.answers }); + } 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-openai-decisions-send-to-sentry listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts new file mode 100644 index 000000000000..e0a0c1807f75 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/src/instrument.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/node'; + +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-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts new file mode 100644 index 000000000000..5116ba2ebe5b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tests/send-to-sentry.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from '@playwright/test'; +import { + SEARCH_GEN_AI__INPUT__MESSAGES, + SEARCH_GEN_AI__OPERATION__NAME, + SEARCH_GEN_AI__OUTPUT__MESSAGES, + SEARCH_GEN_AI__PROVIDER__NAME, + SEARCH_GEN_AI__REQUEST__MODEL, + SEARCH_GEN_AI__RESPONSE__MODEL, + SEARCH_GEN_AI__USAGE__INPUT_TOKENS, + SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS, + SEARCH_GEN_AI__USAGE__TOTAL_TOKENS, + SEARCH_ORIGIN, + SEARCH_SPAN__OP, + SEARCH_SPAN__STATUS, +} from '@sentry/conventions/attributes/search'; +import { GEN_AI_DECIDE, HTTP_SERVER } from '@sentry/conventions/op'; +import { + EVENT_POLLING_OPTIONS, + fetchSpanAttributes, + fetchTrace, + findSpanInTrace, + flattenTrace, + traceTarget, +} from '@sentry-internal/test-utils/cli'; + +// Mirrors src/app.ts. +const MODEL = 'gpt-6-luna'; + +/** + * What every decide span carries once Sentry has stored it, whatever the model answers. The keys are the + * search names, e.g. `span.op` and `origin` rather than the `sentry.*` attributes the SDK sends. + * `span.description` stays empty: ingest only derives it for ops it knows, and `gen_ai.decide` is new. + */ +const DECIDE_SPAN_ATTRIBUTES = { + 'span.name': `decide ${MODEL}`, + [SEARCH_SPAN__OP]: GEN_AI_DECIDE, + [SEARCH_SPAN__STATUS]: 'ok', + [SEARCH_ORIGIN]: 'auto.ai.openai', + [SEARCH_GEN_AI__OPERATION__NAME]: 'decide', + [SEARCH_GEN_AI__PROVIDER__NAME]: 'openai', + [SEARCH_GEN_AI__REQUEST__MODEL]: MODEL, + [SEARCH_GEN_AI__RESPONSE__MODEL]: MODEL, +}; + +test('records a text decision with its state, questions and answers', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/decide`); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + const { traceId }: { traceId: string } = body; + + console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + let attributes: Record | undefined; + await expect + .poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS) + .toMatchObject(DECIDE_SPAN_ATTRIBUTES); + + // Integers come back as strings. Decisions bills input tokens only, so there are no output tokens. + expect(Number(attributes![SEARCH_GEN_AI__USAGE__OUTPUT_TOKENS])).toBe(0); + expect(Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS])).toBeGreaterThan(0); + expect(Number(attributes![SEARCH_GEN_AI__USAGE__TOTAL_TOKENS])).toBe( + Number(attributes![SEARCH_GEN_AI__USAGE__INPUT_TOKENS]), + ); + + // The request span is the segment; it ends last, so it can land after the decide span. + await expect + .poll(() => { + const requestSpan = flattenTrace(fetchTrace(traceId)).find(item => item.op === HTTP_SERVER); + return flattenTrace(requestSpan?.children ?? []).some(item => item.event_id === spanId); + }, EVENT_POLLING_OPTIONS) + .toBe(true); + + expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + state: 'I was charged twice this month and want a refund.', + questions: { + wants_refund: { type: 'predicate', instructions: 'Is a refund requested?' }, + department: { + type: 'choice', + instructions: 'Which team should handle this?', + criteria: { billing: 'Charges and refunds', other: 'Anything else' }, + }, + urgency: { + type: 'score', + instructions: 'How urgent is this ticket?', + criteria: ['low', 'high'], + }, + }, + }, + ]); + + // The answers depend on the model, the shape does not. + expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + answers: { + wants_refund: { type: 'predicate', probability: expect.any(Number) }, + department: { + type: 'choice', + choice: expect.stringMatching(/^(billing|other)$/), + probabilities: { billing: expect.any(Number), other: expect.any(Number) }, + confidence: expect.any(Number), + }, + urgency: { + type: 'score', + score: expect.any(Number), + probabilities: { 0: expect.any(Number), 1: expect.any(Number) }, + legend: { 0: 'low', 1: 'high' }, + confidence: expect.any(Number), + }, + }, + }, + ]); +}); + +test('records an image decision with its image data', async ({ baseURL }) => { + const response = await fetch(`${baseURL}/decide-image`); + const body = await response.json(); + expect(response.status, JSON.stringify(body)).toBe(200); + const { traceId }: { traceId: string } = body; + + console.log(`Polling for gen_ai.decide span: sentry trace view ${traceTarget(traceId)}`); + + let spanId: string | undefined; + await expect + .poll(() => (spanId = findSpanInTrace(traceId, GEN_AI_DECIDE)?.event_id), EVENT_POLLING_OPTIONS) + .toBeDefined(); + + let attributes: Record | undefined; + await expect + .poll(() => (attributes = fetchSpanAttributes(traceId, spanId!)), EVENT_POLLING_OPTIONS) + .toMatchObject(DECIDE_SPAN_ATTRIBUTES); + + expect(JSON.parse(attributes![SEARCH_GEN_AI__INPUT__MESSAGES] as string)).toEqual([ + { + type: 'evaluation', + state: [ + { + role: 'user', + content: [ + { type: 'input_text', text: 'Is this image mostly red?' }, + { type: 'input_image', image_url: expect.stringMatching(/^data:image\/png;base64,/) }, + ], + }, + ], + questions: { is_red: { type: 'predicate', instructions: 'Is the image mostly red?' } }, + }, + ]); + + expect(JSON.parse(attributes![SEARCH_GEN_AI__OUTPUT__MESSAGES] as string)).toEqual([ + { type: 'evaluation', answers: { is_red: { type: 'predicate', probability: expect.any(Number) } } }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-openai-decisions-send-to-sentry/tsconfig.json new file mode 100644 index 000000000000..795574fd9df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-openai-decisions-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 f4ae6c55220f1dd36d253d005ef290a9b1e46d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Wed, 14 Oct 2026 19:10:27 +0200 Subject: [PATCH 30/37] fix(browser): Apply `urlQueryParams` to the Referer header (#25324) closes #25303 closes [JS-3918](https://linear.app/getsentry/issue/JS-3918) ## Description This applies `dataCollection.urlQueryParams` to the `Referer` header that the browser SDK collects from `document.referrer`. Sensitive query params of the previous page are now filtered in `request.headers.Referer` of error events and static transactions, and in the `http.request.header.referer` span attribute. ## Known issues We do have a button to go to this page. By now adding filters we redirect to a potential broken page, but this can't and shouldn't be fixed, as this is filtered. Screenshot 2026-10-13 at 14 21 34 ## Sentry events Before (11.7.0), the Referer has `jwt=sensitive-jwt-value`: https://sentry-sdks.sentry.io/issues/7789837458/events/b27e43ad65b7405b8e030ebbcbff37e1/?project=4510555608449024 After, the Referer has `jwt=[Filtered]`: https://sentry-sdks.sentry.io/issues/7789837458/events/e5d05ddd3fd14c40a0c24e26e962223c/?project=4510555608449024 Co-authored-by: Claude Opus 5.5 --- .../suites/integrations/httpContext/test.ts | 34 ++++++++++++++ .../test.ts | 3 +- packages/browser/src/helpers.ts | 10 +++-- .../browser/src/integrations/httpcontext.ts | 4 +- .../src/tracing/browserTracingIntegration.ts | 4 +- .../test/integrations/httpcontext.test.ts | 45 +++++++++++++++++++ 6 files changed, 92 insertions(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts b/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts index e50a658b4c9a..40cce0c2ee28 100644 --- a/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts +++ b/dev-packages/browser-integration-tests/suites/integrations/httpContext/test.ts @@ -37,6 +37,40 @@ sentryTest('httpContextIntegration filters sensitive query params in the URL', a expect(errorEvent.request?.url).toBe(`${url}?jwt=[Filtered]&page=1`); }); +sentryTest( + 'httpContextIntegration filters sensitive query params in the Referer header', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + const errorEventPromise = waitForErrorRequest(page); + + await page.goto(url, { referer: `${url}?jwt=sensitive-jwt-value&page=1` }); + + const errorEvent = envelopeRequestParser(await errorEventPromise); + + expect(errorEvent.request?.headers?.Referer).toBe(`${url}?jwt=[Filtered]&page=1`); + }, +); + +sentryTest( + 'httpContextIntegration filters sensitive query params in the referer span attribute', + async ({ getLocalTestUrl, page }) => { + sentryTest.skip(shouldSkipTracingTest()); + const url = await getLocalTestUrl({ testDir: __dirname }); + + const spanPromise = waitForStreamedSpan(page, span => span.is_segment && getSpanOp(span) === 'pageload'); + + await page.goto(url, { referer: `${url}?jwt=sensitive-jwt-value&page=1` }); + + const pageloadSpan = await spanPromise; + + expect(pageloadSpan.attributes['http.request.header.referer']).toEqual({ + type: 'array', + value: [`${url}?jwt=[Filtered]&page=1`], + }); + }, +); + sentryTest('httpContextIntegration captures url, user-agent, and referer', async ({ getLocalTestUrl, page }) => { sentryTest.skip(shouldSkipTracingTest()); const url = await getLocalTestUrl({ testDir: __dirname }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts index 8bfc5a6df874..c20135d73286 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/pageload-sensitive-query-params-static/test.ts @@ -16,10 +16,11 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === PAGELOAD); - await page.goto(`${url}?jwt=sensitive-jwt-value&page=1`); + await page.goto(`${url}?jwt=sensitive-jwt-value&page=1`, { referer: `${url}?jwt=sensitive-jwt-value&page=2` }); const eventData = envelopeRequestParser(await pageloadRequestPromise); expect(eventData.request?.url).toBe(`${url}?jwt=[Filtered]&page=1`); + expect(eventData.request?.headers?.Referer).toBe(`${url}?jwt=[Filtered]&page=2`); expect(eventData.spans?.find(({ op }) => op === BROWSER_REQUEST)?.description).toBe(`${url}?jwt=[Filtered]&page=1`); expect(eventData.spans?.find(({ op }) => op === RESOURCE_IMG)?.description).toBe( 'https://sentry-test-site.example/path/to/image.svg?token=[Filtered]&size=2', diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts index 4b060abafc71..65255d987e7d 100644 --- a/packages/browser/src/helpers.ts +++ b/packages/browser/src/helpers.ts @@ -1,9 +1,10 @@ -import type { HandlerDataError, Mechanism, WrappedFunction } from '@sentry/core'; +import type { Client, HandlerDataError, Mechanism, WrappedFunction } from '@sentry/core'; import { addExceptionMechanism, addExceptionTypeValue, addNonEnumerableProperty, captureException, + filterCollectedUrl, getOriginalFunction, GLOBAL_OBJ, markFunctionWrapped, @@ -221,15 +222,18 @@ export function wrap( /** * Get HTTP request data from the current page. + * + * The `Referer` header has `dataCollection.urlQueryParams` of `client` applied. `url` is unfiltered, so + * callers must pass it through `filterCollectedUrl`. */ -export function getHttpRequestData(): { url: string; headers: Record } { +export function getHttpRequestData(client: Client): { url: string; headers: Record } { // grab as much info as exists and add it to the event const url = getLocationHref(); const { referrer } = WINDOW.document || {}; const { userAgent } = WINDOW.navigator || {}; const headers = { - ...(referrer && { Referer: referrer }), + ...(referrer && { Referer: filterCollectedUrl(referrer, client) }), ...(userAgent && { 'User-Agent': userAgent }), }; const request = { diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts index 45961e307b15..1f20ffb9e843 100644 --- a/packages/browser/src/integrations/httpcontext.ts +++ b/packages/browser/src/integrations/httpcontext.ts @@ -22,7 +22,7 @@ export const httpContextIntegration = defineIntegration(() => { return; } - const { url, headers: collectedHeaders } = getHttpRequestData(); + const { url, headers: collectedHeaders } = getHttpRequestData(client); // We only filter the headers we collected ourselves, so headers the user set are left alone. Going by key // means we also catch these headers when `browserTracingIntegration` already put them on the event. @@ -47,7 +47,7 @@ export const httpContextIntegration = defineIntegration(() => { return; } - const reqData = getHttpRequestData(); + const reqData = getHttpRequestData(client); // `httpHeadersToSpanAttributes` would also work here, but its cookie and array handling never runs for these // two headers and costs every browser bundle ~400B gzip. diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a900309bf992..b02a5d5ebcca 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -574,7 +574,7 @@ export const browserTracingIntegration = ((options: Partial { expect(event.request?.url).toBe('https://example.com/?jwt=[Filtered]&page=1'); }); + it('filters sensitive query params in the Referer header attached to events', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions()); + const event: Event = {}; + + document.referrer = 'https://example.com/?jwt=baz&page=1'; + try { + httpContextIntegration().preprocessEvent!(event, {}, client); + } finally { + document.referrer = 'https://example.com'; + } + + expect(event.request?.headers?.Referer).toBe('https://example.com/?jwt=[Filtered]&page=1'); + }); + + it('removes the query from the Referer header attached to events when `urlQueryParams` is disabled', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions({ dataCollection: { urlQueryParams: false } })); + const event: Event = {}; + + document.referrer = 'https://example.com/?jwt=baz&page=1'; + try { + httpContextIntegration().preprocessEvent!(event, {}, client); + } finally { + document.referrer = 'https://example.com'; + } + + expect(event.request?.headers?.Referer).toBe('https://example.com/'); + }); + + it('filters sensitive query params in the referer attribute of segment spans', () => { + const client = new BrowserClient(getDefaultBrowserClientOptions()); + const span: Partial = { + is_segment: true, + attributes: { [SENTRY_OP]: 'pageload' }, + }; + + document.referrer = 'https://example.com/?jwt=baz&page=1'; + try { + httpContextIntegration().processSpan!(span as StreamedSpanJSON, client); + } finally { + document.referrer = 'https://example.com'; + } + + expect(span.attributes?.['http.request.header.referer']).toEqual(['https://example.com/?jwt=[Filtered]&page=1']); + }); + it('leaves a user-supplied event URL untouched', () => { const client = new BrowserClient(getDefaultBrowserClientOptions()); const event: Event = { request: { url: 'https://example.com/?jwt=user-set' } }; From 747134758f4f6cb9ab522aa03bdf5a188ffe958c Mon Sep 17 00:00:00 2001 From: whatever_kunaal <143793407+kunal649@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:36:28 +0530 Subject: [PATCH 31/37] feat(core): Support SuppressedError in LinkedErrors integration (#25246) `SuppressedError` is what JS throws when a cleanup (`using` / `Symbol.dispose`) fails while another error is already in flight. It keeps both errors, `error` (the new one) and `suppressed` (the original), and it has no `cause`. because of that, LinkedErrors never saw them. In Sentry I only got the generic wrapper message, "An error was suppressed during disposal". Take a look , how it looks today (node SDK): Screenshot 2026-10-11 195752 This change makes `aggregateExceptionsFromError` also follow `error` and `suppressed`, the same way it already follows `cause` and `errors[]`, both errors now show up as chained exceptions. If a SuppressedError has another SuppressedError inside, it goes down the chain too. And one thing I'm not sure about: I did not set `is_exception_group` on the wrapper. From [#10850](https://github.com/getsentry/sentry-javascript/pull/10850) i understand that flag makes sentry ignore that entry for grouping and titles, and that was a problem for `cause` errors. But the wrapper here has a meaningless message, so maybe it should be a group. (happy to change it if you prefer.) I added tests for both errors, nested SuppressedErrors, non-Error values, and an error that only has an `error` property (it must not be treated as a SuppressedError). Fixes [#8441](https://github.com/getsentry/sentry-javascript/issues/8441) --- .../suppressed-error/scenario.ts | 27 ++++++ .../captureException/suppressed-error/test.ts | 38 +++++++++ packages/core/src/utils/aggregate-errors.ts | 30 +++++++ .../test/lib/utils/aggregate-errors.test.ts | 84 +++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts diff --git a/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts new file mode 100644 index 000000000000..9d6526337eb4 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/scenario.ts @@ -0,0 +1,27 @@ +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', + transport: loggingTransport, +}); + +function createResource() { + return { + [Symbol.dispose]() { + throw new Error('error from dispose'); + }, + }; +} + +function run(): void { + using _resource = createResource(); + throw new Error('original error'); +} + +try { + run(); +} catch (error) { + Sentry.captureException(error); +} diff --git a/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts new file mode 100644 index 000000000000..a482241adf20 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/public-api/captureException/suppressed-error/test.ts @@ -0,0 +1,38 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { supports } from '../../../../utils'; +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe.runIf(supports({ min: 22 }))('SuppressedError', () => { + test('captures both errors of a SuppressedError as chained exceptions', async () => { + await createRunner(__dirname, 'scenario.ts') + .expect({ + event: { + exception: { + values: [ + { + type: 'Error', + value: 'original error', + mechanism: { type: 'chained', source: 'suppressed', handled: true, exception_id: 2, parent_id: 0 }, + }, + { + type: 'Error', + value: 'error from dispose', + mechanism: { type: 'chained', source: 'error', handled: true, exception_id: 1, parent_id: 0 }, + }, + { + type: 'SuppressedError', + value: expect.any(String), + mechanism: { type: 'generic', handled: true, exception_id: 0 }, + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); diff --git a/packages/core/src/utils/aggregate-errors.ts b/packages/core/src/utils/aggregate-errors.ts index ef006d12e69d..a481ea7ab28c 100644 --- a/packages/core/src/utils/aggregate-errors.ts +++ b/packages/core/src/utils/aggregate-errors.ts @@ -95,6 +95,32 @@ function aggregateExceptionsFromError( }); } + // SuppressedError (explicit resource management) wraps two errors: `error` (the new one thrown during disposal) + // and `suppressed` (the original one it replaced). + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SuppressedError + + if (isSuppressedError(error)) { + ['error', 'suppressed'].forEach(property => { + const childError = error[property]; + if (isError(childError)) { + applyExceptionGroupFieldsForParentException(exception, exceptionId, error); + const newException = exceptionFromErrorImplementation(parser, childError); + const newExceptionId = newExceptions.length; + applyExceptionGroupFieldsForChildException(newException, property, newExceptionId, exceptionId); + newExceptions = aggregateExceptionsFromError( + exceptionFromErrorImplementation, + parser, + limit, + childError as ExtendedError, + key, + [newException, ...newExceptions], + newException, + newExceptionId, + ); + } + }); + } + return newExceptions; } @@ -102,6 +128,10 @@ function isExceptionGroup(error: ExtendedError): error is ExtendedError & { erro return Array.isArray(error.errors); } +function isSuppressedError(error: ExtendedError): boolean { + return 'error' in error && 'suppressed' in error; +} + function applyExceptionGroupFieldsForParentException( exception: Exception, exceptionId: number, diff --git a/packages/core/test/lib/utils/aggregate-errors.test.ts b/packages/core/test/lib/utils/aggregate-errors.test.ts index 86f796d2d8e0..2ca4891710a9 100644 --- a/packages/core/test/lib/utils/aggregate-errors.test.ts +++ b/packages/core/test/lib/utils/aggregate-errors.test.ts @@ -32,6 +32,18 @@ class CustomAggregateError extends FakeAggregateError { } } +class FakeSuppressedError extends Error { + public error: unknown; + public suppressed: unknown; + + constructor(error: unknown, suppressed: unknown, message = 'An error was suppressed during disposal.') { + super(message); + this.name = 'SuppressedError'; + this.error = error; + this.suppressed = suppressed; + } +} + describe('applyAggregateErrorsToEvent()', () => { test('should not do anything if event does not contain an exception', () => { const event: Event = { exception: undefined }; @@ -482,4 +494,76 @@ describe('applyAggregateErrorsToEvent()', () => { }, }); }); + + test('adds both the `error` and the `suppressed` error of a SuppressedError', () => { + const suppressedError = new FakeSuppressedError(new Error('Error from dispose'), new Error('Original error')); + const event: Event = { exception: { values: [exceptionFromError(stackParser, suppressedError)] } }; + const eventHint: EventHint = { originalException: suppressedError }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + + expect(event).toStrictEqual({ + exception: { + values: [ + { + mechanism: { exception_id: 2, handled: true, parent_id: 0, source: 'suppressed', type: 'chained' }, + type: 'Error', + value: 'Original error', + }, + { + mechanism: { exception_id: 1, handled: true, parent_id: 0, source: 'error', type: 'chained' }, + type: 'Error', + value: 'Error from dispose', + }, + { + mechanism: { exception_id: 0, handled: true, type: 'instrument' }, + type: 'SuppressedError', + value: 'An error was suppressed during disposal.', + }, + ], + }, + }); + }); + + test('walks nested SuppressedErrors (several disposers throwing)', () => { + const inner = new FakeSuppressedError(new Error('dispose 2 failed'), new Error('Original error')); + const outer = new FakeSuppressedError(new Error('dispose 1 failed'), inner); + const event: Event = { exception: { values: [exceptionFromError(stackParser, outer)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { originalException: outer }); + + expect(event.exception?.values?.map(e => e.value)).toEqual([ + 'Original error', + 'dispose 2 failed', + 'An error was suppressed during disposal.', + 'dispose 1 failed', + 'An error was suppressed during disposal.', + ]); + }); + + test('skips `error` / `suppressed` values that are not Errors', () => { + const suppressedError = new FakeSuppressedError('just a string', new Error('Original error')); + const event: Event = { exception: { values: [exceptionFromError(stackParser, suppressedError)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { + originalException: suppressedError, + }); + + expect(event.exception?.values?.map(e => e.value)).toEqual([ + 'Original error', + 'An error was suppressed during disposal.', + ]); + }); + + test('does not treat an error that only has an `error` property as a SuppressedError', () => { + const notSuppressed: ExtendedError = new Error('Something else'); + notSuppressed.error = new Error('unrelated'); + const event: Event = { exception: { values: [exceptionFromError(stackParser, notSuppressed)] } }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, { + originalException: notSuppressed, + }); + + expect(event.exception?.values).toHaveLength(1); + }); }); From 9516aeb8509fab96b433792bb183f2fec04f003a Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 14 Oct 2026 16:36:06 -0700 Subject: [PATCH 32/37] fix(cloudflare): auto-instrument when wrangler `main` is a package (#25343) When wrangler `main` is a package export, such as `"main": "vinext/server/fetch-handler"` or `"main": "@astrojs/cloudflare/entrypoints/server"`, the Vite plugin did not wrap the Worker. The build passed, but the bundle had no Sentry code, and the plugin gave no warning. `unstable_readConfig()` resolved every `main` as a path relative to the wrangler config, so no module matched the entry. The plugin now reads `main` the way `@cloudflare/vite-plugin` does. A value with a JS or TS extension is a file path. Any other value is resolved with Vite, once per environment, from the wrangler config. If it does not resolve, the plugin warns that auto-instrumentation is off. For a package entry, `instrument.server.*` is read from beside the wrangler config. The resolve uses `createIdResolver` from `vite`, so `vite` is now an optional peer dependency. `this.resolve()` in `transform` does not work: Rollup's commonjs resolver loads each module it resolves, so a resolve of the entry from the entry's own transform never finishes. A `resolveId` hook does not work either, because `vite:resolve` resolves bare specifiers before user plugins see them. `createIdResolver` uses each environment's own conditions (for example `workerd`), so it picks the same file that the Cloudflare plugin bundles. Bundled package entries often end in `export { handler as default }`. The transform only wrapped `export default `, so it now also wraps a default export in that form. The new `cloudflare-autoinstrument-package-entry` e2e app installs a local package as the Worker entry. `@sentry/astro` still needs changes before `astro-6-cf-workers` can use a package `main`, so that app stays as it is, and will be fixed in a subsequent PR. Fixes #24522 Fixes JS-3725 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../.gitignore | 4 + .../instrument.server.ts | 10 + .../package.json | 29 +++ .../playwright.config.ts | 12 ++ .../start-event-proxy.mjs | 6 + .../tests/package-entry.test.ts | 35 ++++ .../vite.config.ts | 7 + .../worker-entry/fetch-handler.js | 11 ++ .../worker-entry/package.json | 9 + .../wrangler.jsonc | 8 + packages/cloudflare/package.json | 4 + .../cloudflare/src/vite/autoInstrument.ts | 86 ++++++--- .../cloudflare/src/vite/instrumentFile.ts | 10 +- packages/cloudflare/src/vite/transform.ts | 78 +++++++- .../cloudflare/src/vite/wranglerConfig.ts | 30 ++- .../test/vite/autoInstrument.test.ts | 83 ++++++++- .../cloudflare/test/vite/transform.test.ts | 175 ++++++++++++++++++ .../test/vite/wranglerConfig.test.ts | 8 + 18 files changed, 561 insertions(+), 44 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore new file mode 100644 index 000000000000..3f64191fe034 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/.gitignore @@ -0,0 +1,4 @@ +dist +.wrangler +test-results +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts new file mode 100644 index 000000000000..10d0f17fbcdb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/instrument.server.ts @@ -0,0 +1,10 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// The worker entry is a package, so the plugin reads this file from beside +// the wrangler config. +export default defineCloudflareOptions((env: { E2E_TEST_DSN: string }) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json new file mode 100644 index 000000000000..2211b7f3455b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/package.json @@ -0,0 +1,29 @@ +{ + "name": "cloudflare-autoinstrument-package-entry", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "worker-entry": "file:../../test-applications/cloudflare-autoinstrument-package-entry/worker-entry" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.47.0", + "@cloudflare/workers-types": "^5.20260727.1", + "@playwright/test": "~1.63.0", + "@sentry/conventions": "0.27.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^8.1.5", + "wrangler": "^4.114.0" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts new file mode 100644 index 000000000000..9633de63b272 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/playwright.config.ts @@ -0,0 +1,12 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 8787, + }, + { + workers: '100%', + retries: 0, + }, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..553dac81dc3c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-autoinstrument-package-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts new file mode 100644 index 000000000000..7baf4af55892 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/tests/package-entry.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test'; +import { SENTRY_ENVIRONMENT, SENTRY_ORIGIN } from '@sentry/conventions/attributes'; +import { HTTP_SERVER } from '@sentry/conventions/op'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// The worker entry is a package with no Sentry calls. Any event that arrives +// proves the plugin wrapped the module that wrangler's `main` resolved to. + +test('wraps a package entry with withSentry (options from instrument.server.ts)', async ({ baseURL }) => { + const spanPromise = waitForStreamedSpan( + 'cloudflare-autoinstrument-package-entry', + span => getSpanOp(span) === HTTP_SERVER && span.is_segment, + ); + + const res = await fetch(baseURL!); + expect(res.status).toBe(200); + + const span = await spanPromise; + expect(span.attributes[SENTRY_ORIGIN]?.value).toBe('auto.http.cloudflare'); + // Only `instrument.server.ts` sets this environment. + expect(span.attributes[SENTRY_ENVIRONMENT]?.value).toBe('qa'); +}); + +test('captures an error thrown by a package entry', async ({ baseURL }) => { + const errorPromise = waitForError( + 'cloudflare-autoinstrument-package-entry', + event => event.exception?.values?.[0]?.value === 'Thrown from a package entry', + ); + + const res = await fetch(`${baseURL}/throw`); + expect(res.status).toBe(500); + + const error = await errorPromise; + expect(error.environment).toBe('qa'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts new file mode 100644 index 000000000000..005f4448f6cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/vite.config.ts @@ -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/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js new file mode 100644 index 000000000000..1d8a976634e6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/fetch-handler.js @@ -0,0 +1,11 @@ +// Shaped like bundler output (e.g. `@astrojs/cloudflare`): the default export +// is a specifier, not `export default `. +var fetch_handler_default = { + async fetch(request) { + if (new URL(request.url).pathname === '/throw') { + throw new Error('Thrown from a package entry'); + } + return new Response('ok'); + }, +}; +export { fetch_handler_default as default }; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json new file mode 100644 index 000000000000..08018b205e6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/worker-entry/package.json @@ -0,0 +1,9 @@ +{ + "name": "worker-entry", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./fetch-handler": "./fetch-handler.js" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc new file mode 100644 index 000000000000..4f9bc84616ee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-autoinstrument-package-entry/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-autoinstrument-package-entry", + // A package export, not a file path. The Sentry plugin resolves it with Vite. + "main": "worker-entry/fetch-handler", + "compatibility_date": "2026-05-20", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index c4145caf6c47..27494f3c6059 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -61,12 +61,16 @@ }, "peerDependencies": { "@cloudflare/workers-types": "^4.x || ^5.x", + "vite": "^6.1.0 || ^7.0.0 || ^8.0.0", "wrangler": "^4.x" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { "optional": true }, + "vite": { + "optional": true + }, "wrangler": { "optional": true } diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index c4550f2fa12f..0a725d224a0e 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -1,4 +1,5 @@ -import { basename } from 'node:path'; +import { basename, dirname, isAbsolute } from 'node:path'; +import { createIdResolver, type ResolvedConfig } from 'vite'; import { collectAgentCandidates, detectAgentClasses, type ModuleResolver } from './agentClass'; import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile'; import { applyAutoInstrumentTransforms, type ClassWrapperKind, type ProgramBody } from './transform'; @@ -30,14 +31,48 @@ async function resolvesNextjsCloudflare(resolver: ModuleResolver, importer: stri } } +type TransformContext = ModuleResolver & { + warn?(msg: string): void; + environment?: Parameters>[0]; +}; + export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPath?: string } = {}) { let wranglerConfig: WranglerConfig | undefined; let entryFilePath: string | undefined; - - let optionsFn = ENV_FALLBACK_OPTIONS_FN; - let optionsImport: string | undefined; + // A `main` that is not a file path (a package export or virtual module). + let entrySpecifier: string | undefined; + let wranglerConfigPath: string | undefined; + let resolveId: ReturnType | undefined; + // Each environment resolves with its own conditions, so the entry may differ between them. + const resolvedEntries = new Map>(); + + let instrumentFilePath: string | undefined; let isVinext = false; + function resolveEntrySpecifier(ctx: TransformContext, specifier: string): Promise { + const environment = ctx.environment; + if (!environment || !resolveId) return Promise.resolve(undefined); + + let entry = resolvedEntries.get(environment.name); + if (!entry) { + // Resolved from the wrangler config, as wrangler does. Without an importer, Vite's resolver + // tries the specifier as a path below the root first. + entry = resolveId(environment, specifier, wranglerConfigPath) + .catch(() => undefined) + .then(id => { + if (!id) { + ctx.warn?.( + `[sentry] Could not resolve the wrangler \`main\` "${specifier}". Auto-instrumentation disabled.`, + ); + return undefined; + } + return normalizePath(id.replace(/[?#].*$/, '')); + }); + resolvedEntries.set(environment.name, entry); + } + return entry; + } + return { name: 'sentry-cloudflare-auto-instrument', @@ -64,40 +99,43 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa } wranglerConfig = result.config; - if (wranglerConfig.main) { - // `main` is already absolute (wrangler resolves it); just normalize - // separators so the entry-module comparison holds on Windows. - entryFilePath = normalizePath(wranglerConfig.main); - } - - if (entryFilePath) { - const instrumentFilePath = resolveInstrumentFile(entryFilePath); - if (instrumentFilePath) { - const built = buildOptionsImport(entryFilePath, instrumentFilePath); - optionsFn = built.optionsFn; - optionsImport = built.importStmt; - } + const main = wranglerConfig.main; + if (main && isAbsolute(main)) { + // Normalize separators so the entry-module comparison holds on Windows. + entryFilePath = normalizePath(main); + instrumentFilePath = resolveInstrumentFile(dirname(main)); + } else if (main) { + entrySpecifier = main; + wranglerConfigPath = result.configPath; + // Not `this.resolve()`: Rollup's commonjs resolver loads the module it resolves, so a + // resolve of the entry from the entry's own `transform` never finishes. + resolveId = createIdResolver(config as ResolvedConfig); + // A package entry has no project directory of its own. + instrumentFilePath = resolveInstrumentFile(result.configDir); } }, async transform( - this: ModuleResolver & { warn?(msg: string): void; environment?: { name?: string } }, + this: TransformContext, code: string, id: string, ): Promise<{ code: string; map: unknown } | undefined> { - if (!wranglerConfig || !entryFilePath) return undefined; + if (!wranglerConfig || (!entryFilePath && !entrySpecifier)) return undefined; // The worker entry never belongs to the client (browser) environment. // Skipping it keeps a same-basename sibling (e.g. a `src/index.tsx` // client entry next to a `src/index.ts` worker) out of the browser bundle. if (this.environment?.name === 'client') return undefined; + const entryPath = entrySpecifier ? await resolveEntrySpecifier(this, entrySpecifier) : entryFilePath; + if (!entryPath) return undefined; + // Vite may append query/hash params to the module ID. const normalizedId = normalizePath(id.replace(/[?#].*$/, '')); - if (normalizedId !== entryFilePath) { + if (normalizedId !== entryPath) { // Tolerate a differing JS-flavored extension (e.g. `.js` vs `.ts`). - if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryFilePath)) return undefined; - if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryFilePath.replace(JS_EXTENSION_REGEX, '')) { + if (!JS_EXTENSION_REGEX.test(normalizedId) || !JS_EXTENSION_REGEX.test(entryPath)) return undefined; + if (normalizedId.replace(JS_EXTENSION_REGEX, '') !== entryPath.replace(JS_EXTENSION_REGEX, '')) { return undefined; } } @@ -145,6 +183,10 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa const sentryModule = isVinext && (await resolvesNextjsCloudflare(this, normalizedId)) ? NEXTJS_CLOUDFLARE_MODULE : undefined; + const { optionsFn, importStmt: optionsImport } = instrumentFilePath + ? buildOptionsImport(entryPath, instrumentFilePath) + : { optionsFn: ENV_FALLBACK_OPTIONS_FN, importStmt: undefined }; + // No registration import is injected here: the orchestrion plugin's // subscribe-injection makes each bundled package self-register its channel // subscriber on the global marker, so wrapping the entry with `withSentry` diff --git a/packages/cloudflare/src/vite/instrumentFile.ts b/packages/cloudflare/src/vite/instrumentFile.ts index ffd79ca0104c..91c8e9c057f5 100644 --- a/packages/cloudflare/src/vite/instrumentFile.ts +++ b/packages/cloudflare/src/vite/instrumentFile.ts @@ -10,17 +10,17 @@ export const ENV_FALLBACK_OPTIONS_FN = '() => undefined'; const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__'; // Conventional, non-configurable name of the Sentry options module. It is -// looked up next to the worker entry file; its default export is the options +// looked up next to the worker entry file, or next to the wrangler config +// when `main` is not a file path; its default export is the options // callback `(env) => CloudflareOptions`. const INSTRUMENT_FILE_BASENAME = 'instrument.server'; const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs']; /** - * Locate the conventional `instrument.server.*` module sitting next to the - * worker entry file. Returns its absolute path, or `undefined` when absent. + * Locate the conventional `instrument.server.*` module in `dir`. Returns its + * absolute path, or `undefined` when absent. */ -export function resolveInstrumentFile(entryFilePath: string): string | undefined { - const dir = dirname(entryFilePath); +export function resolveInstrumentFile(dir: string): string | undefined { for (const ext of INSTRUMENT_FILE_EXTENSIONS) { const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`); if (existsSync(candidate)) return candidate; diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 3674ed5dd032..49b623d6c431 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -61,6 +61,10 @@ interface VariableDeclarationNode extends BaseNode { declarations?: VariableDeclaratorNode[]; } +interface ImportDeclarationNode extends BaseNode { + specifiers?: Array<{ local?: { type: string; name?: string } }>; +} + function isCallToMethod(node: BaseNode, methodName: string): boolean { if (node.type !== 'CallExpression') return false; const callee = (node as CallExpressionNode).callee; @@ -169,6 +173,7 @@ export function applyAutoInstrumentTransforms( optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn, autoWrapped: new Set(), manuallyWrappedLocals: collectManuallyWrappedLocals(ast), + declaringStatements: collectDeclaringStatements(ast), }; const { wrappedClasses } = state; @@ -214,7 +219,7 @@ interface TransformState { needsImport: boolean; wrappedClasses: Set; /** - * Top-level (non-exported) class declarations, so specifier exports like + * Top-level class declarations, exported or not, so specifier exports like * `export { MyDO }` can locate the class they refer to. */ topLevelClasses: Map; @@ -245,6 +250,8 @@ interface TransformState { * specifier must report it as wrapped rather than wrap it a second time. */ manuallyWrappedLocals: Set; + /** Top-level binding name → the statement that declares it. */ + declaringStatements: Map; } /** @@ -319,8 +326,9 @@ function collectManuallyWrappedLocals(ast: ProgramBody): Set { const locals = new Set(); for (const node of ast.body) { - if (node.type !== 'VariableDeclaration') continue; - for (const declarator of (node as VariableDeclarationNode).declarations ?? []) { + const declaration = declarationOf(node); + if (declaration?.type !== 'VariableDeclaration') continue; + for (const declarator of (declaration as VariableDeclarationNode).declarations ?? []) { const name = declarator.id?.type === 'Identifier' ? declarator.id.name : undefined; const init = declarator.init; if (name && init && wrapperMethods.some(method => isCallToMethod(init, method))) { @@ -332,16 +340,40 @@ function collectManuallyWrappedLocals(ast: ProgramBody): Set { return locals; } +function collectDeclaringStatements(ast: ProgramBody): Map { + const statements = new Map(); + for (const node of ast.body) { + const declaration = declarationOf(node); + if (!declaration) continue; + const ids = + declaration.type === 'VariableDeclaration' + ? ((declaration as VariableDeclarationNode).declarations ?? []).map(declarator => declarator.id) + : declaration.type === 'ImportDeclaration' + ? ((declaration as ImportDeclarationNode).specifiers ?? []).map(specifier => specifier.local) + : [(declaration as ClassDeclarationNode).id]; + for (const id of ids) { + if (id?.type === 'Identifier' && id.name) statements.set(id.name, node); + } + } + return statements; +} + function collectTopLevelClasses(ast: ProgramBody): Map { const classes = new Map(); for (const node of ast.body) { - if (node.type !== 'ClassDeclaration') continue; - const classNode = node as ClassDeclarationNode; + const declaration = declarationOf(node); + if (declaration?.type !== 'ClassDeclaration') continue; + const classNode = declaration as ClassDeclarationNode; if (classNode.id?.name) classes.set(classNode.id.name, classNode); } return classes; } +/** The declaration a top-level statement makes, looking through `export` (`export const x = ...`). */ +function declarationOf(node: BaseNode): BaseNode | null | undefined { + return node.type === 'ExportNamedDeclaration' ? (node as ExportNamedNode).declaration : node; +} + function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state: TransformState): void { const decl = node.declaration; @@ -503,6 +535,37 @@ function wrapSpecifierExports(node: ExportNamedNode, ctx: TransformContext, stat state.ms.overwrite(node.start, node.end, statements.join('\n')); } +/** + * `export { handler as default }`, the form bundlers emit for a default export. The export is set + * in two steps. Right after `handler` is declared, it is bound to `handler` as-is, so a circular + * import that reads it during module evaluation gets the handler, as before the transform. At the + * end of the module, it is rebound to the wrapper, so a handler assigned or given methods later is + * wrapped as it ends up. The binding is a `var`: a read before `handler` is declared gets + * `undefined`, as it would from the bundler's own `var`, where a `const` would throw and stop the + * Worker from starting. + */ +function wrapDefaultSpecifier(localName: string, state: TransformState): string | undefined { + if (state.manuallyWrappedLocals.has(localName)) return undefined; + + // Same as `export default Foo`: re-point at the wrapper a named export already made. + const wrappedName = state.wrappedLocals.get(localName); + if (wrappedName) { + state.autoWrapped.add(DEFAULT_EXPORT); + return `${wrappedName} as default`; + } + + const declaringStatement = state.declaringStatements.get(localName); + if (declaringStatement) { + state.ms.appendLeft(declaringStatement.end, `\nvar __SENTRY_DEFAULT_EXPORT__ = ${localName};`); + } + state.ms.append( + `\n${declaringStatement ? '' : 'var '}__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry(${state.optionsFn}, ${localName});`, + ); + state.needsImport = true; + state.autoWrapped.add(DEFAULT_EXPORT); + return '__SENTRY_DEFAULT_EXPORT__ as default'; +} + /** * Handle one export specifier, returning the `Wrapped as Exported` pair to emit for a class that * gets wrapped. The import/wrapper statements a cross-module pair depends on are pushed onto @@ -528,7 +591,10 @@ function wrapSpecifier( ? resolveWrapperKind(exportedName, sourceLiteral ? undefined : localName, state) : undefined; - if (!exportedName || !localName || !kind) return undefined; + if (!exportedName || !localName) return undefined; + if (!kind) { + return exportedName === DEFAULT_IMPORT && !sourceLiteral ? wrapDefaultSpecifier(localName, state) : undefined; + } // Without a `from` clause the specifier points at a module-local binding. if (!sourceLiteral) { diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index 9838f0609e46..8e02d2036f30 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -4,10 +4,13 @@ import { type Unstable_Config, unstable_readConfig } from 'wrangler'; /** * The slice of the wrangler configuration the auto-instrument plugin cares - * about. `main` is an absolute path (wrangler resolves it against the config - * file's directory). + * about. */ export interface WranglerConfig { + /** + * An absolute path when `main` names a JS or TS file, otherwise the + * original value (a package export or virtual module) for Vite to resolve. + */ main?: string; durableObjects: Array<{ name: string; className: string }>; workflows: Array<{ name: string; className: string }>; @@ -25,6 +28,9 @@ export interface WranglerConfig { sameWorkerBindings: SameWorkerBinding[]; } +// Same rule as `@cloudflare/vite-plugin`: only these extensions mark `main` as a file path. +const ENTRY_FILE_REGEX = /\.(?:m?[jt]s|[jt]sx)$/; + /** Stands in for a class name where a binding targets the module's default export. */ export const DEFAULT_EXPORT = Symbol('defaultExport'); @@ -42,10 +48,10 @@ export interface SameWorkerBinding { * * We only locate the file (probing `wrangler.json`, `.jsonc`, `.toml` inside * `root` with wrangler's own precedence, since it discovers from `cwd` rather - * than an arbitrary root); wrangler then parses it, flattens the active - * environment (honoring `CLOUDFLARE_ENV`), and resolves `main` to an absolute - * path. Durable Object and Workflow bindings are the active environment's, - * matching what the deployed Worker actually binds. + * than an arbitrary root); wrangler then parses it and flattens the active + * environment (honoring `CLOUDFLARE_ENV`). Durable Object and Workflow + * bindings are the active environment's, matching what the deployed Worker + * actually binds. * * Returns `undefined` when no config file is found or it can't be read/parsed * (the caller warns and disables auto-instrumentation rather than failing the @@ -54,7 +60,7 @@ export interface SameWorkerBinding { export function resolveWranglerConfig( root: string, explicitPath?: string, -): { config: WranglerConfig; configDir: string } | undefined { +): { config: WranglerConfig; configPath: string; configDir: string } | undefined { const configPath = explicitPath ? resolve(root, explicitPath) : ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'].map(name => resolve(root, name)).find(existsSync); @@ -67,20 +73,24 @@ export function resolveWranglerConfig( try { // `hideWarnings` keeps wrangler's config diagnostics (e.g. missing DO // migrations) out of the Vite build output. - raw = unstable_readConfig({ config: configPath }, { hideWarnings: true }); + raw = unstable_readConfig({ config: configPath }, { hideWarnings: true, preserveOriginalMain: true }); } catch { return undefined; } + const resolvedConfigPath = raw.configPath ?? configPath; + const configDir = dirname(resolvedConfigPath); + return { config: { - main: raw.main, + main: raw.main && ENTRY_FILE_REGEX.test(raw.main) ? resolve(configDir, raw.main) : raw.main, durableObjects: collectClassBindings(raw.durable_objects?.bindings), workflows: collectClassBindings(raw.workflows), workerEntrypoints: collectSelfBoundEntrypoints(raw), sameWorkerBindings: collectSameWorkerBindings(raw), }, - configDir: dirname(raw.configPath ?? configPath), + configPath: resolvedConfigPath, + configDir, }; } diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index f9654b0d3b5d..9f987f6ea78a 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -2,9 +2,12 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse } from 'acorn'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { sentryCloudflareAutoInstrumentPlugin } from '../../src/vite/autoInstrument'; +const { idResolver } = vi.hoisted(() => ({ idResolver: vi.fn() })); +vi.mock('vite', () => ({ createIdResolver: () => idResolver })); + function parseJS(code: string) { return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; } @@ -562,6 +565,84 @@ describe('wranglerConfigPath option', () => { }); }); +describe('package specifier as wrangler `main`', () => { + const MAIN = 'vinext/server/fetch-handler'; + const code = 'export default { fetch() { return new Response("ok"); } };'; + const ssr = { name: 'ssr' }; + + beforeEach(() => { + idResolver.mockReset(); + }); + + function createPlugin(files: Record = {}) { + const dir = writeTempDir({ 'wrangler.jsonc': JSON.stringify({ main: MAIN }), ...files }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const entryPath = join(dir, 'node_modules/vinext/dist/server/fetch-handler.js'); + idResolver.mockImplementation(async (_env: unknown, id: string) => (id === MAIN ? entryPath : undefined)); + const warn = vi.fn(); + const tx = (id: string, environment = ssr) => + plugin.transform.call({ parse: (c: string) => parseJS(c), warn, environment }, code, id); + + return { tx, entryPath, dir, warn }; + } + + it('wraps the module the specifier resolves to', async () => { + const { tx, entryPath, dir } = createPlugin(); + + const result = await tx(entryPath); + expect(idResolver).toHaveBeenCalledWith(ssr, MAIN, join(dir, 'wrangler.jsonc')); + expect(result.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY_DEFAULT_EXPORT__ = { fetch() { return new Response("ok"); } };', + 'export default __SENTRY__.withSentry(() => undefined, __SENTRY_DEFAULT_EXPORT__);', + '', + ].join('\n'), + ); + }); + + it('resolves the specifier once per environment', async () => { + const { tx, dir } = createPlugin(); + + await tx(join(dir, 'a.js')); + await tx(join(dir, 'b.js')); + await tx(join(dir, 'a.js'), { name: 'rsc' }); + expect(idResolver).toHaveBeenCalledTimes(2); + expect(idResolver).toHaveBeenCalledWith({ name: 'rsc' }, MAIN, join(dir, 'wrangler.jsonc')); + }); + + it('does not treat the specifier as a path relative to the config', async () => { + const { tx, dir } = createPlugin(); + + expect(await tx(join(dir, MAIN))).toBeUndefined(); + expect(await tx(join(dir, `${MAIN}.js`))).toBeUndefined(); + }); + + it('imports instrument.server.* from beside the wrangler config', async () => { + const { tx, entryPath } = createPlugin({ + 'instrument.server.ts': 'export default (env) => ({ dsn: env.SENTRY_DSN });', + }); + + const result = await tx(entryPath); + expect(result.code).toContain("import __SENTRY_OPTIONS_CALLBACK__ from '../../../../instrument.server.ts';"); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS_CALLBACK__,'); + }); + + it('warns once when the specifier does not resolve', async () => { + const { tx, dir, warn } = createPlugin(); + idResolver.mockResolvedValue(undefined); + + expect(await tx(join(dir, 'a.js'))).toBeUndefined(); + expect(await tx(join(dir, 'b.js'))).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[sentry] Could not resolve the wrangler `main` "vinext/server/fetch-handler". Auto-instrumentation disabled.', + ); + }); +}); + // --------------------------------------------------------------------------- // instrument.server.* auto-detection (config from a conventional module) // --------------------------------------------------------------------------- diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 7e26dd77465b..92396bf21bae 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { parse } from 'acorn'; import { describe, expect, it } from 'vitest'; import { applyAutoInstrumentTransforms, type ClassWrapperKind, type TransformContext } from '../../src/vite/transform'; @@ -75,6 +79,161 @@ describe('default export wrapping', () => { expect(result.code).toContain('__SENTRY__.withSentry('); }); + it('wraps a local binding exported as default by a specifier', () => { + const code = ['var server_default = { fetch: handle };', 'export { server_default as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'var server_default = { fetch: handle };', + 'var __SENTRY_DEFAULT_EXPORT__ = server_default;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), server_default);', + ].join('\n'), + ); + }); + + it('binds the default export after a local declared below the export', () => { + const code = ['export { handler as default };', 'const handler = { fetch() {} };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'const handler = { fetch() {} };', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('binds the default export after a function declaration', () => { + const code = ['function handler() {}', 'export { handler as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'function handler() {}', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('binds the default export after the import of an imported local', () => { + const code = ["import handler from './handler';", 'export { handler as default };'].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import handler from './handler';", + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('keeps the default export readable from a circular import during module evaluation', () => { + // `b` reads the entry's default export while the entry is still evaluating, as bundled code can. + const entry = [ + "import { readDefault } from './b.mjs';", + 'var server_default = { fetch() {} };', + 'console.log(readDefault());', + 'export { server_default as default };', + ].join('\n'); + const transformed = transform(entry, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => handler };', + ); + + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-cycle-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync( + join(dir, 'b.mjs'), + "import handler from './a.mjs';\nexport function readDefault() { return typeof handler.fetch; }\n", + ); + // Plain Node: Vitest's module runner does not evaluate import cycles like native ESM does. + expect(execFileSync(process.execPath, [join(dir, 'a.mjs')], { encoding: 'utf8' }).trim()).toBe('function'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + /** + * Runs `a.mjs` transformed, under plain Node, with a `withSentry` stub that marks each method it + * finds as instrumented, and returns what `main.mjs` prints. + */ + function runInstrumented(entry: string): string { + const transformed = transform(entry, ctx)!.code.replace( + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const __SENTRY__ = { withSentry: (_options, handler) => { for (const key of Object.keys(handler)) handler[key].instrumented = true; return handler; } };', + ); + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-late-')); + try { + writeFileSync(join(dir, 'a.mjs'), transformed); + writeFileSync( + join(dir, 'main.mjs'), + "import handler from './a.mjs';\nconsole.log(String(handler?.fetch?.instrumented));\n", + ); + return execFileSync(process.execPath, [join(dir, 'main.mjs')], { encoding: 'utf8' }).trim(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it('wraps a default export assigned after its declaration', () => { + const entry = ['let handler;', 'handler = { fetch() {} };', 'export { handler as default };'].join('\n'); + expect(runInstrumented(entry)).toBe('true'); + }); + + it('wraps a method added to the default export after its declaration', () => { + const entry = ['const handler = {};', 'handler.fetch = function () {};', 'export { handler as default };'].join( + '\n', + ); + expect(runInstrumented(entry)).toBe('true'); + }); + + it('leaves sibling specifiers of a default specifier export untouched', () => { + const code = [ + 'const helper = 1;', + 'const handler = { fetch() {} };', + 'export { helper, handler as default };', + ].join('\n'); + + expect(transform(code, ctx)!.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + 'const helper = 1;', + 'const handler = { fetch() {} };', + 'var __SENTRY_DEFAULT_EXPORT__ = handler;', + 'export { __SENTRY_DEFAULT_EXPORT__ as default };', + 'export { helper };', + '__SENTRY_DEFAULT_EXPORT__ = __SENTRY__.withSentry((env) => ({}), handler);', + ].join('\n'), + ); + }); + + it('skips a hand-wrapped `export const` exported as default by a specifier', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export const handler = withSentry((env) => ({}), { fetch() {} });', + 'export { handler as default };', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('skips a hand-wrapped local exported as default by a specifier', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'const handler = withSentry((env) => ({}), { fetch() {} });', + 'export { handler as default };', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + it('uses custom options callback', () => { const custom: TransformContext = { classWrappers: doWrappers(), @@ -500,6 +659,22 @@ describe('WorkerEntrypoint class wrapping (structural)', () => { expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); }); + it.each([ + ['after', ['export class AdminEntry extends WorkerEntrypoint {}', 'export { AdminEntry as default };']], + ['before', ['export { AdminEntry as default };', 'export class AdminEntry extends WorkerEntrypoint {}']], + ])('shares one wrapper between `export class` and a default specifier %s it', (_order, lines) => { + const code = ["import { WorkerEntrypoint } from 'cloudflare:workers';", ...lines].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code.match(/withSentry\(/g)).toHaveLength(1); + // The wrapper follows the class, so it never reads the class before it is set. + expect(result.code.indexOf('const __SENTRY_WRAPPED_AdminEntry__')).toBeGreaterThan( + result.code.indexOf('class AdminEntry extends WorkerEntrypoint {}'), + ); + expect(result.code).toContain('export { __SENTRY_WRAPPED_AdminEntry__ as default };'); + expect(result.code).toContain('export { __SENTRY_WRAPPED_AdminEntry__ as AdminEntry };'); + }); + it('wraps a class extending an aliased WorkerEntrypoint import', () => { const code = [ "import { WorkerEntrypoint as WE } from 'cloudflare:workers';", diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts index 9d8612146b60..87396151e929 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -41,6 +41,14 @@ describe('resolveWranglerConfig', () => { expect(result!.config.durableObjects).toEqual([{ name: 'MY_DO', className: 'MyDurableObject' }]); }); + it('keeps a package specifier `main` unchanged', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': JSON.stringify({ main: '@astrojs/cloudflare/entrypoints/server' }), + }); + + expect(resolveWranglerConfig(dir)!.config.main).toBe('@astrojs/cloudflare/entrypoints/server'); + }); + it('parses wrangler.json', () => { const dir = writeTempDir({ 'wrangler.json': JSON.stringify({ From 0ed4606d53844053bef9cd9f59489163677b5163 Mon Sep 17 00:00:00 2001 From: isaacs Date: Wed, 14 Oct 2026 22:43:37 -0700 Subject: [PATCH 33/37] fix(sveltekit): add op and origin to Kit's `sveltekit.endpoint` span (#25376) SvelteKit 3.1.0 emits a `sveltekit.endpoint` span for `+server` handlers when server tracing is on. The SDK did not know the span, so it went out with origin `manual` and no op, unlike Kit's other spans. It also broke the `sveltekit-3` e2e test, which counts the child spans of a page that fetches a `+server` route. The span now gets op `function` and origin `auto.function.sveltekit.endpoint`. The origin follows the trace origin format and the other Kit span origins (`auto.function.sveltekit.load`, `auto.function.sveltekit.action`). Its part keeps the span apart from the SDK's own `wrapServerRouteWithSentry` span (`auto.function.sveltekit`), which can now appear next to it. Under span streaming, the span also gets `sentry.description`, as Kit's other spans do, because the `function` description template needs `code.function.name`, which Kit does not set. Re: sveltejs/kit#17325 Co-authored-by: Claude Opus 5.5 (1M context) --- .../sveltekit-3/tests/tracing.server.test.ts | 15 ++++++++++++++- .../server-common/integrations/svelteKitSpans.ts | 2 ++ .../integrations/svelteKitSpans.test.ts | 2 ++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts index 3e5d13e187e6..f92dfad104f4 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/tests/tracing.server.test.ts @@ -26,7 +26,7 @@ test('server pageload request span has nested request span for sub request', asy const spans = getSegmentChildSpans(serverTraceSpans, serverSpan); - expect(spans).toHaveLength(6); + expect(spans).toHaveLength(7); expect(spans).toEqual( expect.arrayContaining([ @@ -102,6 +102,19 @@ test('server pageload request span has nested request span for sub request', asy 'http.route': { value: '/api/users', type: 'string' }, }), }), + + // sub request `+server` handler span (emitted by Kit since 3.1.0): + expect.objectContaining({ + name: 'sveltekit.endpoint', + status: 'ok', + attributes: expect.objectContaining({ + 'sentry.op': { value: 'function', type: 'string' }, + 'sentry.origin': { value: 'auto.function.sveltekit.endpoint', type: 'string' }, + 'sentry.description': { value: 'sveltekit.endpoint', type: 'string' }, + 'http.route': { value: '/api/users', type: 'string' }, + 'http.method': { value: 'GET', type: 'string' }, + }), + }), ]), ); }); diff --git a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts index 3d48b7e80ea8..78081dfaacb4 100644 --- a/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts +++ b/packages/sveltekit/src/server-common/integrations/svelteKitSpans.ts @@ -84,6 +84,8 @@ function _getKitSpanOrigin(spanName: string | undefined): SpanOrigin | undefined return 'auto.function.sveltekit.load'; case 'sveltekit.form_action': return 'auto.function.sveltekit.action'; + case 'sveltekit.endpoint': + return 'auto.function.sveltekit.endpoint'; case 'sveltekit.remote.call': return 'auto.rpc.sveltekit.remote'; case 'sveltekit.handle.root': diff --git a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts index 2c53de829a3e..5d8d2d7c0d69 100644 --- a/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts +++ b/packages/sveltekit/test/server-common/integrations/svelteKitSpans.test.ts @@ -56,6 +56,7 @@ describe('svelteKitSpansIntegration', () => { ['sveltekit.resolve', 'function', 'auto.http.sveltekit'], ['sveltekit.load', 'function', 'auto.function.sveltekit.load'], ['sveltekit.form_action', 'function', 'auto.function.sveltekit.action'], + ['sveltekit.endpoint', 'function', 'auto.function.sveltekit.endpoint'], ['sveltekit.remote.call', 'function', 'auto.rpc.sveltekit.remote'], ['sveltekit.handle.sequenced.0', 'function', 'auto.function.sveltekit.handle'], ['sveltekit.handle.sequenced.myHandler', 'function', 'auto.function.sveltekit.handle'], @@ -194,6 +195,7 @@ describe('svelteKitSpansIntegration', () => { ['sveltekit.resolve', 'function', 'auto.http.sveltekit'], ['sveltekit.load', 'function', 'auto.function.sveltekit.load'], ['sveltekit.form_action', 'function', 'auto.function.sveltekit.action'], + ['sveltekit.endpoint', 'function', 'auto.function.sveltekit.endpoint'], ['sveltekit.remote.call', 'function', 'auto.rpc.sveltekit.remote'], ['sveltekit.handle.sequenced.0', 'function', 'auto.function.sveltekit.handle'], ['sveltekit.handle.sequenced.myHandler', 'function', 'auto.function.sveltekit.handle'], From e54e577ded87006c778bde1cb6a78f1dd043922f Mon Sep 17 00:00:00 2001 From: "javascript-sdk-gitflow[bot]" <255134079+javascript-sdk-gitflow[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:47:39 +0200 Subject: [PATCH 34/37] chore: Add external contributor to CHANGELOG.md (#25375) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #25246 Co-authored-by: timfish <1150298+timfish@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5571f47fb4eb..6e180a27b0b9 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 @kunal649. Thank you for your contribution! + ### Important Changes - **feat(bundler-plugins): Release `@sentry/webpack-plugin`, `@sentry/vite-plugin`, `@sentry/rollup-plugin` and `@sentry/esbuild-plugin` from the SDK repository ([#25095](https://github.com/getsentry/sentry-javascript/pull/25095))** From 71f1ef954dd1cd1168e21c4a84a5d52f8b7943a9 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 15 Oct 2026 09:17:18 +0200 Subject: [PATCH 35/37] test(e2e): Inherit root Volta config in all e2e test apps (#25369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most e2e apps that pinned their own Node version did so to run on something newer than the old Node 20 base. Since #24343 moved the root to Node 24.21.0, those pins are either redundant or now *older* than the root (e.g. the `24.15.0` pins), so every app now simply extends the root Volta config. This also drops the Node 20 pin that #24343 added to `react-send-to-sentry` as a stopgap, since it passes on Node 24 now. Two overrides remain: - pnpm `11.10.0` in `nuxt-5` and `nuxt-5-sourcemaps`, which was bumped deliberately in #22017. - Node `20.19.5` in `tsx-express`. On Node >= 24.13 the SDK injects instrumentation through `Module.registerHooks`, whose `load` hook tsx's CJS loader bypasses (it replaces `Module._extensions` and calls `_compile` directly), so Express is never instrumented. #25351 fixes this; the pin can go once it lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../e2e-tests/test-applications/angular-21/package.json | 1 - .../e2e-tests/test-applications/angular-22/package.json | 1 - dev-packages/e2e-tests/test-applications/astro-6/package.json | 1 - .../test-applications/astro-7-sourcemaps/package.json | 1 - .../e2e-tests/test-applications/astro-7-static/package.json | 1 - dev-packages/e2e-tests/test-applications/astro-7/package.json | 1 - .../e2e-tests/test-applications/browser-bfcache/package.json | 4 +--- .../e2e-tests/test-applications/browser-mfe-vite/package.json | 4 +--- .../test-applications/browser-webworker-vite/package.json | 4 +--- .../e2e-tests/test-applications/cloudflare-agent/package.json | 1 - .../cloudflare-flue-send-to-sentry/package.json | 1 - .../test-applications/cloudflare-mastra/package.json | 1 - .../e2e-tests/test-applications/cloudflare-think/package.json | 1 - .../cloudflare-workers-send-to-sentry/package.json | 1 - .../e2e-tests/test-applications/elysia-node/package.json | 3 +-- .../e2e-tests/test-applications/gen-ai-libraries/package.json | 1 - .../e2e-tests/test-applications/hono-4-legacy/package.json | 1 - dev-packages/e2e-tests/test-applications/hono-4/package.json | 1 - .../node-anthropic-send-to-sentry/package.json | 1 - .../e2e-tests/test-applications/node-eve/package.json | 1 - .../e2e-tests/test-applications/node-fastify-6/package.json | 1 - .../e2e-tests/test-applications/node-flue/package.json | 1 - .../e2e-tests/test-applications/node-mastra/package.json | 1 - .../test-applications/node-openai-send-to-sentry/package.json | 1 - .../e2e-tests/test-applications/node-pi-durable/package.json | 1 - dev-packages/e2e-tests/test-applications/nuxt-3/package.json | 3 +-- .../test-applications/nuxt-4-cloudflare/package.json | 1 - .../test-applications/nuxt-4-sourcemaps/package.json | 3 +-- .../e2e-tests/test-applications/nuxt-4-static/package.json | 3 +-- .../react-router-7-framework-spa/package.json | 3 +-- .../test-applications/react-router-8-framework/package.json | 3 +-- .../test-applications/react-send-to-sentry/package.json | 1 - .../e2e-tests/test-applications/remix-v3/package.json | 3 +-- .../sveltekit-3-cloudflare-workers/package.json | 1 - .../test-applications/sveltekit-3-sourcemaps/package.json | 1 - .../e2e-tests/test-applications/sveltekit-3/package.json | 1 - 36 files changed, 10 insertions(+), 49 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/angular-21/package.json b/dev-packages/e2e-tests/test-applications/angular-21/package.json index 86b8fd27796b..fd9a6cd181e1 100644 --- a/dev-packages/e2e-tests/test-applications/angular-21/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-21/package.json @@ -47,7 +47,6 @@ "typescript": "~6.0.0" }, "volta": { - "node": "22.22.3", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/angular-22/package.json b/dev-packages/e2e-tests/test-applications/angular-22/package.json index d96d2a0f2016..4f8edc6fab7b 100644 --- a/dev-packages/e2e-tests/test-applications/angular-22/package.json +++ b/dev-packages/e2e-tests/test-applications/angular-22/package.json @@ -47,7 +47,6 @@ "typescript": "~6.0.0" }, "volta": { - "node": "22.22.3", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/astro-6/package.json b/dev-packages/e2e-tests/test-applications/astro-6/package.json index 4261956d39d5..68a872f29f37 100644 --- a/dev-packages/e2e-tests/test-applications/astro-6/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-6/package.json @@ -19,7 +19,6 @@ "astro": "^6.2.0" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json index 20e1e8f4b37b..f31dd0e34bce 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7-sourcemaps/package.json @@ -21,7 +21,6 @@ "@types/node": "^22.20.0" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" }, "sentryTest": { 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 ce0452bf7e37..f10815c6a9a7 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 @@ -24,7 +24,6 @@ "mysql": "^2.18.1" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" }, "sentryTest": { 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 33126242d1f6..10618dfc3227 100644 --- a/dev-packages/e2e-tests/test-applications/astro-7/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-7/package.json @@ -24,7 +24,6 @@ "mysql": "^2.18.1" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json index 7c1e27f8e43a..9a2bf4d39eca 100644 --- a/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-bfcache/package.json @@ -22,8 +22,6 @@ "vite": "^7.3.2" }, "volta": { - "node": "20.19.2", - "yarn": "1.22.22", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json index da4e9cba0a8c..9cdc87c04d8d 100644 --- a/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-mfe-vite/package.json @@ -18,8 +18,6 @@ "concurrently": "^8.2.2" }, "volta": { - "node": "20.19.2", - "yarn": "1.22.22", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json index 0aa7157512c5..58fae8fb3df3 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/package.json @@ -22,8 +22,6 @@ "@sentry/vite-plugin": "^5.4.1" }, "volta": { - "node": "20.19.2", - "yarn": "1.22.22", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json index 0d25c711360a..26b44523be92 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-agent/package.json @@ -45,7 +45,6 @@ "optional": true }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json index 7f7be894c7e6..d24873e57136 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-flue-send-to-sentry/package.json @@ -33,7 +33,6 @@ "wrangler": "^4.114.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json index 9b45bb26c8bb..c8880b0e3bf5 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-mastra/package.json @@ -34,7 +34,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json index 0bb10a6be700..4adbc345fceb 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-think/package.json @@ -34,7 +34,6 @@ "ws": "^8.18.3" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json index 3bebd9caebf1..1c2069e8c9d4 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-workers-send-to-sentry/package.json @@ -26,7 +26,6 @@ "wrangler": "^4.114.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/elysia-node/package.json b/dev-packages/e2e-tests/test-applications/elysia-node/package.json index e1ca655b6d02..2cf414460220 100644 --- a/dev-packages/e2e-tests/test-applications/elysia-node/package.json +++ b/dev-packages/e2e-tests/test-applications/elysia-node/package.json @@ -20,7 +20,6 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json", - "node": "24.11.0" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json index 89364cbbe423..2ea5cee00536 100644 --- a/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json +++ b/dev-packages/e2e-tests/test-applications/gen-ai-libraries/package.json @@ -50,7 +50,6 @@ ] }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json index 2c0d72b3b1d6..c707ee5f2608 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4-legacy/package.json @@ -30,7 +30,6 @@ "wrangler": "^4.61.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/hono-4/package.json b/dev-packages/e2e-tests/test-applications/hono-4/package.json index 99b2e1ce111e..4efa27a21f54 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/package.json +++ b/dev-packages/e2e-tests/test-applications/hono-4/package.json @@ -35,7 +35,6 @@ "wrangler": "^4.114.0" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { 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 index ef1b771dd545..2f194ff478dd 100644 --- 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 @@ -34,7 +34,6 @@ ] }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-eve/package.json b/dev-packages/e2e-tests/test-applications/node-eve/package.json index a82ea0bedbbc..0165ca0f8bf2 100644 --- a/dev-packages/e2e-tests/test-applications/node-eve/package.json +++ b/dev-packages/e2e-tests/test-applications/node-eve/package.json @@ -41,7 +41,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json index f9c37b7f636c..cf104f24b932 100644 --- a/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json +++ b/dev-packages/e2e-tests/test-applications/node-fastify-6/package.json @@ -22,7 +22,6 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "node": "22.22.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-flue/package.json b/dev-packages/e2e-tests/test-applications/node-flue/package.json index 7f6afaeff6b8..f3876f611d92 100644 --- a/dev-packages/e2e-tests/test-applications/node-flue/package.json +++ b/dev-packages/e2e-tests/test-applications/node-flue/package.json @@ -35,7 +35,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-mastra/package.json b/dev-packages/e2e-tests/test-applications/node-mastra/package.json index a00ed28ea209..686eae393ed3 100644 --- a/dev-packages/e2e-tests/test-applications/node-mastra/package.json +++ b/dev-packages/e2e-tests/test-applications/node-mastra/package.json @@ -37,7 +37,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json index 5b1626a09130..dcb65009d4f8 100644 --- a/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/node-openai-send-to-sentry/package.json @@ -36,7 +36,6 @@ ] }, "volta": { - "node": "24.15.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json index 33bc22b4e00f..8be685f10351 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json @@ -27,7 +27,6 @@ "node": "24.x" }, "volta": { - "node": "24.15.0", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index 73e9d6386b54..7213c3eaf121 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -33,7 +33,6 @@ ] }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json index 1a008da130ee..9947f20ef982 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-cloudflare/package.json @@ -22,7 +22,6 @@ "wrangler": "^4.72.0" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json index 98beb8756ea7..f417c74e5448 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/package.json @@ -25,8 +25,7 @@ "typescript": "~5.0.0" }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json index 9e7ef0d268ca..26ba31d66c7f 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-static/package.json @@ -30,8 +30,7 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json", - "node": "22.20.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json index 0689caa3bbba..6edc8f0335d8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/package.json @@ -51,7 +51,6 @@ ] }, "volta": { - "extends": "../../package.json", - "node": "22.17.0" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json index 4cf3a981b6be..552e505b3457 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-8-framework/package.json @@ -65,8 +65,7 @@ ] }, "volta": { - "extends": "../../package.json", - "node": "22.22.0" + "extends": "../../package.json" }, "sentryTest": { "variants": [ diff --git a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json index 3cfd16a29ae2..cefd05e58db5 100644 --- a/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json +++ b/dev-packages/e2e-tests/test-applications/react-send-to-sentry/package.json @@ -45,7 +45,6 @@ "serve": "14.0.1" }, "volta": { - "node": "20.19.5", "extends": "../../package.json" }, "sentryTest": { diff --git a/dev-packages/e2e-tests/test-applications/remix-v3/package.json b/dev-packages/e2e-tests/test-applications/remix-v3/package.json index 5cb015c44c9a..6e27d54be58b 100644 --- a/dev-packages/e2e-tests/test-applications/remix-v3/package.json +++ b/dev-packages/e2e-tests/test-applications/remix-v3/package.json @@ -22,7 +22,6 @@ "typescript": "~5.9.0" }, "volta": { - "node": "24.18.0", - "pnpm": "9.15.9" + "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json index 29b9ccc3e3f5..1844e0e7b5cd 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-cloudflare-workers/package.json @@ -29,7 +29,6 @@ "wrangler": "^4.118.0" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json index 93da7029d773..a58195028424 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3-sourcemaps/package.json @@ -24,7 +24,6 @@ "vite": "^8.0.12" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" } } diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json index 6bd39bf23423..7bf8da3e4e00 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-3/package.json @@ -29,7 +29,6 @@ "vite": "^8.0.12" }, "volta": { - "node": "22.20.0", "extends": "../../package.json" }, "type": "module" From a309cf8ea6ffe4f5ba2523137184138c16e39906 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 15 Oct 2026 09:17:42 +0200 Subject: [PATCH 36/37] test(bundler-plugins): Disable CLI telemetry in the CLI contract tests (#25308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `SentryCliAdapter` contract tests in `bundler-plugins` occasionally time out on the first test. Every command run through the real CLI also sends the CLI's own telemetry to `o1.ingest.us.sentry.io` and waits up to ~3s for it to flush. With that endpoint unreachable, each test takes ~3s instead of ~80ms, which leaves the first test, which also pays the CLI's cold start, too little headroom under the 5s timeout on a slow runner. Setting `SENTRY_CLI_NO_TELEMETRY` for the suite brings every test back to a few milliseconds, and the tests no longer send anything to sentry.io. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- packages/bundler-plugins/test/core/cli-contract.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/bundler-plugins/test/core/cli-contract.test.ts b/packages/bundler-plugins/test/core/cli-contract.test.ts index 2786e9a868eb..f6c1a1d4c50d 100644 --- a/packages/bundler-plugins/test/core/cli-contract.test.ts +++ b/packages/bundler-plugins/test/core/cli-contract.test.ts @@ -89,6 +89,10 @@ beforeAll(() => { process.env['SENTRY_CONFIG_DIR'] = join(workDir, 'sentry-home'); mkdirSync(process.env['SENTRY_CONFIG_DIR']); + // Every command otherwise waits up to ~3s to flush its own telemetry to sentry.io, which makes + // the suite depend on that endpoint being reachable and pushes slow CI runs past the timeout. + process.env['SENTRY_CLI_NO_TELEMETRY'] = '1'; + // The mock server writes its recording relative to cwd. previousCwd = process.cwd(); process.chdir(workDir); @@ -100,6 +104,7 @@ afterAll(() => { server.close(); process.chdir(previousCwd); delete process.env['SENTRY_CONFIG_DIR']; + delete process.env['SENTRY_CLI_NO_TELEMETRY']; rmSync(workDir, { recursive: true, force: true }); }); From 30ec7548ee289d50b13e997142607229aba8957b Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Thu, 15 Oct 2026 11:30:06 +0200 Subject: [PATCH 37/37] meta(changelog): Update changelog for 11.8.0 --- CHANGELOG.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e180a27b0b9..66ad8165bd35 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 @kunal649. Thank you for your contribution! +## 11.8.0 ### Important Changes @@ -17,6 +17,55 @@ Work in this release was contributed by @kunal649. Thank you for your contributi - The plugins use the `sentry` CLI SDK instead of `@sentry/cli`. Sentry self-hosted 26.4.2 or higher is supported. Lower versions may continue to work. - Deploys that the plugins create on Vercel use the value of `VERCEL_TARGET_ENV` (`production`, `preview` or a custom name) as their environment, instead of `vercel-production` or `vercel-preview`. To keep the old value, set `release.deploy.env`. +- **feat(server-utils): Instrument the OpenAI decisions API as `gen_ai.decide` spans ([#25285](https://github.com/getsentry/sentry-javascript/pull/25285))** + + OpenAI `decisions.create` calls now create `gen_ai.decide` spans. + +### Other Changes + +- feat(core): Support SuppressedError in LinkedErrors integration ([#25246](https://github.com/getsentry/sentry-javascript/pull/25246)) +- fix(astro): Delete generated source maps for static output ([#25310](https://github.com/getsentry/sentry-javascript/pull/25310)) +- fix(astro): Upload source maps of Astro 7's prerender build ([#25309](https://github.com/getsentry/sentry-javascript/pull/25309)) +- fix(browser): Apply `urlQueryParams` to the Referer header ([#25324](https://github.com/getsentry/sentry-javascript/pull/25324)) +- fix(cloudflare): auto-instrument when wrangler `main` is a package ([#25343](https://github.com/getsentry/sentry-javascript/pull/25343)) +- fix(hono): Expose runtime-specific SDK options ([#25290](https://github.com/getsentry/sentry-javascript/pull/25290)) +- fix(node): Instrument Prisma 8 CollectionBase from rc.15 ([#25301](https://github.com/getsentry/sentry-javascript/pull/25301)) +- fix(nuxt): Skip 3xx/4xx Nuxt errors in the vue:error hook ([#25341](https://github.com/getsentry/sentry-javascript/pull/25341)) +- fix(server-utils): Read pi-durable 1.2.0 tool results from `output` ([#25312](https://github.com/getsentry/sentry-javascript/pull/25312)) +- fix(server-utils): Transform modules once when two Sentry Vite plugins add orchestrion ([#25043](https://github.com/getsentry/sentry-javascript/pull/25043)) +- fix(sveltekit): add op and origin to Kit's `sveltekit.endpoint` span ([#25376](https://github.com/getsentry/sentry-javascript/pull/25376)) +- fix(tanstackstart-react): Keep parametrized route names in Nitro 3 builds ([#25307](https://github.com/getsentry/sentry-javascript/pull/25307)) +- fix(tanstackstart-react): Rewrite source paths of split route modules ([#25361](https://github.com/getsentry/sentry-javascript/pull/25361)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#25375](https://github.com/getsentry/sentry-javascript/pull/25375)) +- ci: Install the pnpm version that Volta pins ([#25287](https://github.com/getsentry/sentry-javascript/pull/25287)) +- license: Add cli FSL notice to package licenses ([#25314](https://github.com/getsentry/sentry-javascript/pull/25314)) +- ref(core): use shared MCP attribute conventions ([#25059](https://github.com/getsentry/sentry-javascript/pull/25059)) +- ref(tanstackstart-react): Reuse shared component annotation options ([#25313](https://github.com/getsentry/sentry-javascript/pull/25313)) +- test(browser): Keep whitespace text out of the handlers-lcp page ([#25280](https://github.com/getsentry/sentry-javascript/pull/25280)) +- test(bun): Restore @sentry/core spies after the Bun.serve tests ([#25276](https://github.com/getsentry/sentry-javascript/pull/25276)) +- test(bundler-plugins): Disable CLI telemetry in the CLI contract tests ([#25308](https://github.com/getsentry/sentry-javascript/pull/25308)) +- test(e2e): Add node-openai-decisions-send-to-sentry test app ([#25331](https://github.com/getsentry/sentry-javascript/pull/25331)) +- test(e2e): Add node-openai-send-to-sentry test app ([#24824](https://github.com/getsentry/sentry-javascript/pull/24824)) +- test(e2e): Add nuxt-5-sourcemaps test ([#25268](https://github.com/getsentry/sentry-javascript/pull/25268)) +- test(e2e): Inherit root Volta config in all e2e test apps ([#25369](https://github.com/getsentry/sentry-javascript/pull/25369)) +- test(e2e): Use an external host address for the Lambda DSN on loopback hostnames ([#25282](https://github.com/getsentry/sentry-javascript/pull/25282)) +- test(nextjs): Run nextjs-16-tunnel on Cloudflare ([#25161](https://github.com/getsentry/sentry-javascript/pull/25161)) +- test(nextjs): Switch DEBUG_BUILD with a getter instead of re-importing the SDK ([#25279](https://github.com/getsentry/sentry-javascript/pull/25279)) +- test(node): Await the child exit in the "should exit" integration tests ([#25277](https://github.com/getsentry/sentry-javascript/pull/25277)) +- test(node): Cover ioredis v6 in the diagnostics_channel suite ([#25300](https://github.com/getsentry/sentry-javascript/pull/25300)) +- test(node): Cover LangChain model calls made from a tool without config ([#25326](https://github.com/getsentry/sentry-javascript/pull/25326)) +- test(node): Log slow and failed Docker Compose starts in integration tests ([#25278](https://github.com/getsentry/sentry-javascript/pull/25278)) +- test(node): Unflake captureMessage with_level test ([#25358](https://github.com/getsentry/sentry-javascript/pull/25358)) +- test(sveltekit): Pin rollup to 4.64.5 ([#25328](https://github.com/getsentry/sentry-javascript/pull/25328)) + +
+ +Work in this release was contributed by @kunal649 and @mketiku. Thank you for your contributions! + ## 11.7.0 ### Important Changes