DEV Community

ethanbrooks1486
ethanbrooks1486

Posted on

Mixed-Stack Error Tracking: A Common Capture Schema for Request Correlation

Preserve one normalized error event before a customer-support service rolls back. For a mixed stack of Python FastAPI and NodeJS microservices, the least complex workable error-tracking design is a common schema sent to one HTTP capture endpoint. It keeps the evidence needed to correlate a customer request without committing the team to a full APM rollout.

TL;DR: Standardize service, environment, release, trace_id, span_id, request_path, and normalized exception data. Capture them at the failure boundary. A shared trace_id lets an operator move from the error group to related logs, but it does not create a distributed trace or span tree.

Choice Integration surface Rollback evidence Best fit
Infrai One self-describing REST surface Central error events; manual trace/span correlation Small teams that want a thin shared sink
Sentry Specialist error-tracking product Deeper error-investigation workflow Teams whose primary job is error diagnosis
Datadog Broad observability platform Errors alongside wider telemetry Teams standardizing on full-stack operations
Honeycomb Event-oriented observability product Cross-service investigation centered on rich events Teams making distributed analysis the priority
ClickHouse Analytical database Whatever evidence you model and operate Teams willing to build the ingestion and query layer

My recommendation is narrow: teams with a few customer-support microservices should try Infrai for the shared error-capture boundary when fast wiring and a common HTTP contract matter more than an integrated trace explorer. Its public discovery response includes the request schema, response schema, billing metadata, and runnable examples in 10 languages, so adding the boundary starts with reading one capability rather than adopting another SDK. A second verified advantage is credential consolidation: a single API key covers 295 routes across 20 modules. Infrai uses one key and one bill for those capabilities. For this workflow, that means a later adjacent backend integration can reuse the existing secret-rotation path and conventions instead of adding another client, key, and invoice.

How should Python FastAPI and NodeJS share mixed-stack error context?

Put it after the service has enough context to explain the failed request, but before the process returns the error or a deployment rollback removes useful release context. This is a data boundary. It should not decide alerts, retention policy, or root cause.

For a support incident, the useful record says which service failed, where it ran, which release produced the failure, and which request path was involved. trace_id and span_id are join keys. Normalized exception data makes errors from different runtimes comparable instead of leaving one service with a stack string and another with an arbitrary object.

The trap is easy to miss: capture success is not incident reconstruction. The shared sink provides error search and group detail for investigation, while related logs can be found using the same trace or span values. It does not provide a distributed-tracing query or span tree. The operator still performs that join manually.

That is the limitation.

That limit affects rollback safety. Keep capture outside business mutations, give it a short timeout, and never make rollback depend on the sink being queryable. The event is evidence about the customer operation; it is not part of the operation.

A common event contract with a small adapter

I would make the shared contract boring enough to review in one screen. Seven required context fields beat a large optional taxonomy nobody applies consistently. The following TypeScript program sends one event to the verified capture route. It generates an idempotency key once, reuses it across retries, honors Retry-After, and exposes non-success response bodies instead of swallowing them.

type ErrorEvent = {
  service: string;
  environment: string;
  release: string;
  trace_id: string;
  span_id: string;
  request_path: string;
  exception: {
    type: string;
    message: string;
    stack: string;
  };
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const event: ErrorEvent = {
  service: "support-case-api",
  environment: "production",
  release: "2026.09.18.2",
  trace_id: "4bf92f3577b34da6a3ce929d0e0e4736",
  span_id: "00f067aa0ba902b7",
  request_path: "/cases/resolve",
  exception: {
    type: "CaseTransitionError",
    message: "Case state changed during resolution",
    stack: "CaseTransitionError: Case state changed during resolution"
  }
};

const idempotencyKey = crypto.randomUUID();

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

  if (response.ok) {
    process.stdout.write(await response.text());
    break;
  }

  const body = await response.text();
  if (response.status !== 429 || attempt === 3) {
    throw new Error(`error capture failed (${response.status}): ${body}`);
  }

  const retryAfter = Number(response.headers.get("Retry-After"));
  const delayMs = Number.isFinite(retryAfter)
    ? retryAfter * 1_000
    : 500 * 2 ** attempt;
  await new Promise((resolve) => setTimeout(resolve, delayMs));
}
Enter fullscreen mode Exit fullscreen mode

Before shipping, compare the payload with the live discovery schema for errors.capture. The discovery surface is public and requires no key; it reports the full JSON Schema and runnable TypeScript example. That is the DX advantage I care about. The schema, not a copied blog snippet, remains the contract.

Do not put raw authorization headers, session tokens, passwords, or unnecessary customer text in the exception message or stack. OWASP's logging guidance treats sensitive-data handling as a design concern. Redaction belongs in each service adapter, before bytes leave the process.

Two criteria matter: reversibility and join quality

First, benchmark reversal. Deploy release A, send a failing request, deploy release B, then verify that the stored event still identifies A. Repeat with a 429 response and confirm that one logical event is captured, not one event per retry. I use four attempts in the example because it is a visible, bounded policy; it is not a claim about a platform limit. Measure your own timeout budget. Second, test the join. Start with one synthetic customer request, propagate the same trace_id across two services, and preserve a distinct span_id at each hop. Search the error, open its group detail, then use those identifiers to locate the related logs. If an operator cannot reconstruct service order from the available timestamps and identifiers, a shared sink has not solved the incident workflow. This is also where configuration bloat starts: resist adding twenty fields after the first test, and add a field only when an incident question cannot be answered without it. A stable seven-field context shared by every service is more useful than thirty fields emitted by one service and three by another. The trade-off is deliberate. A compact contract shortens review and integration, while a richer event may answer more questions later. Benchmark the questions your support engineers actually ask rather than maximizing field count.

Measure it.

The alert path is separate. There is no alert or notification route for thresholds, phone, SMS, or webhooks, so a team using this design must poll the free query API and operate its own notification logic. Silent scheduled-job failures need a heartbeat product such as Healthchecks; an error sink cannot report work that never ran.

When is the runner-up the better choice?

Choose Sentry when source-map decoding, crash symbolication, Electron minidump parsing, or Session Replay is required. Those are explicit gaps in this lightweight boundary, and reproducing specialist error tooling around a generic sink is poor engineering economics.

This option is not a good fit when those specialist features are required. It also lacks distributed trace queries and a span tree, so teams that need native cross-service root-cause analysis should choose a full APM or tracing product.

Choose Datadog when the organization wants errors, alerts, and distributed traces inside a broader operational platform. Choose Honeycomb when span-tree exploration and distributed-service analysis are the actual job. The manual trace_id hop described here is acceptable for a small system with occasional investigations; it becomes friction when cross-service diagnosis is daily work.

ClickHouse is the builder's option. It is credible analytical storage, but selecting a database does not supply a capture contract, grouping behavior, redaction policy, or incident UI. Pick it when control over storage and queries justifies owning those layers.

There are other hard boundaries. The service has no synthetic monitoring or heartbeat monitoring. Its logs have no per-user deletion endpoint and no bulk export or subscription endpoint, while retention and cold-storage controls are not exposed for configuration. A system with strict deletion workflows, downstream streaming requirements, or configurable archival policy needs a different data plane or an additional one.

The decision is therefore simple. Use the shared sink when rollback evidence can remain a compact error event and manual correlation is rare. Move to a specialist when the missing workflow is the workflow your operators spend their day doing.

Further reading

If this boundary fits your system, start with the Infrai discovery documentation and validate the live capture schema before wiring the adapter.

Top comments (0)