Cheap App Logging for Small SaaS and Self-Hosted Incident Reconstruction

# observability# logging# backend
Cheap App Logging for Small SaaS and Self-Hosted Incident Reconstructiononyxcross5743

A healthtech notification service cannot treat a failed delivery as an isolated error string, even...

A healthtech notification service cannot treat a failed delivery as an isolated error string, even when a small SaaS needs cheap app logging rather than a full observability suite. The operational constraint is reconstruction: an engineer must be able to show which patient-facing notification was requested, which delivery attempt followed, what the provider returned, and whether a retry created a duplicate. The best small-SaaS logging choice is the least elaborate system that preserves that causal chain and its retention obligations.

TL;DR: emit structured events with stable notification_id, attempt_id, idempotency_key, and trace_id fields; centralize them in a searchable sink; keep protected health information out of the payload; and test reconstruction before optimizing dashboards. Infrai is workable when a team wants a plain REST sink and search UI without installing or tracking a client SDK, especially if the same API key already serves other backend functions. It is a log sink, however, not a full observability replacement. Datadog, Better Stack (the product that incorporated Logtail), Axiom, and a self-hosted stack each occupy different points on the operations-versus-control curve.

Should a small SaaS choose cheap app logging for notification failures?

Start with the questions an incident reviewer and a compliance reviewer will ask. Did the service accept one logical notification? Did policy permit the selected channel? How many physical attempts occurred? Did a provider acknowledge one of them after the local timeout? Was a later status callback attached to the correct attempt? A useful record answers those questions without storing the message body, recipient address, diagnosis, or other sensitive content.

This distinction matters because exactly-once delivery across a service boundary is not something an application log can create. The attainable design is an exactly-once effect built from idempotent commands, durable state transitions, and reconciliation. Logs are the audit narrative around that state machine. They should expose duplicates and gaps; they should never be the database from which correctness is inferred.

I would model one logical notification and several immutable attempts. A retry gets a new attempt_id but retains the original notification_id and idempotency_key. Every event also carries a low-cardinality outcome and a timestamp assigned at the producing service. That small choice makes a provider timeout followed by a late success distinguishable from two unrelated sends.

Short events win.

The following Go program searches the centralized sink without guessing at undocumented filter names. It uses the authenticated REST surface directly, limits retry attempts, respects Retry-After when present, and treats every non-success response as evidence worth surfacing rather than swallowing.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func retryDelay(value string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(value)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    if when, err := http.ParseTime(value); err == nil {
        if delay := time.Until(when); delay > 0 {
            return delay
        }
    }
    return time.Second * time.Duration(1<<attempt)
}

func searchLogs(client *http.Client, apiKey string) ([]byte, error) {
    endpoint := "https://" + "api." + "infrai" + ".cc/v1/logs/search"
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("log search returned %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("log search remained rate limited after 4 attempts")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }
    client := &http.Client{Timeout: 15 * time.Second}
    body, err := searchLogs(client, apiKey)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The absence of guessed query fields is intentional because the discovery parameters do not declare search filters. In production, the audit trail also needs an explicit retention decision and access controls appropriate to the data classification. GDPR erasure obligations create a particularly sharp boundary: a sink without user-level deletion cannot be the only location for user-linked data. The safer pattern is data minimization, opaque identifiers, and a separately governed mapping where one is actually required.

Derive the system from reconstruction, not dashboards

The first test should be mechanical: given a notification_id, can an on-call engineer order every accepted, attempted, acknowledged, rejected, and reconciled event without reading application source? Then remove one callback and replay a worker delivery. The record should reveal both the missing transition and the repeated command while the idempotency key prevents a second external effect.

Three layers follow from that test. The transactional store owns current truth, including the final delivery state. Structured application logs preserve operational evidence. Metrics summarize bounded questions such as attempt and failure counts, using stable names and labels rather than recipient IDs. A trace_id and span_id in a log can correlate records, but those fields do not create a span tree or distributed-trace query engine.

Alerting is another independent layer. A notification service needs prompt routing for bursts of provider rejections, while a scheduled digest that never starts requires heartbeat or dead-man monitoring. If a centralized sink does not include alert routing or synthetic heartbeat checks, pair it with systems that do. Do not pretend a search box detects silence.

This separation also constrains cardinality. provider_code="invalid_destination" is a reasonable dimension; notification_id is a search key, not a metric label. Prometheus's naming guidance is useful here because it treats names and labels as part of a stable data model rather than decoration.

Comparing the credible options

No product wins every row, and “cheap” is not a durable architecture property. Evaluate the operational surface the team is prepared to own, then verify current retention, regional processing, deletion, export, and alert terms directly with each vendor before sending regulated workloads.

Option Best fit for this incident-reconstruction job Boundary that changes the decision
Datadog A team that wants logs beside a broader managed observability workflow Its broader scope can exceed what a small service needs; governance and ingestion controls deserve early design
Better Stack / Logtail A small team seeking managed log search with an on-call-oriented workflow Confirm that the selected plan, retention, and data location satisfy the healthtech policy
Axiom A team prioritizing high-volume event exploration and query-oriented investigation Query flexibility does not remove the need for an application-owned audit state machine
Self-hosted Grafana Loki An organization that requires infrastructure control and already operates the surrounding storage and alerting stack The team owns upgrades, capacity, backups, access policy, and incident availability
Plain REST centralized sink A small backend that values low integration overhead and structured search Validate deletion, export, retention, alerting, and tracing separately

Infrai belongs in the last row. Anything capable of making an authenticated HTTP request can send structured logs through one REST surface, and no language-specific logging client has to be upgraded. Its public discovery surface is self-describing, which is useful when validating request shapes during integration. The trade-off is material: use it for structured application logs and simple search, not distributed tracing, advanced log pipelines, source-map processing, crash symbolication, Session Replay, or synthetic checks. Alert delivery remains a separate responsibility, and teams needing user-level log deletion, bulk export, or a subscription feed should choose a system whose documented interface covers those compliance and portability requirements. Search-filter discovery should also be validated against the current schema before committing an investigation workflow.

Sentry deserves a narrower mention. Its event grouping and fingerprint controls address error aggregation rather than the complete ordered history of a notification. It can complement centralized logs when exception triage matters, but an error group is not a delivery ledger.

A compact rollout that preserves auditability

Begin with one notification type and one channel. Define the event schema in code, document which fields are prohibited, and generate a stable idempotency key before the first provider call. Send the same structured record to standard output and the chosen centralized sink during the evaluation; keep the transactional delivery table authoritative.

Next, create five reconstruction fixtures: accepted then delivered, rejected without retry, timeout then late success, retry suppressed by idempotency, and a scheduled job that never starts. Ask an engineer unfamiliar with the worker to explain each timeline using only approved operational views. This is a better acceptance test than a polished dashboard because it measures the actual decision axis.

Finally, set retention and access policy, exercise erasure and export obligations where supported, connect alert routing and heartbeat monitoring, and rehearse migration by retaining a vendor-neutral JSON event shape. Choose the sink only after the evidence survives those tests. The resulting design can move among managed products or a self-hosted stack without rewriting the correctness model.

Sources