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/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
+ }