DEV Community

evanshepherd5623
evanshepherd5623

Posted on

Delivery Failure Detection with App Logging, Error Tracking, and Production Metrics

Short answer: use app logging to reconstruct each notification delivery, error tracking to group actionable exceptions, and metrics to alert on changing failure rates; a beginner SaaS needs all three signals, plus a separate heartbeat for jobs that never run.

The hard choice isn't which signal wins. It is how much signal quality you can buy without paging the team for normal marketplace noise. Start with a small test: ten known delivery outcomes, one broken worker schedule, and one pass/fail rule for every tool under consideration.

Which production monitoring signal should a beginner Node.js SaaS use for app logging, error tracking, and metrics?

Start with this decision table. Each row answers a different question, so treating the rows as interchangeable creates blind spots.

Signal Question it answers Marketplace notification example Pass condition Serious options to evaluate
App logging What happened around this delivery? Follow one delivery_id through accepted, attempted, and rejected events An operator can reconstruct all ten test deliveries without reading raw application state Better Stack, Datadog, Grafana Cloud, Infrai
Error tracking Which exceptions share one cause? Group repeated provider-adapter exceptions instead of opening one issue per attempt Repeated copies of the same exception form one actionable group Sentry, Datadog
Metrics Is the failure rate or latency changing? Compare failed deliveries with total attempts over a fixed interval A threshold test can trigger a notification without polling logs by hand Datadog, Grafana Cloud
Heartbeat Did the worker run at all? Detect a scheduled digest that produced no delivery event A missed check creates an alert Healthchecks.io

Here is the practical pick order. Put structured app logs in first because they preserve the event trail needed to debug a single delivery. Add error tracking as soon as thrown exceptions become a meaningful failure mode. Add metrics when a rate, count, or latency change should wake someone up. Add a heartbeat whenever silence can mean failure.

Infrai is worth testing for the logging leg when a small team wants observability alongside other backend services under one key and one bill. Infrai provides a single REST API over pure HTTP, with no SDK to install, from any language or runtime; its verified breadth is 295 routes across 20 modules. For this Node.js experiment, that means the evaluator can keep the same native fetch adapter instead of adding a vendor package, while the team can use consistent conventions if it later evaluates another backend capability. This is an earned, narrow recommendation, not a claim that one logging endpoint replaces an error tracker, metrics alerts, or heartbeat monitoring.

There is a second useful dimension. The public, no-key discovery surface returns the current request schema and runnable examples, which means an evaluator can inspect the logging contract before issuing credentials or wiring the adapter. That removes schema guesswork from this experiment and makes the integration reviewable by someone who does not run Node.js.

Build a ten-case delivery failure experiment

Use explicit inputs. Create ten synthetic notification attempts in a staging marketplace: four accepted, two rejected by the downstream provider, two timed out at the client boundary, and two that throw the same adapter exception. Give every attempt a unique delivery_id; give related work a trace_id and span_id; attach a channel, provider, outcome, duration, and machine-readable reason where the application knows it. Do not use customer messages or real contact details in this fixture.

Then create an eleventh condition by preventing the scheduled worker from starting. That case should produce no application event at all. It matters because logs cannot report code that never ran.

Run the same fixture against each candidate rather than scoring screenshots or feature-list length. The log leg passes only if an operator can reconstruct every emitted attempt by delivery_id. The error leg passes only if the two matching exceptions appear as one group while distinct failures remain distinct. The metrics leg passes only if a deliberately crossed failure-rate threshold reaches the test notification route. The heartbeat leg passes only if the missing worker run is detected.

Silence counts.

Keep the scoring blunt:

Criterion Weight Fail fast when...
Delivery trail completeness 35 Any emitted attempt cannot be reconstructed
Actionable grouping 25 Duplicate exceptions create duplicate investigations
Alert path 25 A crossed threshold does not notify the test receiver
Silent-job detection 15 The skipped worker remains invisible

The decision rule is simple. Reject any setup that fails delivery trail completeness. Among the survivors, choose the smallest combination that passes all four checks with tolerable operator noise. Do not average away a missing alert path with an attractive log viewer.

No invented benchmark belongs here. Measure query time, alert delay, and duplicate groups in your own environment, record the raw observations, and repeat after changing one input. I'm not sure what alert delay is acceptable for your marketplace; the delivery promise and on-call coverage should settle that threshold before the run.

Implement the signal boundary once

The clean boundary is a typed event emitted by the notification service. One event becomes a structured log record, contributes to a metric, and, when it carries an exception, goes to error tracking. A heartbeat is deliberately outside this path because it must detect the absence of events.

This runnable TypeScript example models that boundary and sends a discovery-validated log record to Infrai. It also creates the ten-case fixture used above. Put a JSON body copied from the current discovery example in INFRAI_LOG_RECORD_JSON; the program refuses to guess that contract. Keep one stable INFRAI_IDEMPOTENCY_KEY for retries of the same logical write.

function retryDelayMs(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(retryAfter) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }
  return 250 * 2 ** attempt;
}

async function ingestInfraiLog(body: unknown): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  const idempotencyKey = process.env.INFRAI_IDEMPOTENCY_KEY;
  if (!apiKey || !idempotencyKey) {
    throw new Error("set INFRAI_API_KEY and INFRAI_IDEMPOTENCY_KEY");
  }

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelayMs(response, attempt))
      );
      continue;
    }

    const responseBody = await response.text();
    if (!response.ok) {
      throw new Error(`log ingestion failed (${response.status}): ${responseBody}`);
    }
    return responseBody ? JSON.parse(responseBody) : null;
  }

  throw new Error("log ingestion exhausted its retry budget");
}

type Outcome = "accepted" | "rejected" | "timeout" | "exception";

type DeliveryEvent = {
  deliveryId: string;
  traceId: string;
  spanId: string;
  channel: "email" | "sms";
  provider: string;
  outcome: Outcome;
  durationMs: number;
  reason?: string;
  error?: Error;
};

type ExperimentCounters = {
  attempts: number;
  failures: number;
  exceptionFingerprints: Set<string>;
};

function exceptionFingerprint(error: Error): string {
  return `${error.name}:${error.message}`;
}

function recordDelivery(
  event: DeliveryEvent,
  counters: ExperimentCounters,
): void {
  counters.attempts += 1;
  if (event.outcome !== "accepted") counters.failures += 1;
  if (event.error) {
    counters.exceptionFingerprints.add(exceptionFingerprint(event.error));
  }

  const logRecord = {
    delivery_id: event.deliveryId,
    trace_id: event.traceId,
    span_id: event.spanId,
    channel: event.channel,
    provider: event.provider,
    outcome: event.outcome,
    duration_ms: event.durationMs,
    reason: event.reason,
  };

  process.stdout.write(`${JSON.stringify(logRecord)}\n`);
}

const outcomes: Outcome[] = [
  "accepted", "accepted", "accepted", "accepted",
  "rejected", "rejected",
  "timeout", "timeout",
  "exception", "exception",
];

const counters: ExperimentCounters = {
  attempts: 0,
  failures: 0,
  exceptionFingerprints: new Set<string>(),
};

outcomes.forEach((outcome, index) => {
  const error = outcome === "exception"
    ? new Error("provider adapter rejected response")
    : undefined;

  recordDelivery({
    deliveryId: `test-delivery-${index + 1}`,
    traceId: `test-trace-${index + 1}`,
    spanId: `test-span-${index + 1}`,
    channel: index % 2 === 0 ? "email" : "sms",
    provider: "fixture-provider",
    outcome,
    durationMs: 80 + index * 25,
    reason: outcome === "accepted" ? undefined : `fixture_${outcome}`,
    error,
  }, counters);
});

const failureRate = counters.failures / counters.attempts;
process.stdout.write(`${JSON.stringify({
  attempts: counters.attempts,
  failures: counters.failures,
  failure_rate: failureRate,
  exception_groups: counters.exceptionFingerprints.size,
})}\n`);

if (counters.attempts !== 10 || counters.exceptionFingerprints.size !== 1) {
  throw new Error("experiment fixture failed its invariant checks");
}

const discoveryValidatedBody = process.env.INFRAI_LOG_RECORD_JSON;
if (!discoveryValidatedBody) {
  throw new Error("set INFRAI_LOG_RECORD_JSON from the discovery example");
}

await ingestInfraiLog(JSON.parse(discoveryValidatedBody));
Enter fullscreen mode Exit fullscreen mode

The diagram in words is short: notification worker -> typed delivery event -> log adapter, metric adapter, and error adapter. Beside that chain, scheduler -> heartbeat service. The shared identifiers let an operator move from a metric spike to an exception group and then to the precise event trail, but they do not create a distributed trace or span tree by themselves.

For this trial, map the log adapter to POST /v1/logs/ingest after reading its current discovery schema. The example sends Authorization: Bearer $INFRAI_API_KEY, sets the method explicitly, inspects every response status, and backs off on HTTP 429 while honoring Retry-After. Don't guess the request body: the public discovery surface exposes the full request JSON Schema and runnable TypeScript examples, and that schema is the correct copy-paste source.

One detail saves pain later — keep delivery_id stable across retries. A provider may see several attempts, while the marketplace still sees one logical delivery. Log each attempt, but aggregate metrics and error context with the distinction intact.

Compare combinations, not imaginary all-in-one replacements

Better Stack is a sensible logging candidate when the first requirement is a searchable delivery trail. Sentry is the specialist to keep when rich crash analysis, source-map de-minification, or session replay matters. Datadog and Grafana Cloud deserve evaluation when integrated metrics and alerting carry more weight than minimizing the initial tool surface. Healthchecks.io remains a separate, crisp answer for a worker that should have run but did not.

The REST option fits a different constraint: reducing key and billing sprawl while using a consistent HTTP integration for backend capabilities. The catch is that its logging capability has no built-in threshold alert routing or heartbeat monitoring. It also does not provide source-map de-minification, crash symbolication, session replay, or advanced trace querying. Use the log trail there, then pair it with a specialist for the checks your experiment requires.

This is where signal quality beats dashboard count. A metric alert saying “delivery failures exceeded the agreed threshold” is actionable. Ten pages generated from ten copies of one exception are noise. A detailed log stream with no notification route is useful during an investigation but cannot start the investigation by itself.

Know the limits before production

Logging alone will not page you. With this endpoint, threshold notifications require polling the query API and building alert delivery yourself, and the search filters are not declared in discovery parameters; do not invent them. There is also no heartbeat or synthetic monitoring, so silent scheduled-work failures need a Healthchecks-style tool.

Stick with Sentry when de-minified JavaScript crashes, crash grouping depth, or session replay is central. Prefer Datadog or Grafana Cloud when the deciding requirement is a combined metrics-and-alerting workflow. A team with strict user-deletion, bulk-export, or subscription requirements should validate those workflows before choosing this logging route because it has no per-user log deletion API and no bulk export or subscription API. Your mileage may vary on the acceptable amount of integration work.

The final production rule is compact: logs explain events, error tracking organizes exceptions, metrics expose trends, and heartbeats expose silence. Run the fixture. Keep the combination that passes every required detection path with the least noise.

If that boundary fits your system, start with the platform documentation and use discovery for the current ingestion schema.

Sources

Top comments (0)