@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.
- 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 +30 -5
- 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,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
|
+
})
|