Skip to content

feat(nextjs): Mark clientside prefetch request spans with http.request.prefetch: true attribute #15980

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Apr 7, 2025
Merged
Show file tree
Hide file tree
Changes from 16 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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '39.5 KB',
limit: '40 KB',
},
// Svelte SDK (ESM)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [
Sentry.browserTracingIntegration({
idleTimeout: 1000,
onRequestSpanStart(span, { headers }) {
if (headers) {
span.setAttribute('hook.called.headers', headers.get('foo'));
}
},
}),
],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
fetch('http://sentry-test-site-fetch.example/', {
headers: {
foo: 'fetch',
},
});

const xhr = new XMLHttpRequest();

xhr.open('GET', 'http://sentry-test-site-xhr.example/');
xhr.setRequestHeader('foo', 'xhr');
xhr.send();
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/core';

import { sentryTest } from '../../../../utils/fixtures';
import { getMultipleSentryEnvelopeRequests, shouldSkipTracingTest } from '../../../../utils/helpers';

sentryTest('should call onRequestSpanStart hook', async ({ browserName, getLocalTestUrl, page }) => {
const supportedBrowsers = ['chromium', 'firefox'];

if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) {
sentryTest.skip();
}

await page.route('http://sentry-test-site-fetch.example/', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '',
});
});
await page.route('http://sentry-test-site-xhr.example/', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: '',
});
});

const url = await getLocalTestUrl({ testDir: __dirname });

const envelopes = await getMultipleSentryEnvelopeRequests<Event>(page, 2, { url, timeout: 10000 });

const tracingEvent = envelopes[envelopes.length - 1]; // last envelope contains tracing data on all browsers

expect(tracingEvent.spans).toContainEqual(
expect.objectContaining({
op: 'http.client',
data: expect.objectContaining({
'hook.called.headers': 'xhr',
}),
}),
);

expect(tracingEvent.spans).toContainEqual(
expect.objectContaining({
op: 'http.client',
data: expect.objectContaining({
'hook.called.headers': 'fetch',
}),
}),
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Link from 'next/link';

export default function Page() {
return (
<Link id="prefetch-link" href="/prefetching/to-be-prefetched">
link
</Link>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export default function Page() {
return <p>Hello</p>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@types/node": "^18.19.1",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"next": "15.0.0-canary.182",
"next": "15.3.0-canary.33",
"react": "beta",
"react-dom": "beta",
"typescript": "~5.0.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test';
import { waitForTransaction } from '@sentry-internal/test-utils';

test('Prefetch client spans should have a http.request.prefetch attribute', async ({ page }) => {
test.skip(process.env.TEST_ENV === 'development', "Prefetch requests don't have the prefetch header in dev mode");

const pageloadTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => {
return transactionEvent?.transaction === '/prefetching';
});

await page.goto(`/prefetching`);

// Make it more likely that nextjs prefetches
await page.hover('#prefetch-link');

expect((await pageloadTransactionPromise).spans).toContainEqual(
expect.objectContaining({
op: 'http.client',
data: expect.objectContaining({
'http.request.prefetch': true,
}),
}),
);
});
11 changes: 10 additions & 1 deletion packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
startTrackingLongTasks,
startTrackingWebVitals,
} from '@sentry-internal/browser-utils';
import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource } from '@sentry/core';
import type { Client, IntegrationFn, Span, StartSpanOptions, TransactionSource, WebFetchHeaders } from '@sentry/core';
import {
GLOBAL_OBJ,
SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,
Expand Down Expand Up @@ -195,6 +195,13 @@ export interface BrowserTracingOptions {
* Default: (url: string) => true
*/
shouldCreateSpanForRequest?(this: void, url: string): boolean;

/**
* This callback is invoked directly after a span is started for an outgoing fetch or XHR request.
* You can use it to annotate the span with additional data or attributes, for example by setting
* attributes based on the passed request headers.
*/
onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void;
}

const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
Expand Down Expand Up @@ -246,6 +253,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
instrumentPageLoad,
instrumentNavigation,
linkPreviousTrace,
onRequestSpanStart,
} = {
...DEFAULT_BROWSER_TRACING_OPTIONS,
..._options,
Expand Down Expand Up @@ -468,6 +476,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
tracePropagationTargets: client.getOptions().tracePropagationTargets,
shouldCreateSpanForRequest,
enableHTTPTimings,
onRequestSpanStart,
});
},
};
Expand Down
34 changes: 25 additions & 9 deletions packages/browser/src/tracing/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
extractNetworkProtocol,
} from '@sentry-internal/browser-utils';
import type { XhrHint } from '@sentry-internal/browser-utils';
import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span } from '@sentry/core';
import type { Client, HandlerDataXhr, SentryWrappedXMLHttpRequest, Span, WebFetchHeaders } from '@sentry/core';
import {
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
Expand Down Expand Up @@ -98,6 +98,11 @@ export interface RequestInstrumentationOptions {
* Default: (url: string) => true
*/
shouldCreateSpanForRequest?(this: void, url: string): boolean;

/**
* Is called when spans are started for outgoing requests.
*/
onRequestSpanStart?(span: Span, requestInformation: { headers?: WebFetchHeaders }): void;
}

const responseToSpanId = new WeakMap<object, string>();
Expand All @@ -119,10 +124,9 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
shouldCreateSpanForRequest,
enableHTTPTimings,
tracePropagationTargets,
onRequestSpanStart,
} = {
traceFetch: defaultRequestInstrumentationOptions.traceFetch,
traceXHR: defaultRequestInstrumentationOptions.traceXHR,
trackFetchStreamPerformance: defaultRequestInstrumentationOptions.trackFetchStreamPerformance,
...defaultRequestInstrumentationOptions,
..._options,
};

Expand Down Expand Up @@ -179,19 +183,31 @@ export function instrumentOutgoingRequests(client: Client, _options?: Partial<Re
'http.url': fullUrl,
'server.address': host,
});
}

if (enableHTTPTimings && createdSpan) {
addHTTPTimings(createdSpan);
if (enableHTTPTimings) {
addHTTPTimings(createdSpan);
}

onRequestSpanStart?.(createdSpan, { headers: handlerData.headers });
}
});
}

if (traceXHR) {
addXhrInstrumentationHandler(handlerData => {
const createdSpan = xhrCallback(handlerData, shouldCreateSpan, shouldAttachHeadersWithTargets, spans);
if (enableHTTPTimings && createdSpan) {
addHTTPTimings(createdSpan);
if (createdSpan) {
if (enableHTTPTimings) {
addHTTPTimings(createdSpan);
}

let headers;
try {
headers = new Headers(handlerData.xhr.__sentry_xhr_v3__?.request_headers);
} catch {
// noop
}
onRequestSpanStart?.(createdSpan, { headers });
}
});
}
Expand Down
6 changes: 1 addition & 5 deletions packages/core/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SPAN_STATUS_ERROR, setHttpStatus, startInactiveSpan } from './tracing';
import { SentryNonRecordingSpan } from './tracing/sentryNonRecordingSpan';
import type { FetchBreadcrumbHint, HandlerDataFetch, Span, SpanAttributes, SpanOrigin } from './types-hoist';
import { SENTRY_BAGGAGE_KEY_PREFIX } from './utils-hoist/baggage';
import { isInstanceOf } from './utils-hoist/is';
import { isInstanceOf, isRequest } from './utils-hoist/is';
import { getSanitizedUrlStringFromUrlObject, isURLObjectRelative, parseStringToURLObject } from './utils-hoist/url';
import { hasSpansEnabled } from './utils/hasSpansEnabled';
import { getActiveSpan } from './utils/spanUtils';
Expand Down Expand Up @@ -227,10 +227,6 @@ function stripBaggageHeaderOfSentryBaggageValues(baggageHeader: string): string
);
}

function isRequest(request: unknown): request is Request {
return typeof Request !== 'undefined' && isInstanceOf(request, Request);
}

function isHeaders(headers: unknown): headers is Headers {
return typeof Headers !== 'undefined' && isInstanceOf(headers, Headers);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types-hoist/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export interface HandlerDataFetch {
error?: unknown;
// This is to be consumed by the HttpClient integration
virtualError?: unknown;
/** Headers that the user passed to the fetch request. */
headers?: WebFetchHeaders;
}

export interface HandlerDataDom {
Expand Down
28 changes: 26 additions & 2 deletions packages/core/src/utils-hoist/instrument/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { HandlerDataFetch } from '../../types-hoist';
import type { HandlerDataFetch, WebFetchHeaders } from '../../types-hoist';

import { isError } from '../is';
import { isError, isRequest } from '../is';
import { addNonEnumerableProperty, fill } from '../object';
import { supportsNativeFetch } from '../supports';
import { timestampInSeconds } from '../time';
Expand Down Expand Up @@ -67,6 +67,7 @@ function instrumentFetch(onFetchResolved?: (response: Response) => void, skipNat
startTimestamp: timestampInSeconds() * 1000,
// // Adding the error to be able to fingerprint the failed fetch event in HttpClient instrumentation
virtualError,
headers: getHeadersFromFetchArgs(args),
};

// if there is no callback, fetch is instrumented directly
Expand Down Expand Up @@ -253,3 +254,26 @@ export function parseFetchArgs(fetchArgs: unknown[]): { method: string; url: str
method: hasProp(arg, 'method') ? String(arg.method).toUpperCase() : 'GET',
};
}

function getHeadersFromFetchArgs(fetchArgs: unknown[]): WebFetchHeaders | undefined {
const [requestArgument, optionsArgument] = fetchArgs;

try {
if (
typeof optionsArgument === 'object' &&
optionsArgument !== null &&
'headers' in optionsArgument &&
optionsArgument.headers
) {
return new Headers(optionsArgument.headers as any);
}

if (isRequest(requestArgument)) {
return new Headers(requestArgument.headers);
}
} catch {
// noop
}

return;
}
9 changes: 9 additions & 0 deletions packages/core/src/utils-hoist/is.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,12 @@ export function isVueViewModel(wat: unknown): boolean {
// Not using Object.prototype.toString because in Vue 3 it would read the instance's Symbol(Symbol.toStringTag) property.
return !!(typeof wat === 'object' && wat !== null && ((wat as VueViewModel).__isVue || (wat as VueViewModel)._isVue));
}

/**
* Checks whether the given parameter is a Standard Web API Request instance.
*
* Returns false if Request is not available in the current runtime.
*/
export function isRequest(request: unknown): request is Request {
return typeof Request !== 'undefined' && isInstanceOf(request, Request);
}
10 changes: 10 additions & 0 deletions packages/nextjs/src/client/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ export function browserTracingIntegration(
...options,
instrumentNavigation: false,
instrumentPageLoad: false,
onRequestSpanStart(...args) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

l: do we care about cases where users register their own browserTracingIntegration()? As in should we merge a possible custom implementation with our logic? Not sure what our general approach to this is so feel free to declare undefined behaviour :D

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think user's would be always importing this browserTracingIntegration (the Next.js one) so the behavior should already be merged (see line 23). Unless I am misunderstanding.

const [span, { headers }] = args;

// Next.js prefetch requests have a `next-router-prefetch` header
if (headers?.get('next-router-prefetch')) {
span?.setAttribute('http.request.prefetch', true);
}

return options.onRequestSpanStart?.(...args);
},
});

const { instrumentPageLoad = true, instrumentNavigation = true } = options;
Expand Down
Loading