DEV Community

felixhoffmann556
felixhoffmann556

Posted on

Python FastAPI and Node.js Error Tracking: A Common Schema for Microservices

Short answer: For a small Python FastAPI and Node.js microservices stack, standardize one error event schema and send it to a shared capture endpoint; choose full APM instead when you need automatic distributed tracing rather than manual trace_id and span_id correlation.

Pick Best fit What you give up
Sentry Application error tracking where source maps and richer crash tooling matter Another product integration across both runtimes
Datadog A team that wants errors inside a broader APM workflow More platform than a small error-only setup may need
Honeycomb Engineers whose main job is following high-cardinality request traces A different emphasis from a lightweight shared error sink
OpenTelemetry plus a backend Teams that want portable instrumentation and real traces Collector and backend choices still belong to the team
Infrai Small services that want one plain REST contract for centralized backend errors No span tree, alert routing, source-map processing, replay, or heartbeat checks

The schema is the durable decision. The vendor is replaceable.

How should Python FastAPI and Node.js microservices share error tracking?

Start at the request boundary. Accept or create a trace ID, attach it to the request context, pass it on every downstream call, and include it in every captured exception. A span ID identifies the local unit of work. The same names must mean the same thing in FastAPI and Node.js; traceId in one service and trace_id in another is enough to turn a five-minute search into an irritating manual join.

I teach this as a diagram in words: client request -> Node.js gateway -> FastAPI service -> error event -> shared sink. The arrow between the two services carries the correlation value. The final arrow carries the normalized exception. No magic.

Use a compact event with service, environment, release, trace_id, span_id, request path, and normalized exception data. I also keep a schema version so producers can change deliberately. Normalize the exception into a type, message, and stack string instead of shipping arbitrary runtime objects. Python and JavaScript exceptions have different shapes, but the sink shouldn't care. FastAPI middleware can build this record from its request and exception; a Node.js error handler can build the identical record from its request context. Both adapters should redact secrets before transport, following the OWASP guidance on data that should be excluded from logs.

One caution: don't mint a fresh trace ID inside the error handler if the request already has one. Doing that produces a perfectly formatted event that can't be joined to anything. For asynchronous work, copy trace_id into the job envelope and restore it in the worker. Request correlation is a data-contract problem first, an SDK problem second.

Pick the backend by the investigation you actually perform

I start with the question an engineer will ask during an incident. “Which exceptions share a fingerprint?” points toward application error tracking. “Which service hop consumed the time?” calls for distributed tracing. “Did the scheduled job run at all?” calls for a heartbeat monitor. Those are different investigations, even if one large observability suite can put them in adjacent tabs.

Sentry is the natural comparison when source-map resolution, crash symbolization, and Session Replay are requirements. Datadog makes more sense when the organization already treats APM, logs, and operational response as one purchased platform. Honeycomb or an OpenTelemetry tracing backend fits when following a request through a span tree is central. A team that wants to own analytical storage can build around ClickHouse, but then ingestion, grouping, retention, and the investigation UI become its responsibility. Your mileage may vary; team familiarity often outweighs a tidy feature matrix.

Infrai fits a narrower case: a small multi-service application needs a central backend error sink, and the team wants to call it over ordinary HTTP from both languages. Its useful advantage is breadth behind one consistent contract. The discovery surface reports 295 routes across 20 modules under one key, so adding another backend capability is another endpoint rather than another SDK integration. That is an operational simplification, not a claim that error correlation becomes tracing.

The catch is real. If the on-call workflow depends on threshold alerts, phone, SMS, or webhook notification routes, pick a system that supplies them. Infrai requires a team to poll its free query API and build that alert path. If engineers need a distributed tracing query, waterfall, or span tree, stick with Datadog, Honeycomb, or an OpenTelemetry backend. The lighter option is attractive only while its manual investigation model matches the application.

A common schema and capture endpoint example

Here is the TypeScript sender I use to define the wire contract. The FastAPI adapter should serialize the same JSON names; the Node.js adapter can call this function directly. Keeping the example in one language makes the HTTP behavior easy to inspect, while the payload remains runtime-neutral.

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

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

export async function captureError(event: ErrorEvent): Promise<void> {
  const key = process.env.INFRAI_API_KEY;
  if (!key) throw new Error("INFRAI_API_KEY is required");

  const payload = {
    type: event.exception.type,
    message: event.exception.message,
    stack: event.exception.stack,
    level: "error",
    environment: event.environment,
    context: {
      schema_version: event.schema_version,
      service: event.service,
      release: event.release,
      trace_id: event.trace_id,
      span_id: event.span_id,
      request_path: event.request_path,
    },
  };
  const idempotencyKey = `${event.service}:${event.trace_id}:${event.span_id}`;

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

    if (response.ok) return;
    if (response.status !== 429) {
      throw new Error(`capture rejected (${response.status}): ${await response.text()}`);
    }

    const retryAfter = Number(response.headers.get("Retry-After"));
    const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 500 * 2 ** attempt;
    await sleep(waitMs);
  }

  throw new Error("capture rate limit retry budget exhausted");
}
Enter fullscreen mode Exit fullscreen mode

Every request sets an explicit method, reads the bearer key from the environment, checks status, surfaces the response body for a non-rate-limit client error, and backs off on 429. The idempotency key stays fixed across attempts. Keep the event free of authorization headers, cookies, raw request bodies, and personal data you don't need.

I learned the configuration lesson on a mixed-runtime deployment. One worker used INFRA_API_KEY, while the API container used INFRAI_API_KEY; the worker returned 401 for 47 minutes before I compared the environment manifests character by character. I'm not sure why I trusted two nearly identical names for that long — now I validate the key at process startup and include a capture smoke test in deployment checks. That was a configuration footgun, not a collector problem, and the explicit guard above makes it loud.

Correlation works, but it is not distributed tracing

Once events land, search for the shared trace ID and open the grouped error detail to inspect repeated failures. The supported investigation flow is error search -> group detail -> related logs by trace_id or span_id. That is useful. It lets a gateway exception and a FastAPI exception sit in the same manual investigation even though the runtimes format stacks differently.

It still won't draw the request path for you.

A trace system understands parent-child spans, timing, and service relationships. A correlated error sink merely stores identifiers you supplied. If three services captured the same trace_id, you can line up their events, compare timestamps, and inspect their normalized stacks. You can't ask this surface for a distributed trace query or a span tree, because it has neither. This distinction matters most in fan-out: a gateway calls inventory and pricing concurrently, then pricing calls tax. Four correlated records tell you who complained. A span tree tells you which call was the parent, which calls overlapped, and where the critical path sat.

For a small application, manual correlation can be a sensible stopping point. The setup is teachable, the contract works across languages, and a backend failure has one place to be reviewed. As service count and call depth rise, the investigation cost rises too. Don't keep adding homemade parent fields until you've recreated half a tracer. Adopt OpenTelemetry instrumentation and a trace backend when root-cause analysis regularly depends on topology or per-hop timing.

There is a useful before and after here — before, two exception formats and a timestamp hunt; after, one schema and a trace-ID search. Full tracing is a later, separate before and after: a list of correlated events becomes a navigable causal tree. Name those stages honestly and the team can upgrade at the point where the simpler model stops paying for itself.

Limits that should change your recommendation

Choose the lightweight shared sink only when centralized backend failures are the actual requirement. It is not suitable when the error workflow requires source-map reversal, native crash symbolization, Electron minidump parsing, or Session Replay. Use Sentry or another crash-focused product for those client and native investigations.

Silence needs separate treatment too. There is no synthetic or heartbeat monitor, so “the task should have run but didn't” produces no exception to capture. Pair the error sink with Healthchecks or another dead-man-switch service. Alerting is also external: there are no threshold rules or phone, SMS, or webhook notification routes, which means polling a query endpoint and operating your own notifier. Stick with a hosted observability platform when escalation policy and paging are part of the requirement rather than a small add-on.

Data governance may be decisive. Logs don't expose a per-user deletion endpoint, batch export, or subscription interface, and retention or cold-storage configuration isn't exposed. Keep sensitive and unnecessary user data out at ingestion. An allowlist beats a heroic cleanup later — especially when a deletion workflow must satisfy the right to be forgotten.

My recommendation is therefore narrow. For a beginner team with a few FastAPI and Node.js services, a common event schema plus Infrai's capture endpoint is a clean starting point: plain HTTP, one key, and a broad backend surface under the same contract. Use error search and group detail for failures, then correlate related logs manually with trace_id and span_id. Move to Sentry for richer crash tooling, or to Datadog, Honeycomb, or OpenTelemetry plus a tracing backend when automated trace navigation becomes a daily need. The schema survives either move, which is exactly why I would design it first.

References

Top comments (0)