diff --git a/CHANGELOG.md b/CHANGELOG.md index e70a011716be..baf79461528f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 9.30.0 + +- feat(nextjs): Add URL to tags of server components and generation functions issues ([#16500](https://github.com/getsentry/sentry-javascript/pull/16500)) +- feat(nextjs): Ensure all packages we auto-instrument are externalized ([#16552](https://github.com/getsentry/sentry-javascript/pull/16552)) +- feat(node): Automatically enable `vercelAiIntegration` when `ai` module is detected ([#16565](https://github.com/getsentry/sentry-javascript/pull/16565)) +- feat(node): Ensure `modulesIntegration` works in more environments ([#16566](https://github.com/getsentry/sentry-javascript/pull/16566)) +- feat(core): Don't gate user on logs with `sendDefaultPii` ([#16527](https://github.com/getsentry/sentry-javascript/pull/16527)) +- feat(browser): Add detail to measure spans and add regression tests ([#16557](https://github.com/getsentry/sentry-javascript/pull/16557)) +- feat(node): Update Vercel AI span attributes ([#16580](https://github.com/getsentry/sentry-javascript/pull/16580)) +- fix(opentelemetry): Ensure only orphaned spans of sent spans are sent ([#16590](https://github.com/getsentry/sentry-javascript/pull/16590)) + ## 9.29.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js new file mode 100644 index 000000000000..f4df5dbe13e8 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/init.js @@ -0,0 +1,58 @@ +import * as Sentry from '@sentry/browser'; + +// Create measures BEFORE SDK initializes + +// Create a measure with detail +const measure = performance.measure('restricted-test-measure', { + start: performance.now(), + end: performance.now() + 1, + detail: { test: 'initial-value' }, +}); + +// Simulate Firefox's permission denial by overriding the detail getter +// This mimics the actual Firefox behavior where accessing detail throws +Object.defineProperty(measure, 'detail', { + get() { + throw new DOMException('Permission denied to access object', 'SecurityError'); + }, + configurable: false, + enumerable: true, +}); + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 9000, + }), + ], + tracesSampleRate: 1, +}); + +// Also create a normal measure to ensure SDK still works +performance.measure('normal-measure', { + start: performance.now(), + end: performance.now() + 50, + detail: 'this-should-work', +}); + +// Create a measure with complex detail object +performance.measure('complex-detail-measure', { + start: performance.now(), + end: performance.now() + 25, + detail: { + nested: { + array: [1, 2, 3], + object: { + key: 'value', + }, + }, + metadata: { + type: 'test', + version: '1.0', + tags: ['complex', 'nested', 'object'], + }, + }, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts new file mode 100644 index 000000000000..a990694b46bf --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans-domexception-details/test.ts @@ -0,0 +1,65 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers'; + +// This is a regression test for https://github.com/getsentry/sentry-javascript/issues/16347 + +sentryTest( + 'should handle permission denial gracefully and still create measure spans', + async ({ getLocalTestUrl, page, browserName }) => { + // Skip test on webkit because we can't validate the detail in the browser + if (shouldSkipTracingTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + // Find all measure spans + const measureSpans = eventData.spans?.filter(({ op }) => op === 'measure'); + expect(measureSpans?.length).toBe(3); // All three measures should create spans + + // Test 1: Verify the restricted-test-measure span exists but has no detail + const restrictedMeasure = measureSpans?.find(span => span.description === 'restricted-test-measure'); + expect(restrictedMeasure).toBeDefined(); + expect(restrictedMeasure?.data).toMatchObject({ + 'sentry.op': 'measure', + 'sentry.origin': 'auto.resource.browser.metrics', + }); + + // Verify no detail attributes were added due to the permission error + const restrictedDataKeys = Object.keys(restrictedMeasure?.data || {}); + const restrictedDetailKeys = restrictedDataKeys.filter(key => key.includes('detail')); + expect(restrictedDetailKeys).toHaveLength(0); + + // Test 2: Verify the normal measure still captures detail correctly + const normalMeasure = measureSpans?.find(span => span.description === 'normal-measure'); + expect(normalMeasure).toBeDefined(); + expect(normalMeasure?.data).toMatchObject({ + 'sentry.browser.measure.detail': 'this-should-work', + 'sentry.op': 'measure', + 'sentry.origin': 'auto.resource.browser.metrics', + }); + + // Test 3: Verify the complex detail object is captured correctly + const complexMeasure = measureSpans?.find(span => span.description === 'complex-detail-measure'); + expect(complexMeasure).toBeDefined(); + expect(complexMeasure?.data).toMatchObject({ + 'sentry.op': 'measure', + 'sentry.origin': 'auto.resource.browser.metrics', + // The entire nested object is stringified as a single value + 'sentry.browser.measure.detail.nested': JSON.stringify({ + array: [1, 2, 3], + object: { + key: 'value', + }, + }), + 'sentry.browser.measure.detail.metadata': JSON.stringify({ + type: 'test', + version: '1.0', + tags: ['complex', 'nested', 'object'], + }), + }); + }, +); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js index db9c448ed19b..f3e6fa567911 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/init.js @@ -10,7 +10,6 @@ performance.measure('Next.js-before-hydration', { window.Sentry = Sentry; Sentry.init({ - debug: true, dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [ Sentry.browserTracingIntegration({ diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/ai-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/ai-test/page.tsx new file mode 100644 index 000000000000..828e92baf62a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/ai-test/page.tsx @@ -0,0 +1,101 @@ +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; +import * as Sentry from '@sentry/nextjs'; + +export const dynamic = 'force-dynamic'; + +async function runAITest() { + // First span - telemetry should be enabled automatically but no input/output recorded when sendDefaultPii: true + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second span - explicitly enabled telemetry, should record inputs/outputs + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third span - with tool calls and tool results + const result3 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async (args) => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth span - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Third span here!', + }), + }), + prompt: 'Where is the third span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; +} + +export default async function Page() { + const results = await Sentry.startSpan( + { op: 'function', name: 'ai-test' }, + async () => { + return await runAITest(); + } + ); + + return ( +
+

AI Test Results

+
{JSON.stringify(results, null, 2)}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index a79d34746ee4..416102b15da7 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -18,10 +18,12 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", + "ai": "^3.0.0", "next": "15.3.0-canary.33", "react": "beta", "react-dom": "beta", - "typescript": "~5.0.0" + "typescript": "~5.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@playwright/test": "~1.50.0", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts index 067d2ead0b8b..947e8bb7f819 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/sentry.server.config.ts @@ -10,4 +10,7 @@ Sentry.init({ // We are doing a lot of events at once in this test bufferSize: 1000, }, + integrations: [ + Sentry.vercelAIIntegration(), + ], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts new file mode 100644 index 000000000000..8f08a4a60841 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/ai-test.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create AI spans with correct attributes', async ({ page }) => { + const aiTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === 'GET /ai-test'; + }); + + await page.goto('/ai-test'); + + const aiTransaction = await aiTransactionPromise; + + expect(aiTransaction).toBeDefined(); + expect(aiTransaction.transaction).toBe('GET /ai-test'); + + const spans = aiTransaction.spans || []; + + // We expect spans for the first 3 AI calls (4th is disabled) + // Each generateText call should create 2 spans: one for the pipeline and one for doGenerate + // Plus a span for the tool call + // TODO: For now, this is sadly not fully working - the monkey patching of the ai package is not working + // because of this, only spans that are manually opted-in at call time will be captured + // this may be fixed by https://github.com/vercel/ai/pull/6716 in the future + const aiPipelineSpans = spans.filter(span => span.op === 'ai.pipeline.generate_text'); + const aiGenerateSpans = spans.filter(span => span.op === 'gen_ai.generate_text'); + const toolCallSpans = spans.filter(span => span.op === 'gen_ai.execute_tool'); + + expect(aiPipelineSpans.length).toBeGreaterThanOrEqual(1); + expect(aiGenerateSpans.length).toBeGreaterThanOrEqual(1); + expect(toolCallSpans.length).toBeGreaterThanOrEqual(0); + + // First AI call - should have telemetry enabled and record inputs/outputs (sendDefaultPii: true) + /* const firstPipelineSpan = aiPipelineSpans[0]; + expect(firstPipelineSpan?.data?.['ai.model.id']).toBe('mock-model-id'); + expect(firstPipelineSpan?.data?.['ai.model.provider']).toBe('mock-provider'); + expect(firstPipelineSpan?.data?.['ai.prompt']).toContain('Where is the first span?'); + expect(firstPipelineSpan?.data?.['gen_ai.response.text']).toBe('First span here!'); + expect(firstPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(10); + expect(firstPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(20); */ + + // Second AI call - explicitly enabled telemetry + const secondPipelineSpan = aiPipelineSpans[0]; + expect(secondPipelineSpan?.data?.['ai.prompt']).toContain('Where is the second span?'); + expect(secondPipelineSpan?.data?.['gen_ai.response.text']).toContain('Second span here!'); + + // Third AI call - with tool calls + /* const thirdPipelineSpan = aiPipelineSpans[2]; + expect(thirdPipelineSpan?.data?.['ai.response.finishReason']).toBe('tool-calls'); + expect(thirdPipelineSpan?.data?.['gen_ai.usage.input_tokens']).toBe(15); + expect(thirdPipelineSpan?.data?.['gen_ai.usage.output_tokens']).toBe(25); */ + + // Tool call span + /* const toolSpan = toolCallSpans[0]; + expect(toolSpan?.data?.['ai.toolCall.name']).toBe('getWeather'); + expect(toolSpan?.data?.['ai.toolCall.id']).toBe('call-1'); + expect(toolSpan?.data?.['ai.toolCall.args']).toContain('San Francisco'); + expect(toolSpan?.data?.['ai.toolCall.result']).toContain('Sunny, 72°F'); */ + + // Verify the fourth call was not captured (telemetry disabled) + const promptsInSpans = spans + .map(span => span.data?.['ai.prompt']) + .filter((prompt): prompt is string => prompt !== undefined); + const hasDisabledPrompt = promptsInSpans.some(prompt => prompt.includes('Where is the third span?')); + expect(hasDisabledPrompt).toBe(false); + + // Verify results are displayed on the page + const resultsText = await page.locator('#ai-results').textContent(); + expect(resultsText).toContain('First span here!'); + expect(resultsText).toContain('Second span here!'); + expect(resultsText).toContain('Tool call completed!'); + expect(resultsText).toContain('Third span here!'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts index 498c9b969ed9..0a32972b0e6a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts @@ -39,6 +39,7 @@ test('Sends a transaction for a request to app router', async ({ page }) => { headers: expect.objectContaining({ 'user-agent': expect.any(String), }), + url: expect.stringContaining('/server-component/parameter/1337/42'), }); // The transaction should not contain any spans with the same name as the transaction @@ -123,4 +124,12 @@ test('Should capture an error and transaction for a app router page', async ({ p expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); expect(transactionEvent.tags?.['my-isolated-tag']).toBe(true); expect(transactionEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + + // Modules are set for Next.js + expect(errorEvent.modules).toEqual( + expect.objectContaining({ + '@sentry/nextjs': expect.any(String), + '@playwright/test': expect.any(String), + }), + ); }); diff --git a/dev-packages/node-integration-tests/suites/modules/instrument.mjs b/dev-packages/node-integration-tests/suites/modules/instrument.mjs new file mode 100644 index 000000000000..9ffde125d498 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/modules/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/modules/server.js b/dev-packages/node-integration-tests/suites/modules/server.js new file mode 100644 index 000000000000..9b24c0845ac0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/modules/server.js @@ -0,0 +1,22 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +// express must be required after Sentry is initialized +const express = require('express'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.get('/test1', () => { + throw new Error('error_1'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/modules/server.mjs b/dev-packages/node-integration-tests/suites/modules/server.mjs new file mode 100644 index 000000000000..6edeb78c703f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/modules/server.mjs @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); + +app.get('/test1', () => { + throw new Error('error_1'); +}); + +Sentry.setupExpressErrorHandler(app); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/modules/test.ts b/dev-packages/node-integration-tests/suites/modules/test.ts new file mode 100644 index 000000000000..89fe98c62867 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/modules/test.ts @@ -0,0 +1,48 @@ +import { SDK_VERSION } from '@sentry/core'; +import { join } from 'path'; +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +describe('modulesIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('CJS', async () => { + const runner = createRunner(__dirname, 'server.js') + .withMockSentryServer() + .expect({ + event: { + modules: { + // exact version comes from require.caches + express: '4.21.1', + // this comes from package.json + '@sentry/node': SDK_VERSION, + yargs: '^16.2.0', + }, + }, + }) + .start(); + runner.makeRequest('get', '/test1', { expectError: true }); + await runner.completed(); + }); + + test('ESM', async () => { + const runner = createRunner(__dirname, 'server.mjs') + .withInstrument(join(__dirname, 'instrument.mjs')) + .withMockSentryServer() + .expect({ + event: { + modules: { + // this comes from package.json + express: '^4.21.1', + '@sentry/node': SDK_VERSION, + yargs: '^16.2.0', + }, + }, + }) + .start(); + runner.makeRequest('get', '/test1', { expectError: true }); + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs index b798e21228f5..d69f7dca5feb 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument-with-pii.mjs @@ -7,5 +7,5 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, transport: loggingTransport, - integrations: [Sentry.vercelAIIntegration()], + integrations: [Sentry.vercelAIIntegration({ force: true })], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs index 5e898ee1949d..e4cd7b9cabd7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/instrument.mjs @@ -6,5 +6,5 @@ Sentry.init({ release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, - integrations: [Sentry.vercelAIIntegration()], + integrations: [Sentry.vercelAIIntegration({ force: true })], }); 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 fdeec051389f..946e2067212b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/test.ts @@ -73,7 +73,7 @@ describe('Vercel AI integration', () => { 'ai.pipeline.name': 'generateText', 'ai.prompt': '{"prompt":"Where is the second span?"}', 'ai.response.finishReason': 'stop', - 'ai.response.text': expect.any(String), + 'gen_ai.response.text': expect.any(String), 'ai.settings.maxRetries': 2, 'ai.settings.maxSteps': 1, 'ai.streaming': false, @@ -108,10 +108,10 @@ describe('Vercel AI integration', () => { 'ai.response.finishReason': 'stop', 'ai.response.model': 'mock-model-id', 'ai.response.id': expect.any(String), - 'ai.response.text': expect.any(String), + 'gen_ai.response.text': expect.any(String), 'ai.response.timestamp': expect.any(String), 'ai.prompt.format': expect.any(String), - 'ai.prompt.messages': 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, @@ -210,7 +210,7 @@ describe('Vercel AI integration', () => { 'ai.pipeline.name': 'generateText', 'ai.prompt': '{"prompt":"Where is the first span?"}', 'ai.response.finishReason': 'stop', - 'ai.response.text': 'First span here!', + 'gen_ai.response.text': 'First span here!', 'ai.settings.maxRetries': 2, 'ai.settings.maxSteps': 1, 'ai.streaming': false, @@ -236,11 +236,11 @@ describe('Vercel AI integration', () => { 'ai.operationId': 'ai.generateText.doGenerate', 'ai.pipeline.name': 'generateText.doGenerate', 'ai.prompt.format': 'prompt', - 'ai.prompt.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', + 'gen_ai.request.messages': '[{"role":"user","content":[{"type":"text","text":"Where is the first span?"}]}]', 'ai.response.finishReason': 'stop', 'ai.response.id': expect.any(String), 'ai.response.model': 'mock-model-id', - 'ai.response.text': 'First span here!', + 'gen_ai.response.text': 'First span here!', 'ai.response.timestamp': expect.any(String), 'ai.settings.maxRetries': 2, 'ai.streaming': false, @@ -270,7 +270,7 @@ describe('Vercel AI integration', () => { 'ai.pipeline.name': 'generateText', 'ai.prompt': '{"prompt":"Where is the second span?"}', 'ai.response.finishReason': 'stop', - 'ai.response.text': expect.any(String), + 'gen_ai.response.text': expect.any(String), 'ai.settings.maxRetries': 2, 'ai.settings.maxSteps': 1, 'ai.streaming': false, @@ -305,10 +305,10 @@ describe('Vercel AI integration', () => { 'ai.response.finishReason': 'stop', 'ai.response.model': 'mock-model-id', 'ai.response.id': expect.any(String), - 'ai.response.text': expect.any(String), + 'gen_ai.response.text': expect.any(String), 'ai.response.timestamp': expect.any(String), 'ai.prompt.format': expect.any(String), - 'ai.prompt.messages': 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, @@ -330,8 +330,8 @@ describe('Vercel AI integration', () => { 'ai.pipeline.name': 'generateText', 'ai.prompt': '{"prompt":"What is the weather in San Francisco?"}', 'ai.response.finishReason': 'tool-calls', - 'ai.response.text': 'Tool call completed!', - 'ai.response.toolCalls': expect.any(String), + 'gen_ai.response.text': 'Tool call completed!', + 'gen_ai.response.tool_calls': expect.any(String), 'ai.settings.maxRetries': 2, 'ai.settings.maxSteps': 1, 'ai.streaming': false, @@ -357,15 +357,15 @@ describe('Vercel AI integration', () => { 'ai.operationId': 'ai.generateText.doGenerate', 'ai.pipeline.name': 'generateText.doGenerate', 'ai.prompt.format': expect.any(String), - 'ai.prompt.messages': expect.any(String), + 'gen_ai.request.messages': expect.any(String), 'ai.prompt.toolChoice': expect.any(String), - 'ai.prompt.tools': expect.any(Array), + 'gen_ai.request.available_tools': expect.any(Array), 'ai.response.finishReason': 'tool-calls', 'ai.response.id': expect.any(String), 'ai.response.model': 'mock-model-id', - 'ai.response.text': 'Tool call completed!', + 'gen_ai.response.text': 'Tool call completed!', 'ai.response.timestamp': expect.any(String), - 'ai.response.toolCalls': expect.any(String), + 'gen_ai.response.tool_calls': expect.any(String), 'ai.settings.maxRetries': 2, 'ai.streaming': false, 'gen_ai.request.model': 'mock-model-id', diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 7695802941a6..4c5e78899b29 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -1,10 +1,11 @@ /* eslint-disable max-lines */ -import type { Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/core'; +import type { Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core'; import { browserPerformanceTimeOrigin, getActiveSpan, getComponentName, htmlTreeAsString, + isPrimitive, parseUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, setMeasurement, @@ -483,6 +484,8 @@ export function _addMeasureSpans( attributes['sentry.browser.measure_start_time'] = measureStartTimestamp; } + _addDetailToSpanAttributes(attributes, entry as PerformanceMeasure); + // Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process. if (measureStartTimestamp <= measureEndTimestamp) { startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, { @@ -493,6 +496,50 @@ export function _addMeasureSpans( } } +function _addDetailToSpanAttributes(attributes: SpanAttributes, performanceMeasure: PerformanceMeasure): void { + try { + // Accessing detail might throw in some browsers (e.g., Firefox) due to security restrictions + const detail = performanceMeasure.detail; + + if (!detail) { + return; + } + + // Process detail based on its type + if (typeof detail === 'object') { + // Handle object details + for (const [key, value] of Object.entries(detail)) { + if (value && isPrimitive(value)) { + attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue; + } else if (value !== undefined) { + try { + // This is user defined so we can't guarantee it's serializable + attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value); + } catch { + // Skip values that can't be stringified + } + } + } + return; + } + + if (isPrimitive(detail)) { + // Handle primitive details + attributes['sentry.browser.measure.detail'] = detail as SpanAttributeValue; + return; + } + + try { + attributes['sentry.browser.measure.detail'] = JSON.stringify(detail); + } catch { + // Skip if stringification fails + } + } catch { + // Silently ignore any errors when accessing detail + // This handles the Firefox "Permission denied to access object" error + } +} + /** * Instrument navigation entries * exported only for tests diff --git a/packages/core/src/logs/exports.ts b/packages/core/src/logs/exports.ts index f44817c13715..176cdcd657b0 100644 --- a/packages/core/src/logs/exports.ts +++ b/packages/core/src/logs/exports.ts @@ -138,14 +138,12 @@ export function _INTERNAL_captureLog( ...beforeLog.attributes, }; - const { user } = getMergedScopeData(currentScope); - // Only attach user to log attributes if sendDefaultPii is enabled - if (client.getOptions().sendDefaultPii) { - const { id, email, username } = user; - setLogAttribute(processedLogAttributes, 'user.id', id, false); - setLogAttribute(processedLogAttributes, 'user.email', email, false); - setLogAttribute(processedLogAttributes, 'user.name', username, false); - } + const { + user: { id, email, username }, + } = getMergedScopeData(currentScope); + setLogAttribute(processedLogAttributes, 'user.id', id, false); + setLogAttribute(processedLogAttributes, 'user.email', email, false); + setLogAttribute(processedLogAttributes, 'user.name', username, false); setLogAttribute(processedLogAttributes, 'sentry.release', release); setLogAttribute(processedLogAttributes, 'sentry.environment', environment); diff --git a/packages/core/test/lib/logs/exports.test.ts b/packages/core/test/lib/logs/exports.test.ts index 8c1fe4d8e76f..3ba9f59b50d3 100644 --- a/packages/core/test/lib/logs/exports.test.ts +++ b/packages/core/test/lib/logs/exports.test.ts @@ -377,11 +377,10 @@ describe('_INTERNAL_captureLog', () => { }); describe('user functionality', () => { - it('includes user data in log attributes when sendDefaultPii is enabled', () => { + it('includes user data in log attributes', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, _experiments: { enableLogs: true }, - sendDefaultPii: true, }); const client = new TestClient(options); const scope = new Scope(); @@ -410,26 +409,6 @@ describe('_INTERNAL_captureLog', () => { }); }); - it('does not include user data in log attributes when sendDefaultPii is disabled', () => { - const options = getDefaultTestClientOptions({ - dsn: PUBLIC_DSN, - _experiments: { enableLogs: true }, - sendDefaultPii: false, - }); - const client = new TestClient(options); - const scope = new Scope(); - scope.setUser({ - id: '123', - email: 'user@example.com', - username: 'testuser', - }); - - _INTERNAL_captureLog({ level: 'info', message: 'test log without user' }, client, scope); - - const logAttributes = _INTERNAL_getLogBuffer(client)?.[0]?.attributes; - expect(logAttributes).toEqual({}); - }); - it('includes partial user data when only some fields are available', () => { const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN, diff --git a/packages/nextjs/src/common/utils/urls.ts b/packages/nextjs/src/common/utils/urls.ts new file mode 100644 index 000000000000..d1274e1c35d9 --- /dev/null +++ b/packages/nextjs/src/common/utils/urls.ts @@ -0,0 +1,121 @@ +import { getSanitizedUrlStringFromUrlObject, parseStringToURLObject } from '@sentry/core'; + +type ComponentRouteParams = Record | undefined; +type HeadersDict = Record | undefined; + +const HeaderKeys = { + FORWARDED_PROTO: 'x-forwarded-proto', + FORWARDED_HOST: 'x-forwarded-host', + HOST: 'host', + REFERER: 'referer', +} as const; + +/** + * Replaces route parameters in a path template with their values + * @param path - The path template containing parameters in [paramName] format + * @param params - Optional route parameters to replace in the template + * @returns The path with parameters replaced + */ +export function substituteRouteParams(path: string, params?: ComponentRouteParams): string { + if (!params || typeof params !== 'object') return path; + + let resultPath = path; + for (const [key, value] of Object.entries(params)) { + resultPath = resultPath.split(`[${key}]`).join(encodeURIComponent(value)); + } + return resultPath; +} + +/** + * Normalizes a path by removing route groups + * @param path - The path to normalize + * @returns The normalized path + */ +export function sanitizeRoutePath(path: string): string { + const cleanedSegments = path + .split('/') + .filter(segment => segment && !(segment.startsWith('(') && segment.endsWith(')'))); + + return cleanedSegments.length > 0 ? `/${cleanedSegments.join('/')}` : '/'; +} + +/** + * Constructs a full URL from the component route, parameters, and headers. + * + * @param componentRoute - The route template to construct the URL from + * @param params - Optional route parameters to replace in the template + * @param headersDict - Optional headers containing protocol and host information + * @param pathname - Optional pathname coming from parent span "http.target" + * @returns A sanitized URL string + */ +export function buildUrlFromComponentRoute( + componentRoute: string, + params?: ComponentRouteParams, + headersDict?: HeadersDict, + pathname?: string, +): string { + const parameterizedPath = substituteRouteParams(componentRoute, params); + // If available, the pathname from the http.target of the HTTP request server span takes precedence over the parameterized path. + // Spans such as generateMetadata and Server Component rendering are typically direct children of that span. + const path = pathname ?? sanitizeRoutePath(parameterizedPath); + + const protocol = headersDict?.[HeaderKeys.FORWARDED_PROTO]; + const host = headersDict?.[HeaderKeys.FORWARDED_HOST] || headersDict?.[HeaderKeys.HOST]; + + if (!protocol || !host) { + return path; + } + + const fullUrl = `${protocol}://${host}${path}`; + + const urlObject = parseStringToURLObject(fullUrl); + if (!urlObject) { + return path; + } + + return getSanitizedUrlStringFromUrlObject(urlObject); +} + +/** + * Returns a sanitized URL string from the referer header if it exists and is valid. + * + * @param headersDict - Optional headers containing the referer + * @returns A sanitized URL string or undefined if referer is missing/invalid + */ +export function extractSanitizedUrlFromRefererHeader(headersDict?: HeadersDict): string | undefined { + const referer = headersDict?.[HeaderKeys.REFERER]; + if (!referer) { + return undefined; + } + + try { + const refererUrl = new URL(referer); + return getSanitizedUrlStringFromUrlObject(refererUrl); + } catch (error) { + return undefined; + } +} + +/** + * Returns a sanitized URL string using the referer header if available, + * otherwise constructs the URL from the component route, params, and headers. + * + * @param componentRoute - The route template to construct the URL from + * @param params - Optional route parameters to replace in the template + * @param headersDict - Optional headers containing protocol, host, and referer + * @param pathname - Optional pathname coming from root span "http.target" + * @returns A sanitized URL string + */ +export function getSanitizedRequestUrl( + componentRoute: string, + params?: ComponentRouteParams, + headersDict?: HeadersDict, + pathname?: string, +): string { + const refererUrl = extractSanitizedUrlFromRefererHeader(headersDict); + if (refererUrl) { + return refererUrl; + } + + return buildUrlFromComponentRoute(componentRoute, params, headersDict, pathname); +} diff --git a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts index 801c0e9b0dab..79af67475b06 100644 --- a/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts +++ b/packages/nextjs/src/common/wrapGenerationFunctionWithSentry.ts @@ -13,6 +13,7 @@ import { setCapturedScopesOnSpan, SPAN_STATUS_ERROR, SPAN_STATUS_OK, + spanToJSON, startSpanManual, winterCGHeadersToDict, withIsolationScope, @@ -22,7 +23,7 @@ import type { GenerationFunctionContext } from '../common/types'; import { isNotFoundNavigationError, isRedirectNavigationError } from './nextNavigationErrorUtils'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; - +import { getSanitizedRequestUrl } from './utils/urls'; /** * Wraps a generation function (e.g. generateMetadata) with Sentry error and performance instrumentation. */ @@ -44,14 +45,23 @@ export function wrapGenerationFunctionWithSentry a } const isolationScope = commonObjectToIsolationScope(headers); + let pathname = undefined as string | undefined; const activeSpan = getActiveSpan(); if (activeSpan) { const rootSpan = getRootSpan(activeSpan); const { scope } = getCapturedScopesOnSpan(rootSpan); setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); + + const spanData = spanToJSON(rootSpan); + + if (spanData.data && 'http.target' in spanData.data) { + pathname = spanData.data['http.target'] as string; + } } + const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; + let data: Record | undefined = undefined; if (getClient()?.getOptions().sendDefaultPii) { const props: unknown = args[0]; @@ -61,8 +71,6 @@ export function wrapGenerationFunctionWithSentry a data = { params, searchParams }; } - const headersDict = headers ? winterCGHeadersToDict(headers) : undefined; - return withIsolationScope(isolationScope, () => { return withScope(scope => { scope.setTransactionName(`${componentType}.${generationFunctionIdentifier} (${componentRoute})`); @@ -70,6 +78,12 @@ export function wrapGenerationFunctionWithSentry a isolationScope.setSDKProcessingMetadata({ normalizedRequest: { headers: headersDict, + url: getSanitizedRequestUrl( + componentRoute, + data?.params as Record | undefined, + headersDict, + pathname, + ), } satisfies RequestEventData, }); diff --git a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts index 7319ddee9837..16f6728deda1 100644 --- a/packages/nextjs/src/common/wrapServerComponentWithSentry.ts +++ b/packages/nextjs/src/common/wrapServerComponentWithSentry.ts @@ -3,6 +3,7 @@ import { captureException, getActiveSpan, getCapturedScopesOnSpan, + getClient, getRootSpan, handleCallbackErrors, propagationContextFromHeaders, @@ -12,6 +13,7 @@ import { setCapturedScopesOnSpan, SPAN_STATUS_ERROR, SPAN_STATUS_OK, + spanToJSON, startSpanManual, vercelWaitUntil, winterCGHeadersToDict, @@ -23,6 +25,7 @@ import type { ServerComponentContext } from '../common/types'; import { TRANSACTION_ATTR_SENTRY_TRACE_BACKFILL } from './span-attributes-with-logic-attached'; import { flushSafelyWithTimeout } from './utils/responseEnd'; import { commonObjectToIsolationScope, commonObjectToPropagationContext } from './utils/tracingUtils'; +import { getSanitizedRequestUrl } from './utils/urls'; /** * Wraps an `app` directory server component with Sentry error instrumentation. @@ -41,18 +44,36 @@ export function wrapServerComponentWithSentry any> const requestTraceId = getActiveSpan()?.spanContext().traceId; const isolationScope = commonObjectToIsolationScope(context.headers); + let pathname = undefined as string | undefined; const activeSpan = getActiveSpan(); if (activeSpan) { const rootSpan = getRootSpan(activeSpan); const { scope } = getCapturedScopesOnSpan(rootSpan); setCapturedScopesOnSpan(rootSpan, scope ?? new Scope(), isolationScope); + + const spanData = spanToJSON(rootSpan); + + if (spanData.data && 'http.target' in spanData.data) { + pathname = spanData.data['http.target']?.toString(); + } } const headersDict = context.headers ? winterCGHeadersToDict(context.headers) : undefined; + let params: Record | undefined = undefined; + + if (getClient()?.getOptions().sendDefaultPii) { + const props: unknown = args[0]; + params = + props && typeof props === 'object' && 'params' in props + ? (props.params as Record) + : undefined; + } + isolationScope.setSDKProcessingMetadata({ normalizedRequest: { headers: headersDict, + url: getSanitizedRequestUrl(componentRoute, params, headersDict, pathname), } satisfies RequestEventData, }); diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 965233d08b76..ffce8a8b0641 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -45,10 +45,12 @@ export type NextConfigObject = { experimental?: { instrumentationHook?: boolean; clientTraceMetadata?: string[]; + serverComponentsExternalPackages?: string[]; // next < v15.0.0 }; productionBrowserSourceMaps?: boolean; // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; + serverExternalPackages?: string[]; // next >= v15.0.0 }; export type SentryBuildOptions = { diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 322f2e320624..8898b3495ba9 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -410,6 +410,14 @@ export function constructWebpackConfigFunction( ); } + // We inject a map of dependencies that the nextjs app has, as we cannot reliably extract them at runtime, sadly + newConfig.plugins = newConfig.plugins || []; + newConfig.plugins.push( + new buildContext.webpack.DefinePlugin({ + __SENTRY_SERVER_MODULES__: JSON.stringify(_getModules(projectDir)), + }), + ); + return newConfig; }; } @@ -825,3 +833,21 @@ function addOtelWarningIgnoreRule(newConfig: WebpackConfigObjectWithModuleRules) newConfig.ignoreWarnings.push(...ignoreRules); } } + +function _getModules(projectDir: string): Record { + try { + const packageJson = path.join(projectDir, 'package.json'); + const packageJsonContent = fs.readFileSync(packageJson, 'utf8'); + const packageJsonObject = JSON.parse(packageJsonContent) as { + dependencies?: Record; + devDependencies?: Record; + }; + + return { + ...packageJsonObject.dependencies, + ...packageJsonObject.devDependencies, + }; + } catch { + return {}; + } +} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 543f271c1999..b94ce6187f97 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -17,6 +17,35 @@ import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; let showedExperimentalBuildModeWarning = false; +// Packages we auto-instrument need to be external for instrumentation to work +// Next.js externalizes some packages by default, see: https://nextjs.org/docs/app/api-reference/config/next-config-js/serverExternalPackages +// Others we need to add ourselves +export const DEFAULT_SERVER_EXTERNAL_PACKAGES = [ + 'ai', + 'amqplib', + 'connect', + 'dataloader', + 'express', + 'generic-pool', + 'graphql', + '@hapi/hapi', + 'ioredis', + 'kafkajs', + 'koa', + 'lru-memoizer', + 'mongodb', + 'mongoose', + 'mysql', + 'mysql2', + 'knex', + 'pg', + 'pg-pool', + '@node-redis/client', + '@redis/client', + 'redis', + 'tedious', +]; + /** * Modifies the passed in Next.js configuration with automatic build-time instrumentation and source map upload. * @@ -190,8 +219,10 @@ function getFinalConfigObject( ); } + let nextMajor: number | undefined; if (nextJsVersion) { const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); + nextMajor = major; const isSupportedVersion = major !== undefined && minor !== undefined && @@ -229,6 +260,22 @@ function getFinalConfigObject( return { ...incomingUserNextConfigObject, + ...(nextMajor && nextMajor >= 15 + ? { + serverExternalPackages: [ + ...(incomingUserNextConfigObject.serverExternalPackages || []), + ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ], + } + : { + experimental: { + ...incomingUserNextConfigObject.experimental, + serverComponentsExternalPackages: [ + ...(incomingUserNextConfigObject.experimental?.serverComponentsExternalPackages || []), + ...DEFAULT_SERVER_EXTERNAL_PACKAGES, + ], + }, + }), webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName), }; } diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index f9db1a68771e..ee4b2d364c6a 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as util from '../../src/config/util'; +import { DEFAULT_SERVER_EXTERNAL_PACKAGES } from '../../src/config/withSentryConfig'; import { defaultRuntimePhase, defaultsObject, exportedNextConfig, userNextConfig } from './fixtures'; import { materializeFinalNextConfig } from './testUtils'; @@ -22,10 +24,16 @@ describe('withSentryConfig', () => { it("works when user's overall config is an object", () => { const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig).toEqual( + const { webpack, experimental, ...restOfFinalConfig } = finalConfig; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { webpack: _userWebpack, experimental: _userExperimental, ...restOfUserConfig } = userNextConfig; + + expect(restOfFinalConfig).toEqual(restOfUserConfig); + expect(webpack).toBeInstanceOf(Function); + expect(experimental).toEqual( expect.objectContaining({ - ...userNextConfig, - webpack: expect.any(Function), // `webpack` is tested specifically elsewhere + instrumentationHook: true, + serverComponentsExternalPackages: expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES), }), ); }); @@ -35,10 +43,21 @@ describe('withSentryConfig', () => { const finalConfig = materializeFinalNextConfig(exportedNextConfigFunction); - expect(finalConfig).toEqual( + const { webpack, experimental, ...restOfFinalConfig } = finalConfig; + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + webpack: _userWebpack, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + experimental: _userExperimental, + ...restOfUserConfig + } = exportedNextConfigFunction(); + + expect(restOfFinalConfig).toEqual(restOfUserConfig); + expect(webpack).toBeInstanceOf(Function); + expect(experimental).toEqual( expect.objectContaining({ - ...exportedNextConfigFunction(), - webpack: expect.any(Function), // `webpack` is tested specifically elsewhere + instrumentationHook: true, + serverComponentsExternalPackages: expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES), }), ); }); @@ -75,4 +94,54 @@ describe('withSentryConfig', () => { consoleWarnSpy.mockRestore(); } }); + + describe('server packages configuration', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('uses serverExternalPackages for Next.js 15+', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.serverExternalPackages).toBeDefined(); + expect(finalConfig.serverExternalPackages).toEqual(expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES)); + expect(finalConfig.experimental?.serverComponentsExternalPackages).toBeUndefined(); + }); + + it('uses experimental.serverComponentsExternalPackages for Next.js < 15', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('14.0.0'); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); + + expect(finalConfig.serverExternalPackages).toBeUndefined(); + expect(finalConfig.experimental?.serverComponentsExternalPackages).toBeDefined(); + expect(finalConfig.experimental?.serverComponentsExternalPackages).toEqual( + expect.arrayContaining(DEFAULT_SERVER_EXTERNAL_PACKAGES), + ); + }); + + it('preserves existing packages in both versions', () => { + const existingPackages = ['@some/existing-package']; + + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); + const config15 = materializeFinalNextConfig({ + ...exportedNextConfig, + serverExternalPackages: existingPackages, + }); + expect(config15.serverExternalPackages).toEqual( + expect.arrayContaining([...existingPackages, ...DEFAULT_SERVER_EXTERNAL_PACKAGES]), + ); + + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('14.0.0'); + const config14 = materializeFinalNextConfig({ + ...exportedNextConfig, + experimental: { + serverComponentsExternalPackages: existingPackages, + }, + }); + expect(config14.experimental?.serverComponentsExternalPackages).toEqual( + expect.arrayContaining([...existingPackages, ...DEFAULT_SERVER_EXTERNAL_PACKAGES]), + ); + }); + }); }); diff --git a/packages/nextjs/test/utils/urls.test.ts b/packages/nextjs/test/utils/urls.test.ts new file mode 100644 index 000000000000..5b122ef915ec --- /dev/null +++ b/packages/nextjs/test/utils/urls.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; +import { + buildUrlFromComponentRoute, + extractSanitizedUrlFromRefererHeader, + getSanitizedRequestUrl, + sanitizeRoutePath, + substituteRouteParams, +} from '../../src/common/utils/urls'; + +describe('URL Utilities', () => { + describe('buildUrlFromComponentRoute', () => { + const mockHeaders = { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'example.com', + host: 'example.com', + }; + + it('should build URL with protocol and host', () => { + const result = buildUrlFromComponentRoute('/test', undefined, mockHeaders); + expect(result).toBe('https://example.com/test'); + }); + + it('should handle route parameters', () => { + const result = buildUrlFromComponentRoute( + '/users/[id]/posts/[postId]', + { id: '123', postId: '456' }, + mockHeaders, + ); + expect(result).toBe('https://example.com/users/123/posts/456'); + }); + + it('should handle multiple instances of the same parameter', () => { + const result = buildUrlFromComponentRoute('/users/[id]/[id]/profile', { id: '123' }, mockHeaders); + expect(result).toBe('https://example.com/users/123/123/profile'); + }); + + it('should handle special characters in parameters', () => { + const result = buildUrlFromComponentRoute('/search/[query]', { query: 'hello world' }, mockHeaders); + expect(result).toBe('https://example.com/search/hello%20world'); + }); + + it('should handle route groups', () => { + const result = buildUrlFromComponentRoute('/(auth)/login', undefined, mockHeaders); + expect(result).toBe('https://example.com/login'); + }); + + it('should normalize multiple slashes', () => { + const result = buildUrlFromComponentRoute('//users///profile', undefined, mockHeaders); + expect(result).toBe('https://example.com/users/profile'); + }); + + it('should handle trailing slashes', () => { + const result = buildUrlFromComponentRoute('/users/', undefined, mockHeaders); + expect(result).toBe('https://example.com/users'); + }); + + it('should handle root path', () => { + const result = buildUrlFromComponentRoute('', undefined, mockHeaders); + expect(result).toBe('https://example.com/'); + }); + + it('should use pathname if provided', () => { + const result = buildUrlFromComponentRoute('/original', undefined, mockHeaders, '/override'); + expect(result).toBe('https://example.com/override'); + }); + + it('should return path only if protocol is missing', () => { + const result = buildUrlFromComponentRoute('/test', undefined, { host: 'example.com' }); + expect(result).toBe('/test'); + }); + + it('should return path only if host is missing', () => { + const result = buildUrlFromComponentRoute('/test', undefined, { 'x-forwarded-proto': 'https' }); + expect(result).toBe('/test'); + }); + + it('should handle invalid URL construction', () => { + const result = buildUrlFromComponentRoute('/test', undefined, { + 'x-forwarded-proto': 'invalid://', + host: 'example.com', + }); + expect(result).toBe('/test'); + }); + }); + + describe('extractSanitizedUrlFromRefererHeader', () => { + it('should return undefined if referer is missing', () => { + const result = extractSanitizedUrlFromRefererHeader({}); + expect(result).toBeUndefined(); + }); + + it('should return undefined if referer is invalid', () => { + const result = extractSanitizedUrlFromRefererHeader({ referer: 'invalid-url' }); + expect(result).toBeUndefined(); + }); + + it('should handle referer with special characters', () => { + const headers = { referer: 'https://example.com/path with spaces/ümlaut' }; + const result = extractSanitizedUrlFromRefererHeader(headers); + expect(result).toBe('https://example.com/path%20with%20spaces/%C3%BCmlaut'); + }); + }); + + describe('getSanitizedRequestUrl', () => { + const mockHeaders = { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'example.com', + host: 'example.com', + }; + + it('should use referer URL if available and valid', () => { + const headers = { + ...mockHeaders, + referer: 'https://example.com/referer-page', + }; + const result = getSanitizedRequestUrl('/original', undefined, headers); + expect(result).toBe('https://example.com/referer-page'); + }); + + it('should fall back to building URL if referer is invalid', () => { + const headers = { + ...mockHeaders, + referer: 'invalid-url', + }; + const result = getSanitizedRequestUrl('/fallback', undefined, headers); + expect(result).toBe('https://example.com/fallback'); + }); + + it('should fall back to building URL if referer is missing', () => { + const result = getSanitizedRequestUrl('/fallback', undefined, mockHeaders); + expect(result).toBe('https://example.com/fallback'); + }); + + it('should handle route parameters in fallback URL', () => { + const result = getSanitizedRequestUrl('/users/[id]', { id: '123' }, mockHeaders); + expect(result).toBe('https://example.com/users/123'); + }); + + it('should handle pathname override in fallback URL', () => { + const result = getSanitizedRequestUrl('/original', undefined, mockHeaders, '/override'); + expect(result).toBe('https://example.com/override'); + }); + + it('should handle empty headers', () => { + const result = getSanitizedRequestUrl('/test', undefined, {}); + expect(result).toBe('/test'); + }); + + it('should handle undefined headers', () => { + const result = getSanitizedRequestUrl('/test', undefined, undefined); + expect(result).toBe('/test'); + }); + }); + + describe('sanitizeRoutePath', () => { + it('should handle root path', () => { + const result = sanitizeRoutePath(''); + expect(result).toBe('/'); + }); + + it('should handle multiple slashes', () => { + const result = sanitizeRoutePath('////foo///bar'); + expect(result).toBe('/foo/bar'); + }); + + it('should handle route groups', () => { + const result = sanitizeRoutePath('/products/(auth)/details'); + expect(result).toBe('/products/details'); + }); + }); + + describe('substituteRouteParams', () => { + it('should handle route parameters', () => { + const result = substituteRouteParams('/users/[id]', { id: '123' }); + expect(result).toBe('/users/123'); + }); + + it('should handle multiple instances of the same parameter', () => { + const result = substituteRouteParams('/users/[id]/[id]/profile', { id: '123' }); + expect(result).toBe('/users/123/123/profile'); + }); + + it('should handle special characters in parameters', () => { + const result = substituteRouteParams('/search/[query]', { query: 'hello world' }); + expect(result).toBe('/search/hello%20world'); + }); + + it('should handle undefined parameters', () => { + const result = substituteRouteParams('/users/[id]', undefined); + expect(result).toBe('/users/[id]'); + }); + }); +}); diff --git a/packages/node/src/integrations/modules.ts b/packages/node/src/integrations/modules.ts index e15aa9dd245b..6adee9e46744 100644 --- a/packages/node/src/integrations/modules.ts +++ b/packages/node/src/integrations/modules.ts @@ -1,26 +1,23 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, logger } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; import { isCjs } from '../utils/commonjs'; -let moduleCache: { [key: string]: string }; +type ModuleInfo = Record; + +let moduleCache: ModuleInfo | undefined; const INTEGRATION_NAME = 'Modules'; -const _modulesIntegration = (() => { - // This integration only works in CJS contexts - if (!isCjs()) { - DEBUG_BUILD && - logger.warn( - 'modulesIntegration only works in CommonJS (CJS) environments. Remove this integration if you are using ESM.', - ); - return { - name: INTEGRATION_NAME, - }; - } +declare const __SENTRY_SERVER_MODULES__: Record; + +/** + * `__SENTRY_SERVER_MODULES__` can be replaced at build time with the modules loaded by the server. + * Right now, we leverage this in Next.js to circumvent the problem that we do not get access to these things at runtime. + */ +const SERVER_MODULES = typeof __SENTRY_SERVER_MODULES__ === 'undefined' ? {} : __SENTRY_SERVER_MODULES__; +const _modulesIntegration = (() => { return { name: INTEGRATION_NAME, processEvent(event) { @@ -31,18 +28,20 @@ const _modulesIntegration = (() => { return event; }, + getModules: _getModules, }; }) satisfies IntegrationFn; /** * Add node modules / packages to the event. - * - * Only works in CommonJS (CJS) environments. + * For this, multiple sources are used: + * - They can be injected at build time into the __SENTRY_SERVER_MODULES__ variable (e.g. in Next.js) + * - They are extracted from the dependencies & devDependencies in the package.json file + * - They are extracted from the require.cache (CJS only) */ -export const modulesIntegration = defineIntegration(_modulesIntegration); +export const modulesIntegration = _modulesIntegration; -/** Extract information about paths */ -function getPaths(): string[] { +function getRequireCachePaths(): string[] { try { return require.cache ? Object.keys(require.cache as Record) : []; } catch (e) { @@ -51,17 +50,23 @@ function getPaths(): string[] { } /** Extract information about package.json modules */ -function collectModules(): { - [name: string]: string; -} { +function collectModules(): ModuleInfo { + return { + ...SERVER_MODULES, + ...getModulesFromPackageJson(), + ...(isCjs() ? collectRequireModules() : {}), + }; +} + +/** Extract information about package.json modules from require.cache */ +function collectRequireModules(): ModuleInfo { const mainPaths = require.main?.paths || []; - const paths = getPaths(); - const infos: { - [name: string]: string; - } = {}; - const seen: { - [path: string]: boolean; - } = {}; + const paths = getRequireCachePaths(); + + // We start with the modules from package.json (if possible) + // These may be overwritten by more specific versions from the require.cache + const infos: ModuleInfo = {}; + const seen = new Set(); paths.forEach(path => { let dir = path; @@ -71,7 +76,7 @@ function collectModules(): { const orig = dir; dir = dirname(orig); - if (!dir || orig === dir || seen[orig]) { + if (!dir || orig === dir || seen.has(orig)) { return undefined; } if (mainPaths.indexOf(dir) < 0) { @@ -79,7 +84,7 @@ function collectModules(): { } const pkgfile = join(orig, 'package.json'); - seen[orig] = true; + seen.add(orig); if (!existsSync(pkgfile)) { return updir(); @@ -103,9 +108,34 @@ function collectModules(): { } /** Fetches the list of modules and the versions loaded by the entry file for your node.js app. */ -function _getModules(): { [key: string]: string } { +function _getModules(): ModuleInfo { if (!moduleCache) { moduleCache = collectModules(); } return moduleCache; } + +interface PackageJson { + dependencies?: Record; + devDependencies?: Record; +} + +function getPackageJson(): PackageJson { + try { + const filePath = join(process.cwd(), 'package.json'); + const packageJson = JSON.parse(readFileSync(filePath, 'utf8')) as PackageJson; + + return packageJson; + } catch (e) { + return {}; + } +} + +function getModulesFromPackageJson(): ModuleInfo { + const packageJson = getPackageJson(); + + return { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; +} diff --git a/packages/node/src/integrations/tracing/vercelai/index.ts b/packages/node/src/integrations/tracing/vercelai/index.ts index d2f73e02adc3..5c7a0ed5d959 100644 --- a/packages/node/src/integrations/tracing/vercelai/index.ts +++ b/packages/node/src/integrations/tracing/vercelai/index.ts @@ -1,13 +1,18 @@ /* eslint-disable @typescript-eslint/no-dynamic-delete */ /* eslint-disable complexity */ -import type { IntegrationFn } from '@sentry/core'; +import type { Client, IntegrationFn } from '@sentry/core'; import { defineIntegration, SEMANTIC_ATTRIBUTE_SENTRY_OP, spanToJSON } from '@sentry/core'; import { generateInstrumentOnce } from '../../../otel/instrument'; import { addOriginToSpan } from '../../../utils/addOriginToSpan'; +import type { modulesIntegration } from '../../modules'; import { AI_MODEL_ID_ATTRIBUTE, AI_MODEL_PROVIDER_ATTRIBUTE, AI_PROMPT_ATTRIBUTE, + AI_PROMPT_MESSAGES_ATTRIBUTE, + AI_PROMPT_TOOLS_ATTRIBUTE, + AI_RESPONSE_TEXT_ATTRIBUTE, + AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, AI_TELEMETRY_FUNCTION_ID_ATTRIBUTE, AI_TOOL_CALL_ID_ATTRIBUTE, AI_TOOL_CALL_NAME_ATTRIBUTE, @@ -23,6 +28,15 @@ import type { VercelAiOptions } from './types'; export const instrumentVercelAi = generateInstrumentOnce(INTEGRATION_NAME, () => new SentryVercelAiInstrumentation({})); +/** + * Determines if the integration should be forced based on environment and package availability. + * Returns true if the 'ai' package is available. + */ +function shouldForceIntegration(client: Client): boolean { + const modules = client.getIntegrationByName>('Modules'); + return !!modules?.getModules?.()?.ai; +} + const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { let instrumentation: undefined | SentryVercelAiInstrumentation; @@ -32,7 +46,7 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { setupOnce() { instrumentation = instrumentVercelAi(); }, - setup(client) { + afterAllSetup(client) { function registerProcessors(): void { client.on('spanStart', span => { const { data: attributes, description: name } = spanToJSON(span); @@ -183,6 +197,24 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { attributes['gen_ai.usage.total_tokens'] = attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] + attributes[GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE]; } + + // Rename AI SDK attributes to standardized gen_ai attributes + if (attributes[AI_PROMPT_MESSAGES_ATTRIBUTE] != undefined) { + attributes['gen_ai.request.messages'] = attributes[AI_PROMPT_MESSAGES_ATTRIBUTE]; + delete attributes[AI_PROMPT_MESSAGES_ATTRIBUTE]; + } + if (attributes[AI_RESPONSE_TEXT_ATTRIBUTE] != undefined) { + attributes['gen_ai.response.text'] = attributes[AI_RESPONSE_TEXT_ATTRIBUTE]; + delete attributes[AI_RESPONSE_TEXT_ATTRIBUTE]; + } + if (attributes[AI_RESPONSE_TOOL_CALLS_ATTRIBUTE] != undefined) { + attributes['gen_ai.response.tool_calls'] = attributes[AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]; + delete attributes[AI_RESPONSE_TOOL_CALLS_ATTRIBUTE]; + } + if (attributes[AI_PROMPT_TOOLS_ATTRIBUTE] != undefined) { + attributes['gen_ai.request.available_tools'] = attributes[AI_PROMPT_TOOLS_ATTRIBUTE]; + delete attributes[AI_PROMPT_TOOLS_ATTRIBUTE]; + } } } @@ -190,7 +222,11 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { }); } - if (options.force) { + // Auto-detect if we should force the integration when running with 'ai' package available + // Note that this can only be detected if the 'Modules' integration is available, and running in CJS mode + const shouldForce = options.force ?? shouldForceIntegration(client); + + if (shouldForce) { registerProcessors(); } else { instrumentation?.callWhenPatched(registerProcessors); @@ -213,6 +249,9 @@ const _vercelAIIntegration = ((options: VercelAiOptions = {}) => { * }); * ``` * + * The integration automatically detects when to force registration in CommonJS environments + * when the 'ai' package is available. You can still manually set the `force` option if needed. + * * By default this integration adds tracing support to all `ai` function calls. If you need to disable * collecting spans for a specific call, you can do so by setting `experimental_telemetry.isEnabled` to * `false` in the first argument of the function call. diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 1536242cfdcb..e693d3976fe4 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -40,10 +40,6 @@ import { defaultStackParser, getSentryRelease } from './api'; import { NodeClient } from './client'; import { initOpenTelemetry, maybeInitializeEsmLoader } from './initOtel'; -function getCjsOnlyIntegrations(): Integration[] { - return isCjs() ? [modulesIntegration()] : []; -} - /** * Get default integrations, excluding performance. */ @@ -69,7 +65,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { nodeContextIntegration(), childProcessIntegration(), processSessionIntegration(), - ...getCjsOnlyIntegrations(), + modulesIntegration(), ]; } diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index fee780def708..3c6b41de60f5 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -180,9 +180,6 @@ export class SentrySpanExporter { this.flushSentSpanCache(); const sentSpans = this._maybeSend(finishedSpans); - for (const span of finishedSpans) { - this._sentSpans.set(span.spanContext().spanId, Date.now() + DEFAULT_TIMEOUT * 1000); - } const sentSpanCount = sentSpans.size; const remainingOpenSpanCount = finishedSpans.length - sentSpanCount; @@ -191,7 +188,10 @@ export class SentrySpanExporter { `SpanExporter exported ${sentSpanCount} spans, ${remainingOpenSpanCount} spans are waiting for their parent spans to finish`, ); + const expirationDate = Date.now() + DEFAULT_TIMEOUT * 1000; + for (const span of sentSpans) { + this._sentSpans.set(span.spanContext().spanId, expirationDate); const bucketEntry = this._spansToBucketEntry.get(span); if (bucketEntry) { bucketEntry.spans.delete(span); diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index 165871df69ca..9bc1847b422b 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -680,6 +680,80 @@ describe('Integration | Transactions', () => { expect(finishedSpans[0]?.name).toBe('inner span 2'); }); + it('only considers sent spans, not finished spans, for flushing orphaned spans of sent spans', async () => { + const timeout = 5 * 60 * 1000; + const now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + + const logs: unknown[] = []; + vi.spyOn(logger, 'log').mockImplementation(msg => logs.push(msg)); + + const transactions: Event[] = []; + + mockSdkInit({ + tracesSampleRate: 1, + beforeSendTransaction: event => { + transactions.push(event); + return null; + }, + }); + + const provider = getProvider(); + const multiSpanProcessor = provider?.activeSpanProcessor as + | (SpanProcessor & { _spanProcessors?: SpanProcessor[] }) + | undefined; + const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( + spanProcessor => spanProcessor instanceof SentrySpanProcessor, + ) as SentrySpanProcessor | undefined; + + const exporter = spanProcessor ? spanProcessor['_exporter'] : undefined; + + if (!exporter) { + throw new Error('No exporter found, aborting test...'); + } + + /** + * This is our span structure: + * span 1 -------- + * span 2 --- + * span 3 - + * + * Where span 2 is finished before span 3 & span 1 + */ + + const [span1, span3] = startSpanManual({ name: 'span 1' }, span1 => { + const [span2, span3] = startSpanManual({ name: 'span 2' }, span2 => { + const span3 = startInactiveSpan({ name: 'span 3' }); + return [span2, span3]; + }); + + // End span 2 before span 3 + span2.end(); + + return [span1, span3]; + }); + + vi.advanceTimersByTime(1); + + // nothing should be sent yet, as span1 is not yet done + expect(transactions).toHaveLength(0); + + // Now finish span1, should be sent with only span2 but without span3, as that is not yet finished + span1.end(); + vi.advanceTimersByTime(1); + + expect(transactions).toHaveLength(1); + expect(transactions[0]?.spans).toHaveLength(1); + + // now finish span3, which should be sent as transaction too + span3.end(); + vi.advanceTimersByTime(timeout); + + expect(transactions).toHaveLength(2); + expect(transactions[1]?.spans).toHaveLength(0); + }); + it('uses & inherits DSC on span trace state', async () => { const transactionEvents: Event[] = []; const beforeSendTransaction = vi.fn(event => { diff --git a/yarn.lock b/yarn.lock index 02a6f885056b..0deb47ad0828 100644 --- a/yarn.lock +++ b/yarn.lock @@ -27707,9 +27707,9 @@ tmp@^0.2.1, tmp@~0.2.1: rimraf "^3.0.0" tmpl@1.0.x: - version "1.0.4" - resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1" - integrity sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE= + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== to-object-path@^0.3.0: version "0.3.0"