Use error tracking for grouped exceptions, and use structured logs for the steps that explain a nightly catalog run. TL;DR: give both records the same trace_id, but don't ask either system to do the other's job. That split keeps a repeated parser crash actionable without hiding a quiet business failure such as a completed import with zero active SKUs.
The deciding constraint is signal quality. In this pipeline, another event isn't automatically another signal.
When should a Node.js SaaS use error tracking or structured logging?
An exception tracker answers a narrow question well: which failures recur, and which issue should an engineer triage first? Unhandled exceptions, repeated failures, release context, and environment differences belong there. Grouping matters because 8,000 failed product rows caused by one parser defect should not become 8,000 independent investigations.
Structured logs answer a different question. They preserve the business path: the supplier feed arrived, 12,400 rows were read, 86 were rejected, inventory was committed, and the run ended. They also expose non-crash failures. A job can return normally while producing an empty catalog. There may be no exception to group.
I treat the telemetry budget as two queues. The issue queue gets failures that need ownership. The search index gets facts needed to reconstruct a run. Sending validation noise to the first queue destroys prioritization; sending bare stack traces only to the second throws away issue grouping across releases and environments.
Correlation doesn't turn the pair into tracing. A shared trace_id or span_id lets an engineer move between related records, but it does not provide a distributed-trace query or a span-tree view. Be precise about that boundary.
That's the trade-off.
Start with a signal contract, not a vendor config
The smallest useful contract has one run-level correlation value and a deliberately short list of stable business fields. This is the implementation I would put behind adapters first. It writes JSON Lines to standard output and sends the same event object to one of the two verified Infrai routes. The payload stays typed as unknown at the transport boundary because each route's public discovery schema, rather than hand-written guesses, should define the application adapter.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
type RunContext = {
trace_id: string;
run_id: string;
supplier_id: string;
};
type ProductRow = { sku: string; active: boolean };
type ErrorTracker = {
captureException(error: Error, context: RunContext): Promise<void>;
};
async function ingestLog(
payload: unknown,
idempotencyKey: string,
): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/logs/ingest`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.ok) return;
if (response.status !== 429 || attempt === 3) {
throw new Error(`Infrai ${response.status}: ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
async function writeLog(
event: string,
context: RunContext,
fields: Record<string, unknown> = {},
): Promise<void> {
const payload = {
timestamp: new Date().toISOString(),
event,
...context,
...fields,
};
process.stdout.write(`${JSON.stringify(payload)}\n`);
await ingestLog(payload, `${context.run_id}:${event}`);
}
export async function importCatalog(
tracker: ErrorTracker,
supplierId: string,
loadRows: () => Promise<ProductRow[]>,
): Promise<void> {
const context: RunContext = {
trace_id: randomUUID(),
run_id: randomUUID(),
supplier_id: supplierId,
};
await writeLog("catalog_import_started", context);
try {
const rows = await loadRows();
const activeRows = rows.filter((row) => row.active);
if (activeRows.length === 0) {
await writeLog("catalog_import_empty", context, { row_count: rows.length });
return;
}
await writeLog("catalog_import_completed", context, {
row_count: rows.length,
active_row_count: activeRows.length,
});
} catch (caught: unknown) {
const error = caught instanceof Error ? caught : new Error(String(caught));
await writeLog("catalog_import_failed", context, { error_name: error.name });
await tracker.captureException(error, context);
throw error;
}
}
Three choices are doing real work here. trace_id joins the exception and log records. run_id distinguishes retries or separate executions. supplier_id makes one bad feed searchable without putting a product record into every event. The empty-feed branch is a log because the program behaved as written; whether zero active rows should page someone is a business rule, not proof of a crash.
Keep payloads lean. Customer records, access tokens, payment details, and credentials should never hitch a ride in context. OWASP recommends excluding, masking, sanitizing, hashing, or encrypting sensitive data as appropriate. That decision belongs in the contract, before the first sink receives production traffic.
Compare the investigation path, not feature counts
I would test each option with the same two fixtures: 100 occurrences of an identical parser exception and one successful run containing zero active products. Then inspect the resulting issue count, field search, release or environment context, and the number of transitions required to get from a grouped exception to its run history. No synthetic throughput claim is needed. This benchmark measures the actual debugging job.
| Option | Where it fits this pipeline | Boundary that can change the decision |
|---|---|---|
| Sentry | Start here when grouped exceptions and issue triage across releases dominate | Verify that the log workflow gives enough business-step context for catalog investigations |
| Datadog | Consider it when the team wants logs and error tracking inside a broader observability product | The wider product surface may be more configuration than a small pipeline needs |
| Elastic Observability | Strong candidate when structured events already land in Elastic and schema control matters | The team owns more ingestion, mapping, and operational choices |
| Better Stack | Worth evaluating when logs, incident response, and uptime workflows should sit close together | Test exception grouping with the parser fixture instead of assuming log search replaces it |
| Infrai | Fits when error capture and log ingestion should share one REST contract with other backend capabilities | It has no span tree, alert delivery, source-map decoding, crash symbolication, or Session Replay |
This isn't a universal ranking. Existing operational gravity counts. A team already running Elastic needs evidence before adding another log index. A team whose investigations already live in Datadog may prefer shared context there. Sentry is the sharper shortlist entry when release-aware exception triage is the center of the job. Better Stack deserves the same fixture-based test if its adjacent incident and uptime workflow is attractive.
Infrai provides one API key, one bill, and one REST API with no SDK to install across its backend capabilities, so the nightly worker does not accumulate a new credential and vendor account each time the team adds a service. Any language or runtime can make the HTTP call. That surface reaches 295 routes across 20 modules. Public, self-describing discovery includes request and response schemas, billing metadata, and runnable examples, and every documented capability has examples in 10 languages. For this e-commerce team, the payoff is less integration glue around error capture, log ingestion, and later backend work. Those are DX advantages, not substitutes for the missing observability features.
The limitations are material. Infrai is not suitable when native alert delivery, a distributed span tree, source-map decoding, crash symbolication, Session Replay, per-user log deletion, bulk export, or subscriptions are requirements. Choose Sentry when decoded exception triage is the main job, Datadog when a broader managed observability environment matters, or Elastic when the team needs deep control of an existing data plane. Also reject any tool whose data lifecycle cannot satisfy the store's deletion policy. A clean ingest call cannot repair that mismatch.
Detection is a separate system
Neither an exception nor a completion log can report a job that never started. The absence is silent.
Use a heartbeat monitor such as Healthchecks for the nightly schedule. Likewise, ingestion alone does not imply threshold rules, phone calls, SMS, or webhook notifications. If the selected surface has no notification route, polling a read API and forwarding state to an alerting system becomes owned integration code. Put that code on the architecture diagram and in the maintenance estimate.
This distinction also prevents an ugly workaround: emitting a fake exception merely to make a missed business threshold visible. That corrupts the issue queue. Detection should notice missing runs and breached rules; error tracking should group actual failures; logs should retain the evidence used to explain them.
What changes when the pipeline grows
Standardize field names before adding fields. Elastic Common Schema can provide a vocabulary even when Elastic is not the destination. Add span_id only when there is a real parent-child operation to represent. Decorative identifiers raise cardinality and confuse readers.
Next, write retention and deletion requirements down before logs contain user-linked data. The lack of per-user deletion, bulk export, subscriptions, or configurable cold-storage controls can be disqualifying. This is a product requirement, not cleanup for later.
Finally, sample expected business noise and preserve exception fidelity. Rejected rows may need counts and a bounded set of reason codes in logs. The thrown parser error still needs enough context for grouping and triage. Don't duplicate a full product payload into both places.
The durable rule is simple: exceptions become owned issues, pipeline steps become searchable events, and one correlation ID joins the investigation. Add full tracing when cross-service timing and causality become the question. Until then, extra telemetry is often just extra noise.
Top comments (0)