@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.
- package/README.md +8 -0
- package/dist/edge.d.ts +2 -0
- package/dist/honeybadger-nextjs-edge.cjs.js +384 -0
- package/dist/honeybadger-nextjs-edge.cjs.js.map +1 -0
- package/dist/honeybadger-nextjs-edge.esm.js +357 -0
- package/dist/honeybadger-nextjs-edge.esm.js.map +1 -0
- package/dist/honeybadger-nextjs.cjs.js +334 -14
- package/dist/honeybadger-nextjs.cjs.js.map +1 -1
- package/dist/honeybadger-nextjs.esm.js +315 -14
- package/dist/honeybadger-nextjs.esm.js.map +1 -1
- package/dist/insights-instrumentation.d.ts +17 -0
- package/dist/with-honeybadger.d.ts +28 -3
- package/dist/with-honeybadger.test.d.ts +2 -0
- package/package.json +33 -6
- package/src/copy-config-files.test.ts +9 -0
- package/src/edge.ts +1 -0
- package/src/insights-instrumentation.ts +155 -0
- package/src/with-honeybadger.test.ts +527 -0
- package/src/with-honeybadger.ts +242 -12
- package/templates/honeybadger.browser.config.js +4 -2
- package/templates/honeybadger.edge.config.js +4 -2
- package/templates/honeybadger.server.config.js +11 -8
|
@@ -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
|
+
})
|