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 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 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/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", 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/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(); + }); + }); +}); 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/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/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', 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([]); + }); + }); }); 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' }, 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; 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 c4b44e29e3fa..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" @@ -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" @@ -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"