Skip to content

Commit

Permalink
feat(nextjs): Add request data to all edge-capable functionalities (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
lforst authored Nov 23, 2023
1 parent 8c9ff6b commit eb2d726
Show file tree
Hide file tree
Showing 22 changed files with 172 additions and 26 deletions.
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 {
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 @@ -54,13 +57,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;
}
Expand Down
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

0 comments on commit eb2d726

Please sign in to comment.