Skip to content

Commit eb2d726

Browse files
author
Luca Forstner
authored
feat(nextjs): Add request data to all edge-capable functionalities (#9636)
1 parent 8c9ff6b commit eb2d726

22 files changed

+172
-26
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const dynamic = 'force-dynamic';
2+
3+
export const runtime = 'edge';
4+
5+
export default async function Page() {
6+
return <h1>Hello world!</h1>;
7+
}

packages/e2e-tests/test-applications/nextjs-app-dir/sentry.client.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ Sentry.init({
55
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1.0,
8+
sendDefaultPii: true,
89
});

packages/e2e-tests/test-applications/nextjs-app-dir/sentry.edge.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ Sentry.init({
55
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1.0,
8+
sendDefaultPii: true,
89
});

packages/e2e-tests/test-applications/nextjs-app-dir/sentry.server.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ Sentry.init({
55
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
66
tunnel: `http://localhost:3031/`, // proxy server
77
tracesSampleRate: 1.0,
8+
sendDefaultPii: true,
89
});

packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge-route.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,19 @@ test('Should create a transaction for edge routes', async ({ request }) => {
88
);
99
});
1010

11-
const response = await request.get('/api/edge-endpoint');
11+
const response = await request.get('/api/edge-endpoint', {
12+
headers: {
13+
'x-yeet': 'test-value',
14+
},
15+
});
1216
expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' });
1317

1418
const edgerouteTransaction = await edgerouteTransactionPromise;
1519

1620
expect(edgerouteTransaction.contexts?.trace?.status).toBe('ok');
1721
expect(edgerouteTransaction.contexts?.trace?.op).toBe('http.server');
1822
expect(edgerouteTransaction.contexts?.runtime?.name).toBe('vercel-edge');
23+
expect(edgerouteTransaction.request?.headers?.['x-yeet']).toBe('test-value');
1924
});
2025

2126
test('Should create a transaction with error status for faulty edge routes', async ({ request }) => {

packages/e2e-tests/test-applications/nextjs-app-dir/tests/edge.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect } from '@playwright/test';
2-
import { waitForError } from '../event-proxy-server';
2+
import { waitForError, waitForTransaction } from '../event-proxy-server';
33

44
test('Should record exceptions for faulty edge server components', async ({ page }) => {
55
const errorEventPromise = waitForError('nextjs-13-app-dir', errorEvent => {
@@ -10,3 +10,16 @@ test('Should record exceptions for faulty edge server components', async ({ page
1010

1111
expect(await errorEventPromise).toBeDefined();
1212
});
13+
14+
test('Should record transaction for edge server components', async ({ page }) => {
15+
const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
16+
return transactionEvent?.transaction === 'Page Server Component (/edge-server-components)';
17+
});
18+
19+
await page.goto('/edge-server-components');
20+
21+
const serverComponentTransaction = await serverComponentTransactionPromise;
22+
23+
expect(serverComponentTransaction).toBeDefined();
24+
expect(serverComponentTransaction.request?.headers).toBeDefined();
25+
});

packages/e2e-tests/test-applications/nextjs-app-dir/tests/route-handlers.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ test('Should create a transaction for route handlers', async ({ request }) => {
66
return transactionEvent?.transaction === 'GET /route-handlers/[param]';
77
});
88

9-
const response = await request.get('/route-handlers/foo');
9+
const response = await request.get('/route-handlers/foo', { headers: { 'x-yeet': 'test-value' } });
1010
expect(await response.json()).toStrictEqual({ name: 'John Doe' });
1111

1212
const routehandlerTransaction = await routehandlerTransactionPromise;
1313

1414
expect(routehandlerTransaction.contexts?.trace?.status).toBe('ok');
1515
expect(routehandlerTransaction.contexts?.trace?.op).toBe('http.server');
16+
expect(routehandlerTransaction.request?.headers?.['x-yeet']).toBe('test-value');
1617
});
1718

1819
test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({

packages/e2e-tests/test-applications/nextjs-app-dir/tests/transactions.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ if (process.env.TEST_ENV === 'production') {
6363
const transactionEvent = await serverComponentTransactionPromise;
6464
const transactionEventId = transactionEvent.event_id;
6565

66+
expect(transactionEvent.request?.headers).toBeDefined();
67+
6668
await expect
6769
.poll(
6870
async () => {

packages/nextjs/src/common/types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,38 @@
1-
import type { Transaction, WrappedFunction } from '@sentry/types';
1+
import type { Transaction, WebFetchHeaders, WrappedFunction } from '@sentry/types';
22
import type { NextApiRequest, NextApiResponse } from 'next';
33

44
export type ServerComponentContext = {
55
componentRoute: string;
66
componentType: string;
7+
// TODO(v8): Remove
8+
/**
9+
* @deprecated pass a complete `Headers` object with the `headers` field instead.
10+
*/
711
sentryTraceHeader?: string;
12+
// TODO(v8): Remove
13+
/**
14+
* @deprecated pass a complete `Headers` object with the `headers` field instead.
15+
*/
816
baggageHeader?: string;
17+
headers?: WebFetchHeaders;
918
};
1019

1120
export interface RouteHandlerContext {
21+
// TODO(v8): Remove
22+
/**
23+
* @deprecated The SDK will automatically pick up the method from the incoming Request object instead.
24+
*/
1225
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
1326
parameterizedRoute: string;
27+
// TODO(v8): Remove
28+
/**
29+
* @deprecated The SDK will automatically pick up the `sentry-trace` header from the incoming Request object instead.
30+
*/
1431
sentryTraceHeader?: string;
32+
// TODO(v8): Remove
33+
/**
34+
* @deprecated The SDK will automatically pick up the `baggage` header from the incoming Request object instead.
35+
*/
1536
baggageHeader?: string;
1637
}
1738

packages/nextjs/src/common/utils/edgeWrapperUtils.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { addTracingExtensions, captureException, flush, getCurrentHub, startTransaction } from '@sentry/core';
22
import type { Span } from '@sentry/types';
3-
import { addExceptionMechanism, logger, objectify, tracingContextFromHeaders } from '@sentry/utils';
3+
import {
4+
addExceptionMechanism,
5+
logger,
6+
objectify,
7+
tracingContextFromHeaders,
8+
winterCGRequestToRequestData,
9+
} from '@sentry/utils';
410

511
import type { EdgeRouteHandler } from '../../edge/types';
612

@@ -44,6 +50,7 @@ export function withEdgeWrapping<H extends EdgeRouteHandler>(
4450
origin: 'auto.ui.nextjs.withEdgeWrapping',
4551
...traceparentData,
4652
metadata: {
53+
request: winterCGRequestToRequestData(req),
4754
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
4855
source: 'route',
4956
},

packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core';
2-
import { tracingContextFromHeaders } from '@sentry/utils';
2+
import { tracingContextFromHeaders, winterCGRequestToRequestData } from '@sentry/utils';
33

44
import { isRedirectNavigationError } from './nextNavigationErrorUtils';
55
import type { RouteHandlerContext } from './types';
@@ -14,13 +14,21 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
1414
context: RouteHandlerContext,
1515
): (...args: Parameters<F>) => ReturnType<F> extends Promise<unknown> ? ReturnType<F> : Promise<ReturnType<F>> {
1616
addTracingExtensions();
17+
// eslint-disable-next-line deprecation/deprecation
1718
const { method, parameterizedRoute, baggageHeader, sentryTraceHeader } = context;
1819
return new Proxy(routeHandler, {
1920
apply: (originalFunction, thisArg, args) => {
2021
return runWithAsyncContext(async () => {
2122
const hub = getCurrentHub();
2223
const currentScope = hub.getScope();
2324

25+
let req: Request | undefined;
26+
let reqMethod: string | undefined;
27+
if (args[0] instanceof Request) {
28+
req = args[0];
29+
reqMethod = req.method;
30+
}
31+
2432
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
2533
sentryTraceHeader,
2634
baggageHeader,
@@ -32,10 +40,11 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
3240
res = await trace(
3341
{
3442
op: 'http.server',
35-
name: `${method} ${parameterizedRoute}`,
43+
name: `${reqMethod ?? method} ${parameterizedRoute}`,
3644
status: 'ok',
3745
...traceparentData,
3846
metadata: {
47+
request: req ? winterCGRequestToRequestData(req) : undefined,
3948
source: 'route',
4049
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
4150
},

packages/nextjs/src/common/wrapServerComponentWithSentry.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
runWithAsyncContext,
77
startTransaction,
88
} from '@sentry/core';
9-
import { tracingContextFromHeaders } from '@sentry/utils';
9+
import { tracingContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils';
1010

1111
import { isNotFoundNavigationError, isRedirectNavigationError } from '../common/nextNavigationErrorUtils';
1212
import type { ServerComponentContext } from '../common/types';
@@ -33,9 +33,15 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
3333

3434
let maybePromiseResult;
3535

36+
const completeHeadersDict: Record<string, string> = context.headers
37+
? winterCGHeadersToDict(context.headers)
38+
: {};
39+
3640
const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
37-
context.sentryTraceHeader,
38-
context.baggageHeader,
41+
// eslint-disable-next-line deprecation/deprecation
42+
context.sentryTraceHeader ?? completeHeadersDict['sentry-trace'],
43+
// eslint-disable-next-line deprecation/deprecation
44+
context.baggageHeader ?? completeHeadersDict['baggage'],
3945
);
4046
currentScope.setPropagationContext(propagationContext);
4147

@@ -46,6 +52,9 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
4652
origin: 'auto.function.nextjs',
4753
...traceparentData,
4854
metadata: {
55+
request: {
56+
headers: completeHeadersDict,
57+
},
4958
source: 'component',
5059
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
5160
},
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import type { WebFetchHeaders } from '@sentry/types';
2+
13
export interface RequestAsyncStorage {
24
getStore: () =>
35
| {
4-
headers: {
5-
get: Headers['get'];
6-
};
6+
headers: WebFetchHeaders;
77
}
88
| undefined;
99
}

packages/nextjs/src/config/templates/serverComponentWrapperTemplate.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM_
77
import * as serverComponentModule from '__SENTRY_WRAPPING_TARGET_FILE__';
88
// eslint-disable-next-line import/no-extraneous-dependencies
99
import * as Sentry from '@sentry/nextjs';
10+
import type { WebFetchHeaders } from '@sentry/types';
1011

1112
import type { RequestAsyncStorage } from './requestAsyncStorageShim';
1213

@@ -27,12 +28,14 @@ if (typeof serverComponent === 'function') {
2728
apply: (originalFunction, thisArg, args) => {
2829
let sentryTraceHeader: string | undefined | null = undefined;
2930
let baggageHeader: string | undefined | null = undefined;
31+
let headers: WebFetchHeaders | undefined = undefined;
3032

3133
// We try-catch here just in `requestAsyncStorage` is undefined since it may not be defined
3234
try {
3335
const requestAsyncStore = requestAsyncStorage.getStore();
3436
sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace');
3537
baggageHeader = requestAsyncStore?.headers.get('baggage');
38+
headers = requestAsyncStore?.headers;
3639
} catch (e) {
3740
/** empty */
3841
}
@@ -42,6 +45,7 @@ if (typeof serverComponent === 'function') {
4245
componentType: '__COMPONENT_TYPE__',
4346
sentryTraceHeader,
4447
baggageHeader,
48+
headers,
4549
}).apply(thisArg, args);
4650
},
4751
});

packages/nextjs/test/edge/edgeWrapperUtils.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ describe('withEdgeWrapping', () => {
8787
await wrappedFunction(request);
8888
expect(startTransactionSpy).toHaveBeenCalledTimes(1);
8989
expect(startTransactionSpy).toHaveBeenCalledWith(
90-
expect.objectContaining({ metadata: { source: 'route' }, name: 'some label', op: 'some op' }),
90+
expect.objectContaining({
91+
metadata: expect.objectContaining({ source: 'route' }),
92+
name: 'some label',
93+
op: 'some op',
94+
}),
9195
);
9296
});
9397

packages/nextjs/test/edge/withSentryAPI.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ describe('wrapApiHandlerWithSentry', () => {
5252
expect(startTransactionSpy).toHaveBeenCalledTimes(1);
5353
expect(startTransactionSpy).toHaveBeenCalledWith(
5454
expect.objectContaining({
55-
metadata: { source: 'route' },
55+
metadata: expect.objectContaining({ source: 'route' }),
5656
name: 'POST /user/[userId]/post/[postId]',
5757
op: 'http.server',
5858
}),

packages/types/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export type {
122122
TransportRequestExecutor,
123123
} from './transport';
124124
export type { User, UserFeedback } from './user';
125+
export type { WebFetchHeaders, WebFetchRequest } from './webfetchapi';
125126
export type { WrappedFunction } from './wrappedfunction';
126127
export type { Instrumenter } from './instrumenter';
127128
export type {

packages/types/src/instrument.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// This should be: null | Blob | BufferSource | FormData | URLSearchParams | string
22
// But since not all of those are available in node, we just export `unknown` here for now
3+
4+
import type { WebFetchHeaders } from './webfetchapi';
5+
36
// Make sure to cast it where needed!
47
type XHRSendInput = unknown;
58

@@ -54,13 +57,7 @@ export interface HandlerDataFetch {
5457
readonly ok: boolean;
5558
readonly status: number;
5659
readonly url: string;
57-
headers: {
58-
append(name: string, value: string): void;
59-
delete(name: string): void;
60-
get(name: string): string | null;
61-
has(name: string): boolean;
62-
set(name: string, value: string): void;
63-
};
60+
headers: WebFetchHeaders;
6461
};
6562
error?: unknown;
6663
}

packages/types/src/webfetchapi.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// These are vendored types for the standard web fetch API types because typescript needs the DOM types to be able to understand the `Request`, `Headers`, ... types and not everybody has those.
2+
3+
export interface WebFetchHeaders {
4+
append(name: string, value: string): void;
5+
delete(name: string): void;
6+
get(name: string): string | null;
7+
has(name: string): boolean;
8+
set(name: string, value: string): void;
9+
forEach(callbackfn: (value: string, key: string, parent: WebFetchHeaders) => void): void;
10+
}
11+
12+
export interface WebFetchRequest {
13+
readonly headers: WebFetchHeaders;
14+
readonly method: string;
15+
readonly url: string;
16+
clone(): WebFetchRequest;
17+
}

0 commit comments

Comments
 (0)