@honeybadger-io/nextjs 5.10.14 → 5.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ import Honeybadger from '@honeybadger-io/js';
2
+
3
+ /**
4
+ * Edge-safe equivalents of the inbound instrumentation helpers in
5
+ * `@honeybadger-io/js` (src/server/instrumentation/http_event.ts). They are
6
+ * duplicated here because this module must also load on the edge runtime where
7
+ * Node builtins (the `crypto` module, `process.hrtime`) are unavailable. Keep
8
+ * the header names and the `request_id` / `correlation_id` contract in sync
9
+ * with that file.
10
+ *
11
+ * Both request shapes Next.js uses are supported: the `*RequestEventContext` /
12
+ * `*RequestEvent` pairs come in a web-`Headers`/`Request` variant (App Router
13
+ * route handlers and middleware) and a Node-bag variant (Pages Router API
14
+ * routes, which only ever run on the Node runtime).
15
+ */
16
+ function generateId(): string {
17
+ const webCrypto = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
18
+ if (webCrypto && typeof webCrypto.randomUUID === 'function') {
19
+ try {
20
+ return webCrypto.randomUUID()
21
+ }
22
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
23
+ catch (error) {
24
+ // fall through to manual generation
25
+ }
26
+ }
27
+ // v4-shaped, not crypto-quality. Acceptable since this is a correlation id,
28
+ // not a security token.
29
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
30
+ const r = (Math.random() * 16) | 0
31
+ const v = ch === 'x' ? r : (r & 0x3) | 0x8
32
+ return v.toString(16)
33
+ })
34
+ }
35
+
36
+ function readHeader(headers: Headers, name: string): string | undefined {
37
+ const value = headers.get(name)
38
+ if (typeof value !== 'string') {
39
+ return undefined
40
+ }
41
+ const trimmed = value.trim()
42
+ return trimmed.length ? trimmed : undefined
43
+ }
44
+
45
+ // Node-style headers bag, as seen on the Pages Router `req` (an
46
+ // `IncomingMessage`): lowercased keys, values that may be arrays.
47
+ export type NodeHeaders = Record<string, string | string[] | undefined>
48
+
49
+ // Minimal shape of a Node-style request (Pages Router `NextApiRequest`),
50
+ // covering only what the instrumentation reads.
51
+ export type NodeRequestLike = { method?: string; url?: string; headers: NodeHeaders }
52
+
53
+ function readNodeHeader(headers: NodeHeaders, name: string): string | undefined {
54
+ if (!headers) {
55
+ return undefined
56
+ }
57
+ const lower = name.toLowerCase()
58
+ let value: string | string[] | undefined = headers[lower]
59
+ if (value === undefined) {
60
+ for (const key of Object.keys(headers)) {
61
+ if (key.toLowerCase() === lower) {
62
+ value = headers[key]
63
+ break
64
+ }
65
+ }
66
+ }
67
+ if (Array.isArray(value)) {
68
+ value = value[0]
69
+ }
70
+ if (typeof value !== 'string') {
71
+ return undefined
72
+ }
73
+ const trimmed = value.trim()
74
+ return trimmed.length ? trimmed : undefined
75
+ }
76
+
77
+ // A type alias (not an interface) so it stays assignable to the
78
+ // Record<string, unknown> that setEventContext expects.
79
+ export type RequestIds = {
80
+ request_id: string
81
+ correlation_id: string
82
+ }
83
+
84
+ // Shared id precedence. Kept in one place (rather than once per request shape)
85
+ // so the header-name contract documented above is only spelled out once.
86
+ function seedIds(read: (name: string) => string | undefined): RequestIds {
87
+ const requestId =
88
+ read('x-request-id') ??
89
+ read('request-id') ??
90
+ generateId()
91
+ const correlationId =
92
+ read('x-correlation-id') ??
93
+ read('x-amzn-trace-id') ??
94
+ requestId
95
+ return { request_id: requestId, correlation_id: correlationId }
96
+ }
97
+
98
+ // App Router / middleware: headers are a web `Headers` instance.
99
+ export function seedRequestEventContext(headers: Headers): RequestIds {
100
+ return seedIds((name) => readHeader(headers, name))
101
+ }
102
+
103
+ // Pages Router: headers are a Node bag (Pages routes are Node-only, never edge).
104
+ export function seedNodeRequestEventContext(headers: NodeHeaders): RequestIds {
105
+ return seedIds((name) => readNodeHeader(headers, name))
106
+ }
107
+
108
+ export function now(): number {
109
+ return typeof performance !== 'undefined' ? performance.now() : Date.now()
110
+ }
111
+
112
+ // Mirrors Util.resolveInsights from @honeybadger-io/core: the master gate and
113
+ // the per-source flag must both be on.
114
+ export function insightsHttpEnabled(): boolean {
115
+ const insights = Honeybadger.config.insights
116
+ return insights?.enabled === true && insights?.http === true
117
+ }
118
+
119
+ // The ids are embedded directly in the payload (instead of relying on the
120
+ // store's eventContext merge) so the event carries them even on the edge
121
+ // runtime, where there is no per-request store isolation. On the Node.js
122
+ // runtime they match the seeded event context, so embedding is a no-op.
123
+ function emitHandledEvent(method: string | undefined, path: string | undefined, status: number | undefined, start: number, ids: RequestIds): void {
124
+ const payload: Record<string, unknown> = {
125
+ method,
126
+ duration: Math.round(now() - start),
127
+ ...ids,
128
+ }
129
+ if (typeof path === 'string') {
130
+ payload.path = path
131
+ }
132
+ if (typeof status === 'number') {
133
+ payload.status = status
134
+ }
135
+ Honeybadger.event('request.handled', payload)
136
+ }
137
+
138
+ // App Router / middleware: `req.url` is absolute, so parse out the pathname.
139
+ export function emitRequestEvent(req: Request, status: number | undefined, start: number, ids: RequestIds): void {
140
+ let path: string | undefined
141
+ try {
142
+ path = new URL(req.url).pathname
143
+ }
144
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
145
+ catch (error) {
146
+ // relative or malformed URL — leave path unset
147
+ }
148
+ emitHandledEvent(req.method, path, status, start, ids)
149
+ }
150
+
151
+ // Pages Router: `req.url` is a relative path that may carry a query string.
152
+ export function emitNodeRequestEvent(req: NodeRequestLike, status: number | undefined, start: number, ids: RequestIds): void {
153
+ const path = typeof req.url === 'string' ? req.url.split('?')[0] : undefined
154
+ emitHandledEvent(req.method, path, status, start, ids)
155
+ }
@@ -0,0 +1,527 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import Honeybadger from '@honeybadger-io/js'
3
+ import type { NextResponse } from 'next/server'
4
+ import { withHoneybadger } from './with-honeybadger'
5
+
6
+ // Mutable so individual tests can install/remove `after` without reloading the
7
+ // module. scheduleFlush reads nextServer.after at call time via the namespace.
8
+ const nextServerMocks: { after?: (cb: () => unknown) => void } = {}
9
+ jest.mock('next/server', () => {
10
+ const actual = jest.requireActual('next/server') as Record<string, unknown>
11
+ return {
12
+ ...actual,
13
+ get after() {
14
+ return nextServerMocks.after
15
+ },
16
+ }
17
+ })
18
+
19
+ describe('withHoneybadger', () => {
20
+ let workerLogSpy: jest.SpyInstance
21
+ let notifyAsyncSpy: jest.SpyInstance
22
+ let flushAsyncSpy: jest.SpyInstance
23
+
24
+ // The events worker is `protected` on Client; in tests we read it via `as any`.
25
+ const eventsWorker = () => (Honeybadger as any).__eventsWorker
26
+
27
+ // client.event() pushes to the worker after the async beforeEvent chain
28
+ // resolves — give it a tick.
29
+ const waitForEvents = () => new Promise((resolve) => setTimeout(resolve, 50))
30
+
31
+ const nullLogger = () => ({
32
+ log: () => undefined,
33
+ info: () => undefined,
34
+ debug: () => undefined,
35
+ warn: () => undefined,
36
+ error: () => undefined,
37
+ })
38
+
39
+ const requestHandledCalls = () =>
40
+ workerLogSpy.mock.calls.filter(
41
+ (c) => (c[0] as Record<string, unknown>).event_type === 'request.handled'
42
+ )
43
+
44
+ const okHandler = jest.fn(async () => new Response('ok', { status: 200 }) as NextResponse)
45
+
46
+ // The platform-injected waitUntil accessor that Next.js reads to extend a
47
+ // serverless invocation past the response.
48
+ const requestContextSymbol = Symbol.for('@next/request-context')
49
+ const installRequestContext = (waitUntil: (p: Promise<unknown>) => void) => {
50
+ (globalThis as any)[requestContextSymbol] = { get: () => ({ waitUntil }) }
51
+ }
52
+
53
+ beforeEach(() => {
54
+ delete (globalThis as any)[requestContextSymbol]
55
+ delete nextServerMocks.after
56
+ // Configure explicitly so the wrapper's auto-configure (env-based) is
57
+ // skipped, and reset the insights gates mutated by previous tests.
58
+ Honeybadger.configure({
59
+ apiKey: 'test-api-key',
60
+ environment: 'test',
61
+ logger: nullLogger() as any,
62
+ insights: { enabled: false, console: false, http: false },
63
+ })
64
+ Honeybadger.clear()
65
+ // Stub the worker's queue entry point: the spy still records the merged
66
+ // event payloads, but nothing enters the queue — otherwise the worker's
67
+ // dispatch cooldown timer keeps the jest process alive after the run.
68
+ workerLogSpy = jest.spyOn(eventsWorker(), 'log').mockImplementation(() => undefined)
69
+ notifyAsyncSpy = jest.spyOn(Honeybadger, 'notifyAsync').mockResolvedValue(undefined as any)
70
+ // next/server has no after() in our pinned Next 13, so scheduleFlush falls
71
+ // back to a blocking flushAsync — spy it to assert delivery is scheduled
72
+ // per instrumented request.
73
+ flushAsyncSpy = jest.spyOn(Honeybadger, 'flushAsync').mockResolvedValue(undefined as any)
74
+ okHandler.mockClear()
75
+ })
76
+
77
+ afterEach(() => {
78
+ delete (globalThis as any)[requestContextSymbol]
79
+ jest.restoreAllMocks()
80
+ })
81
+
82
+ describe('with insights: { enabled: true, http: true }', () => {
83
+ beforeEach(() => {
84
+ Honeybadger.configure({ insights: { enabled: true, http: true } })
85
+ })
86
+
87
+ it('emits a request.handled event with method, path, status, and duration', async () => {
88
+ const wrapped = withHoneybadger(okHandler)
89
+ const res = await wrapped(new Request('http://localhost:3000/api/items?secret=1'))
90
+
91
+ expect(res.status).toBe(200)
92
+ await waitForEvents()
93
+
94
+ const calls = requestHandledCalls()
95
+ expect(calls).toHaveLength(1)
96
+ const payload = calls[0][0] as Record<string, unknown>
97
+ expect(payload).toMatchObject({
98
+ method: 'GET',
99
+ path: '/api/items',
100
+ status: 200,
101
+ })
102
+ expect(typeof payload.duration).toBe('number')
103
+ expect(payload.duration as number).toBeGreaterThanOrEqual(0)
104
+ expect(flushAsyncSpy).toHaveBeenCalled()
105
+ })
106
+
107
+ it('registers flush with after() when available (App Router)', async () => {
108
+ const afterMock = jest.fn((cb: () => unknown) => { void cb() })
109
+ nextServerMocks.after = afterMock
110
+
111
+ const wrapped = withHoneybadger(okHandler)
112
+ await wrapped(new Request('http://localhost:3000/api/items'))
113
+ await waitForEvents()
114
+
115
+ expect(afterMock).toHaveBeenCalledTimes(1)
116
+ expect(flushAsyncSpy).toHaveBeenCalled()
117
+ })
118
+
119
+ it('falls back to waitUntil when after() throws', async () => {
120
+ nextServerMocks.after = jest.fn(() => {
121
+ throw new Error('after() called outside a request scope')
122
+ })
123
+ const waitUntil = jest.fn((_p: Promise<unknown>) => undefined)
124
+ installRequestContext(waitUntil)
125
+
126
+ const wrapped = withHoneybadger(okHandler)
127
+ const res = await wrapped(new Request('http://localhost:3000/api/items'))
128
+
129
+ expect(res.status).toBe(200)
130
+ await waitForEvents()
131
+
132
+ expect(nextServerMocks.after).toHaveBeenCalledTimes(1)
133
+ expect(waitUntil).toHaveBeenCalledTimes(1)
134
+ expect(flushAsyncSpy).toHaveBeenCalled()
135
+ })
136
+
137
+ it('falls back to a blocking flush when after() throws and no waitUntil exists', async () => {
138
+ nextServerMocks.after = jest.fn(() => {
139
+ throw new Error('after() called outside a request scope')
140
+ })
141
+
142
+ const wrapped = withHoneybadger(okHandler)
143
+ const res = await wrapped(new Request('http://localhost:3000/api/items'))
144
+
145
+ expect(res.status).toBe(200)
146
+ await waitForEvents()
147
+
148
+ expect(flushAsyncSpy).toHaveBeenCalled()
149
+ })
150
+
151
+ it('uses the middleware event waitUntil when after() is unavailable', async () => {
152
+ const waitUntil = jest.fn((_p: Promise<unknown>) => undefined)
153
+ // Middleware is called as (request, event); the event owns waitUntil.
154
+ const event = { waitUntil }
155
+
156
+ const wrapped = withHoneybadger(okHandler)
157
+ await (wrapped as any)(new Request('http://localhost:3000/api/items'), event)
158
+ await waitForEvents()
159
+
160
+ expect(waitUntil).toHaveBeenCalledTimes(1)
161
+ expect(flushAsyncSpy).toHaveBeenCalled()
162
+ })
163
+
164
+ it('falls back to the platform request-context waitUntil (App Router route handler)', async () => {
165
+ const waitUntil = jest.fn((_p: Promise<unknown>) => undefined)
166
+ installRequestContext(waitUntil)
167
+
168
+ const wrapped = withHoneybadger(okHandler)
169
+ // Route handlers get { params } as the second argument — no waitUntil.
170
+ await (wrapped as any)(new Request('http://localhost:3000/api/items'), { params: {} })
171
+ await waitForEvents()
172
+
173
+ expect(waitUntil).toHaveBeenCalledTimes(1)
174
+ expect(flushAsyncSpy).toHaveBeenCalled()
175
+ })
176
+
177
+ it('carries request_id/correlation_id from the request headers', async () => {
178
+ const wrapped = withHoneybadger(okHandler)
179
+ await wrapped(new Request('http://localhost:3000/api/items', {
180
+ headers: { 'x-request-id': 'abc-123' },
181
+ }))
182
+ await waitForEvents()
183
+
184
+ const payload = requestHandledCalls()[0][0] as Record<string, unknown>
185
+ expect(payload.request_id).toBe('abc-123')
186
+ expect(payload.correlation_id).toBe('abc-123')
187
+ })
188
+
189
+ it('uses x-correlation-id for correlation_id (distinct from request_id)', async () => {
190
+ const wrapped = withHoneybadger(okHandler)
191
+ await wrapped(new Request('http://localhost:3000/api/items', {
192
+ headers: { 'x-request-id': 'req-1', 'x-correlation-id': 'trace-9' },
193
+ }))
194
+ await waitForEvents()
195
+
196
+ const payload = requestHandledCalls()[0][0] as Record<string, unknown>
197
+ expect(payload.request_id).toBe('req-1')
198
+ expect(payload.correlation_id).toBe('trace-9')
199
+ })
200
+
201
+ it('generates non-empty equal ids when no headers are present', async () => {
202
+ const wrapped = withHoneybadger(okHandler)
203
+ await wrapped(new Request('http://localhost:3000/api/items'))
204
+ await waitForEvents()
205
+
206
+ const payload = requestHandledCalls()[0][0] as Record<string, unknown>
207
+ expect(typeof payload.request_id).toBe('string')
208
+ expect((payload.request_id as string).length).toBeGreaterThan(0)
209
+ expect(payload.correlation_id).toBe(payload.request_id)
210
+ })
211
+
212
+ it('emits with status 500, notifies, and re-throws when the handler throws', async () => {
213
+ const error = new Error('boom')
214
+ const wrapped = withHoneybadger(async () => {
215
+ throw error
216
+ })
217
+
218
+ await expect(wrapped(new Request('http://localhost:3000/api/fail'))).rejects.toThrow('boom')
219
+ await waitForEvents()
220
+
221
+ const calls = requestHandledCalls()
222
+ expect(calls).toHaveLength(1)
223
+ const payload = calls[0][0] as Record<string, unknown>
224
+ expect(payload.status).toBe(500)
225
+ expect(payload.path).toBe('/api/fail')
226
+ expect(notifyAsyncSpy).toHaveBeenCalledWith(error)
227
+ expect(flushAsyncSpy).toHaveBeenCalled()
228
+ })
229
+
230
+ it('does not emit request.handled when the first argument is not a Request', async () => {
231
+ const wrapped = withHoneybadger(okHandler as any)
232
+ const res = await wrapped(undefined as any)
233
+
234
+ expect(res.status).toBe(200)
235
+ await waitForEvents()
236
+
237
+ expect(requestHandledCalls()).toHaveLength(0)
238
+ expect(flushAsyncSpy).not.toHaveBeenCalled()
239
+ })
240
+
241
+ it('does not leak event context between concurrent requests', async () => {
242
+ const slowHandler = async () => {
243
+ await new Promise((resolve) => setTimeout(resolve, 20))
244
+ return new Response('ok', { status: 200 }) as NextResponse
245
+ }
246
+ const wrapped = withHoneybadger(slowHandler)
247
+
248
+ await Promise.all([1, 2].map((i) => wrapped(new Request('http://localhost:3000/api/items', {
249
+ headers: { 'x-request-id': `rid-${i}` },
250
+ }))))
251
+ await waitForEvents()
252
+
253
+ const ids = requestHandledCalls().map((c) => (c[0] as Record<string, unknown>).request_id)
254
+ expect(ids.sort()).toEqual(['rid-1', 'rid-2'])
255
+ })
256
+ })
257
+
258
+ describe('gating', () => {
259
+ it('with default config, does not emit request.handled but programmatic events carry request_id/correlation_id', async () => {
260
+ const wrapped = withHoneybadger(async () => {
261
+ Honeybadger.event('custom', { msg: 'hi' })
262
+ return new Response('ok', { status: 200 }) as NextResponse
263
+ })
264
+
265
+ await wrapped(new Request('http://localhost:3000/api/custom', {
266
+ headers: { 'x-request-id': 'rid-prog' },
267
+ }))
268
+ await waitForEvents()
269
+
270
+ expect(requestHandledCalls()).toHaveLength(0)
271
+
272
+ const customCall = workerLogSpy.mock.calls.find(
273
+ (c) => (c[0] as Record<string, unknown>).event_type === 'custom'
274
+ )
275
+ expect(customCall).toBeDefined()
276
+ const payload = customCall[0] as Record<string, unknown>
277
+ expect(payload.request_id).toBe('rid-prog')
278
+ expect(payload.correlation_id).toBe('rid-prog')
279
+ })
280
+
281
+ it('with insights.http: true but insights.enabled missing (footgun), does not emit request.handled', async () => {
282
+ Honeybadger.configure({ insights: { http: true } })
283
+ const wrapped = withHoneybadger(okHandler)
284
+
285
+ await wrapped(new Request('http://localhost:3000/api/items'))
286
+ await waitForEvents()
287
+
288
+ expect(requestHandledCalls()).toHaveLength(0)
289
+ })
290
+
291
+ it('with insights.enabled: false, does not emit request.handled even when insights.http is true', async () => {
292
+ Honeybadger.configure({ insights: { enabled: false, http: true } })
293
+ const wrapped = withHoneybadger(okHandler)
294
+
295
+ await wrapped(new Request('http://localhost:3000/api/items'))
296
+ await waitForEvents()
297
+
298
+ expect(requestHandledCalls()).toHaveLength(0)
299
+ })
300
+ })
301
+
302
+ describe('withHoneybadger(handler, config)', () => {
303
+ it('applies the config when Honeybadger has not been configured yet', async () => {
304
+ // Simulate a fresh client: no apiKey set yet, so the wrapper's internal
305
+ // `configure()` has not early-returned.
306
+ Honeybadger.configure({ apiKey: '' })
307
+
308
+ const wrapped = withHoneybadger(okHandler, { insights: { enabled: true, http: true } })
309
+ await wrapped(new Request('http://localhost:3000/api/items'))
310
+ await waitForEvents()
311
+
312
+ expect(Honeybadger.config.insights.http).toBe(true)
313
+ expect(requestHandledCalls()).toHaveLength(1)
314
+ })
315
+
316
+ it('does not override a client that is already configured (apiKey set)', async () => {
317
+ // The outer beforeEach already configured Honeybadger with an apiKey
318
+ // and insights.http: false; this mirrors the existing early-return
319
+ // semantics of the parameterless `configure()`.
320
+ const wrapped = withHoneybadger(okHandler, { insights: { enabled: true, http: true } })
321
+ await wrapped(new Request('http://localhost:3000/api/items'))
322
+ await waitForEvents()
323
+
324
+ expect(Honeybadger.config.insights.http).toBe(false)
325
+ expect(requestHandledCalls()).toHaveLength(0)
326
+ })
327
+ })
328
+
329
+ describe('edge runtime fallback (no Honeybadger.run)', () => {
330
+ let originalRun: unknown
331
+
332
+ beforeEach(() => {
333
+ originalRun = (Honeybadger as any).run;
334
+ (Honeybadger as any).run = undefined
335
+ })
336
+
337
+ afterEach(() => {
338
+ (Honeybadger as any).run = originalRun
339
+ })
340
+
341
+ it('emits request.handled with embedded ids but does not touch the shared event context', async () => {
342
+ Honeybadger.configure({ insights: { enabled: true, http: true } })
343
+ const setEventContextSpy = jest.spyOn(Honeybadger, 'setEventContext')
344
+ const wrapped = withHoneybadger(okHandler)
345
+
346
+ const res = await wrapped(new Request('http://localhost:3000/api/items', {
347
+ headers: { 'x-request-id': 'edge-rid' },
348
+ }))
349
+ expect(res.status).toBe(200)
350
+ await waitForEvents()
351
+
352
+ expect(setEventContextSpy).not.toHaveBeenCalled()
353
+ const calls = requestHandledCalls()
354
+ expect(calls).toHaveLength(1)
355
+ const payload = calls[0][0] as Record<string, unknown>
356
+ expect(payload).toMatchObject({
357
+ method: 'GET',
358
+ path: '/api/items',
359
+ status: 200,
360
+ request_id: 'edge-rid',
361
+ correlation_id: 'edge-rid',
362
+ })
363
+ })
364
+
365
+ it('with default config, does not emit request.handled', async () => {
366
+ const setEventContextSpy = jest.spyOn(Honeybadger, 'setEventContext')
367
+ const wrapped = withHoneybadger(okHandler)
368
+
369
+ const res = await wrapped(new Request('http://localhost:3000/api/items'))
370
+ expect(res.status).toBe(200)
371
+ await waitForEvents()
372
+
373
+ expect(setEventContextSpy).not.toHaveBeenCalled()
374
+ expect(requestHandledCalls()).toHaveLength(0)
375
+ })
376
+
377
+ it('still notifies and re-throws on error', async () => {
378
+ const error = new Error('edge boom')
379
+ const wrapped = withHoneybadger(async () => {
380
+ throw error
381
+ })
382
+
383
+ await expect(wrapped(new Request('http://localhost:3000/api/fail'))).rejects.toThrow('edge boom')
384
+ expect(notifyAsyncSpy).toHaveBeenCalledWith(error)
385
+ })
386
+ })
387
+
388
+ describe('Next.js control-flow errors', () => {
389
+ beforeEach(() => {
390
+ Honeybadger.configure({ insights: { enabled: true, http: true } })
391
+ })
392
+
393
+ it('re-throws redirect/notFound errors without notifying or emitting an event', async () => {
394
+ const redirect = Object.assign(new Error('NEXT_REDIRECT'), {
395
+ digest: 'NEXT_REDIRECT;replace;/login;307;',
396
+ })
397
+ const wrapped = withHoneybadger(async () => {
398
+ throw redirect
399
+ })
400
+
401
+ await expect(wrapped(new Request('http://localhost:3000/api/go'))).rejects.toBe(redirect)
402
+ await waitForEvents()
403
+
404
+ expect(notifyAsyncSpy).not.toHaveBeenCalled()
405
+ expect(requestHandledCalls()).toHaveLength(0)
406
+ })
407
+ })
408
+
409
+ describe('Pages Router API routes', () => {
410
+ // A Pages handler is invoked as `(req, res)` with Node-style objects; the
411
+ // status lives on `res.statusCode` rather than a returned Response.
412
+ const makeReq = (url: string, headers: Record<string, string | string[]> = {}, method = 'GET') =>
413
+ ({ method, url, headers }) as any
414
+ const makeRes = (statusCode = 200) =>
415
+ ({ statusCode, end: () => undefined, setHeader: () => undefined }) as any
416
+
417
+ describe('with insights: { enabled: true, http: true }', () => {
418
+ beforeEach(() => {
419
+ Honeybadger.configure({ insights: { enabled: true, http: true } })
420
+ })
421
+
422
+ it('emits a request.handled event from the Node req/res pair', async () => {
423
+ const handler = jest.fn(async (_req: any, res: any) => {
424
+ res.statusCode = 201
425
+ })
426
+ const wrapped = withHoneybadger(handler)
427
+ const res = makeRes(200)
428
+
429
+ await wrapped(makeReq('/api/items?secret=1', { 'x-request-id': 'pg-1' }, 'POST'), res)
430
+ await waitForEvents()
431
+
432
+ const calls = requestHandledCalls()
433
+ expect(calls).toHaveLength(1)
434
+ const payload = calls[0][0] as Record<string, unknown>
435
+ expect(payload).toMatchObject({
436
+ method: 'POST',
437
+ path: '/api/items',
438
+ status: 201,
439
+ request_id: 'pg-1',
440
+ correlation_id: 'pg-1',
441
+ })
442
+ expect(typeof payload.duration).toBe('number')
443
+ expect(flushAsyncSpy).toHaveBeenCalled()
444
+ })
445
+
446
+ it('does not call after() even when it is exported (Pages Router has no request context)', async () => {
447
+ const afterMock = jest.fn((cb: () => unknown) => { void cb() })
448
+ nextServerMocks.after = afterMock
449
+
450
+ const handler = jest.fn(async (_req: any, _res: any) => undefined)
451
+ const wrapped = withHoneybadger(handler)
452
+ await wrapped(makeReq('/api/items'), makeRes())
453
+ await waitForEvents()
454
+
455
+ expect(afterMock).not.toHaveBeenCalled()
456
+ expect(flushAsyncSpy).toHaveBeenCalled()
457
+ })
458
+
459
+ it('delivers via the platform request-context waitUntil instead of blocking', async () => {
460
+ const waitUntil = jest.fn((_p: Promise<unknown>) => undefined)
461
+ installRequestContext(waitUntil)
462
+
463
+ const handler = jest.fn(async (_req: any, _res: any) => undefined)
464
+ const wrapped = withHoneybadger(handler)
465
+ await wrapped(makeReq('/api/items'), makeRes())
466
+ await waitForEvents()
467
+
468
+ expect(waitUntil).toHaveBeenCalledTimes(1)
469
+ expect(flushAsyncSpy).toHaveBeenCalled()
470
+ })
471
+
472
+ it('reads ids from Node headers (array values, case-insensitive)', async () => {
473
+ const handler = jest.fn(async (_req: any, _res: any) => undefined)
474
+ const wrapped = withHoneybadger(handler)
475
+
476
+ await wrapped(makeReq('/api/x', { 'X-Request-Id': ['rid-arr', 'other'] }), makeRes())
477
+ await waitForEvents()
478
+
479
+ const payload = requestHandledCalls()[0][0] as Record<string, unknown>
480
+ expect(payload.request_id).toBe('rid-arr')
481
+ expect(payload.correlation_id).toBe('rid-arr')
482
+ })
483
+
484
+ it('emits status 500, notifies, and re-throws when the handler throws', async () => {
485
+ const error = new Error('pages boom')
486
+ const wrapped = withHoneybadger(async () => {
487
+ throw error
488
+ })
489
+
490
+ await expect(wrapped(makeReq('/api/fail'), makeRes())).rejects.toThrow('pages boom')
491
+ await waitForEvents()
492
+
493
+ const payload = requestHandledCalls()[0][0] as Record<string, unknown>
494
+ expect(payload.status).toBe(500)
495
+ expect(payload.path).toBe('/api/fail')
496
+ expect(notifyAsyncSpy).toHaveBeenCalledWith(error)
497
+ })
498
+
499
+ it('does not leak event context between concurrent requests', async () => {
500
+ const slowHandler = async (_req: any, res: any) => {
501
+ await new Promise((resolve) => setTimeout(resolve, 20))
502
+ res.statusCode = 200
503
+ }
504
+ const wrapped = withHoneybadger(slowHandler)
505
+
506
+ await Promise.all(
507
+ [1, 2].map((i) => wrapped(makeReq('/api/items', { 'x-request-id': `rid-${i}` }), makeRes()))
508
+ )
509
+ await waitForEvents()
510
+
511
+ const ids = requestHandledCalls().map((c) => (c[0] as Record<string, unknown>).request_id)
512
+ expect(ids.sort()).toEqual(['rid-1', 'rid-2'])
513
+ })
514
+ })
515
+
516
+ it('with default config, does not emit request.handled', async () => {
517
+ const handler = jest.fn(async (_req: any, _res: any) => undefined)
518
+ const wrapped = withHoneybadger(handler)
519
+
520
+ await wrapped(makeReq('/api/items'), makeRes())
521
+ await waitForEvents()
522
+
523
+ expect(requestHandledCalls()).toHaveLength(0)
524
+ expect(handler).toHaveBeenCalledTimes(1)
525
+ })
526
+ })
527
+ })