@honeybadger-io/nextjs 5.10.13 → 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,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
+ })