Observability
OpenTelemetry spans, Prometheus-named metrics, and trace-correlated logs for the sender path.
Postel ships three observability pillars for the sender path: OpenTelemetry spans, a dependency-free metrics snapshot, and trace-correlated structured logs. All three are read-only — none of them affect delivery behavior.
Tracing
Postel emits OpenTelemetry spans for send, dispatch, attempt, retry, and replay. @opentelemetry/api is an optional peer dependency — @postel/core has zero hard runtime dependencies, and if the package isn't installed or no tracer provider is registered, instrumented operations run exactly as they do without this page: no spans, no measurable overhead.
Enabling it
Install the API package alongside whatever SDK wires up your exporter — a typical Node setup pulls in @opentelemetry/sdk-node and an exporter for your backend:
npm install @opentelemetry/api @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-httpRegister a provider before constructing Postel({...}) (or anywhere before your first send() — spans just no-op until then):
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(),
});
sdk.start();Nothing else changes on the Postel side — no config slot to flip. The library checks for a registered provider on each instrumented call.
What's traced
| Span | When | Attributes |
|---|---|---|
postel.send | postel.outbound.send(...) enqueues a message | postel.tenant.id, postel.event.type, postel.message.id |
postel.dispatch | A worker reserves a message and fans it out to its endpoints | postel.message.id, postel.tenant.id |
postel.retry | The retry/circuit-breaker orchestrator runs for one endpoint | postel.message.id, postel.tenant.id, postel.endpoint.id, postel.attempt.status |
postel.attempt | The actual HTTP delivery attempt to an endpoint | postel.message.id, postel.tenant.id, postel.endpoint.id, http.request.method, http.response.status_code |
postel.replay | postel.outbound.replay(...) re-enqueues a message | postel.message.id and/or postel.endpoint.id, when the replay options carry one |
Spans nest the way the runtime does: a dispatch span for a fanout to three endpoints has three child retry spans, each wrapping its own attempt span. If send() runs inside an already-active span — a traced HTTP handler, for instance — the postel.send span is a child of it and shares its trace id, the same as any other OpenTelemetry-instrumented call.
Metrics
postel.metrics() returns a snapshot of the same Prometheus metric names the wire spec commits to — no prom-client dependency, no config slot to flip. It's cheap to call on every scrape: the counters and histogram accumulate in memory as deliveries happen, and the gauges are read from storage at snapshot time.
const snapshot = await postel.metrics();
// {
// webhook_send_total: [{ value: 2, labels: { tenant_id: "t_1", event_type: "order.created" } }],
// webhook_attempt_duration_seconds: [{ count: 5, sum: 1.2, labels: { endpoint_id: "ep_1" } }],
// webhook_attempt_success_ratio: [{ value: 0.8, labels: { endpoint_id: "ep_1" } }],
// webhook_dead_letter_total: [{ value: 1, labels: { endpoint_id: "ep_1" } }],
// webhook_outbox_depth: [{ value: 42, labels: { tenant_id: "t_1" } }],
// webhook_endpoint_circuit_state: [{ value: 0, labels: { endpoint_id: "ep_1" } }],
// }| Metric | Kind | Labels | Meaning |
|---|---|---|---|
webhook_send_total | counter | tenant_id, event_type | Messages enqueued via send() |
webhook_attempt_duration_seconds | histogram (count/sum) | endpoint_id | Delivery-attempt latency |
webhook_attempt_success_ratio | gauge (0–1) | endpoint_id | Successful attempts ÷ total attempts |
webhook_dead_letter_total | counter | endpoint_id | Messages that exhausted retries |
webhook_outbox_depth | gauge | tenant_id | Pending messages, per tenant |
webhook_endpoint_circuit_state | gauge (1=open, 0=closed) | endpoint_id, tenant_id | Current circuit-breaker state, read live from the endpoint's persisted state |
A receiver-only instance (no outbound slot) has nothing to count — every array in the snapshot is empty rather than the call rejecting.
To feed a real Prometheus scrape, map the snapshot onto prom-client (or any client) yourself:
import { Counter, Gauge, Histogram } from "prom-client";
const sendTotal = new Counter({ name: "webhook_send_total", labelNames: ["tenant_id", "event_type"], help: "" });
const attemptDuration = new Histogram({ name: "webhook_attempt_duration_seconds", labelNames: ["endpoint_id"], help: "" });
const outboxDepth = new Gauge({ name: "webhook_outbox_depth", labelNames: ["tenant_id"], help: "" });
setInterval(async () => {
const snapshot = await postel.metrics();
sendTotal.reset();
for (const s of snapshot.webhook_send_total) sendTotal.inc(s.labels, s.value);
outboxDepth.reset();
for (const s of snapshot.webhook_outbox_depth) outboxDepth.set(s.labels, s.value);
// ...same pattern for the other metrics
}, 15_000);Structured logs with trace correlation
observability.logger (the pass-through described in Outbound) attaches a trace_id field to every forwarded entry whenever an OpenTelemetry trace is active — the same trace id as the postel.dispatch / postel.retry / postel.attempt spans covering that delivery. With no tracer provider registered, entries carry no trace_id field at all; your logger's own JSON serialization decides how the entry reaches your log backend.
const postel = Postel({
observability: {
logger: (entry) => myJsonLogger[entry.level](entry), // entry.trace_id, when present, lines up with your trace backend
},
outbound: { storage: InMemoryStorage() },
});Recipe: dead-letter alerting
Combine the dead-letter event (or webhook_dead_letter_total) with observability.logger to page on-call the moment a message exhausts retries, without polling:
const postel = Postel({
observability: {
logger: (entry) => {
if (entry.event === "dead-letter") {
alerting.page({
title: `Webhook dead-lettered: endpoint ${entry.data.endpointId}`,
traceId: entry.trace_id,
finalError: entry.data.finalError,
});
}
},
},
outbound: { storage: InMemoryStorage() },
});For a dashboard rather than a page-per-event, scrape webhook_dead_letter_total on an interval and alert on its rate of increase (e.g. a PromQL increase(webhook_dead_letter_total[5m]) > 0) — the counter only goes up, so any increase over a window is a real dead-letter, not noise from a gauge resetting.