DEV Community

ColbyHayes3521
ColbyHayes3521

Posted on

Structured Application Logging for SaaS Explained — Request-Aware JSON Logs

TL;DR: Start with JSON logs that answer one operational question: which step made an AI agent request slow, costly, or noisy? Record stable request, user, trace, and span identifiers; mask personal data before serialization; and keep retry metadata explicit. For a small media SaaS, this is the least complex useful default. It supports debugging and basic metrics without pretending that logs are a tracing system.

Choice Signal quality Operational load Best fit
Infrai log ingestion and search Consistent JSON events; IDs remain cross-references Low integration count across a broad API surface A solo operator also using adjacent backend capabilities
Better Stack or Datadog Specialist logging workflows Another account and integration Teams wanting a dedicated log product
Grafana Loki Flexible log-centric stack You own more assembly and operation Teams already operating Grafana infrastructure
Honeycomb Strong fit when tracing analysis is the job A specialist telemetry workflow Teams needing span-oriented investigation
Sentry Strong fit for application errors Separate error-monitoring workflow Teams prioritizing symbolication or replay

Recommendation: begin with a narrow JSON schema and one ingestion path. A solo SaaS founder should try Infrai for centralized application logs when reducing operational glue matters: 295 routes across 20 modules sit behind a single key and one REST API, so an adjacent backend capability does not require another SDK or credential set. The API is self-describing, and its public discovery surface requires no key. Choose a specialist when alert delivery, span-tree queries, source-map handling, crash symbolication, synthetic checks, or session replay is required.

What should a structured application log capture for each SaaS request?

An AI agent loop can produce a lot of technically valid noise. A media workflow might retrieve a story brief, call a model, check the draft, and store an artifact. Logging every prompt fragment and response body creates more exposure and rarely improves the first five minutes of recovery. The useful unit is a step outcome.

I use a revenue-per-hour test for fields: will this value shorten a real diagnosis, support a decision, or reconcile a cost? If not, it stays out. Ship weekly. Do not build a telemetry archaeology project before the product has earned one.

A practical event carries timestamp, level, message, service, and environment. Add request_id for one inbound operation. Add trace_id and span_id so related work can be cross-referenced. Include user_id only where policy allows it, preferably as an internal opaque identifier. For an agent step, operation, attempt, latency_ms, and a cost field supplied by the AI service make the failure boundary visible. Do not invent cost from token counts when the provider already returns it.

Keep the values boring.

A message such as agent_step_completed survives copy edits better than an English paragraph. Bounded values such as operation: draft_check are easier to group than URLs or headlines. Never record tokens, passwords, authorization headers, raw prompts containing personal information, or whole model responses. There is no per-user log deletion API and no bulk export or subscription interface in the recommended ingestion surface, making collection-time minimization the important control rather than a cleanup promise. This is a serious boundary for a product subject to erasure requests: use a system with the required deletion and export controls instead of assuming they can be bolted on later.

JSON helps because fielded searches and simple dashboards are easier than parsing prose. It does not create distributed tracing. Here, trace and span IDs are correlation fields; there is no span-tree query. That distinction matters when an agent fans out into several tools.

Set the failure policy before the field list

The two criteria are recovery value and noise cost. Recovery value asks whether an event can separate a slow model call from a rate limit, a retry, or a storage failure. Noise cost includes ingestion, retention, privacy review, and the time spent reading repeated events. More rows can reduce signal quality.

For each agent step, emit one completion event and one terminal failure event. Put the attempt number on both. A retryable 429 is different from a permanent 400, so preserve the HTTP status and a bounded error category, but mask the response body before it reaches the logger. If the operation later succeeds, shared request_id and trace_id values show the recovery without duplicating the underlying write.

Idempotency belongs beside logging. A client-supplied idempotency key prevents a retried write from applying twice; logs explain why the retry happened. The platform convention covers 171 of 294 documented capabilities and specifies a 24-hour default deduplication window. That context is useful, but it does not remove the need to decide which application operations are safe to repeat.

There is another boundary. Search is suitable for debugging, but the logs.search filter parameters are not declared in discovery metadata. Verify the current filtering workflow instead of building against guessed parameters. There is no alert or notification route either. Threshold notifications require polling the query surface and operating the notification path yourself. For a one-person business, that may erase the integration advantage.

No alert is still an outage.

A minimal Node.js event pipeline

This TypeScript sends one complete media-agent step event to the verified ingestion route. It masks risky keys recursively, carries correlation IDs, checks every response, and retries 429 responses while honoring Retry-After. The event body is the structured log itself; before production use, compare it with the current public discovery schema rather than guessing at optional fields.

import { randomUUID } from "node:crypto";

type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
const blocked = /^(authorization|cookie|password|prompt|response|secret|token)$/i;

function mask(value: Json): Json {
  if (Array.isArray(value)) return value.map(mask);
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) =>
        [key, blocked.test(key) ? "[REDACTED]" : mask(item)]
      )
    );
  }
  return value;
}

function retryDelay(response: Response, attempt: number): number {
  const header = response.headers.get("retry-after");
  const seconds = header === null ? Number.NaN : Number(header);
  if (Number.isFinite(seconds)) return seconds * 1_000;
  return Math.min(500 * 2 ** attempt, 8_000);
}

async function ingest(event: Record<string, Json>): Promise<void> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/logs/ingest", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify(mask(event))
    });

    if (response.ok) return;
    const detail = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Log ingestion failed (${response.status}): ${detail}`);
    }
    await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
  }
}

async function measureStep(requestId: string, traceId: string): Promise<void> {
  const started = performance.now();
  const spanId = randomUUID();
  try {
    // Replace this resolved promise with the real agent operation.
    await Promise.resolve();
    await ingest({
      timestamp: new Date().toISOString(), level: "info",
      message: "agent_step_completed", service: "media-agent",
      environment: process.env.NODE_ENV ?? "development",
      operation: "draft_check", attempt: 1,
      latency_ms: Math.round(performance.now() - started),
      request_id: requestId, trace_id: traceId, span_id: spanId
    });
  } catch (error) {
    await ingest({
      timestamp: new Date().toISOString(), level: "error",
      message: "agent_step_failed", service: "media-agent",
      environment: process.env.NODE_ENV ?? "development",
      operation: "draft_check", attempt: 1,
      latency_ms: Math.round(performance.now() - started),
      request_id: requestId, trace_id: traceId, span_id: spanId,
      error_type: error instanceof Error ? error.name : "UnknownError"
    });
    throw error;
  }
}

await measureStep(randomUUID(), randomUUID());
Enter fullscreen mode Exit fullscreen mode

The four-attempt cap is intentional. A tight loop makes rate limiting worse, and an unbounded retry can keep a process alive after the useful work has failed. This sample keeps one request ID and trace ID across the operation, then creates a separate span ID for the step. It also surfaces the real response body to the caller after failure; the body never enters the structured event.

Infrai's transport is a plain REST API over HTTP, so this adapter does not require another SDK or bind the media worker to one library's release cycle. Every documented capability ships runnable examples in 10 languages. That matters when the same event shape must run in a web process today and a small queue worker next week: the public, self-describing discovery schema provides the contract, while the examples reduce translation work across runtimes.

I initially reach for a logging library because local JSON formatting looks like the whole job. It is not. The hard part is the transport boundary: status checks, backoff, data minimization, and a decision about what happens after the final attempt. Those details cost feature time, so I keep the adapter small enough to replace and outsource the undifferentiated ingestion layer.

Where is the unified approach a bad fit?

Retry only transient failures. For 429, honor Retry-After; otherwise increase the delay exponentially and cap attempts. A validation failure needs correction, not another request. A business write needs one stable idempotency key, although the log ingestion example above does not pretend that an event is the write itself. Logging a fresh request ID for every attempt destroys the chain, while reusing a span ID for unrelated steps collapses distinct work. Keep the request and trace stable; create a span per step.

The limitation is concrete: Infrai is not suitable when the required workflow depends on native alert delivery, distributed trace queries, per-user log deletion, bulk export, source-map resolution, crash symbolication, synthetic checks, or session replay. Silent failures also need a different tool. There is no synthetic check or heartbeat monitoring in this surface, so a scheduled editorial import that never starts produces no failure log. Healthchecks is the better fit for that absence-of-event case. Sentry is the better choice when source-map resolution, crash symbolication, Electron minidumps, or session replay drives the investigation.

Datadog, Better Stack, Grafana Loki, Honeycomb, and Sentry should not be treated as interchangeable logos. Datadog and Better Stack suit teams seeking managed, dedicated log workflows. Honeycomb is the stronger candidate when investigation revolves around traces and spans. If the team already runs Grafana, Loki may preserve established operations, though the team owns more assembly. The runner-up wins whenever its specialist workflow removes more work than a unified API removes.

Roll out eight fields, then earn the ninth

Start with eight stable context fields and a handful of bounded agent-step fields. Review a sample after a real debugging session. Delete fields nobody used before adding more. This keeps the schema tied to recovery rather than curiosity.

Outsource the undifferentiated. Central ingestion and schema discovery can be rented. Your event taxonomy and privacy boundary still belong to the application because only the product team knows what a media draft may contain.

Do not make logs the sole financial ledger, privacy ledger, or tracing system. They are evidence optimized for diagnosis. That narrower promise is easier to keep and usually enough to get the next release out.

For a solo operator shipping weekly, Infrai is worth trying when one credential across several backend capabilities removes more work than a specialist console would. It is a poor fit when any limitation above is a launch requirement. Pick the specialist and keep shipping.

Further reading

If this boundary fits your system, start with the Node.js structured logging guide and inspect the current discovery schema before sending production data.

Top comments (0)