DEV Community

ZeligHolloway9071
ZeligHolloway9071

Posted on

Reconstructing Small SaaS Incidents with Node.js Docker Cron Logs

Cheap centralized logging for a small SaaS should start with a strict event schema and a useful retention window, not with a pile of agents. The practical target is evidence: enough correlated events to reconstruct a customer incident across a Node.js web process, Docker, and cron jobs without drowning the search index in debug noise.

Short answer: emit one JSON object per event to stdout, collect every container and cron stream in one searchable store, and make requestId, job, customerId (or a non-sensitive surrogate), status, and durationMs first-class fields. Keep the event vocabulary small. Sample success noise, retain failures longer, and test the fields you need before paying for more volume.

The event contract comes before the search tool

A log line is evidence only if another engineer can join it to the rest of the incident. A timestamp and message are not enough for an order problem. The event needs a stable action, outcome, actor boundary, and correlation key. Use UTC timestamps and numbers as numbers. Avoid serializing an entire request object just because JSON makes that easy.

Start with the failure.

Here is a deliberately boring logger. It has no vendor SDK and no configuration framework. That is a feature for a small service.

type LogLevel = "info" | "warn" | "error";

type LogEvent = {
  ts: string;
  level: LogLevel;
  service: string;
  event: string;
  requestId?: string;
  job?: string;
  customerRef?: string;
  orderRef?: string;
  status?: string;
  durationMs?: number;
  errorCode?: string;
};

export function log(level: LogLevel, fields: Omit<LogEvent, "ts" | "level">): void {
  const record: LogEvent = {
    ts: new Date().toISOString(),
    level,
    ...fields,
  };

  process.stdout.write(`${JSON.stringify(record)}\n`);
}
Enter fullscreen mode Exit fullscreen mode

customerRef and orderRef are intentionally not raw email addresses, payment details, or full request payloads. The application can derive a controlled reference before logging. OWASP's Logging Cheat Sheet calls out sensitive personal data, tokens, and secrets as data that should not be written casually. That is a security rule and a search-quality rule: sensitive payloads create access risk while making the useful fields harder to find. In a real checkout investigation, an engineer should be able to start from a redacted order reference, pivot to the request id, compare the payment outcome, and then inspect the reconciliation run without opening a raw body. That sequence is the design test because it reflects the evidence a support or on-call engineer actually needs, not the fields that happen to be convenient for the application to dump.

Use the same event name for the same operation. checkout.completed, checkout.rejected, and checkout.payment_timeout are easier to count than three free-form sentences. Do not turn every line into an event. A successful health check every five seconds is rarely incident evidence.

The first test is not a load test. It is a reconstruction test. Given a failed checkout, can a person find the request, the downstream result, and the relevant cron reconciliation event with two or three field filters? If the answer is no, a larger logging bill will not fix the design.

The three-stream collection boundary

Treat the three sources as one event stream with different identities. A web request gets requestId. A scheduled task gets job and a run identifier. The Docker container adds deployment context at collection time, such as service and environment. The application should not depend on a particular collector to invent the business fields.

For a cron task, create a run id at the beginning and log the terminal result even if the task finds nothing to process. A missing terminal event is ambiguous: the job may have completed, exited early, or never started. A compact TypeScript example looks like this:

import { randomUUID } from "node:crypto";

export async function runReconciliation(): Promise<void> {
  const runId = randomUUID();
  const started = Date.now();

  log("info", {
    service: "billing-worker",
    event: "reconciliation.started",
    job: "reconciliation",
    requestId: runId,
  });

  try {
    const checked = await reconcileOrders();
    log("info", {
      service: "billing-worker",
      event: "reconciliation.completed",
      job: "reconciliation",
      requestId: runId,
      status: "ok",
      durationMs: Date.now() - started,
      orderRef: `checked:${checked}`,
    });
  } catch (error) {
    log("error", {
      service: "billing-worker",
      event: "reconciliation.failed",
      job: "reconciliation",
      requestId: runId,
      status: "failed",
      durationMs: Date.now() - started,
      errorCode: error instanceof Error ? error.name : "unknown_error",
    });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The requestId name is doing double duty here as a correlation id. That is acceptable for a small system if the convention is documented. At larger scale, use an explicit traceId and keep request and job identifiers separate. The important property is uniqueness within the retention window and consistency across services.

Docker should carry logs to the host or platform collector through stdout and stderr. A container-local file is a poor default for incident evidence because its lifetime and access path are tied to the container. Cron jobs need the same treatment: run them in a container or process whose output reaches the central collector, and attach the job name and run id before the first meaningful operation.

Search should begin with exact fields, then add time and severity. A useful investigation might filter service=billing-worker, job=reconciliation, status=failed, and a narrow time range, then pivot on requestId. Full-text search has a place for an error message, but it should not be the primary join mechanism.

How should a small SaaS search structured logs from Node.js, Docker, and cron jobs?

Centralized logging has two different costs: storing events and making fields fast to query. A cheap design separates those decisions. Index the fields used during an incident. Store the full structured event for the retention period that matches the business need. Do not index every arbitrary key from user input.

Field class Examples Default treatment
Join keys requestId, job, runId, service Index
Decision fields event, level, status, errorCode Index
Investigation context durationMs, region, deployment id Store; index selectively
Unbounded content stack traces, request bodies, free-form messages Store on error; restrict access

Sampling must preserve failure evidence. Drop or sample repetitive successful events only after measuring how they are used. Never sample the only record of a rejected payment, a failed cron run, or an authorization decision. I benchmark this boundary with a replay of representative traffic, measuring event bytes, query latency, and the percentage of incident cases that remain reconstructable.

Your mileage may vary because retention is a policy choice, not a universal number. A payment workflow may need a different evidence window from a low-risk internal dashboard. Write the policy down with the fields that are redacted, the people who can search them, and the deletion rule. “We can search everything forever” is not an observability plan.

Pricing pages commonly distinguish ingestion from indexing, so a low ingestion quote does not by itself describe the cost of a useful search experience. That is why the first budget exercise should count events by level and estimate bytes per event, then separately list indexed fields and query frequency. The exact provider changes; the accounting model does not.

Retention, indexing, and sampling are governance

The smallest implementation can write JSON to stdout. At scale, add a collector with buffering, backpressure, access controls, schema validation, and a durable destination. Keep the application contract stable while the transport changes. A collector can enrich records with container metadata, but it should not guess whether a string is a customer id or a secret.

Add a schema check in CI. It should reject new unbounded fields, raw credentials, and event names that do not match the documented vocabulary. Add a fixture for one successful checkout, one rejected checkout, and one failed reconciliation run. Then assert that a query using the join fields can reconstruct each case.

There is a second scale boundary: logs stop being the best signal for every question. Metrics are better for request rate and error rate. Traces are better for a long request crossing several services. Logs remain the detailed evidence, especially for a single customer incident. Combining all three too early creates config bloat; refusing to add the right signal later creates blind spots.

The operating boundary

Self-hosted storage gives control over retention, indexing, and access, but it makes upgrades, disk pressure, backups, and on-call ownership your problem. A hosted service removes much of that operations work, but its ingestion and indexing model can make verbose defaults expensive. A small team should choose based on who will operate the failure path, not on a screenshot of a search box.

The approach here is not suitable when you need long-term legal discovery, strict cross-region residency controls, or high-cardinality analytics with guaranteed query performance. Use a platform that has those controls when they are requirements. It is also a weak fit if nobody owns retention, redaction, and collector health; in that case, a managed option may be the more responsible choice even if its bill is higher.

Stick with simple stdout JSON when the service count is small and the incident questions are known. Change the transport when buffering, access policy, or durable retention becomes a real requirement. Keep the schema. That is the part worth carrying forward.

References

Top comments (0)