One incident, two languages: FastAPI and Node in one error group

A shared fingerprint taxonomy plus a W3C traceparent gives a Python and Node service pair one group per incident, without running an OpenTelemetry collector.

When a FastAPI service and a Node service fail on the same request, most error trackers show you two unrelated issues. Python reports ReadTimeout, Node reports UND_ERR_HEADERS_TIMEOUT, and default grouping keys on the message text — so one incident becomes two rows in a list, discovered a day apart by two different people. Infrai’s POST /v1/errors/capture is plain REST with the same five fields from either language, which removes the SDK-parity problem but not the grouping problem.

The grouping problem is solved by agreeing on a string. Send an explicit fingerprint computed the same way in both languages and both events land in one group; leave it out and the raw message decides, which guarantees the split. Everything else in this article follows from that one decision.

The contract both services implement

FieldRuleExample
fingerprint<flow>:<operation>:<canonical error> — no service name, no idscheckout:charge_order:upstream_timeout
message line 1the native error, verbatimReadTimeout: timed out reading response
message line 2machine-readable context[svc=checkout-py trace=4bf92f35… op=charge_order]
releaseservice identity plus versioncheckout-py@2026.07.25
environmentshared vocabulary across the fleetproduction

The counter-intuitive row is the first one. Keeping the service name out of the fingerprint is what lets both sides collapse into a single group; service identity moves to release, where the group’s release_distribution then tells you how many events each side contributed. You get “one incident, 60% from Python” instead of two half-incidents.

The error taxonomy

Agree on the words first, write the code second.

Native exception names are the enemy here. Two runtimes will never spell the same failure the same way, so map both to a small vocabulary you own — a dozen tokens is plenty for a small fleet.

FailurePython (httpx / SQLAlchemy)Node (undici / pg)Canonical token
upstream too slowReadTimeout, ConnectTimeoutUND_ERR_HEADERS_TIMEOUT, ETIMEDOUTupstream_timeout
upstream downConnectErrorECONNREFUSEDupstream_unreachable
duplicate keyIntegrityErrorSQLSTATE 23505db_constraint
bad inputValidationErrorZodErrorbad_payload
anything elseunhandled

Anything unmapped falls to unhandled, which is fine: it groups by flow and operation, and a group of unhandled events that starts growing is your prompt to add a row to the table.

Python side

# errors_client.py — Python 3.12, httpx.
import contextvars
import os
import traceback

import httpx

CAPTURE_URL = "https://api.infrai.cc/v1/errors/capture"
SERVICE = os.environ.get("SERVICE_NAME", "checkout-py")
RELEASE = os.environ.get("APP_RELEASE", "dev")
FLOW = os.environ.get("FLOW_NAME", "checkout")

trace_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("trace_id", default="-")

TAXONOMY = {
    "ReadTimeout": "upstream_timeout",
    "ConnectTimeout": "upstream_timeout",
    "ConnectError": "upstream_unreachable",
    "IntegrityError": "db_constraint",
    "ValidationError": "bad_payload",
}


def canonical(exc: BaseException) -> str:
    return TAXONOMY.get(type(exc).__name__, "unhandled")


def capture(exc: BaseException, op: str) -> str | None:
    key = os.environ.get("INFRAI_API_KEY")
    if not key:
        return None
    frames = "".join(traceback.format_exception(exc))[-1500:]
    body = {
        "message": f"{type(exc).__name__}: {exc}\n"
                   f"[svc={SERVICE} trace={trace_id_var.get()} op={op}]\n{frames}",
        "exception": type(exc).__name__,
        "fingerprint": f"{FLOW}:{op}:{canonical(exc)}",
        "environment": os.environ.get("APP_ENV", "development"),
        "release": f"{SERVICE}@{RELEASE}",
    }
    try:
        res = httpx.post(
            CAPTURE_URL,
            json=body,
            headers={"Authorization": f"Bearer {key}"},
            timeout=2.0,
        )
        res.raise_for_status()
        return res.json()["data"]["error_group_id"]
    except Exception as send_failure:  # a reporter that raises is worse than one that misses
        print(f"[capture] failed: {send_failure}")
        return None
# main.py — uvicorn main:app
import uuid

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

from errors_client import capture, trace_id_var

app = FastAPI()


@app.middleware("http")
async def trace_context(request: Request, call_next):
    parts = request.headers.get("traceparent", "").split("-")
    trace_id = parts[1] if len(parts) == 4 and len(parts[1]) == 32 else uuid.uuid4().hex
    token = trace_id_var.set(trace_id)
    try:
        return await call_next(request)
    finally:
        trace_id_var.reset(token)


@app.exception_handler(Exception)
async def unhandled(request: Request, exc: Exception):
    route = request.scope.get("route")
    capture(exc, op=getattr(route, "name", None) or request.url.path)
    return JSONResponse(
        status_code=500,
        content={"error": "internal", "trace_id": trace_id_var.get()},
    )

contextvars is Python’s answer to the same problem AsyncLocalStorage solves in Node: the trace id is set once per request and readable from any coroutine underneath it, including the exception handler that runs after the stack has unwound. Returning it in the 500 body costs nothing and gives support a string to quote.

Node side

// trace.mjs — Node 22 ESM.
import { AsyncLocalStorage } from "node:async_hooks";
import { randomBytes } from "node:crypto";

const CAPTURE_URL = "https://api.infrai.cc/v1/errors/capture";
const SERVICE = process.env.SERVICE_NAME ?? "orders-node";
const RELEASE = process.env.APP_RELEASE ?? "dev";
const FLOW = process.env.FLOW_NAME ?? "checkout";
const store = new AsyncLocalStorage();

const TAXONOMY = new Map([
  ["UND_ERR_HEADERS_TIMEOUT", "upstream_timeout"],
  ["ETIMEDOUT", "upstream_timeout"],
  ["ECONNREFUSED", "upstream_unreachable"],
  ["23505", "db_constraint"],
  ["ZodError", "bad_payload"],
]);

export const traceId = () => store.getStore()?.traceId ?? "-";

export function withTrace(header, fn) {
  const parts = String(header ?? "").split("-");
  const id = parts.length === 4 && parts[1].length === 32 ? parts[1] : randomBytes(16).toString("hex");
  return store.run({ traceId: id }, fn);
}

export function callDownstream(url) {
  return fetch(url, {
    headers: { traceparent: `00-${traceId()}-${randomBytes(8).toString("hex")}-01` },
    signal: AbortSignal.timeout(8000),
  });
}

export async function capture(err, op) {
  const key = process.env.INFRAI_API_KEY;
  if (!key) return null;
  const token = TAXONOMY.get(err.code ?? err.name) ?? "unhandled";
  try {
    const res = await fetch(CAPTURE_URL, {
      method: "POST",
      headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
      signal: AbortSignal.timeout(2000),
      body: JSON.stringify({
        message: `${err.name}: ${err.message}\n[svc=${SERVICE} trace=${traceId()} op=${op}]\n${err.stack ?? ""}`.slice(0, 8000),
        exception: err.name,
        fingerprint: `${FLOW}:${op}:${token}`,
        environment: process.env.APP_ENV ?? "development",
        release: `${SERVICE}@${RELEASE}`,
      }),
    });
    if (!res.ok) console.error("[capture] HTTP", res.status);
    return res.ok ? (await res.json()).data.error_group_id : null;
  } catch (e) {
    console.error("[capture] failed:", e.message);
    return null;
  }
}

Wrap your request handler in withTrace(req.headers.traceparent, …) and every downstream call made through callDownstream carries the same trace id onward. That’s the whole propagation story — the traceparent header is a W3C standard precisely so two runtimes can agree without sharing a library.

Proving both sides land together

Seed the Python-side event first:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "ReadTimeout: timed out reading response from payments-api\n[svc=checkout-py trace=4bf92f3577b34da6a3ce929d0e0e4736 op=charge_order]\n  File /app/checkout/charge.py, line 88, in charge_order",
    "exception": "ReadTimeout",
    "fingerprint": "checkout:charge_order:upstream_timeout",
    "environment": "production",
    "release": "checkout-py@2026.07.25"
  }'

The Node service then reports its own view of the same failure — different class name, different stack, same fingerprint — and the response says "is_new_group": false: it joined the existing group rather than opening a second one. Read the group back and the split is visible without being a split:

curl -sS "https://api.infrai.cc/v1/errors/group_detail/errgrp_F8elgl9ad2OBmtak6UnYrOJ5" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '{count: .data.count, releases: .data.releases, per_service: .data.release_distribution}'
{
  "count": 2,
  "releases": ["checkout-py@2026.07.25", "orders-node@2026.07.25"],
  "per_service": {
    "checkout-py@2026.07.25": 1,
    "orders-node@2026.07.25": 1
  }
}

And the per-request question — what happened to this customer’s charge — is a text search, because the trace id is in the message body:

curl -sS "https://api.infrai.cc/v1/errors/search?q=4bf92f3577b34da6a3ce929d0e0e4736&limit=5" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq -r '.data.items[] | [.release, (.title | split("\n")[0])] | @tsv'
orders-node@2026.07.25	TimeoutError: UND_ERR_HEADERS_TIMEOUT calling payments-api
checkout-py@2026.07.25	ReadTimeout: timed out reading response from payments-api

Two services, one query, ordered newest first. Search needs a non-empty q and ignores environment and level; those filters live on GET /v1/errors/list.

What this is not

There is no trace field on the capture route, no span model, no parent-child links and no service map — the trace id is searchable text, nothing more. So you can answer “which services saw this request fail” but not “where did the 800ms go”, and no amount of clever fingerprinting will change that. The stored exception is also normalised to {"type": "Message", "value": "…", "stacktrace": []}, so neither language gets structured frames back.

If latency attribution across services is the actual requirement, instrument with OpenTelemetry and send spans to a backend that understands them; Datadog APM and Sentry’s tracing both do this properly, and Sentry additionally links errors to the span that produced them. That’s a real trade-off, and it costs a collector, an agent, and a per-host or per-span bill.

Where this approach earns its place is the middle ground: a handful of services in two languages, no tracing infrastructure, and a team that needs one page per incident rather than a waterfall.

The bill

Reads are free and rate-limited across the namespace, so searching and reading groups from any number of services costs nothing. Capture is metered at $0.00005 per event, verified 2026-07-26, and a new account carries $2 of free credit — roughly 39,999 events. Rates here trend downward and campaigns run, so treat the printed figure as an upper bound and read the live one:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id=="errors.capture") | .billing'

One key covers both services, and the same key covers the queue the Python worker pulls from and the cron that runs the Node job — which is the practical reason a mixed stack ends up cheaper to operate here than as two vendor accounts with two invoices and two rotation schedules.

References

Browse more errors developer guides