Skip to content

Commit a0bab2f

Browse files
authored
feat(nextjs): Parameterize server-side transaction names and harvest request data (#3577)
This PR does two things: 1) It wraps two more methods on `next`'s `Server` class, one of which is called on every API request, and the other of which is called on every page request. Both methods are passed a parameterized version of the path, which our wrappers grab and use to modify the name of the currently active transaction. 2) It adds an event processor which pulls data off of the request and adds it to the event. This runs whether or not tracing is enabled, in order to add data to both error and transaction events. The only other thing of minor interest is that the type for `Event.request.query_string` is expanded to include objects, which is okay according to the dev docs: https://develop.sentry.dev/sdk/event-payloads/request/#attribute).
1 parent 415d04e commit a0bab2f

File tree

4 files changed

+105
-29
lines changed

4 files changed

+105
-29
lines changed

packages/nextjs/src/utils/instrumentServer.ts

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { deepReadDirSync } from '@sentry/node';
2-
import { hasTracingEnabled } from '@sentry/tracing';
3-
import { Transaction } from '@sentry/types';
2+
import { getActiveTransaction, hasTracingEnabled } from '@sentry/tracing';
3+
import { Event as SentryEvent, Transaction } from '@sentry/types';
44
import { fill, logger } from '@sentry/utils';
55
import * as domain from 'domain';
66
import * as http from 'http';
77
import { default as createNextServer } from 'next';
8+
import * as querystring from 'querystring';
89
import * as url from 'url';
910

1011
import * as Sentry from '../index.server';
@@ -29,6 +30,8 @@ interface Server {
2930
interface NextRequest extends http.IncomingMessage {
3031
cookies: Record<string, string>;
3132
url: string;
33+
query: { [key: string]: string };
34+
headers: { [key: string]: string };
3235
}
3336

3437
interface NextResponse extends http.ServerResponse {
@@ -40,11 +43,19 @@ interface NextResponse extends http.ServerResponse {
4043
type HandlerGetter = () => Promise<ReqHandler>;
4144
type ReqHandler = (req: NextRequest, res: NextResponse, parsedUrl?: url.UrlWithParsedQuery) => Promise<void>;
4245
type ErrorLogger = (err: Error) => void;
46+
type ApiPageEnsurer = (path: string) => Promise<void>;
47+
type PageComponentFinder = (
48+
pathname: string,
49+
query: querystring.ParsedUrlQuery,
50+
params: { [key: string]: any } | null,
51+
) => Promise<{ [key: string]: any } | null>;
4352

4453
// these aliases are purely to make the function signatures more easily understandable
4554
type WrappedHandlerGetter = HandlerGetter;
4655
type WrappedErrorLogger = ErrorLogger;
4756
type WrappedReqHandler = ReqHandler;
57+
type WrappedApiPageEnsurer = ApiPageEnsurer;
58+
type WrappedPageComponentFinder = PageComponentFinder;
4859

4960
// TODO is it necessary for this to be an object?
5061
const closure: PlainObject = {};
@@ -125,6 +136,12 @@ function makeWrappedHandlerGetter(origHandlerGetter: HandlerGetter): WrappedHand
125136
// to the appropriate handlers)
126137
fill(serverPrototype, 'handleRequest', makeWrappedReqHandler);
127138

139+
// Wrap as a way to grab the parameterized request URL to use as the transaction name for API requests and page
140+
// requests, respectively. These methods are chosen because they're the first spot in the request-handling process
141+
// where the parameterized path is provided as an argument, so it's easy to grab.
142+
fill(serverPrototype, 'ensureApiPage', makeWrappedMethodForGettingParameterizedPath);
143+
fill(serverPrototype, 'findPageComponents', makeWrappedMethodForGettingParameterizedPath);
144+
128145
sdkSetupComplete = true;
129146
}
130147

@@ -182,40 +199,80 @@ function makeWrappedReqHandler(origReqHandler: ReqHandler): WrappedReqHandler {
182199
// local.on('error', Sentry.captureException);
183200

184201
local.run(() => {
185-
// We only want to record page and API requests
186-
if (hasTracingEnabled() && shouldTraceRequest(req.url, publicDirFiles)) {
187-
const transaction = Sentry.startTransaction({
188-
name: `${(req.method || 'GET').toUpperCase()} ${req.url}`,
189-
op: 'http.server',
190-
});
191-
Sentry.getCurrentHub()
192-
.getScope()
193-
?.setSpan(transaction);
194-
195-
res.__sentry__ = { transaction };
196-
197-
res.once('finish', () => {
198-
const transaction = res.__sentry__?.transaction;
199-
if (transaction) {
200-
// Push `transaction.finish` to the next event loop so open spans have a chance to finish before the transaction
201-
// closes
202-
setImmediate(() => {
203-
// TODO
204-
// addExpressReqToTransaction(transaction, req);
202+
const currentScope = Sentry.getCurrentHub().getScope();
203+
204+
if (currentScope) {
205+
currentScope.addEventProcessor(event => addRequestDataToEvent(event, req));
206+
207+
// We only want to record page and API requests
208+
if (hasTracingEnabled() && shouldTraceRequest(req.url, publicDirFiles)) {
209+
// pull off query string, if any
210+
const reqPath = req.url.split('?')[0];
211+
212+
// requests for pages will only ever be GET requests, so don't bother to include the method in the transaction
213+
// name; requests to API routes could be GET, POST, PUT, etc, so do include it there
214+
const namePrefix = req.url.startsWith('/api') ? `${(req.method || 'GET').toUpperCase()} ` : '';
215+
216+
const transaction = Sentry.startTransaction({
217+
name: `${namePrefix}${reqPath}`,
218+
op: 'http.server',
219+
metadata: { requestPath: req.url.split('?')[0] },
220+
});
221+
222+
currentScope.setSpan(transaction);
223+
224+
res.once('finish', () => {
225+
const transaction = getActiveTransaction();
226+
if (transaction) {
205227
transaction.setHttpStatus(res.statusCode);
206-
transaction.finish();
207-
});
208-
}
209-
});
210228

211-
return origReqHandler.call(this, req, res, parsedUrl);
229+
// we'll collect this data in a more targeted way in the event processor we added above,
230+
// `addRequestDataToEvent`
231+
delete transaction.metadata.requestPath;
232+
233+
// Push `transaction.finish` to the next event loop so open spans have a chance to finish before the
234+
// transaction closes
235+
setImmediate(() => {
236+
transaction.finish();
237+
});
238+
}
239+
});
240+
}
212241
}
242+
243+
return origReqHandler.call(this, req, res, parsedUrl);
213244
});
214245
};
215246

216247
return wrappedReqHandler;
217248
}
218249

250+
/**
251+
* Wrap the given method in order to use the parameterized path passed to it in the transaction name.
252+
*
253+
* @param origMethod Either `ensureApiPage` (called for every API request) or `findPageComponents` (called for every
254+
* page request), both from the `Server` class
255+
* @returns A wrapped version of the given method
256+
*/
257+
function makeWrappedMethodForGettingParameterizedPath(
258+
origMethod: ApiPageEnsurer | PageComponentFinder,
259+
): WrappedApiPageEnsurer | WrappedPageComponentFinder {
260+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
261+
const wrappedMethod = async function(this: Server, parameterizedPath: string, ...args: any[]): Promise<any> {
262+
const transaction = getActiveTransaction();
263+
264+
// replace specific URL with parameterized version
265+
if (transaction && transaction.metadata.requestPath) {
266+
const origPath = transaction.metadata.requestPath;
267+
transaction.name = transaction.name.replace(origPath, parameterizedPath);
268+
}
269+
270+
return origMethod.call(this, parameterizedPath, ...args);
271+
};
272+
273+
return wrappedMethod;
274+
}
275+
219276
/**
220277
* Determine if the request should be traced, by filtering out requests for internal next files and static resources.
221278
*
@@ -228,3 +285,17 @@ function shouldTraceRequest(url: string, publicDirFiles: Set<string>): boolean {
228285
// `static` is a deprecated but still-functional location for static resources
229286
return !url.startsWith('/_next/') && !url.startsWith('/static/') && !publicDirFiles.has(url);
230287
}
288+
289+
function addRequestDataToEvent(event: SentryEvent, req: NextRequest): SentryEvent {
290+
event.request = {
291+
...event.request,
292+
// TODO body/data
293+
url: req.url.split('?')[0],
294+
cookies: req.cookies,
295+
headers: req.headers,
296+
method: req.method,
297+
query_string: req.query,
298+
};
299+
300+
return event;
301+
}

packages/types/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export { Mechanism } from './mechanism';
1515
export { ExtractedNodeRequestData, Primitive, WorkerLocation } from './misc';
1616
export { Options } from './options';
1717
export { Package } from './package';
18-
export { Request, SentryRequest, SentryRequestType } from './request';
18+
export { QueryParams, Request, SentryRequest, SentryRequestType } from './request';
1919
export { Response } from './response';
2020
export { Runtime } from './runtime';
2121
export { CaptureContext, Scope, ScopeContext } from './scope';

packages/types/src/request.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ export interface Request {
1313
url?: string;
1414
method?: string;
1515
data?: any;
16-
query_string?: string;
16+
query_string?: QueryParams;
1717
cookies?: { [key: string]: string };
1818
env?: { [key: string]: string };
1919
headers?: { [key: string]: string };
2020
}
21+
22+
export type QueryParams = string | { [key: string]: string } | Array<[string, string]>;

packages/types/src/transaction.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,7 @@ export interface TransactionMetadata {
132132
sentry?: string;
133133
thirdparty?: string;
134134
};
135+
136+
/** For transactions tracing server-side request handling, the path of the request being tracked. */
137+
requestPath?: string;
135138
}

0 commit comments

Comments
 (0)