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
[](https://www.npmjs.com/package/@sentry/nuxt)
[](https://www.npmjs.com/package/@sentry/nuxt)
[](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