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

InstrumentTypeUnitAttributes
good_webhooks.attempt.durationHistogramsoutcome, event_type
good_webhooks.attemptsCounter{attempt}outcome, event_type
good_webhooks.attempt.responsesCounter{response}status_class, event_type
good_webhooks.endpoint.pausesCounter{pause}reason
  • outcome is one of the attempt outcomes: succeeded, retry, failed, or abandoned.
  • status_class is 2xx, 4xx, 5xx, and so on, or none when no response arrived.
  • reason is failing or gone. Pauses with pause() 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:

examples/operations/otel.ts
// 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:

webhooks.ts
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.

On this page