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 CHANGED
@@ -545,63 +545,47 @@ createWebhooks(webhooks, {
545
545
  })
546
546
  ```
547
547
 
548
- ### OpenTelemetry metrics
548
+ ### OpenTelemetry
549
549
 
550
- ```ts
551
- import { metrics } from '@opentelemetry/api'
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
- createWebhooks(webhooks, {
556
- onEvent: (e) =>
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 { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'
572
-
573
- const tracer = trace.getTracer('webhooks')
558
+ import { otelWebhooks } from 'hono-webhooks/otel'
574
559
 
575
560
  createWebhooks(webhooks, {
576
- around: ({ c, route, method }, next) =>
577
- tracer.startActiveSpan(
578
- `webhook ${method} ${route ?? 'unmatched'}`,
579
- {
580
- kind: SpanKind.SERVER,
581
- attributes: {
582
- 'http.request.method': method,
583
- 'http.route': route ?? 'unmatched',
584
- 'url.path': c.req.path
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.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
+ }