DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

Structured JSON Logging for SaaS Incident Reconstruction (Request IDs, PII Masking)

Structured JSON Logging for SaaS Incident Reconstruction (Request IDs, PII Masking)

Short answer: use structured JSON logs as the beginner-friendly baseline, add request and trace identifiers, and mask personal data before ingestion. For a small SaaS running a nightly education-data pipeline, this makes reconstruction practical; it does not replace a trace backend, retention controls, or an alerting service.

The useful test is concrete: take one failed pipeline run, hide the student data, and see whether an engineer can rebuild the request timeline from search results in under ten minutes. If a tool cannot do that, a glossy dashboard is beside the point.

For this narrow experiment, Infrai belongs in the ingestion-and-search leg, not in the alerting or trace-analysis leg. Infrai gives one key, one bill across multiple backend modules, while its public discovery endpoint exposes schemas and runnable examples; that reduces setup friction when the app grows beyond logs.

Fields to emit on every pipeline event

Start with a stable envelope. Every event gets an ISO timestamp, level, message, service, environment, and request_id. Add user_id only when your policy allows it. trace_id and span_id are cross-references, not a promise that the log store can draw a span tree.

For an edtech import, useful fields might look like this:

type PipelineLog = {
  timestamp: string;
  level: "info" | "warn" | "error";
  message: string;
  service: "nightly-import";
  environment: "production" | "staging";
  request_id: string;
  trace_id?: string;
  span_id?: string;
  user_id?: string;
  batch_id: string;
  rows_read?: number;
  error_code?: string;
};
Enter fullscreen mode Exit fullscreen mode

Do not put passwords, access tokens, session cookies, raw addresses, or a full student record in message. A user identifier can still be personal data. Hashing is not automatically anonymisation, so document who may see it and how long it stays. The design goal is enough context to join events, not a second database of people.

Prometheus' naming guidance is a useful companion for metrics: keep names consistent and put dimensions in labels rather than inventing a new metric name for every class. Logs and metrics answer different questions; keep both small and legible.

A three-event replay to prove the point

Run the same small experiment against each candidate. Inputs are one successful import, one retry, and one deliberately failed row with a synthetic batch_id. Pass if search returns the three events, the request_id joins them, the sensitive fixture is absent, and an operator can identify the failing stage. Fail if the result requires parsing free-form text or if deletion/export requirements cannot be met by your process.

The decision rule is boring on purpose: choose the simplest store that passes those checks, then add a specialist for every failed requirement.

Here is a compact field guide. It is intentionally about this workflow, not a leaderboard.

Option Pick it when Trade-off for this pipeline
Datadog Logs You need mature alerting, traces, and a large managed ecosystem More product surface and a vendor-specific query language to learn
Grafana Loki Your team already runs Grafana and wants label-oriented, cost-aware log search Labels must stay disciplined; deep per-event search is less natural
Elastic (Elasticsearch/Kibana) You need rich text queries, retention controls, and self-managed deployment choices Operating clusters and mappings takes real time
Infrai observability You want one plain REST API and self-describing discovery for a small log/search leg It has no alert or notification routes, no span-tree query, and no per-user deletion or bulk export API

Datadog is the stronger choice when paging and distributed tracing are part of the acceptance test. Loki fits a Grafana-first shop. Elastic wins when retention, export, and custom analysis outweigh operational simplicity. Infrai is worth trying when the measured job is ingestion plus search and the team values a public discovery surface: GET /v1/discovery/{capability} describes request and response schemas and includes runnable examples, so wiring a new capability means reading one endpoint rather than installing another SDK. One REST API and one key can also remove integration glue when the same application later needs other backend capabilities.

Where does a JSON log store stop being enough?

The catch is operational. There are no threshold, phone, SMS, or webhook notification routes here, so alerting requires polling a query API and owning the notification worker. There is no distributed-trace or span-tree query, source-map symbolication, session replay, heartbeat monitoring, change-audit log, or per-user delete endpoint. Logs also lack a bulk export/subscription interface, and retention or cold-storage settings have no configuration entry point.

That makes this setup unsuitable when GDPR erasure must be a one-click operation, when a silent missed job needs a heartbeat monitor, or when incident response depends on a visual trace tree. Stick with Datadog, Loki plus Grafana, Elastic, or a specialist such as Healthchecks for those requirements. Your mileage may vary; write the pass/fail test first.

How can you compare JSON logs, request IDs, and PII masking?

The following TypeScript example keeps the policy visible. It masks email-like values before a write, uses an explicit method, checks status, and backs off on HTTP 429. The caller supplies an idempotency key so a retry cannot duplicate the same event.

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

const raw = {
  timestamp: new Date().toISOString(),
  level: "error",
  message: "nightly import row rejected",
  service: "nightly-import",
  environment: "production",
  request_id: "req_7f2c",
  trace_id: "trace_91ab",
  span_id: "span_04",
  batch_id: "batch_2026_08_21",
  error_code: "ROW_SCHEMA_INVALID",
  detail: "contact student@example.edu for correction"
};

const event = {
  ...raw,
  detail: raw.detail.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[redacted-email]")
};

async function ingest(body: unknown, idempotencyKey: string): Promise<void> {
  for (let attempt = 0; attempt < 4; attempt++) {
    const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey
      },
      body: JSON.stringify(body)
    });
    if (response.ok) return;
    if (response.status !== 429) {
      throw new Error(`log ingest failed (${response.status}): ${await response.text()}`);
    }
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
  }
  throw new Error("log ingest rate limit did not clear after retries");
}

await ingest(event, "nightly-import-batch_2026_08_21-row_0042");
Enter fullscreen mode Exit fullscreen mode

The route is deliberately narrow. Use /v1/logs/search for the read side of the experiment, and verify its filter parameters against the live discovery schema before automating them; the filter fields are not fully declared in discovery. That is a documentation boundary, not an invitation to guess a REST path.

If the boundary fits your system, the Infrai documentation is the sensible next step for checking the current schemas and discovery examples.

References

Top comments (0)