hono-webhooks 0.3.0 → 0.4.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 +38 -47
- package/dist/otel.d.ts +31 -0
- package/dist/otel.js +77 -0
- package/package.json +20 -4
- package/src/otel.ts +127 -0
package/README.md
CHANGED
|
@@ -545,63 +545,47 @@ createWebhooks(webhooks, {
|
|
|
545
545
|
})
|
|
546
546
|
```
|
|
547
547
|
|
|
548
|
-
### OpenTelemetry
|
|
548
|
+
### OpenTelemetry
|
|
549
549
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const duration = metrics.getMeter('webhooks').createHistogram('webhook.duration', { unit: 'ms' })
|
|
550
|
+
`hono-webhooks/otel` puts each request in a server span and records two
|
|
551
|
+
metrics. It needs `@opentelemetry/api` (an optional peer dependency):
|
|
554
552
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
duration.record(e.durationMs, {
|
|
558
|
-
'http.route': e.route ?? 'unmatched',
|
|
559
|
-
'http.request.method': e.method,
|
|
560
|
-
'http.response.status_code': e.status
|
|
561
|
-
})
|
|
562
|
-
})
|
|
553
|
+
```bash
|
|
554
|
+
bun add @opentelemetry/api
|
|
563
555
|
```
|
|
564
556
|
|
|
565
|
-
### OpenTelemetry traces
|
|
566
|
-
|
|
567
|
-
One span per request, named by the route pattern (low cardinality), with
|
|
568
|
-
`onEvent` logs attached to it:
|
|
569
|
-
|
|
570
557
|
```ts
|
|
571
|
-
import {
|
|
572
|
-
|
|
573
|
-
const tracer = trace.getTracer('webhooks')
|
|
558
|
+
import { otelWebhooks } from 'hono-webhooks/otel'
|
|
574
559
|
|
|
575
560
|
createWebhooks(webhooks, {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
},
|
|
587
|
-
async (span) => {
|
|
588
|
-
try {
|
|
589
|
-
const res = await next()
|
|
590
|
-
span.setAttribute('http.response.status_code', res.status)
|
|
591
|
-
if (res.status >= 500) span.setStatus({ code: SpanStatusCode.ERROR })
|
|
592
|
-
return res
|
|
593
|
-
} finally {
|
|
594
|
-
span.end()
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
),
|
|
598
|
-
onEvent: (e) => {
|
|
599
|
-
// trace.getActiveSpan() is the span above
|
|
600
|
-
if (e.error instanceof Error) trace.getActiveSpan()?.recordException(e.error)
|
|
601
|
-
}
|
|
561
|
+
...otelWebhooks({
|
|
562
|
+
ignoreRoutes: ['/health'],
|
|
563
|
+
onWebhookCall(call, event) {
|
|
564
|
+
// `call` is summarizeEvent(event); the request's span is active here
|
|
565
|
+
logger.info('webhook.completed', { route: call.route, status: call.status })
|
|
566
|
+
}
|
|
567
|
+
}),
|
|
568
|
+
// onWebhookCall logs errors with the route and span
|
|
569
|
+
logErrors: false
|
|
602
570
|
})
|
|
603
571
|
```
|
|
604
572
|
|
|
573
|
+
- **Span** `webhook: POST /orders/:id` (kind `SERVER`) with `http.method`,
|
|
574
|
+
`http.route`, `http.status_code`, `webhook.duration_ms`, `webhook.success`
|
|
575
|
+
and, for validation failures, `webhook.validation_error`. Status is `ERROR`
|
|
576
|
+
for 4xx and 5xx; 5xx errors are recorded as exceptions. Spans created in
|
|
577
|
+
handlers nest under it when an OTel context manager is registered.
|
|
578
|
+
- **Metrics** `webhook_request_total` (counter) and
|
|
579
|
+
`webhook_request_duration_ms` (histogram, ms), both by `route` (the pattern,
|
|
580
|
+
`__unmatched__` for a 404) and `status` (`success` below 400, else `failed`).
|
|
581
|
+
- `ignoreRoutes` skips metrics only; the span and `onWebhookCall` still run.
|
|
582
|
+
- `tracer` and `meter` default to `trace.getTracer('webhooks')` and
|
|
583
|
+
`metrics.getMeter('webhooks')`. The meter is resolved on the first request,
|
|
584
|
+
so registering the SDK after import works.
|
|
585
|
+
|
|
586
|
+
It returns `{ around, onEvent }`. To add your own, call them from yours, or
|
|
587
|
+
use `onWebhookCall` instead of `onEvent`.
|
|
588
|
+
|
|
605
589
|
### Skipping noise
|
|
606
590
|
|
|
607
591
|
`onEvent` fires for everything, so filter in the hook:
|
|
@@ -771,6 +755,11 @@ import {
|
|
|
771
755
|
hmac // (options) => a scheme like the ones above
|
|
772
756
|
} from 'hono-webhooks/signatures'
|
|
773
757
|
|
|
758
|
+
import {
|
|
759
|
+
otelWebhooks, // (options?) => { around, onEvent } with spans and metrics
|
|
760
|
+
UNMATCHED_ROUTE // '__unmatched__', the route label for 404s
|
|
761
|
+
} from 'hono-webhooks/otel'
|
|
762
|
+
|
|
774
763
|
import type {
|
|
775
764
|
Webhook,
|
|
776
765
|
WebhookConfig,
|
|
@@ -791,6 +780,8 @@ import type {
|
|
|
791
780
|
PathParams,
|
|
792
781
|
StandardSchemaV1
|
|
793
782
|
} from 'hono-webhooks'
|
|
783
|
+
|
|
784
|
+
import type { OtelWebhooksOptions } from 'hono-webhooks/otel'
|
|
794
785
|
```
|
|
795
786
|
|
|
796
787
|
Use `Webhook` to type a list: `const all: Webhook<AppEnv>[] = [a, b]`.
|
package/dist/otel.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Meter, type Tracer } from '@opentelemetry/api';
|
|
2
|
+
import type { Env } from 'hono';
|
|
3
|
+
import { type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
|
|
4
|
+
/** The `route` label for 404s, which match no webhook. */
|
|
5
|
+
export declare const UNMATCHED_ROUTE = "__unmatched__";
|
|
6
|
+
export interface OtelWebhooksOptions<E extends Env = any> {
|
|
7
|
+
/** @default trace.getTracer('webhooks') */
|
|
8
|
+
tracer?: Tracer;
|
|
9
|
+
/** @default metrics.getMeter('webhooks'), resolved on the first request */
|
|
10
|
+
meter?: Meter;
|
|
11
|
+
/** Routes that get a span but no metrics, e.g. `['/health']`. */
|
|
12
|
+
ignoreRoutes?: readonly string[];
|
|
13
|
+
/**
|
|
14
|
+
* Called after every request, ignored routes included, with the span
|
|
15
|
+
* still active: log it, store it. A returned promise is handled like
|
|
16
|
+
* `onEvent`'s.
|
|
17
|
+
*/
|
|
18
|
+
onWebhookCall?: (call: WebhookEventSummary, event: WebhookEvent<E>) => void | Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* `around` and `onEvent` that put each request in a server span and record
|
|
22
|
+
* `webhook_request_total` and `webhook_request_duration_ms` by route and
|
|
23
|
+
* outcome.
|
|
24
|
+
*
|
|
25
|
+
* With `createWebhookFactory<AppEnv>()`, call `otelWebhooks<AppEnv>(...)`
|
|
26
|
+
* so `onWebhookCall` gets a typed context.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* createWebhooks(webhooks, { ...otelWebhooks({ ignoreRoutes: ['/health'] }), logErrors: false })
|
|
30
|
+
*/
|
|
31
|
+
export declare function otelWebhooks<E extends Env = any>(options?: OtelWebhooksOptions<E>): Required<Pick<WebhooksOptions<E>, 'around' | 'onEvent'>>;
|
package/dist/otel.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// src/otel.ts
|
|
2
|
+
import {
|
|
3
|
+
metrics,
|
|
4
|
+
SpanKind,
|
|
5
|
+
SpanStatusCode,
|
|
6
|
+
trace
|
|
7
|
+
} from "@opentelemetry/api";
|
|
8
|
+
import {
|
|
9
|
+
summarizeEvent
|
|
10
|
+
} from "hono-webhooks";
|
|
11
|
+
var UNMATCHED_ROUTE = "__unmatched__";
|
|
12
|
+
function otelWebhooks(options = {}) {
|
|
13
|
+
const tracer = options.tracer ?? trace.getTracer("webhooks");
|
|
14
|
+
const ignored = new Set(options.ignoreRoutes);
|
|
15
|
+
const spans = new WeakMap;
|
|
16
|
+
let instruments;
|
|
17
|
+
return {
|
|
18
|
+
around: ({ c, route, method }, next) => {
|
|
19
|
+
const label = route ?? UNMATCHED_ROUTE;
|
|
20
|
+
return tracer.startActiveSpan(`webhook: ${method} ${label}`, {
|
|
21
|
+
kind: SpanKind.SERVER,
|
|
22
|
+
attributes: { "http.method": method, "http.route": label }
|
|
23
|
+
}, async (span) => {
|
|
24
|
+
spans.set(c, span);
|
|
25
|
+
try {
|
|
26
|
+
return await next();
|
|
27
|
+
} finally {
|
|
28
|
+
span.end();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
},
|
|
32
|
+
onEvent: (event) => {
|
|
33
|
+
const call = summarizeEvent(event);
|
|
34
|
+
const success = call.status < 400;
|
|
35
|
+
const span = spans.get(event.c);
|
|
36
|
+
span?.setAttributes({
|
|
37
|
+
"http.status_code": call.status,
|
|
38
|
+
"webhook.duration_ms": call.durationMs,
|
|
39
|
+
"webhook.success": success
|
|
40
|
+
});
|
|
41
|
+
if (call.validation) {
|
|
42
|
+
span?.setAttribute("webhook.validation_error", call.validation.message);
|
|
43
|
+
}
|
|
44
|
+
if (event.error instanceof Error && call.status >= 500) {
|
|
45
|
+
span?.recordException(event.error);
|
|
46
|
+
}
|
|
47
|
+
span?.setStatus({
|
|
48
|
+
code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
|
|
49
|
+
});
|
|
50
|
+
if (call.route === null || !ignored.has(call.route)) {
|
|
51
|
+
instruments ??= createInstruments(options.meter ?? metrics.getMeter("webhooks"));
|
|
52
|
+
const attributes = {
|
|
53
|
+
route: call.route ?? UNMATCHED_ROUTE,
|
|
54
|
+
status: success ? "success" : "failed"
|
|
55
|
+
};
|
|
56
|
+
instruments.total.add(1, attributes);
|
|
57
|
+
instruments.duration.record(event.durationMs, attributes);
|
|
58
|
+
}
|
|
59
|
+
return options.onWebhookCall?.(call, event);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function createInstruments(meter) {
|
|
64
|
+
return {
|
|
65
|
+
total: meter.createCounter("webhook_request_total", {
|
|
66
|
+
description: "HTTP webhook requests by route template and status"
|
|
67
|
+
}),
|
|
68
|
+
duration: meter.createHistogram("webhook_request_duration_ms", {
|
|
69
|
+
unit: "ms",
|
|
70
|
+
description: "HTTP webhook handler duration by route template and status"
|
|
71
|
+
})
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export {
|
|
75
|
+
UNMATCHED_ROUTE,
|
|
76
|
+
otelWebhooks
|
|
77
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hono-webhooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Type-safe webhook routes for Hono, validated with any Standard Schema library (Zod, Valibot, ArkType)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"valibot",
|
|
26
26
|
"arktype",
|
|
27
27
|
"typescript",
|
|
28
|
-
"type-safe"
|
|
28
|
+
"type-safe",
|
|
29
|
+
"opentelemetry"
|
|
29
30
|
],
|
|
30
31
|
"files": [
|
|
31
32
|
"src/**/*.ts",
|
|
@@ -48,11 +49,17 @@
|
|
|
48
49
|
"bun": "./src/signatures.ts",
|
|
49
50
|
"import": "./dist/signatures.js",
|
|
50
51
|
"default": "./dist/signatures.js"
|
|
52
|
+
},
|
|
53
|
+
"./otel": {
|
|
54
|
+
"types": "./dist/otel.d.ts",
|
|
55
|
+
"bun": "./src/otel.ts",
|
|
56
|
+
"import": "./dist/otel.js",
|
|
57
|
+
"default": "./dist/otel.js"
|
|
51
58
|
}
|
|
52
59
|
},
|
|
53
60
|
"scripts": {
|
|
54
61
|
"build": "rm -rf dist && bun run build:js && bun run build:types",
|
|
55
|
-
"build:js": "bun build ./src/index.ts ./src/signatures.ts ./src/cli.ts --outdir ./dist --target node --packages external",
|
|
62
|
+
"build:js": "bun build ./src/index.ts ./src/signatures.ts ./src/otel.ts ./src/cli.ts --outdir ./dist --target node --packages external",
|
|
56
63
|
"build:types": "tsgo -p tsconfig.build.json",
|
|
57
64
|
"prepublishOnly": "bun run build",
|
|
58
65
|
"test": "bun test",
|
|
@@ -65,10 +72,19 @@
|
|
|
65
72
|
"@standard-schema/spec": "^1.1.0"
|
|
66
73
|
},
|
|
67
74
|
"peerDependencies": {
|
|
68
|
-
"hono": "^4.8.0"
|
|
75
|
+
"hono": "^4.8.0",
|
|
76
|
+
"@opentelemetry/api": "^1.9.0"
|
|
77
|
+
},
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"@opentelemetry/api": {
|
|
80
|
+
"optional": true
|
|
81
|
+
}
|
|
69
82
|
},
|
|
70
83
|
"devDependencies": {
|
|
71
84
|
"@biomejs/biome": "^2.5.14",
|
|
85
|
+
"@opentelemetry/api": "^1.9.1",
|
|
86
|
+
"@opentelemetry/sdk-metrics": "^2.12.0",
|
|
87
|
+
"@opentelemetry/sdk-trace-base": "^2.12.0",
|
|
72
88
|
"@types/bun": "^1.4.2",
|
|
73
89
|
"@typescript/native-preview": "^7.0.0-dev.20260707.2",
|
|
74
90
|
"hono": "^4.13.10",
|
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
|
+
}
|