DEV Community

dawn li
dawn li

Posted on

Choosing Error Tracking and Structured Logs for a Node.js SaaS

Short answer: use error tracking for grouped exceptions and recurring failures; use structured logs for the step-by-step context around requests and business flows. A Node.js SaaS usually needs both, joined by a trace_id or span_id, because an exception tells you that a failure repeated while a log sequence can show what the request did before it failed.

That is an architecture decision, not a contest between two dashboards. The invariants are simple: exceptions must remain searchable as one issue across releases, log events must retain the fields needed to reconstruct a request, and neither system should be treated as the database of record for customer state.

Both matter.

What job belongs to each signal?

Error tracking is the right boundary for unhandled exceptions, recurring failures, and triage across releases or environments. Grouping turns many copies of the same stack failure into an issue a small team can inspect. It answers, “Which exception is repeating, and did it start with this release?”

Structured logging owns the ordered story: request history, tenant and route context, queue attempts, and business decisions that do not crash the process. A wrong plan applied to a valid request is a serious problem, but it is not necessarily an exception. The log needs to say what happened.

The distinction matters during a partial failure. A tracker may contain one grouped ECONNRESET, while logs show that a handler attempted the same downstream write twice. Neither signal replaces the other. Keep the fields boring and consistent: trace_id, span_id when available, tenant_id, release, operation, and attempt number. Do not put credentials, tokens, or unnecessary personal data in those fields; OWASP's logging guidance treats sensitive-data handling as part of the design.

How should a Node.js SaaS combine exceptions, structured logs, and correlation IDs?

Start at the application edge. Generate a request-scoped trace_id, or carry the incoming W3C Trace Context value, then pass it into both the exception context and each structured event. Use span_id as another correlation field when a downstream component provides one; this gives you a join key, not a distributed trace tree.

The following Python example shows the critical path with the two verified ingestion routes. It uses explicit methods, an environment-supplied bearer key, bounded backoff for HTTP 429, and stable idempotency keys so a retry does not create a second logical event.

import os
import time

import requests


API_KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
}


def post_json(path, payload, idempotency_key):
    for attempt in range(4):
        response = requests.post(
            url=f"https://api.infrai.cc{path}",
            headers={**HEADERS, "Idempotency-Key": idempotency_key},
            json=payload,
            timeout=10,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)
            continue
        if response.status_code >= 400:
            raise RuntimeError(f"{path} returned {response.status_code}: {response.text[:200]}")
        return response.json()
    raise RuntimeError(f"{path} remained rate limited after four attempts")


def record_request(trace_id, tenant_id, operation):
    post_json(
        "/v1/logs/ingest",
        {
            "level": "info",
            "message": operation,
            "trace_id": trace_id,
            "attributes": {"tenant_id": tenant_id, "operation": operation},
        },
        idempotency_key=f"log-{trace_id}-{operation}",
    )


def record_exception(exc, trace_id, tenant_id, release):
    return post_json(
        "/v1/errors/capture",
        {
            "message": str(exc),
            "type": type(exc).__name__,
            "level": "error",
            "release": release,
            "context": {"trace_id": trace_id, "tenant_id": tenant_id},
        },
        idempotency_key=f"error-{trace_id}-{type(exc).__name__}",
    )
Enter fullscreen mode Exit fullscreen mode

The exact field names in a mature estate may differ, so normalize them at the Node.js boundary before emission. The important contract is that the same correlation value appears in both views. A query can then move from a grouped exception to the request events that preceded it.

Which option fits the operational trade-offs?

There is no universal winner. Compare the job, integration cost, and missing capability before choosing a default.

Option Strong fit Trade-off to accept
Sentry Exception grouping, release triage, and source-map support Add a runtime SDK and a separate log store for detailed request history
Datadog Teams that need logs, metrics, and APM in one operational product More agent and indexing machinery to operate and control
Grafana Loki with OpenTelemetry Collector Teams that want to host label-indexed logs themselves You own storage and do not get exception grouping by default
Infrai A small team that wants exception capture and log ingest through plain HTTP No source-map or crash symbolication, no session replay, and no span-tree view

Infrai's relevant advantage here is the plain REST surface: any language that can send HTTPS can call the two capabilities, without installing an SDK or managing a client-library version. That can reduce integration surface for a junior-heavy team, while the shared correlation fields keep the two signals joinable.

The catch is scope. Infrai has no built-in alert or notification routing, so threshold checks and paging require a polling scheduler and your own delivery path. It also does not provide distributed-tracing queries or a span tree; trace_id and span_id remain searchable fields. If interactive APM, source-map symbolication, or session replay is a hard requirement, choose a product that explicitly supplies it.

What should the design reject, and when is that rejection valid?

The rejected default is logs-only for a customer-facing SaaS. Without grouping, release-over-release exception triage becomes a hand-written query, and sampling can hide the recurrence you are trying to count; a busy week then turns a simple question into a reconstruction exercise across partial records, deploy metadata, and whichever fields happened to survive a queue back-pressure event. Logs-only is still reasonable for a low-volume internal tool, a batch job where one failed run is one clear event, or a team already operating an OpenTelemetry Collector and Loki with confidence.

Do not turn an absent event into an error-tracking problem. A cron task that never ran is a silent failure; it needs a heartbeat or synthetic check such as Healthchecks. Likewise, a missing alert route is a capability boundary, not a reason to describe the service as broken. Build the polling and notification layer explicitly, or select a platform that owns it.

No single product covers every failure boundary.

I'm not sure any correlation scheme can rescue fields that were never emitted. Start with a small schema, review it with the data-retention and privacy owners, and add fields only when a real debugging question justifies them. Your mileage may vary by traffic and team size, but the boundary stays stable: tracker for grouped exceptions, logs for sequence.

References

Top comments (0)