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"