From 0dcdd581cb9637257b57b5698560827ec2b9d225 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:29:01 +0100 Subject: [PATCH 01/10] feat(consola): Enhance Consola integration to extract first-param object as searchable attributes (#19534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the Consola integration so object-first logs are structured and fallback logs get template + parameters. ## Universal principle - **Object-first** (first argument is a plain object): object keys become log attributes, second argument (if string) is the message, remaining arguments → sentry.message.parameter.{0, 1, 2, ...}. - **Fallback** (first argument is not an object): message = formatted(all args), args[1:] → sentry.message.template and sentry.message.parameter.{0, 1, 2, ...} (same as console integration). ## Consola-specific behavior - **Consola-merged**: For `consola.log({ message: "x", userId, action })` Consola passes `args: ["x"]` and spreads the rest on the log object. We detect this (single string in args + extra keys on logObj) and treat it as one logical object: message = `args[0]`, attributes = extra keys. - **Object-first** now applies to any plain object as first arg (including objects with message or args keys), so e.g. `consola.log.raw({ message: "raw-hello" })` produces attributes from the object and an empty message. - **Fallback** uses the same template/parameter pattern as the console integration (no extraction of objects into top-level attributes; all post-first args go into the formatted message and `sentry.message.parameter.*`). ## Example ```ts // Object-first consola.log({ userId: 123, action: "login" }, "User logged in"); // → message: "User logged in", attributes: { userId: 123, action: "login" } // With extra parameters consola.log({ userId: 123 }, "User action", requestId, timestamp); // → message: "User action", userId: 123, sentry.message.parameter.0: requestId, .1: timestamp // Fallback (non-object first) consola.log("Legacy log", { data: 1 }, 123); // → message: "Legacy log {\"data\":1} 123", sentry.message.template: "Legacy log {} {}", sentry.message.parameter.0/1 ``` Console String substitutions are not added as a template attribute because parsing is too complicated on the client-side (see here: https://github.com/getsentry/sentry-javascript/pull/17703) Closes https://github.com/getsentry/sentry-javascript/issues/18593 --- .../suites/consola/subject-object-first.ts | 28 ++ .../suites/consola/test.ts | 51 ++++ packages/core/src/integrations/consola.ts | 134 ++++++++- .../test/lib/integrations/consola.test.ts | 254 +++++++++++++++--- 4 files changed, 423 insertions(+), 44 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/consola/subject-object-first.ts diff --git a/dev-packages/node-integration-tests/suites/consola/subject-object-first.ts b/dev-packages/node-integration-tests/suites/consola/subject-object-first.ts new file mode 100644 index 000000000000..05443b924ab8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/consola/subject-object-first.ts @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { consola } from 'consola'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + enableLogs: true, + transport: loggingTransport, +}); + +async function run(): Promise { + consola.level = 5; + const sentryReporter = Sentry.createConsolaReporter(); + consola.addReporter(sentryReporter); + + // Object-first: args = [object, string] — first object becomes attributes, second arg is part of formatted message + consola.info({ userId: 100, action: 'login' }, 'User logged in'); + + // Object-first: args = [object] only — object keys become attributes, message is stringified object + consola.info({ event: 'click', count: 2 }); + + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +void run(); diff --git a/dev-packages/node-integration-tests/suites/consola/test.ts b/dev-packages/node-integration-tests/suites/consola/test.ts index 2ee47a17dd20..5f5028278e47 100644 --- a/dev-packages/node-integration-tests/suites/consola/test.ts +++ b/dev-packages/node-integration-tests/suites/consola/test.ts @@ -491,4 +491,55 @@ describe('consola integration', () => { await runner.completed(); }); + + test('should capture object-first consola logs (object as first arg)', async () => { + const runner = createRunner(__dirname, 'subject-object-first.ts') + .expect({ + log: { + items: [ + { + timestamp: expect.any(Number), + level: 'info', + body: '{"userId":100,"action":"login"} User logged in', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + userId: { value: 100, type: 'integer' }, + action: { value: 'login', type: 'string' }, + }, + }, + { + timestamp: expect.any(Number), + level: 'info', + body: '{"event":"click","count":2}', + severity_number: expect.any(Number), + trace_id: expect.any(String), + attributes: { + 'sentry.origin': { value: 'auto.log.consola', type: 'string' }, + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'server.address': { value: expect.any(String), type: 'string' }, + 'consola.type': { value: 'info', type: 'string' }, + 'consola.level': { value: 3, type: 'integer' }, + event: { value: 'click', type: 'string' }, + count: { value: 2, type: 'integer' }, + }, + }, + ], + }, + }) + .start(); + + await runner.completed(); + }); }); diff --git a/packages/core/src/integrations/consola.ts b/packages/core/src/integrations/consola.ts index 26ca7b71ab4e..158d2430d4a1 100644 --- a/packages/core/src/integrations/consola.ts +++ b/packages/core/src/integrations/consola.ts @@ -1,10 +1,36 @@ import type { Client } from '../client'; import { getClient } from '../currentScopes'; import { _INTERNAL_captureLog } from '../logs/internal'; -import { formatConsoleArgs } from '../logs/utils'; +import { createConsoleTemplateAttributes, formatConsoleArgs, hasConsoleSubstitutions } from '../logs/utils'; import type { LogSeverityLevel } from '../types-hoist/log'; +import { isPlainObject } from '../utils/is'; import { normalize } from '../utils/normalize'; +/** + * Result of extracting structured attributes from console arguments. + */ +interface ExtractAttributesResult { + /** + * The log message to use for the log entry, typically constructed from the console arguments. + */ + message?: string; + + /** + * The parameterized template string which is added as `sentry.message.template` attribute if applicable. + */ + messageTemplate?: string; + + /** + * Remaining arguments to process as attributes with keys like `sentry.message.parameter.0`, `sentry.message.parameter.1`, etc. + */ + messageParameters?: unknown[]; + + /** + * Additional attributes to add to the log. + */ + attributes?: Record; +} + /** * Options for the Sentry Consola reporter. */ @@ -125,7 +151,7 @@ export interface ConsolaLogObject { /** * The raw arguments passed to the log method. * - * These args are typically formatted into the final `message`. In Consola reporters, `message` is not provided. + * These args are typically formatted into the final `message`. In Consola reporters, `message` is not provided. See: https://github.com/unjs/consola/issues/406#issuecomment-3684792551 * * @example * ```ts @@ -220,16 +246,6 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con const { normalizeDepth = 3, normalizeMaxBreadth = 1_000 } = client.getOptions(); - // Format the log message using the same approach as consola's basic reporter - const messageParts = []; - if (consolaMessage) { - messageParts.push(consolaMessage); - } - if (args && args.length > 0) { - messageParts.push(formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth)); - } - const message = messageParts.join(' '); - const attributes: Record = {}; // Build attributes @@ -252,9 +268,23 @@ export function createConsolaReporter(options: ConsolaReporterOptions = {}): Con attributes['consola.level'] = level; } + const extractionResult = processExtractedAttributes( + defaultExtractAttributes(args, normalizeDepth, normalizeMaxBreadth), + normalizeDepth, + normalizeMaxBreadth, + ); + + if (extractionResult?.attributes) { + Object.assign(attributes, extractionResult.attributes); + } + _INTERNAL_captureLog({ level: logSeverityLevel, - message, + message: + extractionResult?.message || + consolaMessage || + (args && formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth)) || + '', attributes, }); }, @@ -330,3 +360,81 @@ function getLogSeverityLevel(type?: string, level?: number | null): LogSeverityL // Default fallback return 'info'; } + +/** + * Extracts structured attributes from console arguments. If the first argument is a plain object, its properties are extracted as attributes. + */ +function defaultExtractAttributes( + args: unknown[] | undefined, + normalizeDepth: number, + normalizeMaxBreadth: number, +): ExtractAttributesResult { + if (!args?.length) { + return { message: '' }; + } + + // Message looks like how consola logs the message to the console (all args stringified and joined) + const message = formatConsoleArgs(args, normalizeDepth, normalizeMaxBreadth); + + const firstArg = args[0]; + + if (isPlainObject(firstArg)) { + // Remaining args start from index 2 i f we used second arg as message, otherwise from index 1 + const remainingArgsStartIndex = typeof args[1] === 'string' ? 2 : 1; + const remainingArgs = args.slice(remainingArgsStartIndex); + + return { + message, + // Object content from first arg is added as attributes + attributes: firstArg, + // Add remaining args as message parameters + messageParameters: remainingArgs, + }; + } else { + const followingArgs = args.slice(1); + + const shouldAddTemplateAttr = + followingArgs.length > 0 && typeof firstArg === 'string' && !hasConsoleSubstitutions(firstArg); + + return { + message, + messageTemplate: shouldAddTemplateAttr ? firstArg : undefined, + messageParameters: shouldAddTemplateAttr ? followingArgs : undefined, + }; + } +} + +/** + * Processes extracted attributes by normalizing them and preparing message parameter attributes if a template is present. + */ +function processExtractedAttributes( + extractionResult: ExtractAttributesResult, + normalizeDepth: number, + normalizeMaxBreadth: number, +): { message: string | undefined; attributes: Record } { + const { message, attributes, messageTemplate, messageParameters } = extractionResult; + + const messageParamAttributes: Record = {}; + + if (messageTemplate && messageParameters) { + const templateAttrs = createConsoleTemplateAttributes(messageTemplate, messageParameters); + + for (const [key, value] of Object.entries(templateAttrs)) { + messageParamAttributes[key] = key.startsWith('sentry.message.parameter.') + ? normalize(value, normalizeDepth, normalizeMaxBreadth) + : value; + } + } else if (messageParameters && messageParameters.length > 0) { + messageParameters.forEach((arg, index) => { + messageParamAttributes[`sentry.message.parameter.${index}`] = normalize(arg, normalizeDepth, normalizeMaxBreadth); + }); + } + + return { + message: message, + attributes: { + ...normalize(attributes, normalizeDepth, normalizeMaxBreadth), + ...messageParamAttributes, + }, + }; +} diff --git a/packages/core/test/lib/integrations/consola.test.ts b/packages/core/test/lib/integrations/consola.test.ts index e1a32b775e54..0ab7a3cc1e98 100644 --- a/packages/core/test/lib/integrations/consola.test.ts +++ b/packages/core/test/lib/integrations/consola.test.ts @@ -62,13 +62,81 @@ describe('createConsolaReporter', () => { }); describe('message and args handling', () => { + describe('calling consola with object-only', () => { + it('args=[object] with message key uses only message as log message and other keys as attributes', () => { + sentryReporter.log({ + type: 'log', + level: 2, + tag: '', + // Calling consola with a `message` key like below will format the log object like here in this test + args: ['Calling: consola.log({ message: "", time: new Date(), userId: 123, smallObj: { word: "hi" } })'], + time: '2026-02-24T10:24:04.477Z', + userId: 123, + smallObj: { word: 'hi' }, + }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + 'Calling: consola.log({ message: "", time: new Date(), userId: 123, smallObj: { word: "hi" } })', + ); + expect(call.attributes).toMatchObject({ + time: '2026-02-24T10:24:04.477Z', + userId: 123, + smallObj: { word: 'hi' }, + }); + }); + + it('args=[object] with no message key uses empty message and object as attributes', () => { + sentryReporter.log({ + type: 'log', + level: 2, + tag: '', + args: [ + { + noMessage: 'Calling: consola.log({ noMessage: "", time: new Date() })', + time: '2026-02-24T10:24:04.477Z', + }, + ], + }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + '{"noMessage":"Calling: consola.log({ noMessage: \\"\\", time: new Date() })","time":"2026-02-24T10:24:04.477Z"}', + ); + expect(call.attributes).toMatchObject({ + noMessage: 'Calling: consola.log({ noMessage: "", time: new Date() })', + time: '2026-02-24T10:24:04.477Z', + }); + }); + + it('args=[object with message] keeps message in attributes only (e.g. .raw())', () => { + sentryReporter.log({ + type: 'log', + level: 2, + tag: '', + args: [ + { + message: 'Calling: consola.raw({ message: "", userId: 123, smallObj: { word: "hi" } })', + userId: 123, + smallObj: { word: 'hi' }, + }, + ], + }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + '{"message":"Calling: consola.raw({ message: \\"\\", userId: 123, smallObj: { word: \\"hi\\" } })","userId":123,"smallObj":{"word":"hi"}}', + ); + expect(call.attributes).toMatchObject({ + message: 'Calling: consola.raw({ message: "", userId: 123, smallObj: { word: "hi" } })', + userId: 123, + smallObj: { word: 'hi' }, + }); + }); + }); + it('should format message from args', () => { - const logObj = { + sentryReporter.log({ type: 'info', args: ['Hello', 'world', 123, { key: 'value' }], - }; - - sentryReporter.log(logObj); + }); expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ @@ -77,20 +145,154 @@ describe('createConsolaReporter', () => { attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', + 'sentry.message.parameter.0': 'world', + 'sentry.message.parameter.1': 123, + 'sentry.message.parameter.2': { key: 'value' }, + 'sentry.message.template': 'Hello {} {} {}', }, }); }); + it('uses consolaMessage when result.message is empty (e.g. args is [])', () => { + sentryReporter.log({ + type: 'info', + message: 'From consola message key', + args: [], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('From consola message key'); + }); + + it('uses formatConsoleArgs when result.message and consolaMessage are falsy but args is truthy', () => { + sentryReporter.log({ + type: 'info', + args: [], + }); + + expect(formatConsoleArgs).toHaveBeenCalledWith([], 3, 1000); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe(''); + }); + + it('overrides consola.tag or sentry.origin with object properties', () => { + sentryReporter.log({ + type: 'info', + message: 'Test', + tag: 'api', + args: [{ 'sentry.origin': 'object-args', 'consola.tag': 'object-args-tag' }, 'Test'], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.origin']).toBe('object-args'); + expect(call.attributes?.['consola.tag']).toBe('object-args-tag'); + }); + + it('respects normalizeDepth in fallback mode', () => { + sentryReporter.log({ + type: 'info', + args: [ + 'Deep', + { + level1: { level2: { level3: { level4: 'deep' } } }, + simpleKey: 'simple value', + }, + ], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.attributes?.['sentry.message.parameter.0']).toEqual({ + level1: { level2: { level3: '[Object]' } }, + simpleKey: 'simple value', + }); + }); + + it('adds additional params in object-first mode', () => { + sentryReporter.log({ + type: 'info', + args: [ + { + level1: { level2: { level3: { level4: 'deep' } } }, + simpleKey: 'simple value', + }, + 'Deep object', + 12345, + { another: 'object', level1: { level2: { level3: { level4: 'deep' } } } }, + ], + }); + + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe( + '{"level1":{"level2":{"level3":"[Object]"}},"simpleKey":"simple value"} Deep object 12345 {"another":"object","level1":{"level2":{"level3":"[Object]"}}}', + ); + expect(call.attributes?.level1).toEqual({ level2: { level3: '[Object]' } }); + expect(call.attributes?.simpleKey).toBe('simple value'); + + expect(call.attributes?.['sentry.message.template']).toBeUndefined(); + expect(call.attributes?.['sentry.message.parameter.0']).toBe(12345); + expect(call.attributes?.['sentry.message.parameter.1']).toStrictEqual({ + another: 'object', + level1: { level2: { level3: '[Object]' } }, + }); + }); + + it('stores Date and Error in message params (fallback)', () => { + const date = new Date('2023-01-01T00:00:00.000Z'); + sentryReporter.log({ type: 'info', args: ['Time:', date] }); + expect(vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]!.attributes?.['sentry.message.parameter.0']).toBe( + '2023-01-01T00:00:00.000Z', + ); + + vi.clearAllMocks(); + const err = new Error('Test error'); + sentryReporter.log({ type: 'error', args: ['Error occurred:', err] }); + const errCall = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(errCall.attributes?.['sentry.message.parameter.0']).toMatchObject({ + message: 'Test error', + name: 'Error', + }); + }); + + it('handles console substitution patterns in first arg', () => { + sentryReporter.log({ type: 'info', args: ['Value: %d, another: %s', 42, 'hello'] }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + + // We don't substitute as it gets too complicated on the client-side: https://github.com/getsentry/sentry-javascript/pull/17703 + expect(call.message).toBe('Value: %d, another: %s 42 hello'); + expect(call.attributes?.['sentry.message.template']).toBeUndefined(); + expect(call.attributes?.['sentry.message.parameter.0']).toBeUndefined(); + }); + + it.each([ + ['string', ['Normal log', { data: 1 }, 123], 'Normal log {} {}', undefined], + ['array', [[1, 2, 3], 'Array data'], undefined, undefined], + ['Error', [new Error('Test'), 'Error occurred'], undefined, 'error'], + ] as const)('falls back to non-object extracting when first arg is %s', (_, args, template, level) => { + vi.clearAllMocks(); + // @ts-expect-error Testing legacy fallback + sentryReporter.log({ type: level ?? 'info', args }); + expect(formatConsoleArgs).toHaveBeenCalled(); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + if (template !== undefined) expect(call.attributes?.['sentry.message.template']).toBe(template); + if (template === 'Normal log {} {}') expect(call.attributes?.data).toBeUndefined(); + if (level) expect(call.level).toBe(level); + }); + + it('object-first: empty object as first arg', () => { + sentryReporter.log({ type: 'info', args: [{}, 'Empty object log'] }); + const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; + expect(call.message).toBe('{} Empty object log'); + expect(call.attributes?.['sentry.origin']).toBe('auto.log.consola'); + }); + it('should handle args with unparseable objects', () => { const circular: any = {}; circular.self = circular; - const logObj = { + sentryReporter.log({ type: 'info', args: ['Message', circular], - }; - - sentryReporter.log(logObj); + }); expect(_INTERNAL_captureLog).toHaveBeenCalledWith({ level: 'info', @@ -98,39 +300,29 @@ describe('createConsolaReporter', () => { attributes: { 'sentry.origin': 'auto.log.consola', 'consola.type': 'info', + 'sentry.message.template': 'Message {}', + 'sentry.message.parameter.0': { self: '[Circular ~]' }, }, }); }); - it('consola-merged: args=[message] with extra keys on log object', () => { + it('formats message from args when message not provided (template + params)', () => { sentryReporter.log({ - type: 'log', - level: 2, - args: ['Hello', 'world', { some: 'obj' }], - userId: 123, - action: 'login', - time: '2026-02-24T10:24:04.477Z', - smallObj: { firstLevel: { secondLevel: { thirdLevel: { fourthLevel: 'deep' } } } }, - tag: '', + type: 'info', + args: ['Hello', 'world', 123, { key: 'value' }], }); + expect(formatConsoleArgs).toHaveBeenCalledWith(['Hello', 'world', 123, { key: 'value' }], 3, 1000); const call = vi.mocked(_INTERNAL_captureLog).mock.calls[0]![0]; - - // Message from args - expect(call.message).toBe('Hello world {"some":"obj"}'); - expect(call.attributes).toMatchObject({ - 'consola.type': 'log', - 'consola.level': 2, - userId: 123, - smallObj: { firstLevel: { secondLevel: { thirdLevel: '[Object]' } } }, // Object is normalized - action: 'login', - time: '2026-02-24T10:24:04.477Z', - 'sentry.origin': 'auto.log.consola', - }); - expect(call.attributes?.['sentry.message.parameter.0']).toBeUndefined(); + expect(call.level).toBe('info'); + expect(call.message).toContain('Hello'); + expect(call.attributes?.['sentry.message.template']).toBe('Hello {} {} {}'); + expect(call.attributes?.['sentry.message.parameter.0']).toBe('world'); + expect(call.attributes?.['sentry.message.parameter.1']).toBe(123); + expect(call.attributes?.['sentry.message.parameter.2']).toEqual({ key: 'value' }); }); - it('capturing custom keys mimicking direct reporter.log({ type, message, userId, sessionId })', () => { + it('Uses "message" key as fallback message, when no args are available', () => { sentryReporter.log({ type: 'info', message: 'User action', From 61b1f3f203d645cdedb72d3f10e82ff435657acc Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:47:05 +0100 Subject: [PATCH 02/10] ref(nuxt): Use `addVitePlugin` instead of deprecated `vite:extendConfig` (#19464) `vite:extendConfig` is deprecated, so source maps handling will be done in a plugin which is added with `addVitePlugin` from Nuxt. Also updated the existing tests so they can test the new plugin functionality. Closes https://github.com/getsentry/sentry-javascript/issues/19345 --- packages/nuxt/src/module.ts | 3 +- packages/nuxt/src/vite/sentryVitePlugin.ts | 57 +++ packages/nuxt/src/vite/sourceMaps.ts | 72 ++-- .../test/vite/sourceMaps-nuxtHooks.test.ts | 327 ++++++++++++++---- packages/nuxt/test/vite/sourceMaps.test.ts | 114 ++++-- 5 files changed, 435 insertions(+), 138 deletions(-) create mode 100644 packages/nuxt/src/vite/sentryVitePlugin.ts diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index f2968d70482d..55656e103738 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -3,6 +3,7 @@ import { addPluginTemplate, addServerPlugin, addTemplate, + addVitePlugin, createResolver, defineNuxtModule, } from '@nuxt/kit'; @@ -88,7 +89,7 @@ export default defineNuxtModule({ } if (clientConfigFile || serverConfigFile) { - setupSourceMaps(moduleOptions, nuxt); + setupSourceMaps(moduleOptions, nuxt, addVitePlugin); } addOTelCommonJSImportAlias(nuxt); diff --git a/packages/nuxt/src/vite/sentryVitePlugin.ts b/packages/nuxt/src/vite/sentryVitePlugin.ts new file mode 100644 index 000000000000..78c11110bf72 --- /dev/null +++ b/packages/nuxt/src/vite/sentryVitePlugin.ts @@ -0,0 +1,57 @@ +import type { Nuxt } from '@nuxt/schema'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import type { ConfigEnv, Plugin, UserConfig } from 'vite'; +import type { SentryNuxtModuleOptions } from '../common/types'; +import { extractNuxtSourceMapSetting, getPluginOptions, validateDifferentSourceMapSettings } from './sourceMaps'; + +/** + * Creates a Vite plugin that adds the Sentry Vite plugin and validates source map settings. + */ +export function createSentryViteConfigPlugin(options: { + nuxt: Nuxt; + moduleOptions: SentryNuxtModuleOptions; + sourceMapsEnabled: boolean; + shouldDeleteFilesFallback: { client: boolean; server: boolean }; +}): Plugin { + const { nuxt, moduleOptions, sourceMapsEnabled, shouldDeleteFilesFallback } = options; + const isDebug = moduleOptions.debug; + + return { + name: 'sentry-nuxt-vite-config', + config(viteConfig: UserConfig, env: ConfigEnv) { + // Only run in production builds + if (!sourceMapsEnabled || env.mode === 'development' || nuxt.options?._prepare) { + return; + } + + // Detect runtime from Vite config + // In Nuxt, SSR builds have build.ssr: true, client builds don't + const runtime = viteConfig.build?.ssr ? 'server' : 'client'; + + const nuxtSourceMapSetting = extractNuxtSourceMapSetting(nuxt, runtime); + + // Initialize build config if needed + viteConfig.build = viteConfig.build || {}; + const viteSourceMap = viteConfig.build.sourcemap; + + // Vite source map options are the same as the Nuxt source map config options (unless overwritten) + validateDifferentSourceMapSettings({ + nuxtSettingKey: `sourcemap.${runtime}`, + nuxtSettingValue: nuxtSourceMapSetting, + otherSettingKey: 'viteConfig.build.sourcemap', + otherSettingValue: viteSourceMap, + }); + + if (isDebug) { + // eslint-disable-next-line no-console + console.log(`[Sentry] Adding Sentry Vite plugin to the ${runtime} runtime.`); + } + + // Add Sentry plugin by mutating the config + // Vite plugin is added on the client and server side (plugin runs for both builds) + // Nuxt client source map is 'false' by default. Warning about this will be shown already in an earlier step, and it's also documented that `nuxt.sourcemap.client` needs to be enabled. + viteConfig.plugins = viteConfig.plugins || []; + viteConfig.plugins.push(sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))); + }, + }; +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index 771be8d3d532..b270a34a50b5 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -1,8 +1,10 @@ import type { Nuxt } from '@nuxt/schema'; import { sentryRollupPlugin, type SentryRollupPluginOptions } from '@sentry/rollup-plugin'; -import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plugin'; +import type { SentryVitePluginOptions } from '@sentry/vite-plugin'; import type { NitroConfig } from 'nitropack'; +import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; +import { createSentryViteConfigPlugin } from './sentryVitePlugin'; /** * Whether the user enabled (true, 'hidden', 'inline') or disabled (false) source maps @@ -15,7 +17,11 @@ export type SourceMapSetting = boolean | 'hidden' | 'inline'; /** * Setup source maps for Sentry inside the Nuxt module during build time (in Vite for Nuxt and Rollup for Nitro). */ -export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nuxt): void { +export function setupSourceMaps( + moduleOptions: SentryNuxtModuleOptions, + nuxt: Nuxt, + addVitePlugin: (plugin: Plugin | (() => Plugin), options?: { dev?: boolean; build?: boolean }) => void, +): void { // TODO(v11): remove deprecated options (also from SentryNuxtModuleOptions type) const isDebug = moduleOptions.debug; @@ -32,7 +38,7 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu (sourceMapsUploadOptions.enabled ?? true); // In case we overwrite the source map settings, we default to deleting the files - let shouldDeleteFilesFallback = { client: true, server: true }; + const shouldDeleteFilesFallback = { client: true, server: true }; nuxt.hook('modules:done', () => { if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { @@ -41,13 +47,12 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu // - for server to viteConfig.build.sourceMap and nitro.sourceMap // On server, nitro.rollupConfig.output.sourcemap remains unaffected from this change. - // ONLY THIS nuxt.sourcemap.(server/client) setting is the one Sentry will eventually overwrite with 'hidden' + // ONLY THIS nuxt.sourcemap.(server/client) setting is the one Sentry will overwrite with 'hidden', if needed. const previousSourceMapSettings = changeNuxtSourceMapSettings(nuxt, moduleOptions); - shouldDeleteFilesFallback = { - client: previousSourceMapSettings.client === 'unset', - server: previousSourceMapSettings.server === 'unset', - }; + // Mutate in place so the Vite plugin (which captured this object at registration time) sees the updated values + shouldDeleteFilesFallback.client = previousSourceMapSettings.client === 'unset'; + shouldDeleteFilesFallback.server = previousSourceMapSettings.server === 'unset'; if (isDebug && (shouldDeleteFilesFallback.client || shouldDeleteFilesFallback.server)) { const enabledDeleteFallbacks = @@ -76,39 +81,16 @@ export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nu } }); - nuxt.hook('vite:extendConfig', async (viteConfig, env) => { - if (sourceMapsEnabled && viteConfig.mode !== 'development' && !nuxt.options?._prepare) { - const runtime = env.isServer ? 'server' : env.isClient ? 'client' : undefined; - const nuxtSourceMapSetting = extractNuxtSourceMapSetting(nuxt, runtime); - - viteConfig.build = viteConfig.build || {}; - const viteSourceMap = viteConfig.build.sourcemap; - - // Vite source map options are the same as the Nuxt source map config options (unless overwritten) - validateDifferentSourceMapSettings({ - nuxtSettingKey: `sourcemap.${runtime}`, - nuxtSettingValue: nuxtSourceMapSetting, - otherSettingKey: 'viteConfig.build.sourcemap', - otherSettingValue: viteSourceMap, - }); - - if (isDebug) { - if (!runtime) { - // eslint-disable-next-line no-console - console.log("[Sentry] Cannot detect runtime (client/server) inside hook 'vite:extendConfig'."); - } else { - // eslint-disable-next-line no-console - console.log(`[Sentry] Adding Sentry Vite plugin to the ${runtime} runtime.`); - } - } - - // Add Sentry plugin - // Vite plugin is added on the client and server side (hook runs twice) - // Nuxt client source map is 'false' by default. Warning about this will be shown already in an earlier step, and it's also documented that `nuxt.sourcemap.client` needs to be enabled. - viteConfig.plugins = viteConfig.plugins || []; - viteConfig.plugins.push(sentryVitePlugin(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))); - } - }); + addVitePlugin( + createSentryViteConfigPlugin({ + nuxt, + moduleOptions, + sourceMapsEnabled, + shouldDeleteFilesFallback, + }), + // Only add source map plugin during build + { dev: false, build: true }, + ); nuxt.hook('nitro:config', (nitroConfig: NitroConfig) => { if (sourceMapsEnabled && !nitroConfig.dev && !nuxt.options?._prepare) { @@ -379,7 +361,13 @@ export function validateNitroSourceMapSettings( } } -function validateDifferentSourceMapSettings({ +/** + * Validates that source map settings are consistent between Nuxt and Vite/Nitro configurations. + * Logs a warning if conflicting settings are detected. + * + * @internal Only exported for testing. + */ +export function validateDifferentSourceMapSettings({ nuxtSettingKey, nuxtSettingValue, otherSettingKey, diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 230c92b812a7..4a881583ac93 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -1,7 +1,50 @@ import type { Nuxt } from '@nuxt/schema'; +import type { Plugin, UserConfig } from 'vite'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { SourceMapSetting } from '../../src/vite/sourceMaps'; +function createMockAddVitePlugin() { + let capturedPlugin: Plugin | null = null; + + const mockAddVitePlugin = vi.fn((plugin: Plugin | (() => Plugin)) => { + capturedPlugin = typeof plugin === 'function' ? plugin() : plugin; + }); + + return { + mockAddVitePlugin, + getCapturedPlugin: () => capturedPlugin, + }; +} + +type HookCallback = (...args: unknown[]) => void | Promise; + +function createMockNuxt(options: { + _prepare?: boolean; + dev?: boolean; + sourcemap?: SourceMapSetting | { server?: SourceMapSetting; client?: SourceMapSetting }; +}) { + const hooks: Record = {}; + + return { + options: { + _prepare: options._prepare ?? false, + dev: options.dev ?? false, + sourcemap: options.sourcemap ?? { server: undefined, client: undefined }, + }, + hook: (name: string, callback: HookCallback) => { + hooks[name] = hooks[name] || []; + hooks[name].push(callback); + }, + // Helper to trigger hooks in tests + triggerHook: async (name: string, ...args: unknown[]) => { + const callbacks = hooks[name] || []; + for (const callback of callbacks) { + await callback(...args); + } + }, + }; +} + describe('setupSourceMaps hooks', () => { const mockSentryVitePlugin = vi.fn(() => ({ name: 'sentry-vite-plugin' })); const mockSentryRollupPlugin = vi.fn(() => ({ name: 'sentry-rollup-plugin' })); @@ -32,93 +75,247 @@ describe('setupSourceMaps hooks', () => { mockSentryRollupPlugin.mockClear(); }); - type HookCallback = (...args: unknown[]) => void | Promise; + describe('vite plugin registration', () => { + it('calls `addVitePlugin` when setupSourceMaps is called', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); - function createMockNuxt(options: { - _prepare?: boolean; - dev?: boolean; - sourcemap?: SourceMapSetting | { server?: SourceMapSetting; client?: SourceMapSetting }; - }) { - const hooks: Record = {}; + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); - return { - options: { - _prepare: options._prepare ?? false, - dev: options.dev ?? false, - sourcemap: options.sourcemap ?? { server: undefined, client: undefined }, - }, - hook: (name: string, callback: HookCallback) => { - hooks[name] = hooks[name] || []; - hooks[name].push(callback); + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + expect(plugin?.name).toBe('sentry-nuxt-vite-config'); + // modules:done is called afterward. Later, the plugin is actually added + }); + + it.each([ + { + label: 'prepare mode', + nuxtOptions: { _prepare: true }, + viteOptions: { mode: 'production', command: 'build' as const }, + buildConfig: { build: {}, plugins: [] }, }, - // Helper to trigger hooks in tests - triggerHook: async (name: string, ...args: unknown[]) => { - const callbacks = hooks[name] || []; - for (const callback of callbacks) { - await callback(...args); - } + { + label: 'dev mode', + nuxtOptions: { dev: true }, + viteOptions: { mode: 'development', command: 'build' as const }, + buildConfig: { build: {}, plugins: [] }, }, - }; - } + ])('does not add plugins to vite config in $label', async ({ nuxtOptions, viteOptions, buildConfig }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + + if (plugin && typeof plugin.config === 'function') { + const viteConfig: UserConfig = buildConfig; + plugin.config(viteConfig, viteOptions); + expect(viteConfig.plugins?.length).toBe(0); + } + }); + + it.each([ + { label: 'server (SSR) build', buildConfig: { build: { ssr: true }, plugins: [] } }, + { label: 'client build', buildConfig: { build: { ssr: false }, plugins: [] } }, + ])('adds sentry vite plugin to vite config for $label in production', async ({ buildConfig }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + + if (plugin && typeof plugin.config === 'function') { + const viteConfig: UserConfig = buildConfig; + plugin.config(viteConfig, { mode: 'production', command: 'build' }); + expect(viteConfig.plugins?.length).toBeGreaterThan(0); + } + }); + }); + + describe('sentry vite plugin calls', () => { + it('calls sentryVitePlugin in production mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); - it('should not call any source map related functions in nuxt prepare mode', async () => { - const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); - const mockNuxt = createMockNuxt({ _prepare: true }); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); - setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt); + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } - await mockNuxt.triggerHook('modules:done'); - await mockNuxt.triggerHook( - 'vite:extendConfig', - { build: {}, plugins: [], mode: 'production' }, - { isServer: true, isClient: false }, - ); - await mockNuxt.triggerHook('nitro:config', { rollupConfig: { plugins: [] }, dev: false }); + expect(mockSentryVitePlugin).toHaveBeenCalled(); + }); - expect(mockSentryVitePlugin).not.toHaveBeenCalled(); - expect(mockSentryRollupPlugin).not.toHaveBeenCalled(); + it.each([ + { label: 'prepare mode', nuxtOptions: { _prepare: true }, viteMode: 'production' as const }, + { label: 'dev mode', nuxtOptions: { dev: true }, viteMode: 'development' as const }, + ])('does not call sentryVitePlugin in $label', async ({ nuxtOptions, viteMode }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); - expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('[Sentry]')); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: {}, plugins: [] }, { mode: viteMode, command: 'build' }); + } + + expect(mockSentryVitePlugin).not.toHaveBeenCalled(); + }); }); - it('should call source map related functions when not in prepare mode', async () => { - const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); - const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + describe('shouldDeleteFilesFallback passed to getPluginOptions in Vite plugin', () => { + const defaultFilesToDeleteAfterUpload = [ + '.*/**/public/**/*.map', + '.*/**/server/**/*.map', + '.*/**/output/**/*.map', + '.*/**/function/**/*.map', + ]; + + it('uses mutated shouldDeleteFilesFallback (unset → true): plugin.config() after modules:done gets fallback filesToDeleteAfterUpload', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: undefined, server: undefined }, + }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } + + expect(mockSentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ + filesToDeleteAfterUpload: defaultFilesToDeleteAfterUpload, + }), + }), + ); + }); + + it('uses mutated shouldDeleteFilesFallback (explicitly enabled → false): plugin.config() after modules:done gets no filesToDeleteAfterUpload', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ + _prepare: false, + dev: false, + sourcemap: { client: true, server: true }, + }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + expect(plugin).not.toBeNull(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } - setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt); + const pluginOptions = (mockSentryVitePlugin?.mock?.calls?.[0] as unknown[])?.[0] as { + sourcemaps?: { filesToDeleteAfterUpload?: string[] }; + }; + expect(pluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeUndefined(); + }); + }); + + describe('nitro:config hook', () => { + it('adds sentryRollupPlugin to nitro rollup config in production mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin } = createMockAddVitePlugin(); - await mockNuxt.triggerHook('modules:done'); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); - const viteConfig = { build: {}, plugins: [] as unknown[], mode: 'production' }; - await mockNuxt.triggerHook('vite:extendConfig', viteConfig, { isServer: true, isClient: false }); + const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; + await mockNuxt.triggerHook('nitro:config', nitroConfig); - const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; - await mockNuxt.triggerHook('nitro:config', nitroConfig); + expect(mockSentryRollupPlugin).toHaveBeenCalled(); + expect(nitroConfig.rollupConfig.plugins.length).toBeGreaterThan(0); + }); - expect(mockSentryVitePlugin).toHaveBeenCalled(); - expect(mockSentryRollupPlugin).toHaveBeenCalled(); + it.each([ + { + label: 'prepare mode', + nuxtOptions: { _prepare: true }, + nitroConfig: { rollupConfig: { plugins: [] }, dev: false }, + }, + { label: 'dev mode', nuxtOptions: { dev: true }, nitroConfig: { rollupConfig: { plugins: [] }, dev: true } }, + ])('does not add sentryRollupPlugin to nitro rollup config in $label', async ({ nuxtOptions, nitroConfig }) => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt(nuxtOptions); + const { mockAddVitePlugin } = createMockAddVitePlugin(); - expect(viteConfig.plugins.length).toBeGreaterThan(0); - expect(nitroConfig.rollupConfig.plugins.length).toBeGreaterThan(0); + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:config', nitroConfig); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('[Sentry]')); + expect(mockSentryRollupPlugin).not.toHaveBeenCalled(); + }); }); - it('should not call source map related functions in dev mode', async () => { - const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); - const mockNuxt = createMockNuxt({ _prepare: false, dev: true }); + describe('debug logging', () => { + it('logs a [Sentry] message in production mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); + + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: { ssr: false }, plugins: [] }, { mode: 'production', command: 'build' }); + } + + const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: {} }, dev: false }; + await mockNuxt.triggerHook('nitro:config', nitroConfig); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[Sentry] Adding Sentry Vite plugin to the client runtime.'), + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[Sentry] Adding Sentry Rollup plugin to the server runtime.'), + ); + }); + + it('does not log a [Sentry] messages in prepare mode', async () => { + const { setupSourceMaps } = await import('../../src/vite/sourceMaps'); + const mockNuxt = createMockNuxt({ _prepare: true }); + const { mockAddVitePlugin, getCapturedPlugin } = createMockAddVitePlugin(); + + setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt, mockAddVitePlugin); + await mockNuxt.triggerHook('modules:done'); - setupSourceMaps({ debug: true }, mockNuxt as unknown as Nuxt); + const plugin = getCapturedPlugin(); + if (plugin && typeof plugin.config === 'function') { + plugin.config({ build: {}, plugins: [] }, { mode: 'production', command: 'build' }); + } - await mockNuxt.triggerHook('modules:done'); - await mockNuxt.triggerHook( - 'vite:extendConfig', - { build: {}, plugins: [], mode: 'development' }, - { isServer: true, isClient: false }, - ); - await mockNuxt.triggerHook('nitro:config', { rollupConfig: { plugins: [] }, dev: true }); + await mockNuxt.triggerHook('nitro:config', { rollupConfig: { plugins: [] }, dev: false }); - expect(mockSentryVitePlugin).not.toHaveBeenCalled(); - expect(mockSentryRollupPlugin).not.toHaveBeenCalled(); + expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining('[Sentry]')); + }); }); }); diff --git a/packages/nuxt/test/vite/sourceMaps.test.ts b/packages/nuxt/test/vite/sourceMaps.test.ts index e4ae498639b0..87e87d14b635 100644 --- a/packages/nuxt/test/vite/sourceMaps.test.ts +++ b/packages/nuxt/test/vite/sourceMaps.test.ts @@ -4,7 +4,9 @@ import type { SentryNuxtModuleOptions } from '../../src/common/types'; import type { SourceMapSetting } from '../../src/vite/sourceMaps'; import { changeNuxtSourceMapSettings, + extractNuxtSourceMapSetting, getPluginOptions, + validateDifferentSourceMapSettings, validateNitroSourceMapSettings, } from '../../src/vite/sourceMaps'; @@ -35,6 +37,7 @@ describe('getPluginOptions', () => { authToken: 'default-token', url: 'https://santry.io', telemetry: true, + debug: false, sourcemaps: expect.objectContaining({ rewriteSources: expect.any(Function), }), @@ -43,7 +46,6 @@ describe('getPluginOptions', () => { metaFramework: 'nuxt', }), }), - debug: false, }), ); }); @@ -57,6 +59,7 @@ describe('getPluginOptions', () => { expect(options).toEqual( expect.objectContaining({ telemetry: true, + debug: false, sourcemaps: expect.objectContaining({ rewriteSources: expect.any(Function), }), @@ -65,7 +68,6 @@ describe('getPluginOptions', () => { metaFramework: 'nuxt', }), }), - debug: false, }), ); }); @@ -108,6 +110,14 @@ describe('getPluginOptions', () => { ); }); + it('normalizes source paths via rewriteSources', () => { + const options = getPluginOptions({} as SentryNuxtModuleOptions, undefined); + const rewrite = options.sourcemaps?.rewriteSources as ((s: string) => string) | undefined; + expect(rewrite).toBeTypeOf('function'); + expect(rewrite!('../../../foo/bar')).toBe('./foo/bar'); + expect(rewrite!('./local')).toBe('./local'); + }); + it('prioritizes new BuildTimeOptionsBase options over deprecated ones', () => { const options: SentryNuxtModuleOptions = { // New options @@ -268,27 +278,19 @@ describe('getPluginOptions', () => { name: 'both client and server fallback are true', clientFallback: true, serverFallback: true, - customOptions: {}, - expectedFilesToDelete: [ - '.*/**/public/**/*.map', - '.*/**/server/**/*.map', - '.*/**/output/**/*.map', - '.*/**/function/**/*.map', - ], + expected: ['.*/**/public/**/*.map', '.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], }, { name: 'only client fallback is true', clientFallback: true, serverFallback: false, - customOptions: {}, - expectedFilesToDelete: ['.*/**/public/**/*.map'], + expected: ['.*/**/public/**/*.map'], }, { name: 'only server fallback is true', clientFallback: false, serverFallback: true, - customOptions: {}, - expectedFilesToDelete: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], + expected: ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map'], }, { name: 'no fallback, but custom filesToDeleteAfterUpload is provided (deprecated)', @@ -299,7 +301,7 @@ describe('getPluginOptions', () => { sourcemaps: { filesToDeleteAfterUpload: ['deprecated/path/**/*.map'] }, }, }, - expectedFilesToDelete: ['deprecated/path/**/*.map'], + expected: ['deprecated/path/**/*.map'], }, { name: 'no fallback, but custom filesToDeleteAfterUpload is provided (new)', @@ -308,46 +310,95 @@ describe('getPluginOptions', () => { customOptions: { sourcemaps: { filesToDeleteAfterUpload: ['new-custom/path/**/*.map'] }, }, - expectedFilesToDelete: ['new-custom/path/**/*.map'], + expected: ['new-custom/path/**/*.map'], }, { name: 'no fallback, both source maps explicitly false and no custom filesToDeleteAfterUpload', clientFallback: false, serverFallback: false, customOptions: {}, - expectedFilesToDelete: undefined, + expected: undefined, }, ])( 'sets filesToDeleteAfterUpload correctly when $name', - ({ clientFallback, serverFallback, customOptions, expectedFilesToDelete }) => { + ({ clientFallback, serverFallback, customOptions = {}, expected }) => { const options = getPluginOptions(customOptions as SentryNuxtModuleOptions, { client: clientFallback, server: serverFallback, }); - expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expectedFilesToDelete); + expect(options?.sourcemaps?.filesToDeleteAfterUpload).toEqual(expected); }, ); }); -describe('validate sourcemap settings', () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +describe('validateDifferentSourceMapSettings', () => { + let consoleWarnSpy: ReturnType; beforeEach(() => { - consoleLogSpy.mockClear(); - consoleWarnSpy.mockClear(); + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { - vi.clearAllMocks(); + consoleWarnSpy.mockRestore(); }); - describe('should handle nitroConfig.rollupConfig.output.sourcemap settings', () => { - afterEach(() => { - vi.clearAllMocks(); + it('does not warn when both settings match', () => { + validateDifferentSourceMapSettings({ + nuxtSettingKey: 'sourcemap.server', + nuxtSettingValue: true, + otherSettingKey: 'nitro.sourceMap', + otherSettingValue: true, + }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('warns when settings conflict', () => { + validateDifferentSourceMapSettings({ + nuxtSettingKey: 'sourcemap.server', + nuxtSettingValue: true, + otherSettingKey: 'nitro.sourceMap', + otherSettingValue: false, }); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('sourcemap.server')); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('nitro.sourceMap')); + }); +}); +describe('extractNuxtSourceMapSetting', () => { + it.each<{ + runtime: 'client' | 'server' | undefined; + sourcemap: SourceMapSetting | { client?: SourceMapSetting; server?: SourceMapSetting }; + expected: SourceMapSetting | undefined; + }>([ + { runtime: undefined, sourcemap: true, expected: undefined }, + { runtime: 'client', sourcemap: true, expected: true }, + { runtime: 'server', sourcemap: 'hidden', expected: 'hidden' }, + { runtime: 'client', sourcemap: { client: true, server: false }, expected: true }, + { runtime: 'server', sourcemap: { client: true, server: 'hidden' }, expected: 'hidden' }, + ])('returns correct value for runtime=$runtime and sourcemap type', ({ runtime, sourcemap, expected }) => { + const nuxt = { options: { sourcemap } }; + expect(extractNuxtSourceMapSetting(nuxt as Parameters[0], runtime)).toBe( + expected, + ); + }); +}); + +describe('validate sourcemap settings', () => { + let consoleWarnSpy: ReturnType; + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + describe('should handle nitroConfig.rollupConfig.output.sourcemap settings', () => { type MinimalNitroConfig = { sourceMap?: SourceMapSetting; rollupConfig?: { @@ -401,17 +452,20 @@ describe('validate sourcemap settings', () => { describe('change Nuxt source map settings', () => { let nuxt: { options: { sourcemap: { client: boolean | 'hidden'; server: boolean | 'hidden' } } }; let sentryModuleOptions: SentryNuxtModuleOptions; - - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + let consoleLogSpy: ReturnType; beforeEach(() => { - consoleLogSpy.mockClear(); + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); // @ts-expect-error - Nuxt types don't accept `undefined` but we want to test this case nuxt = { options: { sourcemap: { client: undefined } } }; sentryModuleOptions = {}; }); + afterEach(() => { + consoleLogSpy.mockRestore(); + }); + it.each([ { clientSourcemap: false, expectedSourcemap: false, expectedReturn: 'disabled' }, { clientSourcemap: 'hidden', expectedSourcemap: 'hidden', expectedReturn: 'enabled' }, From 116c3f3fa9840f3a68ba17b232856859973983f8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 2 Mar 2026 16:21:35 +0100 Subject: [PATCH 03/10] fix(deps): Bump fast-xml-parser to 4.5.4 for CVE-2026-25896 (#19588) Update lockfile to resolve fast-xml-parser@^4.4.1 (transitive via @langchain/anthropic) to 4.5.4, which patches a critical entity encoding bypass via regex injection in DOCTYPE entity names. Fixes https://github.com/getsentry/sentry-javascript/security/dependabot/1108 Co-authored-by: Claude Opus 4.6 --- yarn.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/yarn.lock b/yarn.lock index c4b44e29e3fa..66400a56bb13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17048,9 +17048,9 @@ fast-xml-parser@5.3.6, fast-xml-parser@^5.0.7: strnum "^2.1.2" fast-xml-parser@^4.4.1: - version "4.5.0" - resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz#2882b7d01a6825dfdf909638f2de0256351def37" - integrity sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg== + version "4.5.4" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz#64e52ddf1308001893bd225d5b1768840511c797" + integrity sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ== dependencies: strnum "^1.0.5" @@ -28013,9 +28013,9 @@ strip-literal@^3.0.0, strip-literal@^3.1.0: js-tokens "^9.0.1" strnum@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.0.5.tgz#5c4e829fe15ad4ff0d20c3db5ac97b73c9b072db" - integrity sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA== + version "1.1.2" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" + integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== strnum@^2.1.2: version "2.1.2" From 5d4c0eb493c2cc33ab485ae5c7c70e249da38de2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:30:56 +0100 Subject: [PATCH 04/10] chore(deps-dev): bump @sveltejs/kit from 2.52.2 to 2.53.3 (#19571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.52.2 to 2.53.3.
Release notes

Sourced from @​sveltejs/kit's releases.

@​sveltejs/kit@​2.53.3

Patch Changes

  • fix: prevent overlapping file metadata in remote functions form (faba869)

@​sveltejs/kit@​2.53.2

Patch Changes

  • fix: server-render nested form value sets (#15378)

  • fix: use deep partial types for form remote functions .value() and .set(...) (#14837)

  • fix: provide correct url info to remote functions (#15418)

  • fix: allow optional types for remote query/command/prerender functions (#15293)

  • fix: allow commands in more places (#15288)

@​sveltejs/kit@​2.53.1

Patch Changes

  • fix: address warning about inlineDynamicImports when using Vite 8 (#15403)

@​sveltejs/kit@​2.53.0

Minor Changes

  • feat: support Vite 8 (#15024)

Patch Changes

  • fix: remove event listeners on form attachment cleanup (#15286)

  • fix: apply queries refreshed in a form remote function when a redirect is thrown (#15362)

Changelog

Sourced from @​sveltejs/kit's changelog.

2.53.3

Patch Changes

  • fix: prevent overlapping file metadata in remote functions form (faba869)

2.53.2

Patch Changes

  • fix: server-render nested form value sets (#15378)

  • fix: use deep partial types for form remote functions .value() and .set(...) (#14837)

  • fix: provide correct url info to remote functions (#15418)

  • fix: allow optional types for remote query/command/prerender functions (#15293)

  • fix: allow commands in more places (#15288)

2.53.1

Patch Changes

  • fix: address warning about inlineDynamicImports when using Vite 8 (#15403)

2.53.0

Minor Changes

  • feat: support Vite 8 (#15024)

Patch Changes

  • fix: remove event listeners on form attachment cleanup (#15286)

  • fix: apply queries refreshed in a form remote function when a redirect is thrown (#15362)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@sveltejs/kit&package-manager=npm_and_yarn&previous-version=2.52.2&new-version=2.53.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/getsentry/sentry-javascript/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/sveltekit/package.json | 2 +- yarn.lock | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 3c0c2d64d65f..310959378dc8 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@babel/types": "^7.26.3", - "@sveltejs/kit": "^2.52.2", + "@sveltejs/kit": "^2.53.3", "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.2.8", "vite": "^5.4.11" diff --git a/yarn.lock b/yarn.lock index 66400a56bb13..ac89a4468d6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8502,10 +8502,10 @@ resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7" integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA== -"@sveltejs/kit@^2.52.2": - version "2.52.2" - resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-2.52.2.tgz#8de4a96ef7b54a59ccb2d13f4297da3f22c3ec1d" - integrity sha512-1in76dftrofUt138rVLvYuwiQLkg9K3cG8agXEE6ksf7gCGs8oIr3+pFrVtbRmY9JvW+psW5fvLM/IwVybOLBA== +"@sveltejs/kit@^2.53.3": + version "2.53.3" + resolved "https://registry.yarnpkg.com/@sveltejs/kit/-/kit-2.53.3.tgz#72283a76e63ca62ddc7f500f47ed4aaf86b2b0c4" + integrity sha512-tshOeBUid2v5LAblUpatIdFm5Cyykbw2EiKWOunAAX0A/oJaR7DOdC9wLR5Qqh9zUf3QUISA2m9A3suBdQSYQg== dependencies: "@standard-schema/spec" "^1.0.0" "@sveltejs/acorn-typescript" "^1.0.5" @@ -28096,6 +28096,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2" From 003e894cb1b8fc7bacc92c0d685ba830f2cf73d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:41:18 +0100 Subject: [PATCH 05/10] ci(deps): bump actions/checkout from 4 to 6 (#19570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
Release notes

Sourced from actions/checkout's releases.

v6.0.0

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

v6-beta

What's Changed

Updated persist-credentials to store the credentials under $RUNNER_TEMP instead of directly in the local git config.

This requires a minimum Actions Runner version of v2.329.0 to access the persisted credentials for Docker container action scenarios.

v5.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

v4.3.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.1

v4.3.0

What's Changed

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

v6.0.2

v6.0.1

v6.0.0

v5.0.1

v5.0.0

v4.3.1

v4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=4&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/fix-security-vulnerability.yml | 2 +- .github/workflows/triage-issue.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fix-security-vulnerability.yml b/.github/workflows/fix-security-vulnerability.yml index f78290c032c6..bfaecfb175eb 100644 --- a/.github/workflows/fix-security-vulnerability.yml +++ b/.github/workflows/fix-security-vulnerability.yml @@ -24,7 +24,7 @@ jobs: issues: write id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: ref: develop diff --git a/.github/workflows/triage-issue.yml b/.github/workflows/triage-issue.yml index 54e2ebb5260c..b1af7c47bdd2 100644 --- a/.github/workflows/triage-issue.yml +++ b/.github/workflows/triage-issue.yml @@ -48,7 +48,7 @@ jobs: echo "Processing issue #$ISSUE_NUM in CI mode" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: develop From 4a7c056ebe9a51e6c11e1b6c7f47f250bdb7f2d6 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 3 Mar 2026 09:59:57 +0100 Subject: [PATCH 06/10] fix(react-router): Set correct transaction name when navigating with object argument (#19590) When navigate() or is called with an object `to` prop (e.g. { pathname: '/items/123' }), the transaction name currently is set to `[object Object]`. This adds a `resolveNavigateArg` helper that extracts the pathname from object arguments: - If we get a string or number we use it directly as transaction name - If we get an object with a pathname property we use that - If we get an object without a pathname property we stay on the current page so we try to grab the current path as transaction name, fallback to `/` Closes https://github.com/getsentry/sentry-javascript/issues/19580 --- .../app/routes/performance/index.tsx | 2 + .../performance/navigation.client.test.ts | 50 +++++++++++++++++++ .../src/client/createClientInstrumentation.ts | 5 +- .../react-router/src/client/hydratedRouter.ts | 3 +- packages/react-router/src/client/utils.ts | 24 +++++++++ .../createClientInstrumentation.test.ts | 31 ++++++++++++ .../test/client/hydratedRouter.test.ts | 22 ++++++++ 7 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 packages/react-router/src/client/utils.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx index e5383306625a..ca131f0f4354 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/index.tsx @@ -7,6 +7,8 @@ export default function PerformancePage() { diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts index 9e9891bd9306..c273b5b55195 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/navigation.client.test.ts @@ -54,6 +54,56 @@ test.describe('client - navigation performance', () => { }); }); + test('should create navigation transaction when navigating with object `to` prop', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance/with/:param'; + }); + + await page.goto(`/performance`); // pageload + await page.waitForTimeout(1000); // give it a sec before navigation + await page.getByRole('link', { name: 'Object Navigate' }).click(); // navigation with object to + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + data: { + 'sentry.source': 'route', + }, + }, + }, + transaction: '/performance/with/:param', + type: 'transaction', + transaction_info: { source: 'route' }, + }); + }); + + test('should create navigation transaction when navigating with search-only object `to` prop', async ({ page }) => { + const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { + return transactionEvent.transaction === '/performance' && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/performance`); // pageload + await page.waitForTimeout(1000); // give it a sec before navigation + await page.getByRole('link', { name: 'Search Only Navigate' }).click(); // navigation with search-only object to + + const transaction = await txPromise; + + expect(transaction).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react_router', + }, + }, + transaction: '/performance', + type: 'transaction', + }); + }); + test('should update navigation transaction for dynamic routes', async ({ page }) => { const txPromise = waitForTransaction(APP_NAME, async transactionEvent => { return transactionEvent.transaction === '/performance/with/:param'; diff --git a/packages/react-router/src/client/createClientInstrumentation.ts b/packages/react-router/src/client/createClientInstrumentation.ts index 86784127ec91..97f0c0670bce 100644 --- a/packages/react-router/src/client/createClientInstrumentation.ts +++ b/packages/react-router/src/client/createClientInstrumentation.ts @@ -13,6 +13,7 @@ import { import { DEBUG_BUILD } from '../common/debug-build'; import type { ClientInstrumentation, InstrumentableRoute, InstrumentableRouter } from '../common/types'; import { captureInstrumentationError, getPathFromRequest, getPattern, normalizeRoutePath } from '../common/utils'; +import { resolveNavigateArg } from './utils'; const WINDOW = GLOBAL_OBJ as typeof GLOBAL_OBJ & Window; @@ -164,9 +165,9 @@ export function createSentryClientInstrumentation( return; } - // Handle string navigations (e.g., navigate('/about')) + // Handle string/object navigations (e.g., navigate('/about') or navigate({ pathname: '/about' })) const client = getClient(); - const toPath = String(info.to); + const toPath = resolveNavigateArg(info.to); let navigationSpan; if (client) { diff --git a/packages/react-router/src/client/hydratedRouter.ts b/packages/react-router/src/client/hydratedRouter.ts index 499e1fcc1751..f63a60d4a234 100644 --- a/packages/react-router/src/client/hydratedRouter.ts +++ b/packages/react-router/src/client/hydratedRouter.ts @@ -14,6 +14,7 @@ import { import type { DataRouter, RouterState } from 'react-router'; import { DEBUG_BUILD } from '../common/debug-build'; import { isClientInstrumentationApiUsed } from './createClientInstrumentation'; +import { resolveNavigateArg } from './utils'; const GLOBAL_OBJ_WITH_DATA_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { __reactRouterDataRouter?: DataRouter; @@ -59,7 +60,7 @@ export function instrumentHydratedRouter(): void { router.navigate = function sentryPatchedNavigate(...args) { // Skip if instrumentation API is enabled (it handles navigation spans itself) if (!isClientInstrumentationApiUsed()) { - maybeCreateNavigationTransaction(String(args[0]) || '', 'url'); + maybeCreateNavigationTransaction(resolveNavigateArg(args[0]) || '', 'url'); } return originalNav(...args); }; diff --git a/packages/react-router/src/client/utils.ts b/packages/react-router/src/client/utils.ts new file mode 100644 index 000000000000..58d8677e87a2 --- /dev/null +++ b/packages/react-router/src/client/utils.ts @@ -0,0 +1,24 @@ +import { GLOBAL_OBJ } from '@sentry/core'; + +/** + * Resolves a navigate argument to a pathname string. + * + * React Router's navigate() accepts a string, number, or a To object ({ pathname, search, hash }). + * All fields in the To object are optional (Partial), so we need to detect object args + * to avoid "[object Object]" transaction names. + */ +export function resolveNavigateArg(target: unknown): string { + if (typeof target !== 'object' || target === null) { + // string or number + return String(target); + } + + // Object `to` with pathname + const pathname = (target as Record).pathname; + if (typeof pathname === 'string') { + return pathname || '/'; + } + + // Object `to` without pathname - navigation stays on current path + return (GLOBAL_OBJ as typeof GLOBAL_OBJ & Window).location?.pathname || '/'; +} diff --git a/packages/react-router/test/client/createClientInstrumentation.test.ts b/packages/react-router/test/client/createClientInstrumentation.test.ts index 8f04bf8d7851..00323eb17629 100644 --- a/packages/react-router/test/client/createClientInstrumentation.test.ts +++ b/packages/react-router/test/client/createClientInstrumentation.test.ts @@ -100,6 +100,37 @@ describe('createSentryClientInstrumentation', () => { expect(mockCallNavigate).toHaveBeenCalled(); }); + it('should create navigation span with correct name when `to` is an object', async () => { + const mockCallNavigate = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); + const mockInstrument = vi.fn(); + const mockClient = {}; + + (core.getClient as any).mockReturnValue(mockClient); + + const instrumentation = createSentryClientInstrumentation(); + instrumentation.router?.({ instrument: mockInstrument }); + + expect(mockInstrument).toHaveBeenCalled(); + const hooks = mockInstrument.mock.calls[0]![0]; + + // Call the navigate hook with an object `to` (pathname + search) + await hooks.navigate(mockCallNavigate, { + currentUrl: '/home', + to: { pathname: '/items/123', search: '?foo=bar' }, + }); + + expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith(mockClient, { + name: '/items/123', + attributes: expect.objectContaining({ + 'sentry.source': 'url', + 'sentry.op': 'navigation', + 'sentry.origin': 'auto.navigation.react_router.instrumentation_api', + 'navigation.type': 'router.navigate', + }), + }); + expect(mockCallNavigate).toHaveBeenCalled(); + }); + it('should instrument router fetch with spans', async () => { const mockCallFetch = vi.fn().mockResolvedValue({ status: 'success', error: undefined }); const mockInstrument = vi.fn(); diff --git a/packages/react-router/test/client/hydratedRouter.test.ts b/packages/react-router/test/client/hydratedRouter.test.ts index 457a701f835f..eb0a27073a9f 100644 --- a/packages/react-router/test/client/hydratedRouter.test.ts +++ b/packages/react-router/test/client/hydratedRouter.test.ts @@ -127,6 +127,28 @@ describe('instrumentHydratedRouter', () => { delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; }); + it('creates navigation transaction with correct name when navigate is called with an object `to`', () => { + instrumentHydratedRouter(); + mockRouter.navigate({ pathname: '/items/123', search: '?foo=bar' }); + expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + name: '/items/123', + }), + ); + }); + + it('creates navigation transaction with correct name when navigate is called with a number', () => { + instrumentHydratedRouter(); + mockRouter.navigate(-1); + expect(browser.startBrowserTracingNavigationSpan).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + name: '-1', + }), + ); + }); + it('creates navigation span when client instrumentation API is not enabled', () => { // Ensure the flag is not set (default state - instrumentation API not used) delete (globalThis as any).__sentryReactRouterClientInstrumentationUsed; From 1ffba2c78afb1049eb6a63486e2f5d07c504cc68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 3 Mar 2026 10:31:15 +0100 Subject: [PATCH 07/10] fix(core): Do not remove promiseBuffer entirely (#19592) closes #19589 closes [JS-1839](https://linear.app/getsentry/issue/JS-1839/flushanddispose-in-10410-crashes-when-workerentrypoint-rpc-methods-use) It seems that there are way to rely on the memory leak on Cloudflare. When that is the case the promise buffer is set to undefined and would fail in a latter step. Creating a new promise buffer would release everything we had before and mark with that it would be ready to be released by the garbage collector. The transport above is ok to be set to undefined, as it is readonly on TypeScript level. --- packages/core/src/server-runtime-client.ts | 5 +++-- .../test/lib/server-runtime-client.test.ts | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/server-runtime-client.ts b/packages/core/src/server-runtime-client.ts index d163fbc6d9e9..a1958f0bcbbb 100644 --- a/packages/core/src/server-runtime-client.ts +++ b/packages/core/src/server-runtime-client.ts @@ -4,6 +4,7 @@ import { getIsolationScope } from './currentScopes'; import { DEBUG_BUILD } from './debug-build'; import type { Scope } from './scope'; import { registerSpanErrorInstrumentation } from './tracing'; +import { DEFAULT_TRANSPORT_BUFFER_SIZE } from './transports/base'; import { addUserAgentToTransportHeaders } from './transports/userAgent'; import type { CheckIn, MonitorConfig, SerializedCheckIn } from './types-hoist/checkin'; import type { Event, EventHint } from './types-hoist/event'; @@ -14,7 +15,7 @@ import type { BaseTransportOptions, Transport } from './types-hoist/transport'; import { debug } from './utils/debug-logger'; import { eventFromMessage, eventFromUnknownInput } from './utils/eventbuilder'; import { uuid4 } from './utils/misc'; -import type { PromiseBuffer } from './utils/promisebuffer'; +import { makePromiseBuffer } from './utils/promisebuffer'; import { resolvedSyncPromise } from './utils/syncpromise'; import { _getTraceInfoFromScope } from './utils/trace-info'; @@ -176,7 +177,7 @@ export class ServerRuntimeClient< this._integrations = {}; this._outcomes = {}; (this as unknown as { _transport?: Transport })._transport = undefined; - (this as unknown as { _promiseBuffer?: PromiseBuffer })._promiseBuffer = undefined; + this._promiseBuffer = makePromiseBuffer(DEFAULT_TRANSPORT_BUFFER_SIZE); } /** diff --git a/packages/core/test/lib/server-runtime-client.test.ts b/packages/core/test/lib/server-runtime-client.test.ts index 24fb60d187ef..bbe9ee84a716 100644 --- a/packages/core/test/lib/server-runtime-client.test.ts +++ b/packages/core/test/lib/server-runtime-client.test.ts @@ -301,4 +301,24 @@ describe('ServerRuntimeClient', () => { ); }); }); + + describe('dispose', () => { + it('resets _promiseBuffer to a new empty buffer instead of undefined', () => { + const options = getDefaultClientOptions({ dsn: PUBLIC_DSN }); + client = new ServerRuntimeClient(options); + + // Access the private _promiseBuffer before dispose + const originalBuffer = client['_promiseBuffer']; + expect(originalBuffer).toBeDefined(); + + client.dispose(); + + // After dispose, _promiseBuffer should still be defined (not undefined) + const bufferAfterDispose = client['_promiseBuffer']; + expect(bufferAfterDispose).toBeDefined(); + expect(bufferAfterDispose).not.toBe(originalBuffer); + // Verify it's a fresh buffer with no pending items + expect(bufferAfterDispose.$).toEqual([]); + }); + }); }); From 552187dea7f017b4e76d038e6dc2a8c802b9afa6 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Tue, 3 Mar 2026 10:32:51 +0100 Subject: [PATCH 08/10] chore(deps): Bump @sveltejs/kit to 2.53.3 in sveltekit-2-svelte-5 E2E test (#19594) Resolves Dependabot alert #1079 (CPU exhaustion in SvelteKit remote form deserialization, experimental only). Co-authored-by: Claude Opus 4.6 --- .../test-applications/sveltekit-2-svelte-5/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json index 51ff252e716f..6b183ea3ca54 100644 --- a/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json +++ b/dev-packages/e2e-tests/test-applications/sveltekit-2-svelte-5/package.json @@ -22,7 +22,7 @@ "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "@sveltejs/adapter-auto": "^3.0.0", - "@sveltejs/kit": "2.49.5", + "@sveltejs/kit": "2.53.3", "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^5.0.0-next.115", "svelte-check": "^3.6.0", From f8700734ebcc825d3402e04f90834a1ef69c68bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Peer=20St=C3=B6cklmair?= Date: Tue, 3 Mar 2026 13:11:34 +0100 Subject: [PATCH 09/10] fix(astro): Do not inject withSentry into Cloudflare Pages (#19558) When running on Cloudflare Pages the `withSentry` function got bundled and wrapped into the `index.js`. This actually didn't cause any problems during the runtime, but it just added unnecessary code into the bundle. This removes now the automatic wrapping, which is required for Cloudflare Workers only. By adding the plugin `sentryCloudflareNodeWarningPlugin` we also remove tons of warnings like following when building for Cloudflare Pages: ``` 11:20:40 [WARN] [vite] [plugin vite:resolve] Automatically externalized node built-in module "node:diagnostics_channel" imported from "node_modules/.pnpm/@sentry+node@10.40.0/node_modules/@sentry/node/build/esm/integrations/tracing/fastify/index.js". Consider adding it to environments.ssr.external if it is intended. 11:20:40 [WARN] [vite] [plugin vite:resolve] Automatically externalized node built-in module "node:diagnostics_channel" imported from "node_modules/.pnpm/@sentry+node-core@10.40.0_@opentelemetry+api@1.9.0_@opentelemetry+context-async-hooks@2_feb79575758996d9eeb93d3eb9a5534b/node_modules/@sentry/node-core/build/esm/integrations/http/httpServerIntegration.js". Consider adding it to environments.ssr.external if it is intended. ``` Closes #19559 (added automatically) --- packages/astro/src/integration/index.ts | 41 ++- .../astro/test/integration/cloudflare.test.ts | 347 ++++++++++++++++++ 2 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 packages/astro/test/integration/cloudflare.test.ts diff --git a/packages/astro/src/integration/index.ts b/packages/astro/src/integration/index.ts index 796d6f84a12b..5c5ca2710af6 100644 --- a/packages/astro/src/integration/index.ts +++ b/packages/astro/src/integration/index.ts @@ -163,6 +163,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { } const isCloudflare = config?.adapter?.name?.startsWith('@astrojs/cloudflare'); + const isCloudflareWorkers = isCloudflare && !isCloudflarePages(); if (isCloudflare) { try { @@ -191,8 +192,8 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { injectScript('page-ssr', buildServerSnippet(options || {})); } - if (isCloudflare && command !== 'dev') { - // For Cloudflare production builds, additionally use a Vite plugin to: + if (isCloudflareWorkers && command !== 'dev') { + // For Cloudflare Workers production builds, additionally use a Vite plugin to: // 1. Import the server config at the Worker entry level (so Sentry.init() runs // for ALL requests, not just SSR pages — covers actions and API routes) // 2. Wrap the default export with `withSentry` from @sentry/cloudflare for @@ -215,6 +216,7 @@ export const sentryAstro = (options: SentryOptions = {}): AstroIntegration => { // Ref: https://developers.cloudflare.com/workers/runtime-apis/nodejs/ updateConfig({ vite: { + plugins: [sentryCloudflareNodeWarningPlugin()], ssr: { // @sentry/node is required in case we have 2 different @sentry/node // packages installed in the same project. @@ -255,6 +257,41 @@ function findDefaultSdkInitFile(type: 'server' | 'client'): string | undefined { .find(filename => fs.existsSync(filename)); } +/** + * Detects if the project is a Cloudflare Pages project by checking for + * `pages_build_output_dir` in the wrangler configuration file. + * + * Cloudflare Pages projects use `pages_build_output_dir` while Workers projects + * use `assets.directory` or `main` fields instead. + */ +function isCloudflarePages(): boolean { + const cwd = process.cwd(); + const configFiles = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']; + + for (const configFile of configFiles) { + const configPath = path.join(cwd, configFile); + + if (!fs.existsSync(configPath)) { + continue; + } + + const content = fs.readFileSync(configPath, 'utf-8'); + + if (configFile.endsWith('.toml')) { + // https://regex101.com/r/Uxe4p0/1 + // Match pages_build_output_dir as a TOML key (at start of line, ignoring whitespace) + // This avoids false positives from comments (lines starting with #) + return /^\s*pages_build_output_dir\s*=/m.test(content); + } + + // Match "pages_build_output_dir" as a JSON key (followed by :) + // This works for both .json and .jsonc without needing to strip comments + return /"pages_build_output_dir"\s*:/.test(content); + } + + return false; +} + function getSourcemapsAssetsGlob(config: AstroConfig): string { // The vercel adapter puts the output into its .vercel directory // However, the way this adapter is written, the config.outDir value is update too late for diff --git a/packages/astro/test/integration/cloudflare.test.ts b/packages/astro/test/integration/cloudflare.test.ts new file mode 100644 index 000000000000..e928e556ca4b --- /dev/null +++ b/packages/astro/test/integration/cloudflare.test.ts @@ -0,0 +1,347 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { sentryAstro } from '../../src/integration'; + +const getWranglerConfig = vi.hoisted(() => vi.fn()); + +vi.mock('fs', async requireActual => { + return { + ...(await requireActual()), + existsSync: vi.fn((p: string) => { + const wranglerConfig = getWranglerConfig(); + + if (wranglerConfig && p.includes(wranglerConfig.filename)) { + return true; + } + return false; + }), + readFileSync: vi.fn(() => { + const wranglerConfig = getWranglerConfig(); + + if (wranglerConfig) { + return wranglerConfig.content; + } + return ''; + }), + }; +}); + +vi.mock('@sentry/vite-plugin', () => ({ + sentryVitePlugin: vi.fn(() => 'sentryVitePlugin'), +})); + +vi.mock('../../src/integration/cloudflare', () => ({ + sentryCloudflareNodeWarningPlugin: vi.fn(() => 'sentryCloudflareNodeWarningPlugin'), + sentryCloudflareVitePlugin: vi.fn(() => 'sentryCloudflareVitePlugin'), +})); + +const baseConfigHookObject = vi.hoisted(() => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() }, + injectScript: vi.fn(), + updateConfig: vi.fn(), +})); + +describe('Cloudflare Pages vs Workers detection', () => { + beforeEach(() => { + vi.clearAllMocks(); + getWranglerConfig.mockReturnValue(null); + }); + + describe('Cloudflare Workers (no pages_build_output_dir)', () => { + it('adds Cloudflare Vite plugins for Workers production build', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + main: 'dist/_worker.js/index.js', + assets: { directory: './dist' }, + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + vite: expect.objectContaining({ + plugins: expect.arrayContaining(['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin']), + }), + }), + ); + }); + + it('adds Cloudflare Vite plugins when no wrangler config exists', async () => { + getWranglerConfig.mockReturnValue(null); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + vite: expect.objectContaining({ + plugins: expect.arrayContaining(['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin']), + }), + }), + ); + }); + }); + + describe('Cloudflare Pages (with pages_build_output_dir)', () => { + it('does not show warning for Pages project with wrangler.json', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + + it('does not show warning for Pages project with wrangler.jsonc', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.jsonc', + content: `{ + // This is a comment + "pages_build_output_dir": "./dist" + }`, + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + + it('correctly parses wrangler.json with URLs containing double slashes', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + vars: { + API_URL: 'https://api.example.com/v1', + ANOTHER_URL: 'http://localhost:3000', + }, + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + }); + }); + + it('correctly parses wrangler.jsonc with URLs and comments', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.jsonc', + content: `{ + // API configuration + "pages_build_output_dir": "./dist", + "vars": { + "API_URL": "https://api.example.com/v1", // Production API + "WEBHOOK_URL": "https://hooks.example.com/callback" + } + /* Multi-line + comment */ + }`, + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + }); + }); + + it('does not show warning for Pages project with wrangler.toml', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.toml', + content: ` +name = "my-astro-app" +pages_build_output_dir = "./dist" + `, + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + + it('correctly identifies Workers when pages_build_output_dir appears only in comments', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.toml', + content: ` +name = "my-astro-worker" +# pages_build_output_dir is not used for Workers +main = "dist/_worker.js/index.js" + +[assets] +directory = "./dist" + `, + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + // Workers should get both Cloudflare Vite plugins (including sentryCloudflareVitePlugin) + // This distinguishes it from Pages which only gets sentryCloudflareNodeWarningPlugin + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ + vite: expect.objectContaining({ + plugins: ['sentryCloudflareNodeWarningPlugin', 'sentryCloudflareVitePlugin'], + }), + }); + }); + + it('does not add Cloudflare Vite plugins for Pages production build', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'build', + }); + + // Check that sentryCloudflareVitePlugin is NOT in any of the calls + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith({ + vite: expect.objectContaining({ plugins: ['sentryCloudflareNodeWarningPlugin'] }), + }); + }); + + it('still adds SSR noExternal config for Pages in dev mode', async () => { + getWranglerConfig.mockReturnValue({ + filename: 'wrangler.json', + content: JSON.stringify({ + pages_build_output_dir: './dist', + }), + }); + + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/cloudflare' }, + }, + command: 'dev', + }); + + expect(baseConfigHookObject.updateConfig).toHaveBeenCalledWith( + expect.objectContaining({ + vite: expect.objectContaining({ + ssr: expect.objectContaining({ + noExternal: ['@sentry/astro', '@sentry/node'], + }), + }), + }), + ); + }); + }); + + describe('Non-Cloudflare adapters', () => { + it('does not show Cloudflare warning for other adapters', async () => { + const integration = sentryAstro({}); + + // @ts-expect-error - the hook exists and we only need to pass what we actually use + await integration.hooks['astro:config:setup']({ + ...baseConfigHookObject, + config: { + // @ts-expect-error - we only need to pass what we actually use + adapter: { name: '@astrojs/vercel' }, + }, + command: 'build', + }); + + expect(baseConfigHookObject.logger.error).not.toHaveBeenCalled(); + }); + }); +}); From 8738f9be4665123725be363dea2237c9356c2721 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 3 Mar 2026 13:20:54 +0100 Subject: [PATCH 10/10] meta(changelog): Update changelog for 10.42.0 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a40cb69762..397f7b0c3f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.42.0 + +- feat(consola): Enhance Consola integration to extract first-param object as searchable attributes ([#19534](https://github.com/getsentry/sentry-javascript/pull/19534)) +- fix(astro): Do not inject withSentry into Cloudflare Pages ([#19558](https://github.com/getsentry/sentry-javascript/pull/19558)) +- fix(core): Do not remove promiseBuffer entirely ([#19592](https://github.com/getsentry/sentry-javascript/pull/19592)) +- fix(deps): Bump fast-xml-parser to 4.5.4 for CVE-2026-25896 ([#19588](https://github.com/getsentry/sentry-javascript/pull/19588)) +- fix(react-router): Set correct transaction name when navigating with object argument ([#19590](https://github.com/getsentry/sentry-javascript/pull/19590)) +- ref(nuxt): Use `addVitePlugin` instead of deprecated `vite:extendConfig` ([#19464](https://github.com/getsentry/sentry-javascript/pull/19464)) + +
+ Internal Changes + +- chore(deps-dev): bump @sveltejs/kit from 2.52.2 to 2.53.3 ([#19571](https://github.com/getsentry/sentry-javascript/pull/19571)) +- chore(deps): Bump @sveltejs/kit to 2.53.3 in sveltekit-2-svelte-5 E2E test ([#19594](https://github.com/getsentry/sentry-javascript/pull/19594)) +- ci(deps): bump actions/checkout from 4 to 6 ([#19570](https://github.com/getsentry/sentry-javascript/pull/19570)) + +
+ ## 10.41.0 ### Important Changes