hono-webhooks 0.3.0 → 0.4.1
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 +38 -47
- package/dist/cli-commands.d.ts +1 -0
- package/dist/cli.js +5674 -233
- package/dist/otel.d.ts +31 -0
- package/dist/otel.js +77 -0
- package/package.json +22 -4
- package/src/cli-commands.ts +270 -0
- package/src/cli.ts +4 -253
- package/src/otel.ts +127 -0
package/src/otel.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Meter,
|
|
3
|
+
metrics,
|
|
4
|
+
type Span,
|
|
5
|
+
SpanKind,
|
|
6
|
+
SpanStatusCode,
|
|
7
|
+
type Tracer,
|
|
8
|
+
trace
|
|
9
|
+
} from '@opentelemetry/api'
|
|
10
|
+
import type { Context, Env } from 'hono'
|
|
11
|
+
import {
|
|
12
|
+
summarizeEvent,
|
|
13
|
+
type WebhookEvent,
|
|
14
|
+
type WebhookEventSummary,
|
|
15
|
+
type WebhooksOptions
|
|
16
|
+
} from 'hono-webhooks'
|
|
17
|
+
|
|
18
|
+
/** The `route` label for 404s, which match no webhook. */
|
|
19
|
+
export const UNMATCHED_ROUTE = '__unmatched__'
|
|
20
|
+
|
|
21
|
+
export interface OtelWebhooksOptions<E extends Env = any> {
|
|
22
|
+
/** @default trace.getTracer('webhooks') */
|
|
23
|
+
tracer?: Tracer
|
|
24
|
+
/** @default metrics.getMeter('webhooks'), resolved on the first request */
|
|
25
|
+
meter?: Meter
|
|
26
|
+
/** Routes that get a span but no metrics, e.g. `['/health']`. */
|
|
27
|
+
ignoreRoutes?: readonly string[]
|
|
28
|
+
/**
|
|
29
|
+
* Called after every request, ignored routes included, with the span
|
|
30
|
+
* still active: log it, store it. A returned promise is handled like
|
|
31
|
+
* `onEvent`'s.
|
|
32
|
+
*/
|
|
33
|
+
onWebhookCall?: (
|
|
34
|
+
call: WebhookEventSummary,
|
|
35
|
+
event: WebhookEvent<E>
|
|
36
|
+
) => void | Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `around` and `onEvent` that put each request in a server span and record
|
|
41
|
+
* `webhook_request_total` and `webhook_request_duration_ms` by route and
|
|
42
|
+
* outcome.
|
|
43
|
+
*
|
|
44
|
+
* With `createWebhookFactory<AppEnv>()`, call `otelWebhooks<AppEnv>(...)`
|
|
45
|
+
* so `onWebhookCall` gets a typed context.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* createWebhooks(webhooks, { ...otelWebhooks({ ignoreRoutes: ['/health'] }), logErrors: false })
|
|
49
|
+
*/
|
|
50
|
+
export function otelWebhooks<E extends Env = any>(
|
|
51
|
+
options: OtelWebhooksOptions<E> = {}
|
|
52
|
+
): Required<Pick<WebhooksOptions<E>, 'around' | 'onEvent'>> {
|
|
53
|
+
const tracer = options.tracer ?? trace.getTracer('webhooks')
|
|
54
|
+
const ignored = new Set(options.ignoreRoutes)
|
|
55
|
+
// The span is looked up by context, so it is found even without an OTel
|
|
56
|
+
// context manager.
|
|
57
|
+
const spans = new WeakMap<Context, Span>()
|
|
58
|
+
let instruments: ReturnType<typeof createInstruments> | undefined
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
around: ({ c, route, method }, next) => {
|
|
62
|
+
const label = route ?? UNMATCHED_ROUTE
|
|
63
|
+
return tracer.startActiveSpan(
|
|
64
|
+
`webhook: ${method} ${label}`,
|
|
65
|
+
{
|
|
66
|
+
kind: SpanKind.SERVER,
|
|
67
|
+
attributes: { 'http.method': method, 'http.route': label }
|
|
68
|
+
},
|
|
69
|
+
async (span) => {
|
|
70
|
+
spans.set(c, span)
|
|
71
|
+
try {
|
|
72
|
+
return await next()
|
|
73
|
+
} finally {
|
|
74
|
+
span.end()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
onEvent: (event) => {
|
|
80
|
+
const call = summarizeEvent(event)
|
|
81
|
+
const success = call.status < 400
|
|
82
|
+
|
|
83
|
+
const span = spans.get(event.c)
|
|
84
|
+
span?.setAttributes({
|
|
85
|
+
'http.status_code': call.status,
|
|
86
|
+
'webhook.duration_ms': call.durationMs,
|
|
87
|
+
'webhook.success': success
|
|
88
|
+
})
|
|
89
|
+
if (call.validation) {
|
|
90
|
+
span?.setAttribute('webhook.validation_error', call.validation.message)
|
|
91
|
+
}
|
|
92
|
+
if (event.error instanceof Error && call.status >= 500) {
|
|
93
|
+
span?.recordException(event.error)
|
|
94
|
+
}
|
|
95
|
+
span?.setStatus({
|
|
96
|
+
code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if (call.route === null || !ignored.has(call.route)) {
|
|
100
|
+
// Created lazily so a meter provider registered after import still applies.
|
|
101
|
+
instruments ??= createInstruments(
|
|
102
|
+
options.meter ?? metrics.getMeter('webhooks')
|
|
103
|
+
)
|
|
104
|
+
const attributes = {
|
|
105
|
+
route: call.route ?? UNMATCHED_ROUTE,
|
|
106
|
+
status: success ? 'success' : 'failed'
|
|
107
|
+
}
|
|
108
|
+
instruments.total.add(1, attributes)
|
|
109
|
+
instruments.duration.record(event.durationMs, attributes)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return options.onWebhookCall?.(call, event)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function createInstruments(meter: Meter) {
|
|
118
|
+
return {
|
|
119
|
+
total: meter.createCounter('webhook_request_total', {
|
|
120
|
+
description: 'HTTP webhook requests by route template and status'
|
|
121
|
+
}),
|
|
122
|
+
duration: meter.createHistogram('webhook_request_duration_ms', {
|
|
123
|
+
unit: 'ms',
|
|
124
|
+
description: 'HTTP webhook handler duration by route template and status'
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|