1- import { captureException , flush , getCurrentHub , Handlers , startTransaction , withScope } from '@sentry/node' ;
2- import { extractTraceparentData , getActiveTransaction , hasTracingEnabled } from '@sentry/tracing' ;
1+ import { captureException , flush , getCurrentHub , Handlers , startTransaction } from '@sentry/node' ;
2+ import { extractTraceparentData , hasTracingEnabled } from '@sentry/tracing' ;
3+ import { Transaction } from '@sentry/types' ;
34import { addExceptionMechanism , isString , logger , stripUrlQueryAndFragment } from '@sentry/utils' ;
45import * as domain from 'domain' ;
5- import { NextApiHandler } from 'next' ;
6-
7- import { addRequestDataToEvent , NextRequest } from './instrumentServer' ;
6+ import { NextApiHandler , NextApiResponse } from 'next' ;
87
98const { parseRequest } = Handlers ;
109
1110// purely for clarity
1211type WrappedNextApiHandler = NextApiHandler ;
1312
13+ type AugmentedResponse = NextApiResponse & { __sentryTransaction ?: Transaction } ;
14+
1415// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
1516export const withSentry = ( handler : NextApiHandler ) : WrappedNextApiHandler => {
1617 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
1718 return async ( req , res ) => {
18- // wrap everything in a domain in order to prevent scope bleed between requests
19+ // first order of business: monkeypatch `res.end()` so that it will wait for us to send events to sentry before it
20+ // fires (if we don't do this, the lambda will close too early and events will be either delayed or lost)
21+ // eslint-disable-next-line @typescript-eslint/unbound-method
22+ res . end = wrapEndMethod ( res . end ) ;
23+
24+ // use a domain in order to prevent scope bleed between requests
1925 const local = domain . create ( ) ;
2026 local . add ( req ) ;
2127 local . add ( res ) ;
@@ -24,73 +30,102 @@ export const withSentry = (handler: NextApiHandler): WrappedNextApiHandler => {
2430 // return a value. In our case, all any of the codepaths return is a promise of `void`, but nextjs still counts on
2531 // getting that before it will finish the response.
2632 const boundHandler = local . bind ( async ( ) => {
27- try {
28- const currentScope = getCurrentHub ( ) . getScope ( ) ;
33+ const currentScope = getCurrentHub ( ) . getScope ( ) ;
2934
30- if ( currentScope ) {
31- currentScope . addEventProcessor ( event => addRequestDataToEvent ( event , req as NextRequest ) ) ;
32-
33- if ( hasTracingEnabled ( ) ) {
34- // If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision)
35- let traceparentData ;
36- if ( req . headers && isString ( req . headers [ 'sentry-trace' ] ) ) {
37- traceparentData = extractTraceparentData ( req . headers [ 'sentry-trace' ] as string ) ;
38- logger . log ( `[Tracing] Continuing trace ${ traceparentData ?. traceId } .` ) ;
39- }
35+ if ( currentScope ) {
36+ currentScope . addEventProcessor ( event => parseRequest ( event , req ) ) ;
37+
38+ if ( hasTracingEnabled ( ) ) {
39+ // If there is a trace header set, extract the data from it (parentSpanId, traceId, and sampling decision)
40+ let traceparentData ;
41+ if ( req . headers && isString ( req . headers [ 'sentry-trace' ] ) ) {
42+ traceparentData = extractTraceparentData ( req . headers [ 'sentry-trace' ] as string ) ;
43+ logger . log ( `[Tracing] Continuing trace ${ traceparentData ?. traceId } .` ) ;
44+ }
4045
41- const url = `${ req . url } ` ;
42- // pull off query string, if any
43- let reqPath = stripUrlQueryAndFragment ( url ) ;
44- // Replace with placeholder
45- if ( req . query ) {
46- // TODO get this from next if possible, to avoid accidentally replacing non-dynamic parts of the path if
47- // they match dynamic parts
48- for ( const [ key , value ] of Object . entries ( req . query ) ) {
49- reqPath = reqPath . replace ( `${ value } ` , `[${ key } ]` ) ;
50- }
46+ const url = `${ req . url } ` ;
47+ // pull off query string, if any
48+ let reqPath = stripUrlQueryAndFragment ( url ) ;
49+ // Replace with placeholder
50+ if ( req . query ) {
51+ // TODO get this from next if possible, to avoid accidentally replacing non-dynamic parts of the path if
52+ // they match dynamic parts
53+ for ( const [ key , value ] of Object . entries ( req . query ) ) {
54+ reqPath = reqPath . replace ( `${ value } ` , `[${ key } ]` ) ;
5155 }
52- const reqMethod = `${ ( req . method || 'GET' ) . toUpperCase ( ) } ` ;
53-
54- const transaction = startTransaction (
55- {
56- name : `${ reqMethod } ${ reqPath } ` ,
57- op : 'http.server' ,
58- ...traceparentData ,
59- } ,
60- // extra context passed to the `tracesSampler`
61- { request : req } ,
62- ) ;
63- currentScope . setSpan ( transaction ) ;
6456 }
57+ const reqMethod = `${ ( req . method || 'GET' ) . toUpperCase ( ) } ` ;
58+
59+ const transaction = startTransaction (
60+ {
61+ name : `${ reqMethod } ${ reqPath } ` ,
62+ op : 'http.server' ,
63+ ...traceparentData ,
64+ } ,
65+ // extra context passed to the `tracesSampler`
66+ { request : req } ,
67+ ) ;
68+ currentScope . setSpan ( transaction ) ;
69+
70+ // save a link to the transaction on the response, so that even if there's an error (landing us outside of
71+ // the domain), we can still finish it (albeit possibly missing some scope data)
72+ ( res as AugmentedResponse ) . __sentryTransaction = transaction ;
6573 }
74+ }
6675
76+ try {
6777 return await handler ( req , res ) ; // Call original handler
6878 } catch ( e ) {
69- withScope ( scope => {
70- scope . addEventProcessor ( event => {
79+ if ( currentScope ) {
80+ currentScope . addEventProcessor ( event => {
7181 addExceptionMechanism ( event , {
7282 handled : false ,
7383 } ) ;
74- return parseRequest ( event , req ) ;
84+ return event ;
7585 } ) ;
7686 captureException ( e ) ;
77- } ) ;
78- throw e ;
79- } finally {
80- const transaction = getActiveTransaction ( ) ;
81- if ( transaction ) {
82- transaction . setHttpStatus ( res . statusCode ) ;
83-
84- transaction . finish ( ) ;
85- }
86- try {
87- await flush ( 2000 ) ;
88- } catch ( e ) {
89- // no-empty
9087 }
88+ throw e ;
9189 }
9290 } ) ;
9391
9492 return await boundHandler ( ) ;
9593 } ;
9694} ;
95+
96+ type ResponseEndMethod = AugmentedResponse [ 'end' ] ;
97+ type WrappedResponseEndMethod = AugmentedResponse [ 'end' ] ;
98+
99+ function wrapEndMethod ( origEnd : ResponseEndMethod ) : WrappedResponseEndMethod {
100+ return async function newEnd ( this : AugmentedResponse , ...args : unknown [ ] ) {
101+ // TODO: if the handler errored, it will have popped us out of the domain, so all of our scope data will be missing
102+
103+ const transaction = this . __sentryTransaction ;
104+
105+ if ( transaction ) {
106+ transaction . setHttpStatus ( this . statusCode ) ;
107+
108+ // Push `transaction.finish` to the next event loop so open spans have a better chance of finishing before the
109+ // transaction closes, and make sure to wait until that's done before flushing events
110+ const transactionFinished : Promise < void > = new Promise ( resolve => {
111+ setImmediate ( ( ) => {
112+ transaction . finish ( ) ;
113+ resolve ( ) ;
114+ } ) ;
115+ } ) ;
116+ await transactionFinished ;
117+ }
118+
119+ // flush the event queue to ensure that events get sent to Sentry before the response is finished and the lambda
120+ // ends
121+ try {
122+ logger . log ( 'Flushing events...' ) ;
123+ await flush ( 2000 ) ;
124+ logger . log ( 'Done flushing events' ) ;
125+ } catch ( e ) {
126+ logger . log ( `Error while flushing events:\n${ e } ` ) ;
127+ }
128+
129+ return origEnd . call ( this , ...args ) ;
130+ } ;
131+ }
0 commit comments