Skip to content

Commit fc9bc4e

Browse files
committed
feat(nuxt): Do not inject trace meta-tags on cached HTML pages
1 parent 52be861 commit fc9bc4e

File tree

10 files changed

+261
-5
lines changed

10 files changed

+261
-5
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template><p>Client Side Only Page</p></template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template><p>ISR 1h Cached Page</p></template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template><p>ISR Cached Page</p></template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template><p>Pre-Rendered Page</p></template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template><p>SWR 1h Cached Page</p></template>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<template><p>SWR Cached Page</p></template>

dev-packages/e2e-tests/test-applications/nuxt-4/nuxt.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ export default defineNuxtConfig({
33
compatibilityDate: '2025-06-06',
44
imports: { autoImport: false },
55

6+
routeRules: {
7+
'/rendering-modes/client-side-only-page': { ssr: false },
8+
'/rendering-modes/pre-rendered-page': { prerender: true },
9+
'/rendering-modes/swr-cached-page': { swr: true },
10+
'/rendering-modes/swr-1h-cached-page': { swr: 3600 },
11+
'/rendering-modes/isr-cached-page': { isr: true },
12+
'/rendering-modes/isr-1h-cached-page': { isr: 3600 },
13+
},
14+
615
modules: ['@pinia/nuxt', '@sentry/nuxt/module'],
716

817
runtimeConfig: {

dev-packages/e2e-tests/test-applications/nuxt-4/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"start": "node .output/server/index.mjs",
1111
"start:import": "node --import ./.output/server/sentry.server.config.mjs .output/server/index.mjs",
1212
"clean": "npx nuxi cleanup",
13-
"test": "playwright test",
13+
"test": "playwright test -g 'Rendering Modes'",
1414
"test:build": "pnpm install && pnpm build",
1515
"test:build-canary": "pnpm add nuxt@npm:nuxt-nightly@latest && pnpm add nitropack@npm:nitropack-nightly@latest && pnpm install --force && pnpm build",
1616
"test:assert": "pnpm test"
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/test-utils';
3+
4+
test.describe('Rendering Modes with Cached HTML', () => {
5+
test('changes tracing meta tags with multiple requests on ISR-cached page', async ({ page }) => {
6+
// === 1. Request ===
7+
const clientTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => {
8+
return txnEvent.transaction === '/rendering-modes/isr-cached-page';
9+
});
10+
11+
const serverTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => {
12+
return txnEvent.transaction?.includes('GET /rendering-modes/isr-cached-page') ?? false;
13+
});
14+
15+
const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([
16+
page.goto(`/rendering-modes/isr-cached-page`),
17+
clientTxnEventPromise1,
18+
serverTxnEventPromise1,
19+
expect(page.getByText(`ISR Cached Page`, { exact: true })).toBeVisible(),
20+
]);
21+
22+
const baggageMetaTagContent1 = await page.locator('meta[name="baggage"]').getAttribute('content');
23+
const sentryTraceMetaTagContent1 = await page.locator('meta[name="sentry-trace"]').getAttribute('content');
24+
const [htmlMetaTraceId1] = sentryTraceMetaTagContent1?.split('-') || [];
25+
26+
// === 2. Request ===
27+
28+
const clientTxnEventPromise2 = waitForTransaction('nuxt-4', txnEvent => {
29+
return txnEvent.transaction === '/rendering-modes/isr-cached-page';
30+
});
31+
32+
const serverTxnEventPromise2 = waitForTransaction('nuxt-4', txnEvent => {
33+
return txnEvent.transaction?.includes('GET /rendering-modes/isr-cached-page') ?? false;
34+
});
35+
36+
const [_2, clientTxnEvent2, serverTxnEvent2] = await Promise.all([
37+
page.goto(`/rendering-modes/isr-cached-page`),
38+
clientTxnEventPromise2,
39+
serverTxnEventPromise2,
40+
expect(page.getByText(`ISR Cached Page`, { exact: true })).toBeVisible(),
41+
]);
42+
43+
const baggageMetaTagContent2 = await page.locator('meta[name="baggage"]').getAttribute('content');
44+
const sentryTraceMetaTagContent2 = await page.locator('meta[name="sentry-trace"]').getAttribute('content');
45+
const [htmlMetaTraceId2] = sentryTraceMetaTagContent2?.split('-') || [];
46+
47+
const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id;
48+
const serverTxnEvent2TraceId = serverTxnEvent2.contexts?.trace?.trace_id;
49+
50+
console.log('Server Transaction 1:', serverTxnEvent1TraceId);
51+
console.log('Server Transaction 2:', serverTxnEvent2TraceId);
52+
53+
await test.step('Test distributed trace from 1. request', () => {
54+
expect(baggageMetaTagContent1).toContain(`sentry-trace_id=${serverTxnEvent1TraceId}`);
55+
56+
expect(clientTxnEvent1.contexts?.trace?.trace_id).toBe(serverTxnEvent1TraceId);
57+
expect(clientTxnEvent1.contexts?.trace?.parent_span_id).toBe(serverTxnEvent1.contexts?.trace?.span_id);
58+
expect(serverTxnEvent1.contexts?.trace?.trace_id).toBe(htmlMetaTraceId1);
59+
});
60+
61+
await test.step('Test distributed trace from 2. request', () => {
62+
expect(baggageMetaTagContent2).toContain(`sentry-trace_id=${serverTxnEvent2TraceId}`);
63+
64+
expect(clientTxnEvent2.contexts?.trace?.trace_id).toBe(serverTxnEvent2TraceId);
65+
expect(clientTxnEvent2.contexts?.trace?.parent_span_id).toBe(serverTxnEvent2.contexts?.trace?.span_id);
66+
expect(serverTxnEvent2.contexts?.trace?.trace_id).toBe(htmlMetaTraceId2);
67+
});
68+
69+
await test.step('Test that trace IDs from subsequent requests are different', () => {
70+
// Different trace IDs for the server transactions
71+
expect(serverTxnEvent1TraceId).not.toBe(serverTxnEvent2TraceId);
72+
expect(serverTxnEvent1TraceId).not.toBe(htmlMetaTraceId2);
73+
});
74+
});
75+
76+
test('exclude tracing meta tags on SWR-cached page', async ({ page }) => {
77+
// === 1. Request ===
78+
const clientTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => {
79+
return txnEvent.transaction === '/rendering-modes/swr-cached-page';
80+
});
81+
82+
// Only the 1. request creates a server transaction
83+
const serverTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => {
84+
return txnEvent.transaction?.includes('GET /rendering-modes/swr-cached-page') ?? false;
85+
});
86+
87+
const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([
88+
page.goto(`/rendering-modes/swr-cached-page`),
89+
clientTxnEventPromise1,
90+
serverTxnEventPromise1,
91+
expect(page.getByText(`SWR Cached Page`, { exact: true })).toBeVisible(),
92+
]);
93+
94+
await test.step('No baggage and sentry-trace meta tags are present on first request', async () => {
95+
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
96+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
97+
});
98+
99+
// === 2. Request ===
100+
101+
await page.goto(`/rendering-modes/swr-cached-page`);
102+
103+
const clientTxnEventPromise2 = waitForTransaction('nuxt-4', txnEvent => {
104+
return txnEvent.transaction === '/rendering-modes/swr-cached-page';
105+
});
106+
107+
let serverTxnEvent2 = undefined;
108+
const serverTxnEventPromise2 = Promise.race([
109+
waitForTransaction('nuxt-4', txnEvent => {
110+
return txnEvent.transaction?.includes('GET /rendering-modes/swr-cached-page') ?? false;
111+
}),
112+
new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)),
113+
]);
114+
115+
try {
116+
serverTxnEvent2 = await serverTxnEventPromise2;
117+
throw new Error('Second server transaction should not have been sent');
118+
} catch (error) {
119+
expect(error.message).toBe('No second server transaction expected');
120+
}
121+
122+
const [clientTxnEvent2] = await Promise.all([
123+
clientTxnEventPromise2,
124+
expect(page.getByText(`SWR Cached Page`, { exact: true })).toBeVisible(),
125+
]);
126+
127+
const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id;
128+
const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id;
129+
130+
const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id;
131+
const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id;
132+
133+
await test.step('No baggage and sentry-trace meta tags are present on first request', async () => {
134+
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
135+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
136+
});
137+
138+
await test.step('First Server Transaction and all Client Transactions are defined', () => {
139+
expect(serverTxnEvent1TraceId).toBeDefined();
140+
expect(clientTxnEvent1TraceId).toBeDefined();
141+
expect(clientTxnEvent2TraceId).toBeDefined();
142+
expect(serverTxnEvent2).toBeUndefined();
143+
expect(serverTxnEvent2TraceId).toBeUndefined();
144+
});
145+
146+
await test.step('Trace is not distributed', () => {
147+
// Cannot create distributed trace as HTML Meta Tags are not added (SWR caching leads to multiple usages of the same server trace id)
148+
expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId);
149+
expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId);
150+
});
151+
});
152+
153+
test('exclude tracing meta tags on pre-rendered page', async ({ page }) => {
154+
// === 1. Request ===
155+
const clientTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => {
156+
return txnEvent.transaction === '/rendering-modes/pre-rendered-page';
157+
});
158+
159+
// Only the 1. request creates a server transaction
160+
const serverTxnEventPromise1 = waitForTransaction('nuxt-4', txnEvent => {
161+
return txnEvent.transaction?.includes('GET /rendering-modes/pre-rendered-page') ?? false;
162+
});
163+
164+
const [_1, clientTxnEvent1, serverTxnEvent1] = await Promise.all([
165+
page.goto(`/rendering-modes/pre-rendered-page`),
166+
clientTxnEventPromise1,
167+
serverTxnEventPromise1,
168+
expect(page.getByText(`Pre-Rendered Page`, { exact: true })).toBeVisible(),
169+
]);
170+
171+
await test.step('No baggage and sentry-trace meta tags are present on first request', async () => {
172+
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
173+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
174+
});
175+
176+
// === 2. Request ===
177+
178+
await page.goto(`/rendering-modes/pre-rendered-page`);
179+
180+
const clientTxnEventPromise2 = waitForTransaction('nuxt-4', txnEvent => {
181+
return txnEvent.transaction === '/rendering-modes/pre-rendered-page';
182+
});
183+
184+
let serverTxnEvent2 = undefined;
185+
const serverTxnEventPromise2 = Promise.race([
186+
waitForTransaction('nuxt-4', txnEvent => {
187+
return txnEvent.transaction?.includes('GET /rendering-modes/pre-rendered-page') ?? false;
188+
}),
189+
new Promise((_, reject) => setTimeout(() => reject(new Error('No second server transaction expected')), 2000)),
190+
]);
191+
192+
try {
193+
serverTxnEvent2 = await serverTxnEventPromise2;
194+
throw new Error('Second server transaction should not have been sent');
195+
} catch (error) {
196+
expect(error.message).toBe('No second server transaction expected');
197+
}
198+
199+
const [clientTxnEvent2] = await Promise.all([
200+
clientTxnEventPromise2,
201+
expect(page.getByText(`Pre-Rendered Page`, { exact: true })).toBeVisible(),
202+
]);
203+
204+
const clientTxnEvent1TraceId = clientTxnEvent1.contexts?.trace?.trace_id;
205+
const clientTxnEvent2TraceId = clientTxnEvent2.contexts?.trace?.trace_id;
206+
207+
const serverTxnEvent1TraceId = serverTxnEvent1.contexts?.trace?.trace_id;
208+
const serverTxnEvent2TraceId = serverTxnEvent2?.contexts?.trace?.trace_id;
209+
210+
await test.step('No baggage and sentry-trace meta tags are present on first request', async () => {
211+
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
212+
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
213+
});
214+
215+
await test.step('First Server Transaction and all Client Transactions are defined', () => {
216+
expect(serverTxnEvent1TraceId).toBeDefined();
217+
expect(clientTxnEvent1TraceId).toBeDefined();
218+
expect(clientTxnEvent2TraceId).toBeDefined();
219+
expect(serverTxnEvent2).toBeUndefined();
220+
expect(serverTxnEvent2TraceId).toBeUndefined();
221+
});
222+
223+
await test.step('Trace is not distributed', () => {
224+
// Cannot create distributed trace as HTML Meta Tags are not added (pre-rendering leads to multiple usages of the same server trace id)
225+
expect(clientTxnEvent1TraceId).not.toBe(clientTxnEvent2TraceId);
226+
expect(clientTxnEvent1TraceId).not.toBe(serverTxnEvent1TraceId);
227+
});
228+
});
229+
});

packages/nuxt/src/runtime/plugins/sentry.server.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import {
55
getIsolationScope,
66
withIsolationScope,
77
} from '@sentry/core';
8-
// eslint-disable-next-line import/no-extraneous-dependencies
9-
import { type EventHandler } from 'h3';
8+
import type { EventHandler, H3Event } from 'h3';
109
// eslint-disable-next-line import/no-extraneous-dependencies
1110
import { defineNitroPlugin } from 'nitropack/runtime';
1211
import type { NuxtRenderHTMLContext } from 'nuxt/app';
@@ -22,8 +21,21 @@ export default defineNitroPlugin(nitroApp => {
2221
nitroApp.hooks.hook('error', sentryCaptureErrorHook);
2322

2423
// @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context
25-
nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext) => {
26-
addSentryTracingMetaTags(html.head);
24+
nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => {
25+
const headers = event.node.res?.getHeaders() || {};
26+
27+
const isPreRenderedPage = Object.keys(headers).includes('x-nitro-prerender');
28+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
29+
const isSWRCachedPage = event?.context?.cache?.options.swr as boolean | undefined;
30+
31+
if (!isPreRenderedPage && !isSWRCachedPage) {
32+
addSentryTracingMetaTags(html.head);
33+
} else {
34+
const reason = isPreRenderedPage ? 'the page was pre-rendered' : 'SWR caching is enabled for the route';
35+
debug.log(
36+
`Not adding Sentry tracing meta tags to HTML for ${event.path} because ${reason}. This will disable distributed tracing for the page.`,
37+
);
38+
}
2739
});
2840
});
2941

0 commit comments

Comments
 (0)