hono-webhooks 0.4.1 → 0.5.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
@@ -560,12 +560,15 @@ import { otelWebhooks } from 'hono-webhooks/otel'
560
560
  createWebhooks(webhooks, {
561
561
  ...otelWebhooks({
562
562
  ignoreRoutes: ['/health'],
563
+ // one `webhook.completed` log per request, with the request's span
564
+ logger: (span) => createLogger({ span }),
563
565
  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
+ // `call` is summarizeEvent(event) plus `level` and `outcome`;
567
+ // the request's span is active here
568
+ storeCall(call)
566
569
  }
567
570
  }),
568
- // onWebhookCall logs errors with the route and span
571
+ // the completion log carries the error type
569
572
  logErrors: false
570
573
  })
571
574
  ```
@@ -578,7 +581,23 @@ createWebhooks(webhooks, {
578
581
  - **Metrics** `webhook_request_total` (counter) and
579
582
  `webhook_request_duration_ms` (histogram, ms), both by `route` (the pattern,
580
583
  `__unmatched__` for a 404) and `status` (`success` below 400, else `failed`).
581
- - `ignoreRoutes` skips metrics only; the span and `onWebhookCall` still run.
584
+ - **Log** `webhook.completed`, when `logger` is set, at `error` for 5xx,
585
+ `warn` for 4xx and `info` otherwise, with `route`, `method`, `status_code`,
586
+ `duration_ms`, `status` (`success`, `failed` or `validation_failed`) and
587
+ `error_type`. `logger` gets the request's span and returns anything with
588
+ `info`, `warn` and `error` methods taking `(message, fields)`, so
589
+ `logger: () => console` works. 404s and `ignoreRoutes` are not logged.
590
+ - `ignoreRoutes` skips metrics and the log; the span and `onWebhookCall` still
591
+ run.
592
+ - `webhookMetrics` describes both metrics (`name`, `kind`, `unit`,
593
+ `description` and `attributes`, where each attribute maps to its values or
594
+ `'string'`), so a metric catalog can list them:
595
+
596
+ ```ts
597
+ import { webhookMetrics } from 'hono-webhooks/otel'
598
+
599
+ export const otelMetrics = [...appMetrics, ...webhookMetrics]
600
+ ```
582
601
  - `tracer` and `meter` default to `trace.getTracer('webhooks')` and
583
602
  `metrics.getMeter('webhooks')`. The meter is resolved on the first request,
584
603
  so registering the SDK after import works.
@@ -757,6 +776,7 @@ import {
757
776
 
758
777
  import {
759
778
  otelWebhooks, // (options?) => { around, onEvent } with spans and metrics
779
+ webhookMetrics, // the two metric definitions, as data
760
780
  UNMATCHED_ROUTE // '__unmatched__', the route label for 404s
761
781
  } from 'hono-webhooks/otel'
762
782
 
@@ -781,7 +801,12 @@ import type {
781
801
  StandardSchemaV1
782
802
  } from 'hono-webhooks'
783
803
 
784
- import type { OtelWebhooksOptions } from 'hono-webhooks/otel'
804
+ import type {
805
+ OtelWebhooksOptions,
806
+ WebhookCall,
807
+ WebhookLogger,
808
+ WebhookMetricDefinition
809
+ } from 'hono-webhooks/otel'
785
810
  ```
786
811
 
787
812
  Use `Webhook` to type a list: `const all: Webhook<AppEnv>[] = [a, b]`.
package/dist/otel.d.ts CHANGED
@@ -1,8 +1,47 @@
1
- import { type Meter, type Tracer } from '@opentelemetry/api';
1
+ import { type Meter, type Span, type Tracer } from '@opentelemetry/api';
2
2
  import type { Env } from 'hono';
3
3
  import { type WebhookEvent, type WebhookEventSummary, type WebhooksOptions } from 'hono-webhooks';
4
4
  /** The `route` label for 404s, which match no webhook. */
5
5
  export declare const UNMATCHED_ROUTE = "__unmatched__";
6
+ /** A metric `otelWebhooks` records, described for a metric catalog. */
7
+ export interface WebhookMetricDefinition {
8
+ name: string;
9
+ kind: 'counter' | 'histogram';
10
+ unit?: string;
11
+ description: string;
12
+ /** Attribute key to its closed set of values, or `'string'` when open. */
13
+ attributes: Record<string, readonly string[] | 'string'>;
14
+ }
15
+ /**
16
+ * The metrics `otelWebhooks` records, as data: list them in a metric
17
+ * catalog without creating a second recorder.
18
+ */
19
+ export declare const webhookMetrics: readonly [{
20
+ readonly name: 'webhook_request_total';
21
+ readonly kind: 'counter';
22
+ readonly description: 'HTTP webhook requests by route template and status';
23
+ readonly attributes: {
24
+ readonly route: 'string';
25
+ readonly status: readonly ['success', 'failed'];
26
+ };
27
+ }, {
28
+ readonly name: 'webhook_request_duration_ms';
29
+ readonly kind: 'histogram';
30
+ readonly unit: 'ms';
31
+ readonly description: 'HTTP webhook handler duration by route template and status';
32
+ readonly attributes: {
33
+ readonly route: 'string';
34
+ readonly status: readonly ['success', 'failed'];
35
+ };
36
+ }];
37
+ /** `summarizeEvent(event)` plus what a completion log needs. */
38
+ export interface WebhookCall extends WebhookEventSummary {
39
+ /** `error` for 5xx, `warn` for 4xx, else `info`. */
40
+ level: 'info' | 'warn' | 'error';
41
+ outcome: 'success' | 'failed' | 'validation_failed';
42
+ }
43
+ /** `console` fits, as does any logger with these three methods. */
44
+ export type WebhookLogger = Record<WebhookCall['level'], (message: string, fields: Record<string, string | number>) => void>;
6
45
  export interface OtelWebhooksOptions<E extends Env = any> {
7
46
  /** @default trace.getTracer('webhooks') */
8
47
  tracer?: Tracer;
@@ -15,7 +54,17 @@ export interface OtelWebhooksOptions<E extends Env = any> {
15
54
  * still active: log it, store it. A returned promise is handled like
16
55
  * `onEvent`'s.
17
56
  */
18
- onWebhookCall?: (call: WebhookEventSummary, event: WebhookEvent<E>) => void | Promise<void>;
57
+ onWebhookCall?: (call: WebhookCall, event: WebhookEvent<E>) => void | Promise<void>;
58
+ /**
59
+ * Logs one `webhook.completed` event per request at `call.level`, with
60
+ * `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
61
+ * and `error_type`. 404s and `ignoreRoutes` are skipped. Called with the
62
+ * request's span, so the logger can attach to it.
63
+ *
64
+ * @example
65
+ * logger: () => console
66
+ */
67
+ logger?: (span: Span) => WebhookLogger;
19
68
  }
20
69
  /**
21
70
  * `around` and `onEvent` that put each request in a server span and record
package/dist/otel.js CHANGED
@@ -9,6 +9,25 @@ import {
9
9
  summarizeEvent
10
10
  } from "hono-webhooks";
11
11
  var UNMATCHED_ROUTE = "__unmatched__";
12
+ var metricAttributes = {
13
+ route: "string",
14
+ status: ["success", "failed"]
15
+ };
16
+ var webhookMetrics = [
17
+ {
18
+ name: "webhook_request_total",
19
+ kind: "counter",
20
+ description: "HTTP webhook requests by route template and status",
21
+ attributes: metricAttributes
22
+ },
23
+ {
24
+ name: "webhook_request_duration_ms",
25
+ kind: "histogram",
26
+ unit: "ms",
27
+ description: "HTTP webhook handler duration by route template and status",
28
+ attributes: metricAttributes
29
+ }
30
+ ];
12
31
  function otelWebhooks(options = {}) {
13
32
  const tracer = options.tracer ?? trace.getTracer("webhooks");
14
33
  const ignored = new Set(options.ignoreRoutes);
@@ -30,8 +49,13 @@ function otelWebhooks(options = {}) {
30
49
  });
31
50
  },
32
51
  onEvent: (event) => {
33
- const call = summarizeEvent(event);
34
- const success = call.status < 400;
52
+ const summary = summarizeEvent(event);
53
+ const success = summary.status < 400;
54
+ const call = {
55
+ ...summary,
56
+ level: summary.status >= 500 ? "error" : success ? "info" : "warn",
57
+ outcome: summary.validation ? "validation_failed" : success ? "success" : "failed"
58
+ };
35
59
  const span = spans.get(event.c);
36
60
  span?.setAttributes({
37
61
  "http.status_code": call.status,
@@ -47,7 +71,8 @@ function otelWebhooks(options = {}) {
47
71
  span?.setStatus({
48
72
  code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
49
73
  });
50
- if (call.route === null || !ignored.has(call.route)) {
74
+ const isIgnored = call.route !== null && ignored.has(call.route);
75
+ if (!isIgnored) {
51
76
  instruments ??= createInstruments(options.meter ?? metrics.getMeter("webhooks"));
52
77
  const attributes = {
53
78
  route: call.route ?? UNMATCHED_ROUTE,
@@ -56,22 +81,35 @@ function otelWebhooks(options = {}) {
56
81
  instruments.total.add(1, attributes);
57
82
  instruments.duration.record(event.durationMs, attributes);
58
83
  }
84
+ if (span && options.logger && call.route !== null && !isIgnored) {
85
+ options.logger(span)[call.level]("webhook.completed", {
86
+ event: "webhook.completed",
87
+ route: call.route,
88
+ method: call.method,
89
+ status_code: call.status,
90
+ duration_ms: call.durationMs,
91
+ status: call.outcome,
92
+ ...call.error && { error_type: call.error.name }
93
+ });
94
+ }
59
95
  return options.onWebhookCall?.(call, event);
60
96
  }
61
97
  };
62
98
  }
63
99
  function createInstruments(meter) {
100
+ const [total, duration] = webhookMetrics;
64
101
  return {
65
- total: meter.createCounter("webhook_request_total", {
66
- description: "HTTP webhook requests by route template and status"
102
+ total: meter.createCounter(total.name, {
103
+ description: total.description
67
104
  }),
68
- duration: meter.createHistogram("webhook_request_duration_ms", {
69
- unit: "ms",
70
- description: "HTTP webhook handler duration by route template and status"
105
+ duration: meter.createHistogram(duration.name, {
106
+ unit: duration.unit,
107
+ description: duration.description
71
108
  })
72
109
  };
73
110
  }
74
111
  export {
75
112
  UNMATCHED_ROUTE,
76
- otelWebhooks
113
+ otelWebhooks,
114
+ webhookMetrics
77
115
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hono-webhooks",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",
package/src/otel.ts CHANGED
@@ -18,6 +18,54 @@ import {
18
18
  /** The `route` label for 404s, which match no webhook. */
19
19
  export const UNMATCHED_ROUTE = '__unmatched__'
20
20
 
21
+ /** A metric `otelWebhooks` records, described for a metric catalog. */
22
+ export interface WebhookMetricDefinition {
23
+ name: string
24
+ kind: 'counter' | 'histogram'
25
+ unit?: string
26
+ description: string
27
+ /** Attribute key to its closed set of values, or `'string'` when open. */
28
+ attributes: Record<string, readonly string[] | 'string'>
29
+ }
30
+
31
+ const metricAttributes = {
32
+ route: 'string',
33
+ status: ['success', 'failed']
34
+ } as const
35
+
36
+ /**
37
+ * The metrics `otelWebhooks` records, as data: list them in a metric
38
+ * catalog without creating a second recorder.
39
+ */
40
+ export const webhookMetrics = [
41
+ {
42
+ name: 'webhook_request_total',
43
+ kind: 'counter',
44
+ description: 'HTTP webhook requests by route template and status',
45
+ attributes: metricAttributes
46
+ },
47
+ {
48
+ name: 'webhook_request_duration_ms',
49
+ kind: 'histogram',
50
+ unit: 'ms',
51
+ description: 'HTTP webhook handler duration by route template and status',
52
+ attributes: metricAttributes
53
+ }
54
+ ] as const satisfies readonly WebhookMetricDefinition[]
55
+
56
+ /** `summarizeEvent(event)` plus what a completion log needs. */
57
+ export interface WebhookCall extends WebhookEventSummary {
58
+ /** `error` for 5xx, `warn` for 4xx, else `info`. */
59
+ level: 'info' | 'warn' | 'error'
60
+ outcome: 'success' | 'failed' | 'validation_failed'
61
+ }
62
+
63
+ /** `console` fits, as does any logger with these three methods. */
64
+ export type WebhookLogger = Record<
65
+ WebhookCall['level'],
66
+ (message: string, fields: Record<string, string | number>) => void
67
+ >
68
+
21
69
  export interface OtelWebhooksOptions<E extends Env = any> {
22
70
  /** @default trace.getTracer('webhooks') */
23
71
  tracer?: Tracer
@@ -31,9 +79,19 @@ export interface OtelWebhooksOptions<E extends Env = any> {
31
79
  * `onEvent`'s.
32
80
  */
33
81
  onWebhookCall?: (
34
- call: WebhookEventSummary,
82
+ call: WebhookCall,
35
83
  event: WebhookEvent<E>
36
84
  ) => void | Promise<void>
85
+ /**
86
+ * Logs one `webhook.completed` event per request at `call.level`, with
87
+ * `route`, `method`, `status_code`, `duration_ms`, `status` (the outcome)
88
+ * and `error_type`. 404s and `ignoreRoutes` are skipped. Called with the
89
+ * request's span, so the logger can attach to it.
90
+ *
91
+ * @example
92
+ * logger: () => console
93
+ */
94
+ logger?: (span: Span) => WebhookLogger
37
95
  }
38
96
 
39
97
  /**
@@ -77,8 +135,17 @@ export function otelWebhooks<E extends Env = any>(
77
135
  )
78
136
  },
79
137
  onEvent: (event) => {
80
- const call = summarizeEvent(event)
81
- const success = call.status < 400
138
+ const summary = summarizeEvent(event)
139
+ const success = summary.status < 400
140
+ const call: WebhookCall = {
141
+ ...summary,
142
+ level: summary.status >= 500 ? 'error' : success ? 'info' : 'warn',
143
+ outcome: summary.validation
144
+ ? 'validation_failed'
145
+ : success
146
+ ? 'success'
147
+ : 'failed'
148
+ }
82
149
 
83
150
  const span = spans.get(event.c)
84
151
  span?.setAttributes({
@@ -96,7 +163,8 @@ export function otelWebhooks<E extends Env = any>(
96
163
  code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR
97
164
  })
98
165
 
99
- if (call.route === null || !ignored.has(call.route)) {
166
+ const isIgnored = call.route !== null && ignored.has(call.route)
167
+ if (!isIgnored) {
100
168
  // Created lazily so a meter provider registered after import still applies.
101
169
  instruments ??= createInstruments(
102
170
  options.meter ?? metrics.getMeter('webhooks')
@@ -109,19 +177,32 @@ export function otelWebhooks<E extends Env = any>(
109
177
  instruments.duration.record(event.durationMs, attributes)
110
178
  }
111
179
 
180
+ if (span && options.logger && call.route !== null && !isIgnored) {
181
+ options.logger(span)[call.level]('webhook.completed', {
182
+ event: 'webhook.completed',
183
+ route: call.route,
184
+ method: call.method,
185
+ status_code: call.status,
186
+ duration_ms: call.durationMs,
187
+ status: call.outcome,
188
+ ...(call.error && { error_type: call.error.name })
189
+ })
190
+ }
191
+
112
192
  return options.onWebhookCall?.(call, event)
113
193
  }
114
194
  }
115
195
  }
116
196
 
117
197
  function createInstruments(meter: Meter) {
198
+ const [total, duration] = webhookMetrics
118
199
  return {
119
- total: meter.createCounter('webhook_request_total', {
120
- description: 'HTTP webhook requests by route template and status'
200
+ total: meter.createCounter(total.name, {
201
+ description: total.description
121
202
  }),
122
- duration: meter.createHistogram('webhook_request_duration_ms', {
123
- unit: 'ms',
124
- description: 'HTTP webhook handler duration by route template and status'
203
+ duration: meter.createHistogram(duration.name, {
204
+ unit: duration.unit,
205
+ description: duration.description
125
206
  })
126
207
  }
127
208
  }