DEV Community

BrodyVance2149
BrodyVance2149

Posted on

Simple App Logging Service for Small SaaS — Structured JSON Logs

Short answer: choose the app logging service you can leave without changing the application's event contract, then prove that its US or EU configuration preserves one structured JSON trail from an Express request through Postgres and an AI agent loop. Signal quality is the deciding constraint: a cheap, attractive console is useless if the page cannot lead an engineer to the terminal customer outcome, its latency breakdown, and the raw usage inputs behind the cost estimate.

For a small SaaS, the safest setup is deliberately boring. Write newline-delimited JSON to standard output, keep remote delivery outside the request path, correlate events with stable identifiers, and treat the destination as a replaceable query and retention layer. Don't choose from screenshots. Start by exporting a fixture from one candidate, replaying it into another, and asking whether the same incident questions still work.

This is a migration-first decision, because lock-in usually becomes visible at the worst possible time: during an incident, a residency review, or a cost review when the team has no spare week to redesign its telemetry. The test is concrete. Can the team move the evidence, preserve its types and timestamps, and reconstruct an agent run without application changes?

Rehearse alert failure before production rollout

Deploy the contract and exporter to staging, then inject the terminal failure represented by the fixture. One actionable notification should fire from the terminal customer outcome, not from each internal attempt. Walk from the notification to a representative run, order its steps, find the slowest model, tool, queue, or Postgres operation, and recover the usage inputs. Replay the recovered retry and confirm that it remains searchable evidence without producing another page.

Ask what page fired.

An alert saying only "error count increased" leaves the responder to discover whether traffic increased, a retry became common, or completed runs actually fell. A useful notification identifies the service and environment, the terminal outcome, the evaluation window, and a stable grouping key. Dashboards are secondary — averages hide awkward tails, and a wall of charts does not explain which condition crossed a boundary.

Measure delivery health separately from application health. Compare events accepted near the producer with events searchable at the destination while accounting for batching. Silence during active request traffic is a telemetry symptom; silence when there is no traffic is not. Keep this on a non-paging operational view unless missing evidence would make the primary customer alert blind.

Then move a small production slice. Watch request latency, emitted event counts, destination counts, and parsing failures during the change. Asynchronous export protects the request path from destination latency, but it introduces a queue whose memory and loss behavior need explicit bounds. If those bounds are crossed, the application should preserve its customer-facing behavior while the delivery-health signal shows that evidence is at risk.

No drama. Just proof.

Migration begins with exported evidence

Before evaluating search syntax, create a sanitized fixture with three runs: one success, one tool retry that recovers, and one terminal failure. The fixture should include the Express request completion, relevant Postgres timing, each meaningful agent step, and a final run outcome. It should not include prompt bodies, credentials, raw SQL, returned database rows, arbitrary customer text, email addresses, or URL query strings. Hiding a field in a saved view does not remove it from stored data.

Import that fixture, run the investigation, export it, and inspect the result. Timestamps must retain their meaning, numeric duration and usage values must remain numeric, and request_id plus agent_run_id must remain exactly searchable. Nested JSON should not silently become an opaque string. The exported copy must be usable rather than merely downloadable; replay it into a neutral test program or a second store and repeat the questions.

The pass criteria are narrow on purpose:

Gate Evidence Reject when
Correlation One run can be ordered by request and run ID Identifiers disappear, mutate, or cannot be searched exactly
Type fidelity Durations and usage remain numeric after export Values become decorated strings such as "2400ms"
Residency The configured ingestion and storage region meets policy Region, backups, deletion, or support access stays ambiguous
Noise A recovered retry is searchable but does not page Attempts cannot be separated from terminal outcomes
Portability The exported fixture reproduces the investigation Export drops fields, timestamps, or ordering evidence

There is no universal service winner in this table. A managed service is not suitable when policy requires telemetry to remain inside infrastructure the team controls, or when the required region and access terms cannot be established. Stick with a self-managed store when data control and custom retention are hard requirements and the team can own capacity, upgrades, backups, and access control. A managed destination fits when reducing that operational load matters more, but it still has to pass the same fixture.

The catch is toil.

Self-management moves toil into the team; a hosted destination moves constraints into a contract, region menu, query model, and billing dimensions. Neither arrangement makes weak events useful.

How should a small SaaS connect Node.js Express Postgres structured JSON logging?

Keep the application contract smaller than the destination schema. A useful core event contains a timestamp, service, deployment environment, event name, outcome, request ID, and agent-run ID. Add a trace ID when a trace exists. Add numeric duration and usage fields only where they mean something, rather than filling every record with zeroes, and keep the applicable model or tool identifier when it answers an operational question.

Use state transitions rather than prose assembled from variables. http.request.completed, agent.step.completed, and agent.run.completed are easier to validate and aggregate than a message such as "agent finished after several things happened." One terminal event should say whether the customer-visible run completed. Step events explain where its time went. A relevant database event contributes Postgres duration without copying SQL or results into the logging stream.

OpenTelemetry treats logs as an observability signal and defines relationships that can correlate a log record with a trace and span. That gives a language-neutral path for correlation when tracing is present. Logs explain events, traces expose a path, and metrics evaluate bounded rates and distributions; forcing unstructured log text to do every job produces more parsing and usually more noise.

The following Go verifier is intentionally destination-agnostic. Feed it newline-delimited JSON from a staging fixture or an export. It checks the fields needed to follow a run without teaching the application a commercial query language.

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
)

type Event struct {
    Timestamp   string `json:"timestamp"`
    Service     string `json:"service.name"`
    Environment string `json:"deployment.environment"`
    RequestID   string `json:"request_id"`
    AgentRunID  string `json:"agent_run_id"`
    EventName   string `json:"event_name"`
    Outcome     string `json:"outcome"`
    DurationMS  int64  `json:"duration_ms,omitempty"`
}

func main() {
    scanner := bufio.NewScanner(os.Stdin)
    line := 0
    for scanner.Scan() {
        line++
        var event Event
        if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
            fmt.Fprintf(os.Stderr, "line %d: invalid JSON: %v\n", line, err)
            continue
        }
        if event.Timestamp == "" || event.Service == "" ||
            event.Environment == "" || event.RequestID == "" ||
            event.AgentRunID == "" || event.EventName == "" ||
            event.Outcome == "" {
            fmt.Fprintf(os.Stderr, "line %d: missing required field\n", line)
        }
    }
    if err := scanner.Err(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Run an equivalent schema check in application tests and at the trusted collection boundary. Version incompatible changes. A handler emitting latency while another emits duration_ms, or one serializer turning a number into a unit-bearing string, creates investigation work exactly when nobody wants it.

Error grouping needs stable inputs too. Full messages often carry changing identifiers, while a status code can be too broad to distinguish unrelated operations. Form a fingerprint from stable attributes such as error type, operation, and code, and retain the message as context. Sentry's event-grouping documentation is useful primary evidence for how stack traces, exception information, and explicit fingerprints affect grouping; the general design lesson survives a change of destination.

Residency and retention share a governance boundary

Cost is an output of event rate, event size, retention, indexing, query activity, and export traffic. Measure representative daily bytes from the sanitized stream and build a 30-day forecast using each candidate's documented billing dimensions. I'm not sure a public pricing page can capture every consequence of high-cardinality indexing or support access in a specific contract, so settle those questions with written terms and a configured test account.

Do not index every field because the first demo feels fast. Start with service, environment, outcome, region, and a stable error fingerprint, then add an indexed field only for a named incident question. Request and run IDs still need exact lookup, but their high cardinality makes the destination's indexing behavior part of the test. Cheap ingest can coexist with unacceptable query, retention, or export economics, and a low estimate cannot compensate for a failed residency gate.

Sampling is another place where a tidy bill can destroy the denominator. It can be reasonable for repetitive successful step events, provided the policy preserves terminal failures and enough successful runs to evaluate a rate. Never sample terminal outcomes blindly. If failures survive while nearly all successes disappear, the resulting view becomes alarming without becoming informative.

Cost attribution for an AI agent loop should retain raw usage counts supplied by the relevant API and the applicable model identifier. Calculate money from a separate, versioned rate table, because rates and accounting rules can change independently of an application deployment. Reconcile the calculation with billing exports. Your mileage may vary as provider accounting details change, but versioned inputs make the discrepancy explainable.

Preserve event compatibility during rollback

Define rollback before rollout: disable the new remote exporter, retain local structured output, and route the same event contract through the previous collection path. Do not make the application switch event names or field types to satisfy a destination. If rollback requires an application release that rewrites every log call, the abstraction boundary is in the wrong place.

After rollback, replay the fixture through the restored path and repeat the terminal-failure drill. Confirm that no duplicate page is created during overlap, the correlation chain remains intact, and the export still reproduces the investigation. Only then remove the old delivery path according to the team's retention and deletion policy.

This selection method deliberately favors evidence quality and reversibility over a long feature list. It is not suitable for a team that needs a specialized analysis workflow the small portable contract cannot represent; in that case, choose the system that supports the required analysis and document the migration cost openly. For a small SaaS measuring AI agent latency and cost, though, the best simple logging service is the one that passes the region, investigation, noise, forecast, and exit gates with the least operational burden.

References

Top comments (0)