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/.size-limit.js b/.size-limit.js index dd65a987d506..490195900900 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -233,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '148 KB', + limit: '149 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cdde3b7bea..6529fcfe59cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.6.0 + +### Important Changes + +- **feat(node): Add Anthropic AI integration ([#17348](https://github.com/getsentry/sentry-javascript/pull/17348))** + +This release adds support for automatically tracing Anthropic AI SDK requests, providing better observability for AI-powered applications. + +- **fix(core): Instrument invoke_agent root span, and support Vercel `ai` v5 ([#17395](https://github.com/getsentry/sentry-javascript/pull/17395))** + +This release makes the Sentry `vercelAiIntegration` compatible with version 5 of Vercel `ai`. + +- **docs(nuxt): Remove beta notice ([#17400](https://github.com/getsentry/sentry-javascript/pull/17400))** + +The Sentry Nuxt SDK is now considered stable and no longer in beta! + +### Other Changes + +- feat(astro): Align options with shared build time options type ([#17396](https://github.com/getsentry/sentry-javascript/pull/17396)) +- feat(aws): Add support for automatic wrapping in ESM ([#17407](https://github.com/getsentry/sentry-javascript/pull/17407)) +- feat(node): Add an instrumentation interface for Hono ([#17366](https://github.com/getsentry/sentry-javascript/pull/17366)) +- fix(browser): Use `DedicatedWorkerGlobalScope` global object type in `registerWebWorker` ([#17447](https://github.com/getsentry/sentry-javascript/pull/17447)) +- fix(core): Only consider ingest endpoint requests when checking `isSentryRequestUrl` ([#17393](https://github.com/getsentry/sentry-javascript/pull/17393)) +- fix(node): Fix preloading of instrumentation ([#17403](https://github.com/getsentry/sentry-javascript/pull/17403)) + +
+ Internal Changes + +- chore: Add external contributor to CHANGELOG.md ([#17449](https://github.com/getsentry/sentry-javascript/pull/17449)) +- chore(deps): bump astro from 4.16.18 to 4.16.19 in /dev-packages/e2e-tests/test-applications/astro-4 ([#17434](https://github.com/getsentry/sentry-javascript/pull/17434)) +- test(e2e/firebase): Fix firebase e2e test failing due to outdated rules file ([#17448](https://github.com/getsentry/sentry-javascript/pull/17448)) +- test(nextjs): Fix canary tests ([#17416](https://github.com/getsentry/sentry-javascript/pull/17416)) +- test(nuxt): Don't rely on flushing for lowQualityTransactionFilter ([#17406](https://github.com/getsentry/sentry-javascript/pull/17406)) +- test(solidstart): Don't rely on flushing for lowQualityTransactionFilter ([#17408](https://github.com/getsentry/sentry-javascript/pull/17408)) + +
+ +Work in this release was contributed by @Karibash. Thank you for your contribution! + ## 10.5.0 - feat(core): better cause data extraction ([#17375](https://github.com/getsentry/sentry-javascript/pull/17375)) diff --git a/dev-packages/e2e-tests/test-applications/astro-4/package.json b/dev-packages/e2e-tests/test-applications/astro-4/package.json index 742d78cb096c..d355f35e6315 100644 --- a/dev-packages/e2e-tests/test-applications/astro-4/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-4/package.json @@ -18,7 +18,7 @@ "@sentry/astro": "* || latest", "@sentry-internal/test-utils": "link:../../../test-utils", "@spotlightjs/astro": "2.1.6", - "astro": "4.16.18", + "astro": "4.16.19", "typescript": "^5.5.4" }, "devDependencies": { 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/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/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, 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; } } } diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs new file mode 100644 index 000000000000..9344137a4ed3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-options.mjs @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/node'; +import { nodeContextIntegration } from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + integrations: [ + Sentry.anthropicAIIntegration({ + recordInputs: true, + recordOutputs: true, + }), + nodeContextIntegration(), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs new file mode 100644 index 000000000000..eb8b02b1cf8b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument-with-pii.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { nodeContextIntegration } from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: true, + transport: loggingTransport, + integrations: [ + Sentry.anthropicAIIntegration(), + nodeContextIntegration(), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs new file mode 100644 index 000000000000..fa011052c50c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/instrument.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/node'; +import { nodeContextIntegration } from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + sendDefaultPii: false, + transport: loggingTransport, + // Force include the integration + integrations: [ + Sentry.anthropicAIIntegration(), + nodeContextIntegration(), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs new file mode 100644 index 000000000000..425d1366879e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/scenario.mjs @@ -0,0 +1,119 @@ +import { instrumentAnthropicAiClient } from '@sentry/core'; +import * as Sentry from '@sentry/node'; + +class MockAnthropic { + constructor(config) { + this.apiKey = config.apiKey; + + // Create messages object with create and countTokens methods + this.messages = { + create: this._messagesCreate.bind(this), + countTokens: this._messagesCountTokens.bind(this) + }; + + this.models = { + retrieve: this._modelsRetrieve.bind(this), + }; + } + + /** + * Create a mock message + */ + async _messagesCreate(params) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + error.headers = { 'x-request-id': 'mock-request-123' }; + throw error; + } + + return { + id: 'msg_mock123', + type: 'message', + model: params.model, + role: 'assistant', + content: [ + { + type: 'text', + text: 'Hello from Anthropic mock!', + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 15, + }, + }; + } + + async _messagesCountTokens() { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // For countTokens, just return input_tokens + return { + input_tokens: 15 + } + } + + async _modelsRetrieve(modelId) { + // Simulate processing time + await new Promise(resolve => setTimeout(resolve, 10)); + + // Match what the actual implementation would return + return { + id: modelId, + name: modelId, + created_at: 1715145600, + model: modelId, // Add model field to match the check in addResponseAttributes + }; + } +} + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const mockClient = new MockAnthropic({ + apiKey: 'mock-api-key', + }); + + const client = instrumentAnthropicAiClient(mockClient); + + // First test: basic message completion + await client.messages.create({ + model: 'claude-3-haiku-20240307', + system: 'You are a helpful assistant.', + messages: [ + { role: 'user', content: 'What is the capital of France?' }, + ], + temperature: 0.7, + max_tokens: 100, + }); + + // Second test: error handling + try { + await client.messages.create({ + model: 'error-model', + messages: [{ role: 'user', content: 'This will fail' }], + }); + } catch { + // Error is expected and handled + } + + // Third test: count tokens with cached tokens + await client.messages.countTokens({ + model: 'claude-3-haiku-20240307', + messages: [ + { role: 'user', content: 'What is the capital of France?' }, + ], + }); + + // Fourth test: models.retrieve + await client.models.retrieve('claude-3-haiku-20240307'); + }); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts new file mode 100644 index 000000000000..4b7d19b7cc58 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/anthropic/test.ts @@ -0,0 +1,221 @@ +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; + +describe('Anthropic integration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic message completion without PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_mock123', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Second span - error handling + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + }, + description: 'messages error-model', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'unknown_error', + }), + // Third span - token counting (no response.text because recordOutputs=false by default) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Fourth span - models.retrieve + expect.objectContaining({ + data: { + 'anthropic.response.timestamp': '2024-05-08T05:20:00.000Z', + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'claude-3-haiku-20240307', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + }, + description: 'models claude-3-haiku-20240307', + op: 'gen_ai.models', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - basic message completion with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.temperature': 0.7, + 'gen_ai.request.max_tokens': 100, + 'gen_ai.request.messages': '[{"role":"user","content":"What is the capital of France?"}]', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'msg_mock123', + 'gen_ai.response.text': 'Hello from Anthropic mock!', + 'gen_ai.usage.input_tokens': 10, + 'gen_ai.usage.output_tokens': 15, + 'gen_ai.usage.total_tokens': 25, + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Second span - error handling with PII + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'error-model', + 'gen_ai.request.messages': '[{"role":"user","content":"This will fail"}]', + }, + description: 'messages error-model', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'unknown_error', + }), + // Third span - token counting with PII (response.text is present because sendDefaultPii=true enables recordOutputs) + expect.objectContaining({ + data: { + 'gen_ai.operation.name': 'messages', + 'sentry.op': 'gen_ai.messages', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.request.messages': '[{"role":"user","content":"What is the capital of France?"}]', + 'gen_ai.response.text': '15', // Only present because recordOutputs=true when sendDefaultPii=true + }, + description: 'messages claude-3-haiku-20240307', + op: 'gen_ai.messages', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + // Fourth span - models.retrieve with PII + expect.objectContaining({ + data: { + 'anthropic.response.timestamp': '2024-05-08T05:20:00.000Z', + 'gen_ai.operation.name': 'models', + 'sentry.op': 'gen_ai.models', + 'sentry.origin': 'auto.ai.anthropic', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'claude-3-haiku-20240307', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + }, + description: 'models claude-3-haiku-20240307', + op: 'gen_ai.models', + origin: 'auto.ai.anthropic', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_WITH_OPTIONS = { + transaction: 'main', + spans: expect.arrayContaining([ + // Check that custom options are respected + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': expect.any(String), // Should include response text when recordOutputs: true + }), + }), + // Check token counting with options + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'messages', + 'gen_ai.request.messages': expect.any(String), // Should include messages when recordInputs: true + 'gen_ai.response.text': '15', // Present because recordOutputs=true is set in options + }), + op: 'gen_ai.messages', + }), + // Check models.retrieve with options + expect.objectContaining({ + data: expect.objectContaining({ + 'gen_ai.operation.name': 'models', + 'gen_ai.system': 'anthropic', + 'gen_ai.request.model': 'claude-3-haiku-20240307', + 'gen_ai.response.id': 'claude-3-haiku-20240307', + 'gen_ai.response.model': 'claude-3-haiku-20240307', + }), + op: 'gen_ai.models', + description: 'models claude-3-haiku-20240307', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates anthropic related spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates anthropic related spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-with-options.mjs', (createRunner, test) => { + test('creates anthropic related spans with custom options', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_WITH_OPTIONS }) + .start() + .completed(); + }); + }); +}); diff --git a/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/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/index.server.ts b/packages/astro/src/index.server.ts index 3b2f589f7fc2..ce9a1b1fa65a 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -12,6 +12,7 @@ export { addEventProcessor, addIntegration, amqplibIntegration, + anthropicAIIntegration, // eslint-disable-next-line deprecation/deprecation anrIntegration, // eslint-disable-next-line deprecation/deprecation diff --git a/packages/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', + }, }, }; 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..7d7455d496bb 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -122,6 +122,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + anthropicAIIntegration, vercelAIIntegration, logger, consoleLoggingIntegration, @@ -147,5 +148,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/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"] } } diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b9af910eb0f1..ec092bcdbbba 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -140,6 +140,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + anthropicAIIntegration, vercelAIIntegration, logger, consoleLoggingIntegration, diff --git a/packages/cloudflare/test/integrations/fetch.test.ts b/packages/cloudflare/test/integrations/fetch.test.ts index 724ff39c7dde..2a8f9cf6e718 100644 --- a/packages/cloudflare/test/integrations/fetch.test.ts +++ b/packages/cloudflare/test/integrations/fetch.test.ts @@ -101,8 +101,8 @@ describe('WinterCGFetch instrumentation', () => { expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337'], + fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], startTimestamp: Date.now(), }; fetchInstrumentationHandlerCallback(startHandlerData); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f81a6937d89c..6385a75687f7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -121,9 +121,13 @@ 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 { instrumentAnthropicAiClient } from './utils/anthropic-ai'; +export { ANTHROPIC_AI_INTEGRATION_NAME } from './utils/anthropic-ai/constants'; export type { OpenAiClient, OpenAiOptions, InstrumentedMethod } from './utils/openai/types'; +export type { AnthropicAiClient, AnthropicAiOptions, AnthropicAiInstrumentedMethod } from './utils/anthropic-ai/types'; export type { FeatureFlag } from './utils/featureFlags'; export { diff --git a/packages/core/src/utils/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts similarity index 91% rename from packages/core/src/utils/gen-ai-attributes.ts rename to packages/core/src/utils/ai/gen-ai-attributes.ts index d1b45532e8a5..9124602644e4 100644 --- a/packages/core/src/utils/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -8,6 +8,11 @@ // OPENTELEMETRY SEMANTIC CONVENTIONS FOR GENAI // ============================================================================= +/** + * The input messages sent to the model + */ +export const GEN_AI_PROMPT_ATTRIBUTE = 'gen_ai.prompt'; + /** * The Generative AI system being used * For OpenAI, this should always be "openai" @@ -164,3 +169,12 @@ export const OPENAI_OPERATIONS = { CHAT: 'chat', RESPONSES: 'responses', } as const; + +// ============================================================================= +// ANTHROPIC AI OPERATIONS +// ============================================================================= + +/** + * The response timestamp from Anthropic AI (ISO string) + */ +export const ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE = 'anthropic.response.timestamp'; diff --git a/packages/core/src/utils/ai/utils.ts b/packages/core/src/utils/ai/utils.ts new file mode 100644 index 000000000000..2a2952ce6ad8 --- /dev/null +++ b/packages/core/src/utils/ai/utils.ts @@ -0,0 +1,83 @@ +/** + * Shared utils for AI integrations (OpenAI, Anthropic, Verce.AI, etc.) + */ +import type { Span } from '../../types-hoist/span'; +import { + GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, + GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, +} from './gen-ai-attributes'; +/** + * Maps AI method paths to Sentry operation name + */ +export function getFinalOperationName(methodPath: string): string { + if (methodPath.includes('messages')) { + return 'messages'; + } + if (methodPath.includes('completions')) { + return 'completions'; + } + if (methodPath.includes('models')) { + return 'models'; + } + return methodPath.split('.').pop() || 'unknown'; +} + +/** + * Get the span operation for AI methods + * Following Sentry's convention: "gen_ai.{operation_name}" + */ +export function getSpanOperation(methodPath: string): string { + return `gen_ai.${getFinalOperationName(methodPath)}`; +} + +/** + * Build method path from current traversal + */ +export function buildMethodPath(currentPath: string, prop: string): string { + return currentPath ? `${currentPath}.${prop}` : prop; +} + +/** + * Set token usage attributes + * @param span - The span to add attributes to + * @param promptTokens - The number of prompt tokens + * @param completionTokens - The number of completion tokens + * @param cachedInputTokens - The number of cached input tokens + * @param cachedOutputTokens - The number of cached output tokens + */ +export function setTokenUsageAttributes( + span: Span, + promptTokens?: number, + completionTokens?: number, + cachedInputTokens?: number, + cachedOutputTokens?: number, +): void { + if (promptTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]: promptTokens, + }); + } + if (completionTokens !== undefined) { + span.setAttributes({ + [GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE]: completionTokens, + }); + } + if ( + promptTokens !== undefined || + completionTokens !== undefined || + cachedInputTokens !== undefined || + cachedOutputTokens !== undefined + ) { + /** + * Total input tokens in a request is the summation of `input_tokens`, + * `cache_creation_input_tokens`, and `cache_read_input_tokens`. + */ + const totalTokens = + (promptTokens ?? 0) + (completionTokens ?? 0) + (cachedInputTokens ?? 0) + (cachedOutputTokens ?? 0); + + span.setAttributes({ + [GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE]: totalTokens, + }); + } +} diff --git a/packages/core/src/utils/anthropic-ai/constants.ts b/packages/core/src/utils/anthropic-ai/constants.ts new file mode 100644 index 000000000000..41a227f171e0 --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/constants.ts @@ -0,0 +1,11 @@ +export const ANTHROPIC_AI_INTEGRATION_NAME = 'Anthropic_AI'; + +// https://docs.anthropic.com/en/api/messages +// https://docs.anthropic.com/en/api/models-list +export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [ + 'messages.create', + 'messages.countTokens', + 'models.get', + 'completions.create', + 'models.retrieve', +] as const; diff --git a/packages/core/src/utils/anthropic-ai/index.ts b/packages/core/src/utils/anthropic-ai/index.ts new file mode 100644 index 000000000000..8d56b2a56c04 --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/index.ts @@ -0,0 +1,242 @@ +import { getCurrentScope } from '../../currentScopes'; +import { captureException } from '../../exports'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; +import { startSpan } from '../../tracing/trace'; +import type { Span, SpanAttributeValue } from '../../types-hoist/span'; +import { + ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE, + GEN_AI_OPERATION_NAME_ATTRIBUTE, + GEN_AI_PROMPT_ATTRIBUTE, + GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE, + GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE, + GEN_AI_REQUEST_MESSAGES_ATTRIBUTE, + GEN_AI_REQUEST_MODEL_ATTRIBUTE, + GEN_AI_REQUEST_STREAM_ATTRIBUTE, + GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE, + GEN_AI_REQUEST_TOP_K_ATTRIBUTE, + GEN_AI_REQUEST_TOP_P_ATTRIBUTE, + GEN_AI_RESPONSE_ID_ATTRIBUTE, + GEN_AI_RESPONSE_MODEL_ATTRIBUTE, + GEN_AI_RESPONSE_TEXT_ATTRIBUTE, + GEN_AI_SYSTEM_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; +import { buildMethodPath, getFinalOperationName, getSpanOperation, setTokenUsageAttributes } from '../ai/utils'; +import { ANTHROPIC_AI_INTEGRATION_NAME } from './constants'; +import type { + AnthropicAiClient, + AnthropicAiInstrumentedMethod, + AnthropicAiIntegration, + AnthropicAiOptions, + AnthropicAiResponse, +} from './types'; +import { shouldInstrument } from './utils'; +/** + * Extract request attributes from method arguments + */ +function extractRequestAttributes(args: unknown[], methodPath: string): Record { + const attributes: Record = { + [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath), + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.anthropic', + }; + + if (args.length > 0 && typeof args[0] === 'object' && args[0] !== null) { + const params = args[0] as Record; + + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = params.model ?? 'unknown'; + if ('temperature' in params) attributes[GEN_AI_REQUEST_TEMPERATURE_ATTRIBUTE] = params.temperature; + if ('top_p' in params) attributes[GEN_AI_REQUEST_TOP_P_ATTRIBUTE] = params.top_p; + if ('stream' in params) attributes[GEN_AI_REQUEST_STREAM_ATTRIBUTE] = params.stream; + if ('top_k' in params) attributes[GEN_AI_REQUEST_TOP_K_ATTRIBUTE] = params.top_k; + if ('frequency_penalty' in params) + attributes[GEN_AI_REQUEST_FREQUENCY_PENALTY_ATTRIBUTE] = params.frequency_penalty; + if ('max_tokens' in params) attributes[GEN_AI_REQUEST_MAX_TOKENS_ATTRIBUTE] = params.max_tokens; + } else { + if (methodPath === 'models.retrieve' || methodPath === 'models.get') { + // models.retrieve(model-id) and models.get(model-id) + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = args[0]; + } else { + attributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] = 'unknown'; + } + } + + return attributes; +} + +/** + * Add private request attributes to spans. + * This is only recorded if recordInputs is true. + */ +function addPrivateRequestAttributes(span: Span, params: Record): void { + if ('messages' in params) { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.messages) }); + } + if ('input' in params) { + span.setAttributes({ [GEN_AI_REQUEST_MESSAGES_ATTRIBUTE]: JSON.stringify(params.input) }); + } + if ('prompt' in params) { + span.setAttributes({ [GEN_AI_PROMPT_ATTRIBUTE]: JSON.stringify(params.prompt) }); + } +} + +/** + * Add response attributes to spans + */ +function addResponseAttributes(span: Span, response: AnthropicAiResponse, recordOutputs?: boolean): void { + if (!response || typeof response !== 'object') return; + + // Private response attributes that are only recorded if recordOutputs is true. + if (recordOutputs) { + // Messages.create + if ('content' in response) { + if (Array.isArray(response.content)) { + span.setAttributes({ + [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.content + .map((item: { text: string | undefined }) => item.text) + .filter((text): text is string => text !== undefined) + .join(''), + }); + } + } + // Completions.create + if ('completion' in response) { + span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: response.completion }); + } + // Models.countTokens + if ('input_tokens' in response) { + span.setAttributes({ [GEN_AI_RESPONSE_TEXT_ATTRIBUTE]: JSON.stringify(response.input_tokens) }); + } + } + + span.setAttributes({ + [GEN_AI_RESPONSE_ID_ATTRIBUTE]: response.id, + }); + span.setAttributes({ + [GEN_AI_RESPONSE_MODEL_ATTRIBUTE]: response.model, + }); + if ('created' in response && typeof response.created === 'number') { + span.setAttributes({ + [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created * 1000).toISOString(), + }); + } + if ('created_at' in response && typeof response.created_at === 'number') { + span.setAttributes({ + [ANTHROPIC_AI_RESPONSE_TIMESTAMP_ATTRIBUTE]: new Date(response.created_at * 1000).toISOString(), + }); + } + + if (response.usage) { + setTokenUsageAttributes( + span, + response.usage.input_tokens, + response.usage.output_tokens, + response.usage.cache_creation_input_tokens, + response.usage.cache_read_input_tokens, + ); + } +} + +/** + * Get record options from the integration + */ +function getRecordingOptionsFromIntegration(): AnthropicAiOptions { + const scope = getCurrentScope(); + const client = scope.getClient(); + const integration = client?.getIntegrationByName(ANTHROPIC_AI_INTEGRATION_NAME) as AnthropicAiIntegration | undefined; + const shouldRecordInputsAndOutputs = integration ? Boolean(client?.getOptions().sendDefaultPii) : false; + + return { + recordInputs: integration?.options?.recordInputs ?? shouldRecordInputsAndOutputs, + recordOutputs: integration?.options?.recordOutputs ?? shouldRecordInputsAndOutputs, + }; +} + +/** + * Instrument a method with Sentry spans + * Following Sentry AI Agents Manual Instrumentation conventions + * @see https://docs.sentry.io/platforms/javascript/guides/node/tracing/instrumentation/ai-agents-module/#manual-instrumentation + */ +function instrumentMethod( + originalMethod: (...args: T) => Promise, + methodPath: AnthropicAiInstrumentedMethod, + context: unknown, + options?: AnthropicAiOptions, +): (...args: T) => Promise { + return async function instrumentedMethod(...args: T): Promise { + const finalOptions = options || getRecordingOptionsFromIntegration(); + const requestAttributes = extractRequestAttributes(args, methodPath); + const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; + const operationName = getFinalOperationName(methodPath); + + // TODO: Handle streaming responses + return startSpan( + { + name: `${operationName} ${model}`, + op: getSpanOperation(methodPath), + attributes: requestAttributes as Record, + }, + async (span: Span) => { + try { + if (finalOptions.recordInputs && args[0] && typeof args[0] === 'object') { + addPrivateRequestAttributes(span, args[0] as Record); + } + + const result = await originalMethod.apply(context, args); + addResponseAttributes(span, result, finalOptions.recordOutputs); + return result; + } catch (error) { + captureException(error, { + mechanism: { + handled: false, + type: 'auto.ai.anthropic', + data: { + function: methodPath, + }, + }, + }); + throw error; + } + }, + ); + }; +} + +/** + * Create a deep proxy for Anthropic AI client instrumentation + */ +function createDeepProxy(target: T, currentPath = '', options?: AnthropicAiOptions): T { + return new Proxy(target, { + get(obj: object, prop: string): unknown { + const value = (obj as Record)[prop]; + const methodPath = buildMethodPath(currentPath, String(prop)); + + if (typeof value === 'function' && shouldInstrument(methodPath)) { + return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); + } + + if (typeof value === 'function') { + // Bind non-instrumented functions to preserve the original `this` context, + return value.bind(obj); + } + + if (value && typeof value === 'object') { + return createDeepProxy(value as object, methodPath, options); + } + + return value; + }, + }) as T; +} + +/** + * Instrument an Anthropic AI client with Sentry tracing + * Can be used across Node.js, Cloudflare Workers, and Vercel Edge + * + * @template T - The type of the client that extends AnthropicAiClient + * @param client - The Anthropic AI client to instrument + * @param options - Optional configuration for recording inputs and outputs + * @returns The instrumented client with the same type as the input + */ +export function instrumentAnthropicAiClient(client: T, options?: AnthropicAiOptions): T { + return createDeepProxy(client, '', options); +} diff --git a/packages/core/src/utils/anthropic-ai/types.ts b/packages/core/src/utils/anthropic-ai/types.ts new file mode 100644 index 000000000000..566e9588d56f --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/types.ts @@ -0,0 +1,63 @@ +import type { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; + +export interface AnthropicAiOptions { + /** + * Enable or disable input recording. + */ + recordInputs?: boolean; + /** + * Enable or disable output recording. + */ + recordOutputs?: boolean; +} + +export type Message = { + role: 'user' | 'assistant'; + content: string | unknown[]; +}; + +export type AnthropicAiResponse = { + [key: string]: unknown; // Allow for additional unknown properties + id: string; + model: string; + created?: number; + created_at?: number; // Available for Models.retrieve + messages?: Array; + content?: string; // Available for Messages.create + completion?: string; // Available for Completions.create + input_tokens?: number; // Available for Models.countTokens + usage?: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + }; +}; + +/** + * Basic interface for Anthropic AI client with only the instrumented methods + * This provides type safety while being generic enough to work with different client implementations + */ +export interface AnthropicAiClient { + messages?: { + create: (...args: unknown[]) => Promise; + countTokens: (...args: unknown[]) => Promise; + }; + models?: { + list: (...args: unknown[]) => Promise; + get: (...args: unknown[]) => Promise; + }; + completions?: { + create: (...args: unknown[]) => Promise; + }; +} + +/** + * Anthropic AI Integration interface for type safety + */ +export interface AnthropicAiIntegration { + name: string; + options: AnthropicAiOptions; +} + +export type AnthropicAiInstrumentedMethod = (typeof ANTHROPIC_AI_INSTRUMENTED_METHODS)[number]; diff --git a/packages/core/src/utils/anthropic-ai/utils.ts b/packages/core/src/utils/anthropic-ai/utils.ts new file mode 100644 index 000000000000..299d20170d6c --- /dev/null +++ b/packages/core/src/utils/anthropic-ai/utils.ts @@ -0,0 +1,9 @@ +import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; +import type { AnthropicAiInstrumentedMethod } from './types'; + +/** + * Check if a method path should be instrumented + */ +export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { + return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); +} diff --git a/packages/core/src/utils/isSentryRequestUrl.ts b/packages/core/src/utils/isSentryRequestUrl.ts index e93f61a5919a..8cda9404164a 100644 --- a/packages/core/src/utils/isSentryRequestUrl.ts +++ b/packages/core/src/utils/isSentryRequestUrl.ts @@ -1,5 +1,6 @@ import type { Client } from '../client'; import type { DsnComponents } from '../types-hoist/dsn'; +import { isURLObjectRelative, parseStringToURLObject } from './url'; /** * Checks whether given url points to Sentry server @@ -21,7 +22,17 @@ function checkTunnel(url: string, tunnel: string | undefined): boolean { } function checkDsn(url: string, dsn: DsnComponents | undefined): boolean { - return dsn ? url.includes(dsn.host) : false; + // Requests to Sentry's ingest endpoint must have a `sentry_key` in the query string + // This is equivalent to the public_key which is required in the DSN + // see https://develop.sentry.dev/sdk/overview/#parsing-the-dsn + // Therefore, a request to the same host and with a `sentry_key` in the query string + // can be considered a request to the ingest endpoint. + const urlParts = parseStringToURLObject(url); + if (!urlParts || isURLObjectRelative(urlParts)) { + return false; + } + + return dsn ? urlParts.host.includes(dsn.host) && /(^|&|\?)sentry_key=/.test(urlParts.search) : false; } function removeTrailingSlash(str: string): string { diff --git a/packages/core/src/utils/openai/index.ts b/packages/core/src/utils/openai/index.ts index 3fb4f0d16fce..3fb8b1bf8b98 100644 --- a/packages/core/src/utils/openai/index.ts +++ b/packages/core/src/utils/openai/index.ts @@ -17,7 +17,7 @@ import { GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, -} from '../gen-ai-attributes'; +} from '../ai/gen-ai-attributes'; import { OPENAI_INTEGRATION_NAME } from './constants'; import { instrumentStream } from './streaming'; import type { diff --git a/packages/core/src/utils/openai/streaming.ts b/packages/core/src/utils/openai/streaming.ts index 2791e715920e..c79448effb35 100644 --- a/packages/core/src/utils/openai/streaming.ts +++ b/packages/core/src/utils/openai/streaming.ts @@ -6,7 +6,7 @@ import { GEN_AI_RESPONSE_STREAMING_ATTRIBUTE, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, -} from '../gen-ai-attributes'; +} from '../ai/gen-ai-attributes'; import { RESPONSE_EVENT_TYPES } from './constants'; import type { OpenAIResponseObject } from './types'; import { diff --git a/packages/core/src/utils/openai/utils.ts b/packages/core/src/utils/openai/utils.ts index f76d26de5d6a..17007693e739 100644 --- a/packages/core/src/utils/openai/utils.ts +++ b/packages/core/src/utils/openai/utils.ts @@ -11,7 +11,7 @@ import { OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE, OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE, -} from '../gen-ai-attributes'; +} from '../ai/gen-ai-attributes'; import { INSTRUMENTED_METHODS } from './constants'; import type { ChatCompletionChunk, diff --git a/packages/core/src/utils/vercel-ai/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..e9df1a4a7f96 --- /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 '../ai/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/core/test/lib/utils/isSentryRequestUrl.test.ts b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts index 195e93493e98..806165fb52be 100644 --- a/packages/core/test/lib/utils/isSentryRequestUrl.test.ts +++ b/packages/core/test/lib/utils/isSentryRequestUrl.test.ts @@ -4,20 +4,46 @@ import type { Client } from '../../../src/client'; describe('isSentryRequestUrl', () => { it.each([ - ['', 'sentry-dsn.com', '', false], - ['http://sentry-dsn.com/my-url', 'sentry-dsn.com', '', true], - ['http://sentry-dsn.com', 'sentry-dsn.com', '', true], - ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200', true], - ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200/', true], - ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200', true], - ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200', false], - ])('works with url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string, expected: boolean) => { + ['http://sentry-dsn.com/my-url?sentry_key=123', 'sentry-dsn.com', ''], + + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200'], + ['http://tunnel:4200', 'sentry-dsn.com', 'http://tunnel:4200/'], + ['http://tunnel:4200/', 'sentry-dsn.com', 'http://tunnel:4200'], + ['http://tunnel:4200/', 'another-dsn.com', 'http://tunnel:4200'], + ])('returns `true` for url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string) => { + const client = { + getOptions: () => ({ tunnel }), + getDsn: () => ({ host: dsn }), + } as unknown as Client; + + expect(isSentryRequestUrl(url, client)).toBe(true); + }); + + it.each([ + ['http://tunnel:4200/?sentry_key=123', 'another-dsn.com', ''], + ['http://sentry-dsn.com/my-url', 'sentry-dsn.com', ''], + ['http://sentry-dsn.com', 'sentry-dsn.com', ''], + ['http://sAntry-dsn.com/?sentry_key=123', 'sentry-dsn.com', ''], + ['http://sAntry-dsn.com/?sAntry_key=123', 'sAntry-dsn.com', ''], + ['/ingest', 'sentry-dsn.com', ''], + ['/ingest?sentry_key=123', 'sentry-dsn.com', ''], + ['/ingest', '', ''], + ['', '', ''], + ['', 'sentry-dsn.com', ''], + + ['http://tunnel:4200/', 'another-dsn.com', 'http://tunnel:4200/sentry-tunnel'], + ['http://tunnel:4200/a', 'sentry-dsn.com', 'http://tunnel:4200'], + ['http://tunnel:4200/a', '', 'http://tunnel:4200/'], + ])('returns `false` for url=%s, dsn=%s, tunnel=%s', (url: string, dsn: string, tunnel: string) => { const client = { getOptions: () => ({ tunnel }), getDsn: () => ({ host: dsn }), } as unknown as Client; - // Works with client passed - expect(isSentryRequestUrl(url, client)).toBe(expected); + expect(isSentryRequestUrl(url, client)).toBe(false); + }); + + it('handles undefined client', () => { + expect(isSentryRequestUrl('http://sentry-dsn.com/my-url?sentry_key=123', undefined)).toBe(false); }); }); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 26ed56f031d8..0b76f7776772 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -120,6 +120,7 @@ export { zodErrorsIntegration, profiler, amqplibIntegration, + anthropicAIIntegration, childProcessIntegration, createSentryWinstonTransport, vercelAIIntegration, diff --git a/packages/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/index.ts b/packages/node/src/index.ts index da97071bdd32..f5f3865feffa 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -24,6 +24,7 @@ export { dataloaderIntegration } from './integrations/tracing/dataloader'; export { amqplibIntegration } from './integrations/tracing/amqplib'; export { vercelAIIntegration } from './integrations/tracing/vercelai'; export { openAIIntegration } from './integrations/tracing/openai'; +export { anthropicAIIntegration } from './integrations/tracing/anthropic-ai'; export { launchDarklyIntegration, buildLaunchDarklyFlagUsedHandler, diff --git a/packages/node/src/integrations/tracing/anthropic-ai/index.ts b/packages/node/src/integrations/tracing/anthropic-ai/index.ts new file mode 100644 index 000000000000..b9ec00013f49 --- /dev/null +++ b/packages/node/src/integrations/tracing/anthropic-ai/index.ts @@ -0,0 +1,74 @@ +import type { AnthropicAiOptions, IntegrationFn } from '@sentry/core'; +import { ANTHROPIC_AI_INTEGRATION_NAME, defineIntegration } from '@sentry/core'; +import { generateInstrumentOnce } from '@sentry/node-core'; +import { SentryAnthropicAiInstrumentation } from './instrumentation'; + +export const instrumentAnthropicAi = generateInstrumentOnce( + ANTHROPIC_AI_INTEGRATION_NAME, + () => new SentryAnthropicAiInstrumentation({}), +); + +const _anthropicAIIntegration = ((options: AnthropicAiOptions = {}) => { + return { + name: ANTHROPIC_AI_INTEGRATION_NAME, + options, + setupOnce() { + instrumentAnthropicAi(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Adds Sentry tracing instrumentation for the Anthropic AI SDK. + * + * This integration is enabled by default. + * + * When configured, this integration automatically instruments Anthropic AI SDK client instances + * to capture telemetry data following OpenTelemetry Semantic Conventions for Generative AI. + * + * @example + * ```javascript + * import * as Sentry from '@sentry/node'; + * + * Sentry.init({ + * integrations: [Sentry.anthropicAIIntegration()], + * }); + * ``` + * + * ## Options + * + * - `recordInputs`: Whether to record prompt messages (default: respects `sendDefaultPii` client option) + * - `recordOutputs`: Whether to record response text (default: respects `sendDefaultPii` client option) + * + * ### Default Behavior + * + * By default, the integration will: + * - Record inputs and outputs ONLY if `sendDefaultPii` is set to `true` in your Sentry client options + * - Otherwise, inputs and outputs are NOT recorded unless explicitly enabled + * + * @example + * ```javascript + * // Record inputs and outputs when sendDefaultPii is false + * Sentry.init({ + * integrations: [ + * Sentry.anthropicAIIntegration({ + * recordInputs: true, + * recordOutputs: true + * }) + * ], + * }); + * + * // Never record inputs/outputs regardless of sendDefaultPii + * Sentry.init({ + * sendDefaultPii: true, + * integrations: [ + * Sentry.anthropicAIIntegration({ + * recordInputs: false, + * recordOutputs: false + * }) + * ], + * }); + * ``` + * + */ +export const anthropicAIIntegration = defineIntegration(_anthropicAIIntegration); diff --git a/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts new file mode 100644 index 000000000000..99fd2c546dd2 --- /dev/null +++ b/packages/node/src/integrations/tracing/anthropic-ai/instrumentation.ts @@ -0,0 +1,122 @@ +import { + type InstrumentationConfig, + type InstrumentationModuleDefinition, + InstrumentationBase, + InstrumentationNodeModuleDefinition, +} from '@opentelemetry/instrumentation'; +import type { AnthropicAiClient, AnthropicAiOptions, Integration } from '@sentry/core'; +import { ANTHROPIC_AI_INTEGRATION_NAME, getCurrentScope, instrumentAnthropicAiClient, SDK_VERSION } from '@sentry/core'; + +const supportedVersions = ['>=0.19.2 <1.0.0']; + +export interface AnthropicAiIntegration extends Integration { + options: AnthropicAiOptions; +} + +/** + * Represents the patched shape of the Anthropic AI module export. + */ +interface PatchedModuleExports { + [key: string]: unknown; + Anthropic: abstract new (...args: unknown[]) => AnthropicAiClient; +} + +/** + * Determines telemetry recording settings. + */ +function determineRecordingSettings( + integrationOptions: AnthropicAiOptions | undefined, + defaultEnabled: boolean, +): { recordInputs: boolean; recordOutputs: boolean } { + const recordInputs = integrationOptions?.recordInputs ?? defaultEnabled; + const recordOutputs = integrationOptions?.recordOutputs ?? defaultEnabled; + return { recordInputs, recordOutputs }; +} + +/** + * Sentry Anthropic AI instrumentation using OpenTelemetry. + */ +export class SentryAnthropicAiInstrumentation extends InstrumentationBase { + public constructor(config: InstrumentationConfig = {}) { + super('@sentry/instrumentation-anthropic-ai', SDK_VERSION, config); + } + + /** + * Initializes the instrumentation by defining the modules to be patched. + */ + public init(): InstrumentationModuleDefinition { + const module = new InstrumentationNodeModuleDefinition( + '@anthropic-ai/sdk', + supportedVersions, + this._patch.bind(this), + ); + return module; + } + + /** + * Core patch logic applying instrumentation to the Anthropic AI client constructor. + */ + private _patch(exports: PatchedModuleExports): PatchedModuleExports | void { + const Original = exports.Anthropic; + + const WrappedAnthropic = function (this: unknown, ...args: unknown[]) { + const instance = Reflect.construct(Original, args); + const scopeClient = getCurrentScope().getClient(); + const integration = scopeClient?.getIntegrationByName(ANTHROPIC_AI_INTEGRATION_NAME); + const integrationOpts = integration?.options; + const defaultPii = Boolean(scopeClient?.getOptions().sendDefaultPii); + + const { recordInputs, recordOutputs } = determineRecordingSettings(integrationOpts, defaultPii); + + return instrumentAnthropicAiClient(instance as AnthropicAiClient, { + recordInputs, + recordOutputs, + }); + } as unknown as abstract new (...args: unknown[]) => AnthropicAiClient; + + // Preserve static and prototype chains + Object.setPrototypeOf(WrappedAnthropic, Original); + Object.setPrototypeOf(WrappedAnthropic.prototype, Original.prototype); + + for (const key of Object.getOwnPropertyNames(Original)) { + if (!['length', 'name', 'prototype'].includes(key)) { + const descriptor = Object.getOwnPropertyDescriptor(Original, key); + if (descriptor) { + Object.defineProperty(WrappedAnthropic, key, descriptor); + } + } + } + + // Constructor replacement - handle read-only properties + // The Anthropic property might have only a getter, so use defineProperty + try { + exports.Anthropic = WrappedAnthropic; + } catch (error) { + // If direct assignment fails, override the property descriptor + Object.defineProperty(exports, 'Anthropic', { + value: WrappedAnthropic, + writable: true, + configurable: true, + enumerable: true, + }); + } + + // Wrap the default export if it points to the original constructor + // Constructor replacement - handle read-only properties + // The Anthropic property might have only a getter, so use defineProperty + if (exports.default === Original) { + try { + exports.default = WrappedAnthropic; + } catch (error) { + // If direct assignment fails, override the property descriptor + Object.defineProperty(exports, 'default', { + value: WrappedAnthropic, + writable: true, + configurable: true, + enumerable: true, + }); + } + } + return exports; + } +} diff --git a/packages/node/src/integrations/tracing/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/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; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 6035cf3669f8..2d660670d297 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,6 +1,7 @@ import type { Integration } from '@sentry/core'; import { instrumentOtelHttp } from '../http'; import { amqplibIntegration, instrumentAmqplib } from './amqplib'; +import { anthropicAIIntegration, instrumentAnthropicAi } from './anthropic-ai'; import { connectIntegration, instrumentConnect } from './connect'; import { expressIntegration, instrumentExpress } from './express'; import { fastifyIntegration, instrumentFastify, instrumentFastifyV3 } from './fastify'; @@ -50,6 +51,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { openAIIntegration(), postgresJsIntegration(), firebaseIntegration(), + anthropicAIIntegration(), ]; } @@ -83,5 +85,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentOpenAi, instrumentPostgresJs, instrumentFirebase, + instrumentAnthropicAi, ]; } 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/integrations/tracing/vercelai/instrumentation.ts b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts index 22ec18a682f0..0b66f7e80919 100644 --- a/packages/node/src/integrations/tracing/vercelai/instrumentation.ts +++ b/packages/node/src/integrations/tracing/vercelai/instrumentation.ts @@ -1,11 +1,16 @@ import type { InstrumentationConfig, InstrumentationModuleDefinition } from '@opentelemetry/instrumentation'; import { InstrumentationBase, InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation'; +import type { Span } from '@sentry/core'; import { + _INTERNAL_cleanupToolCallSpan, + _INTERNAL_getSpanForToolCallId, addNonEnumerableProperty, + captureException, getActiveSpan, getCurrentScope, handleCallbackErrors, SDK_VERSION, + withScope, } from '@sentry/core'; import { INTEGRATION_NAME } from './constants'; import type { TelemetrySettings, VercelAiIntegration } from './types'; @@ -35,6 +40,98 @@ interface RecordingOptions { recordOutputs?: boolean; } +interface ToolError { + type: 'tool-error' | 'tool-result' | 'tool-call'; + toolCallId: string; + toolName: string; + input?: { + [key: string]: unknown; + }; + error: Error; + dynamic?: boolean; +} + +function isToolError(obj: unknown): obj is ToolError { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + const candidate = obj as Record; + return ( + 'type' in candidate && + 'error' in candidate && + 'toolName' in candidate && + 'toolCallId' in candidate && + candidate.type === 'tool-error' && + candidate.error instanceof Error + ); +} + +/** + * Check for tool errors in the result and capture them + * Tool errors are not rejected in Vercel V5, it is added as metadata to the result content + */ +function checkResultForToolErrors(result: unknown | Promise): void { + if (typeof result !== 'object' || result === null || !('content' in result)) { + return; + } + + const resultObj = result as { content: Array }; + if (!Array.isArray(resultObj.content)) { + return; + } + + for (const item of resultObj.content) { + if (isToolError(item)) { + // Try to get the span associated with this tool call ID + const associatedSpan = _INTERNAL_getSpanForToolCallId(item.toolCallId) as Span; + + if (associatedSpan) { + // We have the span, so link the error using span and trace IDs from the span + const spanContext = associatedSpan.spanContext(); + + withScope(scope => { + // Set the span and trace context for proper linking + scope.setContext('trace', { + trace_id: spanContext.traceId, + span_id: spanContext.spanId, + }); + + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, + }); + }); + + // Clean up the span mapping since we've processed this tool error + // We won't get multiple { type: 'tool-error' } parts for the same toolCallId. + _INTERNAL_cleanupToolCallSpan(item.toolCallId); + } else { + // Fallback: capture without span linking + withScope(scope => { + scope.setTag('vercel.ai.tool.name', item.toolName); + scope.setTag('vercel.ai.tool.callId', item.toolCallId); + scope.setLevel('error'); + + captureException(item.error, { + mechanism: { + type: 'auto.vercelai.otel', + handled: false, + }, + }); + }); + } + } + } +} + /** * Determines whether to record inputs and outputs for Vercel AI telemetry based on the configuration hierarchy. * @@ -89,7 +186,7 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { * Initializes the instrumentation by defining the modules to be patched. */ public init(): InstrumentationModuleDefinition { - const module = new InstrumentationNodeModuleDefinition('ai', ['>=3.0.0 <5'], this._patch.bind(this)); + const module = new InstrumentationNodeModuleDefinition('ai', ['>=3.0.0 <6'], this._patch.bind(this)); return module; } @@ -139,9 +236,14 @@ export class SentryVercelAiInstrumentation extends InstrumentationBase { }; return handleCallbackErrors( - () => { + async () => { // @ts-expect-error we know that the method exists - return originalMethod.apply(this, args); + const result = await originalMethod.apply(this, args); + + // Tool errors are not rejected in Vercel V5, it is added as metadata to the result content + checkResultForToolErrors(result); + + return result; }, error => { // This error bubbles up to unhandledrejection handler (if not handled before), 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/nuxt/README.md b/packages/nuxt/README.md index 1513d8a5f6d9..eae1b36b03e5 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -4,15 +4,13 @@

-# Official Sentry SDK for Nuxt (BETA) +# Official Sentry SDK for Nuxt [![npm version](https://img.shields.io/npm/v/@sentry/nuxt.svg)](https://www.npmjs.com/package/@sentry/nuxt) [![npm dm](https://img.shields.io/npm/dm/@sentry/nuxt.svg)](https://www.npmjs.com/package/@sentry/nuxt) [![npm dt](https://img.shields.io/npm/dt/@sentry/nuxt.svg)](https://www.npmjs.com/package/@sentry/nuxt) -This SDK is in **Beta**. The API is stable but updates may include minor changes in behavior. Please reach out on -[GitHub](https://github.com/getsentry/sentry-javascript/issues/new/choose) if you have any feedback or concerns. This -SDK is for [Nuxt](https://nuxt.com/). If you're using [Vue](https://vuejs.org/) see our +This SDK is for [Nuxt](https://nuxt.com/). If you're using [Vue](https://vuejs.org/) see our [Vue SDK here](https://github.com/getsentry/sentry-javascript/tree/develop/packages/vue). ## Links @@ -21,17 +19,13 @@ SDK is for [Nuxt](https://nuxt.com/). If you're using [Vue](https://vuejs.org/) ## Compatibility -The minimum supported version of Nuxt is `3.0.0`. +The minimum supported version of Nuxt is `3.7.0` (`3.14.0+` recommended). ## General This package is a wrapper around `@sentry/node` for the server and `@sentry/vue` for the client side, with added functionality related to Nuxt. -**Limitations:** - -- Server monitoring is not available during development mode (`nuxt dev`) - ## Manual Setup ### 1. Prerequisites & Installation @@ -112,20 +106,18 @@ Sentry.init({ ## Uploading Source Maps To upload source maps, you have to enable client source maps in your `nuxt.config.ts`. Then, you add your project -settings to the `sentry.sourceMapsUploadOptions` of your `nuxt.config.ts`: +settings to `sentry` in your `nuxt.config.ts`: ```javascript // nuxt.config.ts export default defineNuxtConfig({ - sourcemap: { client: true }, + sourcemap: { client: 'hidden' }, modules: ['@sentry/nuxt/module'], sentry: { - sourceMapsUploadOptions: { - org: 'your-org-slug', - project: 'your-project-slug', - authToken: process.env.SENTRY_AUTH_TOKEN, - }, + org: 'your-org-slug', + project: 'your-project-slug', + authToken: process.env.SENTRY_AUTH_TOKEN, }, }); ``` diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 0e6d92636246..5e1343b1ebaa 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -112,15 +112,6 @@ export default defineNuxtModule({ nuxt.hooks.hook('nitro:init', nitro => { if (serverConfigFile?.includes('.server.config')) { - if (nitro.options.dev) { - consoleSandbox(() => { - // eslint-disable-next-line no-console - console.log( - '[Sentry] Your application is running in development mode. Note: @sentry/nuxt does not work as expected on the server-side (Nitro). Errors are reported, but tracing does not work.', - ); - }); - } - consoleSandbox(() => { const serverDir = nitro.options.output.serverDir; @@ -154,8 +145,20 @@ export default defineNuxtModule({ consoleSandbox(() => { // eslint-disable-next-line no-console console.log( - `[Sentry] Using your \`${serverConfigFile}\` file for the server-side Sentry configuration. Make sure to add the Node option \`import\` to the Node command where you deploy and/or run your application. This preloads the Sentry configuration at server startup. You can do this via a command-line flag (\`node --import ${serverConfigRelativePath} [...]\`) or via an environment variable (\`NODE_OPTIONS='--import ${serverConfigRelativePath}' node [...]\`).`, + `[Sentry] Using \`${serverConfigFile}\` for server-side Sentry configuration. To activate Sentry on the Nuxt server-side, this file must be preloaded when starting your application. Make sure to add this where you deploy and/or run your application. Read more here: https://docs.sentry.io/platforms/javascript/guides/nuxt/install/.`, ); + + if (nitro.options.dev) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] During development, preload Sentry with the NODE_OPTIONS environment variable: \`NODE_OPTIONS='--import ${serverConfigRelativePath}' nuxt dev\`. The file is generated in the build directory (usually '.nuxt'). If you delete the build directory, run \`nuxt dev\` to regenerate it.`, + ); + } else { + // eslint-disable-next-line no-console + console.log( + `[Sentry] When running your built application, preload Sentry via a command-line flag (\`node --import ${serverConfigRelativePath} [...]\`) or via an environment variable (\`NODE_OPTIONS='--import ${serverConfigRelativePath}' node [...]\`).`, + ); + } }); } } 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 () => { 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(); }); diff --git a/packages/replay-internal/test/integration/shouldFilterRequest.test.ts b/packages/replay-internal/test/integration/shouldFilterRequest.test.ts index 355afe80060b..8e50fb75d608 100644 --- a/packages/replay-internal/test/integration/shouldFilterRequest.test.ts +++ b/packages/replay-internal/test/integration/shouldFilterRequest.test.ts @@ -20,6 +20,6 @@ describe('Integration | shouldFilterRequest', () => { it('should filter requests for Sentry ingest URLs', async () => { const { replay } = await mockSdk(); - expect(shouldFilterRequest(replay, 'https://03031aa.ingest.f00.f00/api/129312/')).toBe(true); + expect(shouldFilterRequest(replay, 'https://03031aa.ingest.f00.f00/api/129312/?sentry_key=123')).toBe(true); }); }); diff --git a/packages/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'); }); }); diff --git a/packages/vercel-edge/test/wintercg-fetch.test.ts b/packages/vercel-edge/test/wintercg-fetch.test.ts index c1605568de2e..7dc67d0131ea 100644 --- a/packages/vercel-edge/test/wintercg-fetch.test.ts +++ b/packages/vercel-edge/test/wintercg-fetch.test.ts @@ -102,8 +102,8 @@ describe('WinterCGFetch instrumentation', () => { expect(fetchInstrumentationHandlerCallback).toBeDefined(); const startHandlerData: HandlerDataFetch = { - fetchData: { url: 'https://dsn.ingest.sentry.io/1337', method: 'POST' }, - args: ['https://dsn.ingest.sentry.io/1337'], + fetchData: { url: 'https://dsn.ingest.sentry.io/1337?sentry_key=123', method: 'POST' }, + args: ['https://dsn.ingest.sentry.io/1337?sentry_key=123'], startTimestamp: Date.now(), }; fetchInstrumentationHandlerCallback(startHandlerData); 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==