From 2ec09c9ca3f47edfa263e68a4aaf9a48aaf06bca Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 13 Aug 2025 11:42:24 +0200 Subject: [PATCH 01/16] docs(nuxt): Remove beta notice (#17400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the beta notice from the readme. I also changed some logs as it's actually possible to enable tracing during dev mode - thankfully, the command prints you the correct path to the Sentry server config 🙌 --- packages/nuxt/README.md | 24 ++++++++---------------- packages/nuxt/src/module.ts | 23 +++++++++++++---------- 2 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index 1513d8a5f6d9..eae1b36b03e5 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -4,15 +4,13 @@

-# Official Sentry SDK for Nuxt (BETA) +# Official Sentry SDK for Nuxt [![npm version](https://img.shields.io/npm/v/@sentry/nuxt.svg)](https://www.npmjs.com/package/@sentry/nuxt) [![npm dm](https://img.shields.io/npm/dm/@sentry/nuxt.svg)](https://www.npmjs.com/package/@sentry/nuxt) [![npm dt](https://img.shields.io/npm/dt/@sentry/nuxt.svg)](https://www.npmjs.com/package/@sentry/nuxt) -This SDK is in **Beta**. The API is stable but updates may include minor changes in behavior. Please reach out on -[GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns. This -SDK is for [Nuxt](https://nuxt.com/). If you're using [Vue](https://vuejs.org/) see our +This SDK is for [Nuxt](https://nuxt.com/). If you're using [Vue](https://vuejs.org/) see our [Vue SDK here](https://github.com/getsentry/sentry-javascript/tree/develop/packages/vue). ## Links @@ -21,17 +19,13 @@ SDK is for [Nuxt](https://nuxt.com/). If you're using [Vue](https://vuejs.org/) ## Compatibility -The minimum supported version of Nuxt is `3.0.0`. +The minimum supported version of Nuxt is `3.7.0` (`3.14.0+` recommended). ## General This package is a wrapper around `@sentry/node` for the server and `@sentry/vue` for the client side, with added functionality related to Nuxt. -**Limitations:** - -- Server monitoring is not available during development mode (`nuxt dev`) - ## Manual Setup ### 1. Prerequisites & Installation @@ -112,20 +106,18 @@ Sentry.init({ ## Uploading Source Maps To upload source maps, you have to enable client source maps in your `nuxt.config.ts`. Then, you add your project -settings to the `sentry.sourceMapsUploadOptions` of your `nuxt.config.ts`: +settings to `sentry` in your `nuxt.config.ts`: ```javascript // nuxt.config.ts export default defineNuxtConfig({ - sourcemap: { client: true }, + sourcemap: { client: 'hidden' }, modules: ['@sentry/nuxt/module'], sentry: { - sourceMapsUploadOptions: { - org: 'your-org-slug', - project: 'your-project-slug', - authToken: process.env.SENTRY_AUTH_TOKEN, - }, + org: 'your-org-slug', + project: 'your-project-slug', + authToken: process.env.SENTRY_AUTH_TOKEN, }, }); ``` diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 0e6d92636246..5e1343b1ebaa 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -112,15 +112,6 @@ export default defineNuxtModule({ nuxt.hooks.hook('nitro:init', nitro => { if (serverConfigFile?.includes('.server.config')) { - if (nitro.options.dev) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.log( - '[Sentry] Your application is running in development mode. Note: @sentry/nuxt does not work as expected on the server-side (Nitro). Errors are reported, but tracing does not work.', - ); - }); - } - consoleSandbox(() => { const serverDir = nitro.options.output.serverDir; @@ -154,8 +145,20 @@ export default defineNuxtModule({ consoleSandbox(() => { // eslint-disable-next-line no-console console.log( - `[Sentry] Using your \`${serverConfigFile}\` file for the server-side Sentry configuration. Make sure to add the Node option \`import\` to the Node command where you deploy and/or run your application. This preloads the Sentry configuration at server startup. You can do this via a command-line flag (\`node --import ${serverConfigRelativePath} [...]\`) or via an environment variable (\`NODE_OPTIONS='--import ${serverConfigRelativePath}' node [...]\`).`, + `[Sentry] Using \`${serverConfigFile}\` for server-side Sentry configuration. To activate Sentry on the Nuxt server-side, this file must be preloaded when starting your application. Make sure to add this where you deploy and/or run your application. Read more here: https://docs.sentry.io/platforms/javascript/guides/nuxt/install/.`, ); + + if (nitro.options.dev) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] During development, preload Sentry with the NODE_OPTIONS environment variable: \`NODE_OPTIONS='--import ${serverConfigRelativePath}' nuxt dev\`. The file is generated in the build directory (usually '.nuxt'). If you delete the build directory, run \`nuxt dev\` to regenerate it.`, + ); + } else { + // eslint-disable-next-line no-console + console.log( + `[Sentry] When running your built application, preload Sentry via a command-line flag (\`node --import ${serverConfigRelativePath} [...]\`) or via an environment variable (\`NODE_OPTIONS='--import ${serverConfigRelativePath}' node [...]\`).`, + ); + } }); } } From 05af8d01f8cf3fdcef77d2946e286d759e52400e Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Wed, 13 Aug 2025 16:08:04 +0200 Subject: [PATCH 02/16] fix(node): Fix preloading of instrumentation (#17403) Extracted out of https://github.com/getsentry/sentry-javascript/pull/17371 I noticed that we were not fully consistent in instrumentation IDs for integrations that have multiple instrumentation. The intent is that users can provide the _integration name_ (e.g. `Http`) and it will preload all http instrumentation. To achieve this, I adjusted the preload filter code to look for exact matches as well as `startsWith(`${name}.id`)`. I also adjusted the test to be more declarative and mock/reset stuff properly (this lead to issues in the linked PR, and should generally be a bit cleaner). I also updated all instrumentation IDs to follow this pattern. We should be mindful of following this with new instrumentation we add. --- .cursor/BUGBOT.md | 1 + packages/nestjs/src/integrations/nest.ts | 6 ++--- .../src/integrations/tracing/fastify/index.ts | 9 ++++---- .../node/src/integrations/tracing/redis.ts | 4 ++-- packages/node/src/sdk/initOtel.ts | 6 ++++- packages/node/test/sdk/preload.test.ts | 23 +++++++++++++++++-- .../server/integration/reactRouterServer.ts | 2 +- 7 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index a512d79fa435..d70f36ff6c94 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -40,3 +40,4 @@ Do not flag the issues below if they appear in tests. - If there's no direct span that's wrapping the captured exception, apply a proper `type` value, following the same naming convention as the `SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN` value. - When calling `startSpan`, check if error cases are handled. If flag that it might make sense to try/catch and call `captureException`. +- When calling `generateInstrumentationOnce`, the passed in name MUST match the name of the integration that uses it. If there are more than one instrumentations, they need to follow the pattern `${INSTRUMENTATION_NAME}.some-suffix`. diff --git a/packages/nestjs/src/integrations/nest.ts b/packages/nestjs/src/integrations/nest.ts index 53086b7da302..75dc1f845693 100644 --- a/packages/nestjs/src/integrations/nest.ts +++ b/packages/nestjs/src/integrations/nest.ts @@ -6,15 +6,15 @@ import { SentryNestInstrumentation } from './sentry-nest-instrumentation'; const INTEGRATION_NAME = 'Nest'; -const instrumentNestCore = generateInstrumentOnce('Nest-Core', () => { +const instrumentNestCore = generateInstrumentOnce(`${INTEGRATION_NAME}.Core`, () => { return new NestInstrumentationCore(); }); -const instrumentNestCommon = generateInstrumentOnce('Nest-Common', () => { +const instrumentNestCommon = generateInstrumentOnce(`${INTEGRATION_NAME}.Common`, () => { return new SentryNestInstrumentation(); }); -const instrumentNestEvent = generateInstrumentOnce('Nest-Event', () => { +const instrumentNestEvent = generateInstrumentOnce(`${INTEGRATION_NAME}.Event`, () => { return new SentryNestEventInstrumentation(); }); diff --git a/packages/node/src/integrations/tracing/fastify/index.ts b/packages/node/src/integrations/tracing/fastify/index.ts index fd8894e29a96..65d783eb8be7 100644 --- a/packages/node/src/integrations/tracing/fastify/index.ts +++ b/packages/node/src/integrations/tracing/fastify/index.ts @@ -90,10 +90,11 @@ interface FastifyHandlerOptions { } const INTEGRATION_NAME = 'Fastify'; -const INTEGRATION_NAME_V5 = 'Fastify-V5'; -const INTEGRATION_NAME_V3 = 'Fastify-V3'; -export const instrumentFastifyV3 = generateInstrumentOnce(INTEGRATION_NAME_V3, () => new FastifyInstrumentationV3()); +export const instrumentFastifyV3 = generateInstrumentOnce( + `${INTEGRATION_NAME}.v3`, + () => new FastifyInstrumentationV3(), +); function getFastifyIntegration(): ReturnType | undefined { const client = getClient(); @@ -135,7 +136,7 @@ function handleFastifyError( } } -export const instrumentFastify = generateInstrumentOnce(INTEGRATION_NAME_V5, () => { +export const instrumentFastify = generateInstrumentOnce(`${INTEGRATION_NAME}.v5`, () => { const fastifyOtelInstrumentationInstance = new FastifyOtelInstrumentation(); const plugin = fastifyOtelInstrumentationInstance.plugin(); diff --git a/packages/node/src/integrations/tracing/redis.ts b/packages/node/src/integrations/tracing/redis.ts index 308c8be29abe..8376c99c1998 100644 --- a/packages/node/src/integrations/tracing/redis.ts +++ b/packages/node/src/integrations/tracing/redis.ts @@ -75,13 +75,13 @@ const cacheResponseHook: RedisResponseCustomAttributeFunction = (span: Span, red span.updateName(truncate(spanDescription, 1024)); }; -const instrumentIORedis = generateInstrumentOnce('IORedis', () => { +const instrumentIORedis = generateInstrumentOnce(`${INTEGRATION_NAME}.IORedis`, () => { return new IORedisInstrumentation({ responseHook: cacheResponseHook, }); }); -const instrumentRedisModule = generateInstrumentOnce('Redis', () => { +const instrumentRedisModule = generateInstrumentOnce(`${INTEGRATION_NAME}.Redis`, () => { return new RedisInstrumentation({ responseHook: cacheResponseHook, }); diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index fc6b02c3830d..ef27be0514c3 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -101,7 +101,11 @@ function getPreloadMethods(integrationNames?: string[]): ((() => void) & { id: s return instruments; } - return instruments.filter(instrumentation => integrationNames.includes(instrumentation.id)); + // We match exact matches of instrumentation, but also match prefixes, e.g. "Fastify.v5" will match "Fastify" + return instruments.filter(instrumentation => { + const id = instrumentation.id; + return integrationNames.some(integrationName => id === integrationName || id.startsWith(`${integrationName}.`)); + }); } /** Just exported for tests. */ diff --git a/packages/node/test/sdk/preload.test.ts b/packages/node/test/sdk/preload.test.ts index 97badc28c9eb..65e61287bd33 100644 --- a/packages/node/test/sdk/preload.test.ts +++ b/packages/node/test/sdk/preload.test.ts @@ -1,10 +1,27 @@ import { debug } from '@sentry/core'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetGlobals } from '../helpers/mockSdkInit'; describe('preload', () => { + beforeEach(() => { + // Mock this to prevent conflicts with other tests + vi.mock('../../src/integrations/tracing', async (importOriginal: () => Promise>) => { + const actual = await importOriginal(); + return { + ...actual, + getOpenTelemetryInstrumentationToPreload: () => [ + Object.assign(vi.fn(), { id: 'Http.sentry' }), + Object.assign(vi.fn(), { id: 'Http' }), + Object.assign(vi.fn(), { id: 'Express' }), + Object.assign(vi.fn(), { id: 'Graphql' }), + ], + }; + }); + }); + afterEach(() => { - vi.resetAllMocks(); debug.disable(); + resetGlobals(); delete process.env.SENTRY_DEBUG; delete process.env.SENTRY_PRELOAD_INTEGRATIONS; @@ -29,6 +46,7 @@ describe('preload', () => { await import('../../src/preload'); + expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Http.sentry instrumentation'); expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Http instrumentation'); expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Express instrumentation'); expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Graphql instrumentation'); @@ -44,6 +62,7 @@ describe('preload', () => { await import('../../src/preload'); + expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Http.sentry instrumentation'); expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Http instrumentation'); expect(logSpy).toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Express instrumentation'); expect(logSpy).not.toHaveBeenCalledWith('Sentry Logger [log]:', '[Sentry] Preloaded Graphql instrumentation'); diff --git a/packages/react-router/src/server/integration/reactRouterServer.ts b/packages/react-router/src/server/integration/reactRouterServer.ts index 89a0443c2382..4625d1cb979e 100644 --- a/packages/react-router/src/server/integration/reactRouterServer.ts +++ b/packages/react-router/src/server/integration/reactRouterServer.ts @@ -5,7 +5,7 @@ import { ReactRouterInstrumentation } from '../instrumentation/reactRouter'; const INTEGRATION_NAME = 'ReactRouterServer'; -const instrumentReactRouter = generateInstrumentOnce('React-Router-Server', () => { +const instrumentReactRouter = generateInstrumentOnce(INTEGRATION_NAME, () => { return new ReactRouterInstrumentation(); }); From 115c3e634785b5fba2e120633e637b1227ec0883 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:42:55 +0200 Subject: [PATCH 03/16] test(nuxt): Don't rely on flushing for lowQualityTransactionFilter (#17406) The test relied on `flush` and if the network functionality of CI does not work 100%, this test produces a timeout and fails. --- packages/nuxt/test/server/sdk.test.ts | 61 ++++++++++++++------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/packages/nuxt/test/server/sdk.test.ts b/packages/nuxt/test/server/sdk.test.ts index 7efe86b84587..626b574612b0 100644 --- a/packages/nuxt/test/server/sdk.test.ts +++ b/packages/nuxt/test/server/sdk.test.ts @@ -1,10 +1,9 @@ -import type { EventProcessor } from '@sentry/core'; -import type { NodeClient } from '@sentry/node'; +import type { Event, EventProcessor } from '@sentry/core'; import * as SentryNode from '@sentry/node'; import { getGlobalScope, Scope, SDK_VERSION } from '@sentry/node'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { init } from '../../src/server'; -import { clientSourceMapErrorFilter } from '../../src/server/sdk'; +import { clientSourceMapErrorFilter, lowQualityTransactionsFilter } from '../../src/server/sdk'; const nodeInit = vi.spyOn(SentryNode, 'init'); @@ -42,41 +41,43 @@ describe('Nuxt Server SDK', () => { expect(init({})).not.toBeUndefined(); }); - describe('lowQualityTransactionsFilter (%s)', () => { - const beforeSendEvent = vi.fn(event => event); - const client = init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }) as NodeClient; - client.on('beforeSendEvent', beforeSendEvent); + describe('lowQualityTransactionsFilter', () => { + const options = { debug: false }; + const filter = lowQualityTransactionsFilter(options); - it.each([ - [ + describe('filters out low quality transactions', () => { + it.each([ 'GET /_nuxt/some_asset.js', 'GET _nuxt/some_asset.js', 'GET /icons/favicon.ico', 'GET /assets/logo.png', 'GET /icons/zones/forest.svg', - ], - ])('filters out low quality transactions', async transaction => { - client.captureEvent({ type: 'transaction', transaction }); - await client!.flush(); - expect(beforeSendEvent).not.toHaveBeenCalled(); + ])('filters out low quality transaction: (%s)', transaction => { + const event = { type: 'transaction' as const, transaction }; + expect(filter(event, {})).toBeNull(); + }); }); - // Nuxt parametrizes routes sometimes in a special way - especially catchAll o.O - it.each(['GET /', 'POST /_server', 'GET /catchAll/:id(.*)*', 'GET /article/:slug()', 'GET /user/:id'])( - 'does not filter out high quality or route transactions (%s)', - async transaction => { - client.captureEvent({ type: 'transaction', transaction }); - await client!.flush(); - expect(beforeSendEvent).toHaveBeenCalledWith( - expect.objectContaining({ - transaction, - }), - expect.any(Object), - ); - }, - ); + describe('keeps high quality transactions', () => { + // Nuxt parametrizes routes sometimes in a special way - especially catchAll o.O + it.each(['GET /', 'POST /_server', 'GET /catchAll/:id(.*)*', 'GET /article/:slug()', 'GET /user/:id'])( + 'does not filter out route transactions (%s)', + transaction => { + const event = { type: 'transaction' as const, transaction }; + expect(filter(event, {})).toEqual(event); + }, + ); + }); + + it('does not filter non-transaction events', () => { + const event = { type: 'error' as const, transaction: 'GET /assets/image.png' } as unknown as Event; + expect(filter(event, {})).toEqual(event); + }); + + it('handles events without transaction property', () => { + const event = { type: 'transaction' as const }; + expect(filter(event, {})).toEqual(event); + }); }); it('registers an event processor', async () => { From f25664bd6b2da4c99d44a6e639b38550a0e0d436 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:44:44 +0200 Subject: [PATCH 04/16] test(solidstart): Don't rely on flushing for lowQualityTransactionFilter (#17408) The test relied on flush and if the network functionality of CI does not work 100%, this test produces a timeout and fails. Similar to this: https://github.com/getsentry/sentry-javascript/pull/17406 --- packages/solidstart/src/server/utils.ts | 39 ++++++----- packages/solidstart/test/server/sdk.test.ts | 78 +++++++++++++-------- 2 files changed, 70 insertions(+), 47 deletions(-) diff --git a/packages/solidstart/src/server/utils.ts b/packages/solidstart/src/server/utils.ts index 1560b254bd22..8276c32da9e0 100644 --- a/packages/solidstart/src/server/utils.ts +++ b/packages/solidstart/src/server/utils.ts @@ -17,25 +17,32 @@ export function isRedirect(error: unknown): boolean { return hasValidLocation && hasValidStatus; } +/** + * Filter function for low quality transactions + * + * Exported only for tests + */ +export function lowQualityTransactionsFilter(options: Options): EventProcessor { + return Object.assign( + (event => { + if (event.type !== 'transaction') { + return event; + } + // Filter out transactions for build assets + if (event.transaction?.match(/^GET \/_build\//)) { + options.debug && debug.log('SolidStartLowQualityTransactionsFilter filtered transaction', event.transaction); + return null; + } + return event; + }) satisfies EventProcessor, + { id: 'SolidStartLowQualityTransactionsFilter' }, + ); +} + /** * Adds an event processor to filter out low quality transactions, * e.g. to filter out transactions for build assets */ export function filterLowQualityTransactions(options: Options): void { - getGlobalScope().addEventProcessor( - Object.assign( - (event => { - if (event.type !== 'transaction') { - return event; - } - // Filter out transactions for build assets - if (event.transaction?.match(/^GET \/_build\//)) { - options.debug && debug.log('SolidStartLowQualityTransactionsFilter filtered transaction', event.transaction); - return null; - } - return event; - }) satisfies EventProcessor, - { id: 'SolidStartLowQualityTransactionsFilter' }, - ), - ); + getGlobalScope().addEventProcessor(lowQualityTransactionsFilter(options)); } diff --git a/packages/solidstart/test/server/sdk.test.ts b/packages/solidstart/test/server/sdk.test.ts index b700b43a067a..c5df698ed307 100644 --- a/packages/solidstart/test/server/sdk.test.ts +++ b/packages/solidstart/test/server/sdk.test.ts @@ -1,8 +1,9 @@ -import type { NodeClient } from '@sentry/node'; -import { SDK_VERSION } from '@sentry/node'; +import type { EventProcessor } from '@sentry/core'; +import { getGlobalScope, Scope, SDK_VERSION } from '@sentry/node'; import * as SentryNode from '@sentry/node'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { init as solidStartInit } from '../../src/server'; +import { lowQualityTransactionsFilter } from '../../src/server/utils'; const browserInit = vi.spyOn(SentryNode, 'init'); @@ -34,37 +35,52 @@ describe('Initialize Solid Start SDK', () => { expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining(expectedMetadata)); }); - it('filters out low quality transactions', async () => { - const beforeSendEvent = vi.fn(event => event); - const client = solidStartInit({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - }) as NodeClient; - client.on('beforeSendEvent', beforeSendEvent); + describe('lowQualityTransactionsFilter', () => { + const options = { debug: false }; + const filter = lowQualityTransactionsFilter(options); + + describe('filters out low quality transactions', () => { + it.each(['GET /_build/some_asset.js', 'GET /_build/app.js', 'GET /_build/assets/logo.png'])( + 'filters out low quality transaction: (%s)', + transaction => { + const event = { type: 'transaction' as const, transaction }; + expect(filter(event, {})).toBeNull(); + }, + ); + }); + + describe('keeps high quality transactions', () => { + it.each(['GET /', 'POST /_server'])('does not filter out route transactions (%s)', transaction => { + const event = { type: 'transaction' as const, transaction }; + expect(filter(event, {})).toEqual(event); + }); + }); - client.captureEvent({ type: 'transaction', transaction: 'GET /' }); - client.captureEvent({ type: 'transaction', transaction: 'GET /_build/some_asset.js' }); - client.captureEvent({ type: 'transaction', transaction: 'POST /_server' }); + it('does not filter non-transaction events', () => { + const event = { type: 'error' as const, transaction: 'GET /_build/app.js' } as any; + expect(filter(event, {})).toEqual(event); + }); + + it('handles events without transaction property', () => { + const event = { type: 'transaction' as const }; + expect(filter(event, {})).toEqual(event); + }); + }); - await client!.flush(); + it('registers an event processor', () => { + let passedEventProcessors: EventProcessor[] = []; + const addEventProcessor = vi + .spyOn(getGlobalScope(), 'addEventProcessor') + .mockImplementation((eventProcessor: EventProcessor) => { + passedEventProcessors = [...passedEventProcessors, eventProcessor]; + return new Scope(); + }); + + solidStartInit({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }); - expect(beforeSendEvent).toHaveBeenCalledTimes(2); - expect(beforeSendEvent).toHaveBeenCalledWith( - expect.objectContaining({ - transaction: 'GET /', - }), - expect.any(Object), - ); - expect(beforeSendEvent).not.toHaveBeenCalledWith( - expect.objectContaining({ - transaction: 'GET /_build/some_asset.js', - }), - expect.any(Object), - ); - expect(beforeSendEvent).toHaveBeenCalledWith( - expect.objectContaining({ - transaction: 'POST /_server', - }), - expect.any(Object), - ); + expect(addEventProcessor).toHaveBeenCalledTimes(1); + expect(passedEventProcessors[0]?.id).toEqual('SolidStartLowQualityTransactionsFilter'); }); }); From 5ee25973e6baf534ac7502b802f658bb469bbb85 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 14 Aug 2025 15:23:56 +0200 Subject: [PATCH 05/16] test(nextjs): Fix canary tests (#17416) --- dev-packages/e2e-tests/test-applications/nextjs-14/tsconfig.json | 1 + dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json | 1 + .../e2e-tests/test-applications/nextjs-app-dir/tsconfig.json | 1 + .../e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json | 1 + .../e2e-tests/test-applications/nextjs-turbo/tsconfig.json | 1 + 5 files changed, 5 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-14/tsconfig.json index ef9e351d7a7b..1ed098ed9058 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-14/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowImportingTsExtensions": true, "target": "es2018", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json index ef9e351d7a7b..a2672ddb4974 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "target": "es2018", + "allowImportingTsExtensions": true, "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tsconfig.json index bd69196a9ca4..84fbe633ea0b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowImportingTsExtensions": true, "target": "es2018", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json index bd69196a9ca4..84fbe633ea0b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowImportingTsExtensions": true, "target": "es2018", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tsconfig.json index ef9e351d7a7b..1ed098ed9058 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowImportingTsExtensions": true, "target": "es2018", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, From 90c10a97ba720f0a52908589cb24200fe68a3826 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:59:01 +0200 Subject: [PATCH 06/16] feat(astro): Align options with shared build time options type (#17396) As Astro currently mixes build-time and runtime options, the `release` option was omitted from the type. It's only possible to set this option with the `unstable_sentryVitePluginOptions` :( Closes https://github.com/getsentry/sentry-javascript/issues/17067 --- packages/astro/.eslintrc.cjs | 2 +- packages/astro/src/integration/index.ts | 58 ++++-- packages/astro/src/integration/types.ts | 79 +++----- packages/astro/test/buildOptions.test-d.ts | 190 ++++++++++++++++++ packages/astro/test/integration/index.test.ts | 182 ++++++++++------- .../astro/test/integration/snippets.test.ts | 3 +- packages/astro/tsconfig.test.json | 2 +- packages/astro/tsconfig.vite.json | 10 + packages/astro/vite.config.ts | 4 + 9 files changed, 394 insertions(+), 136 deletions(-) create mode 100644 packages/astro/test/buildOptions.test-d.ts create mode 100644 packages/astro/tsconfig.vite.json diff --git a/packages/astro/.eslintrc.cjs b/packages/astro/.eslintrc.cjs index 29b78099e7c6..3be941649fcf 100644 --- a/packages/astro/.eslintrc.cjs +++ b/packages/astro/.eslintrc.cjs @@ -8,7 +8,7 @@ module.exports = { { files: ['vite.config.ts'], parserOptions: { - project: ['tsconfig.test.json'], + project: ['tsconfig.vite.json'], }, }, ], diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 1a9eeaff8cd4..29dcc15ade0c 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -27,8 +27,13 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { clientInitPath, serverInitPath, autoInstrumentation, + // eslint-disable-next-line deprecation/deprecation sourceMapsUploadOptions, + sourcemaps, + // todo(v11): Extract `release` build time option here - cannot be done currently, because it conflicts with the `DeprecatedRuntimeOptions` type + // release, bundleSizeOptimizations, + unstable_sentryVitePluginOptions, debug, ...otherOptions } = options; @@ -48,8 +53,21 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { }; const sourceMapsNeeded = sdkEnabled.client || sdkEnabled.server; - const { unstable_sentryVitePluginOptions, ...uploadOptions } = sourceMapsUploadOptions || {}; - const shouldUploadSourcemaps = (sourceMapsNeeded && uploadOptions?.enabled) ?? true; + // eslint-disable-next-line deprecation/deprecation + const { unstable_sentryVitePluginOptions: deprecatedVitePluginOptions, ...uploadOptions } = + sourceMapsUploadOptions || {}; + + const unstableMerged_sentryVitePluginOptions = { + ...deprecatedVitePluginOptions, + ...unstable_sentryVitePluginOptions, + }; + + const shouldUploadSourcemaps = + (sourceMapsNeeded && + sourcemaps?.disable !== true && + // eslint-disable-next-line deprecation/deprecation + uploadOptions?.enabled) ?? + true; // We don't need to check for AUTH_TOKEN here, because the plugin will pick it up from the env if (shouldUploadSourcemaps && command !== 'dev') { @@ -58,7 +76,9 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { let updatedFilesToDeleteAfterUpload: string[] | undefined = undefined; if ( + // eslint-disable-next-line deprecation/deprecation typeof uploadOptions?.filesToDeleteAfterUpload === 'undefined' && + 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 @@ -79,26 +99,40 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { }, plugins: [ sentryVitePlugin({ - org: uploadOptions.org ?? env.SENTRY_ORG, - project: uploadOptions.project ?? env.SENTRY_PROJECT, - authToken: uploadOptions.authToken ?? env.SENTRY_AUTH_TOKEN, - telemetry: uploadOptions.telemetry ?? true, + // Priority: top-level options > deprecated options > env vars + // eslint-disable-next-line deprecation/deprecation + org: options.org ?? uploadOptions.org ?? env.SENTRY_ORG, + // eslint-disable-next-line deprecation/deprecation + project: options.project ?? uploadOptions.project ?? env.SENTRY_PROJECT, + // eslint-disable-next-line deprecation/deprecation + authToken: options.authToken ?? uploadOptions.authToken ?? env.SENTRY_AUTH_TOKEN, + url: options.sentryUrl ?? env.SENTRY_URL, + headers: options.headers, + // eslint-disable-next-line deprecation/deprecation + telemetry: options.telemetry ?? uploadOptions.telemetry ?? true, + silent: options.silent ?? false, + errorHandler: options.errorHandler, _metaOptions: { telemetry: { metaFramework: 'astro', }, }, - ...unstable_sentryVitePluginOptions, - debug: debug ?? false, + ...unstableMerged_sentryVitePluginOptions, + debug: options.debug ?? false, sourcemaps: { - assets: uploadOptions.assets ?? [getSourcemapsAssetsGlob(config)], + ...options.sourcemaps, + // eslint-disable-next-line deprecation/deprecation + assets: sourcemaps?.assets ?? uploadOptions.assets ?? [getSourcemapsAssetsGlob(config)], filesToDeleteAfterUpload: - uploadOptions?.filesToDeleteAfterUpload ?? updatedFilesToDeleteAfterUpload, - ...unstable_sentryVitePluginOptions?.sourcemaps, + sourcemaps?.filesToDeleteAfterUpload ?? + // eslint-disable-next-line deprecation/deprecation + uploadOptions?.filesToDeleteAfterUpload ?? + updatedFilesToDeleteAfterUpload, + ...unstableMerged_sentryVitePluginOptions?.sourcemaps, }, bundleSizeOptimizations: { ...bundleSizeOptimizations, - ...unstable_sentryVitePluginOptions?.bundleSizeOptimizations, + ...unstableMerged_sentryVitePluginOptions?.bundleSizeOptimizations, }, }), ], diff --git a/packages/astro/src/integration/types.ts b/packages/astro/src/integration/types.ts index aed2b7e1d193..ec9996cba134 100644 --- a/packages/astro/src/integration/types.ts +++ b/packages/astro/src/integration/types.ts @@ -1,3 +1,4 @@ +import type { BuildTimeOptionsBase, UnstableVitePluginOptions } from '@sentry/core'; import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import type { RouteData } from 'astro'; @@ -23,12 +24,16 @@ type SdkInitPaths = { serverInitPath?: string; }; +/** + * @deprecated Move these options to the top-level of your Sentry configuration. + */ type SourceMapsOptions = { /** * If this flag is `true`, and an auth token is detected, the Sentry integration will * automatically generate and upload source maps to Sentry during a production build. * * @default true + * @deprecated Use `sourcemaps.disable` instead (with inverted logic) */ enabled?: boolean; @@ -39,18 +44,24 @@ type SourceMapsOptions = { * * To create an auth token, follow this guide: * @see https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens + * + * @deprecated Use top-level `authToken` option instead */ authToken?: string; /** * The organization slug of your Sentry organization. * Instead of specifying this option, you can also set the `SENTRY_ORG` environment variable. + * + * @deprecated Use top-level `org` option instead */ org?: string; /** * The project slug of your Sentry project. * Instead of specifying this option, you can also set the `SENTRY_PROJECT` environment variable. + * + * @deprecated Use top-level `project` option instead */ project?: string; @@ -59,6 +70,7 @@ type SourceMapsOptions = { * It will not collect any sensitive or user-specific data. * * @default true + * @deprecated Use top-level `telemetry` option instead */ telemetry?: boolean; @@ -71,6 +83,8 @@ type SourceMapsOptions = { * * The globbing patterns must follow the implementation of the `glob` package. * @see https://www.npmjs.com/package/glob#glob-primer + * + * @deprecated Use `sourcemaps.assets` instead */ assets?: string | Array; @@ -81,6 +95,8 @@ type SourceMapsOptions = { * @default [] - By default no files are deleted. * * The globbing patterns follow the implementation of the glob package. (https://www.npmjs.com/package/glob) + * + * @deprecated Use `sourcemaps.filesToDeleteAfterUpload` instead */ filesToDeleteAfterUpload?: string | Array; @@ -95,49 +111,10 @@ type SourceMapsOptions = { * changes can occur at any time within a major SDK version. * * Furthermore, some options are untested with Astro specifically. Use with caution. - */ - unstable_sentryVitePluginOptions?: Partial; -}; - -type BundleSizeOptimizationOptions = { - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) any debugging code within the Sentry SDK. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * Setting this option to `true` will disable features like the SDK's `debug` option. - */ - excludeDebugStatements?: boolean; - - /** - * If set to true, the plugin will try to tree-shake performance monitoring statements out. - * Note that the success of this depends on tree shaking generally being enabled in your build. - * Attention: DO NOT enable this when you're using any performance monitoring-related SDK features (e.g. Sentry.startSpan()). - */ - excludeTracing?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay Shadow DOM recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. * - * This option is safe to be used when you do not want to capture any Shadow DOM activity via Sentry Session Replay. + * @deprecated Use top-level `unstable_sentryVitePluginOptions` instead */ - excludeReplayShadowDom?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay `iframe` recording functionality. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * You can safely do this when you do not want to capture any `iframe` activity via Sentry Session Replay. - */ - excludeReplayIframe?: boolean; - - /** - * If set to `true`, the plugin will attempt to tree-shake (remove) code related to the Sentry SDK's Session Replay's Compression Web Worker. - * Note that the success of this depends on tree shaking being enabled in your build tooling. - * - * **Notice:** You should only do use this option if you manually host a compression worker and configure it in your Sentry Session Replay integration config via the `workerUrl` option. - */ - excludeReplayWorker?: boolean; + unstable_sentryVitePluginOptions?: Partial; }; type InstrumentationOptions = { @@ -202,7 +179,10 @@ type DeprecatedRuntimeOptions = Record; * * If you specify a dedicated init file, the SDK options passed to `sentryAstro` will be ignored. */ -export type SentryOptions = SdkInitPaths & +export type SentryOptions = Omit & + // todo(v11): `release` and `debug` need to be removed from BuildTimeOptionsBase as it is currently conflicting with `DeprecatedRuntimeOptions` + UnstableVitePluginOptions & + SdkInitPaths & InstrumentationOptions & SdkEnabledOptions & { /** @@ -210,19 +190,12 @@ export type SentryOptions = SdkInitPaths & * * These options are always read from the `sentryAstro` integration. * Do not define them in the `sentry.client.config.(js|ts)` or `sentry.server.config.(js|ts)` files. - */ - sourceMapsUploadOptions?: SourceMapsOptions; - /** - * Options for the Sentry Vite plugin to customize bundle size optimizations. * - * These options are always read from the `sentryAstro` integration. - * Do not define them in the `sentry.client.config.(js|ts)` or `sentry.server.config.(js|ts)` files. + * @deprecated This option was deprecated. Please move the options to the top-level configuration. + * See the migration guide in the SourceMapsOptions type documentation. */ - bundleSizeOptimizations?: BundleSizeOptimizationOptions; - /** - * If enabled, prints debug logs during the build process. - */ - debug?: boolean; + // eslint-disable-next-line deprecation/deprecation + sourceMapsUploadOptions?: SourceMapsOptions; // eslint-disable-next-line deprecation/deprecation } & DeprecatedRuntimeOptions; diff --git a/packages/astro/test/buildOptions.test-d.ts b/packages/astro/test/buildOptions.test-d.ts new file mode 100644 index 000000000000..ec4c9c5330f7 --- /dev/null +++ b/packages/astro/test/buildOptions.test-d.ts @@ -0,0 +1,190 @@ +import { describe, expectTypeOf, it } from 'vitest'; +import type { SentryOptions } from '../src/integration/types'; + +describe('Sentry Astro build-time options type', () => { + it('includes all options based on type BuildTimeOptionsBase', () => { + const completeOptions: SentryOptions = { + // --- BuildTimeOptionsBase options --- + org: 'test-org', + project: 'test-project', + authToken: 'test-auth-token', + sentryUrl: 'https://sentry.io', + headers: { Authorization: ' Bearer test-auth-token' }, + telemetry: true, + silent: false, + // eslint-disable-next-line no-console + errorHandler: (err: Error) => console.warn(err), + debug: false, + sourcemaps: { + disable: false, + assets: ['./dist/**/*'], + ignore: ['./dist/*.map'], + filesToDeleteAfterUpload: ['./dist/*.map'], + }, + release: { + name: 'test-release-1.0.0', + create: true, + finalize: true, + dist: 'test-dist', + vcsRemote: 'origin', + setCommits: { + auto: false, + repo: 'test/repo', + commit: 'abc123', + previousCommit: 'def456', + ignoreMissing: false, + ignoreEmpty: false, + }, + deploy: { + env: 'production', + started: 1234567890, + finished: 1234567900, + time: 10, + name: 'deployment-name', + url: 'https://example.com', + }, + }, + bundleSizeOptimizations: { + excludeDebugStatements: true, + excludeTracing: false, + excludeReplayShadowDom: true, + excludeReplayIframe: true, + excludeReplayWorker: true, + }, + + // --- UnstableVitePluginOptions --- + unstable_sentryVitePluginOptions: { + sourcemaps: { + assets: './dist/**/*', + }, + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + }, + + // --- SentryOptions specific options --- + enabled: true, + clientInitPath: './src/sentry.client.config.ts', + serverInitPath: './src/sentry.server.config.ts', + autoInstrumentation: { + requestHandler: true, + }, + + // Deprecated runtime options + environment: 'test', + dsn: 'https://test@sentry.io/123', + sampleRate: 1.0, + tracesSampleRate: 1.0, + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + }; + + expectTypeOf(completeOptions).toEqualTypeOf(); + }); + + it('includes all deprecated options', () => { + const completeOptions: SentryOptions = { + // SentryOptions specific options + enabled: true, + debug: true, + clientInitPath: './src/sentry.client.config.ts', + serverInitPath: './src/sentry.server.config.ts', + autoInstrumentation: { + requestHandler: true, + }, + unstable_sentryVitePluginOptions: { + sourcemaps: { + assets: './dist/**/*', + }, + bundleSizeOptimizations: { + excludeDebugStatements: true, + }, + }, + + // Deprecated sourceMapsUploadOptions + sourceMapsUploadOptions: { + enabled: true, + authToken: 'deprecated-token', + org: 'deprecated-org', + project: 'deprecated-project', + telemetry: false, + assets: './build/**/*', + filesToDeleteAfterUpload: ['./build/*.map'], + unstable_sentryVitePluginOptions: { + sourcemaps: { + ignore: ['./build/*.spec.js'], + }, + }, + }, + }; + + expectTypeOf(completeOptions).toEqualTypeOf(); + }); + + it('allows partial configuration', () => { + const minimalOptions: SentryOptions = { enabled: true }; + + expectTypeOf(minimalOptions).toEqualTypeOf(); + + const partialOptions: SentryOptions = { + enabled: true, + debug: false, + org: 'my-org', + project: 'my-project', + }; + + expectTypeOf(partialOptions).toEqualTypeOf(); + }); + + it('supports BuildTimeOptionsBase options at top level', () => { + const baseOptions: SentryOptions = { + // Test that all BuildTimeOptionsBase options are available at top level + org: 'test-org', + project: 'test-project', + authToken: 'test-token', + sentryUrl: 'https://custom.sentry.io', + headers: { 'Custom-Header': 'value' }, + telemetry: false, + silent: true, + debug: true, + sourcemaps: { + disable: false, + assets: ['./dist/**/*.js'], + ignore: ['./dist/test/**/*'], + filesToDeleteAfterUpload: ['./dist/**/*.map'], + }, + release: { + name: '1.0.0', + create: true, + finalize: false, + }, + bundleSizeOptimizations: { + excludeDebugStatements: true, + excludeTracing: true, + }, + }; + + expectTypeOf(baseOptions).toEqualTypeOf(); + }); + + it('supports UnstableVitePluginOptions at top level', () => { + const viteOptions: SentryOptions = { + unstable_sentryVitePluginOptions: { + org: 'override-org', + project: 'override-project', + sourcemaps: { + assets: './custom-dist/**/*', + ignore: ['./custom-dist/ignore/**/*'], + }, + bundleSizeOptimizations: { + excludeDebugStatements: true, + excludeTracing: false, + }, + debug: true, + silent: false, + }, + }; + + expectTypeOf(viteOptions).toEqualTypeOf(); + }); +}); diff --git a/packages/astro/test/integration/index.test.ts b/packages/astro/test/integration/index.test.ts index f7c0f2ec9e14..abb3f48dcf72 100644 --- a/packages/astro/test/integration/index.test.ts +++ b/packages/astro/test/integration/index.test.ts @@ -20,6 +20,10 @@ const injectScript = vi.fn(); const config = { root: new URL('file://path/to/project'), outDir: new URL('file://path/to/project/out'), +} as AstroConfig; + +const baseConfigHookObject = { + logger: { warn: vi.fn(), info: vi.fn() }, }; describe('sentryAstro integration', () => { @@ -39,7 +43,7 @@ describe('sentryAstro integration', () => { expect(integration.hooks['astro:config:setup']).toBeDefined(); // @ts-expect-error - the hook exists and we only need to pass what we actually use - await integration.hooks['astro:config:setup']({ updateConfig, injectScript, config }); + await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config }); expect(updateConfig).toHaveBeenCalledTimes(1); expect(updateConfig).toHaveBeenCalledWith({ @@ -52,23 +56,25 @@ describe('sentryAstro integration', () => { }); expect(sentryVitePluginSpy).toHaveBeenCalledTimes(1); - expect(sentryVitePluginSpy).toHaveBeenCalledWith({ - authToken: 'my-token', - org: 'my-org', - project: 'my-project', - telemetry: false, - debug: false, - bundleSizeOptimizations: {}, - sourcemaps: { - assets: ['out/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], - }, - _metaOptions: { - telemetry: { - metaFramework: 'astro', + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + authToken: 'my-token', + org: 'my-org', + project: 'my-project', + telemetry: false, + debug: false, + bundleSizeOptimizations: {}, + sourcemaps: { + assets: ['out/**/*'], + filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], }, - }, - }); + _metaOptions: { + telemetry: { + metaFramework: 'astro', + }, + }, + }), + ); }); it('falls back to default output dir, if out and root dir are not available', async () => { @@ -76,26 +82,28 @@ describe('sentryAstro integration', () => { sourceMapsUploadOptions: { enabled: true, org: 'my-org', project: 'my-project', telemetry: false }, }); // @ts-expect-error - the hook exists and we only need to pass what we actually use - await integration.hooks['astro:config:setup']({ updateConfig, injectScript, config: {} }); + await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config: {} }); expect(sentryVitePluginSpy).toHaveBeenCalledTimes(1); - expect(sentryVitePluginSpy).toHaveBeenCalledWith({ - authToken: 'my-token', - org: 'my-org', - project: 'my-project', - telemetry: false, - debug: false, - bundleSizeOptimizations: {}, - sourcemaps: { - assets: ['dist/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], - }, - _metaOptions: { - telemetry: { - metaFramework: 'astro', + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + authToken: 'my-token', + org: 'my-org', + project: 'my-project', + telemetry: false, + debug: false, + bundleSizeOptimizations: {}, + sourcemaps: { + assets: ['dist/**/*'], + filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], }, - }, - }); + _metaOptions: { + telemetry: { + metaFramework: 'astro', + }, + }, + }), + ); }); it('sets the correct assets glob for vercel if the Vercel adapter is used', async () => { @@ -104,6 +112,7 @@ describe('sentryAstro integration', () => { }); // @ts-expect-error - the hook exists and we only need to pass what we actually use await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, updateConfig, injectScript, config: { @@ -113,23 +122,25 @@ describe('sentryAstro integration', () => { }); expect(sentryVitePluginSpy).toHaveBeenCalledTimes(1); - expect(sentryVitePluginSpy).toHaveBeenCalledWith({ - authToken: 'my-token', - org: 'my-org', - project: 'my-project', - telemetry: false, - debug: false, - bundleSizeOptimizations: {}, - sourcemaps: { - assets: ['{.vercel,dist}/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], - }, - _metaOptions: { - telemetry: { - metaFramework: 'astro', + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + authToken: 'my-token', + org: 'my-org', + project: 'my-project', + telemetry: false, + debug: false, + bundleSizeOptimizations: {}, + sourcemaps: { + assets: ['{.vercel,dist}/**/*'], + filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], }, - }, - }); + _metaOptions: { + telemetry: { + metaFramework: 'astro', + }, + }, + }), + ); }); it('prefers user-specified assets-globs over the default values', async () => { @@ -143,6 +154,7 @@ describe('sentryAstro integration', () => { }); // @ts-expect-error - the hook exists and we only need to pass what we actually use await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, updateConfig, injectScript, // @ts-expect-error - only passing in partial config @@ -152,23 +164,25 @@ describe('sentryAstro integration', () => { }); expect(sentryVitePluginSpy).toHaveBeenCalledTimes(1); - expect(sentryVitePluginSpy).toHaveBeenCalledWith({ - authToken: 'my-token', - org: 'my-org', - project: 'my-project', - telemetry: true, - debug: false, - bundleSizeOptimizations: {}, - sourcemaps: { - assets: ['dist/server/**/*, dist/client/**/*'], - filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], - }, - _metaOptions: { - telemetry: { - metaFramework: 'astro', + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + authToken: 'my-token', + org: 'my-org', + project: 'my-project', + telemetry: true, + debug: false, + bundleSizeOptimizations: {}, + sourcemaps: { + assets: ['dist/server/**/*, dist/client/**/*'], + filesToDeleteAfterUpload: ['./dist/**/client/**/*.map', './dist/**/server/**/*.map'], }, - }, - }); + _metaOptions: { + telemetry: { + metaFramework: 'astro', + }, + }, + }), + ); }); it('prefers user-specified filesToDeleteAfterUpload over the default values', async () => { @@ -182,6 +196,7 @@ describe('sentryAstro integration', () => { }); // @ts-expect-error - the hook exists, and we only need to pass what we actually use await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, updateConfig, injectScript, // @ts-expect-error - only passing in partial config @@ -226,6 +241,7 @@ describe('sentryAstro integration', () => { }); // @ts-expect-error - the hook exists, and we only need to pass what we actually use await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, updateConfig, injectScript, // @ts-expect-error - only passing in partial config @@ -260,12 +276,36 @@ describe('sentryAstro integration', () => { expect(integration.hooks['astro:config:setup']).toBeDefined(); // @ts-expect-error - the hook exists and we only need to pass what we actually use - await integration.hooks['astro:config:setup']({ updateConfig, injectScript, config }); + await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config }); expect(updateConfig).toHaveBeenCalledTimes(0); expect(sentryVitePluginSpy).toHaveBeenCalledTimes(0); }); + it("doesn't enable source maps if `sourcemaps.disable` is `true`", async () => { + const integration = sentryAstro({ + sourcemaps: { disable: true }, + }); + + expect(integration.hooks['astro:config:setup']).toBeDefined(); + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config }); + + expect(updateConfig).toHaveBeenCalledTimes(0); + expect(sentryVitePluginSpy).toHaveBeenCalledTimes(0); + }); + + it('enables source maps if `sourcemaps.disable` is not defined', async () => { + const integration = sentryAstro({}); + + expect(integration.hooks['astro:config:setup']).toBeDefined(); + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ ...baseConfigHookObject, updateConfig, injectScript, config }); + + expect(updateConfig).toHaveBeenCalledTimes(1); + expect(sentryVitePluginSpy).toHaveBeenCalledTimes(1); + }); + it("doesn't add the Vite plugin in dev mode", async () => { const integration = sentryAstro({ sourceMapsUploadOptions: { enabled: true }, @@ -273,7 +313,13 @@ describe('sentryAstro integration', () => { expect(integration.hooks['astro:config:setup']).toBeDefined(); // @ts-expect-error - the hook exists and we only need to pass what we actually use - await integration.hooks['astro:config:setup']({ updateConfig, injectScript, config, command: 'dev' }); + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + updateConfig, + injectScript, + config, + command: 'dev', + }); expect(updateConfig).toHaveBeenCalledTimes(0); expect(sentryVitePluginSpy).toHaveBeenCalledTimes(0); diff --git a/packages/astro/test/integration/snippets.test.ts b/packages/astro/test/integration/snippets.test.ts index edc8338906ab..4c3f1a88d25d 100644 --- a/packages/astro/test/integration/snippets.test.ts +++ b/packages/astro/test/integration/snippets.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; import { buildClientSnippet, buildSdkInitFileImportSnippet, buildServerSnippet } from '../../src/integration/snippets'; +import type { SentryOptions } from '../../src/integration/types'; -const allSdkOptions = { +const allSdkOptions: SentryOptions = { dsn: 'my-dsn', release: '1.0.0', environment: 'staging', diff --git a/packages/astro/tsconfig.test.json b/packages/astro/tsconfig.test.json index c41efeacd92f..da5a816712e3 100644 --- a/packages/astro/tsconfig.test.json +++ b/packages/astro/tsconfig.test.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", - "include": ["test/**/*", "vite.config.ts"], + "include": ["test/**/*"], "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used diff --git a/packages/astro/tsconfig.vite.json b/packages/astro/tsconfig.vite.json new file mode 100644 index 000000000000..a3d6e59b1bfe --- /dev/null +++ b/packages/astro/tsconfig.vite.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + + "include": ["vite.config.ts"], + + "compilerOptions": { + // should include all types from `./tsconfig.json` plus types for all test frameworks used + "types": ["node"] + } +} diff --git a/packages/astro/vite.config.ts b/packages/astro/vite.config.ts index f18ec92095bc..5f83f34483c3 100644 --- a/packages/astro/vite.config.ts +++ b/packages/astro/vite.config.ts @@ -4,5 +4,9 @@ export default { ...baseConfig, test: { ...baseConfig.test, + typecheck: { + enabled: true, + tsconfig: './tsconfig.test.json', + }, }, }; From 3bf4a30ab9987def6560304513691a34446c25d5 Mon Sep 17 00:00:00 2001 From: Karibash Date: Mon, 25 Aug 2025 20:04:47 +0900 Subject: [PATCH 07/16] feat(node): Add an instrumentation interface for Hono (#17366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces the scaffolding for Sentry’s tracing integration with [Hono](https://hono.dev/) by adding interface-only implementations and wiring needed to verify the overall approach before filling in the tracing logic. ## Summary - Adds a new Hono Instrumentation (OpenTelemetry-based) which patches Hono route and middleware APIs. - Provides a honoIntegration exported via defineIntegration, plus an instrumentHono guard using generateInstrumentOnce. - Introduces minimal, vendored Hono types required for patching, and enums for consistent attribute naming. ## Intent & scope - Implemented interfaces only to validate the design direction. The goal is to confirm the wrapping points, attribute schema, and initialization flow before we add any span creation, context propagation, or attribute setting. - If this approach looks good, the next step is to ship a patch that implements the route handler tracing. That follow-up will include span start/finish, setting hono.type/hono.name, request path/method extraction, and trace context propagation. - No tests added in this PR because it only introduces the interface and structure. Tests will land together with the first functional instrumentation patch. ## Rationale There is an existing Hono OTel package ([@hono/otel](https://www.npmjs.com/package/@hono/otel)), but it currently lacks several features we need for a robust Sentry integration—especially middleware instrumentation and Sentry-specific integration points (e.g., seamless correlation with Sentry transactions/spans and future Sentry error handler wiring). Given these gaps, we’re proceeding with an in-repo implementation tailored for Sentry’s needs. ## Related Issue #15260 --- .../src/integrations/tracing/hono/index.ts | 35 ++++++++ .../tracing/hono/instrumentation.ts | 84 +++++++++++++++++++ .../src/integrations/tracing/hono/types.ts | 50 +++++++++++ 3 files changed, 169 insertions(+) create mode 100644 packages/node/src/integrations/tracing/hono/index.ts create mode 100644 packages/node/src/integrations/tracing/hono/instrumentation.ts create mode 100644 packages/node/src/integrations/tracing/hono/types.ts diff --git a/packages/node/src/integrations/tracing/hono/index.ts b/packages/node/src/integrations/tracing/hono/index.ts new file mode 100644 index 000000000000..8876d26b829e --- /dev/null +++ b/packages/node/src/integrations/tracing/hono/index.ts @@ -0,0 +1,35 @@ +import type { IntegrationFn } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { HonoInstrumentation } from './instrumentation'; + +const INTEGRATION_NAME = 'Hono'; + +export const instrumentHono = generateInstrumentOnce(INTEGRATION_NAME, () => new HonoInstrumentation()); + +const _honoIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentHono(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for [Hono](https://hono.dev/). + * + * If you also want to capture errors, you need to call `setupHonoErrorHandler(app)` after you set up your Hono server. + * + * For more information, see the [hono documentation](https://docs.sentry.io/platforms/javascript/guides/hono/). + * + * @example + * ```javascript + * const Sentry = require('@sentry/node'); + * + * Sentry.init({ + * integrations: [Sentry.honoIntegration()], + * }) + * ``` + */ +export const honoIntegration = defineIntegration(_honoIntegration); diff --git a/packages/node/src/integrations/tracing/hono/instrumentation.ts b/packages/node/src/integrations/tracing/hono/instrumentation.ts new file mode 100644 index 000000000000..81e062560051 --- /dev/null +++ b/packages/node/src/integrations/tracing/hono/instrumentation.ts @@ -0,0 +1,84 @@ +import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import type { HandlerInterface, Hono, HonoInstance, MiddlewareHandlerInterface, OnHandlerInterface } from './types'; + +const PACKAGE_NAME = '@sentry/instrumentation-hono'; +const PACKAGE_VERSION = '0.0.1'; + +/** + * Hono instrumentation for OpenTelemetry + */ +export class HonoInstrumentation extends InstrumentationBase { + public constructor() { + super(PACKAGE_NAME, PACKAGE_VERSION, {}); + } + + /** + * Initialize the instrumentation. + */ + public init(): InstrumentationNodeModuleDefinition[] { + return [ + new InstrumentationNodeModuleDefinition('hono', ['>=4.0.0 <5'], moduleExports => this._patch(moduleExports)), + ]; + } + + /** + * Patches the module exports to instrument Hono. + */ + private _patch(moduleExports: { Hono: Hono }): { Hono: Hono } { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const instrumentation = this; + + moduleExports.Hono = class HonoWrapper extends moduleExports.Hono { + public constructor(...args: unknown[]) { + super(...args); + + instrumentation._wrap(this, 'get', instrumentation._patchHandler()); + instrumentation._wrap(this, 'post', instrumentation._patchHandler()); + instrumentation._wrap(this, 'put', instrumentation._patchHandler()); + instrumentation._wrap(this, 'delete', instrumentation._patchHandler()); + instrumentation._wrap(this, 'options', instrumentation._patchHandler()); + instrumentation._wrap(this, 'patch', instrumentation._patchHandler()); + instrumentation._wrap(this, 'all', instrumentation._patchHandler()); + instrumentation._wrap(this, 'on', instrumentation._patchOnHandler()); + instrumentation._wrap(this, 'use', instrumentation._patchMiddlewareHandler()); + } + }; + return moduleExports; + } + + /** + * Patches the route handler to instrument it. + */ + private _patchHandler(): (original: HandlerInterface) => HandlerInterface { + return function (original: HandlerInterface) { + return function wrappedHandler(this: HonoInstance, ...args: unknown[]) { + // TODO: Add OpenTelemetry tracing logic here + return original.apply(this, args); + }; + }; + } + + /** + * Patches the 'on' handler to instrument it. + */ + private _patchOnHandler(): (original: OnHandlerInterface) => OnHandlerInterface { + return function (original: OnHandlerInterface) { + return function wrappedHandler(this: HonoInstance, ...args: unknown[]) { + // TODO: Add OpenTelemetry tracing logic here + return original.apply(this, args); + }; + }; + } + + /** + * Patches the middleware handler to instrument it. + */ + private _patchMiddlewareHandler(): (original: MiddlewareHandlerInterface) => MiddlewareHandlerInterface { + return function (original: MiddlewareHandlerInterface) { + return function wrappedHandler(this: HonoInstance, ...args: unknown[]) { + // TODO: Add OpenTelemetry tracing logic here + return original.apply(this, args); + }; + }; + } +} diff --git a/packages/node/src/integrations/tracing/hono/types.ts b/packages/node/src/integrations/tracing/hono/types.ts new file mode 100644 index 000000000000..3d7e057859f1 --- /dev/null +++ b/packages/node/src/integrations/tracing/hono/types.ts @@ -0,0 +1,50 @@ +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/request.ts#L30 +export type HonoRequest = { + path: string; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/context.ts#L291 +export type Context = { + req: HonoRequest; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L36C1-L36C39 +export type Next = () => Promise; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L73 +export type Handler = (c: Context, next: Next) => Promise | Response; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L80 +export type MiddlewareHandler = (c: Context, next: Next) => Promise | Response | void; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L109 +export type HandlerInterface = { + (...handlers: (Handler | MiddlewareHandler)[]): HonoInstance; + (path: string, ...handlers: (Handler | MiddlewareHandler)[]): HonoInstance; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L1071 +export type OnHandlerInterface = { + (method: string | string[], path: string | string[], ...handlers: (Handler | MiddlewareHandler)[]): HonoInstance; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/types.ts#L679 +export type MiddlewareHandlerInterface = { + (...handlers: MiddlewareHandler[]): HonoInstance; + (path: string, ...handlers: MiddlewareHandler[]): HonoInstance; +}; + +// Vendored from: https://github.com/honojs/hono/blob/855e5b1adbf685bf4b3e6b76573aa7cb0a108d04/src/hono-base.ts#L99 +export interface HonoInstance { + get: HandlerInterface; + post: HandlerInterface; + put: HandlerInterface; + delete: HandlerInterface; + options: HandlerInterface; + patch: HandlerInterface; + all: HandlerInterface; + on: OnHandlerInterface; + use: MiddlewareHandlerInterface; +} + +export type Hono = new (...args: unknown[]) => HonoInstance; From 090e7ccee2728085897bf5c4e46b30bb855cde26 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 25 Aug 2025 13:45:28 +0200 Subject: [PATCH 08/16] test(e2e/firebase): Fix firebase e2e test failing due to outdated rules file (#17448) To test our firebase instrumentation, we spin up a firebase emulator with firestore rules. Looks like by default, the generated rules file was set to only allow general data access for 30 days. This caused CI to suddenly fail from Aug 17 onwards (and went unnoticed until today due to Hackweek). Since this is just us running the emulator in a CI job, I think it's okay to allow access unconditionally. Not sure though, so happy to think of something else if reviewers have concerns. --- .../test-applications/node-firebase/firestore.rules | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules index 260e089a299b..20db64464c57 100644 --- a/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules +++ b/dev-packages/e2e-tests/test-applications/node-firebase/firestore.rules @@ -3,16 +3,8 @@ rules_version='2' service cloud.firestore { match /databases/{database}/documents { match /{document=**} { - // This rule allows anyone with your database reference to view, edit, - // and delete all data in your database. It is useful for getting - // started, but it is configured to expire after 30 days because it - // leaves your app open to attackers. At that time, all client - // requests to your database will be denied. - // - // Make sure to write security rules for your app before that time, or - // else all client requests to your database will be denied until you - // update your rules. - allow read, write: if request.time < timestamp.date(2025, 8, 17); + // general access within this test app's emulator is fine + allow read, write: if true; } } } From 7698f61068d6f7b9760092be15e00d344fbc0bad Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Mon, 25 Aug 2025 14:07:02 +0200 Subject: [PATCH 09/16] fix(browser): Use `DedicatedWorkerGlobalScope` global object type in `registerWebWorker` (#17447) Changes the type from `WebWorker` to `DedicatedWorkerGlobalScope`*. Other worker types don't support `postMessage`, so this type correctly scopes the API to its intended use (i.e. in dedicated workers). --- .../browser-webworker-vite/src/worker.ts | 4 +--- .../browser-webworker-vite/src/worker2.ts | 4 +--- .../browser-webworker-vite/src/worker3.ts | 4 +--- .../browser-webworker-vite/tsconfig.json | 5 +++-- .../browser/src/integrations/webWorker.ts | 19 ++++++++++++++++--- packages/browser/tsconfig.json | 2 +- 6 files changed, 23 insertions(+), 15 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts index 455e8e395901..6ed994e9006b 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker.ts @@ -1,8 +1,6 @@ import * as Sentry from '@sentry/browser'; -// type cast necessary because TS thinks this file is part of the main -// thread where self is of type `Window` instead of `Worker` -Sentry.registerWebWorker({ self: self as unknown as Worker }); +Sentry.registerWebWorker({ self }); // Let the main thread know the worker is ready self.postMessage({ diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts index 8dfb70b32853..2582bf234c75 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker2.ts @@ -1,8 +1,6 @@ import * as Sentry from '@sentry/browser'; -// type cast necessary because TS thinks this file is part of the main -// thread where self is of type `Window` instead of `Worker` -Sentry.registerWebWorker({ self: self as unknown as Worker }); +Sentry.registerWebWorker({ self }); // Let the main thread know the worker is ready self.postMessage({ diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts index d68265c24ab7..7ea35b0cd82d 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/src/worker3.ts @@ -1,8 +1,6 @@ import * as Sentry from '@sentry/browser'; -// type cast necessary because TS thinks this file is part of the main -// thread where self is of type `Window` instead of `Worker` -Sentry.registerWebWorker({ self: self as unknown as Worker }); +Sentry.registerWebWorker({ self }); // Let the main thread know the worker is ready self.postMessage({ diff --git a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json index 4f5edc248c88..41928e7c8bb1 100644 --- a/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/browser-webworker-vite/tsconfig.json @@ -3,8 +3,9 @@ "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "skipLibCheck": true, + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "skipLibCheck": false, + "skipDefaultLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", diff --git a/packages/browser/src/integrations/webWorker.ts b/packages/browser/src/integrations/webWorker.ts index f422f372a463..1f7a266a9c8e 100644 --- a/packages/browser/src/integrations/webWorker.ts +++ b/packages/browser/src/integrations/webWorker.ts @@ -25,7 +25,7 @@ interface WebWorkerIntegration extends Integration { * any messages from the worker. Otherwise, your message handlers will receive * messages from the Sentry SDK which you need to ignore. * - * This integration only has an effect, if you call `Sentry.registerWorker(self)` + * This integration only has an effect, if you call `Sentry.registerWebWorker(self)` * from within the worker(s) you're adding to the integration. * * Given that you want to initialize the SDK as early as possible, you most likely @@ -113,8 +113,21 @@ function listenForSentryDebugIdMessages(worker: Worker): void { }); } +/** + * Minimal interface for DedicatedWorkerGlobalScope, only requiring the postMessage method. + * (which is the only thing we need from the worker's global object) + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/DedicatedWorkerGlobalScope + * + * We can't use the actual type because it breaks everyone who doesn't have {"lib": ["WebWorker"]} + * but uses {"skipLibCheck": true} in their tsconfig.json. + */ +interface MinimalDedicatedWorkerGlobalScope { + postMessage: (message: unknown) => void; +} + interface RegisterWebWorkerOptions { - self: Worker & { _sentryDebugIds?: Record }; + self: MinimalDedicatedWorkerGlobalScope & { _sentryDebugIds?: Record }; } /** @@ -125,7 +138,7 @@ interface RegisterWebWorkerOptions { * import * as Sentry from '@sentry/'; * * // Do this as early as possible in your worker. - * Sentry.registerWorker({ self }); + * Sentry.registerWebWorker({ self }); * * // continue setting up your worker * self.postMessage(...) diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json index 6b204e508047..1ac927bde013 100644 --- a/packages/browser/tsconfig.json +++ b/packages/browser/tsconfig.json @@ -4,6 +4,6 @@ "include": ["src/**/*", "test/loader.js"], "compilerOptions": { - "lib": ["DOM", "ES2018"], + "lib": ["DOM", "ES2018", "WebWorker"] } } From f3d387ad2b0dfb947805a93ddabad329e0798520 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Mon, 25 Aug 2025 14:07:26 +0200 Subject: [PATCH 10/16] chore: Add external contributor to CHANGELOG.md (#17449) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17366 Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com> --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cdde3b7bea..c90e60a23b06 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 @Karibash. Thank you for your contribution! + ## 10.5.0 - feat(core): better cause data extraction ([#17375](https://github.com/getsentry/sentry-javascript/pull/17375)) From f8390ae38156934b00c3dae3b6e0c024ce24bcf2 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Mon, 25 Aug 2025 14:15:03 +0200 Subject: [PATCH 11/16] feat(aws): Add support for automatic wrapping in ESM (#17407) This allows code-less setup for Lambda functions running in ESM (and thus the aws-serverless SDK in general) by vendoring the OpenTelemetry AwsLambda instrumentation and wrapping the patched handler with Sentry's `wrapHandler`. --- .../lambda-functions-layer/ErrorEsm/index.mjs | 3 + .../TracingEsm/index.mjs | 4 +- .../lambda-functions-npm/TracingCjs/index.js | 4 +- .../lambda-functions-npm/TracingEsm/index.mjs | 4 +- .../aws-serverless/tests/layer.test.ts | 30 +- packages/aws-serverless/package.json | 2 +- packages/aws-serverless/src/awslambda-auto.ts | 4 - packages/aws-serverless/src/index.ts | 4 +- packages/aws-serverless/src/init.ts | 31 ++ .../src/integration/awslambda.ts | 11 +- .../instrumentation.ts | 526 ++++++++++++++++++ .../internal-types.ts | 19 + .../instrumentation-aws-lambda/semconv.ts | 29 + .../instrumentation-aws-lambda/types.ts | 39 ++ packages/aws-serverless/src/sdk.ts | 114 +--- packages/aws-serverless/test/sdk.test.ts | 3 +- yarn.lock | 19 +- 17 files changed, 714 insertions(+), 132 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ErrorEsm/index.mjs create mode 100644 packages/aws-serverless/src/init.ts create mode 100644 packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts create mode 100644 packages/aws-serverless/src/integration/instrumentation-aws-lambda/internal-types.ts create mode 100644 packages/aws-serverless/src/integration/instrumentation-aws-lambda/semconv.ts create mode 100644 packages/aws-serverless/src/integration/instrumentation-aws-lambda/types.ts diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ErrorEsm/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ErrorEsm/index.mjs new file mode 100644 index 000000000000..53785b6046f7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ErrorEsm/index.mjs @@ -0,0 +1,3 @@ +export const handler = async () => { + throw new Error('test esm'); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingEsm/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingEsm/index.mjs index b13f30397b62..e51d323c1347 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingEsm/index.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingEsm/index.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/aws-serverless'; import * as http from 'node:http'; -export const handler = Sentry.wrapHandler(async () => { +export const handler = async () => { await Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { await new Promise(resolve => { http.get('http://example.com', res => { @@ -16,4 +16,4 @@ export const handler = Sentry.wrapHandler(async () => { }); }); }); -}); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingCjs/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingCjs/index.js index 534909d6764e..e53b6670225d 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingCjs/index.js +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingCjs/index.js @@ -1,7 +1,7 @@ const http = require('http'); const Sentry = require('@sentry/aws-serverless'); -exports.handler = Sentry.wrapHandler(async () => { +exports.handler = async () => { await new Promise(resolve => { const req = http.request( { @@ -21,4 +21,4 @@ exports.handler = Sentry.wrapHandler(async () => { }); Sentry.startSpan({ name: 'manual-span', op: 'manual' }, () => {}); -}); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingEsm/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingEsm/index.mjs index 346613025497..e085a7cc2f8f 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingEsm/index.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingEsm/index.mjs @@ -1,7 +1,7 @@ import * as http from 'node:http'; import * as Sentry from '@sentry/aws-serverless'; -export const handler = Sentry.wrapHandler(async () => { +export const handler = async () => { await new Promise(resolve => { const req = http.request( { @@ -21,4 +21,4 @@ export const handler = Sentry.wrapHandler(async () => { }); Sentry.startSpan({ name: 'manual-span', op: 'manual' }, () => {}); -}); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts index 79ad0fa31070..c20659835ee8 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts @@ -160,7 +160,35 @@ test.describe('Lambda layer', () => { type: 'Error', value: 'test', mechanism: { - type: 'auto.function.aws-serverless.handler', + type: 'auto.function.aws-serverless.otel', + handled: false, + }, + }), + ); + }); + + test('capturing errors works in ESM', async ({ lambdaClient }) => { + const errorEventPromise = waitForError('aws-serverless-lambda-sam', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'test esm'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerErrorEsm', + Payload: JSON.stringify({}), + }), + ); + + const errorEvent = await errorEventPromise; + + // shows the SDK sent an error event + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + type: 'Error', + value: 'test esm', + mechanism: { + type: 'auto.function.aws-serverless.otel', handled: false, }, }), diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 708a9376ba3a..d24ff2560a05 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -67,8 +67,8 @@ "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/instrumentation": "^0.203.0", - "@opentelemetry/instrumentation-aws-lambda": "0.54.0", "@opentelemetry/instrumentation-aws-sdk": "0.56.0", + "@opentelemetry/semantic-conventions": "^1.36.0", "@sentry/core": "10.5.0", "@sentry/node": "10.5.0", "@types/aws-lambda": "^8.10.62" diff --git a/packages/aws-serverless/src/awslambda-auto.ts b/packages/aws-serverless/src/awslambda-auto.ts index 2f23fe652005..5848aa08e568 100644 --- a/packages/aws-serverless/src/awslambda-auto.ts +++ b/packages/aws-serverless/src/awslambda-auto.ts @@ -22,10 +22,6 @@ if (lambdaTaskRoot) { : {}, ), }); - - if (typeof require !== 'undefined') { - Sentry.tryPatchHandler(lambdaTaskRoot, handlerString); - } } else { throw Error('LAMBDA_TASK_ROOT environment variable is not set'); } diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 8cbcd31c50a5..dea73e0c564a 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -147,5 +147,7 @@ export { export { awsIntegration } from './integration/aws'; export { awsLambdaIntegration } from './integration/awslambda'; -export { getDefaultIntegrations, init, tryPatchHandler, wrapHandler } from './sdk'; +export { getDefaultIntegrations, init } from './init'; +// eslint-disable-next-line deprecation/deprecation +export { tryPatchHandler, wrapHandler } from './sdk'; export type { WrapperOptions } from './sdk'; diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts new file mode 100644 index 000000000000..269cc3fe27fb --- /dev/null +++ b/packages/aws-serverless/src/init.ts @@ -0,0 +1,31 @@ +import type { Integration, Options } from '@sentry/core'; +import { applySdkMetadata, getSDKSource } from '@sentry/core'; +import type { NodeClient, NodeOptions } from '@sentry/node'; +import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; +import { awsIntegration } from './integration/aws'; +import { awsLambdaIntegration } from './integration/awslambda'; + +/** + * Get the default integrations for the AWSLambda SDK. + */ +// NOTE: in awslambda-auto.ts, we also call the original `getDefaultIntegrations` from `@sentry/node` to load performance integrations. +// If at some point we need to filter a node integration out for good, we need to make sure to also filter it out there. +export function getDefaultIntegrations(_options: Options): Integration[] { + return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; +} + +/** + * Initializes the Sentry AWS Lambda SDK. + * + * @param options Configuration options for the SDK, @see {@link AWSLambdaOptions}. + */ +export function init(options: NodeOptions = {}): NodeClient | undefined { + const opts = { + defaultIntegrations: getDefaultIntegrations(options), + ...options, + }; + + applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], getSDKSource()); + + return initWithoutDefaultIntegrations(opts); +} diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 00bca1a9219c..c459fc8e25e8 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -1,8 +1,8 @@ -import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda'; import type { IntegrationFn } from '@sentry/core'; import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; -import { generateInstrumentOnce } from '@sentry/node'; -import { eventContextExtractor } from '../utils'; +import { captureException, generateInstrumentOnce } from '@sentry/node'; +import { eventContextExtractor, markEventUnhandled } from '../utils'; +import { AwsLambdaInstrumentation } from './instrumentation-aws-lambda/instrumentation'; interface AwsLambdaOptions { /** @@ -27,6 +27,11 @@ export const instrumentAwsLambda = generateInstrumentOnce( span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.otel.aws-lambda'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'function.aws.lambda'); }, + responseHook(_span, { err }) { + if (err) { + captureException(err, scope => markEventUnhandled(scope, 'auto.function.aws-serverless.otel')); + } + }, }; }, ); diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts new file mode 100644 index 000000000000..39b63551b2aa --- /dev/null +++ b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts @@ -0,0 +1,526 @@ +// Vendored and modified from: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/instrumentation.ts +// Modifications: +// - Added Sentry `wrapHandler` around the OTel patch handler. +// - Cancel init when handler string is invalid (TS) +// - Hardcoded package version and name +/* eslint-disable */ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { + Attributes, + Context as OtelContext, + MeterProvider, + Span, + TextMapGetter, + TracerProvider, +} from '@opentelemetry/api'; +import { + context as otelContext, + diag, + propagation, + ROOT_CONTEXT, + SpanKind, + SpanStatusCode, + trace, +} from '@opentelemetry/api'; +import { + InstrumentationBase, + InstrumentationNodeModuleDefinition, + InstrumentationNodeModuleFile, + isWrapped, + safeExecuteInTheMiddle, +} from '@opentelemetry/instrumentation'; +import { + ATTR_URL_FULL, + SEMATTRS_FAAS_EXECUTION, + SEMRESATTRS_CLOUD_ACCOUNT_ID, + SEMRESATTRS_FAAS_ID, +} from '@opentelemetry/semantic-conventions'; +import type { APIGatewayProxyEventHeaders, Callback, Context, Handler } from 'aws-lambda'; +import * as fs from 'fs'; +import * as path from 'path'; +import type { LambdaModule } from './internal-types'; +import { ATTR_FAAS_COLDSTART } from './semconv'; +import type { AwsLambdaInstrumentationConfig, EventContextExtractor } from './types'; +import { wrapHandler } from '../../sdk'; +import { SDK_VERSION } from '@sentry/core'; + +// OpenTelemetry package version was 0.54.0 at time of vendoring. +const PACKAGE_VERSION = SDK_VERSION; +const PACKAGE_NAME = '@sentry/instrumentation-aws-lambda'; + +const headerGetter: TextMapGetter = { + keys(carrier): string[] { + return Object.keys(carrier); + }, + get(carrier, key: string) { + return carrier[key]; + }, +}; + +export const lambdaMaxInitInMilliseconds = 10_000; + +/** + * + */ +export class AwsLambdaInstrumentation extends InstrumentationBase { + private declare _traceForceFlusher?: () => Promise; + private declare _metricForceFlusher?: () => Promise; + + constructor(config: AwsLambdaInstrumentationConfig = {}) { + super(PACKAGE_NAME, PACKAGE_VERSION, config); + } + + /** + * + */ + init() { + const taskRoot = process.env.LAMBDA_TASK_ROOT; + const handlerDef = this.getConfig().lambdaHandler ?? process.env._HANDLER; + + // _HANDLER and LAMBDA_TASK_ROOT are always defined in Lambda but guard bail out if in the future this changes. + if (!taskRoot || !handlerDef) { + this._diag.debug('Skipping lambda instrumentation: no _HANDLER/lambdaHandler or LAMBDA_TASK_ROOT.', { + taskRoot, + handlerDef, + }); + return []; + } + + const handler = path.basename(handlerDef); + const moduleRoot = handlerDef.substring(0, handlerDef.length - handler.length); + + const [module, functionName] = handler.split('.', 2); + + if (!module || !functionName) { + this._diag.warn('Invalid handler definition', { + handler, + moduleRoot, + module, + }); + return []; + } + + // Lambda loads user function using an absolute path. + let filename = path.resolve(taskRoot, moduleRoot, module); + if (!filename.endsWith('.js')) { + // It's impossible to know in advance if the user has a js, mjs or cjs file. + // Check that the .js file exists otherwise fallback to the next known possibilities (.mjs, .cjs). + try { + fs.statSync(`${filename}.js`); + filename += '.js'; + } catch (e) { + try { + fs.statSync(`${filename}.mjs`); + // fallback to .mjs (ESM) + filename += '.mjs'; + } catch (e2) { + try { + fs.statSync(`${filename}.cjs`); + // fallback to .cjs (CommonJS) + filename += '.cjs'; + } catch (e3) { + this._diag.warn( + 'No handler file was able to resolved with one of the known extensions for the file', + filename, + ); + } + } + } + } + + diag.debug('Instrumenting lambda handler', { + taskRoot, + handlerDef, + handler, + moduleRoot, + module, + filename, + functionName, + }); + + const lambdaStartTime = this.getConfig().lambdaStartTime || Date.now() - Math.floor(1000 * process.uptime()); + + return [ + new InstrumentationNodeModuleDefinition( + // NB: The patching infrastructure seems to match names backwards, this must be the filename, while + // InstrumentationNodeModuleFile must be the module name. + filename, + ['*'], + undefined, + undefined, + [ + new InstrumentationNodeModuleFile( + module, + ['*'], + (moduleExports: LambdaModule) => { + if (isWrapped(moduleExports[functionName])) { + this._unwrap(moduleExports, functionName); + } + this._wrap(moduleExports, functionName, this._getHandler(lambdaStartTime)); + return moduleExports; + }, + (moduleExports?: LambdaModule) => { + if (moduleExports == null) return; + this._unwrap(moduleExports, functionName); + }, + ), + ], + ), + ]; + } + + /** + * + */ + private _getHandler(handlerLoadStartTime: number) { + return (original: Handler) => { + return wrapHandler(this._getPatchHandler(original, handlerLoadStartTime)); + }; + } + + /** + * + */ + private _getPatchHandler(original: Handler, lambdaStartTime: number) { + diag.debug('patch handler function'); + const plugin = this; + + let requestHandledBefore = false; + let requestIsColdStart = true; + + /** + * + */ + function _onRequest(): void { + if (requestHandledBefore) { + // Non-first requests cannot be coldstart. + requestIsColdStart = false; + } else { + if (process.env.AWS_LAMBDA_INITIALIZATION_TYPE === 'provisioned-concurrency') { + // If sandbox environment is initialized with provisioned concurrency, + // even the first requests should not be considered as coldstart. + requestIsColdStart = false; + } else { + // Check whether it is proactive initialization or not: + // https://aaronstuyvenberg.com/posts/understanding-proactive-initialization + const passedTimeSinceHandlerLoad: number = Date.now() - lambdaStartTime; + const proactiveInitialization: boolean = passedTimeSinceHandlerLoad > lambdaMaxInitInMilliseconds; + + // If sandbox has been initialized proactively before the actual request, + // even the first requests should not be considered as coldstart. + requestIsColdStart = !proactiveInitialization; + } + requestHandledBefore = true; + } + } + + return function patchedHandler( + this: never, + // The event can be a user type, it truly is any. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: any, + context: Context, + callback: Callback, + ) { + _onRequest(); + + const config = plugin.getConfig(); + const parent = AwsLambdaInstrumentation._determineParent( + event, + context, + config.eventContextExtractor || AwsLambdaInstrumentation._defaultEventContextExtractor, + ); + + const name = context.functionName; + const span = plugin.tracer.startSpan( + name, + { + kind: SpanKind.SERVER, + attributes: { + [SEMATTRS_FAAS_EXECUTION]: context.awsRequestId, + [SEMRESATTRS_FAAS_ID]: context.invokedFunctionArn, + [SEMRESATTRS_CLOUD_ACCOUNT_ID]: AwsLambdaInstrumentation._extractAccountId(context.invokedFunctionArn), + [ATTR_FAAS_COLDSTART]: requestIsColdStart, + ...AwsLambdaInstrumentation._extractOtherEventFields(event), + }, + }, + parent, + ); + + const { requestHook } = config; + if (requestHook) { + safeExecuteInTheMiddle( + () => requestHook(span, { event, context }), + e => { + if (e) diag.error('aws-lambda instrumentation: requestHook error', e); + }, + true, + ); + } + + return otelContext.with(trace.setSpan(parent, span), () => { + // Lambda seems to pass a callback even if handler is of Promise form, so we wrap all the time before calling + // the handler and see if the result is a Promise or not. In such a case, the callback is usually ignored. If + // the handler happened to both call the callback and complete a returned Promise, whichever happens first will + // win and the latter will be ignored. + const wrappedCallback = plugin._wrapCallback(callback, span); + const maybePromise = safeExecuteInTheMiddle( + () => original.apply(this, [event, context, wrappedCallback]), + error => { + if (error != null) { + // Exception thrown synchronously before resolving callback / promise. + plugin._applyResponseHook(span, error); + plugin._endSpan(span, error, () => {}); + } + }, + ) as Promise<{}> | undefined; + if (typeof maybePromise?.then === 'function') { + return maybePromise.then( + value => { + plugin._applyResponseHook(span, null, value); + return new Promise(resolve => plugin._endSpan(span, undefined, () => resolve(value))); + }, + (err: Error | string) => { + plugin._applyResponseHook(span, err); + return new Promise((resolve, reject) => plugin._endSpan(span, err, () => reject(err))); + }, + ); + } + return maybePromise; + }); + }; + } + + /** + * + */ + override setTracerProvider(tracerProvider: TracerProvider) { + super.setTracerProvider(tracerProvider); + this._traceForceFlusher = this._traceForceFlush(tracerProvider); + } + + /** + * + */ + private _traceForceFlush(tracerProvider: TracerProvider) { + if (!tracerProvider) return undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let currentProvider: any = tracerProvider; + + if (typeof currentProvider.getDelegate === 'function') { + currentProvider = currentProvider.getDelegate(); + } + + if (typeof currentProvider.forceFlush === 'function') { + return currentProvider.forceFlush.bind(currentProvider); + } + + return undefined; + } + + /** + * + */ + override setMeterProvider(meterProvider: MeterProvider) { + super.setMeterProvider(meterProvider); + this._metricForceFlusher = this._metricForceFlush(meterProvider); + } + + /** + * + */ + private _metricForceFlush(meterProvider: MeterProvider) { + if (!meterProvider) return undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentProvider: any = meterProvider; + + if (typeof currentProvider.forceFlush === 'function') { + return currentProvider.forceFlush.bind(currentProvider); + } + + return undefined; + } + + /** + * + */ + private _wrapCallback(original: Callback, span: Span): Callback { + const plugin = this; + return function wrappedCallback(this: never, err, res) { + diag.debug('executing wrapped lookup callback function'); + plugin._applyResponseHook(span, err, res); + + plugin._endSpan(span, err, () => { + diag.debug('executing original lookup callback function'); + return original.apply(this, [err, res]); + }); + }; + } + + /** + * + */ + private _endSpan(span: Span, err: string | Error | null | undefined, callback: () => void) { + if (err) { + span.recordException(err); + } + + let errMessage; + if (typeof err === 'string') { + errMessage = err; + } else if (err) { + errMessage = err.message; + } + if (errMessage) { + span.setStatus({ + code: SpanStatusCode.ERROR, + message: errMessage, + }); + } + + span.end(); + + const flushers = []; + if (this._traceForceFlusher) { + flushers.push(this._traceForceFlusher()); + } else { + diag.debug( + 'Spans may not be exported for the lambda function because we are not force flushing before callback.', + ); + } + if (this._metricForceFlusher) { + flushers.push(this._metricForceFlusher()); + } else { + diag.debug( + 'Metrics may not be exported for the lambda function because we are not force flushing before callback.', + ); + } + + Promise.all(flushers).then(callback, callback); + } + + /** + * + */ + private _applyResponseHook(span: Span, err?: Error | string | null, res?: any) { + const { responseHook } = this.getConfig(); + if (responseHook) { + safeExecuteInTheMiddle( + () => responseHook(span, { err, res }), + e => { + if (e) diag.error('aws-lambda instrumentation: responseHook error', e); + }, + true, + ); + } + } + + /** + * + */ + private static _extractAccountId(arn: string): string | undefined { + const parts = arn.split(':'); + if (parts.length >= 5) { + return parts[4]; + } + return undefined; + } + + /** + * + */ + private static _defaultEventContextExtractor(event: any): OtelContext { + // The default extractor tries to get sampled trace header from HTTP headers. + const httpHeaders = event.headers || {}; + return propagation.extract(otelContext.active(), httpHeaders, headerGetter); + } + + /** + * + */ + private static _extractOtherEventFields(event: any): Attributes { + const answer: Attributes = {}; + const fullUrl = this._extractFullUrl(event); + if (fullUrl) { + answer[ATTR_URL_FULL] = fullUrl; + } + return answer; + } + + /** + * + */ + private static _extractFullUrl(event: any): string | undefined { + // API gateway encodes a lot of url information in various places to recompute this + if (!event.headers) { + return undefined; + } + // Helper function to deal with case variations (instead of making a tolower() copy of the headers) + /** + * + */ + function findAny(event: any, key1: string, key2: string): string | undefined { + return event.headers[key1] ?? event.headers[key2]; + } + const host = findAny(event, 'host', 'Host'); + const proto = findAny(event, 'x-forwarded-proto', 'X-Forwarded-Proto'); + const port = findAny(event, 'x-forwarded-port', 'X-Forwarded-Port'); + if (!(proto && host && (event.path || event.rawPath))) { + return undefined; + } + let answer = `${proto}://${host}`; + if (port) { + answer += `:${port}`; + } + answer += event.path ?? event.rawPath; + if (event.queryStringParameters) { + let first = true; + for (const key in event.queryStringParameters) { + answer += first ? '?' : '&'; + answer += encodeURIComponent(key); + answer += '='; + answer += encodeURIComponent(event.queryStringParameters[key]); + first = false; + } + } + return answer; + } + + /** + * + */ + private static _determineParent( + event: any, + context: Context, + eventContextExtractor: EventContextExtractor, + ): OtelContext { + const extractedContext = safeExecuteInTheMiddle( + () => eventContextExtractor(event, context), + e => { + if (e) diag.error('aws-lambda instrumentation: eventContextExtractor error', e); + }, + true, + ); + if (trace.getSpan(extractedContext)?.spanContext()) { + return extractedContext; + } + return ROOT_CONTEXT; + } +} diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/internal-types.ts b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/internal-types.ts new file mode 100644 index 000000000000..34894e010fa1 --- /dev/null +++ b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/internal-types.ts @@ -0,0 +1,19 @@ +// Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/internal-types.ts +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Handler } from 'aws-lambda'; + +export type LambdaModule = Record; diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/semconv.ts b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/semconv.ts new file mode 100644 index 000000000000..a10eff490322 --- /dev/null +++ b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/semconv.ts @@ -0,0 +1,29 @@ +// Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/semconv.ts +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by this package. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +/** + * A boolean that is true if the serverless function is executed for the first time (aka cold-start). + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_FAAS_COLDSTART = 'faas.coldstart'; diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/types.ts b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/types.ts new file mode 100644 index 000000000000..1b7603281ba0 --- /dev/null +++ b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/types.ts @@ -0,0 +1,39 @@ +// Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/types.ts +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Context as OtelContext, Span } from '@opentelemetry/api'; +import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; +import type { Context } from 'aws-lambda'; + +export type RequestHook = (span: Span, hookInfo: { event: any; context: Context }) => void; + +export type ResponseHook = ( + span: Span, + hookInfo: { + err?: Error | string | null; + res?: any; + }, +) => void; + +export type EventContextExtractor = (event: any, context: Context) => OtelContext; +export interface AwsLambdaInstrumentationConfig extends InstrumentationConfig { + requestHook?: RequestHook; + responseHook?: ResponseHook; + eventContextExtractor?: EventContextExtractor; + lambdaHandler?: string; + lambdaStartTime?: number; +} diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 9bad62f3a848..e6f7d5f3a4f0 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -1,23 +1,10 @@ -import type { Integration, Options, Scope } from '@sentry/core'; -import { applySdkMetadata, consoleSandbox, debug, getSDKSource } from '@sentry/core'; -import type { NodeClient, NodeOptions } from '@sentry/node'; -import { - captureException, - captureMessage, - flush, - getCurrentScope, - getDefaultIntegrationsWithoutPerformance, - initWithoutDefaultIntegrations, - withScope, -} from '@sentry/node'; +import type { Scope } from '@sentry/core'; +import { consoleSandbox, debug } from '@sentry/core'; +import { captureException, captureMessage, flush, getCurrentScope, withScope } from '@sentry/node'; import type { Context, Handler } from 'aws-lambda'; -import { existsSync } from 'fs'; -import { basename, resolve } from 'path'; import { performance } from 'perf_hooks'; import { types } from 'util'; import { DEBUG_BUILD } from './debug-build'; -import { awsIntegration } from './integration/aws'; -import { awsLambdaIntegration } from './integration/awslambda'; import { markEventUnhandled } from './utils'; const { isPromise } = types; @@ -53,42 +40,6 @@ export interface WrapperOptions { startTrace: boolean; } -/** - * Get the default integrations for the AWSLambda SDK. - */ -// NOTE: in awslambda-auto.ts, we also call the original `getDefaultIntegrations` from `@sentry/node` to load performance integrations. -// If at some point we need to filter a node integration out for good, we need to make sure to also filter it out there. -export function getDefaultIntegrations(_options: Options): Integration[] { - return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; -} - -/** - * Initializes the Sentry AWS Lambda SDK. - * - * @param options Configuration options for the SDK, @see {@link AWSLambdaOptions}. - */ -export function init(options: NodeOptions = {}): NodeClient | undefined { - const opts = { - defaultIntegrations: getDefaultIntegrations(options), - ...options, - }; - - applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], getSDKSource()); - - return initWithoutDefaultIntegrations(opts); -} - -/** */ -function tryRequire(taskRoot: string, subdir: string, mod: string): T { - const lambdaStylePath = resolve(taskRoot, subdir, mod); - if (existsSync(lambdaStylePath) || existsSync(`${lambdaStylePath}.js`)) { - // Lambda-style path - return require(lambdaStylePath); - } - // Node-style path - return require(require.resolve(mod, { paths: [taskRoot, subdir] })); -} - /** */ function isPromiseAllSettledResult(result: T[]): boolean { return result.every( @@ -108,56 +59,15 @@ function getRejectedReasons(results: PromiseSettledResult[]): T[] { }, []); } -/** */ -export function tryPatchHandler(taskRoot: string, handlerPath: string): void { - type HandlerBag = HandlerModule | Handler | null | undefined; - - interface HandlerModule { - [key: string]: HandlerBag; - } - - const handlerDesc = basename(handlerPath); - const match = handlerDesc.match(/^([^.]*)\.(.*)$/); - if (!match) { - DEBUG_BUILD && debug.error(`Bad handler ${handlerDesc}`); - return; - } - - const [, handlerMod = '', handlerName = ''] = match; - - let obj: HandlerBag; - try { - const handlerDir = handlerPath.substring(0, handlerPath.indexOf(handlerDesc)); - obj = tryRequire(taskRoot, handlerDir, handlerMod); - } catch (e) { - DEBUG_BUILD && debug.error(`Cannot require ${handlerPath} in ${taskRoot}`, e); - return; - } - - let mod: HandlerBag; - let functionName: string | undefined; - handlerName.split('.').forEach(name => { - mod = obj; - obj = obj && (obj as HandlerModule)[name]; - functionName = name; +/** + * TODO(v11): Remove this function + * @deprecated This function is no longer used and will be removed in a future major version. + */ +export function tryPatchHandler(_taskRoot: string, _handlerPath: string): void { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('The `tryPatchHandler` function is deprecated and will be removed in a future major version.'); }); - if (!obj) { - DEBUG_BUILD && debug.error(`${handlerPath} is undefined or not exported`); - return; - } - if (typeof obj !== 'function') { - DEBUG_BUILD && debug.error(`${handlerPath} is not a function`); - return; - } - - // Check for prototype pollution - if (functionName === '__proto__' || functionName === 'constructor' || functionName === 'prototype') { - DEBUG_BUILD && debug.error(`Invalid handler name: ${functionName}`); - return; - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - (mod as HandlerModule)[functionName!] = wrapHandler(obj); } /** @@ -301,6 +211,8 @@ export function wrapHandler( }); } } catch (e) { + // Errors should already captured in the instrumentation's `responseHook`, + // we capture them here just to be safe. Double captures are deduplicated by the SDK. captureException(e, scope => markEventUnhandled(scope, 'auto.function.aws-serverless.handler')); throw e; } finally { diff --git a/packages/aws-serverless/test/sdk.test.ts b/packages/aws-serverless/test/sdk.test.ts index 648ef4caeaec..58bb04a234b9 100644 --- a/packages/aws-serverless/test/sdk.test.ts +++ b/packages/aws-serverless/test/sdk.test.ts @@ -1,7 +1,8 @@ import type { Event } from '@sentry/core'; import type { Callback, Handler } from 'aws-lambda'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { init, wrapHandler } from '../src/sdk'; +import { init } from '../src/init'; +import { wrapHandler } from '../src/sdk'; const mockFlush = vi.fn((...args) => Promise.resolve(args)); const mockWithScope = vi.fn(); diff --git a/yarn.lock b/yarn.lock index 4175e91b712c..16f91521a991 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5876,15 +5876,6 @@ "@opentelemetry/instrumentation" "^0.203.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-aws-lambda@0.54.0": - version "0.54.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.54.0.tgz#835263593aa988ec460e840d3d47110392aaf92e" - integrity sha512-uiYI+kcMUJ/H9cxAwB8c9CaG8behLRgcYSOEA8M/tMQ54Y1ZmzAuEE3QKOi21/s30x5Q+by9g7BwiVfDtqzeMA== - dependencies: - "@opentelemetry/instrumentation" "^0.203.0" - "@opentelemetry/semantic-conventions" "^1.27.0" - "@types/aws-lambda" "8.10.150" - "@opentelemetry/instrumentation-aws-sdk@0.56.0": version "0.56.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.56.0.tgz#a65cd88351b7bd8566413798764679295166754a" @@ -6140,10 +6131,10 @@ "@opentelemetry/resources" "2.0.0" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0": - version "1.34.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.34.0.tgz#8b6a46681b38a4d5947214033ac48128328c1738" - integrity sha512-aKcOkyrorBGlajjRdVoJWHTxfxO1vCNHLJVlSDaRHDIdjU+pX8IYQPvPDkYiujKLbRnWU+1TBwEt0QRgSm4SGA== +"@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0": + version "1.36.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/semantic-conventions/-/semantic-conventions-1.36.0.tgz#149449bd4df4d0464220915ad4164121e0d75d4d" + integrity sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ== "@opentelemetry/sql-common@^0.41.0": version "0.41.0" @@ -7912,7 +7903,7 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" integrity sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw== -"@types/aws-lambda@8.10.150", "@types/aws-lambda@^8.10.62": +"@types/aws-lambda@^8.10.62": version "8.10.150" resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.150.tgz#4998b238750ec389a326a7cdb625808834036bd3" integrity sha512-AX+AbjH/rH5ezX1fbK8onC/a+HyQHo7QGmvoxAE42n22OsciAxvZoZNEr22tbXs8WfP1nIsBjKDpgPm3HjOZbA== From 382af9b8187fb4ea965abcea607c55a10aeaa415 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 25 Aug 2025 20:39:00 +0200 Subject: [PATCH 12/16] fix(core): Instrument invoke_agent root span, and support Vercel `ai` v5 (#17395) Main changes are: 1. Instrument root `invoke_agent` span: This addresses that transaction event.spans only includes child spans, ensuring proper correlation beyond just children. 2. Refactor `vercelai` integration layout in core: Restructured the vercelai folder to mirror our other AI agent integrations (split constants/utils/index) for clarity and easier maintenance. 3. Link errors in Vercel AI v5 (metadata-based): Added support to capture and link tool errors that are returned as result metadata (not thrown). We correlate tool-call IDs to spans and set the trace/span context when capturing the error, so errors are attached to the right tool-call/trace even without a thrown exception. 4. Added tests to test vercel ai v5 --- .../suites/tracing/vercelai/test.ts | 84 --- .../vercelai/v5/instrument-with-pii.mjs | 11 + .../suites/tracing/vercelai/v5/instrument.mjs | 10 + .../vercelai/v5/scenario-error-in-tool.mjs | 36 ++ .../{scenario-v5.mjs => v5/scenario.mjs} | 19 +- .../suites/tracing/vercelai/v5/test.ts | 564 ++++++++++++++++++ packages/core/src/index.ts | 1 + .../core/src/utils/vercel-ai/constants.ts | 5 + .../{vercel-ai.ts => vercel-ai/index.ts} | 83 +-- packages/core/src/utils/vercel-ai/types.ts | 4 + packages/core/src/utils/vercel-ai/utils.ts | 72 +++ .../{ => vercel-ai}/vercel-ai-attributes.ts | 0 .../tracing/vercelai/instrumentation.ts | 108 +++- 13 files changed, 839 insertions(+), 158 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v5/scenario-error-in-tool.mjs rename dev-packages/node-integration-tests/suites/tracing/vercelai/{scenario-v5.mjs => v5/scenario.mjs} (82%) create mode 100644 dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts create mode 100644 packages/core/src/utils/vercel-ai/constants.ts rename packages/core/src/utils/{vercel-ai.ts => vercel-ai/index.ts} (85%) create mode 100644 packages/core/src/utils/vercel-ai/types.ts create mode 100644 packages/core/src/utils/vercel-ai/utils.ts rename packages/core/src/utils/{ => vercel-ai}/vercel-ai-attributes.ts (100%) diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts index 720345cc7d86..94fd0dde8486 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -197,73 +197,6 @@ describe('Vercel AI integration', () => { ]), }; - // Todo: Add missing attribute spans for v5 - // Right now only second span is recorded as it's manually opted in via explicit telemetry option - const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_V5 = { - transaction: 'main', - spans: expect.arrayContaining([ - expect.objectContaining({ - data: { - 'vercel.ai.model.id': 'mock-model-id', - 'vercel.ai.model.provider': 'mock-provider', - 'vercel.ai.operationId': 'ai.generateText', - 'vercel.ai.pipeline.name': 'generateText', - 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', - 'vercel.ai.response.finishReason': 'stop', - 'gen_ai.response.text': expect.any(String), - 'vercel.ai.settings.maxRetries': 2, - // 'vercel.ai.settings.maxSteps': 1, - 'vercel.ai.streaming': false, - 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', - 'gen_ai.response.model': 'mock-model-id', - 'gen_ai.usage.input_tokens': 10, - 'gen_ai.usage.output_tokens': 20, - 'gen_ai.usage.total_tokens': 30, - 'operation.name': 'ai.generateText', - 'sentry.op': 'gen_ai.invoke_agent', - 'sentry.origin': 'auto.vercelai.otel', - }, - description: 'generateText', - op: 'gen_ai.invoke_agent', - origin: 'auto.vercelai.otel', - status: 'ok', - }), - // doGenerate - expect.objectContaining({ - data: { - 'sentry.origin': 'auto.vercelai.otel', - 'sentry.op': 'gen_ai.generate_text', - 'operation.name': 'ai.generateText.doGenerate', - 'vercel.ai.operationId': 'ai.generateText.doGenerate', - 'vercel.ai.model.provider': 'mock-provider', - 'vercel.ai.model.id': 'mock-model-id', - 'vercel.ai.settings.maxRetries': 2, - 'gen_ai.system': 'mock-provider', - 'gen_ai.request.model': 'mock-model-id', - 'vercel.ai.pipeline.name': 'generateText.doGenerate', - 'vercel.ai.streaming': false, - 'vercel.ai.response.finishReason': 'stop', - 'vercel.ai.response.model': 'mock-model-id', - 'vercel.ai.response.id': expect.any(String), - 'gen_ai.response.text': 'Second span here!', - 'vercel.ai.response.timestamp': expect.any(String), - // 'vercel.ai.prompt.format': expect.any(String), - 'gen_ai.request.messages': expect.any(String), - 'gen_ai.response.finish_reasons': ['stop'], - 'gen_ai.usage.input_tokens': 10, - 'gen_ai.usage.output_tokens': 20, - 'gen_ai.response.id': expect.any(String), - 'gen_ai.response.model': 'mock-model-id', - 'gen_ai.usage.total_tokens': 30, - }, - description: 'generate_text mock-model-id', - op: 'gen_ai.generate_text', - origin: 'auto.vercelai.otel', - status: 'ok', - }), - ]), - }; - const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { transaction: 'main', spans: expect.arrayContaining([ @@ -605,23 +538,6 @@ describe('Vercel AI integration', () => { }); }); - // Test with specific Vercel AI v5 version - createEsmAndCjsTests( - __dirname, - 'scenario-v5.mjs', - 'instrument.mjs', - (createRunner, test) => { - test('creates ai related spans with v5', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_V5 }).start().completed(); - }); - }, - { - additionalDependencies: { - ai: '^5.0.0', - }, - }, - ); - createEsmAndCjsTests(__dirname, 'scenario-error-in-tool-express.mjs', 'instrument.mjs', (createRunner, test) => { test('captures error in tool in express server', async () => { const expectedTransaction = { diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs new file mode 100644 index 000000000000..b798e21228f5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument-with-pii.mjs @@ -0,0 +1,11 @@ +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, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs new file mode 100644 index 000000000000..5e898ee1949d --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/instrument.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + integrations: [Sentry.vercelAIIntegration()], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/scenario-error-in-tool.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/scenario-error-in-tool.mjs new file mode 100644 index 000000000000..9ba3ac4b7d4a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/scenario-error-in-tool.mjs @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import { generateText, tool } from 'ai'; +import { MockLanguageModelV2 } from 'ai/test'; +import { z } from 'zod'; + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + await generateText({ + model: new MockLanguageModelV2({ + doGenerate: async () => ({ + finishReason: 'tool-calls', + usage: { inputTokens: 15, outputTokens: 25, totalTokens: 40 }, + content: [ + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'getWeather', + input: JSON.stringify({ location: 'San Francisco' }), + }, + ], + }), + }), + tools: { + getWeather: tool({ + inputSchema: z.object({ location: z.string() }), + execute: async () => { + throw new Error('Error in tool'); + }, + }), + }, + prompt: 'What is the weather in San Francisco?', + }); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-v5.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/scenario.mjs similarity index 82% rename from dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-v5.mjs rename to dev-packages/node-integration-tests/suites/tracing/vercelai/v5/scenario.mjs index 8cfe6d64ad05..9ef1b8000741 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/scenario-v5.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/scenario.mjs @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/node'; -import { generateText } from 'ai'; +import { generateText, tool } from 'ai'; import { MockLanguageModelV2 } from 'ai/test'; import { z } from 'zod'; @@ -35,24 +35,21 @@ async function run() { doGenerate: async () => ({ finishReason: 'tool-calls', usage: { inputTokens: 15, outputTokens: 25, totalTokens: 40 }, - content: [{ type: 'text', text: 'Tool call completed!' }], - toolCalls: [ + content: [ { - toolCallType: 'function', + type: 'tool-call', toolCallId: 'call-1', toolName: 'getWeather', - args: '{ "location": "San Francisco" }', + input: JSON.stringify({ location: 'San Francisco' }), }, ], }), }), tools: { - getWeather: { - parameters: z.object({ location: z.string() }), - execute: async args => { - return `Weather in ${args.location}: Sunny, 72°F`; - }, - }, + getWeather: tool({ + inputSchema: z.object({ location: z.string() }), + execute: async ({ location }) => `Weather in ${location}: Sunny, 72°F`, + }), }, prompt: 'What is the weather in San Francisco?', }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts new file mode 100644 index 000000000000..470080658dfa --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/v5/test.ts @@ -0,0 +1,564 @@ +import type { Event } from '@sentry/node'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner'; + +describe('Vercel AI integration (V5)', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry but not record inputs/outputs when sendDefaultPii: false + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - explicitly enabled telemetry but recordInputs/recordOutputs not set, should not record when sendDefaultPii: false + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicit telemetry enabled, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicit telemetry enabled call + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fifth span - tool call generateText span + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Sixth span - tool call doGenerate span + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Seventh span - tool call execution span + expect.objectContaining({ + data: { + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - no telemetry config, should enable telemetry AND record inputs/outputs when sendDefaultPii: true + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the first span?"}', + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': 'First span here!', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the first span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Second span - doGenerate for first call, should also include input/output fields when sendDefaultPii: true + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'gen_ai.response.text': 'First span here!', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Third span - explicitly enabled telemetry, should record inputs/outputs regardless of sendDefaultPii + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"Where is the second span?"}', + 'vercel.ai.response.finishReason': 'stop', + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"Where is the second span?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.usage.total_tokens': 30, + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fourth span - doGenerate for explicitly enabled telemetry call + expect.objectContaining({ + data: { + 'sentry.origin': 'auto.vercelai.otel', + 'sentry.op': 'gen_ai.generate_text', + 'operation.name': 'ai.generateText.doGenerate', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.settings.maxRetries': 2, + 'gen_ai.system': 'mock-provider', + 'gen_ai.request.model': 'mock-model-id', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.streaming': false, + 'vercel.ai.response.finishReason': 'stop', + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.id': expect.any(String), + 'gen_ai.response.text': expect.any(String), + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.request.messages': expect.any(String), + 'gen_ai.response.finish_reasons': ['stop'], + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 20, + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.total_tokens': 30, + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Fifth span - tool call generateText span (should include prompts when sendDefaultPii: true) + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'vercel.ai.response.finishReason': 'tool-calls', + // 'gen_ai.response.text': 'Tool call completed!', + 'gen_ai.response.tool_calls': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Sixth span - tool call doGenerate span (should include prompts when sendDefaultPii: true) + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'gen_ai.request.messages': expect.any(String), + 'vercel.ai.prompt.toolChoice': expect.any(String), + 'gen_ai.request.available_tools': expect.any(Array), + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + // 'gen_ai.response.text': 'Tool call completed!', // TODO: look into why this is not being set + 'vercel.ai.response.timestamp': expect.any(String), + 'gen_ai.response.tool_calls': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + // Seventh span - tool call execution span + expect.objectContaining({ + data: { + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.input': expect.any(String), + 'gen_ai.tool.output': expect.any(String), + 'gen_ai.tool.type': 'function', + 'operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans with sendDefaultPii: false', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^5.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument-with-pii.mjs', + (createRunner, test) => { + test('creates ai related spans with sendDefaultPii: true', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^5.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario-error-in-tool.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('captures error in tool', async () => { + const expectedTransaction = { + transaction: 'main', + spans: expect.arrayContaining([ + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText', + 'vercel.ai.pipeline.name': 'generateText', + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText', + 'sentry.op': 'gen_ai.invoke_agent', + 'sentry.origin': 'auto.vercelai.otel', + 'vercel.ai.response.finishReason': 'tool-calls', + }, + description: 'generateText', + op: 'gen_ai.invoke_agent', + origin: 'auto.vercelai.otel', + }), + expect.objectContaining({ + data: { + 'vercel.ai.model.id': 'mock-model-id', + 'vercel.ai.model.provider': 'mock-provider', + 'vercel.ai.operationId': 'ai.generateText.doGenerate', + 'vercel.ai.pipeline.name': 'generateText.doGenerate', + 'vercel.ai.response.finishReason': 'tool-calls', + 'vercel.ai.response.id': expect.any(String), + 'vercel.ai.response.model': 'mock-model-id', + 'vercel.ai.response.timestamp': expect.any(String), + 'vercel.ai.settings.maxRetries': 2, + 'vercel.ai.streaming': false, + 'gen_ai.request.model': 'mock-model-id', + 'gen_ai.response.finish_reasons': ['tool-calls'], + 'gen_ai.response.id': expect.any(String), + 'gen_ai.response.model': 'mock-model-id', + 'gen_ai.system': 'mock-provider', + 'gen_ai.usage.input_tokens': 15, + 'gen_ai.usage.output_tokens': 25, + 'gen_ai.usage.total_tokens': 40, + 'operation.name': 'ai.generateText.doGenerate', + 'sentry.op': 'gen_ai.generate_text', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'generate_text mock-model-id', + op: 'gen_ai.generate_text', + origin: 'auto.vercelai.otel', + status: 'ok', + }), + expect.objectContaining({ + data: { + 'vercel.ai.operationId': 'ai.toolCall', + 'gen_ai.tool.call.id': 'call-1', + 'gen_ai.tool.name': 'getWeather', + 'gen_ai.tool.type': 'function', + 'operation.name': 'ai.toolCall', + 'sentry.op': 'gen_ai.execute_tool', + 'sentry.origin': 'auto.vercelai.otel', + }, + description: 'execute_tool getWeather', + op: 'gen_ai.execute_tool', + origin: 'auto.vercelai.otel', + status: 'unknown_error', + }), + ]), + }; + + const expectedError = { + level: 'error', + tags: expect.objectContaining({ + 'vercel.ai.tool.name': 'getWeather', + 'vercel.ai.tool.callId': 'call-1', + }), + }; + + let transactionEvent: Event | undefined; + let errorEvent: Event | undefined; + + await createRunner() + .expect({ + transaction: transaction => { + transactionEvent = transaction; + }, + }) + .expect({ + event: event => { + errorEvent = event; + }, + }) + .start() + .completed(); + + expect(transactionEvent).toBeDefined(); + expect(transactionEvent).toMatchObject(expectedTransaction); + + expect(errorEvent).toBeDefined(); + expect(errorEvent).toMatchObject(expectedError); + + // Trace id should be the same for the transaction and error event + expect(transactionEvent!.contexts!.trace!.trace_id).toBe(errorEvent!.contexts!.trace!.trace_id); + }); + }, + { + additionalDependencies: { + ai: '^5.0.0', + }, + }, + ); + + createEsmAndCjsTests( + __dirname, + 'scenario.mjs', + 'instrument.mjs', + (createRunner, test) => { + test('creates ai related spans with v5', async () => { + await createRunner().expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }).start().completed(); + }); + }, + { + additionalDependencies: { + ai: '^5.0.0', + }, + }, + ); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f81a6937d89c..4592bc2bd71c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,6 +121,7 @@ export type { ReportDialogOptions } from './report-dialog'; export { _INTERNAL_captureLog, _INTERNAL_flushLogsBuffer, _INTERNAL_captureSerializedLog } from './logs/exports'; export { consoleLoggingIntegration } from './logs/console-integration'; export { addVercelAiProcessors } from './utils/vercel-ai'; +export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './utils/vercel-ai/utils'; export { instrumentOpenAiClient } from './utils/openai'; export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; diff --git a/packages/core/src/utils/vercel-ai/constants.ts b/packages/core/src/utils/vercel-ai/constants.ts new file mode 100644 index 000000000000..fe307b03e7fb --- /dev/null +++ b/packages/core/src/utils/vercel-ai/constants.ts @@ -0,0 +1,5 @@ +import type { Span } from '../../types-hoist/span'; + +// Global Map to track tool call IDs to their corresponding spans +// This allows us to capture tool errors and link them to the correct span +export const toolCallSpanMap = new Map(); diff --git a/packages/core/src/utils/vercel-ai.ts b/packages/core/src/utils/vercel-ai/index.ts similarity index 85% rename from packages/core/src/utils/vercel-ai.ts rename to packages/core/src/utils/vercel-ai/index.ts index 4ef437a1b922..4b317fe653d6 100644 --- a/packages/core/src/utils/vercel-ai.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -1,8 +1,11 @@ -import type { Client } from '../client'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; -import type { Event } from '../types-hoist/event'; -import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, SpanOrigin } from '../types-hoist/span'; -import { spanToJSON } from './spanUtils'; +import type { Client } from '../../client'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import type { Event } from '../../types-hoist/event'; +import type { Span, SpanAttributes, SpanAttributeValue, SpanJSON, SpanOrigin } from '../../types-hoist/span'; +import { spanToJSON } from '../spanUtils'; +import { toolCallSpanMap } from './constants'; +import type { TokenSummary } from './types'; +import { accumulateTokensForParent, applyAccumulatedTokens } from './utils'; import type { ProviderMetadata } from './vercel-ai-attributes'; import { AI_MODEL_ID_ATTRIBUTE, @@ -60,11 +63,6 @@ function onVercelAiSpanStart(span: Span): void { processGenerateSpan(span, name, attributes); } -interface TokenSummary { - inputTokens: number; - outputTokens: number; -} - function vercelAiEventProcessor(event: Event): Event { if (event.type === 'transaction' && event.spans) { // Map to accumulate token data by parent span ID @@ -86,6 +84,12 @@ function vercelAiEventProcessor(event: Event): Event { applyAccumulatedTokens(span, tokenAccumulator); } + + // Also apply to root when it is the invoke_agent pipeline + const trace = event.contexts?.trace; + if (trace && trace.op === 'gen_ai.invoke_agent') { + applyAccumulatedTokens(trace, tokenAccumulator); + } } return event; @@ -148,6 +152,15 @@ function processToolCallSpan(span: Span, attributes: SpanAttributes): void { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'gen_ai.execute_tool'); renameAttributeKey(attributes, AI_TOOL_CALL_NAME_ATTRIBUTE, 'gen_ai.tool.name'); renameAttributeKey(attributes, AI_TOOL_CALL_ID_ATTRIBUTE, 'gen_ai.tool.call.id'); + + // Store the span in our global map using the tool call ID + // This allows us to capture tool errors and link them to the correct span + const toolCallId = attributes['gen_ai.tool.call.id']; + + if (typeof toolCallId === 'string') { + toolCallSpanMap.set(toolCallId, span); + } + // https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/#gen-ai-tool-type if (!attributes['gen_ai.tool.type']) { span.setAttribute('gen_ai.tool.type', 'function'); @@ -262,56 +275,6 @@ export function addVercelAiProcessors(client: Client): void { client.addEventProcessor(Object.assign(vercelAiEventProcessor, { id: 'VercelAiEventProcessor' })); } -/** - * Accumulates token data from a span to its parent in the token accumulator map. - * This function extracts token usage from the current span and adds it to the - * accumulated totals for its parent span. - */ -function accumulateTokensForParent(span: SpanJSON, tokenAccumulator: Map): void { - const parentSpanId = span.parent_span_id; - if (!parentSpanId) { - return; - } - - const inputTokens = span.data[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]; - const outputTokens = span.data[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]; - - if (typeof inputTokens === 'number' || typeof outputTokens === 'number') { - const existing = tokenAccumulator.get(parentSpanId) || { inputTokens: 0, outputTokens: 0 }; - - if (typeof inputTokens === 'number') { - existing.inputTokens += inputTokens; - } - if (typeof outputTokens === 'number') { - existing.outputTokens += outputTokens; - } - - tokenAccumulator.set(parentSpanId, existing); - } -} - -/** - * Applies accumulated token data to the `gen_ai.invoke_agent` span. - * Only immediate children of the `gen_ai.invoke_agent` span are considered, - * since aggregation will automatically occur for each parent span. - */ -function applyAccumulatedTokens(span: SpanJSON, tokenAccumulator: Map): void { - const accumulated = tokenAccumulator.get(span.span_id); - if (!accumulated) { - return; - } - - if (accumulated.inputTokens > 0) { - span.data[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] = accumulated.inputTokens; - } - if (accumulated.outputTokens > 0) { - span.data[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] = accumulated.outputTokens; - } - if (accumulated.inputTokens > 0 || accumulated.outputTokens > 0) { - span.data['gen_ai.usage.total_tokens'] = accumulated.inputTokens + accumulated.outputTokens; - } -} - function addProviderMetadataToAttributes(attributes: SpanAttributes): void { const providerMetadata = attributes[AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE] as string | undefined; if (providerMetadata) { diff --git a/packages/core/src/utils/vercel-ai/types.ts b/packages/core/src/utils/vercel-ai/types.ts new file mode 100644 index 000000000000..03f22c415001 --- /dev/null +++ b/packages/core/src/utils/vercel-ai/types.ts @@ -0,0 +1,4 @@ +export interface TokenSummary { + inputTokens: number; + outputTokens: number; +} diff --git a/packages/core/src/utils/vercel-ai/utils.ts b/packages/core/src/utils/vercel-ai/utils.ts new file mode 100644 index 000000000000..85cc74db7f8d --- /dev/null +++ b/packages/core/src/utils/vercel-ai/utils.ts @@ -0,0 +1,72 @@ +import type { TraceContext } from '../../types-hoist/context'; +import type { Span, SpanJSON } from '../../types-hoist/span'; +import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE } from '../gen-ai-attributes'; +import { toolCallSpanMap } from './constants'; +import type { TokenSummary } from './types'; + +/** + * Accumulates token data from a span to its parent in the token accumulator map. + * This function extracts token usage from the current span and adds it to the + * accumulated totals for its parent span. + */ +export function accumulateTokensForParent(span: SpanJSON, tokenAccumulator: Map): void { + const parentSpanId = span.parent_span_id; + if (!parentSpanId) { + return; + } + + const inputTokens = span.data[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]; + const outputTokens = span.data[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]; + + if (typeof inputTokens === 'number' || typeof outputTokens === 'number') { + const existing = tokenAccumulator.get(parentSpanId) || { inputTokens: 0, outputTokens: 0 }; + + if (typeof inputTokens === 'number') { + existing.inputTokens += inputTokens; + } + if (typeof outputTokens === 'number') { + existing.outputTokens += outputTokens; + } + + tokenAccumulator.set(parentSpanId, existing); + } +} + +/** + * Applies accumulated token data to the `gen_ai.invoke_agent` span. + * Only immediate children of the `gen_ai.invoke_agent` span are considered, + * since aggregation will automatically occur for each parent span. + */ +export function applyAccumulatedTokens( + spanOrTrace: SpanJSON | TraceContext, + tokenAccumulator: Map, +): void { + const accumulated = tokenAccumulator.get(spanOrTrace.span_id); + if (!accumulated || !spanOrTrace.data) { + return; + } + + if (accumulated.inputTokens > 0) { + spanOrTrace.data[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE] = accumulated.inputTokens; + } + if (accumulated.outputTokens > 0) { + spanOrTrace.data[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] = accumulated.outputTokens; + } + if (accumulated.inputTokens > 0 || accumulated.outputTokens > 0) { + spanOrTrace.data['gen_ai.usage.total_tokens'] = accumulated.inputTokens + accumulated.outputTokens; + } +} + +/** + * Get the span associated with a tool call ID + */ +export function _INTERNAL_getSpanForToolCallId(toolCallId: string): Span | undefined { + return toolCallSpanMap.get(toolCallId); +} + +/** + * Clean up the span mapping for a tool call ID + */ +export function _INTERNAL_cleanupToolCallSpan(toolCallId: string): void { + toolCallSpanMap.delete(toolCallId); +} diff --git a/packages/core/src/utils/vercel-ai-attributes.ts b/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts similarity index 100% rename from packages/core/src/utils/vercel-ai-attributes.ts rename to packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts diff --git a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 22ec18a682f0..0b66f7e80919 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,11 +1,16 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import type { Span } from '@sentry/core'; import { + _INTERNAL_cleanupToolCallSpan, + _INTERNAL_getSpanForToolCallId, addNonEnumerableProperty, + captureException, getActiveSpan, getCurrentScope, handleCallbackErrors, SDK_VERSION, + withScope, } from '@sentry/core'; import { INTEGRATION_NAME } from './constants'; import type { TelemetrySettings, VercelAiIntegration } from './types'; @@ -35,6 +40,98 @@ interface RecordingOptions { recordOutputs?: boolean; } +interface ToolError { + type: 'tool-error' | 'tool-result' | 'tool-call'; + toolCallId: string; + toolName: string; + input?: { + [key: string]: unknown; + }; + error: Error; + dynamic?: boolean; +} + +function isToolError(obj: unknown): obj is ToolError { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const candidate = obj as Record; + return ( + 'type' in candidate && + 'error' in candidate && + 'toolName' in candidate && + 'toolCallId' in candidate && + candidate.type === 'tool-error' && + candidate.error instanceof Error + ); +} + +/** + * Check for tool errors in the result and capture them + * Tool errors are not rejected in Vercel V5, it is added as metadata to the result content + */ +function checkResultForToolErrors(result: unknown | Promise): void { + if (typeof result !== 'object' || result === null || !('content' in result)) { + return; + } + + const resultObj = result as { content: Array }; + if (!Array.isArray(resultObj.content)) { + return; + } + + for (const item of resultObj.content) { + if (isToolError(item)) { + // Try to get the span associated with this tool call ID + const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span; + + if (associatedSpan) { + // We have the span, so link the error using span and trace IDs from the span + const spanContext = associatedSpan.spanContext(); + + withScope(scope => { + // Set the span and trace context for proper linking + scope.setContext('trace', { + trace_id: spanContext.traceId, + span_id: spanContext.spanId, + }); + + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, + }); + }); + + // Clean up the span mapping since we've processed this tool error + // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. + _INTERNAL_cleanupToolCallSpan(item.toolCallId); + } else { + // Fallback: capture without span linking + withScope(scope => { + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, + }); + }); + } + } + } +} + /** * Determines whether to record inputs and outputs for Vercel AI telemetry based on the configuration hierarchy. * @@ -89,7 +186,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { * Initializes the instrumentation by defining the modules to be patched. */ public init(): InstrumentationModuleDefinition { - const module = new InstrumentationNodeModuleDefinition('ai', ['>=3.0.0 <5'], this._patch.bind(this)); + const module = new InstrumentationNodeModuleDefinition('ai', ['>=3.0.0 <6'], this._patch.bind(this)); return module; } @@ -139,9 +236,14 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { }; return handleCallbackErrors( - () => { + async () => { // @ts-expect-error we know that the method exists - return originalMethod.apply(this, args); + const result = await originalMethod.apply(this, args); + + // Tool errors are not rejected in Vercel V5, it is added as metadata to the result content + checkResultForToolErrors(result); + + return result; }, error => { // This error bubbles up to unhandledrejection handler (if not handled before), From 444bd825ca850610a83ed1208013c47aa0ab3819 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Mon, 25 Aug 2025 21:48:41 +0200 Subject: [PATCH 13/16] feat(node): Add Anthropic AI integration (#17348) This PR adds official support for instrumenting Anthropic AI SDK calls in Node with Sentry tracing, following OpenTelemetry semantic conventions for Generative AI. We instrument the following Anthropic AI SDK methods: - messages.create - messages.countTokens - models.get - models.retrieve - completions.create Supported in: - Node.js (ESM and CJS) - Will be added to cloudflare workers and vercel edge functions exports. The anthropicAIIntegration() accepts the following options: ``` // The integration respects your sendDefaultPii client option interface AnthropicAiOptions { recordInputs?: boolean; // Whether to record prompt messages recordOutputs?: boolean; // Whether to record response text } ``` --- .size-limit.js | 2 +- .../anthropic/instrument-with-options.mjs | 18 ++ .../tracing/anthropic/instrument-with-pii.mjs | 15 ++ .../suites/tracing/anthropic/instrument.mjs | 16 ++ .../suites/tracing/anthropic/scenario.mjs | 119 +++++++++ .../suites/tracing/anthropic/test.ts | 221 ++++++++++++++++ packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/core/src/index.ts | 3 + .../src/utils/{ => ai}/gen-ai-attributes.ts | 14 + packages/core/src/utils/ai/utils.ts | 83 ++++++ .../core/src/utils/anthropic-ai/constants.ts | 11 + packages/core/src/utils/anthropic-ai/index.ts | 242 ++++++++++++++++++ packages/core/src/utils/anthropic-ai/types.ts | 63 +++++ packages/core/src/utils/anthropic-ai/utils.ts | 9 + packages/core/src/utils/openai/index.ts | 2 +- packages/core/src/utils/openai/streaming.ts | 2 +- packages/core/src/utils/openai/utils.ts | 2 +- packages/core/src/utils/vercel-ai/utils.ts | 2 +- packages/google-cloud-serverless/src/index.ts | 1 + packages/node/src/index.ts | 1 + .../tracing/anthropic-ai/index.ts | 74 ++++++ .../tracing/anthropic-ai/instrumentation.ts | 122 +++++++++ .../node/src/integrations/tracing/index.ts | 3 + 25 files changed, 1023 insertions(+), 5 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts rename packages/core/src/utils/{ => ai}/gen-ai-attributes.ts (91%) create mode 100644 packages/core/src/utils/ai/utils.ts create mode 100644 packages/core/src/utils/anthropic-ai/constants.ts create mode 100644 packages/core/src/utils/anthropic-ai/index.ts create mode 100644 packages/core/src/utils/anthropic-ai/types.ts create mode 100644 packages/core/src/utils/anthropic-ai/utils.ts create mode 100644 packages/node/src/integrations/tracing/anthropic-ai/index.ts create mode 100644 packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts diff --git a/.size-limit.js b/.size-limit.js index dd65a987d506..490195900900 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -233,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '148 KB', + limit: '149 KB', }, { name: '@sentry/node - without tracing', diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs new file mode 100644 index 000000000000..9344137a4ed3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import { nodeContextIntegration } from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.anthropicAIIntegration({ + recordInputs: true, + recordOutputs: true, + }), + nodeContextIntegration(), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs new file mode 100644 index 000000000000..eb8b02b1cf8b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { nodeContextIntegration } from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.anthropicAIIntegration(), + nodeContextIntegration(), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs new file mode 100644 index 000000000000..fa011052c50c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { nodeContextIntegration } from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + // Force include the integration + integrations: [ + Sentry.anthropicAIIntegration(), + nodeContextIntegration(), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs new file mode 100644 index 000000000000..425d1366879e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs @@ -0,0 +1,119 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + // Create messages object with create and countTokens methods + this.messages = { + create: this._messagesCreate.bind(this), + countTokens: this._messagesCountTokens.bind(this) + }; + + this.models = { + retrieve: this._modelsRetrieve.bind(this), + }; + } + + /** + * Create a mock message + */ + async _messagesCreate(params) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + id: 'msg_mock123', + type: 'message', + model: params.model, + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hello from Anthropic mock!', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } + + async _messagesCountTokens() { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // For countTokens, just return input_tokens + return { + input_tokens: 15 + } + } + + async _modelsRetrieve(modelId) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // Match what the actual implementation would return + return { + id: modelId, + name: modelId, + created_at: 1715145600, + model: modelId, // Add model field to match the check in addResponseAttributes + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', + }); + + const client = instrumentAnthropicAiClient(mockClient); + + // First test: basic message completion + await client.messages.create({ + model: 'claude-3-haiku-20240307', + system: 'You are a helpful assistant.', + messages: [ + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + // Second test: error handling + try { + await client.messages.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // Error is expected and handled + } + + // Third test: count tokens with cached tokens + await client.messages.countTokens({ + model: 'claude-3-haiku-20240307', + messages: [ + { role: 'user', content: 'What is the capital of France?' }, + ], + }); + + // Fourth test: models.retrieve + await client.models.retrieve('claude-3-haiku-20240307'); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts new file mode 100644 index 000000000000..4b7d19b7cc58 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -0,0 +1,221 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Anthropic integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic message completion without PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_mock123', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Second span - error handling + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + }, + description: 'messages error-model', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'unknown_error', + }), + // Third span - token counting (no response.text because recordOutputs=false by default) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Fourth span - models.retrieve + expect.objectContaining({ + data: { + 'anthropic.response.timestamp': '2024-05-08T05:20:00.000Z', + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'claude-3-haiku-20240307', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + }, + description: 'models claude-3-haiku-20240307', + op: 'gen_ai.models', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic message completion with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': '[{"role":"user","content":"What is the capital of France?"}]', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_mock123', + 'gen_ai.response.text': 'Hello from Anthropic mock!', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Second span - error handling with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', + }, + description: 'messages error-model', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'unknown_error', + }), + // Third span - token counting with PII (response.text is present because sendDefaultPii=true enables recordOutputs) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.messages': '[{"role":"user","content":"What is the capital of France?"}]', + 'gen_ai.response.text': '15', // Only present because recordOutputs=true when sendDefaultPii=true + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Fourth span - models.retrieve with PII + expect.objectContaining({ + data: { + 'anthropic.response.timestamp': '2024-05-08T05:20:00.000Z', + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'claude-3-haiku-20240307', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + }, + description: 'models claude-3-haiku-20240307', + op: 'gen_ai.models', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_WITH_OPTIONS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Check that custom options are respected + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + }), + }), + // Check token counting with options + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': '15', // Present because recordOutputs=true is set in options + }), + op: 'gen_ai.messages', + }), + // Check models.retrieve with options + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'claude-3-haiku-20240307', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + }), + op: 'gen_ai.models', + description: 'models claude-3-haiku-20240307', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates anthropic related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates anthropic related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('creates anthropic related spans with custom options', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .start() + .completed(); + }); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 3b2f589f7fc2..ce9a1b1fa65a 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -12,6 +12,7 @@ export { addEventProcessor, addIntegration, amqplibIntegration, + anthropicAIIntegration, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index dea73e0c564a..7d7455d496bb 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -122,6 +122,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + anthropicAIIntegration, vercelAIIntegration, logger, consoleLoggingIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b9af910eb0f1..ec092bcdbbba 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -140,6 +140,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + anthropicAIIntegration, vercelAIIntegration, logger, consoleLoggingIntegration, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4592bc2bd71c..6385a75687f7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -124,7 +124,10 @@ export { addVercelAiProcessors } from './utils/vercel-ai'; export { _INTERNAL_getSpanForToolCallId, _INTERNAL_cleanupToolCallSpan } from './utils/vercel-ai/utils'; export { instrumentOpenAiClient } from './utils/openai'; export { OPENAI_INTEGRATION_NAME } from './utils/openai/constants'; +export { instrumentAnthropicAiClient } from './utils/anthropic-ai'; +export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; +export type { AnthropicAiClient, AnthropicAiOptions, AnthropicAiInstrumentedMethod } from './utils/anthropic-ai/types'; export type { FeatureFlag } from './utils/featureFlags'; export { diff --git a/packages/core/src/utils/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts similarity index 91% rename from packages/core/src/utils/gen-ai-attributes.ts rename to packages/core/src/utils/ai/gen-ai-attributes.ts index d1b45532e8a5..9124602644e4 100644 --- a/packages/core/src/utils/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -8,6 +8,11 @@ // OPENTELEMETRY SEMANTIC CONVENTIONS FOR GENAI // ============================================================================= +/** + * The input messages sent to the model + */ +export const GEN_AI_PROMPT_ATTRIBUTE = 'gen_ai.prompt'; + /** * The Generative AI system being used * For OpenAI, this should always be "openai" @@ -164,3 +169,12 @@ export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', } as const; + +// ============================================================================= +// ANTHROPIC AI OPERATIONS +// ============================================================================= + +/** + * The response timestamp from Anthropic AI (ISO string) + */ +export const ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE = 'anthropic.response.timestamp'; diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts new file mode 100644 index 000000000000..2a2952ce6ad8 --- /dev/null +++ b/packages/core/src/utils/ai/utils.ts @@ -0,0 +1,83 @@ +/** + * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) + */ +import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from './gen-ai-attributes'; +/** + * Maps AI method paths to Sentry operation name + */ +export function getFinalOperationName(methodPath: string): string { + if (methodPath.includes('messages')) { + return 'messages'; + } + if (methodPath.includes('completions')) { + return 'completions'; + } + if (methodPath.includes('models')) { + return 'models'; + } + return methodPath.split('.').pop() || 'unknown'; +} + +/** + * Get the span operation for AI methods + * Following Sentry's convention: "gen_ai.{operation_name}" + */ +export function getSpanOperation(methodPath: string): string { + return `gen_ai.${getFinalOperationName(methodPath)}`; +} + +/** + * Build method path from current traversal + */ +export function buildMethodPath(currentPath: string, prop: string): string { + return currentPath ? `${currentPath}.${prop}` : prop; +} + +/** + * Set token usage attributes + * @param span - The span to add attributes to + * @param promptTokens - The number of prompt tokens + * @param completionTokens - The number of completion tokens + * @param cachedInputTokens - The number of cached input tokens + * @param cachedOutputTokens - The number of cached output tokens + */ +export function setTokenUsageAttributes( + span: Span, + promptTokens?: number, + completionTokens?: number, + cachedInputTokens?: number, + cachedOutputTokens?: number, +): void { + if (promptTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: promptTokens, + }); + } + if (completionTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: completionTokens, + }); + } + if ( + promptTokens !== undefined || + completionTokens !== undefined || + cachedInputTokens !== undefined || + cachedOutputTokens !== undefined + ) { + /** + * Total input tokens in a request is the summation of `input_tokens`, + * `cache_creation_input_tokens`, and `cache_read_input_tokens`. + */ + const totalTokens = + (promptTokens ?? 0) + (completionTokens ?? 0) + (cachedInputTokens ?? 0) + (cachedOutputTokens ?? 0); + + span.setAttributes({ + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: totalTokens, + }); + } +} diff --git a/packages/core/src/utils/anthropic-ai/constants.ts b/packages/core/src/utils/anthropic-ai/constants.ts new file mode 100644 index 000000000000..41a227f171e0 --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/constants.ts @@ -0,0 +1,11 @@ +export const ANTHROPIC_AI_INTEGRATION_NAME = 'Anthropic_AI'; + +// https://docs.anthropic.com/en/api/messages +// https://docs.anthropic.com/en/api/models-list +export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [ + 'messages.create', + 'messages.countTokens', + 'models.get', + 'completions.create', + 'models.retrieve', +] as const; diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts new file mode 100644 index 000000000000..8d56b2a56c04 --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -0,0 +1,242 @@ +import { getCurrentScope } from '../../currentScopes'; +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { startSpan } from '../../tracing/trace'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { + ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_PROMPT_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_K_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; +import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants'; +import type { + AnthropicAiClient, + AnthropicAiInstrumentedMethod, + AnthropicAiIntegration, + AnthropicAiOptions, + AnthropicAiResponse, +} from './types'; +import { shouldInstrument } from './utils'; +/** + * Extract request attributes from method arguments + */ +function extractRequestAttributes(args: unknown[], methodPath: string): Record { + const attributes: Record = { + [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.anthropic', + }; + + if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { + const params = args[0] as Record; + + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown'; + if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; + if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; + if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; + if ('top_k' in params) attributes[GEN_AI_REQUEST_TOP_K_ATTRIBUTE] = params.top_k; + if ('frequency_penalty' in params) + attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; + if ('max_tokens' in params) attributes[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE] = params.max_tokens; + } else { + if (methodPath === 'models.retrieve' || methodPath === 'models.get') { + // models.retrieve(model-id) and models.get(model-id) + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = args[0]; + } else { + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = 'unknown'; + } + } + + return attributes; +} + +/** + * Add private request attributes to spans. + * This is only recorded if recordInputs is true. + */ +function addPrivateRequestAttributes(span: Span, params: Record): void { + if ('messages' in params) { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + } + if ('input' in params) { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + } + if ('prompt' in params) { + span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); + } +} + +/** + * Add response attributes to spans + */ +function addResponseAttributes(span: Span, response: AnthropicAiResponse, recordOutputs?: boolean): void { + if (!response || typeof response !== 'object') return; + + // Private response attributes that are only recorded if recordOutputs is true. + if (recordOutputs) { + // Messages.create + if ('content' in response) { + if (Array.isArray(response.content)) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.content + .map((item: { text: string | undefined }) => item.text) + .filter((text): text is string => text !== undefined) + .join(''), + }); + } + } + // Completions.create + if ('completion' in response) { + span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.completion }); + } + // Models.countTokens + if ('input_tokens' in response) { + span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: JSON.stringify(response.input_tokens) }); + } + } + + span.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: response.id, + }); + span.setAttributes({ + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + }); + if ('created' in response && typeof response.created === 'number') { + span.setAttributes({ + [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created * 1000).toISOString(), + }); + } + if ('created_at' in response && typeof response.created_at === 'number') { + span.setAttributes({ + [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created_at * 1000).toISOString(), + }); + } + + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.input_tokens, + response.usage.output_tokens, + response.usage.cache_creation_input_tokens, + response.usage.cache_read_input_tokens, + ); + } +} + +/** + * Get record options from the integration + */ +function getRecordingOptionsFromIntegration(): AnthropicAiOptions { + const scope = getCurrentScope(); + const client = scope.getClient(); + const integration = client?.getIntegrationByName(ANTHROPIC_AI_INTEGRATION_NAME) as AnthropicAiIntegration | undefined; + const shouldRecordInputsAndOutputs = integration ? Boolean(client?.getOptions().sendDefaultPii) : false; + + return { + recordInputs: integration?.options?.recordInputs ?? shouldRecordInputsAndOutputs, + recordOutputs: integration?.options?.recordOutputs ?? shouldRecordInputsAndOutputs, + }; +} + +/** + * Instrument a method with Sentry spans + * Following Sentry AI Agents Manual Instrumentation conventions + * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation + */ +function instrumentMethod( + originalMethod: (...args: T) => Promise, + methodPath: AnthropicAiInstrumentedMethod, + context: unknown, + options?: AnthropicAiOptions, +): (...args: T) => Promise { + return async function instrumentedMethod(...args: T): Promise { + const finalOptions = options || getRecordingOptionsFromIntegration(); + const requestAttributes = extractRequestAttributes(args, methodPath); + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const operationName = getFinalOperationName(methodPath); + + // TODO: Handle streaming responses + return startSpan( + { + name: `${operationName} ${model}`, + op: getSpanOperation(methodPath), + attributes: requestAttributes as Record, + }, + async (span: Span) => { + try { + if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') { + addPrivateRequestAttributes(span, args[0] as Record); + } + + const result = await originalMethod.apply(context, args); + addResponseAttributes(span, result, finalOptions.recordOutputs); + return result; + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic', + data: { + function: methodPath, + }, + }, + }); + throw error; + } + }, + ); + }; +} + +/** + * Create a deep proxy for Anthropic AI client instrumentation + */ +function createDeepProxy(target: T, currentPath = '', options?: AnthropicAiOptions): T { + return new Proxy(target, { + get(obj: object, prop: string): unknown { + const value = (obj as Record)[prop]; + const methodPath = buildMethodPath(currentPath, String(prop)); + + if (typeof value === 'function' && shouldInstrument(methodPath)) { + return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); + } + + if (typeof value === 'function') { + // Bind non-instrumented functions to preserve the original `this` context, + return value.bind(obj); + } + + if (value && typeof value === 'object') { + return createDeepProxy(value as object, methodPath, options); + } + + return value; + }, + }) as T; +} + +/** + * Instrument an Anthropic AI client with Sentry tracing + * Can be used across Node.js, Cloudflare Workers, and Vercel Edge + * + * @template T - The type of the client that extends AnthropicAiClient + * @param client - The Anthropic AI client to instrument + * @param options - Optional configuration for recording inputs and outputs + * @returns The instrumented client with the same type as the input + */ +export function instrumentAnthropicAiClient(client: T, options?: AnthropicAiOptions): T { + return createDeepProxy(client, '', options); +} diff --git a/packages/core/src/utils/anthropic-ai/types.ts b/packages/core/src/utils/anthropic-ai/types.ts new file mode 100644 index 000000000000..566e9588d56f --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/types.ts @@ -0,0 +1,63 @@ +import type { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; + +export interface AnthropicAiOptions { + /** + * Enable or disable input recording. + */ + recordInputs?: boolean; + /** + * Enable or disable output recording. + */ + recordOutputs?: boolean; +} + +export type Message = { + role: 'user' | 'assistant'; + content: string | unknown[]; +}; + +export type AnthropicAiResponse = { + [key: string]: unknown; // Allow for additional unknown properties + id: string; + model: string; + created?: number; + created_at?: number; // Available for Models.retrieve + messages?: Array; + content?: string; // Available for Messages.create + completion?: string; // Available for Completions.create + input_tokens?: number; // Available for Models.countTokens + usage?: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + }; +}; + +/** + * Basic interface for Anthropic AI client with only the instrumented methods + * This provides type safety while being generic enough to work with different client implementations + */ +export interface AnthropicAiClient { + messages?: { + create: (...args: unknown[]) => Promise; + countTokens: (...args: unknown[]) => Promise; + }; + models?: { + list: (...args: unknown[]) => Promise; + get: (...args: unknown[]) => Promise; + }; + completions?: { + create: (...args: unknown[]) => Promise; + }; +} + +/** + * Anthropic AI Integration interface for type safety + */ +export interface AnthropicAiIntegration { + name: string; + options: AnthropicAiOptions; +} + +export type AnthropicAiInstrumentedMethod = (typeof ANTHROPIC_AI_INSTRUMENTED_METHODS)[number]; diff --git a/packages/core/src/utils/anthropic-ai/utils.ts b/packages/core/src/utils/anthropic-ai/utils.ts new file mode 100644 index 000000000000..299d20170d6c --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/utils.ts @@ -0,0 +1,9 @@ +import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; +import type { AnthropicAiInstrumentedMethod } from './types'; + +/** + * Check if a method path should be instrumented + */ +export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { + return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); +} diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 3fb4f0d16fce..3fb8b1bf8b98 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -17,7 +17,7 @@ import { GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, -} from '../gen-ai-attributes'; +} from '../ai/gen-ai-attributes'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { diff --git a/packages/core/src/utils/openai/streaming.ts b/packages/core/src/utils/openai/streaming.ts index 2791e715920e..c79448effb35 100644 --- a/packages/core/src/utils/openai/streaming.ts +++ b/packages/core/src/utils/openai/streaming.ts @@ -6,7 +6,7 @@ import { GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, -} from '../gen-ai-attributes'; +} from '../ai/gen-ai-attributes'; import { RESPONSE_EVENT_TYPES } from './constants'; import type { OpenAIResponseObject } from './types'; import { diff --git a/packages/core/src/utils/openai/utils.ts b/packages/core/src/utils/openai/utils.ts index f76d26de5d6a..17007693e739 100644 --- a/packages/core/src/utils/openai/utils.ts +++ b/packages/core/src/utils/openai/utils.ts @@ -11,7 +11,7 @@ import { OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE, OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE, -} from '../gen-ai-attributes'; +} from '../ai/gen-ai-attributes'; import { INSTRUMENTED_METHODS } from './constants'; import type { ChatCompletionChunk, diff --git a/packages/core/src/utils/vercel-ai/utils.ts b/packages/core/src/utils/vercel-ai/utils.ts index 85cc74db7f8d..e9df1a4a7f96 100644 --- a/packages/core/src/utils/vercel-ai/utils.ts +++ b/packages/core/src/utils/vercel-ai/utils.ts @@ -1,6 +1,6 @@ import type { TraceContext } from '../../types-hoist/context'; import type { Span, SpanJSON } from '../../types-hoist/span'; -import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE } from '../gen-ai-attributes'; +import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE } from '../ai/gen-ai-attributes'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 26ed56f031d8..0b76f7776772 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -120,6 +120,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + anthropicAIIntegration, childProcessIntegration, createSentryWinstonTransport, vercelAIIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index da97071bdd32..f5f3865feffa 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -24,6 +24,7 @@ export { dataloaderIntegration } from './integrations/tracing/dataloader'; export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; +export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler, diff --git a/packages/node/src/integrations/tracing/anthropic-ai/index.ts b/packages/node/src/integrations/tracing/anthropic-ai/index.ts new file mode 100644 index 000000000000..b9ec00013f49 --- /dev/null +++ b/packages/node/src/integrations/tracing/anthropic-ai/index.ts @@ -0,0 +1,74 @@ +import type { AnthropicAiOptions, IntegrationFn } from '@sentry/core'; +import { ANTHROPIC_AI_INTEGRATION_NAME, defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryAnthropicAiInstrumentation } from './instrumentation'; + +export const instrumentAnthropicAi = generateInstrumentOnce( + ANTHROPIC_AI_INTEGRATION_NAME, + () => new SentryAnthropicAiInstrumentation({}), +); + +const _anthropicAIIntegration = ((options: AnthropicAiOptions = {}) => { + return { + name: ANTHROPIC_AI_INTEGRATION_NAME, + options, + setupOnce() { + instrumentAnthropicAi(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the Anthropic AI SDK. + * + * This integration is enabled by default. + * + * When configured, this integration automatically instruments Anthropic AI SDK client instances + * to capture telemetry data following OpenTelemetry Semantic Conventions for Generative AI. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * + * Sentry.init({ + * integrations: [Sentry.anthropicAIIntegration()], + * }); + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record prompt messages (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```javascript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.anthropicAIIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.anthropicAIIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + */ +export const anthropicAIIntegration = defineIntegration(_anthropicAIIntegration); diff --git a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts new file mode 100644 index 000000000000..99fd2c546dd2 --- /dev/null +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -0,0 +1,122 @@ +import { + type InstrumentationConfig, + type InstrumentationModuleDefinition, + InstrumentationBase, + InstrumentationNodeModuleDefinition, +} from '@opentelemetry/instrumentation'; +import type { AnthropicAiClient, AnthropicAiOptions, Integration } from '@sentry/core'; +import { ANTHROPIC_AI_INTEGRATION_NAME, getCurrentScope, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; + +const supportedVersions = ['>=0.19.2 <1.0.0']; + +export interface AnthropicAiIntegration extends Integration { + options: AnthropicAiOptions; +} + +/** + * Represents the patched shape of the Anthropic AI module export. + */ +interface PatchedModuleExports { + [key: string]: unknown; + Anthropic: abstract new (...args: unknown[]) => AnthropicAiClient; +} + +/** + * Determines telemetry recording settings. + */ +function determineRecordingSettings( + integrationOptions: AnthropicAiOptions | undefined, + defaultEnabled: boolean, +): { recordInputs: boolean; recordOutputs: boolean } { + const recordInputs = integrationOptions?.recordInputs ?? defaultEnabled; + const recordOutputs = integrationOptions?.recordOutputs ?? defaultEnabled; + return { recordInputs, recordOutputs }; +} + +/** + * Sentry Anthropic AI instrumentation using OpenTelemetry. + */ +export class SentryAnthropicAiInstrumentation extends InstrumentationBase { + public constructor(config: InstrumentationConfig = {}) { + super('@sentry/instrumentation-anthropic-ai', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + */ + public init(): InstrumentationModuleDefinition { + const module = new InstrumentationNodeModuleDefinition( + '@anthropic-ai/sdk', + supportedVersions, + this._patch.bind(this), + ); + return module; + } + + /** + * Core patch logic applying instrumentation to the Anthropic AI client constructor. + */ + private _patch(exports: PatchedModuleExports): PatchedModuleExports | void { + const Original = exports.Anthropic; + + const WrappedAnthropic = function (this: unknown, ...args: unknown[]) { + const instance = Reflect.construct(Original, args); + const scopeClient = getCurrentScope().getClient(); + const integration = scopeClient?.getIntegrationByName(ANTHROPIC_AI_INTEGRATION_NAME); + const integrationOpts = integration?.options; + const defaultPii = Boolean(scopeClient?.getOptions().sendDefaultPii); + + const { recordInputs, recordOutputs } = determineRecordingSettings(integrationOpts, defaultPii); + + return instrumentAnthropicAiClient(instance as AnthropicAiClient, { + recordInputs, + recordOutputs, + }); + } as unknown as abstract new (...args: unknown[]) => AnthropicAiClient; + + // Preserve static and prototype chains + Object.setPrototypeOf(WrappedAnthropic, Original); + Object.setPrototypeOf(WrappedAnthropic.prototype, Original.prototype); + + for (const key of Object.getOwnPropertyNames(Original)) { + if (!['length', 'name', 'prototype'].includes(key)) { + const descriptor = Object.getOwnPropertyDescriptor(Original, key); + if (descriptor) { + Object.defineProperty(WrappedAnthropic, key, descriptor); + } + } + } + + // Constructor replacement - handle read-only properties + // The Anthropic property might have only a getter, so use defineProperty + try { + exports.Anthropic = WrappedAnthropic; + } catch (error) { + // If direct assignment fails, override the property descriptor + Object.defineProperty(exports, 'Anthropic', { + value: WrappedAnthropic, + writable: true, + configurable: true, + enumerable: true, + }); + } + + // Wrap the default export if it points to the original constructor + // Constructor replacement - handle read-only properties + // The Anthropic property might have only a getter, so use defineProperty + if (exports.default === Original) { + try { + exports.default = WrappedAnthropic; + } catch (error) { + // If direct assignment fails, override the property descriptor + Object.defineProperty(exports, 'default', { + value: WrappedAnthropic, + writable: true, + configurable: true, + enumerable: true, + }); + } + } + return exports; + } +} diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 6035cf3669f8..2d660670d297 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,6 +1,7 @@ import type { Integration } from '@sentry/core'; import { instrumentOtelHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; +import { anthropicAIIntegration, instrumentAnthropicAi } from './anthropic-ai'; import { connectIntegration, instrumentConnect } from './connect'; import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; @@ -50,6 +51,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { openAIIntegration(), postgresJsIntegration(), firebaseIntegration(), + anthropicAIIntegration(), ]; } @@ -83,5 +85,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentOpenAi, instrumentPostgresJs, instrumentFirebase, + instrumentAnthropicAi, ]; } From 2eb681ec1f42b28fe5c01e7b3e650f4fe09d15e3 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 26 Aug 2025 10:48:24 +0200 Subject: [PATCH 14/16] fix(core): Only consider ingest endpoint requests when checking `isSentryRequestUrl` (#17393) This PR makes a change to our `isSentryRequestUrl` utility which is used in various parts of the SDKs. The function checks both, the DSN as well as the `tunnel` option to determine if a request URL is a URL to Sentry. I would argue, we should only return `true` for requests to Sentry's ingest endpoint. For example, if users make regular requests to the Sentry API from within their app that uses a Sentry SDK, we should not special case that request. Therefore, this PR makes the check for the request URL more specific: - If `tunnel` is not provided, return `true` iff the the url includes the host of the DSN AND if it includes the `sentry_key` query param. This param is mandatory to be sent along, as it's equal to the [public key of the DSN ](https://develop.sentry.dev/sdk/overview/#parsing-the-dsn). - If `tunnel` is provided, the check was already specific enough because the request URL has to match _exactly_ the configured tunnel URL. While writing this, I realized there are still a bunch of edge cases here that we probably also should fix but for now, let's keep things atomic. closes https://github.com/getsentry/sentry-javascript/issues/17385 (^ very likely. We didn't repro this specifically but the `httpClientIntegration` bails out exactly if it hits the `isSentryRequestUrl` check) --- .../test/integrations/fetch.test.ts | 4 +- packages/core/src/utils/isSentryRequestUrl.ts | 13 +++++- .../test/lib/utils/isSentryRequestUrl.test.ts | 46 +++++++++++++++---- .../integration/shouldFilterRequest.test.ts | 2 +- .../vercel-edge/test/wintercg-fetch.test.ts | 4 +- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index 724ff39c7dde..2a8f9cf6e718 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -101,8 +101,8 @@ describe('WinterCGFetch instrumentation', () => { expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337'], + fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], startTimestamp: Date.now(), }; fetchInstrumentationHandlerCallback(startHandlerData); diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts index e93f61a5919a..8cda9404164a 100644 --- a/packages/core/src/utils/isSentryRequestUrl.ts +++ b/packages/core/src/utils/isSentryRequestUrl.ts @@ -1,5 +1,6 @@ import type { Client } from '../client'; import type { DsnComponents } from '../types-hoist/dsn'; +import { isURLObjectRelative, parseStringToURLObject } from './url'; /** * Checks whether given url points to Sentry server @@ -21,7 +22,17 @@ function checkTunnel(url: string, tunnel: string | undefined): boolean { } function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { - return dsn ? url.includes(dsn.host) : false; + // Requests to Sentry's ingest endpoint must have a `sentry_key` in the query string + // This is equivalent to the public_key which is required in the DSN + // see https://develop.sentry.dev/sdk/overview/#parsing-the-dsn + // Therefore, a request to the same host and with a `sentry_key` in the query string + // can be considered a request to the ingest endpoint. + const urlParts = parseStringToURLObject(url); + if (!urlParts || isURLObjectRelative(urlParts)) { + return false; + } + + return dsn ? urlParts.host.includes(dsn.host) && /(^|&|\?)sentry_key=/.test(urlParts.search) : false; } function removeTrailingSlash(str: string): string { diff --git a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts index 195e93493e98..806165fb52be 100644 --- a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts +++ b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts @@ -4,20 +4,46 @@ import type { Client } from '../../../src/client'; describe('isSentryRequestUrl', () => { it.each([ - ['', 'sentry-dsn.com', '', false], - ['http://sentry-dsn.com/my-url', 'sentry-dsn.com', '', true], - ['http://sentry-dsn.com', 'sentry-dsn.com', '', true], - ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200', true], - ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200/', true], - ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200', true], - ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200', false], - ])('works with url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string, expected: boolean) => { + ['http://sentry-dsn.com/my-url?sentry_key=123', 'sentry-dsn.com', ''], + + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200'], + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200/'], + ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200'], + ['http://tunnel:4200/', 'another-dsn.com', 'http://tunnel:4200'], + ])('returns `true` for url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string) => { + const client = { + getOptions: () => ({ tunnel }), + getDsn: () => ({ host: dsn }), + } as unknown as Client; + + expect(isSentryRequestUrl(url, client)).toBe(true); + }); + + it.each([ + ['http://tunnel:4200/?sentry_key=123', 'another-dsn.com', ''], + ['http://sentry-dsn.com/my-url', 'sentry-dsn.com', ''], + ['http://sentry-dsn.com', 'sentry-dsn.com', ''], + ['http://sAntry-dsn.com/?sentry_key=123', 'sentry-dsn.com', ''], + ['http://sAntry-dsn.com/?sAntry_key=123', 'sAntry-dsn.com', ''], + ['/ingest', 'sentry-dsn.com', ''], + ['/ingest?sentry_key=123', 'sentry-dsn.com', ''], + ['/ingest', '', ''], + ['', '', ''], + ['', 'sentry-dsn.com', ''], + + ['http://tunnel:4200/', 'another-dsn.com', 'http://tunnel:4200/sentry-tunnel'], + ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200'], + ['http://tunnel:4200/a', '', 'http://tunnel:4200/'], + ])('returns `false` for url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string) => { const client = { getOptions: () => ({ tunnel }), getDsn: () => ({ host: dsn }), } as unknown as Client; - // Works with client passed - expect(isSentryRequestUrl(url, client)).toBe(expected); + expect(isSentryRequestUrl(url, client)).toBe(false); + }); + + it('handles undefined client', () => { + expect(isSentryRequestUrl('http://sentry-dsn.com/my-url?sentry_key=123', undefined)).toBe(false); }); }); diff --git a/packages/replay-internal/test/integration/shouldFilterRequest.test.ts b/packages/replay-internal/test/integration/shouldFilterRequest.test.ts index 355afe80060b..8e50fb75d608 100644 --- a/packages/replay-internal/test/integration/shouldFilterRequest.test.ts +++ b/packages/replay-internal/test/integration/shouldFilterRequest.test.ts @@ -20,6 +20,6 @@ describe('Integration | shouldFilterRequest', () => { it('should filter requests for Sentry ingest URLs', async () => { const { replay } = await mockSdk(); - expect(shouldFilterRequest(replay, 'https://03031aa.ingest.f00.f00/api/129312/')).toBe(true); + expect(shouldFilterRequest(replay, 'https://03031aa.ingest.f00.f00/api/129312/?sentry_key=123')).toBe(true); }); }); diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index c1605568de2e..7dc67d0131ea 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -102,8 +102,8 @@ describe('WinterCGFetch instrumentation', () => { expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337'], + fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], startTimestamp: Date.now(), }; fetchInstrumentationHandlerCallback(startHandlerData); From 123be2473be4357be1649199595decedc4b23e27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 10:48:57 +0200 Subject: [PATCH 15/16] chore(deps): bump astro from 4.16.18 to 4.16.19 in /dev-packages/e2e-tests/test-applications/astro-4 (#17434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.18 to 4.16.19.
Release notes

Sourced from astro's releases.

astro@4.16.19

Patch Changes

Changelog

Sourced from astro's changelog.

4.16.19

Patch Changes

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=astro&package-manager=npm_and_yarn&previous-version=4.16.18&new-version=4.16.19)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dev-packages/e2e-tests/test-applications/astro-4/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/astro-4/package.json b/dev-packages/e2e-tests/test-applications/astro-4/package.json index 742d78cb096c..d355f35e6315 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-4/package.json @@ -18,7 +18,7 @@ "@sentry/astro": "* || latest", "@sentry-internal/test-utils": "link:../../../test-utils", "@spotlightjs/astro": "2.1.6", - "astro": "4.16.18", + "astro": "4.16.19", "typescript": "^5.5.4" }, "devDependencies": { From 6a332d3d3ded86b01b598268a17996d081c17af4 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Tue, 26 Aug 2025 11:33:58 +0200 Subject: [PATCH 16/16] meta(changelog): Update changelog for 10.6.0 --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c90e60a23b06..6529fcfe59cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,43 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.6.0 + +### Important Changes + +- **feat(node): Add Anthropic AI integration ([#17348](https://github.com/getsentry/sentry-javascript/pull/17348))** + +This release adds support for automatically tracing Anthropic AI SDK requests, providing better observability for AI-powered applications. + +- **fix(core): Instrument invoke_agent root span, and support Vercel `ai` v5 ([#17395](https://github.com/getsentry/sentry-javascript/pull/17395))** + +This release makes the Sentry `vercelAiIntegration` compatible with version 5 of Vercel `ai`. + +- **docs(nuxt): Remove beta notice ([#17400](https://github.com/getsentry/sentry-javascript/pull/17400))** + +The Sentry Nuxt SDK is now considered stable and no longer in beta! + +### Other Changes + +- feat(astro): Align options with shared build time options type ([#17396](https://github.com/getsentry/sentry-javascript/pull/17396)) +- feat(aws): Add support for automatic wrapping in ESM ([#17407](https://github.com/getsentry/sentry-javascript/pull/17407)) +- feat(node): Add an instrumentation interface for Hono ([#17366](https://github.com/getsentry/sentry-javascript/pull/17366)) +- fix(browser): Use `DedicatedWorkerGlobalScope` global object type in `registerWebWorker` ([#17447](https://github.com/getsentry/sentry-javascript/pull/17447)) +- fix(core): Only consider ingest endpoint requests when checking `isSentryRequestUrl` ([#17393](https://github.com/getsentry/sentry-javascript/pull/17393)) +- fix(node): Fix preloading of instrumentation ([#17403](https://github.com/getsentry/sentry-javascript/pull/17403)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#17449](https://github.com/getsentry/sentry-javascript/pull/17449)) +- chore(deps): bump astro from 4.16.18 to 4.16.19 in /dev-packages/e2e-tests/test-applications/astro-4 ([#17434](https://github.com/getsentry/sentry-javascript/pull/17434)) +- test(e2e/firebase): Fix firebase e2e test failing due to outdated rules file ([#17448](https://github.com/getsentry/sentry-javascript/pull/17448)) +- test(nextjs): Fix canary tests ([#17416](https://github.com/getsentry/sentry-javascript/pull/17416)) +- test(nuxt): Don't rely on flushing for lowQualityTransactionFilter ([#17406](https://github.com/getsentry/sentry-javascript/pull/17406)) +- test(solidstart): Don't rely on flushing for lowQualityTransactionFilter ([#17408](https://github.com/getsentry/sentry-javascript/pull/17408)) + +
+ Work in this release was contributed by @Karibash. Thank you for your contribution! ## 10.5.0