Short answer: capture structured delivery errors at the notification boundary, enqueue them durably, and post them asynchronously to an error tracking API so incident reconstruction does not depend on request latency.
The least complex design that still reconstructs a failed delivery is a structured event captured at the notification boundary, enriched with a stable order and attempt ID, then posted asynchronously to a generic error API. Keep the original exception, response status, and retry state together. Do not make the customer request wait for telemetry.
| Choice | Reconstruction quality | Operational cost | Best fit |
|---|---|---|---|
| Log only | Low | Low | Local debugging |
| Inline error post | Medium | Medium | Small internal tools |
| Structured event + async outbox | High | Medium | Customer-facing notification services |
| Full trace, logs, and metrics | Highest | High | Cross-service incident analysis |
My default is the third row. It gives an investigator a coherent delivery attempt without coupling checkout latency to an external tracking endpoint. The trade-off is a small durable queue and a clear policy for what data is allowed into it.
That is the whole contract.
What must an investigator know?
A stack trace answers why code stopped. It does not answer which notification was lost, which attempt was made, or whether a retry is still expected. For an e-commerce delivery failure, the minimum useful record is an immutable event ID, order ID, notification type, attempt ID, destination class, failure timestamp, and a redacted provider response.
I initially treated the exception message as the primary key. That was a mistake. Messages change between library versions and often contain recipient data. An attempt ID survives message changes and lets the investigator join the failure to the delivery ledger.
Use a stable envelope. OpenTelemetry describes logs as timestamped records that can carry attributes and an event body; that is a useful boundary even if the final error API is not an OpenTelemetry backend. Keep high-cardinality values such as order and attempt IDs in attributes with explicit retention rules.
How should Node.js handle capturing and posting errors to an error tracking API?
Capture at the boundary where the notification result becomes known. Normalize unknown thrown values, preserve the original stack when available, and make the telemetry call a separate concern. The example uses the platform fetch\ API and a pseudonymous endpoint. It posts a small JSON event, not an entire request object.
type DeliveryFailure = {
eventId: string;
orderId: string;
attemptId: string;
channel: "email" | "sms" | "push";
occurredAt: string;
error: { name: string; message: string; stack?: string };
response?: { status: number; code?: string };
};
function normalizeError(value: unknown) {
if (value instanceof Error) {
return { name: value.name, message: value.message, stack: value.stack };
}
return { name: "UnknownError", message: String(value) };
}
async function postFailure(event: DeliveryFailure, endpoint: string, token: string) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: \`Bearer \${token}\`
},
body: JSON.stringify({ type: "notification.delivery.failed", event })
});
if (!response.ok) {
throw new Error(\`error API rejected event with status \${response.status}\`);
}
}
async function recordDeliveryFailure(input: {
orderId: string;
attemptId: string;
channel: DeliveryFailure["channel"];
run: () => Promise<void>;
}) {
try {
await input.run();
} catch (cause) {
const event: DeliveryFailure = {
eventId: crypto.randomUUID(),
orderId: input.orderId,
attemptId: input.attemptId,
channel: input.channel,
occurredAt: new Date().toISOString(),
error: normalizeError(cause)
};
await enqueueForPosting(event);
throw cause;
}
}
declare function enqueueForPosting(event: DeliveryFailure): Promise<void>;
The queue is intentional. A failed telemetry post must not replace the original delivery failure, and a telemetry outage must not create an infinite retry loop in the notification worker. Give the queue a bounded retry policy, a dead-letter state, and a metric for rejected or expired events. Record the event before acknowledging the delivery job when incident reconstruction matters more than maximum throughput. For a 429 response, apply bounded exponential backoff with jitter and preserve the event's idempotency key across attempts; otherwise a burst of delivery failures can turn the tracking endpoint into a second incident. The key should be the event ID, and the receiver should treat a repeated key as the same write rather than a new failure.
Which fields prevent false conclusions?
The dangerous fields are the ones that look convenient. A boolean failed\ cannot distinguish a timeout from a permanent recipient rejection. A provider response body may contain an address, token, or campaign content. A stack trace can expose secrets embedded in an exception message.
Use a small schema and version it. Separate stable dimensions from diagnosis text:
type DeliveryContextV1 = {
schemaVersion: 1;
eventId: string;
service: "notification-worker";
environment: "production" | "staging";
orderIdHash: string;
attemptId: string;
channel: "email" | "sms" | "push";
failureClass: "timeout" | "rejected" | "transport" | "unknown";
retryable: boolean;
httpStatus?: number;
providerCode?: string;
};
retryable\ should be decided by delivery policy, not guessed by a dashboard. A timeout may be retryable; an invalid destination usually is not. Keep that decision beside the worker's retry code so the error record and job state cannot disagree. Hash or tokenize order identifiers when the investigator can join through a controlled lookup.
Sampling is another trap. Sampling successful deliveries is usually harmless for this question; sampling failures can erase the one event needed to explain a customer complaint. If volume is high, sample repeated identical failures after retaining the first event per attempt and a counter for the rest.
When is the runner-up the better choice?
The async outbox is not always the right answer. An internal tool with no durable worker may reasonably post inline and accept that the request reports telemetry failure separately. A system already carrying trace context across checkout, queue, and notification services may get more value from one trace with linked log records than from a second error-only pipeline. A batch sender may also be preferable when delivery failures arrive in bursts and the tracking API has strict rate limits.
The decision rule is plain: choose the smallest mechanism that preserves the joins an investigator will need during an incident. Measure it with replayed failure fixtures, not a successful happy-path benchmark. I use a fixture set containing timeout, malformed destination, rejected credentials, duplicate retry, and tracking-endpoint outage. The test passes only when each case retains its attempt ID and produces the expected retry classification.
Do not let the tracking API become the system of record. The delivery ledger owns delivery state. The event stream explains how the worker got there. That separation makes retention, redaction, and recovery decisions much less mysterious.
Further reading
- OpenTelemetry, “Logs signal concepts”: https://opentelemetry.io/docs/concepts/signals/logs/
- Node.js documentation, “Using the Fetch API with Undici”: https://nodejs.org/en/learn/getting-started/fetch
- MDN Web Docs, “Fetch API”: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
- RFC 9457, “Problem Details for HTTP APIs”: https://www.rfc-editor.org/rfc/rfc9457
Top comments (0)