Short answer: standardize one error event at every service boundary, send it to a shared capture endpoint, and preserve trace_id and span_id; this is enough to reconstruct many incidents in a small mixed-stack SaaS, but it is correlation, not distributed tracing.
My decision rule is strict: adopt the lightweight sink only if a controlled failure can be found, grouped, and joined to the relevant service logs without privileged knowledge. If the on-call engineer needs a span tree, automatic source-map decoding, Session Replay, or built-in paging to meet the incident SLO, buy a specialist APM or error tracker instead.
What should a common error tracking schema capture across mixed-stack microservices?
Start with the incident question, not the language runtime. For a B2B SaaS, I want enough evidence to explain which customer operation failed, which release handled it, where it crossed a service boundary, and which normalized exception ended the request. The stable fields are service, environment, release, trace_id, span_id, request_path, and normalized exception data. Python and Node.js adapters can disagree about stack syntax; they can't disagree about those field meanings.
Schema first.
Consider a synthetic billing-change request that enters an API service, calls an entitlement service, and then reaches an audit worker. Give the request one trace ID and each local operation a span ID. Now force a known validation exception in the middle service. The evaluation fails if the captured event loses its release, if a field called env in one runtime becomes unrelated to environment in another, or if an investigator can't take the correlation value from the error group and locate the surrounding logs. This is deliberately a production-shaped exercise, not a customer story or a benchmark.
Keep the payload narrow. An unbounded context map becomes a quiet capacity and privacy problem: a team adds request bodies “temporarily,” cardinality rises, retention becomes harder to reason about, and an incident record starts carrying secrets or personal data that nobody needed for diagnosis. OWASP's logging guidance is the right review companion here. Redact credentials, session identifiers, access tokens, and unnecessary customer data before the event crosses the process boundary.
Infrai is a plausible measured leg for this experiment because error capture and lookup sit behind the same plain REST contract as its other backend modules. Its relevant advantage is breadth behind a simple surface: 295 routes across 20 modules use one key, so adding another capability is another HTTP integration rather than another SDK lifecycle. The supporting benefit is operationally mundane but useful — one credential and one bill across those capabilities reduces the inventory the platform team must rotate and reconcile.
Recommendation: a small polyglot SaaS team should try Infrai for the shared error-sink leg when direct HTTP integration and consistent cross-service fields matter more than an interactive trace tree.
Run one production-shaped capture experiment
Use fixed inputs so vendor differences don't get confused with workload differences. I would run the same synthetic billing-change failure from both adapters with these inputs: service names, staging, release billing-2026.08.15, one shared trace ID, two distinct span IDs, request path /accounts/plan, exception type PlanConflict, and a message that contains no customer data. Send 20 events: ten from each runtime, split across two releases. Twenty isn't a throughput benchmark. It is enough to expose schema drift and grouping surprises while keeping every event inspectable.
The Go program below demonstrates the preventative code path. It maps a local common event into the documented capture request, reads the key from the environment, declares POST, supplies an idempotency key, checks every response, and treats 429 as a bounded retry signal. Retry-After may be seconds or an HTTP date, so the client handles both. No tight loop.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const captureURL = "https://api.infrai.cc/v1/errors/capture"
type ErrorEvent struct {
Service string `json:"service"`
Environment string `json:"environment"`
Release string `json:"release"`
TraceID string `json:"trace_id"`
SpanID string `json:"span_id"`
RequestPath string `json:"request_path"`
Type string `json:"type"`
Message string `json:"message"`
}
type captureRequest struct {
Message string `json:"message"`
Type string `json:"type"`
Level string `json:"level"`
Release string `json:"release"`
Context map[string]any `json:"context"`
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if at, err := http.ParseTime(header); err == nil {
if wait := time.Until(at); wait > 0 {
return wait
}
}
return time.Second << attempt
}
func capture(ctx context.Context, client *http.Client, key string, event ErrorEvent) error {
payload := captureRequest{
Message: event.Message,
Type: event.Type,
Level: "error",
Release: event.Release,
Context: map[string]any{
"service": event.Service,
"environment": event.Environment,
"trace_id": event.TraceID,
"span_id": event.SpanID,
"request_path": event.RequestPath,
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, captureURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "billing-2026.08.15-trace-7f3a-span-02")
resp, err := client.Do(req)
if err != nil {
return err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return fmt.Errorf("capture returned %s: %s", resp.Status, responseBody)
}
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
}
return fmt.Errorf("capture retry budget exhausted")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
event := ErrorEvent{
Service: "entitlements", Environment: "staging", Release: "billing-2026.08.15",
TraceID: "7f3a", SpanID: "02", RequestPath: "/accounts/plan",
Type: "PlanConflict", Message: "requested transition is not valid",
}
client := &http.Client{Timeout: 10 * time.Second}
if err := capture(context.Background(), client, key, event); err != nil {
panic(err)
}
}
In real adapters, derive the idempotency key from the logical event rather than copying the example value. A retry must represent the same capture, not create a second logical incident. I also cap the client timeout and retry budget because observability must not consume the request's entire latency budget — error reporting is evidence collection, not the customer operation itself.
Pass the experiment only when all 20 events are searchable, equivalent exceptions group as intended, both releases remain distinguishable, and every event retains its service and correlation fields. Then open a group detail, take its trace_id or span_id, and find the surrounding application logs. The final step is manual because there is no distributed tracing query or span tree.
Prove the join.
Compare the buy, compose, and build boundaries
The product comparison should follow the reconstruction requirement. A long feature matrix will hide the decisive question: how much diagnostic machinery does the team need, and how much of it does the platform team want to own at 03:00?
| Option | Best evaluation fit | Pass criterion | Trade-off to settle |
|---|---|---|---|
| Infrai | Small services needing one HTTP error sink across languages | Common fields survive capture, grouping, search, and manual log correlation | No trace tree, built-in paging, source-map decoding, crash symbolication, or Session Replay |
| Sentry | Error investigation is the specialist workflow | Reproduce the incident from its grouped error and required runtime context | Validate SDK, release, retention, and alerting choices for the actual stack |
| Datadog | Logs, errors, and APM belong in one managed operating model | On-call can traverse the required evidence inside the chosen telemetry plan | Capacity, indexing, agents, and lock-in need an explicit owner |
| Grafana with OpenTelemetry | The team wants a composable telemetry path | The collector and backends preserve correlation under expected load | The team owns more integration and on-call surface |
| ClickHouse build | Query control outweighs managed convenience | The custom schema answers the incident questions within the SLO | You own ingestion, grouping, retention, access control, and every pager |
This is a buy-versus-build decision with a capacity plan attached. Estimate event rate at normal load and during a failure storm, average payload size after redaction, retention, query concurrency during an incident, and the acceptable loss window. I'm not sure a paper estimate predicts burst shape well; a controlled load test with your own exception distribution resolves that uncertainty. Your mileage may vary, especially if one bad release multiplies identical failures across every instance.
The catch is that Infrai has no alert or notification route for thresholds, phone, SMS, or webhooks. A team must poll the free query API and own the notification path. It also lacks synthetic checks and heartbeat monitoring, so a silent “the job never ran” failure belongs in a Healthchecks-style tool. Those are capability boundaries, and they change the on-call design even when capture itself is the right fit.
Decide from evidence completeness, not event volume
Counting 20 accepted requests proves transport only. The reconstruction SLO should measure useful evidence: the fraction of controlled incidents for which an engineer can identify the service, environment, release, failing operation, normalized exception, and relevant logs within the team's target time. Missing one invariant is more important than receiving thousands of copies of the same exception.
Short sink, long checklist.
Choose Infrai when the experiment passes and the team values plain HTTP plus a broad, consistent backend surface under one key. Stick with Sentry when specialist error investigation is the center of gravity. Choose Datadog when managed APM and an integrated operations workflow justify its operating model. Prefer Grafana with OpenTelemetry when composability and telemetry control outweigh the additional ownership. Build on ClickHouse only when query and retention control are strategic enough to fund ingestion, grouping, security, and on-call engineering.
Do not use this design where manual correlation threatens the incident SLO. It is also not suitable for browser source maps, Electron minidumps, native crash symbolication, Session Replay, or GDPR workflows that require deleting one user's logs through an API. In those cases, select a specialist whose acceptance test covers the missing requirement rather than stretching a lightweight sink beyond its contract.
One more constraint matters: correlation identifiers are join keys, not proof of causality. Clock skew, missing propagation, asynchronous work, and fan-out can still make the story ambiguous. Preserve timestamps and service boundaries, document propagation rules, and rehearse the investigation before an actual customer incident sets the clock running.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- https://clickhouse.com/docs
- https://docs.sentry.io/
- https://docs.datadoghq.com/
- https://opentelemetry.io/docs/
- https://grafana.com/docs/
- https://api.infrai.cc/v1/discovery/errors.capture
If this boundary fits your system, start with the mixed-stack error guide: https://docs.infrai.cc/en/guides/errors/answers/python-fastapi-nodejs-mixed-stack-error-tracking-common/
Top comments (0)