OpenTelemetry
Export delivery attempts and automatic pauses as OpenTelemetry metrics through the worker's hooks.
The package records no metrics itself. A short recipe turns the worker's hooks into OpenTelemetry metrics, so attempt latency, outcomes, and automatic pauses reach the backend you already use.
What it records
| Instrument | Type | Unit | Attributes |
|---|---|---|---|
good_webhooks.attempt.duration | Histogram | s | outcome, event_type |
good_webhooks.attempts | Counter | {attempt} | outcome, event_type |
good_webhooks.attempt.responses | Counter | {response} | status_class, event_type |
good_webhooks.endpoint.pauses | Counter | {pause} | reason |
outcomeis one of the attempt outcomes:succeeded,retry,failed, orabandoned.status_classis2xx,4xx,5xx, and so on, ornonewhen no response arrived.reasonisfailingorgone. Pauses withpause()aren't counted.- Durations are in seconds, as the OpenTelemetry semantic conventions ask, although the hook reports milliseconds. The histogram passes the HTTP client duration buckets as advice, because the SDK's default buckets were made for milliseconds.
Wire it up
Copy the recipe into your app. It depends only on @opentelemetry/api:
// Records delivery metrics through the OpenTelemetry API. Pass `hooks: createOtelHooks()` to
// createWebhooks() or createDelivery(). The host application configures the OpenTelemetry SDK
// and exporter. Register its MeterProvider first: meters taken earlier stay no-op.
import { metrics, type Meter } from '@opentelemetry/api'
import type { DeliveryHooks } from 'good-webhooks'
export interface OtelHooksOptions {
/** Defaults to the global meter named `good-webhooks`. */
meter?: Meter
/** Prefix for instrument names. */
prefix?: string
}
const statusClass = (status: number | null) =>
status === null ? 'none' : `${Math.floor(status / 100)}xx`
export function createOtelHooks({
meter = metrics.getMeter('good-webhooks'),
prefix = 'good_webhooks',
}: OtelHooksOptions = {}): DeliveryHooks {
const duration = meter.createHistogram(`${prefix}.attempt.duration`, {
description: 'Duration of each delivery attempt, until the response or the failure.',
// OpenTelemetry conventions measure durations in seconds; the hook reports milliseconds.
unit: 's',
// The SDK's default buckets were made for milliseconds. These are the HTTP client
// request duration buckets from the semantic conventions.
advice: {
explicitBucketBoundaries: [
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
],
},
})
const attempts = meter.createCounter(`${prefix}.attempts`, {
description: 'Delivery attempts by outcome.',
unit: '{attempt}',
})
const responses = meter.createCounter(`${prefix}.attempt.responses`, {
description: 'Delivery attempts by response status class, or none without a response.',
unit: '{response}',
})
const pauses = meter.createCounter(`${prefix}.endpoint.pauses`, {
description: 'Automatic endpoint pauses by reason.',
unit: '{pause}',
})
// Attributes leave out endpoint, scope, delivery, and event IDs on purpose. Every distinct
// value starts a new time series, so unbounded IDs would grow storage without limit.
return {
onAttempt: ({ eventType, outcome, responseStatus, durationMs }) => {
duration.record(durationMs / 1000, { outcome, event_type: eventType })
attempts.add(1, { outcome, event_type: eventType })
responses.add(1, { status_class: statusClass(responseStatus), event_type: eventType })
},
onEndpointPaused: ({ reason }) => pauses.add(1, { reason }),
}
}
Pass its hooks to the engine that runs your worker:
import { createWebhooks } from 'good-webhooks'
import { createOtelHooks } from './otel.js'
export const webhooks = createWebhooks({
database,
encryptionKey: process.env.WEBHOOK_ENCRYPTION_KEY!,
events,
hooks: createOtelHooks(),
})Your app configures the OpenTelemetry SDK and exporter, as for any other metrics. Register its MeterProvider before you call createOtelHooks(): a meter taken earlier stays a no-op and records nothing. To use a meter of your own, pass createOtelHooks({ meter }), and prefix changes the good_webhooks part of each name. To keep your own hooks too, call both from one function, since hooks takes one function per hook.
Keep cardinality bounded
The attributes leave out endpoint, scope, delivery, and event IDs on purpose. Every distinct attribute value starts a new time series, so unbounded IDs would grow your metrics storage without limit. event_type is bounded by your event definitions. To find a failing endpoint, use delivery history or the endpointId in the hook event with your logs.