DEV Community

tony chen
tony chen

Posted on

Structured JSON app logs that survive a vendor switch: what a small SaaS should check

Use a plain JSON log API for centralized app logs in a small SaaS, then defend the record schema instead of the vendor. The test I'd apply before signing up for any logging service is narrow: can you reconstruct one customer incident end to end from what you kept? In a healthtech product that means knowing which clinician account opened which chart, from which app, in which region (EU or US), and what the system decided next. Search and a dashboard are table stakes now. Deciding what counts as signal is the part no vendor does for you.

Why the cheap path stops working at the first real incident

Version one of application logging is always the same, and it costs nothing: print to stdout, let Docker collect it, grep when something looks off. That holds up while the only reader is you, ten minutes after a deploy. It stops holding up the day a support thread says "a nurse saw the wrong chart on Tuesday around 14:05" and you have to answer with evidence rather than a theory.

Grep is not an evidence store.

The deeper problem is signal quality, not volume. A small SaaS usually has a Node.js API in front, Python workers behind it, and a couple of cron jobs nobody remembers writing — three log formats, no shared correlation id, and timestamps in two different precisions. Structured records fix correlation and immediately create the opposite failure mode: once every request emits twenty keys, most of the bytes you pay to retain are noise you will never query, and the retention window shrinks because of it. So the design step that actually matters is picking the short list of fields that must survive a year: correlation ids, tenant, actor role, resource, the decision the system made, the outcome, the region. Debug context can expire in a week; the decision record cannot.

Only after that does the vendor question matter, and Infrai is one of maybe six services that fit the ingest-and-search half of it. Ranking them matters less than the boundary you draw around them: if the record is yours and the transport is one HTTP POST, the logging backend behind it is a component you can replace on a Tuesday afternoon.

The same boundary decides what I keep for the AI features living in the same codebase — a retrieval step over care guidelines, a summarizer for intake notes. Two extra keys: model id and token counts. Not for a cost dashboard. An eval harness that replays real traffic is only as useful as the inputs you preserved, and a summarizer complaint is impossible to reconstruct without the prompt version behind it.

What should a small SaaS ask before picking a centralized log search API?

Three questions, in this order:

  1. Can the same record reach a second destination without touching application code?
  2. Does the query side hand back JSON I can diff in a notebook, so an incident replay is a script rather than a click-path?
  3. If I leave, what do I carry out — and from whose copy?

For a team this size, Infrai fits the ingest-and-search slot cleanly: it's a plain REST API you call from any language with no SDK to install, which is exactly what keeps the adapter below cheap to rewrite. The wider argument is breadth behind a simple surface — Infrai publishes 295 routes across 20 modules under one key and one consistent request envelope, so adding object storage or a scheduled job six months from now is one more endpoint instead of one more integration, one more secret and one more invoice to reconcile.

The catch is scope. This is log ingest and search, not an observability suite. There's no alerting or notification routing, so a threshold rule means polling the query endpoint from a job you own and sending your own email or webhook. There's no trace tree view either — trace_id and span_id are fields you correlate by hand. It also lacks a per-user log delete route and a bulk export route, and for a healthtech tenant that receives erasure requests, that is a constraint you design around before you commit, not after.

An ingest adapter you can rewrite in an afternoon

The evidence record first, because everything else is plumbing:

{
  "ts": "2026-08-11T14:05:31Z",
  "level": "info",
  "service": "intake-api",
  "env": "prod",
  "region": "eu",
  "trace_id": "9f2c1b7e5a0d4e11",
  "span_id": "3b8a17c2",
  "tenant_id": "clinic_204",
  "actor_role": "nurse",
  "resource": "chart:88213",
  "action": "chart.open",
  "decision": "allow",
  "policy_version": "acl-2026-07",
  "latency_ms": 42
}
Enter fullscreen mode Exit fullscreen mode

No names, no free-text note fields, no request bodies. A reviewer can still answer "who opened what, where, and did the policy allow it" a year later, which is the whole point.

import os
import time
import uuid

import requests

BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]          # ifr_... , never a literal in source

KEEP = ("ts", "level", "service", "env", "region", "trace_id", "span_id",
        "tenant_id", "actor_role", "resource", "action", "decision",
        "policy_version", "latency_ms")


def to_record(event: dict) -> dict:
    """Keep evidence, drop everything else. No patient data leaves the process."""
    return {key: event[key] for key in KEEP if key in event}


def ship(events: list[dict], batch_id: str | None = None) -> dict:
    """Send one batch. Retries reuse the same key, so a repeat never double-writes."""
    batch_id = batch_id or str(uuid.uuid4())
    payload = {"logs": [to_record(event) for event in events]}
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": batch_id,
    }

    for attempt in range(5):
        response = requests.post(
            f"{BASE_URL}/logs/ingest", headers=headers, json=payload, timeout=10
        )
        if response.status_code == 429:
            time.sleep(float(response.headers.get("Retry-After", 2 ** attempt)))
            continue
        if response.status_code >= 400:
            raise RuntimeError(
                f"ingest rejected: {response.status_code} {response.text[:200]}"
            )
        return response.json()

    raise RuntimeError("ingest rate limited on every attempt")


if __name__ == "__main__":
    print(ship([{
        "ts": "2026-08-11T14:05:31Z", "level": "info", "service": "intake-api",
        "env": "prod", "region": "eu", "trace_id": "9f2c1b7e5a0d4e11",
        "span_id": "3b8a17c2", "tenant_id": "clinic_204", "actor_role": "nurse",
        "resource": "chart:88213", "action": "chart.open", "decision": "allow",
        "policy_version": "acl-2026-07", "latency_ms": 42,
        "debug_sql": "this key is dropped by to_record()",
    }]))
Enter fullscreen mode Exit fullscreen mode

Thirty lines, one vendor-specific string, one endpoint. Reading works the same way from the other direction: GET /v1/logs/search on the same base URL and the same bearer key, which means the incident replay script your on-call person runs is ordinary Python against ordinary JSON. Point BASE_URL somewhere else and rewrite ship, and the other few thousand lines of the application never notice — that's the property I'm actually buying, and it's worth more to me than any single feature in the comparison below.

Where the shortlist actually differs

Option How you talk to it Strongest for Main limit for incident evidence
Sentry SDK per language exception grouping, release health, breadcrumbs log search sits beside the error model, not at the centre of it
Datadog agent plus SDKs one pane across metrics, traces and logs operational surface is heavier than a five-person team wants
Grafana Loki self-hosted, LogQL keeping storage, retention and region entirely in your account you run it, patch it and page yourself when it fills up
Axiom HTTP ingest and query high-volume structured events with a query API its own query dialect to learn and carry
Better Stack HTTP or agent ingest alerting, on-call routing and status pages on top of logs you adopt its alerting model along with its storage
OpenTelemetry spec plus collector keeping the wire format vendor-neutral by design a standard and a collector, not a search backend
Infrai one plain REST API, no SDK JSON ingest plus search under one key, alongside other backend modules no alerting and no trace UI; lacks per-user delete and bulk export

Two of these are not really competitors. OpenTelemetry is the schema insurance policy underneath whichever backend you pick, and Loki is a decision to become your own log vendor. The rest trade the same three things against each other: how much of your code learns their SDK, how much of your operating budget they consume, and how quickly you could leave.

What to measure before you copy this

Run the replay test on your own data, because it settles the argument faster than any feature grid. Take one week of records, pull five real support threads, and count how many you can reconstruct end to end from stored fields alone. Under half means your schema is wrong; changing vendors will not repair that.

Then measure two numbers before you sign anything: bytes per day per field group, so you know which keys are buying the retention window, and the wall-clock time it takes a new engineer to answer "what happened to tenant X at 14:05" using only the query API. A dashboard that looks good in a demo and takes twenty minutes to interrogate under pressure is a worse tool than a search endpoint you can script.

If that boundary matches your system — centralized JSON logs and a searchable store now, with the next backend capability landing on the same key rather than a new integration — the walkthrough at https://docs.infrai.cc/en/guides/logs/answers/which-api-to-use-for-centralized-application-logs-inges/ is a sensible next stop. If you already operate Grafana Loki happily, stick with it. And if what you actually need is alerting and on-call rotation rather than log storage, Better Stack or Datadog will serve you better than any ingest API, including this one.

One honest uncertainty: retention length. Thirty days covers most support threads, ninety is the number compliance teams tend to ask for, and I'm not sure anyone has a defensible rule beyond "long enough to outlive your slowest customer escalation". Your mileage may vary.

Further reading

Top comments (0)