From 480217f7cd3c39e76932b9e778948e2ad9095a76 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 15 Oct 2025 15:56:04 +0200 Subject: [PATCH 01/23] feat(node): Allow selective tracking of `pino` loggers (#17933) - Closes #17904 This PR adds two methods to the `pinoIntegration` export. `trackLogger` and `untrackLogger`; `untrackLogger` can be used to disable capturing from a specific logger. You can also disable `autoInstrument` and only track specific loggers: ```ts import * as Sentry from '@sentry/node'; import pino from 'pino'; Sentry.init({ dsn: '__DSN__', integrations: [Sentry.pinoIntegration({ autoInstrument: false })], }); const logger = pino({}); Sentry.pinoIntegration.trackLogger(logger); logger.debug('This will be captured!'); ``` --- .../suites/pino/instrument-auto-off.mjs | 8 ++ .../suites/pino/scenario-track.mjs | 23 ++++++ .../suites/pino/scenario.mjs | 5 ++ .../suites/pino/test.ts | 50 ++++++++++++ packages/node-core/src/integrations/pino.ts | 77 +++++++++++++++++-- 5 files changed, 155 insertions(+), 8 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs create mode 100644 dev-packages/node-integration-tests/suites/pino/scenario-track.mjs diff --git a/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs new file mode 100644 index 000000000000..d2a0408eebcd --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/instrument-auto-off.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + release: '1.0', + enableLogs: true, + integrations: [Sentry.pinoIntegration({ autoInstrument: false })], +}); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs new file mode 100644 index 000000000000..2e968444a74f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/pino/scenario-track.mjs @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import pino from 'pino'; + +const logger = pino({ name: 'myapp' }); +Sentry.pinoIntegration.trackLogger(logger); + +const loggerIgnore = pino({ name: 'ignore' }); + +loggerIgnore.info('this should be ignored'); + +Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'startup' }, () => { + logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); + }); +}); + +setTimeout(() => { + Sentry.withIsolationScope(() => { + Sentry.startSpan({ name: 'later' }, () => { + logger.error(new Error('oh no')); + }); + }); +}, 1000); diff --git a/dev-packages/node-integration-tests/suites/pino/scenario.mjs b/dev-packages/node-integration-tests/suites/pino/scenario.mjs index ea8dc5e223d0..beb080ac3c42 100644 --- a/dev-packages/node-integration-tests/suites/pino/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/pino/scenario.mjs @@ -3,6 +3,11 @@ import pino from 'pino'; const logger = pino({ name: 'myapp' }); +const ignoredLogger = pino({ name: 'ignored' }); +Sentry.pinoIntegration.untrackLogger(ignoredLogger); + +ignoredLogger.info('this will not be tracked'); + Sentry.withIsolationScope(() => { Sentry.startSpan({ name: 'startup' }, () => { logger.info({ user: 'user-id', something: { more: 3, complex: 'nope' } }, 'hello world'); diff --git a/dev-packages/node-integration-tests/suites/pino/test.ts b/dev-packages/node-integration-tests/suites/pino/test.ts index cc88f650203b..1982c8d686fc 100644 --- a/dev-packages/node-integration-tests/suites/pino/test.ts +++ b/dev-packages/node-integration-tests/suites/pino/test.ts @@ -173,4 +173,54 @@ conditionalTest({ min: 20 })('Pino integration', () => { .start() .completed(); }); + + test('captures logs when autoInstrument is false and logger is tracked', async () => { + const instrumentPath = join(__dirname, 'instrument-auto-off.mjs'); + + await createRunner(__dirname, 'scenario-track.mjs') + .withMockSentryServer() + .withInstrument(instrumentPath) + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: 'hello world', + trace_id: expect.any(String), + severity_number: 9, + attributes: expect.objectContaining({ + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 30, type: 'integer' }, + user: { value: 'user-id', type: 'string' }, + something: { + type: 'string', + value: '{"more":3,"complex":"nope"}', + }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + { + timestamp: expect.any(Number), + level: 'error', + body: 'oh no', + trace_id: expect.any(String), + severity_number: 17, + attributes: expect.objectContaining({ + 'pino.logger.name': { value: 'myapp', type: 'string' }, + 'pino.logger.level': { value: 50, type: 'integer' }, + err: { value: '{}', type: 'string' }, + 'sentry.origin': { value: 'auto.logging.pino', type: 'string' }, + 'sentry.release': { value: '1.0', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + }), + }, + ], + }, + }) + .start() + .completed(); + }); }); diff --git a/packages/node-core/src/integrations/pino.ts b/packages/node-core/src/integrations/pino.ts index 6b78bcdb4386..dfc51d5022ff 100644 --- a/packages/node-core/src/integrations/pino.ts +++ b/packages/node-core/src/integrations/pino.ts @@ -1,5 +1,5 @@ import { tracingChannel } from 'node:diagnostics_channel'; -import type { IntegrationFn, LogSeverityLevel } from '@sentry/core'; +import type { Integration, IntegrationFn, LogSeverityLevel } from '@sentry/core'; import { _INTERNAL_captureLog, addExceptionMechanism, @@ -11,6 +11,8 @@ import { } from '@sentry/core'; import { addInstrumentationConfig } from '../sdk/injectLoader'; +const SENTRY_TRACK_SYMBOL = Symbol('sentry-track-pino-logger'); + type LevelMapping = { // Fortunately pino uses the same levels as Sentry labels: { [level: number]: LogSeverityLevel }; @@ -18,6 +20,7 @@ type LevelMapping = { type Pino = { levels: LevelMapping; + [SENTRY_TRACK_SYMBOL]?: 'track' | 'ignore'; }; type MergeObject = { @@ -28,6 +31,17 @@ type MergeObject = { type PinoHookArgs = [MergeObject, string, number]; type PinoOptions = { + /** + * Automatically instrument all Pino loggers. + * + * When set to `false`, only loggers marked with `pinoIntegration.trackLogger(logger)` will be captured. + * + * @default true + */ + autoInstrument: boolean; + /** + * Options to enable capturing of error events. + */ error: { /** * Levels that trigger capturing of events. @@ -43,6 +57,9 @@ type PinoOptions = { */ handled: boolean; }; + /** + * Options to enable capturing of logs. + */ log: { /** * Levels that trigger capturing of logs. Logs are only captured if @@ -55,6 +72,7 @@ type PinoOptions = { }; const DEFAULT_OPTIONS: PinoOptions = { + autoInstrument: true, error: { levels: [], handled: true }, log: { levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] }, }; @@ -63,18 +81,18 @@ type DeepPartial = { [P in keyof T]?: T[P] extends object ? Partial : T[P]; }; -/** - * Integration for Pino logging library. - * Captures Pino logs as Sentry logs and optionally captures some log levels as events. - * - * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 - */ -export const pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { +const _pinoIntegration = defineIntegration((userOptions: DeepPartial = {}) => { const options: PinoOptions = { + autoInstrument: userOptions.autoInstrument === false ? userOptions.autoInstrument : DEFAULT_OPTIONS.autoInstrument, error: { ...DEFAULT_OPTIONS.error, ...userOptions.error }, log: { ...DEFAULT_OPTIONS.log, ...userOptions.log }, }; + function shouldTrackLogger(logger: Pino): boolean { + const override = logger[SENTRY_TRACK_SYMBOL]; + return override === 'track' || (override !== 'ignore' && options.autoInstrument); + } + return { name: 'Pino', setup: client => { @@ -95,6 +113,10 @@ export const pinoIntegration = defineIntegration((userOptions: DeepPartial): Integration; + /** + * Marks a Pino logger to be tracked by the Pino integration. + * + * @param logger A Pino logger instance. + */ + trackLogger(logger: unknown): void; + /** + * Marks a Pino logger to be ignored by the Pino integration. + * + * @param logger A Pino logger instance. + */ + untrackLogger(logger: unknown): void; +} + +/** + * Integration for Pino logging library. + * Captures Pino logs as Sentry logs and optionally captures some log levels as events. + * + * By default, all Pino loggers will be captured. To ignore a specific logger, use `pinoIntegration.untrackLogger(logger)`. + * + * If you disable automatic instrumentation with `autoInstrument: false`, you can mark specific loggers to be tracked with `pinoIntegration.trackLogger(logger)`. + * + * Requires Pino >=v8.0.0 and Node >=20.6.0 or >=18.19.0 + */ +export const pinoIntegration = Object.assign(_pinoIntegration, { + trackLogger(logger: unknown): void { + if (logger && typeof logger === 'object' && 'levels' in logger) { + (logger as Pino)[SENTRY_TRACK_SYMBOL] = 'track'; + } + }, + untrackLogger(logger: unknown): void { + if (logger && typeof logger === 'object' && 'levels' in logger) { + (logger as Pino)[SENTRY_TRACK_SYMBOL] = 'ignore'; + } + }, +}) as PinoIntegrationFunction; From 7bc13809dd5a3cc640ac06f85cf396f3b5de8f9e Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:33:21 +0200 Subject: [PATCH 02/23] chore(solid): Remove unnecessary import from README (#17947) --- packages/solid/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/solid/README.md b/packages/solid/README.md index 58fa5c75c345..29336b5ba250 100644 --- a/packages/solid/README.md +++ b/packages/solid/README.md @@ -67,7 +67,6 @@ Pass your router instance from `createRouter` to the integration. ```js import * as Sentry from '@sentry/solid'; import { tanstackRouterBrowserTracingIntegration } from '@sentry/solid/tanstackrouter'; -import { Route, Router } from '@solidjs/router'; const router = createRouter({ // your router config From 6230aed2f5ebecc752a0e5fa05d7c23e3e719a59 Mon Sep 17 00:00:00 2001 From: 0xbad0c0d3 <0xbad0c0d3@gmail.com> Date: Thu, 16 Oct 2025 11:47:31 +0300 Subject: [PATCH 03/23] fix(cloudflare): copy execution context in durable objects and handlers (#17786) I was running tests locally and noticed that they are stays running if something wents wrong that's why I've added: 04d2fbfa187a36595b940599d551dc4ca9f3d138 fixes #17514 Is there a way to run e2e tests pipeline without merging to develop? --- > [!NOTE] > Introduce `copyExecutionContext` with tests; make Cloudflare integration test runner accept `AbortSignal` and update tests; ignore JUnit reports. > > - **Cloudflare SDK**: > - **Utility**: Add `packages/cloudflare/src/utils/copyExecutionContext.ts` to clone `ExecutionContext`/`DurableObjectState` with overridable, bound methods. > - **Tests**: Add `packages/cloudflare/test/copy-execution-context.test.ts` covering method overriding, immutability safety, and symbol property preservation. > - **Integration Tests (Cloudflare)**: > - **Runner**: `dev-packages/cloudflare-integration-tests/runner.ts` `start` now accepts optional `AbortSignal` and forwards it to `spawn`. > - **Suites**: Update tests (`basic`, `tracing/anthropic-ai`, `tracing/durableobject`, `tracing/openai`) to pass `{ signal }` from Vitest and call `.start(signal)`. > - **Repo**: > - `.gitignore`: Ignore `packages/**/*.junit.xml` JUnit reports. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit cb97187046d8b9c618757aba0594dfd171c438eb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: cod1k --- .gitignore | 3 + .../cloudflare-integration-tests/runner.ts | 4 +- .../suites/basic/test.ts | 4 +- .../suites/tracing/anthropic-ai/test.ts | 4 +- .../suites/tracing/durableobject/test.ts | 4 +- .../suites/tracing/openai/test.ts | 4 +- packages/cloudflare/src/durableobject.ts | 4 +- packages/cloudflare/src/handler.ts | 23 +++++-- .../src/utils/copyExecutionContext.ts | 69 +++++++++++++++++++ packages/cloudflare/src/workflows.ts | 8 ++- .../test/copy-execution-context.test.ts | 56 +++++++++++++++ 11 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 packages/cloudflare/src/utils/copyExecutionContext.ts create mode 100644 packages/cloudflare/test/copy-execution-context.test.ts diff --git a/.gitignore b/.gitignore index f381e7e6e24d..36f8a3f6b9fe 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,6 @@ packages/gatsby/gatsby-node.d.ts # intellij *.iml /**/.wrangler/* + +#junit reports +packages/**/*.junit.xml diff --git a/dev-packages/cloudflare-integration-tests/runner.ts b/dev-packages/cloudflare-integration-tests/runner.ts index 849b011250f9..b945bee2eeea 100644 --- a/dev-packages/cloudflare-integration-tests/runner.ts +++ b/dev-packages/cloudflare-integration-tests/runner.ts @@ -86,7 +86,7 @@ export function createRunner(...paths: string[]) { } return this; }, - start: function (): StartResult { + start: function (signal?: AbortSignal): StartResult { const { resolve, reject, promise: isComplete } = deferredPromise(cleanupChildProcesses); const expectedEnvelopeCount = expectedEnvelopes.length; @@ -155,7 +155,7 @@ export function createRunner(...paths: string[]) { '--var', `SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`, ], - { stdio }, + { stdio, signal }, ); CLEANUP_STEPS.add(() => { diff --git a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts index b785e6e37fd1..347c0d3530d8 100644 --- a/dev-packages/cloudflare-integration-tests/suites/basic/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/basic/test.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest'; import { eventEnvelope } from '../../expect'; import { createRunner } from '../../runner'; -it('Basic error in fetch handler', async () => { +it('Basic error in fetch handler', async ({ signal }) => { const runner = createRunner(__dirname) .expect( eventEnvelope({ @@ -26,7 +26,7 @@ it('Basic error in fetch handler', async () => { }, }), ) - .start(); + .start(signal); await runner.makeRequest('get', '/', { expectError: true }); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts index 13966caaf460..c9e112b32241 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/anthropic-ai/test.ts @@ -6,7 +6,7 @@ import { createRunner } from '../../../runner'; // want to test that the instrumentation does not break in our // cloudflare SDK. -it('traces a basic message creation request', async () => { +it('traces a basic message creation request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { @@ -35,7 +35,7 @@ it('traces a basic message creation request', async () => { ]), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts index a9daae21480f..e86508c0f101 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts @@ -1,7 +1,7 @@ import { expect, it } from 'vitest'; import { createRunner } from '../../../runner'; -it('traces a durable object method', async () => { +it('traces a durable object method', async ({ signal }) => { const runner = createRunner(__dirname) .expect(envelope => { const transactionEvent = envelope[1]?.[0]?.[1]; @@ -21,7 +21,7 @@ it('traces a durable object method', async () => { }), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/hello'); await runner.completed(); }); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts index c1aee24136a4..eb15fd80fc97 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/openai/test.ts @@ -6,7 +6,7 @@ import { createRunner } from '../../../runner'; // want to test that the instrumentation does not break in our // cloudflare SDK. -it('traces a basic chat completion request', async () => { +it('traces a basic chat completion request', async ({ signal }) => { const runner = createRunner(__dirname) .ignore('event') .expect(envelope => { @@ -37,7 +37,7 @@ it('traces a basic chat completion request', async () => { ]), ); }) - .start(); + .start(signal); await runner.makeRequest('get', '/'); await runner.completed(); }); diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts index 0f139a80ccd0..64467aad9d8f 100644 --- a/packages/cloudflare/src/durableobject.ts +++ b/packages/cloudflare/src/durableobject.ts @@ -18,6 +18,7 @@ import { isInstrumented, markAsInstrumented } from './instrument'; import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; type MethodWrapperOptions = { spanName?: string; @@ -192,8 +193,9 @@ export function instrumentDurableObjectWithSentry< C extends new (state: DurableObjectState, env: E) => T, >(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C { return new Proxy(DurableObjectClass, { - construct(target, [context, env]) { + construct(target, [ctx, env]) { setAsyncLocalStorageAsyncContextStrategy(); + const context = copyExecutionContext(ctx); const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/handler.ts b/packages/cloudflare/src/handler.ts index 969cb6be72ee..e3e108b913d7 100644 --- a/packages/cloudflare/src/handler.ts +++ b/packages/cloudflare/src/handler.ts @@ -15,6 +15,7 @@ import { getFinalOptions } from './options'; import { wrapRequestHandler } from './request'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; /** * Wrapper for Cloudflare handlers. @@ -38,7 +39,9 @@ export function withSentry>) { - const [request, env, context] = args; + const [request, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; const options = getFinalOptions(optionsCallback(env), env); @@ -72,7 +75,10 @@ export function withSentry>) { - const [event, env, context] = args; + const [event, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; + return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); const waitUntil = context.waitUntil.bind(context); @@ -115,7 +121,10 @@ export function withSentry>) { - const [emailMessage, env, context] = args; + const [emailMessage, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; + return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); const waitUntil = context.waitUntil.bind(context); @@ -156,7 +165,9 @@ export function withSentry>) { - const [batch, env, context] = args; + const [batch, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; return withIsolationScope(isolationScope => { const options = getFinalOptions(optionsCallback(env), env); @@ -206,7 +217,9 @@ export function withSentry>) { - const [, env, context] = args; + const [, env, ctx] = args; + const context = copyExecutionContext(ctx); + args[2] = context; return withIsolationScope(async isolationScope => { const options = getFinalOptions(optionsCallback(env), env); diff --git a/packages/cloudflare/src/utils/copyExecutionContext.ts b/packages/cloudflare/src/utils/copyExecutionContext.ts new file mode 100644 index 000000000000..85a007f16e18 --- /dev/null +++ b/packages/cloudflare/src/utils/copyExecutionContext.ts @@ -0,0 +1,69 @@ +import { type DurableObjectState, type ExecutionContext } from '@cloudflare/workers-types'; + +type ContextType = ExecutionContext | DurableObjectState; +type OverridesStore = Map unknown>; + +/** + * Creates a new copy of the given execution context, optionally overriding methods. + * + * @param {ContextType|void} ctx - The execution context to be copied. Can be of type `ContextType` or `void`. + * @return {ContextType|void} A new execution context with the same properties and overridden methods if applicable. + */ +export function copyExecutionContext(ctx: T): T { + if (!ctx) return ctx; + + const overrides: OverridesStore = new Map(); + const contextPrototype = Object.getPrototypeOf(ctx); + const prototypeMethodNames = Object.getOwnPropertyNames(contextPrototype) as unknown as (keyof T)[]; + const ownPropertyNames = Object.getOwnPropertyNames(ctx) as unknown as (keyof T)[]; + const instrumented = new Set(['constructor']); + const descriptors = [...ownPropertyNames, ...prototypeMethodNames].reduce((prevDescriptors, methodName) => { + if (instrumented.has(methodName)) return prevDescriptors; + if (typeof ctx[methodName] !== 'function') return prevDescriptors; + instrumented.add(methodName); + const overridableDescriptor = makeOverridableDescriptor(overrides, ctx, methodName); + return { + ...prevDescriptors, + [methodName]: overridableDescriptor, + }; + }, {}); + + return Object.create(ctx, descriptors); +} + +/** + * Creates a property descriptor that allows overriding of a method on the given context object. + * + * This descriptor supports property overriding with functions only. It delegates method calls to + * the provided store if an override exists or to the original method on the context otherwise. + * + * @param {OverridesStore} store - The storage for overridden methods specific to the context type. + * @param {ContextType} ctx - The context object that contains the method to be overridden. + * @param {keyof ContextType} method - The method on the context object to create the overridable descriptor for. + * @return {PropertyDescriptor} A property descriptor enabling the overriding of the specified method. + */ +function makeOverridableDescriptor( + store: OverridesStore, + ctx: T, + method: keyof T, +): PropertyDescriptor { + return { + configurable: true, + enumerable: true, + set: newValue => { + if (typeof newValue == 'function') { + store.set(method, newValue); + return; + } + Reflect.set(ctx, method, newValue); + }, + + get: () => { + if (store.has(method)) return store.get(method); + const methodFunction = Reflect.get(ctx, method); + if (typeof methodFunction !== 'function') return methodFunction; + // We should do bind() to make sure that the method is bound to the context object - otherwise it will not work + return methodFunction.bind(ctx); + }, + }; +} diff --git a/packages/cloudflare/src/workflows.ts b/packages/cloudflare/src/workflows.ts index 16327ea71ccf..17ec17e9cd85 100644 --- a/packages/cloudflare/src/workflows.ts +++ b/packages/cloudflare/src/workflows.ts @@ -22,6 +22,7 @@ import { setAsyncLocalStorageAsyncContextStrategy } from './async'; import type { CloudflareOptions } from './client'; import { addCloudResourceContext } from './scope-utils'; import { init } from './sdk'; +import { copyExecutionContext } from './utils/copyExecutionContext'; const UUID_REGEX = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i; @@ -157,6 +158,9 @@ export function instrumentWorkflowWithSentry< return new Proxy(WorkFlowClass, { construct(target: C, args: [ctx: ExecutionContext, env: E], newTarget) { const [ctx, env] = args; + const context = copyExecutionContext(ctx); + args[0] = context; + const options = optionsCallback(env); const instance = Reflect.construct(target, args, newTarget) as T; return new Proxy(instance, { @@ -179,10 +183,10 @@ export function instrumentWorkflowWithSentry< return await obj.run.call( obj, event, - new WrappedWorkflowStep(event.instanceId, ctx, options, step), + new WrappedWorkflowStep(event.instanceId, context, options, step), ); } finally { - ctx.waitUntil(flush(2000)); + context.waitUntil(flush(2000)); } }); }); diff --git a/packages/cloudflare/test/copy-execution-context.test.ts b/packages/cloudflare/test/copy-execution-context.test.ts new file mode 100644 index 000000000000..3ee71a10b695 --- /dev/null +++ b/packages/cloudflare/test/copy-execution-context.test.ts @@ -0,0 +1,56 @@ +import { type Mocked, describe, expect, it, vi } from 'vitest'; +import { copyExecutionContext } from '../src/utils/copyExecutionContext'; + +describe('Copy of the execution context', () => { + describe.for([ + 'waitUntil', + 'passThroughOnException', + 'acceptWebSocket', + 'blockConcurrencyWhile', + 'getWebSockets', + 'arbitraryMethod', + 'anythingElse', + ])('%s', method => { + it('Override without changing original', async () => { + const context = { + [method]: vi.fn(), + } as any; + const copy = copyExecutionContext(context); + copy[method] = vi.fn(); + expect(context[method]).not.toBe(copy[method]); + }); + + it('Overridden method was called', async () => { + const context = { + [method]: vi.fn(), + } as any; + const copy = copyExecutionContext(context); + const overridden = vi.fn(); + copy[method] = overridden; + copy[method](); + expect(overridden).toBeCalled(); + expect(context[method]).not.toBeCalled(); + }); + }); + + it('No side effects', async () => { + const context = makeExecutionContextMock(); + expect(() => copyExecutionContext(Object.freeze(context))).not.toThrow( + /Cannot define property \w+, object is not extensible/, + ); + }); + it('Respects symbols', async () => { + const s = Symbol('test'); + const context = makeExecutionContextMock(); + context[s] = {}; + const copy = copyExecutionContext(context); + expect(copy[s]).toBe(context[s]); + }); +}); + +function makeExecutionContextMock() { + return { + waitUntil: vi.fn(), + passThroughOnException: vi.fn(), + } as unknown as Mocked; +} From a680e0c5dd3c17be6253215f302443b06b92f671 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 16 Oct 2025 11:58:11 +0300 Subject: [PATCH 04/23] feat(browser): Add `onRequestSpanEnd` hook to browser tracing integration (#17884) This PR aims to address #9643 partially by introducing a `onRequestSpanEnd` hook to the browser integration. These changes make it easier for users to enrich tracing spans with response header data. #### Example ```ts import * as Sentry from '@sentry/browser'; Sentry.init({ // ... integrations: [ Sentry.browserTracingIntegration({ onRequestSpanEnd(span, responseInformation) { span.setAttributes({ response_type: 'JSON', }); }, }), ], }); ``` #### Tracing Integration and API Improvements * Added `onRequestSpanEnd` callback to `BrowserTracingOptions` and `RequestInstrumentationOptions`, allowing users to access response headers when a request span ends. This enables custom span annotation based on response data. * Updated internal request instrumentation logic to call `onRequestSpanEnd` for both Fetch and XHR requests, passing parsed response headers to the callback. #### Utility and Refactoring * Centralized header parsing and filtering utilities (`parseXhrResponseHeaders`, `getFetchResponseHeaders`, `filterAllowedHeaders`) in `networkUtils.ts`, and exported them for reuse across packages. * Moved helper functions for baggage header checking, URL resolution, performance timing checks, and safe header creation to a new `utils.ts` file to avoid failing the file size limit lint rule. I was hesitant to hoist up those replay utils initially but a few of them were needed to expose them on the hook callback. #### Type and API Consistency * Introduced new types `RequestHookInfo` and `ResponseHookInfo` to standardize the information passed to request span hooks, and exported them from the core package for use in integrations. I also added the necessary tests to test out the new hook. --- .size-limit.js | 2 +- .../on-request-span-end/init.js | 18 ++++++ .../on-request-span-end/subject.js | 11 ++++ .../on-request-span-end/test.ts | 61 ++++++++++++++++++ packages/browser-utils/src/index.ts | 2 +- packages/browser-utils/src/networkUtils.ts | 26 ++++++++ .../src/tracing/browserTracingIntegration.ts | 19 +++++- packages/browser/src/tracing/request.ts | 62 +++++++++---------- packages/browser/src/tracing/utils.ts | 46 ++++++++++++++ packages/core/src/fetch.ts | 23 +++++++ packages/core/src/index.ts | 8 ++- packages/core/src/types-hoist/request.ts | 18 ++++++ .../src/coreHandlers/util/xhrUtils.ts | 20 +----- 13 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts create mode 100644 packages/browser/src/tracing/utils.ts diff --git a/.size-limit.js b/.size-limit.js index 5b8374f81615..17e33dd7ff21 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -206,7 +206,7 @@ module.exports = [ import: createImport('init'), ignore: ['next/router', 'next/constants'], gzip: true, - limit: '45 KB', + limit: '46 KB', }, // SvelteKit SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js new file mode 100644 index 000000000000..9627bfc003e7 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [ + Sentry.browserTracingIntegration({ + idleTimeout: 1000, + onRequestSpanEnd(span, { headers }) { + if (headers) { + span.setAttribute('hook.called.response-type', headers.get('x-response-type')); + } + }, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js new file mode 100644 index 000000000000..8a1ec65972f2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/subject.js @@ -0,0 +1,11 @@ +fetch('http://sentry-test.io/fetch', { + headers: { + foo: 'fetch', + }, +}); + +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test.io/xhr'); +xhr.setRequestHeader('foo', 'xhr'); +xhr.send(); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts new file mode 100644 index 000000000000..03bfc13814af --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/on-request-span-end/test.ts @@ -0,0 +1,61 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/core'; +import { sentryTest } from '../../../../utils/fixtures'; +import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers'; + +sentryTest('should call onRequestSpanEnd hook', async ({ browserName, getLocalTestUrl, page }) => { + const supportedBrowsers = ['chromium', 'firefox']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('http://sentry-test.io/fetch', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'fetch', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + await page.route('http://sentry-test.io/xhr', async route => { + await route.fulfill({ + status: 200, + headers: { + 'Content-Type': 'application/json', + 'X-Response-Type': 'xhr', + 'access-control-expose-headers': '*', + }, + body: '', + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const envelopes = await getMultipleSentryEnvelopeRequests(page, 2, { url, timeout: 10000 }); + + const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'xhr', + 'hook.called.response-type': 'xhr', + }), + }), + ); + + expect(tracingEvent.spans).toContainEqual( + expect.objectContaining({ + op: 'http.client', + data: expect.objectContaining({ + type: 'fetch', + 'hook.called.response-type': 'fetch', + }), + }), + ); +}); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index accf3cb3a278..a4d0960b1ccb 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -28,7 +28,7 @@ export { fetch, setTimeout, clearCachedImplementation, getNativeImplementation } export { addXhrInstrumentationHandler, SENTRY_XHR_DATA_KEY } from './instrument/xhr'; -export { getBodyString, getFetchRequestArgBody, serializeFormData } from './networkUtils'; +export { getBodyString, getFetchRequestArgBody, serializeFormData, parseXhrResponseHeaders } from './networkUtils'; export { resourceTimingToSpanAttributes } from './metrics/resourceTiming'; diff --git a/packages/browser-utils/src/networkUtils.ts b/packages/browser-utils/src/networkUtils.ts index 607434251872..b8df5886e7ee 100644 --- a/packages/browser-utils/src/networkUtils.ts +++ b/packages/browser-utils/src/networkUtils.ts @@ -54,3 +54,29 @@ export function getFetchRequestArgBody(fetchArgs: unknown[] = []): RequestInit[' return (fetchArgs[1] as RequestInit).body; } + +/** + * Parses XMLHttpRequest response headers into a Record. + * Extracted from replay internals to be reusable. + */ +export function parseXhrResponseHeaders(xhr: XMLHttpRequest): Record { + let headers: string | undefined; + try { + headers = xhr.getAllResponseHeaders(); + } catch (error) { + DEBUG_BUILD && debug.error(error, 'Failed to get xhr response headers', xhr); + return {}; + } + + if (!headers) { + return {}; + } + + return headers.split('\r\n').reduce((acc: Record, line: string) => { + const [key, value] = line.split(': ') as [string, string | undefined]; + if (value) { + acc[key.toLowerCase()] = value; + } + return acc; + }, {}); +} diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index a79f629855d7..2e3eebe86845 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -1,5 +1,13 @@ /* eslint-disable max-lines */ -import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core'; +import type { + Client, + IntegrationFn, + RequestHookInfo, + ResponseHookInfo, + Span, + StartSpanOptions, + TransactionSource, +} from '@sentry/core'; import { addNonEnumerableProperty, browserPerformanceTimeOrigin, @@ -297,7 +305,12 @@ export interface BrowserTracingOptions { * You can use it to annotate the span with additional data or attributes, for example by setting * attributes based on the passed request headers. */ - onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void; + onRequestSpanStart?(span: Span, requestInformation: RequestHookInfo): void; + + /** + * Is called when spans end for outgoing requests, providing access to response headers. + */ + onRequestSpanEnd?(span: Span, responseInformation: ResponseHookInfo): void; } const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { @@ -365,6 +378,7 @@ export const browserTracingIntegration = ((options: Partial(); @@ -125,6 +138,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial { const createdSpan = instrumentFetchRequest(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans, { propagateTraceparent, + onRequestSpanEnd, }); if (handlerData.response && handlerData.fetchData.__span) { @@ -205,6 +220,7 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial boolean, spans: Record, propagateTraceparent?: boolean, + onRequestSpanEnd?: RequestInstrumentationOptions['onRequestSpanEnd'], ): Span | undefined { const xhr = handlerData.xhr; const sentryXhrData = xhr?.[SENTRY_XHR_DATA_KEY]; @@ -337,6 +341,11 @@ function xhrCallback( setHttpStatus(span, sentryXhrData.status_code); span.end(); + onRequestSpanEnd?.(span, { + headers: createHeadersSafely(parseXhrResponseHeaders(xhr as XMLHttpRequest & SentryWrappedXMLHttpRequest)), + error: handlerData.error, + }); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -438,18 +447,3 @@ function setHeaderOnXhr( // Error: InvalidStateError: Failed to execute 'setRequestHeader' on 'XMLHttpRequest': The object's state must be OPENED. } } - -function baggageHeaderHasSentryValues(baggageHeader: string): boolean { - return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); -} - -function getFullURL(url: string): string | undefined { - try { - // By adding a base URL to new URL(), this will also work for relative urls - // If `url` is a full URL, the base URL is ignored anyhow - const parsed = new URL(url, WINDOW.location.origin); - return parsed.href; - } catch { - return undefined; - } -} diff --git a/packages/browser/src/tracing/utils.ts b/packages/browser/src/tracing/utils.ts new file mode 100644 index 000000000000..c422e3438fd9 --- /dev/null +++ b/packages/browser/src/tracing/utils.ts @@ -0,0 +1,46 @@ +import { WINDOW } from '../helpers'; + +/** + * Checks if the baggage header has Sentry values. + */ +export function baggageHeaderHasSentryValues(baggageHeader: string): boolean { + return baggageHeader.split(',').some(value => value.trim().startsWith('sentry-')); +} + +/** + * Gets the full URL from a given URL string. + */ +export function getFullURL(url: string): string | undefined { + try { + // By adding a base URL to new URL(), this will also work for relative urls + // If `url` is a full URL, the base URL is ignored anyhow + const parsed = new URL(url, WINDOW.location.origin); + return parsed.href; + } catch { + return undefined; + } +} + +/** + * Checks if the entry is a PerformanceResourceTiming. + */ +export function isPerformanceResourceTiming(entry: PerformanceEntry): entry is PerformanceResourceTiming { + return ( + entry.entryType === 'resource' && + 'initiatorType' in entry && + typeof (entry as PerformanceResourceTiming).nextHopProtocol === 'string' && + (entry.initiatorType === 'fetch' || entry.initiatorType === 'xmlhttprequest') + ); +} + +/** + * Creates a Headers object from a record of string key-value pairs, and returns undefined if it fails. + */ +export function createHeadersSafely(headers: Record | undefined): Headers | undefined { + try { + return new Headers(headers); + } catch { + // noop + return undefined; + } +} diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts index 9ab62ec732da..b16672430a5d 100644 --- a/packages/core/src/fetch.ts +++ b/packages/core/src/fetch.ts @@ -4,6 +4,7 @@ import { setHttpStatus, SPAN_STATUS_ERROR, startInactiveSpan } from './tracing'; import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan'; import type { FetchBreadcrumbHint } from './types-hoist/breadcrumb'; import type { HandlerDataFetch } from './types-hoist/instrument'; +import type { ResponseHookInfo } from './types-hoist/request'; import type { Span, SpanAttributes, SpanOrigin } from './types-hoist/span'; import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils/baggage'; import { hasSpansEnabled } from './utils/hasSpansEnabled'; @@ -24,6 +25,7 @@ type PolymorphicRequestHeaders = interface InstrumentFetchRequestOptions { spanOrigin?: SpanOrigin; propagateTraceparent?: boolean; + onRequestSpanEnd?: (span: Span, responseInformation: ResponseHookInfo) => void; } /** @@ -82,6 +84,8 @@ export function instrumentFetchRequest( if (span) { endSpan(span, handlerData); + _callOnRequestSpanEnd(span, handlerData, spanOriginOrOptions); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete spans[spanId]; } @@ -141,6 +145,25 @@ export function instrumentFetchRequest( return span; } +/** + * Calls the onRequestSpanEnd callback if it is defined. + */ +export function _callOnRequestSpanEnd( + span: Span, + handlerData: HandlerDataFetch, + spanOriginOrOptions?: SpanOrigin | InstrumentFetchRequestOptions, +): void { + const onRequestSpanEnd = + typeof spanOriginOrOptions === 'object' && spanOriginOrOptions !== null + ? spanOriginOrOptions.onRequestSpanEnd + : undefined; + + onRequestSpanEnd?.(span, { + headers: handlerData.response?.headers, + error: handlerData.error, + }); +} + /** * Adds sentry-trace and baggage headers to the various forms of fetch headers. * exported only for testing purposes diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2377e2ce86b0..7a6c5c2e17d3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -399,7 +399,13 @@ export type { SendFeedbackParams, UserFeedback, } from './types-hoist/feedback'; -export type { QueryParams, RequestEventData, SanitizedRequestData } from './types-hoist/request'; +export type { + QueryParams, + RequestEventData, + RequestHookInfo, + ResponseHookInfo, + SanitizedRequestData, +} from './types-hoist/request'; export type { Runtime } from './types-hoist/runtime'; export type { SdkInfo } from './types-hoist/sdkinfo'; export type { SdkMetadata } from './types-hoist/sdkmetadata'; diff --git a/packages/core/src/types-hoist/request.ts b/packages/core/src/types-hoist/request.ts index 834249cdd24e..028acbe9f77e 100644 --- a/packages/core/src/types-hoist/request.ts +++ b/packages/core/src/types-hoist/request.ts @@ -1,3 +1,5 @@ +import type { WebFetchHeaders } from './webfetchapi'; + /** * Request data included in an event as sent to Sentry. */ @@ -24,3 +26,19 @@ export type SanitizedRequestData = { 'http.fragment'?: string; 'http.query'?: string; }; + +export interface RequestHookInfo { + headers?: WebFetchHeaders; +} + +export interface ResponseHookInfo { + /** + * Headers from the response. + */ + headers?: WebFetchHeaders; + + /** + * Error that may have occurred during the request. + */ + error?: unknown; +} diff --git a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts index bb7c631eddef..be3c205d60d9 100644 --- a/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts +++ b/packages/replay-internal/src/coreHandlers/util/xhrUtils.ts @@ -1,6 +1,6 @@ import type { Breadcrumb, XhrBreadcrumbData } from '@sentry/core'; import type { NetworkMetaWarning, XhrHint } from '@sentry-internal/browser-utils'; -import { getBodyString, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; +import { getBodyString, parseXhrResponseHeaders, SENTRY_XHR_DATA_KEY } from '@sentry-internal/browser-utils'; import { DEBUG_BUILD } from '../../debug-build'; import type { ReplayContainer, ReplayNetworkOptions, ReplayNetworkRequestData } from '../../types'; import { debug } from '../../util/logger'; @@ -104,7 +104,7 @@ function _prepareXhrData( const networkRequestHeaders = xhrInfo ? getAllowedHeaders(xhrInfo.request_headers, options.networkRequestHeaders) : {}; - const networkResponseHeaders = getAllowedHeaders(getResponseHeaders(xhr), options.networkResponseHeaders); + const networkResponseHeaders = getAllowedHeaders(parseXhrResponseHeaders(xhr), options.networkResponseHeaders); const [requestBody, requestWarning] = options.networkCaptureBodies ? getBodyString(input, debug) : [undefined]; const [responseBody, responseWarning] = options.networkCaptureBodies ? _getXhrResponseBody(xhr) : [undefined]; @@ -123,22 +123,6 @@ function _prepareXhrData( }; } -function getResponseHeaders(xhr: XMLHttpRequest): Record { - const headers = xhr.getAllResponseHeaders(); - - if (!headers) { - return {}; - } - - return headers.split('\r\n').reduce((acc: Record, line: string) => { - const [key, value] = line.split(': ') as [string, string | undefined]; - if (value) { - acc[key.toLowerCase()] = value; - } - return acc; - }, {}); -} - function _getXhrResponseBody(xhr: XMLHttpRequest): [string | undefined, NetworkMetaWarning?] { // We collect errors that happen, but only log them if we can't get any response body const errors: unknown[] = []; From a38eed1cca3f65945bc4926af8eea25b71e6f009 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 16 Oct 2025 11:09:29 +0200 Subject: [PATCH 05/23] test(nextjs): Skip webpack dev test for next 16 --- dev-packages/e2e-tests/test-applications/nextjs-16/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 1fd09523ddb2..46fdc5ecffa1 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -18,7 +18,7 @@ "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" + "test:assert-webpack": "pnpm test:prod" }, "dependencies": { "@sentry/nextjs": "latest || *", From ee16e3570694db088271cc7e326059b10eb1697d Mon Sep 17 00:00:00 2001 From: Andrei <168741329+andreiborza@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:42:39 +0200 Subject: [PATCH 06/23] chore(ci): Fix external contributor action when multiple contributions existed (#17950) https://github.com/getsentry/sentry-javascript/pull/13335 added a plural `s` to the contribution message when multiple people contributed to the repo but the regex for the lookup lacks this. --- dev-packages/external-contributor-gh-action/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/external-contributor-gh-action/index.mjs b/dev-packages/external-contributor-gh-action/index.mjs index ffa9369ee2df..2bad8a16f0bd 100644 --- a/dev-packages/external-contributor-gh-action/index.mjs +++ b/dev-packages/external-contributor-gh-action/index.mjs @@ -7,7 +7,7 @@ const UNRELEASED_HEADING = `## Unreleased - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott `; -const contributorMessageRegex = /Work in this release was contributed by (.+)\. Thank you for your contribution!/; +const contributorMessageRegex = /Work in this release was contributed by (.+)\. Thank you for your contributions?!/; async function run() { const { getInput } = core; From 4dc6c7b71a82da02d379b37d021129ba5c56da90 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 16 Oct 2025 12:03:51 +0200 Subject: [PATCH 07/23] chore: Add external contributor to CHANGELOG.md (#17949) This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #17786 Co-authored-by: JPeer264 <10677263+JPeer264@users.noreply.github.com> --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a14433358d0c..2c68921b62c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! + ## 10.20.0 ### Important Changes @@ -42,7 +44,7 @@ - chore: Add external contributor to CHANGELOG.md ([#17940](https://github.com/getsentry/sentry-javascript/pull/17940)) -Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez . Thank you for your contributions! +Work in this release was contributed by @seoyeon9888, @madhuchavva and @thedanchez. Thank you for your contributions! ## 10.19.0 From 8e3afe278275a91a0214fba7668dd93814ce06fb Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 00:16:21 +0300 Subject: [PATCH 08/23] feat(nuxt): Instrument storage API (#17858) ## What This PR adds automatic instrumentation for Nuxt's storage layer (powered by [unstorage](https://unstorage.unjs.io/)), enabling performance monitoring for cache and key-value storage operations in Nuxt/Nitro applications. Storage operations will now automatically create performance spans with detailed attributes for observability in Sentry. ### What's New - **Automatic Storage Instrumentation**: Instruments all storage drivers configured in `nuxt.config.ts` via `nitro.storage` - **Comprehensive Coverage**: Tracks all storage operations including: - `getItem`, `setItem`, `hasItem`, `removeItem` - Raw variants: `getItemRaw`, `setItemRaw` - Batch operations: `getItems`, `setItems` - Utility methods: `getKeys`, `clear` - Aliases: `get`, `set`, `has`, `del`, `remove` ### Implementation Details **Span Attributes:** - `sentry.op`: `cache.{operation}` (e.g., `cache.get_item`, `cache.set_item`) - `sentry.origin`: `auto.cache.nuxt` - `cache.key`: Full key including mount prefix - `cache.hit`: `true` for successful get/has operations - `db.operation.name`: Original method name - `db.collection.name`: Storage mount point - `db.system.name`: Driver name (e.g., `memory`, `fs`, `redis`) **Files Changed:** - `packages/nuxt/src/runtime/plugins/storage.server.ts` - Runtime instrumentation plugin - `packages/nuxt/src/vite/storageConfig.ts` - Build-time configuration - `packages/nuxt/src/module.ts` - Module integration - E2E tests for Nuxt 3 & 4 --- .../test-applications/nuxt-3/nuxt.config.ts | 7 + .../nuxt-3/server/api/storage-aliases-test.ts | 46 ++++ .../nuxt-3/server/api/storage-test.ts | 54 ++++ .../nuxt-3/tests/storage-aliases.test.ts | 108 ++++++++ .../nuxt-3/tests/storage.test.ts | 154 ++++++++++++ .../test-applications/nuxt-4/nuxt.config.ts | 7 + .../nuxt-4/server/api/storage-aliases-test.ts | 46 ++++ .../nuxt-4/server/api/storage-test.ts | 54 ++++ .../nuxt-4/tests/storage-aliases.test.ts | 108 ++++++++ .../nuxt-4/tests/storage.test.ts | 151 +++++++++++ packages/nuxt/src/module.ts | 2 + .../src/runtime/plugins/storage.server.ts | 236 ++++++++++++++++++ packages/nuxt/src/vendor/server-template.ts | 17 ++ packages/nuxt/src/vite/storageConfig.ts | 21 ++ 14 files changed, 1011 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts create mode 100644 packages/nuxt/src/runtime/plugins/storage.server.ts create mode 100644 packages/nuxt/src/vendor/server-template.ts create mode 100644 packages/nuxt/src/vite/storageConfig.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 0fcccd560af9..8ea55702863c 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -11,4 +11,11 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..a721df04b40c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts new file mode 100644 index 000000000000..2c451be51135 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/storage.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index d0ae045f1e9d..50924877649d 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -21,4 +21,11 @@ export default defineNuxtConfig({ }, }, }, + nitro: { + storage: { + 'test-storage': { + driver: 'memory', + }, + }, + }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts new file mode 100644 index 000000000000..e204453d1000 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-aliases-test.ts @@ -0,0 +1,46 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all alias methods (get, set, del, remove) + const results: Record = {}; + + // Test set (alias for setItem) + await storage.set('alias:user', { name: 'Jane Doe', role: 'admin' }); + results.set = 'success'; + + // Test get (alias for getItem) + const user = await storage.get('alias:user'); + results.get = user; + + // Test has (alias for hasItem) + const hasUser = await storage.has('alias:user'); + results.has = hasUser; + + // Setup for delete tests + await storage.set('alias:temp1', 'temp1'); + await storage.set('alias:temp2', 'temp2'); + + // Test del (alias for removeItem) + await storage.del('alias:temp1'); + results.del = 'success'; + + // Test remove (alias for removeItem) + await storage.remove('alias:temp2'); + results.remove = 'success'; + + // Verify deletions worked + const hasTemp1 = await storage.has('alias:temp1'); + const hasTemp2 = await storage.has('alias:temp2'); + results.verifyDeletions = !hasTemp1 && !hasTemp2; + + // Clean up + await storage.clear(); + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts new file mode 100644 index 000000000000..f051daf59422 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/storage-test.ts @@ -0,0 +1,54 @@ +import { useStorage } from '#imports'; +import { defineEventHandler } from 'h3'; + +export default defineEventHandler(async _event => { + const storage = useStorage('test-storage'); + + // Test all instrumented methods + const results: Record = {}; + + // Test setItem + await storage.setItem('user:123', { name: 'John Doe', email: 'john@example.com' }); + results.setItem = 'success'; + + // Test setItemRaw + await storage.setItemRaw('raw:data', Buffer.from('raw data')); + results.setItemRaw = 'success'; + + // Manually set batch items (setItems not supported by memory driver) + await storage.setItem('batch:1', 'value1'); + await storage.setItem('batch:2', 'value2'); + + // Test hasItem + const hasUser = await storage.hasItem('user:123'); + results.hasItem = hasUser; + + // Test getItem + const user = await storage.getItem('user:123'); + results.getItem = user; + + // Test getItemRaw + const rawData = await storage.getItemRaw('raw:data'); + results.getItemRaw = rawData?.toString(); + + // Test getKeys + const keys = await storage.getKeys('batch:'); + results.getKeys = keys; + + // Test removeItem + await storage.removeItem('batch:1'); + results.removeItem = 'success'; + + // Test clear + await storage.clear(); + results.clear = 'success'; + + // Verify clear worked + const keysAfterClear = await storage.getKeys(); + results.keysAfterClear = keysAfterClear; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts new file mode 100644 index 000000000000..1e2fc1eb16b1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage-aliases.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation - Aliases', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments storage alias methods (get, set, has, del, remove) and creates spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-aliases-test') ?? false; + }); + + const response = await request.get('/api/storage-aliases-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test set (alias for setItem) + const setSpans = findSpansByOp('cache.set_item'); + expect(setSpans.length).toBeGreaterThanOrEqual(1); + const setSpan = setSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(setSpan).toBeDefined(); + expect(setSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setSpan?.description).toBe(prefixKey('alias:user')); + + // Test get (alias for getItem) + const getSpans = findSpansByOp('cache.get_item'); + expect(getSpans.length).toBeGreaterThanOrEqual(1); + const getSpan = getSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(getSpan).toBeDefined(); + expect(getSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getSpan?.description).toBe(prefixKey('alias:user')); + + // Test has (alias for hasItem) + const hasSpans = findSpansByOp('cache.has_item'); + expect(hasSpans.length).toBeGreaterThanOrEqual(1); + const hasSpan = hasSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:user')); + expect(hasSpan).toBeDefined(); + expect(hasSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:user'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test del and remove (both aliases for removeItem) + const removeSpans = findSpansByOp('cache.remove_item'); + expect(removeSpans.length).toBeGreaterThanOrEqual(2); // Should have both del and remove calls + + const delSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp1')); + expect(delSpan).toBeDefined(); + expect(delSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(delSpan?.description).toBe(prefixKey('alias:temp1')); + + const removeSpan = removeSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('alias:temp2')); + expect(removeSpan).toBeDefined(); + expect(removeSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('alias:temp2'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(removeSpan?.description).toBe(prefixKey('alias:temp2')); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts new file mode 100644 index 000000000000..c171c9b6956f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/storage.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Storage Instrumentation', () => { + const prefixKey = (key: string) => `test-storage:${key}`; + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments all storage operations and creates spans with correct attributes', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/storage-test') ?? false; + }); + + const response = await request.get('/api/storage-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test setItem spans + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThanOrEqual(1); + const setItemSpan = setItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(setItemSpan).toBeDefined(); + expect(setItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + 'db.operation.name': 'setItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(setItemSpan?.description).toBe(prefixKey('user:123')); + + // Test setItemRaw spans + const setItemRawSpans = findSpansByOp('cache.set_item_raw'); + expect(setItemRawSpans.length).toBeGreaterThanOrEqual(1); + const setItemRawSpan = setItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(setItemRawSpan).toBeDefined(); + expect(setItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + 'db.operation.name': 'setItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test hasItem spans - should have cache hit attribute + const hasItemSpans = findSpansByOp('cache.has_item'); + expect(hasItemSpans.length).toBeGreaterThanOrEqual(1); + const hasItemSpan = hasItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(hasItemSpan).toBeDefined(); + expect(hasItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.has_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'hasItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getItem spans - should have cache hit attribute + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThanOrEqual(1); + const getItemSpan = getItemSpans.find(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('user:123')); + expect(getItemSpan).toBeDefined(); + expect(getItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('user:123'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + expect(getItemSpan?.description).toBe(prefixKey('user:123')); + + // Test getItemRaw spans - should have cache hit attribute + const getItemRawSpans = findSpansByOp('cache.get_item_raw'); + expect(getItemRawSpans.length).toBeGreaterThanOrEqual(1); + const getItemRawSpan = getItemRawSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('raw:data'), + ); + expect(getItemRawSpan).toBeDefined(); + expect(getItemRawSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item_raw', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('raw:data'), + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItemRaw', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test getKeys spans + const getKeysSpans = findSpansByOp('cache.get_keys'); + expect(getKeysSpans.length).toBeGreaterThanOrEqual(1); + expect(getKeysSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_keys', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'getKeys', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test removeItem spans + const removeItemSpans = findSpansByOp('cache.remove_item'); + expect(removeItemSpans.length).toBeGreaterThanOrEqual(1); + const removeItemSpan = removeItemSpans.find( + span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === prefixKey('batch:1'), + ); + expect(removeItemSpan).toBeDefined(); + expect(removeItemSpan?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.remove_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: prefixKey('batch:1'), + 'db.operation.name': 'removeItem', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Test clear spans + const clearSpans = findSpansByOp('cache.clear'); + expect(clearSpans.length).toBeGreaterThanOrEqual(1); + expect(clearSpans[0]?.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.clear', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'clear', + 'db.collection.name': 'test-storage', + 'db.system.name': 'memory', + }); + + // Verify all spans have OK status + const allStorageSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allStorageSpans?.length).toBeGreaterThan(0); + allStorageSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + }); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 1e806e4dc2eb..947eb2710f4d 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -12,6 +12,7 @@ import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; +import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; @@ -126,6 +127,7 @@ export default defineNuxtModule({ // Preps the the middleware instrumentation module. if (serverConfigFile) { addMiddlewareImports(); + addStorageInstrumentation(nuxt); } nuxt.hooks.hook('nitro:init', nitro => { diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts new file mode 100644 index 000000000000..05932394384d --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -0,0 +1,236 @@ +import { + type SpanAttributes, + type StartSpanOptions, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_CACHE_HIT, + SEMANTIC_ATTRIBUTE_CACHE_KEY, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + SPAN_STATUS_OK, + startSpan, +} from '@sentry/core'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useStorage } from 'nitropack/runtime'; +import type { Driver, Storage } from 'unstorage'; +// @ts-expect-error - This is a virtual module +import { userStorageMounts } from '#sentry/storage-config.mjs'; + +type MaybeInstrumented = T & { + __sentry_instrumented__?: boolean; +}; + +type MaybeInstrumentedDriver = MaybeInstrumented; + +type DriverMethod = keyof Driver; + +/** + * Methods that should have a attribute to indicate a cache hit. + */ +const CACHE_HIT_METHODS = new Set(['hasItem', 'getItem', 'getItemRaw']); + +/** + * Creates a Nitro plugin that instruments the storage driver. + */ +export default defineNitroPlugin(async _nitroApp => { + // This runs at runtime when the Nitro server starts + const storage = useStorage(); + // Mounts are suffixed with a colon, so we need to add it to the set items + const userMounts = new Set((userStorageMounts as string[]).map(m => `${m}:`)); + + debug.log('[storage] Starting to instrument storage drivers...'); + + // Get all mounted storage drivers + const mounts = storage.getMounts(); + for (const mount of mounts) { + // Skip excluded mounts and root mount + if (!userMounts.has(mount.base)) { + continue; + } + + instrumentDriver(mount.driver, mount.base); + } + + // Wrap the mount method to instrument future mounts + storage.mount = wrapStorageMount(storage); +}); + +/** + * Instruments a driver by wrapping all method calls using proxies. + */ +function instrumentDriver(driver: MaybeInstrumentedDriver, mountBase: string): Driver { + // Already instrumented, skip... + if (driver.__sentry_instrumented__) { + debug.log(`[storage] Driver already instrumented: "${driver.name}". Skipping...`); + + return driver; + } + + debug.log(`[storage] Instrumenting driver: "${driver.name}" on mount: "${mountBase}"`); + + // List of driver methods to instrument + // get/set/remove are aliases and already use their {method}Item methods + const methodsToInstrument: DriverMethod[] = [ + 'hasItem', + 'getItem', + 'getItemRaw', + 'getItems', + 'setItem', + 'setItemRaw', + 'setItems', + 'removeItem', + 'getKeys', + 'clear', + ]; + + for (const methodName of methodsToInstrument) { + const original = driver[methodName]; + // Skip if method doesn't exist on this driver + if (typeof original !== 'function') { + continue; + } + + // Replace with instrumented + driver[methodName] = createMethodWrapper(original, methodName, driver, mountBase); + } + + // Mark as instrumented + driver.__sentry_instrumented__ = true; + + return driver; +} + +/** + * Creates an instrumented method for the given method. + */ +function createMethodWrapper( + original: (...args: unknown[]) => unknown, + methodName: DriverMethod, + driver: Driver, + mountBase: string, +): (...args: unknown[]) => unknown { + return new Proxy(original, { + async apply(target, thisArg, args) { + const options = createSpanStartOptions(methodName, driver, mountBase, args); + + debug.log(`[storage] Running method: "${methodName}" on driver: "${driver.name ?? 'unknown'}"`); + + return startSpan(options, async span => { + try { + const result = await target.apply(thisArg, args); + span.setStatus({ code: SPAN_STATUS_OK }); + + if (CACHE_HIT_METHODS.has(methodName)) { + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isEmptyValue(result)); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: options.attributes?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN], + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }); + }, + }); +} + +/** + * Wraps the storage mount method to instrument the driver. + */ +function wrapStorageMount(storage: Storage): Storage['mount'] { + const original: MaybeInstrumented = storage.mount; + if (original.__sentry_instrumented__) { + return original; + } + + function mountWithInstrumentation(base: string, driver: Driver): Storage { + debug.log(`[storage] Instrumenting mount: "${base}"`); + + const instrumentedDriver = instrumentDriver(driver, base); + + return original(base, instrumentedDriver); + } + + mountWithInstrumentation.__sentry_instrumented__ = true; + + return mountWithInstrumentation; +} +/** + * Normalizes the method name to snake_case to be used in span names or op. + */ +function normalizeMethodName(methodName: string): string { + return methodName.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Checks if the value is empty, used for cache hit detection. + */ +function isEmptyValue(value: unknown): boolean { + return value === null || value === undefined; +} + +/** + * Creates the span start options for the storage method. + */ +function createSpanStartOptions( + methodName: keyof Driver, + driver: Driver, + mountBase: string, + args: unknown[], +): StartSpanOptions { + const keys = getCacheKeys(args?.[0], mountBase); + + const attributes: SpanAttributes = { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `cache.${normalizeMethodName(methodName)}`, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_KEY]: keys.length > 1 ? keys : keys[0], + 'db.operation.name': methodName, + 'db.collection.name': mountBase.replace(/:$/, ''), + 'db.system.name': driver.name ?? 'unknown', + }; + + return { + name: keys.join(', '), + attributes, + }; +} + +/** + * Gets a normalized array of cache keys. + */ +function getCacheKeys(key: unknown, prefix: string): string[] { + // Handles an array of keys + if (Array.isArray(key)) { + return key.map(k => normalizeKey(k, prefix)); + } + + return [normalizeKey(key, prefix)]; +} + +/** + * Normalizes the key to a string for `cache.key` attribute. + */ +function normalizeKey(key: unknown, prefix: string): string { + if (typeof key === 'string') { + return `${prefix}${key}`; + } + + // Handles an object with a key property + if (typeof key === 'object' && key !== null && 'key' in key) { + return `${prefix}${key.key}`; + } + + return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; +} diff --git a/packages/nuxt/src/vendor/server-template.ts b/packages/nuxt/src/vendor/server-template.ts new file mode 100644 index 000000000000..afdb46345d5c --- /dev/null +++ b/packages/nuxt/src/vendor/server-template.ts @@ -0,0 +1,17 @@ +import { useNuxt } from '@nuxt/kit'; +import type { NuxtTemplate } from 'nuxt/schema'; + +/** + * Adds a virtual file that can be used within the Nuxt Nitro server build. + * Available in NuxtKit v4, so we are porting it here. + * https://github.com/nuxt/nuxt/blob/d6df732eec1a3bd442bdb325b0335beb7e10cd64/packages/kit/src/template.ts#L55-L62 + */ +export function addServerTemplate(template: NuxtTemplate): NuxtTemplate { + const nuxt = useNuxt(); + if (template.filename) { + nuxt.options.nitro.virtual = nuxt.options.nitro.virtual || {}; + nuxt.options.nitro.virtual[template.filename] = template.getContents; + } + + return template; +} diff --git a/packages/nuxt/src/vite/storageConfig.ts b/packages/nuxt/src/vite/storageConfig.ts new file mode 100644 index 000000000000..c0838ad154b8 --- /dev/null +++ b/packages/nuxt/src/vite/storageConfig.ts @@ -0,0 +1,21 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import type { Nuxt } from 'nuxt/schema'; +import { addServerTemplate } from '../vendor/server-template'; + +/** + * Prepares the storage config export to be used in the runtime storage instrumentation. + */ +export function addStorageInstrumentation(nuxt: Nuxt): void { + const moduleDirResolver = createResolver(import.meta.url); + const userStorageMounts = Object.keys(nuxt.options.nitro.storage || {}); + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/storage-config.mjs', + getContents: () => { + return `export const userStorageMounts = ${JSON.stringify(userStorageMounts)};`; + }, + }); + + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/storage.server')); +} From 66dc9a20f53502d676d2f98f277355afcfa8cb86 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 14:26:06 +0300 Subject: [PATCH 09/23] feat(nuxt): Instrument server cache API (#17886) Adds [Nitro/Nuxt Cache API](https://nitro.build/guide/cache) instrumentation by building upon the storage instrumentation in #17858 since both use `unstorage` under the hood. #### How it works Nitro injects the cache storage on either `cache:` or the root mount depending on user configuration, also in production the `cache` storage is placed on the root mount unless the user configures it explicitly to redis or something else. We instrument both mount drivers to cover other cache use cases. --- I made sure to add e2e tests as well for `cachedEventListner` and `cachedFunction` calls which are the main ways to use the Cache API. This PR depends on the storage PR #17858. --------- Co-authored-by: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> --- .../nuxt-3/server/api/cache-test.ts | 84 +++++++++ .../nuxt-3/tests/cache.test.ts | 161 ++++++++++++++++++ .../nuxt-4/server/api/cache-test.ts | 84 +++++++++ .../nuxt-4/tests/cache.test.ts | 161 ++++++++++++++++++ .../src/runtime/plugins/storage.server.ts | 82 ++++++++- packages/nuxt/src/server/sdk.ts | 11 +- 6 files changed, 579 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts new file mode 100644 index 000000000000..b19530e18c96 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = cachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? ''); + const dataKey = String(getQuery(event).data ?? ''); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts new file mode 100644 index 000000000000..a1697136ef01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test?user=123&data=test-key'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts new file mode 100644 index 000000000000..0fb4ace46bd5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/cache-test.ts @@ -0,0 +1,84 @@ +import { cachedFunction, defineCachedEventHandler, defineEventHandler, getQuery } from '#imports'; + +// Test cachedFunction +const getCachedUser = cachedFunction( + async (userId: string) => { + return { + id: userId, + name: `User ${userId}`, + email: `user${userId}@example.com`, + timestamp: Date.now(), + }; + }, + { + maxAge: 60, + name: 'getCachedUser', + getKey: (userId: string) => `user:${userId}`, + }, +); + +// Test cachedFunction with different options +const getCachedData = cachedFunction( + async (key: string) => { + return { + key, + value: `cached-value-${key}`, + timestamp: Date.now(), + }; + }, + { + maxAge: 120, + name: 'getCachedData', + getKey: (key: string) => `data:${key}`, + }, +); + +// Test defineCachedEventHandler +const cachedHandler = defineCachedEventHandler( + async event => { + return { + message: 'This response is cached', + timestamp: Date.now(), + path: event.path, + }; + }, + { + maxAge: 60, + name: 'cachedHandler', + }, +); + +export default defineEventHandler(async event => { + const results: Record = {}; + const testKey = String(getQuery(event).user ?? '123'); + const dataKey = String(getQuery(event).data ?? 'test-key'); + + // Test cachedFunction - first call (cache miss) + const user1 = await getCachedUser(testKey); + results.cachedUser1 = user1; + + // Test cachedFunction - second call (cache hit) + const user2 = await getCachedUser(testKey); + results.cachedUser2 = user2; + + // Test cachedFunction with different key (cache miss) + const user3 = await getCachedUser(`${testKey}456`); + results.cachedUser3 = user3; + + // Test another cachedFunction + const data1 = await getCachedData(dataKey); + results.cachedData1 = data1; + + // Test cachedFunction - cache hit + const data2 = await getCachedData(dataKey); + results.cachedData2 = data2; + + // Test cachedEventHandler by calling it + const cachedResponse = await cachedHandler(event); + results.cachedResponse = cachedResponse; + + return { + success: true, + results, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts new file mode 100644 index 000000000000..1295de002145 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/cache.test.ts @@ -0,0 +1,161 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/nuxt'; + +test.describe('Cache Instrumentation', () => { + const SEMANTIC_ATTRIBUTE_CACHE_KEY = 'cache.key'; + const SEMANTIC_ATTRIBUTE_CACHE_HIT = 'cache.hit'; + + test('instruments cachedFunction and cachedEventHandler calls and creates spans with correct attributes', async ({ + request, + }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + const response = await request.get('/api/cache-test'); + expect(response.status()).toBe(200); + + const transaction = await transactionPromise; + + // Helper to find spans by operation + const findSpansByOp = (op: string) => { + return transaction.spans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === op) || []; + }; + + // Test that we have cache operations from cachedFunction and cachedEventHandler + const allCacheSpans = transaction.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Test getItem spans for cachedFunction - should have both cache miss and cache hit + const getItemSpans = findSpansByOp('cache.get_item'); + expect(getItemSpans.length).toBeGreaterThan(0); + + // Find cache miss (first call to getCachedUser('123')) + const cacheMissSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + !span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheMissSpan) { + expect(cacheMissSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: false, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Find cache hit (second call to getCachedUser('123')) + const cacheHitSpan = getItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123') && + span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT], + ); + if (cacheHitSpan) { + expect(cacheHitSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.get_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + [SEMANTIC_ATTRIBUTE_CACHE_HIT]: true, + 'db.operation.name': 'getItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test setItem spans for cachedFunction - when cache miss occurs, value is set + const setItemSpans = findSpansByOp('cache.set_item'); + expect(setItemSpans.length).toBeGreaterThan(0); + + const cacheSetSpan = setItemSpans.find( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('user:123'), + ); + if (cacheSetSpan) { + expect(cacheSetSpan.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'cache.set_item', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.cache.nuxt', + 'db.operation.name': 'setItem', + 'db.collection.name': expect.stringMatching(/^(cache)?$/), + }); + } + + // Test that we have spans for different cached functions + const dataKeySpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('data:test-key'), + ); + expect(dataKeySpans.length).toBeGreaterThan(0); + + // Test that we have spans for cachedEventHandler + const cachedHandlerSpans = getItemSpans.filter( + span => + typeof span.data?.[SEMANTIC_ATTRIBUTE_CACHE_KEY] === 'string' && + span.data[SEMANTIC_ATTRIBUTE_CACHE_KEY].includes('cachedHandler'), + ); + expect(cachedHandlerSpans.length).toBeGreaterThan(0); + + // Verify all cache spans have OK status + allCacheSpans?.forEach(span => { + expect(span.status).toBe('ok'); + }); + + // Verify cache spans are properly nested under the transaction + allCacheSpans?.forEach(span => { + expect(span.parent_span_id).toBeDefined(); + }); + }); + + test('correctly tracks cache hits and misses for cachedFunction', async ({ request }) => { + // Use a unique key for this test to ensure fresh cache state + const uniqueUser = `test-${Date.now()}`; + const uniqueData = `data-${Date.now()}`; + + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction?.includes('GET /api/cache-test') ?? false; + }); + + await request.get(`/api/cache-test?user=${uniqueUser}&data=${uniqueData}`); + const transaction1 = await transactionPromise; + + // Get all cache-related spans + const allCacheSpans = transaction1.spans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] === 'auto.cache.nuxt', + ); + + // We should have cache operations + expect(allCacheSpans?.length).toBeGreaterThan(0); + + // Get all getItem operations + const allGetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.get_item', + ); + + // Get all setItem operations + const allSetItemSpans = allCacheSpans?.filter( + span => span.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OP] === 'cache.set_item', + ); + + // We should have both get and set operations + expect(allGetItemSpans?.length).toBeGreaterThan(0); + expect(allSetItemSpans?.length).toBeGreaterThan(0); + + // Check for cache misses (cache.hit = false) + const cacheMissSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === false); + + // Check for cache hits (cache.hit = true) + const cacheHitSpans = allGetItemSpans?.filter(span => span.data?.[SEMANTIC_ATTRIBUTE_CACHE_HIT] === true); + + // We should have at least one cache miss (first calls to getCachedUser and getCachedData) + expect(cacheMissSpans?.length).toBeGreaterThanOrEqual(1); + + // We should have at least one cache hit (second calls to getCachedUser and getCachedData) + expect(cacheHitSpans?.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/nuxt/src/runtime/plugins/storage.server.ts b/packages/nuxt/src/runtime/plugins/storage.server.ts index 05932394384d..710424d6995e 100644 --- a/packages/nuxt/src/runtime/plugins/storage.server.ts +++ b/packages/nuxt/src/runtime/plugins/storage.server.ts @@ -14,6 +14,7 @@ import { } from '@sentry/core'; // eslint-disable-next-line import/no-extraneous-dependencies import { defineNitroPlugin, useStorage } from 'nitropack/runtime'; +import type { CacheEntry, ResponseCacheEntry } from 'nitropack/types'; import type { Driver, Storage } from 'unstorage'; // @ts-expect-error - This is a virtual module import { userStorageMounts } from '#sentry/storage-config.mjs'; @@ -42,6 +43,14 @@ export default defineNitroPlugin(async _nitroApp => { debug.log('[storage] Starting to instrument storage drivers...'); + // Adds cache mount to handle Nitro's cache calls + // Nitro uses the mount to cache functions and event handlers + // https://nitro.build/guide/cache + userMounts.add('cache:'); + // In production, unless the user configured a specific cache driver, Nitro will use the memory driver at root mount. + // Either way, we need to instrument the root mount as well. + userMounts.add(''); + // Get all mounted storage drivers const mounts = storage.getMounts(); for (const mount of mounts) { @@ -123,7 +132,7 @@ function createMethodWrapper( span.setStatus({ code: SPAN_STATUS_OK }); if (CACHE_HIT_METHODS.has(methodName)) { - span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, !isEmptyValue(result)); + span.setAttribute(SEMANTIC_ATTRIBUTE_CACHE_HIT, isCacheHit(args[0], result)); } return result; @@ -177,7 +186,7 @@ function normalizeMethodName(methodName: string): string { /** * Checks if the value is empty, used for cache hit detection. */ -function isEmptyValue(value: unknown): boolean { +function isEmptyValue(value: unknown): value is null | undefined { return value === null || value === undefined; } @@ -234,3 +243,72 @@ function normalizeKey(key: unknown, prefix: string): string { return `${prefix}${isEmptyValue(key) ? '' : String(key)}`; } + +const CACHED_FN_HANDLERS_RE = /^nitro:(functions|handlers):/i; + +/** + * Since Nitro's cache may not utilize the driver's TTL, it is possible that the value is present in the cache but won't be used by Nitro. + * The maxAge and expires values are serialized by Nitro in the cache entry. This means the value presence does not necessarily mean a cache hit. + * So in order to properly report cache hits for `defineCachedFunction` and `defineCachedEventHandler` we need to check the cached value ourselves. + * First we check if the key matches the `defineCachedFunction` or `defineCachedEventHandler` key patterns, and if so we check the cached value. + */ +function isCacheHit(key: string, value: unknown): boolean { + try { + const isEmpty = isEmptyValue(value); + // Empty value means no cache hit either way + // Or if key doesn't match the cached function or handler patterns, we can return the empty value check + if (isEmpty || !CACHED_FN_HANDLERS_RE.test(key)) { + return !isEmpty; + } + + return validateCacheEntry(key, JSON.parse(String(value)) as CacheEntry); + } catch (error) { + // this is a best effort, so we return false if we can't validate the cache entry + return false; + } +} + +/** + * Validates the cache entry. + */ +function validateCacheEntry( + key: string, + entry: CacheEntry | CacheEntry, +): boolean { + if (isEmptyValue(entry.value)) { + return false; + } + + // Date.now is used by Nitro internally, so safe to use here. + // https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L78 + if (Date.now() > (entry.expires || 0)) { + return false; + } + + /** + * Pulled from Nitro's cache entry validation + * https://github.com/nitrojs/nitro/blob/5508f71b77730e967fb131de817725f5aa7c4862/src/runtime/internal/cache.ts#L223-L241 + */ + if (isResponseCacheEntry(key, entry)) { + if (entry.value.status >= 400) { + return false; + } + + if (entry.value.body === undefined) { + return false; + } + + if (entry.value.headers.etag === 'undefined' || entry.value.headers['last-modified'] === 'undefined') { + return false; + } + } + + return true; +} + +/** + * Checks if the cache entry is a response cache entry. + */ +function isResponseCacheEntry(key: string, _: CacheEntry): _ is CacheEntry { + return key.startsWith('nitro:handlers:'); +} diff --git a/packages/nuxt/src/server/sdk.ts b/packages/nuxt/src/server/sdk.ts index dcd2f46caec9..edbd26b3d707 100644 --- a/packages/nuxt/src/server/sdk.ts +++ b/packages/nuxt/src/server/sdk.ts @@ -1,5 +1,5 @@ import * as path from 'node:path'; -import type { Client, EventProcessor, Integration } from '@sentry/core'; +import type { Client, Event, EventProcessor, Integration } from '@sentry/core'; import { applySdkMetadata, debug, flush, getGlobalScope, vercelWaitUntil } from '@sentry/core'; import { type NodeOptions, @@ -40,7 +40,7 @@ export function init(options: SentryNuxtServerOptions): Client | undefined { export function lowQualityTransactionsFilter(options: SentryNuxtServerOptions): EventProcessor { return Object.assign( (event => { - if (event.type !== 'transaction' || !event.transaction) { + if (event.type !== 'transaction' || !event.transaction || isCacheEvent(event)) { return event; } @@ -111,3 +111,10 @@ async function flushSafelyWithTimeout(): Promise { DEBUG_BUILD && debug.log('Error while flushing events:\n', e); } } + +/** + * Checks if the event is a cache event. + */ +function isCacheEvent(e: Event): boolean { + return e.contexts?.trace?.origin === 'auto.cache.nuxt'; +} From af83b8778a2454b8eb6d6a61fb45d37c07ab97be Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 14:59:37 +0300 Subject: [PATCH 10/23] fix(nextjs): Inconsistent transaction naming for i18n routing (#17927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem When using Next.js 15 App Router with `next-intl` and `localePrefix: "as-needed"`, Web Vitals and transaction names were inconsistent across locales: - `/foo` (default locale, no prefix) → Transaction: `/:locale` ❌ - `/ar/foo` (non-default locale, with prefix) → Transaction: `/:locale/foo` ✅ This caused all default locale pages to collapse into a single `/:locale` transaction, making Web Vitals data unusable for apps with i18n routing. After investigation it seems like the route parameterization logic couldn't match `/foo` (1 segment) to the `/:locale/foo` pattern (expects 2 segments) because the locale prefix is omitted in default locale URLs. ### Solution Implemented enhanced route matching with automatic i18n prefix detection: 1. **Route Manifest Metadata** - Added `hasOptionalPrefix` flag to route info to identify routes with common i18n parameter names (`locale`, `lang`, `language`) 2. **Smart Fallback Matching** - When a route doesn't match directly, the matcher now tries prepending a placeholder segment for routes flagged with `hasOptionalPrefix` - Example: `/foo` → tries matching as `/PLACEHOLDER/foo` → matches `/:locale/foo` ✓ 3. **Updated Specificity Scoring** - changed route specificity calculation to prefer longer routes when dynamic segment counts are equal - Example: `/:locale/foo` (2 segments) now beats `/:locale` (1 segment) ### Result **After fix:** ``` URL: /foo → Transaction: /:locale/foo ✅ URL: /ar/foo → Transaction: /:locale/foo ✅ URL: /products → Transaction: /:locale/products ✅ URL: /ar/products → Transaction: /:locale/products ✅ ``` All routes now consistently use the same parameterized transaction name regardless of locale, making Web Vitals properly grouped and usable. ### Backwards Compatibility - No breaking changes - only applies when direct matching would fail - Only affects routes with first param named `locale`/`lang`/`language` - Non-i18n apps completely unaffected - Direct matches always take precedence over optional prefix matching Fixes #17775 --- Maybe we should make certain aspects of this configurable, like the `['locale', 'lang', 'language']` collection --- .../nextjs-15-intl/.gitignore | 51 ++++ .../test-applications/nextjs-15-intl/.npmrc | 2 + .../app/[locale]/i18n-test/page.tsx | 9 + .../nextjs-15-intl/app/[locale]/page.tsx | 9 + .../nextjs-15-intl/app/layout.tsx | 11 + .../nextjs-15-intl/i18n/request.ts | 14 + .../nextjs-15-intl/i18n/routing.ts | 10 + .../nextjs-15-intl/instrumentation-client.ts | 11 + .../nextjs-15-intl/instrumentation.ts | 13 + .../nextjs-15-intl/middleware.ts | 8 + .../nextjs-15-intl/next.config.js | 11 + .../nextjs-15-intl/package.json | 31 ++ .../nextjs-15-intl/playwright.config.mjs | 25 ++ .../nextjs-15-intl/sentry.edge.config.ts | 9 + .../nextjs-15-intl/sentry.server.config.ts | 12 + .../nextjs-15-intl/start-event-proxy.mjs | 14 + .../nextjs-15-intl/tests/i18n-routing.test.ts | 90 ++++++ .../nextjs-15-intl/tsconfig.json | 27 ++ .../nextjs-15/app/[locale]/i18n-test/page.tsx | 10 + .../nextjs-15/tests/i18n-routing.test.ts | 56 ++++ .../src/client/routing/parameterization.ts | 28 ++ .../config/manifest/createRouteManifest.ts | 24 +- packages/nextjs/src/config/manifest/types.ts | 5 + .../test/client/parameterization.test.ts | 289 ++++++++++++++++++ .../suites/base-path/base-path.test.ts | 1 + .../catchall-at-root/catchall-at-root.test.ts | 1 + .../manifest/suites/catchall/catchall.test.ts | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 4 + .../suites/route-groups/route-groups.test.ts | 2 + 29 files changed, 775 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore new file mode 100644 index 000000000000..2d0dd371dc86 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.gitignore @@ -0,0 +1,51 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Sentry +.sentryclirc + +pnpm-lock.yaml +.tmp_dev_server_logs +.tmp_build_stdout +.tmp_build_stderr +event-dumps +test-results + diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc new file mode 100644 index 000000000000..c6b3ef9b3eaa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://localhost:4873 +@sentry-internal:registry=http://localhost:4873 diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx new file mode 100644 index 000000000000..7e2e8d45db06 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/i18n-test/page.tsx @@ -0,0 +1,9 @@ +export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

I18n Test Page

+

Current locale: {locale}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx new file mode 100644 index 000000000000..23e7b3213a3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/[locale]/page.tsx @@ -0,0 +1,9 @@ +export default async function LocaleRootPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

Locale Root

+

Current locale: {locale}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx new file mode 100644 index 000000000000..60b3740fd7a2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/app/layout.tsx @@ -0,0 +1,11 @@ +export const metadata = { + title: 'Next.js 15 i18n Test', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts new file mode 100644 index 000000000000..5ed375a9107a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/request.ts @@ -0,0 +1,14 @@ +import { getRequestConfig } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { routing } from './routing'; + +export default getRequestConfig(async ({ requestLocale }) => { + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; + + return { + locale, + messages: {}, + }; +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts new file mode 100644 index 000000000000..efa95881eabc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/i18n/routing.ts @@ -0,0 +1,10 @@ +import { defineRouting } from 'next-intl/routing'; +import { createNavigation } from 'next-intl/navigation'; + +export const routing = defineRouting({ + locales: ['en', 'ar', 'fr'], + defaultLocale: 'en', + localePrefix: 'as-needed', +}); + +export const { Link, redirect, usePathname, useRouter } = createNavigation(routing); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts new file mode 100644 index 000000000000..c232101a75e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation-client.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts new file mode 100644 index 000000000000..14e2b3ce738a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/middleware.ts @@ -0,0 +1,8 @@ +import createMiddleware from 'next-intl/middleware'; +import { routing } from './i18n/routing'; + +export default createMiddleware(routing); + +export const config = { + matcher: ['/((?!api|_next|.*\\..*).*)'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js new file mode 100644 index 000000000000..edd191e14b38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/next.config.js @@ -0,0 +1,11 @@ +const { withSentryConfig } = require('@sentry/nextjs'); +const createNextIntlPlugin = require('next-intl/plugin'); + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); + +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = withSentryConfig(withNextIntl(nextConfig), { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json new file mode 100644 index 000000000000..359b939eaf50 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/package.json @@ -0,0 +1,31 @@ +{ + "name": "nextjs-15-intl", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test:prod && pnpm test:dev" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@types/node": "^18.19.1", + "@types/react": "18.0.26", + "@types/react-dom": "18.0.9", + "next": "15.5.4", + "next-intl": "^4.3.12", + "react": "latest", + "react-dom": "latest", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs new file mode 100644 index 000000000000..38548e975851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/playwright.config.mjs @@ -0,0 +1,25 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts new file mode 100644 index 000000000000..e9521895498e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.edge.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.SENTRY_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts new file mode 100644 index 000000000000..760b8b581a29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/sentry.server.config.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, + tracesSampleRate: 1.0, + sendDefaultPii: true, + transportOptions: { + bufferSize: 1000, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs new file mode 100644 index 000000000000..8f6b9b5886d5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-15-intl', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/nextjs-15-intl-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts new file mode 100644 index 000000000000..0943df8c7216 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tests/i18n-routing.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create consistent parameterized transaction for default locale without prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should create consistent parameterized transaction for non-default locale with prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/ar/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should parameterize locale root page correctly for default locale without prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); + +test('should parameterize locale root page correctly for non-default locale with prefix', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15-intl', async transactionEvent => { + return transactionEvent.transaction === '/:locale' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/fr`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + transaction: '/:locale', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json new file mode 100644 index 000000000000..64c21044c49f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-intl/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + }, + "target": "ES2017" + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx new file mode 100644 index 000000000000..10c32a944514 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/[locale]/i18n-test/page.tsx @@ -0,0 +1,10 @@ +export default async function I18nTestPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + return ( +
+

I18n Test Page

+

Current locale: {locale || 'default'}

+

This page tests i18n route parameterization

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts new file mode 100644 index 000000000000..fda0645fa1a3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/i18n-routing.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create consistent parameterized transaction for i18n routes - locale: en', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/en/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create consistent parameterized transaction for i18n routes - locale: ar', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/:locale/i18n-test' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/ar/i18n-test`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + }, + }, + transaction: '/:locale/i18n-test', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index c20d71614234..d13097435f41 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -37,6 +37,16 @@ function getRouteSpecificity(routePath: string): number { // Static segments add 0 to score as they are most specific } + if (segments.length > 0) { + // Add a small penalty based on inverse of segment count + // This ensures that routes with more segments are preferred + // e.g., '/:locale/foo' is more specific than '/:locale' + // We use a small value (1 / segments.length) so it doesn't override the main scoring + // but breaks ties between routes with the same number of dynamic segments + const segmentCountPenalty = 1 / segments.length; + score += segmentCountPenalty; + } + return score; } @@ -134,6 +144,24 @@ function findMatchingRoutes( } } + // Try matching with optional prefix segments (for i18n routing patterns) + // This handles cases like '/foo' matching '/:locale/foo' when using next-intl with localePrefix: "as-needed" + // We do this regardless of whether we found direct matches, as we want the most specific match + if (!route.startsWith('/:')) { + for (const dynamicRoute of dynamicRoutes) { + if (dynamicRoute.hasOptionalPrefix && dynamicRoute.regex) { + // Prepend a placeholder segment to simulate the optional prefix + // e.g., '/foo' becomes '/PLACEHOLDER/foo' to match '/:locale/foo' + // Special case: '/' becomes '/PLACEHOLDER' (not '/PLACEHOLDER/') to match '/:locale' pattern + const routeWithPrefix = route === '/' ? '/SENTRY_OPTIONAL_PREFIX' : `/SENTRY_OPTIONAL_PREFIX${route}`; + const regex = getCompiledRegex(dynamicRoute.regex); + if (regex?.test(routeWithPrefix)) { + matches.push(dynamicRoute.path); + } + } + } + } + return matches; } diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 32e7db61b57b..5e2a99f66285 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -47,7 +47,11 @@ function getDynamicRouteSegment(name: string): string { return `:${name.slice(1, -1)}`; } -function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } { +function buildRegexForDynamicRoute(routePath: string): { + regex: string; + paramNames: string[]; + hasOptionalPrefix: boolean; +} { const segments = routePath.split('/').filter(Boolean); const regexSegments: string[] = []; const paramNames: string[] = []; @@ -95,7 +99,20 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam pattern = `^/${regexSegments.join('/')}$`; } - return { regex: pattern, paramNames }; + return { regex: pattern, paramNames, hasOptionalPrefix: hasOptionalPrefix(paramNames) }; +} + +/** + * Detect if the first parameter is a common i18n prefix segment + * Common patterns: locale, lang, language + */ +function hasOptionalPrefix(paramNames: string[]): boolean { + const firstParam = paramNames[0]; + if (firstParam === undefined) { + return false; + } + + return firstParam === 'locale' || firstParam === 'lang' || firstParam === 'language'; } function scanAppDirectory( @@ -116,11 +133,12 @@ function scanAppDirectory( const isDynamic = routePath.includes(':'); if (isDynamic) { - const { regex, paramNames } = buildRegexForDynamicRoute(routePath); + const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath); dynamicRoutes.push({ path: routePath, regex, paramNames, + hasOptionalPrefix, }); } else { staticRoutes.push({ diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index e3a26adfce2f..0a0946be70f7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -14,6 +14,11 @@ export type RouteInfo = { * (Optional) The names of dynamic parameters in the route */ paramNames?: string[]; + /** + * (Optional) Indicates if the first segment is an optional prefix (e.g., for i18n routing) + * When true, routes like '/foo' should match '/:locale/foo' patterns + */ + hasOptionalPrefix?: boolean; }; /** diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index e9f484e71827..e593596aa8c1 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -644,4 +644,293 @@ describe('maybeParameterizeRoute', () => { expect(maybeParameterizeRoute('/some/random/path')).toBe('/:catchall*'); }); }); + + describe('i18n routing with optional prefix', () => { + it('should match routes with optional locale prefix for default locale paths', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/bar', + regex: '^/([^/]+)/bar$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products', + regex: '^/([^/]+)/products$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Default locale paths (without prefix) should match parameterized routes + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/bar')).toBe('/:locale/bar'); + expect(maybeParameterizeRoute('/products')).toBe('/:locale/products'); + + // Non-default locale paths (with prefix) should also match + expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/ar/bar')).toBe('/:locale/bar'); + expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products'); + expect(maybeParameterizeRoute('/en/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products'); + }); + + it('should handle nested routes with optional locale prefix', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale/foo/:id', + regex: '^/([^/]+)/foo/([^/]+)$', + paramNames: ['locale', 'id'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products/:productId', + regex: '^/([^/]+)/products/([^/]+)$', + paramNames: ['locale', 'productId'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Default locale (no prefix) + expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/products/abc')).toBe('/:locale/products/:productId'); + + // Non-default locale (with prefix) + expect(maybeParameterizeRoute('/ar/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/ar/products/abc')).toBe('/:locale/products/:productId'); + expect(maybeParameterizeRoute('/en/foo/456')).toBe('/:locale/foo/:id'); + }); + + it('should prioritize direct matches over optional prefix matches', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/foo/:id', + regex: '^/foo/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Direct match should win + expect(maybeParameterizeRoute('/foo/123')).toBe('/foo/:id'); + + // Optional prefix match when direct match isn't available + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/ar/foo')).toBe('/:locale/foo'); + }); + + it('should handle lang and language parameters as optional prefixes', () => { + const manifestWithLang: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:lang/page', + regex: '^/([^/]+)/page$', + paramNames: ['lang'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLang); + expect(maybeParameterizeRoute('/page')).toBe('/:lang/page'); + expect(maybeParameterizeRoute('/en/page')).toBe('/:lang/page'); + + const manifestWithLanguage: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:language/page', + regex: '^/([^/]+)/page$', + paramNames: ['language'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifestWithLanguage); + expect(maybeParameterizeRoute('/page')).toBe('/:language/page'); + expect(maybeParameterizeRoute('/en/page')).toBe('/:language/page'); + }); + + it('should not apply optional prefix logic to non-i18n dynamic segments', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:userId/profile', + regex: '^/([^/]+)/profile$', + paramNames: ['userId'], + hasOptionalPrefix: false, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Should not match without the userId segment + expect(maybeParameterizeRoute('/profile')).toBeUndefined(); + + // Should match with the userId segment + expect(maybeParameterizeRoute('/123/profile')).toBe('/:userId/profile'); + }); + + it('should handle real-world next-intl scenario', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/hola', + regex: '^/([^/]+)/hola$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/products', + regex: '^/([^/]+)/products$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Root should not be parameterized (it's a static route) + expect(maybeParameterizeRoute('/')).toBeUndefined(); + + // Default locale (English, no prefix) - this was the bug + expect(maybeParameterizeRoute('/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/products')).toBe('/:locale/products'); + + // Non-default locale (Arabic, with prefix) + expect(maybeParameterizeRoute('/ar')).toBe('/:locale'); + expect(maybeParameterizeRoute('/ar/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/ar/products')).toBe('/:locale/products'); + + // Other locales + expect(maybeParameterizeRoute('/en/hola')).toBe('/:locale/hola'); + expect(maybeParameterizeRoute('/fr/products')).toBe('/:locale/products'); + }); + + it('should prefer more specific routes over optional prefix matches', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo/:id', + regex: '^/([^/]+)/foo/([^/]+)$', + paramNames: ['locale', 'id'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/foo', + regex: '^/([^/]+)/foo$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // More specific route should win (specificity score) + expect(maybeParameterizeRoute('/foo/123')).toBe('/:locale/foo/:id'); + expect(maybeParameterizeRoute('/foo')).toBe('/:locale/foo'); + expect(maybeParameterizeRoute('/about')).toBe('/:locale'); + }); + + it('should handle deeply nested i18n routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale/users/:userId/posts/:postId/comments/:commentId', + regex: '^/([^/]+)/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$', + paramNames: ['locale', 'userId', 'postId', 'commentId'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Without locale prefix (default locale) + expect(maybeParameterizeRoute('/users/123/posts/456/comments/789')).toBe( + '/:locale/users/:userId/posts/:postId/comments/:commentId', + ); + + // With locale prefix + expect(maybeParameterizeRoute('/ar/users/123/posts/456/comments/789')).toBe( + '/:locale/users/:userId/posts/:postId/comments/:commentId', + ); + }); + + it('should handle root path with optional locale prefix', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:locale', + regex: '^/([^/]+)$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + { + path: '/:locale/about', + regex: '^/([^/]+)/about$', + paramNames: ['locale'], + hasOptionalPrefix: true, + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Root path without locale prefix (default locale) + expect(maybeParameterizeRoute('/')).toBe('/:locale'); + + // Root path with locale prefix + expect(maybeParameterizeRoute('/en')).toBe('/:locale'); + expect(maybeParameterizeRoute('/ar')).toBe('/:locale'); + + // Nested routes still work + expect(maybeParameterizeRoute('/about')).toBe('/:locale/about'); + expect(maybeParameterizeRoute('/fr/about')).toBe('/:locale/about'); + }); + }); }); diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts index a1014b05c32c..097e3f603693 100644 --- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -16,6 +16,7 @@ describe('basePath', () => { path: '/my-app/users/:id', regex: '^/my-app/users/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index b7108b6f6f23..8d78f24a0986 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -13,6 +13,7 @@ describe('catchall', () => { path: '/:path*?', regex: '^/(.*)$', paramNames: ['path'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index b1c417970ba4..d259a1a38223 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -13,6 +13,7 @@ describe('catchall', () => { path: '/catchall/:path*?', regex: '^/catchall(?:/(.*))?$', paramNames: ['path'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index fdcae299d7cf..2ea4b4aca5d8 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -13,21 +13,25 @@ describe('dynamic', () => { path: '/dynamic/:id', regex: '^/dynamic/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, { path: '/users/:id', regex: '^/users/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, { path: '/users/:id/posts/:postId', regex: '^/users/([^/]+)/posts/([^/]+)$', paramNames: ['id', 'postId'], + hasOptionalPrefix: false, }, { path: '/users/:id/settings', regex: '^/users/([^/]+)/settings$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index 36ac9077df7e..8e1fe463190e 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -23,6 +23,7 @@ describe('route-groups', () => { path: '/dashboard/:id', regex: '^/dashboard/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); @@ -55,6 +56,7 @@ describe('route-groups', () => { path: '/(dashboard)/dashboard/:id', regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', paramNames: ['id'], + hasOptionalPrefix: false, }, ], }); From 55f03e019f6edb380166c84ae62b805fd019789c Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 17 Oct 2025 14:33:29 +0200 Subject: [PATCH 11/23] build: Update to typescript 5.8.0 (#17710) This updates the TS version we use to 5.8.0. We still downlevel to 3.8 so this should not be breaking (even if we were to use newer features eventually), downlevel-dts will fail/or our tests anyhow if we use some features that cannot be downlevelled. --- > [!NOTE] > Upgrade TypeScript to 5.8 across the repo, adjust tsconfigs and deps, and fix minor type issues to satisfy stricter checks. > > - **Tooling/Versions**: > - Bump `typescript` to `~5.8.0` and update version guard in `scripts/verify-packages-versions.js`. > - Update `yarn.lock` and package constraints; add new e2e app `dev-packages/e2e-tests/test-applications/generic-ts5.0`. > - **TS Configs**: > - Add `moduleResolution: "Node16"` in multiple `tsconfig.json` files and test configs. > - Add `@types/node` and include `"node"` in `types` where needed (e.g., Hydrogen test app). > - **Type/Code Adjustments**: > - Remove unnecessary casts and add explicit non-null assertions for `Map.keys().next().value`. > - Simplify handler registration (`handlers[type].push(handler)`), and minor TS cleanups in profiling/LRU/debug-id utilities. > - Cloudflare/Nuxt/SvelteKit: relax Request typing and use `@ts-expect-error` for `cf` init property; avoid unused CF type import. > - Next.js webpack: remove unnecessary non-null assertion when joining `appDirPath`. > - Remix: streamline FormData attribute handling and vendor instrumentation check. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 5ec5959c166604506a9099b875de48c042f4811c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: s1gr1d --- .../test-applications/generic-ts5.0/.npmrc | 2 ++ .../test-applications/generic-ts5.0/index.ts | 5 ++++ .../generic-ts5.0/package.json | 26 +++++++++++++++++++ .../generic-ts5.0/tsconfig.json | 11 ++++++++ .../hydrogen-react-router-7/package.json | 1 + .../hydrogen-react-router-7/tsconfig.json | 2 +- package.json | 2 +- .../browser-utils/src/metrics/instrument.ts | 2 +- packages/browser/src/profiling/utils.ts | 3 ++- packages/core/src/instrument/handlers.ts | 2 +- packages/core/src/utils/debug-ids.ts | 2 +- packages/core/src/utils/lru.ts | 4 ++- packages/nextjs/src/config/webpack.ts | 2 +- packages/nextjs/tsconfig.test.json | 1 + .../node-core/src/integrations/anr/worker.ts | 2 +- .../node-core/test/helpers/mockSdkInit.ts | 2 +- packages/node-core/tsconfig.json | 3 ++- .../src/event-loop-block-watchdog.ts | 4 +-- packages/node/test/helpers/mockSdkInit.ts | 2 +- packages/node/tsconfig.json | 3 ++- packages/nuxt/src/module.ts | 8 +++--- .../plugins/sentry-cloudflare.server.ts | 4 +-- packages/remix/src/utils/utils.ts | 2 +- packages/remix/src/vendor/instrumentation.ts | 6 +---- packages/remix/test/integration/package.json | 2 +- packages/remix/tsconfig.test.json | 1 + packages/sveltekit/src/worker/cloudflare.ts | 2 +- packages/tanstackstart-react/tsconfig.json | 3 ++- packages/tanstackstart/tsconfig.json | 3 ++- packages/typescript/package.json | 2 +- scripts/verify-packages-versions.js | 2 +- yarn.lock | 7 +---- 32 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json create mode 100644 dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc b/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts b/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts new file mode 100644 index 000000000000..beb10260da38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/index.ts @@ -0,0 +1,5 @@ +import * as _SentryReplay from '@sentry-internal/replay'; +import * as _SentryBrowser from '@sentry/browser'; +import * as _SentryCore from '@sentry/core'; +import * as _SentryNode from '@sentry/node'; +import * as _SentryWasm from '@sentry/wasm'; diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json new file mode 100644 index 000000000000..1079d8f4c793 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/package.json @@ -0,0 +1,26 @@ +{ + "name": "@sentry-internal/ts5.0-test", + "private": true, + "license": "MIT", + "scripts": { + "build:types": "pnpm run type-check", + "ts-version": "tsc --version", + "type-check": "tsc --project tsconfig.json", + "test:build": "pnpm install && pnpm run build:types", + "test:assert": "pnpm -v" + }, + "devDependencies": { + "typescript": "5.0.2", + "@types/node": "^18.19.1" + }, + "dependencies": { + "@sentry/browser": "latest || *", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry-internal/replay": "latest || *", + "@sentry/wasm": "latest || *" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json b/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json new file mode 100644 index 000000000000..95de9c93fc38 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/generic-ts5.0/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": ["index.ts"], + "compilerOptions": { + "lib": ["es2018", "DOM"], + "skipLibCheck": false, + "noEmit": true, + "types": [], + "target": "es2018", + "moduleResolution": "node" + } +} diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json index 503ad2758767..bf0b59ca0adf 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/package.json @@ -41,6 +41,7 @@ "@total-typescript/ts-reset": "^0.4.2", "@types/eslint": "^8.4.10", "@types/react": "^18.2.22", + "@types/node": "^18.19.1", "@types/react-dom": "^18.2.7", "esbuild": "0.25.0", "eslint": "^9.18.0", diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json index 0d4c4dc2e4de..6b1b95f76f6f 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/tsconfig.json @@ -14,7 +14,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "baseUrl": ".", - "types": ["@shopify/oxygen-workers-types"], + "types": ["@shopify/oxygen-workers-types", "node"], "paths": { "~/*": ["app/*"] }, diff --git a/package.json b/package.json index edbd645b3c97..de0b46add91b 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "size-limit": "~11.1.6", "sucrase": "^3.35.0", "ts-node": "10.9.1", - "typescript": "~5.0.0", + "typescript": "~5.8.0", "vitest": "^3.2.4", "yalc": "^1.0.0-pre.53", "yarn-deduplicate": "6.0.2" diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 8017bd4c89e1..4c461ec6776c 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -301,7 +301,7 @@ function instrumentPerformanceObserver(type: InstrumentHandlerTypePerformanceObs function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { handlers[type] = handlers[type] || []; - (handlers[type] as InstrumentHandlerCallback[]).push(handler); + handlers[type].push(handler); } // Get a callback which can be called to remove the instrumentation handler diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 66b202c8517f..8b7039be7a9b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -564,7 +564,8 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { - const last: string = PROFILE_MAP.keys().next().value; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const last = PROFILE_MAP.keys().next().value!; PROFILE_MAP.delete(last); } } diff --git a/packages/core/src/instrument/handlers.ts b/packages/core/src/instrument/handlers.ts index 86c5a90b7c52..74dbc9902348 100644 --- a/packages/core/src/instrument/handlers.ts +++ b/packages/core/src/instrument/handlers.ts @@ -21,7 +21,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; /** Add a handler function. */ export function addHandler(type: InstrumentHandlerType, handler: InstrumentHandlerCallback): void { handlers[type] = handlers[type] || []; - (handlers[type] as InstrumentHandlerCallback[]).push(handler); + handlers[type].push(handler); } /** diff --git a/packages/core/src/utils/debug-ids.ts b/packages/core/src/utils/debug-ids.ts index 97f30bbe816a..fd31009ae32d 100644 --- a/packages/core/src/utils/debug-ids.ts +++ b/packages/core/src/utils/debug-ids.ts @@ -105,7 +105,7 @@ export function getDebugImagesForResources( images.push({ type: 'sourcemap', code_file: path, - debug_id: filenameDebugIdMap[path] as string, + debug_id: filenameDebugIdMap[path], }); } } diff --git a/packages/core/src/utils/lru.ts b/packages/core/src/utils/lru.ts index 2a3b7bfc8ac0..3158dff7d413 100644 --- a/packages/core/src/utils/lru.ts +++ b/packages/core/src/utils/lru.ts @@ -27,7 +27,9 @@ export class LRUMap { public set(key: K, value: V): void { if (this._cache.size >= this._maxSize) { // keys() returns an iterator in insertion order so keys().next() gives us the oldest key - this._cache.delete(this._cache.keys().next().value); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const nextKey = this._cache.keys().next().value!; + this._cache.delete(nextKey); } this._cache.set(key, value); } diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 6ba07cd09f8f..0b0506d373b0 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -331,7 +331,7 @@ export function constructWebpackConfigFunction({ .map(extension => `global-error.${extension}`) .some( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - globalErrorFile => fs.existsSync(path.join(appDirPath!, globalErrorFile)), + globalErrorFile => fs.existsSync(path.join(appDirPath, globalErrorFile)), ); if ( diff --git a/packages/nextjs/tsconfig.test.json b/packages/nextjs/tsconfig.test.json index 633c4212a0e9..be787654b1a0 100644 --- a/packages/nextjs/tsconfig.test.json +++ b/packages/nextjs/tsconfig.test.json @@ -9,6 +9,7 @@ // require for top-level await "module": "Node16", + "moduleResolution": "Node16", "target": "es2020", // other package-specific, test-specific options diff --git a/packages/node-core/src/integrations/anr/worker.ts b/packages/node-core/src/integrations/anr/worker.ts index 7c2ac91f30af..3ae9e009625c 100644 --- a/packages/node-core/src/integrations/anr/worker.ts +++ b/packages/node-core/src/integrations/anr/worker.ts @@ -110,7 +110,7 @@ function applyDebugMeta(event: Event): void { for (const frame of exception.stacktrace?.frames || []) { const filename = frame.abs_path || frame.filename; if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } diff --git a/packages/node-core/test/helpers/mockSdkInit.ts b/packages/node-core/test/helpers/mockSdkInit.ts index 0ea8a93cb064..8d4cb28bfd66 100644 --- a/packages/node-core/test/helpers/mockSdkInit.ts +++ b/packages/node-core/test/helpers/mockSdkInit.ts @@ -149,7 +149,7 @@ export function getSpanProcessor(): SentrySpanProcessor | undefined { const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + ); return spanProcessor; } diff --git a/packages/node-core/tsconfig.json b/packages/node-core/tsconfig.json index 07c7602c1fdd..28abec410557 100644 --- a/packages/node-core/tsconfig.json +++ b/packages/node-core/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["ES2020", "ES2021.WeakRef"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index 26b9bb683930..492070a2d1dc 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -149,7 +149,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void for (const frame of exception.stacktrace?.frames || []) { const filename = stripFileProtocol(frame.abs_path || frame.filename); if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } @@ -158,7 +158,7 @@ function applyDebugMeta(event: Event, debugImages: Record): void for (const frame of thread.stacktrace?.frames || []) { const filename = stripFileProtocol(frame.abs_path || frame.filename); if (filename && normalisedDebugImages[filename]) { - filenameToDebugId.set(filename, normalisedDebugImages[filename] as string); + filenameToDebugId.set(filename, normalisedDebugImages[filename]); } } } diff --git a/packages/node/test/helpers/mockSdkInit.ts b/packages/node/test/helpers/mockSdkInit.ts index dc4c3586d978..8f8be9e8af68 100644 --- a/packages/node/test/helpers/mockSdkInit.ts +++ b/packages/node/test/helpers/mockSdkInit.ts @@ -61,7 +61,7 @@ export function getSpanProcessor(): SentrySpanProcessor | undefined { const spanProcessor = multiSpanProcessor?.['_spanProcessors']?.find( (spanProcessor: SpanProcessor) => spanProcessor instanceof SentrySpanProcessor, - ) as SentrySpanProcessor | undefined; + ); return spanProcessor; } diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index 64d6f3a1b9e0..d5f034ad1048 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -5,6 +5,7 @@ "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 947eb2710f4d..9e85209eeb55 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -76,14 +76,16 @@ export default defineNuxtModule({ // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { - if (!options.tsConfig.include) { - options.tsConfig.include = []; + const tsConfig = options.tsConfig as { include?: string[] }; + + if (!tsConfig.include) { + tsConfig.include = []; } // Add type references for useRuntimeConfig in root files for nuxt v4 // Should be relative to `root/.nuxt` const relativePath = path.relative(nuxt.options.buildDir, clientConfigFile); - options.tsConfig.include.push(relativePath); + tsConfig.include.push(relativePath); }); } diff --git a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts index 5438ac829d8a..d45d45d0d4ed 100644 --- a/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry-cloudflare.server.ts @@ -1,4 +1,3 @@ -import type { IncomingRequestCfProperties } from '@cloudflare/workers-types'; import type { CloudflareOptions } from '@sentry/cloudflare'; import { setAsyncLocalStorageAsyncContextStrategy, wrapRequestHandler } from '@sentry/cloudflare'; import { debug, getDefaultIsolationScope, getIsolationScope, getTraceData } from '@sentry/core'; @@ -64,8 +63,9 @@ export const sentryCloudflareNitroPlugin = const request = new Request(url, { method: event.method, headers: event.headers, + // @ts-expect-error - 'cf' is a valid property in the RequestInit type for Cloudflare cf: getCfProperties(event), - }) as Request>; + }); const requestHandlerOptions = { options: cloudflareOptions, diff --git a/packages/remix/src/utils/utils.ts b/packages/remix/src/utils/utils.ts index 5485cff5e0a3..9cc02341ac0a 100644 --- a/packages/remix/src/utils/utils.ts +++ b/packages/remix/src/utils/utils.ts @@ -29,7 +29,7 @@ export async function storeFormDataKeys( if (formDataKeys?.[key]) { if (typeof formDataKeys[key] === 'string') { - attrKey = formDataKeys[key] as string; + attrKey = formDataKeys[key]; } span.setAttribute( diff --git a/packages/remix/src/vendor/instrumentation.ts b/packages/remix/src/vendor/instrumentation.ts index 317a17da663d..6ccc56c7a88f 100644 --- a/packages/remix/src/vendor/instrumentation.ts +++ b/packages/remix/src/vendor/instrumentation.ts @@ -310,11 +310,7 @@ export class RemixInstrumentation extends InstrumentationBase { const { actionFormDataAttributes: actionFormAttributes } = plugin.getConfig(); formData.forEach((value: unknown, key: string) => { - if ( - actionFormAttributes?.[key] && - actionFormAttributes[key] !== false && - typeof value === 'string' - ) { + if (actionFormAttributes?.[key] && typeof value === 'string') { const keyName = actionFormAttributes[key] === true ? key : actionFormAttributes[key]; span.setAttribute(`formData.${keyName}`, value.toString()); } diff --git a/packages/remix/test/integration/package.json b/packages/remix/test/integration/package.json index 04e20e5f3a56..d138a0eb8eaa 100644 --- a/packages/remix/test/integration/package.json +++ b/packages/remix/test/integration/package.json @@ -21,7 +21,7 @@ "@types/react": "^18", "@types/react-dom": "^18", "nock": "^13.5.5", - "typescript": "~5.0.0" + "typescript": "~5.8.0" }, "resolutions": { "@sentry/browser": "file:../../../browser", diff --git a/packages/remix/tsconfig.test.json b/packages/remix/tsconfig.test.json index f62d7ff34d09..dace64b4fd9a 100644 --- a/packages/remix/tsconfig.test.json +++ b/packages/remix/tsconfig.test.json @@ -8,6 +8,7 @@ "types": ["node"], // Required for top-level await in tests "module": "Node16", + "moduleResolution": "Node16", "target": "es2020", "esModuleInterop": true diff --git a/packages/sveltekit/src/worker/cloudflare.ts b/packages/sveltekit/src/worker/cloudflare.ts index 612b174f6c69..9cacad6f4cb8 100644 --- a/packages/sveltekit/src/worker/cloudflare.ts +++ b/packages/sveltekit/src/worker/cloudflare.ts @@ -39,7 +39,7 @@ export function initCloudflareSentryHandle(options: CloudflareOptions): Handle { return wrapRequestHandler( { options: opts, - request: event.request as Request>, + request: event.request, // @ts-expect-error This will exist in Cloudflare context: event.platform.context, // We don't want to capture errors here, as we want to capture them in the `sentryHandle` handler diff --git a/packages/tanstackstart-react/tsconfig.json b/packages/tanstackstart-react/tsconfig.json index ff4cadba841a..220ba3fa2b86 100644 --- a/packages/tanstackstart-react/tsconfig.json +++ b/packages/tanstackstart-react/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*"], "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/tanstackstart/tsconfig.json b/packages/tanstackstart/tsconfig.json index ff4cadba841a..220ba3fa2b86 100644 --- a/packages/tanstackstart/tsconfig.json +++ b/packages/tanstackstart/tsconfig.json @@ -3,6 +3,7 @@ "include": ["src/**/*"], "compilerOptions": { "lib": ["es2020"], - "module": "Node16" + "module": "Node16", + "moduleResolution": "Node16" } } diff --git a/packages/typescript/package.json b/packages/typescript/package.json index dc465ec207dd..430842c16d09 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -13,7 +13,7 @@ "tsconfig.json" ], "peerDependencies": { - "typescript": "~5.0.0" + "typescript": "~5.8.0" }, "scripts": { "clean": "yarn rimraf sentry-internal-typescript-*.tgz", diff --git a/scripts/verify-packages-versions.js b/scripts/verify-packages-versions.js index e6f0837cb38c..81eac62e9c90 100644 --- a/scripts/verify-packages-versions.js +++ b/scripts/verify-packages-versions.js @@ -1,6 +1,6 @@ const pkg = require('../package.json'); -const TYPESCRIPT_VERSION = '~5.0.0'; +const TYPESCRIPT_VERSION = '~5.8.0'; if (pkg.devDependencies.typescript !== TYPESCRIPT_VERSION) { console.error(` diff --git a/yarn.lock b/yarn.lock index 70c9e3d80b73..3917862a705f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30045,7 +30045,7 @@ typescript@4.6.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9" integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg== -"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3: +"typescript@>=3 < 6", typescript@^5.0.4, typescript@^5.4.4, typescript@^5.7.3, typescript@~5.8.0: version "5.8.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== @@ -30060,11 +30060,6 @@ typescript@next: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.2.0-dev.20230530.tgz#4251ade97a9d8a86850c4d5c3c4f3e1cb2ccf52c" integrity sha512-bIoMajCZWzLB+pWwncaba/hZc6dRnw7x8T/fenOnP9gYQB/gc4xdm48AXp5SH5I/PvvSeZ/dXkUMtc8s8BiDZw== -typescript@~5.0.0: - version "5.0.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" - integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== - typeson-registry@^1.0.0-alpha.20: version "1.0.0-alpha.39" resolved "https://registry.yarnpkg.com/typeson-registry/-/typeson-registry-1.0.0-alpha.39.tgz#9e0f5aabd5eebfcffd65a796487541196f4b1211" From 2e652f381ab28d0b33e917a82944b1f38975f5d7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 17 Oct 2025 15:30:22 +0200 Subject: [PATCH 12/23] feat(nextjs): Support Next.js proxy files (#17926) - Supports providing a `proxy.ts` file for global middleware as `middleware.ts` will be deprecated with Next.js 16 - Forks Isolation Scope on span start in the edge SDK as we don't wrap middleware/proxy files anymore when using turbopack - Adds middleware e2e tests for next-16 closes https://github.com/getsentry/sentry-javascript/issues/17894 --- .../test-applications/nextjs-16/.npmrc | 4 + .../api/endpoint-behind-middleware/route.ts | 3 + .../test-applications/nextjs-16/package.json | 1 + .../test-applications/nextjs-16/proxy.ts | 24 ++++ .../nextjs-16/sentry.edge.config.ts | 1 + .../nextjs-16/sentry.server.config.ts | 1 + .../nextjs-16/tests/middleware.test.ts | 105 ++++++++++++++++++ .../nextjs-pages-dir/tests/middleware.test.ts | 8 +- .../src/common/wrapMiddlewareWithSentry.ts | 2 +- .../templates/middlewareWrapperTemplate.ts | 5 + packages/nextjs/src/config/webpack.ts | 7 +- packages/nextjs/src/edge/index.ts | 32 +++++- packages/nextjs/test/config/loaders.test.ts | 21 ++++ 13 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/.npmrc @@ -0,0 +1,4 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts new file mode 100644 index 000000000000..2733cc918f44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/api/endpoint-behind-middleware/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return Response.json({ name: 'John Doe' }); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 46fdc5ecffa1..3d1df82b1748 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "@sentry/nextjs": "latest || *", + "@sentry/core": "latest || *", "ai": "^3.0.0", "import-in-the-middle": "^1", "next": "16.0.0-beta.0", diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts new file mode 100644 index 000000000000..60722f329fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/proxy.ts @@ -0,0 +1,24 @@ +import { getDefaultIsolationScope } from '@sentry/core'; +import * as Sentry from '@sentry/nextjs'; +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +export async function proxy(request: NextRequest) { + Sentry.setTag('my-isolated-tag', true); + Sentry.setTag('my-global-scope-isolated-tag', getDefaultIsolationScope().getScopeData().tags['my-isolated-tag']); // We set this tag to be able to assert that the previously set tag has not leaked into the global isolation scope + + if (request.headers.has('x-should-throw')) { + throw new Error('Middleware Error'); + } + + if (request.headers.has('x-should-make-request')) { + await fetch('http://localhost:3030/'); + } + + return NextResponse.next(); +} + +// See "Matching Paths" below to learn more +export const config = { + matcher: ['/api/endpoint-behind-middleware', '/api/endpoint-behind-faulty-middleware'], +}; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts index 85bd765c9c44..2199afc46eaf 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.edge.config.ts @@ -6,4 +6,5 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + // debug: true, }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 8da0a18497a0..08d5d580b314 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -6,5 +6,6 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + // debug: true, integrations: [Sentry.vercelAIIntegration()], }); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts new file mode 100644 index 000000000000..a8096ab7bc69 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Should create a transaction for middleware', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const response = await request.get('/api/endpoint-behind-middleware'); + expect(await response.json()).toStrictEqual({ name: 'John Doe' }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.contexts?.trace?.status).toBe('ok'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + + // Assert that isolation scope works properly + expect(middlewareTransaction.tags?.['my-isolated-tag']).toBe(true); + expect(middlewareTransaction.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); +}); + +test('Faulty middlewares', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent?.transaction === 'middleware GET'; + }); + + const errorEventPromise = waitForError('nextjs-16', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Middleware Error'; + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-throw': '1' } }).catch(() => { + // Noop + }); + + await test.step('should record transactions', async () => { + const middlewareTransaction = await middlewareTransactionPromise; + expect(middlewareTransaction.contexts?.trace?.status).toBe('unknown_error'); + expect(middlewareTransaction.contexts?.trace?.op).toBe('http.server.middleware'); + expect(middlewareTransaction.contexts?.runtime?.name).toBe('vercel-edge'); + expect(middlewareTransaction.transaction_info?.source).toBe('url'); + }); + + await test.step('should record exceptions', async () => { + const errorEvent = await errorEventPromise; + + // Assert that isolation scope works properly + expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); + expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); + // this differs between webpack and turbopack + expect(['middleware GET', '/middleware']).toContain(errorEvent.transaction); + }); +}); + +test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { + const middlewareTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return ( + transactionEvent?.transaction === 'middleware GET' && + !!transactionEvent.spans?.find(span => span.op === 'http.client') + ); + }); + + request.get('/api/endpoint-behind-middleware', { headers: { 'x-should-make-request': '1' } }).catch(() => { + // Noop + }); + + const middlewareTransaction = await middlewareTransactionPromise; + + expect(middlewareTransaction.spans).toEqual( + expect.arrayContaining([ + { + data: { + 'http.method': 'GET', + 'http.response.status_code': 200, + type: 'fetch', + url: 'http://localhost:3030/', + 'http.url': 'http://localhost:3030/', + 'server.address': 'localhost:3030', + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.wintercg_fetch', + }, + description: 'GET http://localhost:3030/', + op: 'http.client', + origin: 'auto.http.wintercg_fetch', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + ]), + ); + expect(middlewareTransaction.breadcrumbs).toEqual( + expect.arrayContaining([ + { + category: 'fetch', + data: { method: 'GET', status_code: 200, url: 'http://localhost:3030/' }, + timestamp: expect.any(Number), + type: 'http', + }, + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts index b9c0e7b4b602..45a89f683be4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-pages-dir/tests/middleware.test.ts @@ -3,7 +3,7 @@ import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; test('Should create a transaction for middleware', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const response = await request.get('/api/endpoint-behind-middleware'); @@ -23,7 +23,7 @@ test('Should create a transaction for middleware', async ({ request }) => { test('Faulty middlewares', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { - return transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-faulty-middleware'; + return transactionEvent?.transaction === 'middleware GET'; }); const errorEventPromise = waitForError('nextjs-pages-dir', errorEvent => { @@ -48,14 +48,14 @@ test('Faulty middlewares', async ({ request }) => { // Assert that isolation scope works properly expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - expect(errorEvent.transaction).toBe('middleware GET /api/endpoint-behind-faulty-middleware'); + expect(errorEvent.transaction).toBe('middleware GET'); }); }); test('Should trace outgoing fetch requests inside middleware and create breadcrumbs for it', async ({ request }) => { const middlewareTransactionPromise = waitForTransaction('nextjs-pages-dir', async transactionEvent => { return ( - transactionEvent?.transaction === 'middleware GET /api/endpoint-behind-middleware' && + transactionEvent?.transaction === 'middleware GET' && !!transactionEvent.spans?.find(span => span.op === 'http.client') ); }); diff --git a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts index 07694d659e57..ba4f7a852d45 100644 --- a/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts +++ b/packages/nextjs/src/common/wrapMiddlewareWithSentry.ts @@ -64,7 +64,7 @@ export function wrapMiddlewareWithSentry( isolationScope.setSDKProcessingMetadata({ normalizedRequest: winterCGRequestToRequestData(req), }); - spanName = `middleware ${req.method} ${new URL(req.url).pathname}`; + spanName = `middleware ${req.method}`; spanSource = 'url'; } else { spanName = 'middleware'; diff --git a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts index 6d44af1275b5..236f4eff3999 100644 --- a/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts +++ b/packages/nextjs/src/config/templates/middlewareWrapperTemplate.ts @@ -15,6 +15,7 @@ type NextApiModule = // ESM export default?: EdgeRouteHandler; middleware?: EdgeRouteHandler; + proxy?: EdgeRouteHandler; } // CJS export | EdgeRouteHandler; @@ -29,6 +30,9 @@ let userProvidedDefaultHandler: EdgeRouteHandler | undefined = undefined; if ('middleware' in userApiModule && typeof userApiModule.middleware === 'function') { // Handle when user defines via named ESM export: `export { middleware };` userProvidedNamedHandler = userApiModule.middleware; +} else if ('proxy' in userApiModule && typeof userApiModule.proxy === 'function') { + // Handle when user defines via named ESM export (Next.js 16): `export { proxy };` + userProvidedNamedHandler = userApiModule.proxy; } else if ('default' in userApiModule && typeof userApiModule.default === 'function') { // Handle when user defines via ESM export: `export default myFunction;` userProvidedDefaultHandler = userApiModule.default; @@ -40,6 +44,7 @@ if ('middleware' in userApiModule && typeof userApiModule.middleware === 'functi export const middleware = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; +export const proxy = userProvidedNamedHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedNamedHandler) : undefined; export default userProvidedDefaultHandler ? Sentry.wrapMiddlewareWithSentry(userProvidedDefaultHandler) : undefined; // Re-export anything exported by the page module we're wrapping. When processing this code, Rollup is smart enough to diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 0b0506d373b0..14f064ae2b0a 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -183,8 +183,11 @@ export function constructWebpackConfigFunction({ ); }; - const possibleMiddlewareLocations = pageExtensions.map(middlewareFileEnding => { - return path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`); + const possibleMiddlewareLocations = pageExtensions.flatMap(middlewareFileEnding => { + return [ + path.join(middlewareLocationFolder, `middleware.${middlewareFileEnding}`), + path.join(middlewareLocationFolder, `proxy.${middlewareFileEnding}`), + ]; }); const isMiddlewareResource = (resourcePath: string): boolean => { const normalizedAbsoluteResourcePath = normalizeLoaderResourcePath(resourcePath); diff --git a/packages/nextjs/src/edge/index.ts b/packages/nextjs/src/edge/index.ts index 6469e3c6a2c8..6ee523fe72dc 100644 --- a/packages/nextjs/src/edge/index.ts +++ b/packages/nextjs/src/edge/index.ts @@ -1,5 +1,8 @@ +import { context } from '@opentelemetry/api'; import { applySdkMetadata, + getCapturedScopesOnSpan, + getCurrentScope, getGlobalScope, getIsolationScope, getRootSpan, @@ -8,10 +11,12 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + setCapturedScopesOnSpan, spanToJSON, stripUrlQueryAndFragment, vercelWaitUntil, } from '@sentry/core'; +import { getScopesFromContext } from '@sentry/opentelemetry'; import type { VercelEdgeOptions } from '@sentry/vercel-edge'; import { getDefaultIntegrations, init as vercelEdgeInit } from '@sentry/vercel-edge'; import { addHeadersAsAttributes } from '../common/utils/addHeadersAsAttributes'; @@ -73,6 +78,19 @@ export function init(options: VercelEdgeOptions = {}): void { if (spanAttributes?.['next.span_type'] === 'Middleware.execute') { span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'http.server.middleware'); span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + + if (isRootSpan) { + // Fork isolation scope for middleware requests + const scopes = getCapturedScopesOnSpan(span); + const isolationScope = (scopes.isolationScope || getIsolationScope()).clone(); + const scope = scopes.scope || getCurrentScope(); + const currentScopesPointer = getScopesFromContext(context.active()); + if (currentScopesPointer) { + currentScopesPointer.isolationScope = isolationScope; + } + + setCapturedScopesOnSpan(span, scope, isolationScope); + } } if (isRootSpan) { @@ -93,7 +111,19 @@ export function init(options: VercelEdgeOptions = {}): void { event.contexts?.trace?.data?.['next.span_name'] ) { if (event.transaction) { - event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + // Older nextjs versions pass the full url appended to the middleware name, which results in high cardinality transaction names. + // We want to remove the url from the name here. + const spanName = event.contexts.trace.data['next.span_name']; + + if (typeof spanName === 'string') { + const match = spanName.match(/^middleware (GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)/); + if (match) { + const normalizedName = `middleware ${match[1]}`; + event.transaction = normalizedName; + } else { + event.transaction = stripUrlQueryAndFragment(event.contexts.trace.data['next.span_name']); + } + } } } }); diff --git a/packages/nextjs/test/config/loaders.test.ts b/packages/nextjs/test/config/loaders.test.ts index 1b290796acb3..a2c1551ae4d1 100644 --- a/packages/nextjs/test/config/loaders.test.ts +++ b/packages/nextjs/test/config/loaders.test.ts @@ -129,6 +129,27 @@ describe('webpack loaders', () => { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/middleware.tsx', expectedWrappingTargetKind: undefined, }, + // Next.js 16+ renamed middleware to proxy + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.js', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: './src/proxy.ts', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/proxy.tsx', + expectedWrappingTargetKind: 'middleware', + }, + { + resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/proxy.tsx', + expectedWrappingTargetKind: undefined, + }, { resourcePath: '/Users/Maisey/projects/squirrelChasingSimulator/src/pages/api/testApiRoute.ts', expectedWrappingTargetKind: 'api-route', From f94b203a7e088469b2cd098fc076a8094e0c67cd Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 17 Oct 2025 18:37:10 +0300 Subject: [PATCH 13/23] feat(nuxt): Instrument Database (#17899) This pull request introduces automatic instrumentation for database queries in Nuxt applications in server side handlers using Sentry. #### Implementation Details - Instruments database `.sql`, `.prepare` and `.exec` calls. - Adds breadcrumbs and spans following cloudflare's D1 implementation. This relies on the work done in #17858 and #17886 --- .../test-applications/nuxt-3/nuxt.config.ts | 17 ++ .../test-applications/nuxt-3/package.json | 3 +- .../nuxt-3/server/api/db-multi-test.ts | 102 ++++++++ .../nuxt-3/server/api/db-test.ts | 70 ++++++ .../nuxt-3/tests/database-multi.test.ts | 156 ++++++++++++ .../nuxt-3/tests/database.test.ts | 197 +++++++++++++++ .../test-applications/nuxt-4/nuxt.config.ts | 18 +- .../test-applications/nuxt-4/package.json | 3 +- .../nuxt-4/server/api/db-multi-test.ts | 102 ++++++++ .../nuxt-4/server/api/db-test.ts | 70 ++++++ .../nuxt-4/tests/database-multi.test.ts | 156 ++++++++++++ .../nuxt-4/tests/database.test.ts | 197 +++++++++++++++ packages/nuxt/src/module.ts | 2 + .../src/runtime/plugins/database.server.ts | 232 ++++++++++++++++++ .../src/runtime/utils/database-span-data.ts | 46 ++++ packages/nuxt/src/vite/databaseConfig.ts | 38 +++ .../runtime/utils/database-span-data.test.ts | 199 +++++++++++++++ 17 files changed, 1605 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts create mode 100644 packages/nuxt/src/runtime/plugins/database.server.ts create mode 100644 packages/nuxt/src/runtime/utils/database-span-data.ts create mode 100644 packages/nuxt/src/vite/databaseConfig.ts create mode 100644 packages/nuxt/test/runtime/utils/database-span-data.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts index 8ea55702863c..8f920a41e76e 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -12,6 +12,23 @@ export default defineNuxtConfig({ }, }, nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, storage: { 'test-storage': { driver: 'memory', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json index b38943d6e3eb..bbf0ced23c12 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -33,6 +33,7 @@ ] }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" } } diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts new file mode 100644 index 000000000000..383617421ec7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts new file mode 100644 index 000000000000..2241afdee14d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { defineEventHandler, getQuery, useDatabase } from '#imports'; + +export default defineEventHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts new file mode 100644 index 000000000000..a229b4db34cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts new file mode 100644 index 000000000000..ecb0e32133db --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-3', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-3', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts index 50924877649d..c7acd2b12328 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts @@ -13,7 +13,6 @@ export default defineNuxtConfig({ }, modules: ['@pinia/nuxt', '@sentry/nuxt/module'], - runtimeConfig: { public: { sentry: { @@ -22,6 +21,23 @@ export default defineNuxtConfig({ }, }, nitro: { + experimental: { + database: true, + }, + database: { + default: { + connector: 'sqlite', + options: { name: 'db' }, + }, + users: { + connector: 'sqlite', + options: { name: 'users_db' }, + }, + analytics: { + connector: 'sqlite', + options: { name: 'analytics_db' }, + }, + }, storage: { 'test-storage': { driver: 'memory', diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json index b16b7ee2b236..a5d36c1f6a61 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/package.json +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/package.json @@ -25,7 +25,8 @@ "@sentry-internal/test-utils": "link:../../../test-utils" }, "volta": { - "extends": "../../package.json" + "extends": "../../package.json", + "node": "22.20.0" }, "sentryTest": { "optionalVariants": [ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts new file mode 100644 index 000000000000..53f110c1ce28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-multi-test.ts @@ -0,0 +1,102 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'default-db': { + // Test default database instance + const db = useDatabase(); + await db.exec('CREATE TABLE IF NOT EXISTS default_table (id INTEGER PRIMARY KEY, data TEXT)'); + await db.exec(`INSERT OR REPLACE INTO default_table (id, data) VALUES (1, 'default data')`); + const stmt = db.prepare('SELECT * FROM default_table WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'default', result }; + } + + case 'users-db': { + // Test named database instance 'users' + const usersDb = useDatabase('users'); + await usersDb.exec( + 'CREATE TABLE IF NOT EXISTS user_profiles (id INTEGER PRIMARY KEY, username TEXT, email TEXT)', + ); + await usersDb.exec( + `INSERT OR REPLACE INTO user_profiles (id, username, email) VALUES (1, 'john_doe', 'john@example.com')`, + ); + const stmt = usersDb.prepare('SELECT * FROM user_profiles WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'users', result }; + } + + case 'analytics-db': { + // Test named database instance 'analytics' + const analyticsDb = useDatabase('analytics'); + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS events (id INTEGER PRIMARY KEY, event_name TEXT, count INTEGER)', + ); + await analyticsDb.exec(`INSERT OR REPLACE INTO events (id, event_name, count) VALUES (1, 'page_view', 100)`); + const stmt = analyticsDb.prepare('SELECT * FROM events WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, database: 'analytics', result }; + } + + case 'multiple-dbs': { + // Test operations across multiple databases in a single request + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + const analyticsDb = useDatabase('analytics'); + + // Create tables and insert data in all databases + await defaultDb.exec('CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY, token TEXT)'); + await defaultDb.exec(`INSERT OR REPLACE INTO sessions (id, token) VALUES (1, 'session-token-123')`); + + await usersDb.exec('CREATE TABLE IF NOT EXISTS accounts (id INTEGER PRIMARY KEY, account_name TEXT)'); + await usersDb.exec(`INSERT OR REPLACE INTO accounts (id, account_name) VALUES (1, 'Premium Account')`); + + await analyticsDb.exec( + 'CREATE TABLE IF NOT EXISTS metrics (id INTEGER PRIMARY KEY, metric_name TEXT, value REAL)', + ); + await analyticsDb.exec( + `INSERT OR REPLACE INTO metrics (id, metric_name, value) VALUES (1, 'conversion_rate', 0.25)`, + ); + + // Query from all databases + const sessionResult = await defaultDb.prepare('SELECT * FROM sessions WHERE id = ?').get(1); + const accountResult = await usersDb.prepare('SELECT * FROM accounts WHERE id = ?').get(1); + const metricResult = await analyticsDb.prepare('SELECT * FROM metrics WHERE id = ?').get(1); + + return { + success: true, + results: { + default: sessionResult, + users: accountResult, + analytics: metricResult, + }, + }; + } + + case 'sql-template-multi': { + // Test SQL template tag across multiple databases + const defaultDb = useDatabase(); + const usersDb = useDatabase('users'); + + await defaultDb.exec('CREATE TABLE IF NOT EXISTS logs (id INTEGER PRIMARY KEY, message TEXT)'); + await usersDb.exec('CREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY, action TEXT)'); + + const defaultResult = await defaultDb.sql`INSERT INTO logs (message) VALUES (${'test message'})`; + const usersResult = await usersDb.sql`INSERT INTO audit_logs (action) VALUES (${'user_login'})`; + + return { + success: true, + results: { + default: defaultResult, + users: usersResult, + }, + }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts new file mode 100644 index 000000000000..4460758ab414 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/server/api/db-test.ts @@ -0,0 +1,70 @@ +import { useDatabase, defineEventHandler, getQuery } from '#imports'; + +export default defineEventHandler(async event => { + const db = useDatabase(); + const query = getQuery(event); + const method = query.method as string; + + switch (method) { + case 'prepare-get': { + await db.exec('CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)'); + await db.exec(`INSERT OR REPLACE INTO users (id, name, email) VALUES (1, 'Test User', 'test@example.com')`); + const stmt = db.prepare('SELECT * FROM users WHERE id = ?'); + const result = await stmt.get(1); + return { success: true, result }; + } + + case 'prepare-all': { + await db.exec('CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY, name TEXT, price REAL)'); + await db.exec(`INSERT OR REPLACE INTO products (id, name, price) VALUES + (1, 'Product A', 10.99), + (2, 'Product B', 20.50), + (3, 'Product C', 15.25)`); + const stmt = db.prepare('SELECT * FROM products WHERE price > ?'); + const results = await stmt.all(10); + return { success: true, count: results.length, results }; + } + + case 'prepare-run': { + await db.exec('CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, customer TEXT, amount REAL)'); + const stmt = db.prepare('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + const result = await stmt.run('John Doe', 99.99); + return { success: true, result }; + } + + case 'prepare-bind': { + await db.exec('CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, category TEXT, value INTEGER)'); + await db.exec(`INSERT OR REPLACE INTO items (id, category, value) VALUES + (1, 'electronics', 100), + (2, 'books', 50), + (3, 'electronics', 200)`); + const stmt = db.prepare('SELECT * FROM items WHERE category = ?'); + const boundStmt = stmt.bind('electronics'); + const results = await boundStmt.all(); + return { success: true, count: results.length, results }; + } + + case 'sql': { + await db.exec('CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY, content TEXT, created_at TEXT)'); + const timestamp = new Date().toISOString(); + const results = await db.sql`INSERT INTO messages (content, created_at) VALUES (${'Hello World'}, ${timestamp})`; + return { success: true, results }; + } + + case 'exec': { + await db.exec('DROP TABLE IF EXISTS logs'); + await db.exec('CREATE TABLE logs (id INTEGER PRIMARY KEY, message TEXT, level TEXT)'); + const result = await db.exec(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + return { success: true, result }; + } + + case 'error': { + const stmt = db.prepare('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + await stmt.get(1); + return { success: false, message: 'Should have thrown an error' }; + } + + default: + return { error: 'Unknown method' }; + } +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts new file mode 100644 index 000000000000..9d995fa1b37c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database-multi.test.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('multiple database instances', () => { + test('instruments default database instance', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=default-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM default_table')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (users)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=users-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from users database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM user_profiles')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments named database instance (analytics)', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=analytics-db'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have the SELECT span from analytics database + const selectSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM events')); + expect(selectSpan).toBeDefined(); + expect(selectSpan?.op).toBe('db.query'); + expect(selectSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(selectSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments multiple database instances in single request', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have spans from all three databases + const sessionSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM sessions')); + const accountSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM accounts')); + const metricSpan = dbSpans?.find(span => span.description?.includes('SELECT * FROM metrics')); + + expect(sessionSpan).toBeDefined(); + expect(sessionSpan?.op).toBe('db.query'); + expect(sessionSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(accountSpan).toBeDefined(); + expect(accountSpan?.op).toBe('db.query'); + expect(accountSpan?.data?.['db.system.name']).toBe('sqlite'); + + expect(metricSpan).toBeDefined(); + expect(metricSpan?.op).toBe('db.query'); + expect(metricSpan?.data?.['db.system.name']).toBe('sqlite'); + + // All should have the same origin + expect(sessionSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(accountSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(metricSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('instruments SQL template tag across multiple databases', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=sql-template-multi'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThan(0); + + // Check that we have INSERT spans from both databases + const logsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO logs')); + const auditLogsInsertSpan = dbSpans?.find(span => span.description?.includes('INSERT INTO audit_logs')); + + expect(logsInsertSpan).toBeDefined(); + expect(logsInsertSpan?.op).toBe('db.query'); + expect(logsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(logsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + + expect(auditLogsInsertSpan).toBeDefined(); + expect(auditLogsInsertSpan?.op).toBe('db.query'); + expect(auditLogsInsertSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(auditLogsInsertSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('creates correct span count for multiple database operations', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-multi-test'; + }); + + await request.get('/api/db-multi-test?method=multiple-dbs'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + // We should have multiple spans: + // - 3 CREATE TABLE (exec) spans + // - 3 INSERT (exec) spans + // - 3 SELECT (prepare + get) spans + // Total should be at least 9 spans + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(9); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts new file mode 100644 index 000000000000..9b9fdd892563 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/database.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test.describe('database integration', () => { + test('captures db.prepare().get() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find(span => span.op === 'db.query' && span.description?.includes('SELECT')); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM users WHERE id = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-all'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM products'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM products WHERE price > ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().run() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-run'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO orders'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('INSERT INTO orders (customer, amount) VALUES (?, ?)'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.prepare().bind().all() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-bind'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM items'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM items WHERE category = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.sql template tag span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=sql'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO messages'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toContain('INSERT INTO messages'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures db.exec() span', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('INSERT INTO logs'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + }); + + test('captures database error and marks span as failed', async ({ request }) => { + const errorPromise = waitForError('nuxt-4', errorEvent => { + return !!errorEvent?.exception?.values?.[0]?.value?.includes('no such table'); + }); + + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=error').catch(() => { + // Expected to fail + }); + + const [error, transaction] = await Promise.all([errorPromise, transactionPromise]); + + expect(error).toBeDefined(); + expect(error.exception?.values?.[0]?.value).toContain('no such table'); + expect(error.exception?.values?.[0]?.mechanism).toEqual({ + handled: false, + type: 'auto.db.nuxt', + }); + + const dbSpan = transaction.spans?.find( + span => span.op === 'db.query' && span.description?.includes('SELECT * FROM nonexistent_table'), + ); + + expect(dbSpan).toBeDefined(); + expect(dbSpan?.op).toBe('db.query'); + expect(dbSpan?.description).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['db.system.name']).toBe('sqlite'); + expect(dbSpan?.data?.['db.query.text']).toBe('SELECT * FROM nonexistent_table WHERE invalid_column = ?'); + expect(dbSpan?.data?.['sentry.origin']).toBe('auto.db.nuxt'); + expect(dbSpan?.status).toBe('internal_error'); + }); + + test('captures breadcrumb for db.exec() queries', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=exec'); + + const transaction = await transactionPromise; + + const dbBreadcrumb = transaction.breadcrumbs?.find( + breadcrumb => breadcrumb.category === 'query' && breadcrumb.message?.includes('INSERT INTO logs'), + ); + + expect(dbBreadcrumb).toBeDefined(); + expect(dbBreadcrumb?.category).toBe('query'); + expect(dbBreadcrumb?.message).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + expect(dbBreadcrumb?.data?.['db.query.text']).toBe(`INSERT INTO logs (message, level) VALUES ('Test log', 'INFO')`); + }); + + test('multiple database operations in single request create multiple spans', async ({ request }) => { + const transactionPromise = waitForTransaction('nuxt-4', transactionEvent => { + return transactionEvent.transaction === 'GET /api/db-test'; + }); + + await request.get('/api/db-test?method=prepare-get'); + + const transaction = await transactionPromise; + + const dbSpans = transaction.spans?.filter(span => span.op === 'db.query'); + + expect(dbSpans).toBeDefined(); + expect(dbSpans!.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 9e85209eeb55..3656eac56e63 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -10,6 +10,7 @@ import { consoleSandbox } from '@sentry/core'; import * as path from 'path'; import type { SentryNuxtModuleOptions } from './common/types'; import { addDynamicImportEntryFileWrapper, addSentryTopImport, addServerConfigToBuild } from './vite/addServerConfig'; +import { addDatabaseInstrumentation } from './vite/databaseConfig'; import { addMiddlewareImports, addMiddlewareInstrumentation } from './vite/middlewareConfig'; import { setupSourceMaps } from './vite/sourceMaps'; import { addStorageInstrumentation } from './vite/storageConfig'; @@ -130,6 +131,7 @@ export default defineNuxtModule({ if (serverConfigFile) { addMiddlewareImports(); addStorageInstrumentation(nuxt); + addDatabaseInstrumentation(nuxt.options.nitro); } nuxt.hooks.hook('nitro:init', nitro => { diff --git a/packages/nuxt/src/runtime/plugins/database.server.ts b/packages/nuxt/src/runtime/plugins/database.server.ts new file mode 100644 index 000000000000..9cdff58d336e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/database.server.ts @@ -0,0 +1,232 @@ +import { + type Span, + type StartSpanOptions, + addBreadcrumb, + captureException, + debug, + flushIfServerless, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startSpan, +} from '@sentry/core'; +import type { Database, PreparedStatement } from 'db0'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { defineNitroPlugin, useDatabase } from 'nitropack/runtime'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +// @ts-expect-error - This is a virtual module +import { databaseConfig } from '#sentry/database-config.mjs'; +import { type DatabaseSpanData, getDatabaseSpanData } from '../utils/database-span-data'; + +type MaybeInstrumentedDatabase = Database & { + __sentry_instrumented__?: boolean; +}; + +/** + * Keeps track of prepared statements that have been patched. + */ +const patchedStatement = new WeakSet(); + +/** + * The Sentry origin for the database plugin. + */ +const SENTRY_ORIGIN = 'auto.db.nuxt'; + +/** + * Creates a Nitro plugin that instruments the database calls. + */ +export default defineNitroPlugin(() => { + try { + const _databaseConfig = databaseConfig as Record; + const databaseInstances = Object.keys(databaseConfig); + debug.log('[Nitro Database Plugin]: Instrumenting databases...'); + + for (const instance of databaseInstances) { + debug.log('[Nitro Database Plugin]: Instrumenting database instance:', instance); + const db = useDatabase(instance); + instrumentDatabase(db, _databaseConfig[instance]); + } + + debug.log('[Nitro Database Plugin]: Databases instrumented.'); + } catch (error) { + // During build time, we can't use the useDatabase function, so we just log an error. + if (error instanceof Error && /Cannot access 'instances'/.test(error.message)) { + debug.log('[Nitro Database Plugin]: Database instrumentation skipped during build time.'); + return; + } + + debug.error('[Nitro Database Plugin]: Failed to instrument database:', error); + } +}); + +/** + * Instruments a database instance with Sentry. + */ +function instrumentDatabase(db: MaybeInstrumentedDatabase, config?: DatabaseConfig): void { + if (db.__sentry_instrumented__) { + debug.log('[Nitro Database Plugin]: Database already instrumented. Skipping...'); + return; + } + + const metadata: DatabaseSpanData = { + 'db.system.name': config?.connector ?? db.dialect, + ...getDatabaseSpanData(config), + }; + + db.prepare = new Proxy(db.prepare, { + apply(target, thisArg, args: Parameters) { + const [query] = args; + + return instrumentPreparedStatement(target.apply(thisArg, args), query, metadata); + }, + }); + + // Sadly the `.sql` template tag doesn't call `db.prepare` internally and it calls the connector's `.prepare` directly + // So we have to patch it manually, and would mean we would have less info in the spans. + // https://github.com/unjs/db0/blob/main/src/database.ts#L64 + db.sql = new Proxy(db.sql, { + apply(target, thisArg, args: Parameters) { + const query = args[0]?.[0] ?? ''; + const opts = createStartSpanOptions(query, metadata); + + return startSpan( + opts, + handleSpanStart(() => target.apply(thisArg, args)), + ); + }, + }); + + db.exec = new Proxy(db.exec, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(args[0], metadata), + handleSpanStart(() => target.apply(thisArg, args), { query: args[0] }), + ); + }, + }); + + db.__sentry_instrumented__ = true; +} + +/** + * Instruments a DB prepared statement with Sentry. + * + * This is meant to be used as a top-level call, under the hood it calls `instrumentPreparedStatementQueries` + * to patch the query methods. The reason for this abstraction is to ensure that the `bind` method is also patched. + */ +function instrumentPreparedStatement( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + // statement.bind() returns a new instance of D1PreparedStatement, so we have to patch it as well. + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.bind = new Proxy(statement.bind, { + apply(target, thisArg, args: Parameters) { + return instrumentPreparedStatementQueries(target.apply(thisArg, args), query, data); + }, + }); + + return instrumentPreparedStatementQueries(statement, query, data); +} + +/** + * Patches the query methods of a DB prepared statement with Sentry. + */ +function instrumentPreparedStatementQueries( + statement: PreparedStatement, + query: string, + data: DatabaseSpanData, +): PreparedStatement { + if (patchedStatement.has(statement)) { + return statement; + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.get = new Proxy(statement.get, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.run = new Proxy(statement.run, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + statement.all = new Proxy(statement.all, { + apply(target, thisArg, args: Parameters) { + return startSpan( + createStartSpanOptions(query, data), + handleSpanStart(() => target.apply(thisArg, args), { query }), + ); + }, + }); + + patchedStatement.add(statement); + + return statement; +} + +/** + * Creates a span start callback handler + */ +function handleSpanStart(fn: () => unknown, breadcrumbOpts?: { query: string }) { + return async (span: Span) => { + try { + const result = await fn(); + if (breadcrumbOpts) { + createBreadcrumb(breadcrumbOpts.query); + } + + return result; + } catch (error) { + span.setStatus({ code: SPAN_STATUS_ERROR, message: 'internal_error' }); + captureException(error, { + mechanism: { + handled: false, + type: SENTRY_ORIGIN, + }, + }); + + // Re-throw the error to be handled by the caller + throw error; + } finally { + await flushIfServerless(); + } + }; +} + +function createBreadcrumb(query: string): void { + addBreadcrumb({ + category: 'query', + message: query, + data: { + 'db.query.text': query, + }, + }); +} + +/** + * Creates a start span options object. + */ +function createStartSpanOptions(query: string, data: DatabaseSpanData): StartSpanOptions { + return { + name: query, + attributes: { + 'db.query.text': query, + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: SENTRY_ORIGIN, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'db.query', + ...data, + }, + }; +} diff --git a/packages/nuxt/src/runtime/utils/database-span-data.ts b/packages/nuxt/src/runtime/utils/database-span-data.ts new file mode 100644 index 000000000000..e5d9c8dc7cec --- /dev/null +++ b/packages/nuxt/src/runtime/utils/database-span-data.ts @@ -0,0 +1,46 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; + +export interface DatabaseSpanData { + [key: string]: string | number | undefined; +} + +/** + * Extracts span attributes from the database configuration. + */ +export function getDatabaseSpanData(config?: DatabaseConfig): Partial { + try { + if (!config?.connector) { + // Default to SQLite if no connector is configured + return { + 'db.namespace': 'db.sqlite', + }; + } + + if (config.connector === 'postgresql' || config.connector === 'mysql2') { + return { + 'server.address': config.options?.host, + 'server.port': config.options?.port, + }; + } + + if (config.connector === 'pglite') { + return { + 'db.namespace': config.options?.dataDir, + }; + } + + if ((['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[]).includes(config.connector)) { + return { + // DB is the default file name in nitro for sqlite-like connectors + 'db.namespace': `${config.options?.name ?? 'db'}.sqlite`, + }; + } + + return {}; + } catch { + // This is a best effort to get some attributes, so it is not an absolute must + // Since the user can configure invalid options, we should not fail the whole instrumentation. + return {}; + } +} diff --git a/packages/nuxt/src/vite/databaseConfig.ts b/packages/nuxt/src/vite/databaseConfig.ts new file mode 100644 index 000000000000..dfe27fd9821d --- /dev/null +++ b/packages/nuxt/src/vite/databaseConfig.ts @@ -0,0 +1,38 @@ +import { addServerPlugin, createResolver } from '@nuxt/kit'; +import { consoleSandbox } from '@sentry/core'; +import type { NitroConfig } from 'nitropack/types'; +import { addServerTemplate } from '../vendor/server-template'; + +/** + * Sets up the database instrumentation. + */ +export function addDatabaseInstrumentation(nitro: NitroConfig): void { + if (!nitro.experimental?.database) { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.log( + '[Sentry] [Nitro Database Plugin]: No database configuration found. Skipping database instrumentation.', + ); + }); + + return; + } + + /** + * This is a different option than the one in `experimental.database`, this configures multiple database instances. + * keys represent database names to be passed to `useDatabase(name?)`. + * We also use the config to populate database span attributes. + * https://nitro.build/guide/database#configuration + */ + const databaseConfig = nitro.database || { default: {} }; + + // Create a virtual module to pass this data to runtime + addServerTemplate({ + filename: '#sentry/database-config.mjs', + getContents: () => { + return `export const databaseConfig = ${JSON.stringify(databaseConfig)};`; + }, + }); + + addServerPlugin(createResolver(import.meta.url).resolve('./runtime/plugins/database.server')); +} diff --git a/packages/nuxt/test/runtime/utils/database-span-data.test.ts b/packages/nuxt/test/runtime/utils/database-span-data.test.ts new file mode 100644 index 000000000000..fc4f4b376af8 --- /dev/null +++ b/packages/nuxt/test/runtime/utils/database-span-data.test.ts @@ -0,0 +1,199 @@ +import type { ConnectorName } from 'db0'; +import type { DatabaseConnectionConfig as DatabaseConfig } from 'nitropack/types'; +import { describe, expect, it } from 'vitest'; +import { getDatabaseSpanData } from '../../../src/runtime/utils/database-span-data'; + +describe('getDatabaseSpanData', () => { + describe('no config', () => { + it('should return default SQLite namespace when no config provided', () => { + const result = getDatabaseSpanData(); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + + it('should return default SQLite namespace when config has no connector', () => { + const result = getDatabaseSpanData({} as DatabaseConfig); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }); + }); + + describe('PostgreSQL connector', () => { + it('should extract host and port for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'localhost', + port: 5432, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'localhost', + 'server.port': 5432, + }); + }); + + it('should handle missing options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + + it('should handle partial options for postgresql', () => { + const config: DatabaseConfig = { + connector: 'postgresql' as ConnectorName, + options: { + host: 'pg-host', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'pg-host', + 'server.port': undefined, + }); + }); + }); + + describe('MySQL connector', () => { + it('should extract host and port for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + options: { + host: 'mysql-host', + port: 3306, + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': 'mysql-host', + 'server.port': 3306, + }); + }); + + it('should handle missing options for mysql2', () => { + const config: DatabaseConfig = { + connector: 'mysql2' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'server.address': undefined, + 'server.port': undefined, + }); + }); + }); + + describe('PGLite connector', () => { + it('should extract dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: { + dataDir: '/path/to/data', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': '/path/to/data', + }); + }); + + it('should handle missing dataDir for pglite', () => { + const config: DatabaseConfig = { + connector: 'pglite' as ConnectorName, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': undefined, + }); + }); + }); + + describe('SQLite-like connectors', () => { + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should extract database name for %s', + connector => { + const config: DatabaseConfig = { + connector, + options: { + name: 'custom-db', + }, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'custom-db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should use default name for %s when name is not provided', + connector => { + const config: DatabaseConfig = { + connector, + options: {}, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + + it.each(['better-sqlite3', 'bun', 'sqlite', 'sqlite3'] as ConnectorName[])( + 'should handle missing options for %s', + connector => { + const config: DatabaseConfig = { + connector, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({ + 'db.namespace': 'db.sqlite', + }); + }, + ); + }); + + describe('unsupported connector', () => { + it('should return empty object for unsupported connector', () => { + const config: DatabaseConfig = { + connector: 'unknown-connector' as ConnectorName, + }; + + const result = getDatabaseSpanData(config); + expect(result).toEqual({}); + }); + }); + + describe('error handling', () => { + it('should return empty object when accessing invalid config throws', () => { + // Simulate a config that might throw during access + const invalidConfig = { + connector: 'postgresql' as ConnectorName, + get options(): never { + throw new Error('Invalid access'); + }, + }; + + const result = getDatabaseSpanData(invalidConfig as unknown as DatabaseConfig); + expect(result).toEqual({}); + }); + }); +}); From 24ecd3a81522cdb726a6c3f211b0d43c64d918af Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 17 Oct 2025 13:16:18 -0400 Subject: [PATCH 14/23] feat(replay): Record outcome when event buffer size exceeded (#17946) Change to record an outcome when failed to add to replay event buffer due to size limitations. This also moves up the `internal_sdk_error` outcome to be recorded before we stop the replay. Note we use the `buffer_overflow` outcome as it is the closest in meaning (source https://github.com/getsentry/snuba/blob/6c73be60716c2fb1c30ca627883207887c733cbd/rust_snuba/src/processors/outcomes.rs#L39) --- .size-limit.js | 2 +- packages/replay-internal/src/util/addEvent.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 17e33dd7ff21..5de4268a53d6 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -75,7 +75,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'replayCanvasIntegration'), gzip: true, - limit: '84 KB', + limit: '85 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback)', diff --git a/packages/replay-internal/src/util/addEvent.ts b/packages/replay-internal/src/util/addEvent.ts index a133d9de6303..0cd76227379c 100644 --- a/packages/replay-internal/src/util/addEvent.ts +++ b/packages/replay-internal/src/util/addEvent.ts @@ -83,6 +83,14 @@ async function _addEvent( } catch (error) { const isExceeded = error && error instanceof EventBufferSizeExceededError; const reason = isExceeded ? 'addEventSizeExceeded' : 'addEvent'; + const client = getClient(); + + if (client) { + // We are limited in the drop reasons: + // https://github.com/getsentry/snuba/blob/6c73be60716c2fb1c30ca627883207887c733cbd/rust_snuba/src/processors/outcomes.rs#L39 + const dropReason = isExceeded ? 'buffer_overflow' : 'internal_sdk_error'; + client.recordDroppedEvent(dropReason, 'replay'); + } if (isExceeded && isBufferMode) { // Clear buffer and wait for next checkout @@ -95,12 +103,6 @@ async function _addEvent( replay.handleException(error); await replay.stop({ reason }); - - const client = getClient(); - - if (client) { - client.recordDroppedEvent('internal_sdk_error', 'replay'); - } } } From 9742f9ebbf75bd0b7a8b4e1f5016d0f94e503765 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 20 Oct 2025 13:47:04 +0200 Subject: [PATCH 15/23] test(nextjs): Fix proxy/middleware test (#17970) closes https://github.com/getsentry/sentry-javascript/issues/17968 closes https://github.com/getsentry/sentry-javascript/issues/17967 --- .../test-applications/nextjs-16/tests/middleware.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts index a8096ab7bc69..4ed289eb6215 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/middleware.test.ts @@ -48,8 +48,11 @@ test('Faulty middlewares', async ({ request }) => { // Assert that isolation scope works properly expect(errorEvent.tags?.['my-isolated-tag']).toBe(true); expect(errorEvent.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); - // this differs between webpack and turbopack - expect(['middleware GET', '/middleware']).toContain(errorEvent.transaction); + expect([ + 'middleware GET', // non-otel webpack versions + '/middleware', // middleware file + '/proxy', // proxy file + ]).toContain(errorEvent.transaction); }); }); From f6645059133981ff225436dfb792c447b4092ecd Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Mon, 20 Oct 2025 10:18:01 -0400 Subject: [PATCH 16/23] chore(build): Upgrade nodemon to 3.1.10 (#17956) Got inspired at jsconf to do some dep upgrades. There are no breaking changes for nodemon that affect us when going from v2 -> v3: https://github.com/remy/nodemon/releases --- package.json | 2 +- yarn.lock | 328 +++++---------------------------------------------- 2 files changed, 31 insertions(+), 299 deletions(-) diff --git a/package.json b/package.json index de0b46add91b..0c3d47a3c7b3 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "jsdom": "^21.1.2", "lerna": "7.1.1", "madge": "7.0.0", - "nodemon": "^2.0.16", + "nodemon": "^3.1.10", "npm-run-all2": "^6.2.0", "prettier": "^3.6.2", "prettier-plugin-astro": "^0.14.1", diff --git a/yarn.lock b/yarn.lock index 3917862a705f..c0bc7ba27923 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7110,11 +7110,6 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== -"@sindresorhus/is@^0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" - integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ== - "@sindresorhus/is@^7.0.2": version "7.0.2" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-7.0.2.tgz#a0df078a8d29f9741503c5a9c302de474ec8564a" @@ -7921,13 +7916,6 @@ dependencies: tslib "^2.4.0" -"@szmarczak/http-timer@^1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" - integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA== - dependencies: - defer-to-connect "^1.0.1" - "@tanstack/history@1.132.21": version "1.132.21" resolved "https://registry.yarnpkg.com/@tanstack/history/-/history-1.132.21.tgz#09ae649b0c0c2d1093f0b1e34b9ab0cd3b2b1d2f" @@ -10379,7 +10367,7 @@ amqplib@^0.10.7: buffer-more-ints "~1.0.0" url-parse "~1.5.10" -ansi-align@^3.0.0, ansi-align@^3.0.1: +ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== @@ -11689,20 +11677,6 @@ bowser@^2.11.0: resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.11.0.tgz#5ca3c35757a7aa5771500c70a73a9f91ef420a8f" integrity sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA== -boxen@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" - integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.2" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - boxen@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/boxen/-/boxen-7.1.1.tgz#f9ba525413c2fec9cdb88987d835c4f7cad9c8f4" @@ -12460,19 +12434,6 @@ cache-base@^1.0.1: union-value "^1.0.0" unset-value "^1.0.0" -cacheable-request@^6.0.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912" - integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^3.0.0" - lowercase-keys "^2.0.0" - normalize-url "^4.1.0" - responselike "^1.0.2" - calculate-cache-key-for-tree@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/calculate-cache-key-for-tree/-/calculate-cache-key-for-tree-2.0.0.tgz#7ac57f149a4188eacb0a45b210689215d3fef8d6" @@ -12544,7 +12505,7 @@ camelcase@^5.3.1: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0, camelcase@^6.3.0: +camelcase@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -12744,11 +12705,6 @@ chrome-trace-event@^1.0.2: dependencies: tslib "^1.9.0" -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - ci-info@^3.2.0, ci-info@^3.4.0, ci-info@^3.6.1, ci-info@^3.7.0, ci-info@^3.8.0: version "3.9.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" @@ -12825,11 +12781,6 @@ clear@^0.1.0: resolved "https://registry.yarnpkg.com/clear/-/clear-0.1.0.tgz#b81b1e03437a716984fd7ac97c87d73bdfe7048a" integrity sha512-qMjRnoL+JDPJHeLePZJuao6+8orzHMGP04A8CdwCNsKhRbOnKRjefxONR7bwILT3MHecxKBjHkKL/tkZ8r4Uzw== -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - cli-boxes@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145" @@ -12933,13 +12884,6 @@ clone-deep@4.0.1, clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" -clone-response@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b" - integrity sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q== - dependencies: - mimic-response "^1.0.0" - clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -13961,10 +13905,10 @@ debug@3.1.0: dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== +debug@4, debug@^4, debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.3.7, debug@^4.4.0, debug@^4.4.1: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" @@ -14026,13 +13970,6 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" integrity sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ== -decompress-response@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3" - integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M= - dependencies: - mimic-response "^1.0.0" - decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -14152,11 +14089,6 @@ defaults@^1.0.3: dependencies: clone "^1.0.2" -defer-to-connect@^1.0.1: - version "1.1.3" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591" - integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ== - define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -14710,11 +14642,6 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -duplexer3@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" - integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= - duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -16151,11 +16078,6 @@ escalade@^3.1.1, escalade@^3.2.0: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" @@ -17791,7 +17713,7 @@ get-stream@6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== -get-stream@^4.0.0, get-stream@^4.1.0: +get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== @@ -18055,13 +17977,6 @@ global-directory@^4.0.1: dependencies: ini "4.1.1" -global-dirs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" - integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== - dependencies: - ini "2.0.0" - global-modules@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea" @@ -18211,23 +18126,6 @@ gopd@^1.0.1, gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -got@^9.6.0: - version "9.6.0" - resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85" - integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q== - dependencies: - "@sindresorhus/is" "^0.14.0" - "@szmarczak/http-timer" "^1.1.2" - cacheable-request "^6.0.0" - decompress-response "^3.3.0" - duplexer3 "^0.1.4" - get-stream "^4.1.0" - lowercase-keys "^1.0.1" - mimic-response "^1.0.1" - p-cancelable "^1.0.0" - to-readable-stream "^1.0.0" - url-parse-lax "^3.0.0" - graceful-fs@4.2.11, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.5, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -18443,11 +18341,6 @@ has-values@^1.0.0: is-number "^3.0.0" kind-of "^4.0.0" -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -18914,7 +18807,7 @@ htmlparser2@^6.1.0: domutils "^2.5.2" entities "^2.0.0" -http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: +http-cache-semantics@^4.1.0, http-cache-semantics@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== @@ -19178,11 +19071,6 @@ import-in-the-middle@^1.14.2, import-in-the-middle@^1.8.1: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== - import-local@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" @@ -19255,11 +19143,6 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@2.0.0, ini@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - ini@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1" @@ -19275,6 +19158,11 @@ ini@^1.3.2, ini@^1.3.4, ini@^1.3.5, ini@^1.3.8, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ini@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" + integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== + init-package-json@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-5.0.0.tgz#030cf0ea9c84cfc1b0dc2e898b45d171393e4b40" @@ -19543,13 +19431,6 @@ is-ci@3.0.1: dependencies: ci-info "^3.2.0" -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - is-core-module@^2.13.0, is-core-module@^2.16.0, is-core-module@^2.3.0, is-core-module@^2.5.0, is-core-module@^2.8.1, is-core-module@^2.9.0: version "2.16.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" @@ -19657,14 +19538,6 @@ is-inside-container@^1.0.0: dependencies: is-docker "^3.0.0" -is-installed-globally@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - is-installed-globally@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-1.0.0.tgz#08952c43758c33d815692392f7f8437b9e436d5a" @@ -19710,11 +19583,6 @@ is-negative-zero@^2.0.2: resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" integrity sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA== -is-npm@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" - integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== - is-number-object@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197" @@ -19742,11 +19610,6 @@ is-obj@^2.0.0: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-path-inside@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-4.0.0.tgz#805aeb62c47c1b12fc3fd13bfb3ed1e7430071db" @@ -19995,11 +19858,6 @@ is-wsl@^3.0.0, is-wsl@^3.1.0: dependencies: is-inside-container "^1.0.0" -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - is64bit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is64bit/-/is64bit-2.0.0.tgz#198c627cbcb198bbec402251f88e5e1a51236c07" @@ -20323,11 +20181,6 @@ json-bigint@^1.0.0: dependencies: bignumber.js "^9.0.0" -json-buffer@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898" - integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg= - json-parse-better-errors@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" @@ -20547,13 +20400,6 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" -keyv@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" - integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA== - dependencies: - json-buffer "3.0.0" - kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" @@ -20649,13 +20495,6 @@ language-tags@^1.0.5: dependencies: language-subtag-registry "~0.3.2" -latest-version@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" - launch-editor@^2.9.1: version "2.9.1" resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.9.1.tgz#253f173bd441e342d4344b4dae58291abb425047" @@ -21359,16 +21198,6 @@ lower-case@^2.0.2: dependencies: tslib "^2.0.3" -lowercase-keys@^1.0.0, lowercase-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f" - integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA== - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== - lru-cache@6.0.0, lru-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" @@ -22356,11 +22185,6 @@ mimic-fn@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc" integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw== -mimic-response@^1.0.0, mimic-response@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" @@ -23387,21 +23211,21 @@ node-watch@0.7.3: resolved "https://registry.yarnpkg.com/node-watch/-/node-watch-0.7.3.tgz#6d4db88e39c8d09d3ea61d6568d80e5975abc7ab" integrity sha512-3l4E8uMPY1HdMMryPRUAl+oIHtXtyiTlIiESNSVSNxcPfzAFzeTbXFQkZfAwBbo0B1qMSG8nUABx+Gd+YrbKrQ== -nodemon@^2.0.16: - version "2.0.16" - resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-2.0.16.tgz#d71b31bfdb226c25de34afea53486c8ef225fdef" - integrity sha512-zsrcaOfTWRuUzBn3P44RDliLlp263Z/76FPoHFr3cFFkOz0lTPAcIw8dCzfdVIx/t3AtDYCZRCDkoCojJqaG3w== +nodemon@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.10.tgz#5015c5eb4fffcb24d98cf9454df14f4fecec9bc1" + integrity sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw== dependencies: chokidar "^3.5.2" - debug "^3.2.7" + debug "^4" ignore-by-default "^1.0.1" - minimatch "^3.0.4" + minimatch "^3.1.2" pstree.remy "^1.1.8" - semver "^5.7.1" + semver "^7.5.3" + simple-update-notifier "^2.0.0" supports-color "^5.5.0" touch "^3.1.0" undefsafe "^2.0.5" - update-notifier "^5.1.0" nopt@^3.0.6: version "3.0.6" @@ -23497,11 +23321,6 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= -normalize-url@^4.1.0: - version "4.5.1" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" - integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== - npm-bundled@^1.1.1, npm-bundled@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1" @@ -24230,11 +24049,6 @@ osenv@^0.1.3: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -p-cancelable@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc" - integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw== - p-defer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" @@ -24441,16 +24255,6 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - package-manager-detector@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea" @@ -25800,11 +25604,6 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -prepend-http@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897" - integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc= - prettier-plugin-astro@^0.14.1: version "0.14.1" resolved "https://registry.yarnpkg.com/prettier-plugin-astro/-/prettier-plugin-astro-0.14.1.tgz#50bff8a659f2a6a4ff3b1d7ea73f2de93c95b213" @@ -26086,13 +25885,6 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -pupa@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - pure-rand@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" @@ -26765,20 +26557,6 @@ regextras@^0.7.1: resolved "https://registry.yarnpkg.com/regextras/-/regextras-0.7.1.tgz#be95719d5f43f9ef0b9fa07ad89b7c606995a3b2" integrity sha512-9YXf6xtW+qzQ+hcMQXx95MOvfqXFgsKDZodX3qZB0x2n5Z94ioetIITsBtvJbiOyxa/6s9AtyweBLCdPmPko/w== -registry-auth-token@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" - integrity sha512-6gkSb4U6aWJB4SF2ZvLb76yCBjcvufXBqvvEx1HbmKPkutswjW1xNVRY0+daljIYRbogN7O0etYSlbiaEQyMyw== - dependencies: - rc "^1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - regjsparser@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" @@ -27101,13 +26879,6 @@ resolve@^2.0.0-next.1, resolve@^2.0.0-next.3: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -responselike@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7" - integrity sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ== - dependencies: - lowercase-keys "^1.0.0" - restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -27647,13 +27418,6 @@ semver-compare@^1.0.0: resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" @@ -27666,7 +27430,7 @@ semver@7.5.3: dependencies: lru-cache "^6.0.0" -semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0, semver@^6.3.1: +semver@^6.0.0, semver@^6.1.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== @@ -28073,6 +27837,13 @@ simple-swizzle@^0.2.2: dependencies: is-arrayish "^0.3.1" +simple-update-notifier@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" + integrity sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w== + dependencies: + semver "^7.5.3" + sinon@19.0.2: version "19.0.2" resolved "https://registry.yarnpkg.com/sinon/-/sinon-19.0.2.tgz#944cf771d22236aa84fc1ab70ce5bffc3a215dad" @@ -28737,7 +28508,7 @@ string-template@~0.2.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -29645,11 +29416,6 @@ to-object-path@^0.3.0: dependencies: kind-of "^3.0.2" -to-readable-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771" - integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q== - to-regex-range@^2.1.0: version "2.1.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" @@ -30686,26 +30452,6 @@ update-browserslist-db@^1.1.1: escalade "^3.2.0" picocolors "^1.1.1" -update-notifier@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" - integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== - dependencies: - boxen "^5.0.0" - chalk "^4.1.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.4.0" - is-npm "^5.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.1.0" - pupa "^2.1.1" - semver "^7.3.4" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" - uqr@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/uqr/-/uqr-0.1.2.tgz#5c6cd5dcff9581f9bb35b982cb89e2c483a41d7d" @@ -30723,13 +30469,6 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= -url-parse-lax@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c" - integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww= - dependencies: - prepend-http "^2.0.0" - url-parse@^1.5.3, url-parse@~1.5.10: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" @@ -31779,13 +31518,6 @@ wide-align@^1.1.5: dependencies: string-width "^1.0.2 || 2 || 3 || 4" -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - widest-line@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-4.0.1.tgz#a0fc673aaba1ea6f0a0d35b3c2795c9a9cc2ebf2" From 910b40bb38635d1889a97f701c46c44fb3038b93 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 20 Oct 2025 20:00:25 +0200 Subject: [PATCH 17/23] fix(nextjs): Update bundler detection (#17976) Test were failing due to missing value injection, because the bundler was incorrectly detected. We can rely on `process.env.TURBOPACK` being set, confirmed this with Vercel. So this PR - simplifies the bundler detection by just checking the env var - brings back webpack dev tests closes https://linear.app/getsentry/issue/FE-618/webpack-breaks-instrumentation-for-dev-mode-in-next-16 --- .../test-applications/nextjs-16/package.json | 2 +- packages/nextjs/src/config/util.ts | 64 +---- .../nextjs/src/config/withSentryConfig.ts | 2 +- packages/nextjs/test/config/util.test.ts | 161 +---------- .../test/config/withSentryConfig.test.ts | 268 +++++------------- 5 files changed, 89 insertions(+), 408 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json index 3d1df82b1748..2da23b152807 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/package.json @@ -18,7 +18,7 @@ "test:build-canary": "pnpm install && pnpm add next@canary && pnpm build", "test:build-canary-webpack": "pnpm install && pnpm add next@canary && pnpm build-webpack", "test:assert": "pnpm test:prod && pnpm test:dev", - "test:assert-webpack": "pnpm test:prod" + "test:assert-webpack": "pnpm test:prod && pnpm test:dev-webpack" }, "dependencies": { "@sentry/nextjs": "latest || *", diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 8d2d7781230b..0970e9573ba9 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -109,66 +109,20 @@ export function supportsNativeDebugIds(version: string): boolean { } /** - * Checks if the current Next.js version uses Turbopack as the default bundler. - * Starting from Next.js 15.6.0-canary.38, turbopack became the default for `next build`. + * Determines which bundler is actually being used based on environment variables, + * and CLI flags. * - * @param version - Next.js version string to check. - * @returns true if the version uses Turbopack by default + * @returns 'turbopack' or 'webpack' */ -export function isTurbopackDefaultForVersion(version: string): boolean { - if (!version) { - return false; - } - - const { major, minor, prerelease } = parseSemver(version); - - if (major === undefined || minor === undefined) { - return false; - } +export function detectActiveBundler(): 'turbopack' | 'webpack' { + const turbopackEnv = process.env.TURBOPACK; - // Next.js 16+ uses turbopack by default - if (major >= 16) { - return true; - } + // Check if TURBOPACK env var is set to a truthy value (excluding falsy strings like 'false', '0', '') + const isTurbopackEnabled = turbopackEnv && turbopackEnv !== 'false' && turbopackEnv !== '0'; - // For Next.js 15, only canary versions 15.6.0-canary.40+ use turbopack by default - // Stable 15.x releases still use webpack by default - if (major === 15 && minor >= 6 && prerelease && prerelease.startsWith('canary.')) { - if (minor >= 7) { - return true; - } - const canaryNumber = parseInt(prerelease.split('.')[1] || '0', 10); - if (canaryNumber >= 40) { - return true; - } - } - - return false; -} - -/** - * Determines which bundler is actually being used based on environment variables, - * CLI flags, and Next.js version. - * - * @param nextJsVersion - The Next.js version string - * @returns 'turbopack', 'webpack', or undefined if it cannot be determined - */ -export function detectActiveBundler(nextJsVersion: string | undefined): 'turbopack' | 'webpack' | undefined { - if (process.env.TURBOPACK || process.argv.includes('--turbo')) { + if (isTurbopackEnabled || process.argv.includes('--turbo')) { return 'turbopack'; - } - - // Explicit opt-in to webpack via --webpack flag - if (process.argv.includes('--webpack')) { + } else { return 'webpack'; } - - // Fallback to version-based default behavior - if (nextJsVersion) { - const turbopackIsDefault = isTurbopackDefaultForVersion(nextJsVersion); - return turbopackIsDefault ? 'turbopack' : 'webpack'; - } - - // Unlikely but at this point, we just assume webpack for older behavior - return 'webpack'; } diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 31ea63f17a9c..1f3f14479656 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -261,7 +261,7 @@ function getFinalConfigObject( nextMajor = major; } - const activeBundler = detectActiveBundler(nextJsVersion); + const activeBundler = detectActiveBundler(); const isTurbopack = activeBundler === 'turbopack'; const isWebpack = activeBundler === 'webpack'; const isTurbopackSupported = supportsProductionCompileHook(nextJsVersion ?? ''); diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 55fd13cf5dc4..37e4079376cd 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -213,117 +213,6 @@ describe('util', () => { }); }); - describe('isTurbopackDefaultForVersion', () => { - describe('returns true for versions where turbopack is default', () => { - it.each([ - // Next.js 16+ stable versions - ['16.0.0', 'Next.js 16.0.0 stable'], - ['16.0.1', 'Next.js 16.0.1 stable'], - ['16.1.0', 'Next.js 16.1.0 stable'], - ['16.2.5', 'Next.js 16.2.5 stable'], - - // Next.js 16+ pre-release versions - ['16.0.0-rc.1', 'Next.js 16.0.0-rc.1'], - ['16.0.0-canary.1', 'Next.js 16.0.0-canary.1'], - ['16.1.0-beta.2', 'Next.js 16.1.0-beta.2'], - - // Next.js 17+ - ['17.0.0', 'Next.js 17.0.0'], - ['18.0.0', 'Next.js 18.0.0'], - ['20.0.0', 'Next.js 20.0.0'], - - // Next.js 15.6.0-canary.40+ (boundary case) - ['15.6.0-canary.40', 'Next.js 15.6.0-canary.40 (exact threshold)'], - ['15.6.0-canary.41', 'Next.js 15.6.0-canary.41'], - ['15.6.0-canary.42', 'Next.js 15.6.0-canary.42'], - ['15.6.0-canary.100', 'Next.js 15.6.0-canary.100'], - - // Next.js 15.7+ canary versions - ['15.7.0-canary.1', 'Next.js 15.7.0-canary.1'], - ['15.7.0-canary.50', 'Next.js 15.7.0-canary.50'], - ['15.8.0-canary.1', 'Next.js 15.8.0-canary.1'], - ['15.10.0-canary.1', 'Next.js 15.10.0-canary.1'], - ])('returns true for %s (%s)', version => { - expect(util.isTurbopackDefaultForVersion(version)).toBe(true); - }); - }); - - describe('returns false for versions where webpack is still default', () => { - it.each([ - // Next.js 15.6.0-canary.39 and below - ['15.6.0-canary.39', 'Next.js 15.6.0-canary.39 (just below threshold)'], - ['15.6.0-canary.36', 'Next.js 15.6.0-canary.36'], - ['15.6.0-canary.38', 'Next.js 15.6.0-canary.38'], - ['15.6.0-canary.0', 'Next.js 15.6.0-canary.0'], - - // Next.js 15.6.x stable releases (NOT canary) - ['15.6.0', 'Next.js 15.6.0 stable'], - ['15.6.1', 'Next.js 15.6.1 stable'], - ['15.6.2', 'Next.js 15.6.2 stable'], - ['15.6.10', 'Next.js 15.6.10 stable'], - - // Next.js 15.6.x rc releases (NOT canary) - ['15.6.0-rc.1', 'Next.js 15.6.0-rc.1'], - ['15.6.0-rc.2', 'Next.js 15.6.0-rc.2'], - - // Next.js 15.7+ stable releases (NOT canary) - ['15.7.0', 'Next.js 15.7.0 stable'], - ['15.8.0', 'Next.js 15.8.0 stable'], - ['15.10.0', 'Next.js 15.10.0 stable'], - - // Next.js 15.5 and below (all versions) - ['15.5.0', 'Next.js 15.5.0'], - ['15.5.0-canary.100', 'Next.js 15.5.0-canary.100'], - ['15.4.1', 'Next.js 15.4.1'], - ['15.0.0', 'Next.js 15.0.0'], - ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], - - // Next.js 14.x and below - ['14.2.0', 'Next.js 14.2.0'], - ['14.0.0', 'Next.js 14.0.0'], - ['14.0.0-canary.50', 'Next.js 14.0.0-canary.50'], - ['13.5.0', 'Next.js 13.5.0'], - ['13.0.0', 'Next.js 13.0.0'], - ['12.0.0', 'Next.js 12.0.0'], - ])('returns false for %s (%s)', version => { - expect(util.isTurbopackDefaultForVersion(version)).toBe(false); - }); - }); - - describe('edge cases', () => { - it.each([ - ['', 'empty string'], - ['invalid', 'invalid version string'], - ['15', 'missing minor and patch'], - ['15.6', 'missing patch'], - ['not.a.version', 'completely invalid'], - ['15.6.0-alpha.1', 'alpha prerelease (not canary)'], - ['15.6.0-beta.1', 'beta prerelease (not canary)'], - ])('returns false for %s (%s)', version => { - expect(util.isTurbopackDefaultForVersion(version)).toBe(false); - }); - }); - - describe('canary number parsing edge cases', () => { - it.each([ - ['15.6.0-canary.', 'canary with no number'], - ['15.6.0-canary.abc', 'canary with non-numeric value'], - ['15.6.0-canary.38.extra', 'canary with extra segments'], - ])('handles malformed canary versions: %s (%s)', version => { - // Should not throw, just return appropriate boolean - expect(() => util.isTurbopackDefaultForVersion(version)).not.toThrow(); - }); - - it('handles canary.40 exactly (boundary)', () => { - expect(util.isTurbopackDefaultForVersion('15.6.0-canary.40')).toBe(true); - }); - - it('handles canary.39 exactly (boundary)', () => { - expect(util.isTurbopackDefaultForVersion('15.6.0-canary.39')).toBe(false); - }); - }); - }); - describe('detectActiveBundler', () => { const originalArgv = process.argv; const originalEnv = process.env; @@ -341,52 +230,26 @@ describe('util', () => { it('returns turbopack when TURBOPACK env var is set', () => { process.env.TURBOPACK = '1'; - expect(util.detectActiveBundler('15.5.0')).toBe('turbopack'); - }); - - it('returns webpack when --webpack flag is present', () => { - process.argv.push('--webpack'); - expect(util.detectActiveBundler('16.0.0')).toBe('webpack'); - }); - - it('returns turbopack for Next.js 16+ by default', () => { - expect(util.detectActiveBundler('16.0.0')).toBe('turbopack'); - expect(util.detectActiveBundler('17.0.0')).toBe('turbopack'); - }); - - it('returns turbopack for Next.js 15.6.0-canary.40+', () => { - expect(util.detectActiveBundler('15.6.0-canary.40')).toBe('turbopack'); - expect(util.detectActiveBundler('15.6.0-canary.50')).toBe('turbopack'); + expect(util.detectActiveBundler()).toBe('turbopack'); }); - it('returns webpack for Next.js 15.6.0 stable', () => { - expect(util.detectActiveBundler('15.6.0')).toBe('webpack'); + it('returns turbopack when TURBOPACK env var is set to auto', () => { + process.env.TURBOPACK = 'auto'; + expect(util.detectActiveBundler()).toBe('turbopack'); }); - it('returns webpack for Next.js 15.5.x and below', () => { - expect(util.detectActiveBundler('15.5.0')).toBe('webpack'); - expect(util.detectActiveBundler('15.0.0')).toBe('webpack'); - expect(util.detectActiveBundler('14.2.0')).toBe('webpack'); + it('returns webpack when TURBOPACK env var is undefined', () => { + process.env.TURBOPACK = undefined; + expect(util.detectActiveBundler()).toBe('webpack'); }); - it('returns webpack when version is undefined', () => { - expect(util.detectActiveBundler(undefined)).toBe('webpack'); + it('returns webpack when TURBOPACK env var is false', () => { + process.env.TURBOPACK = 'false'; + expect(util.detectActiveBundler()).toBe('webpack'); }); - it('prioritizes TURBOPACK env var over version detection', () => { - process.env.TURBOPACK = '1'; - expect(util.detectActiveBundler('14.0.0')).toBe('turbopack'); - }); - - it('prioritizes --webpack flag over version detection', () => { - process.argv.push('--webpack'); - expect(util.detectActiveBundler('16.0.0')).toBe('webpack'); - }); - - it('prioritizes TURBOPACK env var over --webpack flag', () => { - process.env.TURBOPACK = '1'; - process.argv.push('--webpack'); - expect(util.detectActiveBundler('15.5.0')).toBe('turbopack'); + it('returns webpack when TURBOPACK env var is not set', () => { + expect(util.detectActiveBundler()).toBe('webpack'); }); }); }); diff --git a/packages/nextjs/test/config/withSentryConfig.test.ts b/packages/nextjs/test/config/withSentryConfig.test.ts index f1f46c6fc6f2..b67a05845a7e 100644 --- a/packages/nextjs/test/config/withSentryConfig.test.ts +++ b/packages/nextjs/test/config/withSentryConfig.test.ts @@ -269,7 +269,7 @@ describe('withSentryConfig', () => { }); }); - describe('bundler detection with version-based defaults', () => { + describe('bundler detection', () => { const originalTurbopack = process.env.TURBOPACK; const originalArgv = process.argv; @@ -284,192 +284,107 @@ describe('withSentryConfig', () => { process.argv = originalArgv; }); - describe('Next.js 16+ defaults to turbopack', () => { - it('uses turbopack config by default for Next.js 16.0.0', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses turbopack config by default for Next.js 17.0.0', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('17.0.0'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses webpack when --webpack flag is present on Next.js 16.0.0', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - process.argv.push('--webpack'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - - it('prioritizes TURBOPACK env var over --webpack flag', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - process.env.TURBOPACK = '1'; - process.argv.push('--webpack'); + it('uses webpack config by default when TURBOPACK env var is not set', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('Next.js 15.6.0-canary.40+ defaults to turbopack', () => { - it('uses turbopack config by default for 15.6.0-canary.40', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.40'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses turbopack config by default for 15.6.0-canary.50', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.50'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses turbopack config by default for 15.7.0-canary.1', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0-canary.1'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); - - it('uses webpack when --webpack flag is present on 15.6.0-canary.40', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.40'); - process.argv.push('--webpack'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - - it('uses webpack when --webpack flag is present on 15.7.0-canary.1', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0-canary.1'); - process.argv.push('--webpack'); + it('uses turbopack config when TURBOPACK env var is set (supported version)', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); }); - describe('Next.js 15.6.0-canary.37 and below defaults to webpack', () => { - it('uses webpack config by default for 15.6.0-canary.37', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.37'); + it('uses turbopack config when TURBOPACK env var is set (16.0.0)', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('16.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); - it('uses webpack config by default for 15.6.0-canary.1', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.1'); + it('skips webpack config when TURBOPACK env var is set, even with unsupported version', () => { + process.env.TURBOPACK = '1'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.0.0'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + // turbopack config won't be added when version is unsupported, + // but webpack config should still be skipped + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + expect(finalConfig.turbopack).toBeUndefined(); + }); - it('uses turbopack when TURBOPACK env var is set on 15.6.0-canary.37', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0-canary.37'); - process.env.TURBOPACK = '1'; + it('defaults to webpack when Next.js version cannot be determined and no TURBOPACK env var', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('Next.js 15.6.x stable releases default to webpack', () => { - it('uses webpack config by default for 15.6.0 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + it('uses turbopack when TURBOPACK env var is set even when version is undefined', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); + process.env.TURBOPACK = '1'; - it('uses webpack config by default for 15.6.1 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.1'); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeUndefined(); + }); - it('uses webpack config by default for 15.7.0 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.7.0'); + it('uses turbopack when TURBOPACK env var is truthy string', () => { + process.env.TURBOPACK = 'true'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); + expect(finalConfig.turbopack).toBeDefined(); + expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); + }); - it('uses turbopack when explicitly requested via env var on 15.6.0 stable', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.6.0'); - process.env.TURBOPACK = '1'; + it('uses webpack when TURBOPACK env var is empty string', () => { + process.env.TURBOPACK = ''; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeDefined(); - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - }); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('older Next.js versions default to webpack', () => { - it.each([['15.5.0'], ['15.0.0'], ['14.2.0'], ['13.5.0']])( - 'uses webpack config by default for Next.js %s', - version => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(version); + it('uses webpack when TURBOPACK env var is false string', () => { + process.env.TURBOPACK = 'false'; + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }, - ); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); + }); - it.each([['15.5.0-canary.100'], ['15.0.0-canary.1'], ['14.2.0-canary.50']])( - 'uses webpack config by default for Next.js %s canary', - version => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(version); + it('handles malformed version strings gracefully', () => { + vi.spyOn(util, 'getNextjsVersion').mockReturnValue('not.a.version'); - const finalConfig = materializeFinalNextConfig(exportedNextConfig); + const finalConfig = materializeFinalNextConfig(exportedNextConfig); - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }, - ); + expect(finalConfig.turbopack).toBeUndefined(); + expect(finalConfig.webpack).toBeInstanceOf(Function); }); - describe('warnings are shown for unsupported turbopack usage', () => { + describe('warnings for unsupported turbopack usage', () => { let consoleWarnSpy: ReturnType; beforeEach(() => { @@ -508,39 +423,6 @@ describe('withSentryConfig', () => { expect(consoleWarnSpy).not.toHaveBeenCalled(); }); }); - - describe('edge cases', () => { - it('defaults to webpack when Next.js version cannot be determined', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - - it('uses turbopack when TURBOPACK env var is set even when version is undefined', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue(undefined); - process.env.TURBOPACK = '1'; - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - // Note: turbopack config won't be added when version is undefined because - // isTurbopackSupported will be false, but webpack config should still be skipped - expect(finalConfig.webpack).toBe(exportedNextConfig.webpack); - // Turbopack config is only added when both isTurbopack AND isTurbopackSupported are true - expect(finalConfig.turbopack).toBeUndefined(); - }); - - it('handles malformed version strings gracefully', () => { - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('not.a.version'); - - const finalConfig = materializeFinalNextConfig(exportedNextConfig); - - expect(finalConfig.turbopack).toBeUndefined(); - expect(finalConfig.webpack).toBeInstanceOf(Function); - }); - }); }); describe('turbopack sourcemap configuration', () => { @@ -1411,24 +1293,6 @@ describe('withSentryConfig', () => { consoleWarnSpy.mockRestore(); }); - - it('warns when TURBOPACK=0 (truthy string) with unsupported version', () => { - process.env.TURBOPACK = '0'; - // @ts-expect-error - NODE_ENV is read-only in types but we need to set it for testing - process.env.NODE_ENV = 'development'; - vi.spyOn(util, 'getNextjsVersion').mockReturnValue('15.4.1'); - vi.spyOn(util, 'supportsProductionCompileHook').mockReturnValue(false); - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - materializeFinalNextConfig(exportedNextConfig); - - // Note: '0' is truthy in JavaScript, so this will trigger the warning - expect(consoleWarnSpy).toHaveBeenCalledWith( - expect.stringContaining('WARNING: You are using the Sentry SDK with Turbopack'), - ); - - consoleWarnSpy.mockRestore(); - }); }); describe('useRunAfterProductionCompileHook warning logic', () => { From 063ad998f24a605e95cc54d11a401c5ad8cdd8ad Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 21 Oct 2025 10:45:52 +0200 Subject: [PATCH 18/23] fix(nextjs): Don't set experimental instrumentation hook flag for next 16 (#17978) Updated the logic to determine if the instrumentation hook is required or not, as this was wrongly set in next.js 16 apps. closes https://github.com/getsentry/sentry-javascript/issues/17965 --- packages/nextjs/src/config/util.ts | 55 +++++++++++++ .../nextjs/src/config/withSentryConfig.ts | 58 ++++--------- packages/nextjs/test/config/util.test.ts | 82 +++++++++++++++++++ 3 files changed, 154 insertions(+), 41 deletions(-) diff --git a/packages/nextjs/src/config/util.ts b/packages/nextjs/src/config/util.ts index 0970e9573ba9..0d4a55687d2f 100644 --- a/packages/nextjs/src/config/util.ts +++ b/packages/nextjs/src/config/util.ts @@ -108,6 +108,61 @@ export function supportsNativeDebugIds(version: string): boolean { return false; } +/** + * Checks if the given Next.js version requires the `experimental.instrumentationHook` option. + * Next.js 15.0.0 and higher (including certain RC and canary versions) no longer require this option + * and will print a warning if it is set. + * + * @param version - version string to check. + * @returns true if the version requires the instrumentationHook option to be set + */ +export function requiresInstrumentationHook(version: string): boolean { + if (!version) { + return true; // Default to requiring it if version cannot be determined + } + + const { major, minor, patch, prerelease } = parseSemver(version); + + if (major === undefined || minor === undefined || patch === undefined) { + return true; // Default to requiring it if parsing fails + } + + // Next.js 16+ never requires the hook + if (major >= 16) { + return false; + } + + // Next.js 14 and below always require the hook + if (major < 15) { + return true; + } + + // At this point, we know it's Next.js 15.x.y + // Stable releases (15.0.0+) don't require the hook + if (!prerelease) { + return false; + } + + // Next.js 15.x.y with x > 0 or y > 0 don't require the hook + if (minor > 0 || patch > 0) { + return false; + } + + // Check specific prerelease versions that don't require the hook + if (prerelease.startsWith('rc.')) { + const rcNumber = parseInt(prerelease.split('.')[1] || '0', 10); + return rcNumber === 0; // Only rc.0 requires the hook + } + + if (prerelease.startsWith('canary.')) { + const canaryNumber = parseInt(prerelease.split('.')[1] || '0', 10); + return canaryNumber < 124; // canary.124+ doesn't require the hook + } + + // All other 15.0.0 prerelease versions (alpha, beta, etc.) require the hook + return true; +} + /** * Determines which bundler is actually being used based on environment variables, * and CLI flags. diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 1f3f14479656..7ac61d73aa73 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -16,7 +16,12 @@ import type { SentryBuildOptions, TurbopackOptions, } from './types'; -import { detectActiveBundler, getNextjsVersion, supportsProductionCompileHook } from './util'; +import { + detectActiveBundler, + getNextjsVersion, + requiresInstrumentationHook, + supportsProductionCompileHook, +} from './util'; import { constructWebpackConfigFunction } from './webpack'; let showedExportModeTunnelWarning = false; @@ -178,47 +183,18 @@ function getFinalConfigObject( // From Next.js version (15.0.0-canary.124) onwards, Next.js does no longer require the `experimental.instrumentationHook` option and will // print a warning when it is set, so we need to conditionally provide it for lower versions. - if (nextJsVersion) { - const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); - const isFullySupportedRelease = - major !== undefined && - minor !== undefined && - patch !== undefined && - major >= 15 && - ((minor === 0 && patch === 0 && prerelease === undefined) || minor > 0 || patch > 0); - const isSupportedV15Rc = - major !== undefined && - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('rc.') && - parseInt(prerelease.split('.')[1] || '', 10) > 0; - const isSupportedCanary = - minor !== undefined && - patch !== undefined && - prerelease !== undefined && - major === 15 && - minor === 0 && - patch === 0 && - prerelease.startsWith('canary.') && - parseInt(prerelease.split('.')[1] || '', 10) >= 124; - - if (!isFullySupportedRelease && !isSupportedV15Rc && !isSupportedCanary) { - if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { - // eslint-disable-next-line no-console - console.warn( - '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', - ); - } - incomingUserNextConfigObject.experimental = { - instrumentationHook: true, - ...incomingUserNextConfigObject.experimental, - }; + if (nextJsVersion && requiresInstrumentationHook(nextJsVersion)) { + if (incomingUserNextConfigObject.experimental?.instrumentationHook === false) { + // eslint-disable-next-line no-console + console.warn( + '[@sentry/nextjs] You turned off the `experimental.instrumentationHook` option. Note that Sentry will not be initialized if you did not set it up inside `instrumentation.(js|ts)`.', + ); } - } else { + incomingUserNextConfigObject.experimental = { + instrumentationHook: true, + ...incomingUserNextConfigObject.experimental, + }; + } else if (!nextJsVersion) { // If we cannot detect a Next.js version for whatever reason, the sensible default is to set the `experimental.instrumentationHook`, even though it may create a warning. if ( incomingUserNextConfigObject.experimental && diff --git a/packages/nextjs/test/config/util.test.ts b/packages/nextjs/test/config/util.test.ts index 37e4079376cd..7335139b5037 100644 --- a/packages/nextjs/test/config/util.test.ts +++ b/packages/nextjs/test/config/util.test.ts @@ -213,6 +213,88 @@ describe('util', () => { }); }); + describe('requiresInstrumentationHook', () => { + describe('versions that do NOT require the hook (returns false)', () => { + it.each([ + // Fully supported releases (15.0.0 or higher) + ['15.0.0', 'Next.js 15.0.0'], + ['15.0.1', 'Next.js 15.0.1'], + ['15.1.0', 'Next.js 15.1.0'], + ['15.2.0', 'Next.js 15.2.0'], + ['16.0.0', 'Next.js 16.0.0'], + ['17.0.0', 'Next.js 17.0.0'], + ['20.0.0', 'Next.js 20.0.0'], + + // Supported v15.0.0-rc.1 or higher + ['15.0.0-rc.1', 'Next.js 15.0.0-rc.1'], + ['15.0.0-rc.2', 'Next.js 15.0.0-rc.2'], + ['15.0.0-rc.5', 'Next.js 15.0.0-rc.5'], + ['15.0.0-rc.100', 'Next.js 15.0.0-rc.100'], + + // Supported v15.0.0-canary.124 or higher + ['15.0.0-canary.124', 'Next.js 15.0.0-canary.124 (exact threshold)'], + ['15.0.0-canary.125', 'Next.js 15.0.0-canary.125'], + ['15.0.0-canary.130', 'Next.js 15.0.0-canary.130'], + ['15.0.0-canary.200', 'Next.js 15.0.0-canary.200'], + + // Next.js 16+ prerelease versions (all supported) + ['16.0.0-beta.0', 'Next.js 16.0.0-beta.0'], + ['16.0.0-beta.1', 'Next.js 16.0.0-beta.1'], + ['16.0.0-rc.0', 'Next.js 16.0.0-rc.0'], + ['16.0.0-rc.1', 'Next.js 16.0.0-rc.1'], + ['16.0.0-canary.1', 'Next.js 16.0.0-canary.1'], + ['16.0.0-alpha.1', 'Next.js 16.0.0-alpha.1'], + ['17.0.0-canary.1', 'Next.js 17.0.0-canary.1'], + ])('returns false for %s (%s)', version => { + expect(util.requiresInstrumentationHook(version)).toBe(false); + }); + }); + + describe('versions that DO require the hook (returns true)', () => { + it.each([ + // Next.js 14 and below + ['14.2.0', 'Next.js 14.2.0'], + ['14.0.0', 'Next.js 14.0.0'], + ['13.5.0', 'Next.js 13.5.0'], + ['12.0.0', 'Next.js 12.0.0'], + + // Unsupported v15.0.0-rc.0 + ['15.0.0-rc.0', 'Next.js 15.0.0-rc.0'], + + // Unsupported v15.0.0-canary versions below 124 + ['15.0.0-canary.123', 'Next.js 15.0.0-canary.123'], + ['15.0.0-canary.100', 'Next.js 15.0.0-canary.100'], + ['15.0.0-canary.50', 'Next.js 15.0.0-canary.50'], + ['15.0.0-canary.1', 'Next.js 15.0.0-canary.1'], + ['15.0.0-canary.0', 'Next.js 15.0.0-canary.0'], + + // Other prerelease versions + ['15.0.0-alpha.1', 'Next.js 15.0.0-alpha.1'], + ['15.0.0-beta.1', 'Next.js 15.0.0-beta.1'], + ])('returns true for %s (%s)', version => { + expect(util.requiresInstrumentationHook(version)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('returns true for empty string', () => { + expect(util.requiresInstrumentationHook('')).toBe(true); + }); + + it('returns true for invalid version strings', () => { + expect(util.requiresInstrumentationHook('invalid.version')).toBe(true); + }); + + it('returns true for versions missing patch number', () => { + expect(util.requiresInstrumentationHook('15.4')).toBe(true); + }); + + it('returns true for versions missing minor number', () => { + expect(util.requiresInstrumentationHook('15')).toBe(true); + }); + }); + }); + describe('detectActiveBundler', () => { const originalArgv = process.argv; const originalEnv = process.env; From 1bd76c0d6c1c2cda7e6d507112d44e65ad7082ea Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 21 Oct 2025 11:13:57 +0200 Subject: [PATCH 19/23] fix(ember): Use updated version for `clean-css` (#17979) [Ember E2E tests fail](https://github.com/getsentry/sentry-javascript/actions/runs/18656635954/job/53191164591) with `util.isRegExp is not a function`. Apparently, this is a JSDoc/Node issue that was already resolved last year: https://github.com/jsdoc/jsdoc/issues/2126 It is happening since the tests run with Node 24.10.0 instead of 22.20.0. The `isRegExp` API was removed from Node, so I updated the `clean-css` package as it's mentioned in the error stack. However, it could be that we either need to update `ember-cli` or just downgrade the node version for this test. --- .../e2e-tests/test-applications/ember-classic/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/ember-classic/package.json b/dev-packages/e2e-tests/test-applications/ember-classic/package.json index 260a8f8032ae..949b2b05f816 100644 --- a/dev-packages/e2e-tests/test-applications/ember-classic/package.json +++ b/dev-packages/e2e-tests/test-applications/ember-classic/package.json @@ -75,7 +75,8 @@ "node": ">=18" }, "resolutions": { - "@babel/traverse": "~7.25.9" + "@babel/traverse": "~7.25.9", + "clean-css": "^5.3.0" }, "ember": { "edition": "octane" From d551d23f508468faaec4884d948671e8dc872939 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:05:06 +0200 Subject: [PATCH 20/23] feat(browserProfiling): Add `trace` lifecycle mode for UI profiling (#17619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `trace` lifecycle mode and sends `profile_chunk` envelopes. Also adds test for either overlapping root spans (one chunk) or single root spans (multiple chunks). The "manual" mode comes in another PR to keep this from growing too large. **Browser trace-lifecycle profiler (v2):** - Starts when the first sampled root span starts - Stops when the last sampled root span ends - While running, periodically stops and restarts the JS self-profiling API to collect chunks **Profiles are emitted as standalone `profile_chunk` envelopes either when:** - there are no more sampled root spans, or - the 60s chunk timer elapses while profiling is running. **Handling never-ending root spans** In the trace lifecycle, profiling continues as long as a root span is active. To prevent profiling endlessly, each root span has its own profile timeout and is terminated if it is too long (5 minutes). If another root span is still active, profiling will continue regardless. part of https://github.com/getsentry/sentry-javascript/issues/17279 --- > [!NOTE] > Adds UI profiling trace lifecycle mode that samples sessions, streams profile_chunk envelopes, and attaches thread data, with accompanying tests and type options. > > - **Browser Profiling (UI Profiling v2)**: > - Add `profileLifecycle: 'trace'` with session sampling via `profileSessionSampleRate`; defaults lifecycle to `manual` when unspecified. > - Stream profiling as `profile_chunk` envelopes; periodic chunking (60s) and 5‑min root-span timeout. > - New `BrowserTraceLifecycleProfiler` manages start/stop across root spans and chunk sending. > - Attach profiled thread data to events/spans; warn if trace mode without tracing. > - **Profiling Utils**: > - Convert JS self profile to continuous format; validate chunks; main/worker thread constants; helper to attach thread info. > - Split legacy logic: `hasLegacyProfiling`, `shouldProfileSpanLegacy`, `shouldProfileSession`. > - **Integration Changes**: > - Browser integration branches between legacy and trace lifecycle; adds `processEvent` to attach thread data. > - Minor fix in `startProfileForSpan` (processed profile handling). > - **Tests**: > - Add Playwright suites for trace lifecycle (multiple chunks, overlapping spans) and adjust legacy tests. > - Add unit tests for lifecycle behavior, warnings, profiler_id reuse, and option defaults. > - **Types/Config**: > - Extend `BrowserClientProfilingOptions` with `profileSessionSampleRate` and `profileLifecycle`; refine Node types docs. > - Size-limit: add entry for `@sentry/browser` incl. Tracing, Profiling (48 KB). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 765f89de7de8b262daaaafe7cbcdfba66cea9f18. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .size-limit.js | 7 + .../suites/profiling/legacyMode/subject.js | 2 +- .../suites/profiling/legacyMode/test.ts | 16 +- .../subject.js | 48 ++ .../test.ts | 206 ++++++ .../subject.js | 52 ++ .../test.ts | 187 ++++++ packages/browser/src/profiling/integration.ts | 162 +++-- .../lifecycleMode/traceLifecycleProfiler.ts | 355 ++++++++++ .../src/profiling/startProfileForSpan.ts | 3 +- packages/browser/src/profiling/utils.ts | 277 +++++++- .../test/profiling/integration.test.ts | 44 ++ .../profiling/traceLifecycleProfiler.test.ts | 631 ++++++++++++++++++ .../core/src/types-hoist/browseroptions.ts | 22 + packages/node/src/types.ts | 13 +- 15 files changed, 1947 insertions(+), 78 deletions(-) create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts create mode 100644 packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts create mode 100644 packages/browser/test/profiling/traceLifecycleProfiler.test.ts diff --git a/.size-limit.js b/.size-limit.js index 5de4268a53d6..9cebd30285e4 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -40,6 +40,13 @@ module.exports = [ gzip: true, limit: '41 KB', }, + { + name: '@sentry/browser (incl. Tracing, Profiling)', + path: 'packages/browser/build/npm/esm/index.js', + import: createImport('init', 'browserTracingIntegration', 'browserProfilingIntegration'), + gzip: true, + limit: '48 KB', + }, { name: '@sentry/browser (incl. Tracing, Replay)', path: 'packages/browser/build/npm/esm/index.js', diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js index 230e9ee1fb9e..aad9fd2a764c 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/subject.js @@ -17,7 +17,7 @@ function fibonacci(n) { return fibonacci(n - 1) + fibonacci(n - 2); } -await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { +await Sentry.startSpanManual({ name: 'root-fibonacci', parentSpan: null, forceTransaction: true }, async span => { fibonacci(30); // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled diff --git a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts index 35f4e17bec0a..d473236cdfda 100644 --- a/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts +++ b/dev-packages/browser-integration-tests/suites/profiling/legacyMode/test.ts @@ -73,14 +73,16 @@ sentryTest('sends profile envelope in legacy mode', async ({ page, getLocalTestU expect(profile.frames.length).toBeGreaterThan(0); for (const frame of profile.frames) { expect(frame).toHaveProperty('function'); - expect(frame).toHaveProperty('abs_path'); - expect(frame).toHaveProperty('lineno'); - expect(frame).toHaveProperty('colno'); - expect(typeof frame.function).toBe('string'); - expect(typeof frame.abs_path).toBe('string'); - expect(typeof frame.lineno).toBe('number'); - expect(typeof frame.colno).toBe('number'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } } const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js new file mode 100644 index 000000000000..0095eb5743d9 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/subject.js @@ -0,0 +1,48 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +// Create two NON-overlapping root spans so that the profiler stops and emits a chunk +// after each span (since active root span count returns to 0 between them). +await Sentry.startSpanManual({ name: 'root-fibonacci-1', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +// Small delay to ensure the first chunk is collected and sent +await new Promise(r => setTimeout(r, 25)); + +await Sentry.startSpanManual({ name: 'root-largeSum-2', parentSpan: null, forceTransaction: true }, async span => { + largeSum(); + // Ensure we cross the sampling interval to avoid flakes + await new Promise(resolve => setTimeout(resolve, 25)); + span.end(); +}); + +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts new file mode 100644 index 000000000000..702140b8823e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_multiple-chunks/test.ts @@ -0,0 +1,206 @@ +import { expect } from '@playwright/test'; +import type { ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + countEnvelopes, + getMultipleSentryEnvelopeRequests, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + // Assert that no profile_chunk envelope is sent without policy header + const chunkCount = await countEnvelopes(page, { url, envelopeType: 'profile_chunk', timeout: 1500 }); + expect(chunkCount).toBe(0); + }, +); + +sentryTest( + 'sends profile_chunk envelopes in trace mode (multiple chunks)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + + // Expect at least 2 chunks because subject creates two separate root spans, + // causing the profiler to stop and emit a chunk after each root span ends. + const profileChunkEnvelopes = await getMultipleSentryEnvelopeRequests( + page, + 2, + { url, envelopeType: 'profile_chunk', timeout: 5000 }, + properFullEnvelopeRequestParser, + ); + + expect(profileChunkEnvelopes.length).toBeGreaterThanOrEqual(2); + + // Validate the first chunk thoroughly + const profileChunkEnvelopeItem = profileChunkEnvelopes[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload1 = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload1.profile).toBeDefined(); + expect(envelopeItemPayload1.version).toBe('2'); + expect(envelopeItemPayload1.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + expect(typeof envelopeItemPayload1.profiler_id).toBe('string'); + expect(envelopeItemPayload1.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload1.chunk_id).toBe('string'); + expect(envelopeItemPayload1.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload1.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload1.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload1.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload1.release).toBe('string'); + expect(envelopeItemPayload1.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload1?.debug_meta?.images)).toBe(true); + + const profile1 = envelopeItemPayload1.profile; + + expect(profile1.samples).toBeDefined(); + expect(profile1.stacks).toBeDefined(); + expect(profile1.frames).toBeDefined(); + expect(profile1.thread_metadata).toBeDefined(); + + // Samples + expect(profile1.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile1.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile1.stacks.length); + + // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) + expect(typeof (sample as any).timestamp).toBe('number'); + const ts = (sample as any).timestamp as number; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile1.stacks.length).toBeGreaterThan(0); + for (const stack of profile1.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile1.frames.length); + } + } + + // Frames + expect(profile1.frames.length).toBeGreaterThan(0); + for (const frame of profile1.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = profile1.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // first function is captured (other one is in other chunk) + 'fibonacci', + ]), + ); + } + + expect(profile1.thread_metadata).toHaveProperty('0'); + expect(profile1.thread_metadata['0']).toHaveProperty('name'); + expect(profile1.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeSec = (profile1.samples[0] as any).timestamp as number; + const endTimeSec = (profile1.samples[profile1.samples.length - 1] as any).timestamp as number; + const durationSec = endTimeSec - startTimeSec; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationSec).toBeGreaterThan(0.2); + + // === PROFILE CHUNK 2 === + + const profileChunkEnvelopeItem2 = profileChunkEnvelopes[1][1][0]; + const envelopeItemHeader2 = profileChunkEnvelopeItem2[0]; + const envelopeItemPayload2 = profileChunkEnvelopeItem2[1]; + + // Basic sanity on the second chunk: has correct envelope type and structure + expect(envelopeItemHeader2).toHaveProperty('type', 'profile_chunk'); + expect(envelopeItemPayload2.profile).toBeDefined(); + expect(envelopeItemPayload2.version).toBe('2'); + expect(envelopeItemPayload2.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload2.profiler_id).toBe('string'); + expect(envelopeItemPayload2.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload2.chunk_id).toBe('string'); + expect(envelopeItemPayload2.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload2.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload2.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload2.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload2.release).toBe('string'); + expect(envelopeItemPayload2.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload2?.debug_meta?.images)).toBe(true); + + const profile2 = envelopeItemPayload2.profile; + + const functionNames2 = profile2.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames2.length).toBeGreaterThan(0); + expect((functionNames2 as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames2).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // second function is captured (other one is in other chunk) + 'largeSum', + ]), + ); + } + }, +); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js new file mode 100644 index 000000000000..071afe1ed059 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/subject.js @@ -0,0 +1,52 @@ +import * as Sentry from '@sentry/browser'; +import { browserProfilingIntegration } from '@sentry/browser'; + +window.Sentry = Sentry; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + integrations: [browserProfilingIntegration()], + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', +}); + +function largeSum(amount = 1000000) { + let sum = 0; + for (let i = 0; i < amount; i++) { + sum += Math.sqrt(i) * Math.sin(i); + } +} + +function fibonacci(n) { + if (n <= 1) { + return n; + } + return fibonacci(n - 1) + fibonacci(n - 2); +} + +let firstSpan; + +Sentry.startSpanManual({ name: 'root-largeSum-1', parentSpan: null, forceTransaction: true }, span => { + largeSum(); + firstSpan = span; +}); + +await Sentry.startSpanManual({ name: 'root-fibonacci-2', parentSpan: null, forceTransaction: true }, async span => { + fibonacci(40); + + Sentry.startSpan({ name: 'child-fibonacci', parentSpan: span }, childSpan => { + console.log('child span'); + }); + + // Timeout to prevent flaky tests. Integration samples every 20ms, if function is too fast it might not get sampled + await new Promise(resolve => setTimeout(resolve, 21)); + span.end(); +}); + +await new Promise(r => setTimeout(r, 21)); + +firstSpan.end(); + +const client = Sentry.getClient(); +await client?.flush(5000); diff --git a/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts new file mode 100644 index 000000000000..60744def96cd --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/profiling/traceLifecycleMode_overlapping-spans/test.ts @@ -0,0 +1,187 @@ +import { expect } from '@playwright/test'; +import type { Event, Profile, ProfileChunkEnvelope } from '@sentry/core'; +import { sentryTest } from '../../../utils/fixtures'; +import { + getMultipleSentryEnvelopeRequests, + properEnvelopeRequestParser, + properFullEnvelopeRequestParser, + shouldSkipTracingTest, + waitForTransactionRequestOnUrl, +} from '../../../utils/helpers'; + +sentryTest( + 'does not send profile envelope when document-policy is not set', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname }); + + const req = await waitForTransactionRequestOnUrl(page, url); + const transactionEvent = properEnvelopeRequestParser(req, 0); + const profileEvent = properEnvelopeRequestParser(req, 1); + + expect(transactionEvent).toBeDefined(); + + expect(profileEvent).toBeUndefined(); + }, +); + +sentryTest( + 'sends profile envelope in trace mode (single chunk for overlapping spans)', + async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + // Profiling only works when tracing is enabled + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + await page.goto(url); + + const profileChunkEnvelopePromise = getMultipleSentryEnvelopeRequests( + page, + 1, + { envelopeType: 'profile_chunk' }, + properFullEnvelopeRequestParser, + ); + + const profileChunkEnvelopeItem = (await profileChunkEnvelopePromise)[0][1][0]; + const envelopeItemHeader = profileChunkEnvelopeItem[0]; + const envelopeItemPayload = profileChunkEnvelopeItem[1]; + + expect(envelopeItemHeader).toHaveProperty('type', 'profile_chunk'); + + expect(envelopeItemPayload.profile).toBeDefined(); + expect(envelopeItemPayload.version).toBe('2'); + expect(envelopeItemPayload.platform).toBe('javascript'); + + // Required profile metadata (Sample Format V2) + // https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + expect(typeof envelopeItemPayload.profiler_id).toBe('string'); + expect(envelopeItemPayload.profiler_id).toMatch(/^[a-f0-9]{32}$/); + expect(typeof envelopeItemPayload.chunk_id).toBe('string'); + expect(envelopeItemPayload.chunk_id).toMatch(/^[a-f0-9]{32}$/); + expect(envelopeItemPayload.client_sdk).toBeDefined(); + expect(typeof envelopeItemPayload.client_sdk.name).toBe('string'); + expect(typeof envelopeItemPayload.client_sdk.version).toBe('string'); + expect(typeof envelopeItemPayload.release).toBe('string'); + expect(envelopeItemPayload.debug_meta).toBeDefined(); + expect(Array.isArray(envelopeItemPayload?.debug_meta?.images)).toBe(true); + + const profile = envelopeItemPayload.profile; + + expect(profile.samples).toBeDefined(); + expect(profile.stacks).toBeDefined(); + expect(profile.frames).toBeDefined(); + expect(profile.thread_metadata).toBeDefined(); + + // Samples + expect(profile.samples.length).toBeGreaterThanOrEqual(2); + let previousTimestamp = Number.NEGATIVE_INFINITY; + for (const sample of profile.samples) { + expect(typeof sample.stack_id).toBe('number'); + expect(sample.stack_id).toBeGreaterThanOrEqual(0); + expect(sample.stack_id).toBeLessThan(profile.stacks.length); + + // In trace lifecycle mode, samples carry a numeric timestamp (ms since epoch or similar clock) + expect(typeof sample.timestamp).toBe('number'); + const ts = sample.timestamp; + expect(Number.isFinite(ts)).toBe(true); + expect(ts).toBeGreaterThan(0); + // Monotonic non-decreasing timestamps + expect(ts).toBeGreaterThanOrEqual(previousTimestamp); + previousTimestamp = ts; + + expect(sample.thread_id).toBe('0'); // Should be main thread + } + + // Stacks + expect(profile.stacks.length).toBeGreaterThan(0); + for (const stack of profile.stacks) { + expect(Array.isArray(stack)).toBe(true); + for (const frameIndex of stack) { + expect(typeof frameIndex).toBe('number'); + expect(frameIndex).toBeGreaterThanOrEqual(0); + expect(frameIndex).toBeLessThan(profile.frames.length); + } + } + + // Frames + expect(profile.frames.length).toBeGreaterThan(0); + for (const frame of profile.frames) { + expect(frame).toHaveProperty('function'); + expect(typeof frame.function).toBe('string'); + + if (frame.function !== 'fetch' && frame.function !== 'setTimeout') { + expect(frame).toHaveProperty('abs_path'); + expect(frame).toHaveProperty('lineno'); + expect(frame).toHaveProperty('colno'); + expect(typeof frame.abs_path).toBe('string'); + expect(typeof frame.lineno).toBe('number'); + expect(typeof frame.colno).toBe('number'); + } + } + + const functionNames = profile.frames.map(frame => frame.function).filter(name => name !== ''); + + if ((process.env.PW_BUNDLE || '').endsWith('min')) { + // In bundled mode, function names are minified + expect(functionNames.length).toBeGreaterThan(0); + expect((functionNames as string[]).every(name => name?.length > 0)).toBe(true); // Just make sure they're not empty strings + } else { + expect(functionNames).toEqual( + expect.arrayContaining([ + '_startRootSpan', + 'withScope', + 'createChildOrRootSpan', + 'startSpanManual', + 'startJSSelfProfile', + + // both functions are captured + 'fibonacci', + 'largeSum', + ]), + ); + } + + expect(profile.thread_metadata).toHaveProperty('0'); + expect(profile.thread_metadata['0']).toHaveProperty('name'); + expect(profile.thread_metadata['0'].name).toBe('main'); + + // Test that profile duration makes sense (should be > 20ms based on test setup) + const startTimeSec = (profile.samples[0] as any).timestamp as number; + const endTimeSec = (profile.samples[profile.samples.length - 1] as any).timestamp as number; + const durationSec = endTimeSec - startTimeSec; + + // Should be at least 20ms based on our setTimeout(21) in the test + expect(durationSec).toBeGreaterThan(0.2); + }, +); + +sentryTest('attaches thread data to child spans (trace mode)', async ({ page, getLocalTestUrl, browserName }) => { + if (shouldSkipTracingTest() || browserName !== 'chromium') { + sentryTest.skip(); + } + + const url = await getLocalTestUrl({ testDir: __dirname, responseHeaders: { 'Document-Policy': 'js-profiling' } }); + const req = await waitForTransactionRequestOnUrl(page, url); + const rootSpan = properEnvelopeRequestParser(req, 0) as any; + + expect(rootSpan?.type).toBe('transaction'); + expect(rootSpan.transaction).toBe('root-fibonacci-2'); + + const profilerId = rootSpan?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId).toBe('string'); + + expect(profilerId).toMatch(/^[a-f0-9]{32}$/); + + const spans = (rootSpan?.spans ?? []) as Array<{ data?: Record }>; + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.data).toBeDefined(); + expect(span.data?.['thread.id']).toBe('0'); + expect(span.data?.['thread.name']).toBe('main'); + } +}); diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts index 7ad77d8920e5..415282698d45 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,15 +1,21 @@ import type { EventEnvelope, IntegrationFn, Profile, Span } from '@sentry/core'; -import { debug, defineIntegration, getActiveSpan, getRootSpan } from '@sentry/core'; +import { debug, defineIntegration, getActiveSpan, getRootSpan, hasSpansEnabled } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; +import { WINDOW } from '../helpers'; +import { BrowserTraceLifecycleProfiler } from './lifecycleMode/traceLifecycleProfiler'; import { startProfileForSpan } from './startProfileForSpan'; import type { ProfiledEvent } from './utils'; import { addProfilesToEnvelope, + attachProfiledThreadToEvent, createProfilingEvent, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, + hasLegacyProfiling, isAutomatedPageLoadSpan, - shouldProfileSpan, + shouldProfileSession, + shouldProfileSpanLegacy, takeProfileFromGlobalCache, } from './utils'; @@ -19,73 +25,133 @@ const _browserProfilingIntegration = (() => { return { name: INTEGRATION_NAME, setup(client) { + const options = client.getOptions() as BrowserOptions; + + if (!hasLegacyProfiling(options) && !options.profileLifecycle) { + // Set default lifecycle mode + options.profileLifecycle = 'manual'; + } + + if (hasLegacyProfiling(options) && !options.profilesSampleRate) { + DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no profiling options found.'); + return; + } + const activeSpan = getActiveSpan(); const rootSpan = activeSpan && getRootSpan(activeSpan); - if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { - if (shouldProfileSpan(rootSpan)) { - startProfileForSpan(rootSpan); - } + if (hasLegacyProfiling(options) && options.profileSessionSampleRate !== undefined) { + DEBUG_BUILD && + debug.warn( + '[Profiling] Both legacy profiling (`profilesSampleRate`) and UI profiling settings are defined. `profileSessionSampleRate` has no effect when legacy profiling is enabled.', + ); } - client.on('spanStart', (span: Span) => { - if (span === getRootSpan(span) && shouldProfileSpan(span)) { - startProfileForSpan(span); + // UI PROFILING (Profiling V2) + if (!hasLegacyProfiling(options)) { + const sessionSampled = shouldProfileSession(options); + if (!sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled. Skipping lifecycle profiler initialization.'); } - }); - client.on('beforeEnvelope', (envelope): void => { - // if not profiles are in queue, there is nothing to add to the envelope. - if (!getActiveProfilesCount()) { - return; - } + const lifecycleMode = options.profileLifecycle; - const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); - if (!profiledTransactionEvents.length) { - return; - } + if (lifecycleMode === 'trace') { + if (!hasSpansEnabled(options)) { + DEBUG_BUILD && + debug.warn( + "[Profiling] `profileLifecycle` is 'trace' but tracing is disabled. Set a `tracesSampleRate` or `tracesSampler` to enable span tracing.", + ); + return; + } - const profilesToAddToEnvelope: Profile[] = []; + const traceLifecycleProfiler = new BrowserTraceLifecycleProfiler(); + traceLifecycleProfiler.initialize(client, sessionSampled); - for (const profiledTransaction of profiledTransactionEvents) { - const context = profiledTransaction?.contexts; - const profile_id = context?.profile?.['profile_id']; - const start_timestamp = context?.profile?.['start_timestamp']; + // If there is an active, sampled root span already, notify the profiler + if (rootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(rootSpan); + } - if (typeof profile_id !== 'string') { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + // In case rootSpan is created slightly after setup -> schedule microtask to re-check and notify. + WINDOW.setTimeout(() => { + const laterActiveSpan = getActiveSpan(); + const laterRootSpan = laterActiveSpan && getRootSpan(laterActiveSpan); + if (laterRootSpan) { + traceLifecycleProfiler.notifyRootSpanActive(laterRootSpan); + } + }, 0); + } + } else { + // LEGACY PROFILING (v1) + if (rootSpan && isAutomatedPageLoadSpan(rootSpan)) { + if (shouldProfileSpanLegacy(rootSpan)) { + startProfileForSpan(rootSpan); } + } - if (!profile_id) { - DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); - continue; + client.on('spanStart', (span: Span) => { + if (span === getRootSpan(span) && shouldProfileSpanLegacy(span)) { + startProfileForSpan(span); } + }); - // Remove the profile from the span context before sending, relay will take care of the rest. - if (context?.profile) { - delete context.profile; + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!getActiveProfilesCount()) { + return; } - const profile = takeProfileFromGlobalCache(profile_id); - if (!profile) { - DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); - continue; + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; } - const profileEvent = createProfilingEvent( - profile_id, - start_timestamp as number | undefined, - profile, - profiledTransaction as ProfiledEvent, - ); - if (profileEvent) { - profilesToAddToEnvelope.push(profileEvent); + const profilesToAddToEnvelope: Profile[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction?.contexts; + const profile_id = context?.profile?.['profile_id']; + const start_timestamp = context?.profile?.['start_timestamp']; + + if (typeof profile_id !== 'string') { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + if (!profile_id) { + DEBUG_BUILD && debug.log('[Profiling] cannot find profile for a span without a profile context'); + continue; + } + + // Remove the profile from the span context before sending, relay will take care of the rest. + if (context?.profile) { + delete context.profile; + } + + const profile = takeProfileFromGlobalCache(profile_id); + if (!profile) { + DEBUG_BUILD && debug.log(`[Profiling] Could not retrieve profile for span: ${profile_id}`); + continue; + } + + const profileEvent = createProfilingEvent( + profile_id, + start_timestamp as number | undefined, + profile, + profiledTransaction as ProfiledEvent, + ); + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } } - } - addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); - }); + addProfilesToEnvelope(envelope as EventEnvelope, profilesToAddToEnvelope); + }); + } + }, + processEvent(event) { + return attachProfiledThreadToEvent(event); }, }; }) satisfies IntegrationFn; diff --git a/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts new file mode 100644 index 000000000000..3ce773fe01ff --- /dev/null +++ b/packages/browser/src/profiling/lifecycleMode/traceLifecycleProfiler.ts @@ -0,0 +1,355 @@ +import type { Client, ProfileChunk, Span } from '@sentry/core'; +import { + type ProfileChunkEnvelope, + createEnvelope, + debug, + dsnToString, + getGlobalScope, + getRootSpan, + getSdkMetadataForEnvelopeHeader, + uuid4, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import type { JSSelfProfiler } from '../jsSelfProfiling'; +import { createProfileChunkPayload, startJSSelfProfile, validateProfileChunk } from '../utils'; + +const CHUNK_INTERVAL_MS = 60_000; // 1 minute +// Maximum length for trace lifecycle profiling per root span (e.g. if spanEnd never fires) +const MAX_ROOT_SPAN_PROFILE_MS = 300_000; // 5 minutes + +/** + * Browser trace-lifecycle profiler (UI Profiling / Profiling V2): + * - Starts when the first sampled root span starts + * - Stops when the last sampled root span ends + * - While running, periodically stops and restarts the JS self-profiling API to collect chunks + * + * Profiles are emitted as standalone `profile_chunk` envelopes either when: + * - there are no more sampled root spans, or + * - the 60s chunk timer elapses while profiling is running. + */ +export class BrowserTraceLifecycleProfiler { + private _client: Client | undefined; + private _profiler: JSSelfProfiler | undefined; + private _chunkTimer: ReturnType | undefined; + // For keeping track of active root spans + private _activeRootSpanIds: Set; + private _rootSpanTimeouts: Map>; + // ID for Profiler session + private _profilerId: string | undefined; + private _isRunning: boolean; + private _sessionSampled: boolean; + + public constructor() { + this._client = undefined; + this._profiler = undefined; + this._chunkTimer = undefined; + this._activeRootSpanIds = new Set(); + this._rootSpanTimeouts = new Map>(); + this._profilerId = undefined; + this._isRunning = false; + this._sessionSampled = false; + } + + /** + * Initialize the profiler with client and session sampling decision computed by the integration. + */ + public initialize(client: Client, sessionSampled: boolean): void { + // One Profiler ID per profiling session (user session) + this._profilerId = uuid4(); + + DEBUG_BUILD && debug.log("[Profiling] Initializing profiler (lifecycle='trace')."); + + this._client = client; + this._sessionSampled = sessionSampled; + + client.on('spanStart', span => { + if (!this._sessionSampled) { + DEBUG_BUILD && debug.log('[Profiling] Session not sampled because of negative sampling decision.'); + return; + } + if (span !== getRootSpan(span)) { + return; + } + // Only count sampled root spans + if (!span.isRecording()) { + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); + return; + } + + // Matching root spans with profiles + getGlobalScope().setContext('profile', { + profiler_id: this._profilerId, + }); + + const spanId = span.spanContext().spanId; + if (!spanId) { + return; + } + if (this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.add(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + const timeout = setTimeout(() => { + this._onRootSpanTimeout(spanId); + }, MAX_ROOT_SPAN_PROFILE_MS); + this._rootSpanTimeouts.set(spanId, timeout); + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} started. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + + this.start(); + } + }); + + client.on('spanEnd', span => { + if (!this._sessionSampled) { + return; + } + + const spanId = span.spanContext().spanId; + if (!spanId || !this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.delete(spanId); + const rootSpanCount = this._activeRootSpanIds.size; + + DEBUG_BUILD && + debug.log( + `[Profiling] Root span with ID ${spanId} ended. Will continue profiling for as long as there are active root spans (currently: ${rootSpanCount}).`, + ); + if (rootSpanCount === 0) { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `spanEnd`:', e); + }); + + this.stop(); + } + }); + } + + /** + * Handle an already-active root span at integration setup time. + */ + public notifyRootSpanActive(rootSpan: Span): void { + if (!this._sessionSampled) { + return; + } + + const spanId = rootSpan.spanContext().spanId; + if (!spanId || this._activeRootSpanIds.has(spanId)) { + return; + } + + this._activeRootSpanIds.add(spanId); + + const rootSpanCount = this._activeRootSpanIds.size; + + if (rootSpanCount === 1) { + DEBUG_BUILD && + debug.log('[Profiling] Detected already active root span during setup. Active root spans now:', rootSpanCount); + + this.start(); + } + } + + /** + * Start profiling if not already running. + */ + public start(): void { + if (this._isRunning) { + return; + } + this._isRunning = true; + + DEBUG_BUILD && debug.log('[Profiling] Started profiling with profile ID:', this._profilerId); + + this._startProfilerInstance(); + + if (!this._profiler) { + DEBUG_BUILD && debug.log('[Profiling] Stopping trace lifecycle profiling.'); + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + + /** + * Stop profiling; final chunk will be collected and sent. + */ + public stop(): void { + if (!this._isRunning) { + return; + } + + this._isRunning = false; + if (this._chunkTimer) { + clearTimeout(this._chunkTimer); + this._chunkTimer = undefined; + } + + this._clearAllRootSpanTimeouts(); + + // Collect whatever was currently recording + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk on `stop()`:', e); + }); + } + + /** + * Resets profiling information from scope and resets running state + */ + private _resetProfilerInfo(): void { + this._isRunning = false; + getGlobalScope().setContext('profile', {}); + } + + /** + * Clear and reset all per-root-span timeouts. + */ + private _clearAllRootSpanTimeouts(): void { + this._rootSpanTimeouts.forEach(timeout => clearTimeout(timeout)); + this._rootSpanTimeouts.clear(); + } + + /** + * Start a profiler instance if needed. + */ + private _startProfilerInstance(): void { + if (this._profiler?.stopped === false) { + return; + } + const profiler = startJSSelfProfile(); + if (!profiler) { + DEBUG_BUILD && debug.log('[Profiling] Failed to start JS Profiler in trace lifecycle.'); + return; + } + this._profiler = profiler; + } + + /** + * Schedule the next 60s chunk while running. + * Each tick collects a chunk and restarts the profiler. + * A chunk should be closed when there are no active root spans anymore OR when the maximum chunk interval is reached. + */ + private _startPeriodicChunking(): void { + if (!this._isRunning) { + return; + } + + this._chunkTimer = setTimeout(() => { + this._collectCurrentChunk().catch(e => { + DEBUG_BUILD && debug.error('[Profiling] Failed to collect current profile chunk during periodic chunking:', e); + }); + + if (this._isRunning) { + this._startProfilerInstance(); + + if (!this._profiler) { + // If restart failed, stop scheduling further chunks and reset context. + this._resetProfilerInfo(); + return; + } + + this._startPeriodicChunking(); + } + }, CHUNK_INTERVAL_MS); + } + + /** + * Handle timeout for a specific root span ID to avoid indefinitely running profiler if `spanEnd` never fires. + * If this was the last active root span, collect the current chunk and stop profiling. + */ + private _onRootSpanTimeout(rootSpanId: string): void { + // If span already ended, ignore + if (!this._rootSpanTimeouts.has(rootSpanId)) { + return; + } + this._rootSpanTimeouts.delete(rootSpanId); + + if (!this._activeRootSpanIds.has(rootSpanId)) { + return; + } + + DEBUG_BUILD && + debug.log( + `[Profiling] Reached 5-minute timeout for root span ${rootSpanId}. You likely started a manual root span that never called \`.end()\`.`, + ); + + this._activeRootSpanIds.delete(rootSpanId); + + const rootSpanCount = this._activeRootSpanIds.size; + if (rootSpanCount === 0) { + this.stop(); + } + } + + /** + * Stop the current profiler, convert and send a profile chunk. + */ + private async _collectCurrentChunk(): Promise { + const prevProfiler = this._profiler; + this._profiler = undefined; + + if (!prevProfiler) { + return; + } + + try { + const profile = await prevProfiler.stop(); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const chunk = createProfileChunkPayload(profile, this._client!, this._profilerId); + + // Validate chunk before sending + const validationReturn = validateProfileChunk(chunk); + if ('reason' in validationReturn) { + DEBUG_BUILD && + debug.log( + '[Profiling] Discarding invalid profile chunk (this is probably a bug in the SDK):', + validationReturn.reason, + ); + return; + } + + this._sendProfileChunk(chunk); + + DEBUG_BUILD && debug.log('[Profiling] Collected browser profile chunk.'); + } catch (e) { + DEBUG_BUILD && debug.log('[Profiling] Error while stopping JS Profiler for chunk:', e); + } + } + + /** + * Send a profile chunk as a standalone envelope. + */ + private _sendProfileChunk(chunk: ProfileChunk): void { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const client = this._client!; + + const sdkInfo = getSdkMetadataForEnvelopeHeader(client.getSdkMetadata?.()); + const dsn = client.getDsn(); + const tunnel = client.getOptions().tunnel; + + const envelope = createEnvelope( + { + event_id: uuid4(), + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && dsn && { dsn: dsnToString(dsn) }), + }, + [[{ type: 'profile_chunk' }, chunk]], + ); + + client.sendEnvelope(envelope).then(null, reason => { + DEBUG_BUILD && debug.error('Error while sending profile chunk envelope:', reason); + }); + } +} diff --git a/packages/browser/src/profiling/startProfileForSpan.ts b/packages/browser/src/profiling/startProfileForSpan.ts index b60a207abbce..6eaaa016d822 100644 --- a/packages/browser/src/profiling/startProfileForSpan.ts +++ b/packages/browser/src/profiling/startProfileForSpan.ts @@ -41,7 +41,7 @@ export function startProfileForSpan(span: Span): void { // event of an error or user mistake (calling span.finish multiple times), it is important that the behavior of onProfileHandler // is idempotent as we do not want any timings or profiles to be overridden by the last call to onProfileHandler. // After the original finish method is called, the event will be reported through the integration and delegated to transport. - const processedProfile: JSSelfProfile | null = null; + let processedProfile: JSSelfProfile | null = null; getCurrentScope().setContext('profile', { profile_id: profileId, @@ -90,6 +90,7 @@ export function startProfileForSpan(span: Span): void { return; } + processedProfile = profile; addProfileToGlobalCache(profileId, profile); }) .catch(error => { diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 8b7039be7a9b..ed794a40a98b 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,5 +1,16 @@ /* eslint-disable max-lines */ -import type { DebugImage, Envelope, Event, EventEnvelope, Profile, Span, ThreadCpuProfile } from '@sentry/core'; +import type { + Client, + ContinuousThreadCpuProfile, + DebugImage, + Envelope, + Event, + EventEnvelope, + Profile, + ProfileChunk, + Span, + ThreadCpuProfile, +} from '@sentry/core'; import { browserPerformanceTimeOrigin, debug, @@ -7,19 +18,24 @@ import { forEachEnvelopeItem, getClient, getDebugImagesForResources, + GLOBAL_OBJ, spanToJSON, timestampInSeconds, uuid4, } from '@sentry/core'; +import type { BrowserOptions } from '../client'; import { DEBUG_BUILD } from '../debug-build'; import { WINDOW } from '../helpers'; import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor, JSSelfProfileStack } from './jsSelfProfiling'; const MS_TO_NS = 1e6; -// Use 0 as main thread id which is identical to threadId in node:worker_threads -// where main logs 0 and workers seem to log in increments of 1 -const THREAD_ID_STRING = String(0); -const THREAD_NAME = 'main'; + +// Checking if we are in Main or Worker thread: `self` (not `window`) is the `globalThis` in Web Workers and `importScripts` are only available in Web Workers +const isMainThread = 'window' in GLOBAL_OBJ && GLOBAL_OBJ.window === GLOBAL_OBJ && typeof importScripts === 'undefined'; + +// Setting ID to 0 as we cannot get an ID from Web Workers +export const PROFILER_THREAD_ID_STRING = String(0); +export const PROFILER_THREAD_NAME = isMainThread ? 'main' : 'worker'; // We force make this optional to be on the safe side... const navigator = WINDOW.navigator as typeof WINDOW.navigator | undefined; @@ -185,7 +201,7 @@ export function createProfilePayload( name: event.transaction || '', id: event.event_id || uuid4(), trace_id: traceId, - active_thread_id: THREAD_ID_STRING, + active_thread_id: PROFILER_THREAD_ID_STRING, relative_start_ns: '0', relative_end_ns: ((transactionEndMs - transactionStartMs) * 1e6).toFixed(0), }, @@ -195,6 +211,161 @@ export function createProfilePayload( return profile; } +/** + * Create a profile chunk envelope item + */ +export function createProfileChunkPayload( + jsSelfProfile: JSSelfProfile, + client: Client, + profilerId?: string, +): ProfileChunk { + // only == to catch null and undefined + if (jsSelfProfile == null) { + throw new TypeError( + `Cannot construct profiling event envelope without a valid profile. Got ${jsSelfProfile} instead.`, + ); + } + + const continuousProfile = convertToContinuousProfile(jsSelfProfile); + + const options = client.getOptions(); + const sdk = client.getSdkMetadata?.()?.sdk; + + return { + chunk_id: uuid4(), + client_sdk: { + name: sdk?.name ?? 'sentry.javascript.browser', + version: sdk?.version ?? '0.0.0', + }, + profiler_id: profilerId || uuid4(), + platform: 'javascript', + version: '2', + release: options.release ?? '', + environment: options.environment ?? 'production', + debug_meta: { + // function name obfuscation + images: applyDebugMetadata(jsSelfProfile.resources), + }, + profile: continuousProfile, + }; +} + +/** + * Validate a profile chunk against the Sample Format V2 requirements. + * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ + * - Presence of samples, stacks, frames + * - Required metadata fields + */ +export function validateProfileChunk(chunk: ProfileChunk): { valid: true } | { reason: string } { + try { + // Required metadata + if (!chunk || typeof chunk !== 'object') { + return { reason: 'chunk is not an object' }; + } + + // profiler_id and chunk_id must be 32 lowercase hex chars + const isHex32 = (val: unknown): boolean => typeof val === 'string' && /^[a-f0-9]{32}$/.test(val); + if (!isHex32(chunk.profiler_id)) { + return { reason: 'missing or invalid profiler_id' }; + } + if (!isHex32(chunk.chunk_id)) { + return { reason: 'missing or invalid chunk_id' }; + } + + if (!chunk.client_sdk) { + return { reason: 'missing client_sdk metadata' }; + } + + // Profile data must have frames, stacks, samples + const profile = chunk.profile as { frames?: unknown[]; stacks?: unknown[]; samples?: unknown[] } | undefined; + if (!profile) { + return { reason: 'missing profile data' }; + } + + if (!Array.isArray(profile.frames) || !profile.frames.length) { + return { reason: 'profile has no frames' }; + } + if (!Array.isArray(profile.stacks) || !profile.stacks.length) { + return { reason: 'profile has no stacks' }; + } + if (!Array.isArray(profile.samples) || !profile.samples.length) { + return { reason: 'profile has no samples' }; + } + + return { valid: true }; + } catch (e) { + return { reason: `unknown validation error: ${e}` }; + } +} + +/** + * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. + */ +function convertToContinuousProfile(input: { + frames: { name: string; resourceId?: number; line?: number; column?: number }[]; + stacks: { frameId: number; parentId?: number }[]; + samples: { timestamp: number; stackId?: number }[]; + resources: string[]; +}): ContinuousThreadCpuProfile { + // Frames map 1:1 by index; fill only when present to avoid sparse writes + const frames: ContinuousThreadCpuProfile['frames'] = []; + for (let i = 0; i < input.frames.length; i++) { + const frame = input.frames[i]; + if (!frame) { + continue; + } + frames[i] = { + function: frame.name, + abs_path: typeof frame.resourceId === 'number' ? input.resources[frame.resourceId] : undefined, + lineno: frame.line, + colno: frame.column, + }; + } + + // Build stacks by following parent links, top->down order (root last) + const stacks: ContinuousThreadCpuProfile['stacks'] = []; + for (let i = 0; i < input.stacks.length; i++) { + const stackHead = input.stacks[i]; + if (!stackHead) { + continue; + } + const list: number[] = []; + let current: { frameId: number; parentId?: number } | undefined = stackHead; + while (current) { + list.push(current.frameId); + current = current.parentId === undefined ? undefined : input.stacks[current.parentId]; + } + stacks[i] = list; + } + + // Align timestamps to SDK time origin to match span/event timelines + const perfOrigin = browserPerformanceTimeOrigin(); + const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0; + const adjustForOriginChange = origin - (perfOrigin || origin); + + const samples: ContinuousThreadCpuProfile['samples'] = []; + for (let i = 0; i < input.samples.length; i++) { + const sample = input.samples[i]; + if (!sample) { + continue; + } + // Convert ms to seconds epoch-based timestamp + const timestampSeconds = (origin + (sample.timestamp - adjustForOriginChange)) / 1000; + samples[i] = { + stack_id: sample.stackId ?? 0, + thread_id: PROFILER_THREAD_ID_STRING, + timestamp: timestampSeconds, + }; + } + + return { + frames, + stacks, + samples, + thread_metadata: { [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME } }, + }; +} + /** * */ @@ -226,7 +397,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi stacks: [], frames: [], thread_metadata: { - [THREAD_ID_STRING]: { name: THREAD_NAME }, + [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME }, }, }; @@ -258,7 +429,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: EMPTY_STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; return; } @@ -291,7 +462,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profi // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, - thread_id: THREAD_ID_STRING, + thread_id: PROFILER_THREAD_ID_STRING, }; profile['stacks'][STACK_ID] = stack; @@ -459,7 +630,7 @@ export function startJSSelfProfile(): JSSelfProfiler | undefined { /** * Determine if a profile should be profiled. */ -export function shouldProfileSpan(span: Span): boolean { +export function shouldProfileSpanLegacy(span: Span): boolean { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { @@ -469,9 +640,7 @@ export function shouldProfileSpan(span: Span): boolean { } if (!span.isRecording()) { - if (DEBUG_BUILD) { - debug.log('[Profiling] Discarding profile because transaction was not sampled.'); - } + DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); return false; } @@ -518,6 +687,46 @@ export function shouldProfileSpan(span: Span): boolean { return true; } +/** + * Determine if a profile should be created for the current session (lifecycle profiling mode). + */ +export function shouldProfileSession(options: BrowserOptions): boolean { + // If constructor failed once, it will always fail, so we can early return. + if (PROFILING_CONSTRUCTOR_FAILED) { + if (DEBUG_BUILD) { + debug.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); + } + return false; + } + + if (options.profileLifecycle !== 'trace') { + return false; + } + + // Session sampling: profileSessionSampleRate gates whether profiling is enabled for this session + const profileSessionSampleRate = options.profileSessionSampleRate; + + if (!isValidSampleRate(profileSessionSampleRate)) { + DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid profileSessionSampleRate.'); + return false; + } + + if (!profileSessionSampleRate) { + DEBUG_BUILD && + debug.log('[Profiling] Discarding profile because profileSessionSampleRate is not defined or set to 0'); + return false; + } + + return Math.random() <= profileSessionSampleRate; +} + +/** + * Checks if legacy profiling is configured. + */ +export function hasLegacyProfiling(options: BrowserOptions): boolean { + return typeof options.profilesSampleRate !== 'undefined'; +} + /** * Creates a profiling envelope item, if the profile does not pass validation, returns null. * @param event @@ -564,8 +773,44 @@ export function addProfileToGlobalCache(profile_id: string, profile: JSSelfProfi PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const last = PROFILE_MAP.keys().next().value!; - PROFILE_MAP.delete(last); + const last = PROFILE_MAP.keys().next().value; + if (last !== undefined) { + PROFILE_MAP.delete(last); + } } } + +/** + * Attaches the profiled thread information to the event's trace context. + */ +export function attachProfiledThreadToEvent(event: Event): Event { + if (!event?.contexts?.profile) { + return event; + } + + if (!event.contexts) { + return event; + } + + // @ts-expect-error the trace fallback value is wrong, though it should never happen + // and in case it does, we dont want to override whatever was passed initially. + event.contexts.trace = { + ...(event.contexts?.trace ?? {}), + data: { + ...(event.contexts?.trace?.data ?? {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }, + }; + + // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. + event.spans?.forEach(span => { + span.data = { + ...(span.data || {}), + ['thread.id']: PROFILER_THREAD_ID_STRING, + ['thread.name']: PROFILER_THREAD_NAME, + }; + }); + + return event; +} diff --git a/packages/browser/test/profiling/integration.test.ts b/packages/browser/test/profiling/integration.test.ts index 2af3cb662689..f9d97230701c 100644 --- a/packages/browser/test/profiling/integration.test.ts +++ b/packages/browser/test/profiling/integration.test.ts @@ -3,7 +3,9 @@ */ import * as Sentry from '@sentry/browser'; +import { debug } from '@sentry/core'; import { describe, expect, it, vi } from 'vitest'; +import type { BrowserClient } from '../../src/index'; import type { JSSelfProfile } from '../../src/profiling/jsSelfProfiling'; describe('BrowserProfilingIntegration', () => { @@ -65,4 +67,46 @@ describe('BrowserProfilingIntegration', () => { expect(profile_timestamp_ms).toBeGreaterThan(transaction_timestamp_ms); expect(profile.profile.frames[0]).toMatchObject({ function: 'pageload_fn', lineno: 1, colno: 1 }); }); + + it("warns when profileLifecycle is 'trace' but tracing is disabled", async () => { + debug.enable(); + const warnSpy = vi.spyOn(debug, 'warn').mockImplementation(() => {}); + + // @ts-expect-error mock constructor + window.Profiler = class { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return Promise.resolve({ frames: [], stacks: [], samples: [], resources: [] }); + } + }; + + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + // no tracesSampleRate and no tracesSampler → tracing disabled + profileLifecycle: 'trace', + profileSessionSampleRate: 1, + integrations: [Sentry.browserProfilingIntegration()], + }); + + expect( + warnSpy.mock.calls.some(call => + String(call?.[1] ?? call?.[0]).includes("`profileLifecycle` is 'trace' but tracing is disabled"), + ), + ).toBe(true); + + warnSpy.mockRestore(); + }); + + it("auto-sets profileLifecycle to 'manual' when not specified", async () => { + Sentry.init({ + dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0', + integrations: [Sentry.browserProfilingIntegration()], + }); + + const client = Sentry.getClient(); + const lifecycle = client?.getOptions()?.profileLifecycle; + expect(lifecycle).toBe('manual'); + }); }); diff --git a/packages/browser/test/profiling/traceLifecycleProfiler.test.ts b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts new file mode 100644 index 000000000000..f28880960256 --- /dev/null +++ b/packages/browser/test/profiling/traceLifecycleProfiler.test.ts @@ -0,0 +1,631 @@ +/** + * @vitest-environment jsdom + */ + +import * as Sentry from '@sentry/browser'; +import type { Span } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('Browser Profiling v2 trace lifecycle', () => { + afterEach(async () => { + const client = Sentry.getClient(); + await client?.close(); + // reset profiler constructor + (window as any).Profiler = undefined; + vi.restoreAllMocks(); + }); + + function mockProfiler() { + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + const mockConstructor = vi.fn().mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => { + return new MockProfilerImpl(opts); + }); + + (window as any).Profiler = mockConstructor; + return { stop, mockConstructor }; + } + + it('does not start profiler when tracing is disabled (logs a warning)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + Sentry.init({ + // tracing disabled + dsn: 'https://public@o.ingest.sentry.io/1', + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + // no tracesSampleRate/tracesSampler + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + // warning is logged by our debug logger only when DEBUG_BUILD, so just assert no throw and no profiler + const client = Sentry.getClient(); + expect(client).toBeDefined(); + expect(stop).toHaveBeenCalledTimes(0); + expect(mockConstructor).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + describe('profiling lifecycle behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts on first sampled root span and sends a chunk on stop', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-1', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + // Ending the only root span should flush one chunk immediately + spanRef.end(); + + // Resolve any pending microtasks + await Promise.resolve(); + + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(2); // one for transaction, one for profile_chunk + const transactionEnvelopeHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const profileChunkEnvelopeHeader = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + expect(profileChunkEnvelopeHeader?.type).toBe('profile_chunk'); + expect(transactionEnvelopeHeader?.type).toBe('transaction'); + }); + + it('continues while any sampled root span is active; stops after last ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanA: any; + Sentry.startSpanManual({ name: 'root-A', parentSpan: null, forceTransaction: true }, span => { + spanA = span; + }); + + let spanB: any; + Sentry.startSpanManual({ name: 'root-B', parentSpan: null, forceTransaction: true }, span => { + spanB = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // End first root span -> still one active sampled root span; no send yet + spanA.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(0); + expect(send).toHaveBeenCalledTimes(1); // only transaction so far + const envelopeHeadersTxn = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeadersTxn?.type).toBe('transaction'); + + // End last root span -> should flush one chunk + spanB.end(); + await Promise.resolve(); + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(3); + const envelopeHeadersTxn1 = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersTxn2 = send.mock.calls?.[1]?.[0]?.[1]?.[0]?.[0]; + const envelopeHeadersProfile = send.mock.calls?.[2]?.[0]?.[1]?.[0]?.[0]; + + expect(envelopeHeadersTxn1?.type).toBe('transaction'); + expect(envelopeHeadersTxn2?.type).toBe('transaction'); + expect(envelopeHeadersProfile?.type).toBe('profile_chunk'); + }); + + it('sends a periodic chunk every 60s while running and restarts profiler', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger scheduled chunk collection + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted (second constructor call) + expect(stop).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(mockConstructor).toHaveBeenCalledTimes(2); + const envelopeHeaders = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(envelopeHeaders?.type).toBe('profile_chunk'); + + // Clean up + spanRef.end(); + await Promise.resolve(); + }); + + it('emits periodic chunks every 60s while span is stuck (no spanEnd)', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + let spanRef: any; + Sentry.startSpanManual({ name: 'root-interval', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Advance timers by 60s to trigger first periodic chunk while still running + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // One chunk sent and profiler restarted for the next period + expect(stop.mock.calls.length).toBe(1); + expect(send.mock.calls.length).toBe(1); + expect(mockConstructor.mock.calls.length).toBe(2); + const firstChunkHeader = send.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]; + expect(firstChunkHeader?.type).toBe('profile_chunk'); + + // Second chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(2); + expect(send.mock.calls.length).toBe(2); + expect(mockConstructor.mock.calls.length).toBe(3); + + // Third chunk after another 60s + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(3); + expect(send.mock.calls.length).toBe(3); + expect(mockConstructor.mock.calls.length).toBe(4); + + spanRef.end(); + vi.advanceTimersByTime(100_000); + await Promise.resolve(); + + // All chunks should have been sent (4 total) + expect(stop.mock.calls.length).toBe(4); + expect(mockConstructor.mock.calls.length).toBe(4); // still 4 + expect(send.mock.calls.length).toBe(5); // 4 chunks + 1 transaction (tested below) + + const countProfileChunks = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'profile_chunk').length; + const countTransactions = send.mock.calls.filter(obj => obj?.[0]?.[1]?.[0]?.[0].type === 'transaction').length; + expect(countProfileChunks).toBe(4); + expect(countTransactions).toBe(1); + }); + + it('emits periodic chunks and stops after timeout if manual root span never ends', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + expect(mockConstructor).toHaveBeenCalledTimes(1); + + // Creates 2 profile chunks + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + vi.advanceTimersByTime(60_000); + await Promise.resolve(); + + // At least two chunks emitted and profiler restarted in between + const stopsBeforeKill = stop.mock.calls.length; + const sendsBeforeKill = send.mock.calls.length; + const constructorCallsBeforeKill = mockConstructor.mock.calls.length; + expect(stopsBeforeKill).toBe(2); + expect(sendsBeforeKill).toBe(2); + expect(constructorCallsBeforeKill).toBe(3); + + // Advance to session kill switch (~5 minutes total since start) + vi.advanceTimersByTime(180_000); // now 300s total + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(constructorCallsBeforeKill + 2); // constructor was already called 3 times + expect(stopsAtKill).toBe(stopsBeforeKill + 3); + expect(sendsAtKill).toBe(sendsBeforeKill + 3); + + // No calls should happen after kill + vi.advanceTimersByTime(120_000); + await Promise.resolve(); + expect(stop.mock.calls.length).toBe(stopsAtKill); + expect(send.mock.calls.length).toBe(sendsAtKill); + expect(mockConstructor.mock.calls.length).toBe(constructorCallsAtKill); + }); + + it('continues profiling for another rootSpan after one rootSpan profile timed-out', async () => { + const { stop, mockConstructor } = mockProfiler(); + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpanManual({ name: 'root-manual-never-ends', parentSpan: null, forceTransaction: true }, _span => { + // keep open - don't end + }); + + vi.advanceTimersByTime(300_000); // 5 minutes (kill switch) + await Promise.resolve(); + + const stopsAtKill = stop.mock.calls.length; + const sendsAtKill = send.mock.calls.length; + const constructorCallsAtKill = mockConstructor.mock.calls.length; + // 5min/60sec interval = 5 send/stop calls and 5 calls of constructor total + expect(constructorCallsAtKill).toBe(5); + expect(stopsAtKill).toBe(5); + expect(sendsAtKill).toBe(5); + + let spanRef: Span | undefined; + Sentry.startSpanManual({ name: 'root-manual-will-end', parentSpan: null, forceTransaction: true }, span => { + spanRef = span; + }); + + vi.advanceTimersByTime(119_000); // create 2 chunks + await Promise.resolve(); + + spanRef?.end(); + + expect(mockConstructor.mock.calls.length).toBe(sendsAtKill + 2); + expect(stop.mock.calls.length).toBe(constructorCallsAtKill + 2); + expect(send.mock.calls.length).toBe(stopsAtKill + 2); + }); + }); + + describe('profile context', () => { + it('sets global profile context on transaction', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'root-for-context', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + // Allow async tasks to resolve and flush queued envelopes + const client = Sentry.getClient(); + await client?.flush(1000); + + // Find the transaction envelope among sent envelopes + const calls = send.mock.calls; + const txnCall = calls.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction'); + expect(txnCall).toBeDefined(); + + const transaction = txnCall?.[0]?.[1]?.[0]?.[1]; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + data: expect.objectContaining({ + ['thread.id']: expect.any(String), + ['thread.name']: expect.any(String), + }), + }, + profile: { + profiler_id: expect.any(String), + }, + }, + }); + }); + + it('reuses the same profiler_id across multiple root transactions within one session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toEqual(2); + + const firstProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + const secondProfilerId = transactionEvents[1]?.contexts?.profile?.profiler_id; + + expect(typeof firstProfilerId).toBe('string'); + expect(typeof secondProfilerId).toBe('string'); + expect(firstProfilerId).toBe(secondProfilerId); + }); + + it('emits profile_chunk items with the same profiler_id as the transactions within a session', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + const send = vi.fn().mockResolvedValue(undefined); + + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send }), + }); + + Sentry.startSpan({ name: 'rootSpan-chunk-1', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + Sentry.startSpan({ name: 'rootSpan-chunk-2', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + await Sentry.getClient()?.flush(1000); + + const calls = send.mock.calls; + const transactionEvents = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(transactionEvents.length).toBe(2); + const expectedProfilerId = transactionEvents[0]?.contexts?.profile?.profiler_id; + expect(typeof expectedProfilerId).toBe('string'); + + const profileChunks = calls + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + expect(profileChunks.length).toBe(2); + + for (const chunk of profileChunks) { + expect(chunk?.profiler_id).toBe(expectedProfilerId); + } + }); + + it('changes profiler_id when a new user session starts (new SDK init)', async () => { + vi.useRealTimers(); + + const stop = vi.fn().mockResolvedValue({ + frames: [{ name: 'f' }], + stacks: [{ frameId: 0 }], + samples: [{ timestamp: 0 }, { timestamp: 10 }], + resources: [], + }); + + class MockProfilerImpl { + stopped: boolean = false; + constructor(_opts: { sampleInterval: number; maxBufferSize: number }) {} + stop() { + this.stopped = true; + return stop(); + } + addEventListener() {} + } + + (window as any).Profiler = vi + .fn() + .mockImplementation((opts: { sampleInterval: number; maxBufferSize: number }) => new MockProfilerImpl(opts)); + + // Session 1 + const send1 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send1 }), + }); + + Sentry.startSpan({ name: 'session-1-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + let client = Sentry.getClient(); + await client?.flush(1000); + + // Extract first session profiler_id from transaction and a chunk + const calls1 = send1.mock.calls; + const txnEvt1 = calls1.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks1 = calls1 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId1 = txnEvt1?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId1).toBe('string'); + expect(chunks1.length).toBe(1); + for (const chunk of chunks1) { + expect(chunk?.profiler_id).toBe(profilerId1); + } + + // End Session 1 + await client?.close(); + + // Session 2 (new init simulates new user session) + const send2 = vi.fn().mockResolvedValue(undefined); + Sentry.init({ + dsn: 'https://public@o.ingest.sentry.io/1', + tracesSampleRate: 1, + profileSessionSampleRate: 1, + profileLifecycle: 'trace', + integrations: [Sentry.browserProfilingIntegration()], + transport: () => ({ flush: vi.fn().mockResolvedValue(true), send: send2 }), + }); + + Sentry.startSpan({ name: 'session-2-rootSpan', parentSpan: null, forceTransaction: true }, () => { + /* empty */ + }); + + client = Sentry.getClient(); + await client?.flush(1000); + + const calls2 = send2.mock.calls; + const txnEvt2 = calls2.find(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'transaction')?.[0]?.[1]?.[0]?.[1]; + const chunks2 = calls2 + .filter(call => call?.[0]?.[1]?.[0]?.[0]?.type === 'profile_chunk') + .map(call => call?.[0]?.[1]?.[0]?.[1]); + + const profilerId2 = txnEvt2?.contexts?.profile?.profiler_id as string | undefined; + expect(typeof profilerId2).toBe('string'); + expect(profilerId2).not.toBe(profilerId1); + expect(chunks2.length).toBe(1); + for (const chunk of chunks2) { + expect(chunk?.profiler_id).toBe(profilerId2); + } + }); + }); +}); diff --git a/packages/core/src/types-hoist/browseroptions.ts b/packages/core/src/types-hoist/browseroptions.ts index 1df40c6fd614..18bbd46af09c 100644 --- a/packages/core/src/types-hoist/browseroptions.ts +++ b/packages/core/src/types-hoist/browseroptions.ts @@ -18,9 +18,31 @@ export type BrowserClientReplayOptions = { }; export type BrowserClientProfilingOptions = { + // todo: add deprecation warning for profilesSampleRate: @deprecated Use `profileSessionSampleRate` and `profileLifecycle` instead. /** * The sample rate for profiling * 1.0 will profile all transactions and 0 will profile none. */ profilesSampleRate?: number; + + /** + * Sets profiling session sample rate for the entire profiling session. + * + * A profiling session corresponds to a user session, meaning it is set once at integration initialization and + * persisted until the next page reload. This rate determines what percentage of user sessions will have profiling enabled. + * @default 0 + */ + profileSessionSampleRate?: number; + + /** + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). + * + * @default 'manual' + */ + profileLifecycle?: 'manual' | 'trace'; }; diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3d3463d0b5cf..1f84b69a9f28 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -46,16 +46,19 @@ export interface BaseNodeOptions { profilesSampler?: (samplingContext: SamplingContext) => number | boolean; /** - * Sets profiling session sample rate - only evaluated once per SDK initialization. + * Sets profiling session sample rate for the entire profiling session (evaluated once per SDK initialization). + * * @default 0 */ profileSessionSampleRate?: number; /** - * Set the lifecycle of the profiler. - * - * - `manual`: The profiler will be manually started and stopped. - * - `trace`: The profiler will be automatically started when when a span is sampled and stopped when there are no more sampled spans. + * Set the lifecycle mode of the profiler. + * - **manual**: The profiler will be manually started and stopped via `startProfiler`/`stopProfiler`. + * If a session is sampled, is dependent on the `profileSessionSampleRate`. + * - **trace**: The profiler will be automatically started when a root span exists and stopped when there are no + * more sampled root spans. Whether a session is sampled, is dependent on the `profileSessionSampleRate` and the + * existing sampling configuration for tracing (`tracesSampleRate`/`tracesSampler`). * * @default 'manual' */ From 75f68c7f3e06d2776693593567ab87759975fde6 Mon Sep 17 00:00:00 2001 From: Rola Abuhasna Date: Tue, 21 Oct 2025 15:13:25 +0200 Subject: [PATCH 21/23] fix(core): Fix and add missing cache attributes in Vercel AI (#17982) With the relay now handling cache token attributes (instead of scrubbing them), some Anthropic related token attributes were still missing. This PR adds the missing cache attributes and corrects the types in the Anthropic provider metadata used for extracting token data. Fixes: https://github.com/getsentry/sentry-javascript/issues/17890 --- .../core/src/utils/ai/gen-ai-attributes.ts | 10 ++++++ packages/core/src/utils/vercel-ai/index.ts | 33 +++++++++++-------- .../utils/vercel-ai/vercel-ai-attributes.ts | 23 +++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/core/src/utils/ai/gen-ai-attributes.ts b/packages/core/src/utils/ai/gen-ai-attributes.ts index 9124602644e4..d55851927cb6 100644 --- a/packages/core/src/utils/ai/gen-ai-attributes.ts +++ b/packages/core/src/utils/ai/gen-ai-attributes.ts @@ -129,6 +129,16 @@ export const GEN_AI_RESPONSE_STREAMING_ATTRIBUTE = 'gen_ai.response.streaming'; */ export const GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE = 'gen_ai.response.tool_calls'; +/** + * The number of cache write input tokens used + */ +export const GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE = 'gen_ai.usage.input_tokens.cache_write'; + +/** + * The number of cached input tokens that were used + */ +export const GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE = 'gen_ai.usage.input_tokens.cached'; + // ============================================================================= // OPENAI-SPECIFIC ATTRIBUTES // ============================================================================= diff --git a/packages/core/src/utils/vercel-ai/index.ts b/packages/core/src/utils/vercel-ai/index.ts index 9b1cc2bc8aae..8f353e88d394 100644 --- a/packages/core/src/utils/vercel-ai/index.ts +++ b/packages/core/src/utils/vercel-ai/index.ts @@ -2,6 +2,10 @@ 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 { + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, +} from '../ai/gen-ai-attributes'; import { spanToJSON } from '../spanUtils'; import { toolCallSpanMap } from './constants'; import type { TokenSummary } from './types'; @@ -23,6 +27,7 @@ import { AI_TOOL_CALL_ID_ATTRIBUTE, AI_TOOL_CALL_NAME_ATTRIBUTE, AI_TOOL_CALL_RESULT_ATTRIBUTE, + AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_RESPONSE_MODEL_ATTRIBUTE, @@ -107,6 +112,7 @@ function processEndedVercelAiSpan(span: SpanJSON): void { renameAttributeKey(attributes, AI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE); renameAttributeKey(attributes, AI_USAGE_PROMPT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE); + renameAttributeKey(attributes, AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE); if ( typeof attributes[GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE] === 'number' && @@ -287,7 +293,7 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadataObject.openai) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.openai.cachedPromptTokens, ); setAttributeIfDefined( @@ -309,27 +315,26 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { } if (providerMetadataObject.anthropic) { - setAttributeIfDefined( - attributes, - 'gen_ai.usage.input_tokens.cached', - providerMetadataObject.anthropic.cacheReadInputTokens, - ); - setAttributeIfDefined( - attributes, - 'gen_ai.usage.input_tokens.cache_write', - providerMetadataObject.anthropic.cacheCreationInputTokens, - ); + const cachedInputTokens = + providerMetadataObject.anthropic.usage?.cache_read_input_tokens ?? + providerMetadataObject.anthropic.cacheReadInputTokens; + setAttributeIfDefined(attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, cachedInputTokens); + + const cacheWriteInputTokens = + providerMetadataObject.anthropic.usage?.cache_creation_input_tokens ?? + providerMetadataObject.anthropic.cacheCreationInputTokens; + setAttributeIfDefined(attributes, GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, cacheWriteInputTokens); } if (providerMetadataObject.bedrock?.usage) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.bedrock.usage.cacheReadInputTokens, ); setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cache_write', + GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE_ATTRIBUTE, providerMetadataObject.bedrock.usage.cacheWriteInputTokens, ); } @@ -337,7 +342,7 @@ function addProviderMetadataToAttributes(attributes: SpanAttributes): void { if (providerMetadataObject.deepseek) { setAttributeIfDefined( attributes, - 'gen_ai.usage.input_tokens.cached', + GEN_AI_USAGE_INPUT_TOKENS_CACHED_ATTRIBUTE, providerMetadataObject.deepseek.promptCacheHitTokens, ); setAttributeIfDefined( diff --git a/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts b/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts index ac6774b08a02..95052fc1265a 100644 --- a/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts +++ b/packages/core/src/utils/vercel-ai/vercel-ai-attributes.ts @@ -288,6 +288,14 @@ export const AI_RESPONSE_PROVIDER_METADATA_ATTRIBUTE = 'ai.response.providerMeta */ export const AI_SETTINGS_MAX_RETRIES_ATTRIBUTE = 'ai.settings.maxRetries'; +/** + * Basic LLM span information + * Multiple spans + * + * The number of cached input tokens that were used + * @see https://ai-sdk.dev/docs/ai-sdk-core/telemetry#basic-llm-span-information + */ +export const AI_USAGE_CACHED_INPUT_TOKENS_ATTRIBUTE = 'ai.usage.cachedInputTokens'; /** * Basic LLM span information * Multiple spans @@ -863,6 +871,21 @@ interface AnthropicProviderMetadata { * @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control */ cacheReadInputTokens?: number; + + /** + * Usage metrics for the Anthropic model. + */ + usage?: { + input_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + cache_creation?: { + ephemeral_5m_input_tokens?: number; + ephemeral_1h_input_tokens?: number; + }; + output_tokens?: number; + service_tier?: string; + }; } /** From 40bcc3d3cc79c9165300b95d645014407c1a1d98 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Tue, 21 Oct 2025 15:15:26 +0200 Subject: [PATCH 22/23] fix(core): Improve uuid performance (#17938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I did some benchmarking: - https://github.com/getsentry/sentry-javascript/issues/15862#issuecomment-3405607434 ``` ┌─────────┬─────────────────────────────────────────┬──────────────────┬──────────────────┬────────────────────────┬────────────────────────┬─────────┐ │ (index) │ Task name │ Latency avg (ns) │ Latency med (ns) │ Throughput avg (ops/s) │ Throughput med (ops/s) │ Samples │ ├─────────┼─────────────────────────────────────────┼──────────────────┼──────────────────┼────────────────────────┼────────────────────────┼─────────┤ │ 0 │ 'crypto.randomUUID()' │ '269.64 ± 0.24%' │ '250.00 ± 0.00' │ '3879372 ± 0.01%' │ '4000000 ± 0' │ 3708647 │ │ 1 │ 'crypto.randomUUID().replace(/-/g, "")' │ '445.09 ± 6.08%' │ '417.00 ± 0.00' │ '2377301 ± 0.01%' │ '2398082 ± 0' │ 2246729 │ │ 2 │ 'crypto.getRandomValues()' │ '32130 ± 9.11%' │ '28083 ± 834.00' │ '35202 ± 0.07%' │ '35609 ± 1076' │ 31124 │ │ 3 │ 'Math.random()' │ '1929.0 ± 1.02%' │ '1916.0 ± 42.00' │ '525124 ± 0.01%' │ '521921 ± 11413' │ 518396 │ │ 4 │ '@lukeed/uuid' │ '273.79 ± 0.07%' │ '250.00 ± 0.00' │ '3770742 ± 0.01%' │ '4000000 ± 0' │ 3652395 │ │ 5 │ '@lukeed/uuid (custom no hyphens)' │ '262.20 ± 5.68%' │ '250.00 ± 0.00' │ '4089440 ± 0.01%' │ '4000000 ± 0' │ 3813889 │ └─────────┴─────────────────────────────────────────┴──────────────────┴──────────────────┴────────────────────────┴────────────────────────┴─────────┘ ``` I found that in Node.js at least, getting a single byte via `crypto.getRandomValues()` is 10x slower than the `Math.random()` version so we should drop `getRandomValues` usage entirely. I also found that for the `Math.random()` fallback code, we generated the base starting string (`10000000100040008000100000000000`) on every call to `uuid4()`. If we cache this value we get a ~20% improvement in this path. In the browser `crypto.randomUUID()` is only available in secure contexts so our fallback code should have good performance too! --- .../startNewTraceSampling/init.js | 9 ++++ packages/core/src/utils/misc.ts | 30 +++++------- packages/core/test/lib/utils/misc.test.ts | 47 ++++--------------- 3 files changed, 30 insertions(+), 56 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js index 09af5f3e4ab4..d32f77f4cb6b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/trace-lifetime/startNewTraceSampling/init.js @@ -5,6 +5,15 @@ window.Sentry = Sentry; // Force this so that the initial sampleRand is consistent Math.random = () => 0.45; +// Polyfill crypto.randomUUID +crypto.randomUUID = function randomUUID() { + return ([1e7] + 1e3 + 4e3 + 8e3 + 1e11).replace( + /[018]/g, + // eslint-disable-next-line no-bitwise + c => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), + ); +}; + Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', integrations: [Sentry.browserTracingIntegration()], diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 607eff129fe5..69cd217345b8 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -7,7 +7,6 @@ import { snipLine } from './string'; import { GLOBAL_OBJ } from './worldwide'; interface CryptoInternal { - getRandomValues(array: Uint8Array): Uint8Array; randomUUID?(): string; } @@ -22,37 +21,34 @@ function getCrypto(): CryptoInternal | undefined { return gbl.crypto || gbl.msCrypto; } +let emptyUuid: string | undefined; + +function getRandomByte(): number { + return Math.random() * 16; +} + /** * UUID4 generator * @param crypto Object that provides the crypto API. * @returns string Generated UUID4. */ export function uuid4(crypto = getCrypto()): string { - let getRandomByte = (): number => Math.random() * 16; try { if (crypto?.randomUUID) { return crypto.randomUUID().replace(/-/g, ''); } - if (crypto?.getRandomValues) { - getRandomByte = () => { - // crypto.getRandomValues might return undefined instead of the typed array - // in old Chromium versions (e.g. 23.0.1235.0 (151422)) - // However, `typedArray` is still filled in-place. - // @see https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues#typedarray - const typedArray = new Uint8Array(1); - crypto.getRandomValues(typedArray); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return typedArray[0]!; - }; - } } catch { // some runtimes can crash invoking crypto // https://github.com/getsentry/sentry-javascript/issues/8935 } - // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 - // Concatenating the following numbers as strings results in '10000000100040008000100000000000' - return (([1e7] as unknown as string) + 1e3 + 4e3 + 8e3 + 1e11).replace(/[018]/g, c => + if (!emptyUuid) { + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/2117523#2117523 + // Concatenating the following numbers as strings results in '10000000100040008000100000000000' + emptyUuid = ([1e7] as unknown as string) + 1e3 + 4e3 + 8e3 + 1e11; + } + + return emptyUuid.replace(/[018]/g, c => // eslint-disable-next-line no-bitwise ((c as unknown as number) ^ ((getRandomByte() & 15) >> ((c as unknown as number) / 4))).toString(16), ); diff --git a/packages/core/test/lib/utils/misc.test.ts b/packages/core/test/lib/utils/misc.test.ts index 83e7f4c05b66..885e2dc64b8d 100644 --- a/packages/core/test/lib/utils/misc.test.ts +++ b/packages/core/test/lib/utils/misc.test.ts @@ -292,28 +292,21 @@ describe('checkOrSetAlreadyCaught()', () => { describe('uuid4 generation', () => { const uuid4Regex = /^[0-9A-F]{12}[4][0-9A-F]{3}[89AB][0-9A-F]{15}$/i; - it('returns valid uuid v4 ids via Math.random', () => { + it('returns valid and unique uuid v4 ids via Math.random', () => { + const uuids = new Set(); for (let index = 0; index < 1_000; index++) { - expect(uuid4()).toMatch(uuid4Regex); - } - }); - - it('returns valid uuid v4 ids via crypto.getRandomValues', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const cryptoMod = require('crypto'); - - const crypto = { getRandomValues: cryptoMod.getRandomValues }; - - for (let index = 0; index < 1_000; index++) { - expect(uuid4(crypto)).toMatch(uuid4Regex); + const id = uuid4(); + expect(id).toMatch(uuid4Regex); + uuids.add(id); } + expect(uuids.size).toBe(1_000); }); it('returns valid uuid v4 ids via crypto.randomUUID', () => { // eslint-disable-next-line @typescript-eslint/no-var-requires const cryptoMod = require('crypto'); - const crypto = { getRandomValues: cryptoMod.getRandomValues, randomUUID: cryptoMod.randomUUID }; + const crypto = { randomUUID: cryptoMod.randomUUID }; for (let index = 0; index < 1_000; index++) { expect(uuid4(crypto)).toMatch(uuid4Regex); @@ -321,7 +314,7 @@ describe('uuid4 generation', () => { }); it("return valid uuid v4 even if crypto doesn't exists", () => { - const crypto = { getRandomValues: undefined, randomUUID: undefined }; + const crypto = { randomUUID: undefined }; for (let index = 0; index < 1_000; index++) { expect(uuid4(crypto)).toMatch(uuid4Regex); @@ -330,9 +323,6 @@ describe('uuid4 generation', () => { it('return valid uuid v4 even if crypto invoked causes an error', () => { const crypto = { - getRandomValues: () => { - throw new Error('yo'); - }, randomUUID: () => { throw new Error('yo'); }, @@ -342,25 +332,4 @@ describe('uuid4 generation', () => { expect(uuid4(crypto)).toMatch(uuid4Regex); } }); - - // Corner case related to crypto.getRandomValues being only - // semi-implemented (e.g. Chromium 23.0.1235.0 (151422)) - it('returns valid uuid v4 even if crypto.getRandomValues does not return a typed array', () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const cryptoMod = require('crypto'); - - const getRandomValues = (typedArray: Uint8Array) => { - if (cryptoMod.getRandomValues) { - cryptoMod.getRandomValues(typedArray); - } - }; - - const crypto = { getRandomValues }; - - for (let index = 0; index < 1_000; index++) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - we are testing a corner case - expect(uuid4(crypto)).toMatch(uuid4Regex); - } - }); }); From 5bc35a7a00371799c90a27df2e0d1b7236b18b8e Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 21 Oct 2025 15:11:52 +0200 Subject: [PATCH 23/23] meta(changelog): Update changelog for 10.21.0 --- CHANGELOG.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c68921b62c4..9b9b82b9bc8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.21.0 + +### Important Changes + +- **feat(browserProfiling): Add `trace` lifecycle mode for UI profiling ([#17619](https://github.com/getsentry/sentry-javascript/pull/17619))** + + Adds a new `trace` lifecycle mode for UI profiling, allowing profiles to be captured for the duration of a trace. A `manual` mode will be added in a future release. + +- **feat(nuxt): Instrument Database ([#17899](https://github.com/getsentry/sentry-javascript/pull/17899))** + + Adds instrumentation for Nuxt database operations, enabling better performance tracking of database queries. + +- **feat(nuxt): Instrument server cache API ([#17886](https://github.com/getsentry/sentry-javascript/pull/17886))** + + Adds instrumentation for Nuxt's server cache API, providing visibility into cache operations. + +- **feat(nuxt): Instrument storage API ([#17858](https://github.com/getsentry/sentry-javascript/pull/17858))** + + Adds instrumentation for Nuxt's storage API, enabling tracking of storage operations. + +### Other Changes + +- feat(browser): Add `onRequestSpanEnd` hook to browser tracing integration ([#17884](https://github.com/getsentry/sentry-javascript/pull/17884)) +- feat(nextjs): Support Next.js proxy files ([#17926](https://github.com/getsentry/sentry-javascript/pull/17926)) +- feat(replay): Record outcome when event buffer size exceeded ([#17946](https://github.com/getsentry/sentry-javascript/pull/17946)) +- fix(cloudflare): copy execution context in durable objects and handlers ([#17786](https://github.com/getsentry/sentry-javascript/pull/17786)) +- fix(core): Fix and add missing cache attributes in Vercel AI ([#17982](https://github.com/getsentry/sentry-javascript/pull/17982)) +- fix(core): Improve uuid performance ([#17938](https://github.com/getsentry/sentry-javascript/pull/17938)) +- fix(ember): Use updated version for `clean-css` ([#17979](https://github.com/getsentry/sentry-javascript/pull/17979)) +- fix(nextjs): Don't set experimental instrumentation hook flag for next 16 ([#17978](https://github.com/getsentry/sentry-javascript/pull/17978)) +- fix(nextjs): Inconsistent transaction naming for i18n routing ([#17927](https://github.com/getsentry/sentry-javascript/pull/17927)) +- fix(nextjs): Update bundler detection ([#17976](https://github.com/getsentry/sentry-javascript/pull/17976)) + +
+ Internal Changes + +- build: Update to typescript 5.8.0 ([#17710](https://github.com/getsentry/sentry-javascript/pull/17710)) +- chore: Add external contributor to CHANGELOG.md ([#17949](https://github.com/getsentry/sentry-javascript/pull/17949)) +- chore(build): Upgrade nodemon to 3.1.10 ([#17956](https://github.com/getsentry/sentry-javascript/pull/17956)) +- chore(ci): Fix external contributor action when multiple contributions existed ([#17950](https://github.com/getsentry/sentry-javascript/pull/17950)) +- chore(solid): Remove unnecessary import from README ([#17947](https://github.com/getsentry/sentry-javascript/pull/17947)) +- test(nextjs): Fix proxy/middleware test ([#17970](https://github.com/getsentry/sentry-javascript/pull/17970)) + +
+ Work in this release was contributed by @0xbad0c0d3. Thank you for your contribution! ## 10.20.0