Skip to content

feat(nextjs): Add request data to all edge-capable functionalities #9636

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
Nov 23, 2023
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const dynamic = 'force-dynamic';

export const runtime = 'edge';

export default async function Page() {
return <h1>Hello world!</h1>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ Sentry.init({
dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN,
tunnel: `http://localhost:3031/`, // proxy server
tracesSampleRate: 1.0,
sendDefaultPii: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ test('Should create a transaction for edge routes', async ({ request }) => {
);
});

const response = await request.get('/api/edge-endpoint');
const response = await request.get('/api/edge-endpoint', {
headers: {
'x-yeet': 'test-value',
},
});
expect(await response.json()).toStrictEqual({ name: 'Jim Halpert' });

const edgerouteTransaction = await edgerouteTransactionPromise;

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

test('Should create a transaction with error status for faulty edge routes', async ({ request }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
import { waitForError } from '../event-proxy-server';
import { waitForError, waitForTransaction } from '../event-proxy-server';

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

expect(await errorEventPromise).toBeDefined();
});

test('Should record transaction for edge server components', async ({ page }) => {
const serverComponentTransactionPromise = waitForTransaction('nextjs-13-app-dir', async transactionEvent => {
return transactionEvent?.transaction === 'Page Server Component (/edge-server-components)';
});

await page.goto('/edge-server-components');

const serverComponentTransaction = await serverComponentTransactionPromise;

expect(serverComponentTransaction).toBeDefined();
expect(serverComponentTransaction.request?.headers).toBeDefined();
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ test('Should create a transaction for route handlers', async ({ request }) => {
return transactionEvent?.transaction === 'GET /route-handlers/[param]';
});

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

const routehandlerTransaction = await routehandlerTransactionPromise;

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

test('Should create a transaction for route handlers and correctly set span status depending on http status', async ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ if (process.env.TEST_ENV === 'production') {
const transactionEvent = await serverComponentTransactionPromise;
const transactionEventId = transactionEvent.event_id;

expect(transactionEvent.request?.headers).toBeDefined();

await expect
.poll(
async () => {
Expand Down
23 changes: 22 additions & 1 deletion packages/nextjs/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import type { Transaction, WrappedFunction } from '@sentry/types';
import type { Transaction, WebFetchHeaders, WrappedFunction } from '@sentry/types';
import type { NextApiRequest, NextApiResponse } from 'next';

export type ServerComponentContext = {
componentRoute: string;
componentType: string;
// TODO(v8): Remove
/**
* @deprecated pass a complete `Headers` object with the `headers` field instead.
*/
sentryTraceHeader?: string;
// TODO(v8): Remove
/**
* @deprecated pass a complete `Headers` object with the `headers` field instead.
*/
baggageHeader?: string;
headers?: WebFetchHeaders;
};

export interface RouteHandlerContext {
// TODO(v8): Remove
/**
* @deprecated The SDK will automatically pick up the method from the incoming Request object instead.
*/
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
parameterizedRoute: string;
// TODO(v8): Remove
/**
* @deprecated The SDK will automatically pick up the `sentry-trace` header from the incoming Request object instead.
*/
sentryTraceHeader?: string;
// TODO(v8): Remove
/**
* @deprecated The SDK will automatically pick up the `baggage` header from the incoming Request object instead.
*/
baggageHeader?: string;
}

Expand Down
9 changes: 8 additions & 1 deletion packages/nextjs/src/common/utils/edgeWrapperUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { addTracingExtensions, captureException, flush, getCurrentHub, startTransaction } from '@sentry/core';
import type { Span } from '@sentry/types';
import { addExceptionMechanism, logger, objectify, tracingContextFromHeaders } from '@sentry/utils';
import {
addExceptionMechanism,
logger,
objectify,
tracingContextFromHeaders,
winterCGRequestToRequestData,
} from '@sentry/utils';

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

Expand Down Expand Up @@ -44,6 +50,7 @@ export function withEdgeWrapping<H extends EdgeRouteHandler>(
origin: 'auto.ui.nextjs.withEdgeWrapping',
...traceparentData,
metadata: {
request: winterCGRequestToRequestData(req),
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
source: 'route',
},
Expand Down
13 changes: 11 additions & 2 deletions packages/nextjs/src/common/wrapRouteHandlerWithSentry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { addTracingExtensions, captureException, flush, getCurrentHub, runWithAsyncContext, trace } from '@sentry/core';
import { tracingContextFromHeaders } from '@sentry/utils';
import { tracingContextFromHeaders, winterCGRequestToRequestData } from '@sentry/utils';

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

let req: Request | undefined;
let reqMethod: string | undefined;
if (args[0] instanceof Request) {
req = args[0];
reqMethod = req.method;
}

const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
sentryTraceHeader,
baggageHeader,
Expand All @@ -32,10 +40,11 @@ export function wrapRouteHandlerWithSentry<F extends (...args: any[]) => any>(
res = await trace(
{
op: 'http.server',
name: `${method} ${parameterizedRoute}`,
name: `${reqMethod ?? method} ${parameterizedRoute}`,
status: 'ok',
...traceparentData,
metadata: {
request: req ? winterCGRequestToRequestData(req) : undefined,
source: 'route',
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
},
Expand Down
15 changes: 12 additions & 3 deletions packages/nextjs/src/common/wrapServerComponentWithSentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
runWithAsyncContext,
startTransaction,
} from '@sentry/core';
import { tracingContextFromHeaders } from '@sentry/utils';
import { tracingContextFromHeaders, winterCGHeadersToDict } from '@sentry/utils';

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

let maybePromiseResult;

const completeHeadersDict: Record<string, string> = context.headers
? winterCGHeadersToDict(context.headers)
: {};

const { traceparentData, dynamicSamplingContext, propagationContext } = tracingContextFromHeaders(
context.sentryTraceHeader,
context.baggageHeader,
// eslint-disable-next-line deprecation/deprecation
context.sentryTraceHeader ?? completeHeadersDict['sentry-trace'],
// eslint-disable-next-line deprecation/deprecation
context.baggageHeader ?? completeHeadersDict['baggage'],
);
currentScope.setPropagationContext(propagationContext);

Expand All @@ -46,6 +52,9 @@ export function wrapServerComponentWithSentry<F extends (...args: any[]) => any>
origin: 'auto.function.nextjs',
...traceparentData,
metadata: {
request: {
headers: completeHeadersDict,
},
source: 'component',
dynamicSamplingContext: traceparentData && !dynamicSamplingContext ? {} : dynamicSamplingContext,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { WebFetchHeaders } from '@sentry/types';

export interface RequestAsyncStorage {
getStore: () =>
| {
headers: {
get: Headers['get'];
};
headers: WebFetchHeaders;
}
| undefined;
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { requestAsyncStorage } from '__SENTRY_NEXTJS_REQUEST_ASYNC_STORAGE_SHIM_
import * as serverComponentModule from '__SENTRY_WRAPPING_TARGET_FILE__';
// eslint-disable-next-line import/no-extraneous-dependencies
import * as Sentry from '@sentry/nextjs';
import type { WebFetchHeaders } from '@sentry/types';

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

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

// We try-catch here just in `requestAsyncStorage` is undefined since it may not be defined
try {
const requestAsyncStore = requestAsyncStorage.getStore();
sentryTraceHeader = requestAsyncStore?.headers.get('sentry-trace');
baggageHeader = requestAsyncStore?.headers.get('baggage');
headers = requestAsyncStore?.headers;
} catch (e) {
/** empty */
}
Expand All @@ -42,6 +45,7 @@ if (typeof serverComponent === 'function') {
componentType: '__COMPONENT_TYPE__',
sentryTraceHeader,
baggageHeader,
headers,
}).apply(thisArg, args);
},
});
Expand Down
6 changes: 5 additions & 1 deletion packages/nextjs/test/edge/edgeWrapperUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ describe('withEdgeWrapping', () => {
await wrappedFunction(request);
expect(startTransactionSpy).toHaveBeenCalledTimes(1);
expect(startTransactionSpy).toHaveBeenCalledWith(
expect.objectContaining({ metadata: { source: 'route' }, name: 'some label', op: 'some op' }),
expect.objectContaining({
metadata: expect.objectContaining({ source: 'route' }),
name: 'some label',
op: 'some op',
}),
);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/test/edge/withSentryAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('wrapApiHandlerWithSentry', () => {
expect(startTransactionSpy).toHaveBeenCalledTimes(1);
expect(startTransactionSpy).toHaveBeenCalledWith(
expect.objectContaining({
metadata: { source: 'route' },
metadata: expect.objectContaining({ source: 'route' }),
name: 'POST /user/[userId]/post/[postId]',
op: 'http.server',
}),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export type {
TransportRequestExecutor,
} from './transport';
export type { User, UserFeedback } from './user';
export type { WebFetchHeaders, WebFetchRequest } from './webfetchapi';
export type { WrappedFunction } from './wrappedfunction';
export type { Instrumenter } from './instrumenter';
export type { HandlerDataFetch, HandlerDataXhr, SentryXhrData, SentryWrappedXMLHttpRequest } from './instrument';
Expand Down
11 changes: 4 additions & 7 deletions packages/types/src/instrument.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// This should be: null | Blob | BufferSource | FormData | URLSearchParams | string
// But since not all of those are available in node, we just export `unknown` here for now

import type { WebFetchHeaders } from './webfetchapi';

// Make sure to cast it where needed!
type XHRSendInput = unknown;

Expand Down Expand Up @@ -45,13 +48,7 @@ export interface HandlerDataFetch {
readonly ok: boolean;
readonly status: number;
readonly url: string;
headers: {
append(name: string, value: string): void;
delete(name: string): void;
get(name: string): string | null;
has(name: string): boolean;
set(name: string, value: string): void;
};
headers: WebFetchHeaders;
};
error?: unknown;
}
17 changes: 17 additions & 0 deletions packages/types/src/webfetchapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 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.

export interface WebFetchHeaders {
append(name: string, value: string): void;
delete(name: string): void;
get(name: string): string | null;
has(name: string): boolean;
set(name: string, value: string): void;
forEach(callbackfn: (value: string, key: string, parent: WebFetchHeaders) => void): void;
}

export interface WebFetchRequest {
readonly headers: WebFetchHeaders;
readonly method: string;
readonly url: string;
clone(): WebFetchRequest;
}
Loading