Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Work in this release was contributed by @lazerg, @rjmcgee53192, @adityankale1908

### Important Changes

- **feat(nuxt): Mark pageloads of cached pages ([#26305](https://github.com/getsentry/sentry-javascript/pull/26305))**

When Nuxt serves a cached or prerendered page, for example with a `swr` or `isr` route rule, the pageload now has the `page.server_rendering.type: static` attribute and starts its own trace. If the request that rendered the cached page was sampled, the pageload also links to it. So visitors of a cached page no longer continue the trace of the request that rendered it.

- **feat(bundler-plugins): Enable hidden source maps and remove them after upload ([#25637](https://github.com/getsentry/sentry-javascript/pull/25637), [#25645](https://github.com/getsentry/sentry-javascript/pull/25645), [#25646](https://github.com/getsentry/sentry-javascript/pull/25646), [#25647](https://github.com/getsentry/sentry-javascript/pull/25647), [#25648](https://github.com/getsentry/sentry-javascript/pull/25648))**

The Sentry Rollup, Vite, webpack and esbuild plugins now turn on hidden source maps when your bundler has no source maps enabled and the plugin can upload them. They upload the maps to Sentry and remove them from the build output. You no longer need to set `sourcemap: 'hidden'` or `filesToDeleteAfterUpload` globs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@sentry/conventions": "0.32.0",
"@sentry-internal/test-utils": "file:../../packed/sentry-internal-test-utils-packed.tgz"
},
"sentryTest": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test, type Page } from '@playwright/test';
import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
import { isViteServer, NO_META_TAGS } from './isViteServer';

Expand All @@ -18,16 +19,19 @@ test.describe('Rendering Modes with Cached HTML', () => {
await testChangingTracingMetaTagsOnISRPage(page, '/rendering-modes/isr-1h-cached-page', 'ISR 1h Cached Page');
});

test('exclude tracing meta tags on SWR-cached page', async ({ page }) => {
await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page');
test('links the render as cache origin on SWR-cached page', async ({ page }) => {
test.fail(isViteServer, NO_META_TAGS);
await testCacheOriginOnRuntimeCachedPage(page, '/rendering-modes/swr-cached-page', 'SWR Cached Page');
});

test('exclude tracing meta tags on SWR 1h cached page', async ({ page }) => {
await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page');
test('links the render as cache origin on SWR 1h cached page', async ({ page }) => {
test.fail(isViteServer, NO_META_TAGS);
await testCacheOriginOnRuntimeCachedPage(page, '/rendering-modes/swr-1h-cached-page', 'SWR 1h Cached Page');
});

test('exclude tracing meta tags on pre-rendered page', async ({ page }) => {
await testExcludeTracingMetaTagsOnCachedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page');
test('marks pageloads of pre-rendered page as static without a cache origin link', async ({ page }) => {
test.fail(isViteServer, NO_META_TAGS);
await testEmptyCacheOriginOnPrerenderedPage(page, '/rendering-modes/pre-rendered-page', 'Pre-Rendered Page');
});
});

Expand Down Expand Up @@ -122,26 +126,25 @@ export async function testChangingTracingMetaTagsOnISRPage(
});
}

async function getCacheOriginMetaTag(page: Page): Promise<string | null> {
await expect(page.locator('meta[name="sentry-cache-origin"]')).toHaveCount(1);
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
return page.locator('meta[name="sentry-cache-origin"]').getAttribute('content');
}

/**
* Tests that tracing meta-tags are excluded on cached pages (SWR, pre-rendered, etc.)
* This utility handles the common pattern of:
* 1. Making two requests to a cached page
* 2. Verifying no tracing meta-tags are present
* 3. Verifying traces are not distributed (each pageload starts its own trace)
*
* @param page - Playwright page object
* @param routePath - The route path to test (e.g., '/rendering-modes/swr-cached-page')
* @param expectedPageText - The text to verify is visible on the page (e.g., 'SWR Cached Page')
* Tests a page that a route rule caches at runtime. The cached HTML holds the `sentry-trace` value of
* the render in a `sentry-cache-origin` tag. Every pageload starts its own trace, is static, and links
* the render.
*/
export async function testExcludeTracingMetaTagsOnCachedPage(
export async function testCacheOriginOnRuntimeCachedPage(
page: Page,
routePath: string,
expectedPageText: string,
): Promise<void> {
// === 1. Request ===
// === 1. Request (renders and fills the cache) ===
const clientSpanPromise1 = waitForPageloadSegment(routePath);

// Only the 1. request creates a server root span
const serverSpanPromise1 = waitForServerSegment(routePath);

const [_1, clientSpan1, serverSpan1] = await Promise.all([
Expand All @@ -150,44 +153,81 @@ export async function testExcludeTracingMetaTagsOnCachedPage(
serverSpanPromise1,
expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(),
]);
const cacheOrigin1 = await getCacheOriginMetaTag(page);

// Verify no baggage and sentry-trace meta-tags are present on first request
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);

// === 2. Request ===

// === 2. Request (served from the cache) ===
const clientSpanPromise2 = waitForPageloadSegment(routePath);

const [_2, clientSpan2] = await Promise.all([
page.goto(routePath),
clientSpanPromise2,
expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(),
]);

const clientSpan1TraceId = clientSpan1.trace_id;
const clientSpan2TraceId = clientSpan2.trace_id;

const serverSpan1TraceId = serverSpan1.trace_id;

await test.step('No baggage and sentry-trace meta-tags are present on second request', async () => {
expect(await page.locator('meta[name="baggage"]').count()).toBe(0);
expect(await page.locator('meta[name="sentry-trace"]').count()).toBe(0);
});

await test.step('1. server root span and all client root spans are defined', () => {
expect(serverSpan1TraceId).toBeDefined();
expect(clientSpan1TraceId).toBeDefined();
expect(clientSpan2TraceId).toBeDefined();
const cacheOrigin2 = await getCacheOriginMetaTag(page);

const [originTraceId, originSpanId, originSampled] = cacheOrigin1?.split('-') ?? [];
const cacheOriginLink = {
trace_id: serverSpan1.trace_id,
span_id: originSpanId,
sampled: true,
attributes: { [SENTRY_LINK_TYPE]: { type: 'string', value: 'cache_origin' } },
};

await test.step('The cache origin tag holds the sampled render of the 1. request', () => {
expect(originTraceId).toBe(serverSpan1.trace_id);
expect(originSpanId).toMatch(/^[a-f0-9]{16}$/);
expect(originSampled).toBe('1');
expect(cacheOrigin2).toBe(cacheOrigin1);
});

await test.step('Trace is not distributed', () => {
// Cannot create distributed trace as HTML Meta Tags are not added (caching leads to multiple usages of the same server trace id)
expect(clientSpan1TraceId).not.toBe(clientSpan2TraceId);
expect(clientSpan1TraceId).not.toBe(serverSpan1TraceId);
expect(clientSpan2TraceId).not.toBe(serverSpan1TraceId);
// Without meta tags the pageloads have no parent and start their own traces
await test.step('Every pageload starts its own trace', () => {
expect(clientSpan1.parent_span_id).toBeUndefined();
expect(clientSpan2.parent_span_id).toBeUndefined();
expect(clientSpan1.trace_id).not.toBe(serverSpan1.trace_id);
expect(clientSpan2.trace_id).not.toBe(serverSpan1.trace_id);
expect(clientSpan1.trace_id).not.toBe(clientSpan2.trace_id);
});

await test.step('Every pageload is static and links the render', () => {
expect(clientSpan1.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' });
expect(clientSpan2.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' });
expect(clientSpan1.links).toEqual([cacheOriginLink]);
expect(clientSpan2.links).toEqual([cacheOriginLink]);
});
}

/**
* Tests a page that Nitro prerendered during the build. The SDK does not run in the build, so the
* `sentry-cache-origin` tag is empty: every pageload starts its own trace and is static, without a link.
*/
export async function testEmptyCacheOriginOnPrerenderedPage(
page: Page,
routePath: string,
expectedPageText: string,
): Promise<void> {
const clientSpanPromise1 = waitForPageloadSegment(routePath);
const [_1, clientSpan1] = await Promise.all([
page.goto(routePath),
clientSpanPromise1,
expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(),
]);
const cacheOrigin1 = await getCacheOriginMetaTag(page);

const clientSpanPromise2 = waitForPageloadSegment(routePath);
const [_2, clientSpan2] = await Promise.all([
page.goto(routePath),
clientSpanPromise2,
expect(page.getByText(expectedPageText, { exact: true })).toBeVisible(),
]);
const cacheOrigin2 = await getCacheOriginMetaTag(page);

expect(cacheOrigin1).toBe('');
expect(cacheOrigin2).toBe('');
expect(clientSpan1.parent_span_id).toBeUndefined();
expect(clientSpan2.parent_span_id).toBeUndefined();
expect(clientSpan1.trace_id).not.toBe(clientSpan2.trace_id);
expect(clientSpan1.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' });
expect(clientSpan2.attributes?.[PAGE_SERVER_RENDERING_TYPE]).toEqual({ type: 'string', value: 'static' });
expect(clientSpan1.links).toBeUndefined();
expect(clientSpan2.links).toBeUndefined();
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import type { NitroApp, NitroAppPlugin } from 'nitropack';
import type { NuxtRenderHTMLContext } from 'nuxt/app';
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse';
import { addSentryTracingMetaTags } from '../utils';
import { getCfProperties, getCloudflareProperties, hasCfProperty, isEventType } from '../utils/event-type-check';
import { addSentryPageMetaTags } from '../utils/pageMetaTags';

/**
* Sentry Cloudflare Nitro plugin for when using the "cloudflare-pages" preset in Nuxt.
Expand Down Expand Up @@ -162,9 +162,9 @@ export const sentryCloudflareNitroPlugin =

if (storedTraceData && Object.keys(storedTraceData).length > 0) {
debug.log('Using stored trace data for HTML meta-tags: ', storedTraceData);
addSentryTracingMetaTags(html.head, storedTraceData);
addSentryPageMetaTags(html.head, event, storedTraceData);
} else {
addSentryTracingMetaTags(html.head);
addSentryPageMetaTags(html.head, event);
}
});

Expand Down
7 changes: 6 additions & 1 deletion packages/nuxt/src/runtime/plugins/sentry.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { defineNuxtPlugin, isNuxtError } from 'nuxt/app';
import type { GlobalObjWithIntegrationOptions } from '../../client/vueIntegration';
import { reportNuxtError } from '../utils';
import { withCacheOrigin } from '../utils/cacheOriginPageload';

// --- Types are copied from @sentry/vue (so it does not need to be exported) ---
// The following type is an intersection of the Route type from VueRouter v2, v3, and v4.
Expand Down Expand Up @@ -73,7 +74,11 @@ export default defineNuxtPlugin({

if (sentryClient && '$router' in nuxtApp) {
sentryClient.addIntegration(
browserTracingIntegration({ router: nuxtApp.$router as VueRouter, routeLabel: 'path' }),
browserTracingIntegration({
router: nuxtApp.$router as VueRouter,
routeLabel: 'path',
beforeStartSpan: options => withCacheOrigin(options, nuxtApp.payload.serverRendered === true),
}),
);
}
}
Expand Down
27 changes: 2 additions & 25 deletions packages/nuxt/src/runtime/plugins/sentry.server.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,13 @@
import { debug } from '@sentry/core';
import type { H3Event } from 'h3';
import type { NitroAppPlugin } from 'nitropack';
import type { NuxtRenderHTMLContext } from 'nuxt/app';
import { sentryCaptureErrorHook } from '../hooks/captureErrorHook';
import { addSentryTracingMetaTags } from '../utils';
import { addSentryPageMetaTags } from '../utils/pageMetaTags';

export default (nitroApp => {
nitroApp.hooks.hook('error', sentryCaptureErrorHook);

nitroApp.hooks.hook('render:html', (html: NuxtRenderHTMLContext, { event }: { event: H3Event }) => {
// h3 v1 (Nuxt 4): event.node.res.getHeaders(); h3 v2 (Nuxt 5): event.node is undefined
const nodeResHeadersH3v1 = event.node?.res?.getHeaders() || {};

// h3 v2 (Nuxt 5): response headers are on event.res.headers
const isPreRenderedPage =
Object.keys(nodeResHeadersH3v1).includes('x-nitro-prerender') ||
// fix × typescript-eslint(no-unsafe-member-access): Unsafe member access .res on an `any` value.
// oxlint-disable-next-line typescript/no-explicit-any,typescript-oxlint/no-unsafe-member-access
!!(event as any).res?.headers?.has?.('x-nitro-prerender');

// oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access
const isSWRCachedPage = /* Nitro v2 */ (event?.context?.cache?.options?.swr ||
// oxlint-disable-next-line typescript-oxlint/no-unsafe-member-access
/* Nitro v3 */ event?.context?.routeRules?.swr) as boolean;

if (!isPreRenderedPage && !isSWRCachedPage) {
addSentryTracingMetaTags(html.head);
} else {
const reason = isPreRenderedPage ? 'the page was pre-rendered' : 'SWR caching is enabled for the route';
debug.log(
`Not adding Sentry tracing meta tags to HTML for ${event.path} because ${reason}. This will disable distributed tracing and prevent connecting multiple client page loads to the same server request.`,
);
}
addSentryPageMetaTags(html.head, event);
});
}) satisfies NitroAppPlugin;
5 changes: 5 additions & 0 deletions packages/nuxt/src/runtime/utils/cacheOriginMetaTag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Replaces `sentry-trace` and `baggage` in a page that other visitors get too. Holds the `sentry-trace`
* value of the render, or nothing for a build prerender. The client links it and never continues it.
*/
export const CACHE_ORIGIN_META_TAG_NAME = 'sentry-cache-origin';
46 changes: 46 additions & 0 deletions packages/nuxt/src/runtime/utils/cacheOriginPageload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { PAGE_SERVER_RENDERING_TYPE, SENTRY_LINK_TYPE } from '@sentry/conventions/attributes';
import { PAGELOAD } from '@sentry/conventions/op';
import type { SpanLink, StartSpanOptions } from '@sentry/core';
import { extractTraceparentData } from '@sentry/core';
import { WINDOW } from '@sentry/vue';
import { CACHE_ORIGIN_META_TAG_NAME } from './cacheOriginMetaTag';

// `@opentelemetry/api` `TraceFlags`, inlined because core does not export its copies.
const TRACE_FLAG_SAMPLED = 0x1;

/** The `cache_origin` link to the sampled render that a `sentry-trace` value describes. */
function getCacheOriginLink(sentryTrace: string): SpanLink | undefined {
const traceparent = extractTraceparentData(sentryTrace);
if (!traceparent?.traceId || !traceparent.parentSpanId || !traceparent.parentSampled) {
return undefined;
}
return {
context: { traceId: traceparent.traceId, spanId: traceparent.parentSpanId, traceFlags: TRACE_FLAG_SAMPLED },
attributes: { [SENTRY_LINK_TYPE]: 'cache_origin' },
};
}

/**
* Marks the pageload of a reused page as `static` and links the render if it was sampled (as in Next.js).
* Runs at span start, so `tracesSampler` and `ignoreSpans` see the attribute. A client-side shell
* (`ssr: false`) stays unmarked, because the server rendered none of its content.
*/
export function withCacheOrigin(spanOptions: StartSpanOptions, isServerRendered: boolean): StartSpanOptions {
if (spanOptions.op !== PAGELOAD || !isServerRendered) {
return spanOptions;
}

const cacheOrigin = WINDOW.document
?.querySelector(`meta[name="${CACHE_ORIGIN_META_TAG_NAME}"]`)
?.getAttribute('content');
if (typeof cacheOrigin !== 'string') {
return spanOptions;
}

const link = getCacheOriginLink(cacheOrigin);
return {
...spanOptions,
attributes: { ...spanOptions.attributes, [PAGE_SERVER_RENDERING_TYPE]: 'static' },
links: link ? [...(spanOptions.links ?? []), link] : spanOptions.links,
};
}
Loading
Loading