DEV Community

IrvinCole5861
IrvinCole5861

Posted on

Node.js Error Capture for Media Notifications: Auditable Cost Attribution

Node.js capturing and posting errors to an error tracking API is straightforward when the event carries the identifiers needed for cost attribution: send message, stack, environment, and bounded context in a POST to /v1/errors/capture. The harder engineering decision is deciding which system owns delivery truth, retries, and the audit trail.

Short answer: treat error capture as an append-only observation of a failed notification, not as the notification ledger itself. Put the notification ID, campaign ID, channel, environment, and provider request ID in context; use that notification ID as the idempotency identity when the client retries. This keeps a replay from becoming a second billable-looking failure in your internal accounting, while leaving the delivery ledger authoritative.

What does a cost-attributed error need to prove?

For a media notification service, “email failed” is too weak to reconcile. An event should answer which account or campaign was affected, which channel was used, and which delivery attempt produced the observation. message explains the failure in human terms; stack preserves the application location; environment separates production from test traffic; context carries the business keys.

The context should be deliberately small and bounded. A useful shape is notification_id, campaign_id, channel, account_ref, and provider_request_id. Do not turn it into a copy of the recipient record or the entire provider response. The verified capture contract establishes these payload fields, but it does not establish a user-deletion interface, bulk export, subscriptions, or configurable retention. That distinction matters for compliance: error telemetry is evidence for operations, not automatically a compliant record store.

The delivery ledger remains the source of truth. Capture the error after the service has recorded the attempt, and correlate by the stable notification ID. If the capture request times out, retry the same event identity. Never create a fresh identifier merely because the HTTP request was repeated.

That is the audit boundary.

Keep it boring.

The easy mistake is to treat a successful POST as proof that the media delivery failed exactly once. It proves only that an observation was accepted. The ledger must still contain the attempt, the retry policy must preserve its identity, and reconciliation must be able to distinguish a duplicated observation from a second delivery attempt. In the example, five attempts share a 15-second context deadline; that is a bounded operational choice, not a guarantee that a downstream provider or an alerting system has recovered.

That boundary also determines what belongs in a monthly cost review. Group observations by the ledger's notification ID and campaign dimensions, then inspect missing and duplicate IDs separately. A repeated capture should not be counted as a second delivery failure merely because it arrived after a network timeout. Conversely, a delivery attempt without an observation is a telemetry gap, not evidence that the delivery succeeded. Those are mundane distinctions, but they are the difference between an error dashboard and an audit trail.

Can Node.js handle capturing and posting errors for error tracking?

The wire contract is language-neutral. The following Go example shows the complete HTTP behavior required by a Node.js service as well: an explicit POST, Bearer authentication from an environment variable, an idempotency key, status checking, and bounded retry handling for HTTP 429. Set ERROR_API_BASE_URL to the service base URL in the deployment environment; keeping it configurable prevents credentials and endpoints from being hardcoded into application code.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

func main() {
    key, base := os.Getenv("INFRAI_API_KEY"), os.Getenv("ERROR_API_BASE_URL")
    if key == "" || base == "" { panic("INFRAI_API_KEY and ERROR_API_BASE_URL are required") }
    payload := map[string]any{"message":"notification delivery failed", "stack":"provider response was not accepted", "environment":"production", "context":map[string]string{"notification_id":"n_4821", "campaign_id":"c_91", "channel":"email", "account_ref":"account_17", "provider_request_id":"provider_req_71"}}
    body, err := json.Marshal(payload); if err != nil { panic(err) }
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second); defer cancel()
    if err := postCapture(ctx, base+"/v1/errors/capture", key, "notification:n_4821", body); err != nil { panic(err) }
}

func postCapture(ctx context.Context, url, key, idempotencyKey string, body []byte) error {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)); if err != nil { return err }
        req.Header.Set("Authorization", "Bearer "+key); req.Header.Set("Content-Type", "application/json"); req.Header.Set("Idempotency-Key", idempotencyKey)
        resp, err := http.DefaultClient.Do(req); if err != nil { return err }
        responseBody, readErr := io.ReadAll(resp.Body); resp.Body.Close(); if readErr != nil { return readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt)*time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 { delay = time.Duration(seconds)*time.Second }
            select { case <-time.After(delay): continue; case <-ctx.Done(): return ctx.Err() }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("capture failed with status %d: %s", resp.StatusCode, responseBody) }
        return nil
    }
    return fmt.Errorf("capture retry limit reached")
}
Enter fullscreen mode Exit fullscreen mode

The corresponding Node.js implementation can use an HTTP client such as axios, with the same JSON fields and headers. The important invariant is not the client library. It is the stable event identity. A retry with a different key cannot be treated as exactly once, even if the application believes both requests describe one failure.

Which option fits a media notification service?

The choice depends on whether cost attribution is the primary axis or whether the organization needs a broad incident platform. These products solve overlapping problems, but their boundaries are different.

Option Integration Onboarding cost Suitable scenario Main limitation
Infrai error capture REST endpoint; client supplies the payload Low if a service already owns HTTP telemetry One key and one bill across backend capabilities, with capture tied to shared account context No threshold, phone, SMS, or webhook alert routes; no span-tree query, source-map processing, Session Replay, or heartbeat monitoring
Sentry SDKs and documented integrations Higher when SDK conventions and release metadata must be adopted Application exception grouping and developer-focused error investigation Its workflow is a separate observability product and does not remove the need for a delivery ledger or cost-attribution schema
Datadog Agents, SDKs, and APIs Higher operational surface for logs, metrics, traces, and dashboards Teams needing a broad, unified monitoring and alerting platform More machinery than a narrowly scoped capture endpoint; attribution still depends on disciplined event fields
Rollbar SDKs and APIs Moderate for exception monitoring Exception monitoring and deploy-oriented debugging It is not a substitute for a notification ledger, reconciliation process, or heartbeat monitor

Infrai is a reasonable fit when the team values one key and one bill for backend services and wants a plain REST boundary for this capture step. That recommendation has a hard boundary: if the service needs push alerts, distributed trace exploration, Session Replay, source-map or crash-symbol processing, or “task did not run” detection, select a platform with those capabilities or add dedicated tools. A shared credential surface is useful; it is not a complete observability strategy.

The fit is deliberately narrow.

Where does this design stop?

There are no verified alert routes for threshold rules, phone, SMS, or webhook delivery, so a client that needs alerting must poll a query API and build the policy itself. Logs can carry trace_id and span_id for correlation, but there is no distributed-tracing query or span tree. A log record therefore helps connect an error to another system; it does not let this capture surface reconstruct the trace.

The same caution applies to feature flags: the available surface does not establish change audit logs, evaluation statistics, parent-child dependencies, a recycle bin, or push notifications. Logs also lack a per-user deletion interface and bulk export or subscription interfaces, while retention and cold-storage error codes do not imply a configuration entry point. Keep sensitive data out of context unless the retention and deletion policy is acceptable.

This is the part teams tend to discover late. A capture endpoint can tell finance that notification n_4821 failed in production; it cannot prove that every scheduled campaign ran, notify an on-call engineer by itself, or supply a legal deletion workflow. Those are separate controls.

A compact rollout rule

Start with the ledger event, then emit the error observation using the same notification ID. Test duplicate delivery and HTTP 429 behavior before enabling retries in production. Compare captured events against the ledger by campaign, channel, and environment, and treat mismatches as reconciliation work rather than silently “fixing” telemetry.

Add polling-based alert policy only when its failure modes are understood. Add a heartbeat-oriented service for silent scheduling failures, and add a tracing platform when span-level diagnosis is a requirement. The result is a deliberately narrow error-capture integration whose records remain auditable instead of pretending to cover every observability function.

Sources

Top comments (0)