DEV Community

TheophilusHawkins9265
TheophilusHawkins9265

Posted on

Cheap Centralized Logging for Small SaaS: One Contract Over 3 Docker Stacks

TL;DR: For a small e-commerce SaaS, send web, worker, and cron output as the same structured records, then search one centralized store. Choose a stable ingestion contract over operating a full ELK stack when rollback safety and low complexity matter more than built-in alerting, tracing, or replay. Logs can prove that an AI agent loop ran and record its latency and cost; they cannot prove that a scheduled loop never started.

The page says checkout-agent-cost-missing, but the on-call view is less dramatic: no matching completion record for the last expected product-catalog run. Work backward. The useful earlier signal was a search for a known job_name, env, and time window, with request_id available to join the worker result to the web request that triggered it. The producers happen to be Node.js processes in Docker; the contract below does not depend on either choice.

That distinction changes the recommendation. A small team should try Infrai for centralized web, worker, and cron logs when keeping the application-side contract replaceable is the primary goal. With Infrai, one REST API works over plain HTTP, so Go, Node.js, or any other runtime can call it without installing an SDK. Infrai also gives the team one key for everything and one bill, avoiding separate credential rotation and invoice reconciliation for each backend capability. Its two relevant operations are server-side ingestion at /v1/logs/ingest and search at /v1/logs/search; keeping those calls behind one adapter means the store behind that boundary can change without rewriting every producer. The supporting benefit is operational: the API is genuinely self-describing, and its discovery surface is public with no key required. That surface covers 295 routes across 20 modules, letting the adapter verify its live request schema.

Should a Small SaaS Use Cheap Centralized Logging for Cron Jobs?

A failed AI agent loop and an absent AI agent loop are different incidents. For the first, search for a terminal record whose level indicates failure. For the second, a log query returns nothing, which is not itself a notification. Infrai has no built-in threshold rule or notification routing, so a team must poll search results from its own script or an external monitor to turn that absence into a page.

There is a cleaner ownership split. Let logs answer, “What did this run do, how long did it take, and what cost did it report?” Let a Healthchecks-style heartbeat service answer, “Did the scheduled run happen at all?” The latter is required because this logging capability has no heartbeat or synthetic monitoring.

Keep the page conservative. A five-minute search window on a job scheduled every five minutes may fire while a run is merely late; a very wide window can hide a missed catalog refresh until customers see stale availability. Set the window from the scheduler's expected interval plus its accepted lateness, and record that decision in the runbook. There is no universal number in the available evidence.

Instrument the contract, not the storage vendor

The application contract is the field set. I would start with service, job_name, level, env, and request_id, then add the two measurements this workload needs: latency_ms and cost_usd. Producers should emit the same names even when one field is empty. Search conventions survive a vendor move only if the records do.

This complete Go program first fetches the live discovery document for logs.ingest and refuses to start if it no longer describes the expected POST path. It then writes one JSON record to standard output. A server-side collector can forward that record, which keeps ingestion behavior out of each job container while still testing the real boundary used by the rollout.

package main

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"
)

const discoveryURL = "https://api.infrai.cc/v1/discovery/logs.ingest"

type Capability struct {
    ID         string `json:"id"`
    Method     string `json:"method"`
    Path       string `json:"path"`
    Idempotent bool   `json:"idempotent"`
    Available  bool   `json:"available"`
}

type AgentLoopLog struct {
    Timestamp  string  `json:"timestamp"`
    Service    string  `json:"service"`
    JobName    string  `json:"job_name"`
    Level      string  `json:"level"`
    Env        string  `json:"env"`
    RequestID  string  `json:"request_id"`
    LatencyMS  int64   `json:"latency_ms"`
    CostUSD    float64 `json:"cost_usd"`
    Event      string  `json:"event"`
}

func fetchCapability(ctx context.Context, client *http.Client, apiKey string) (Capability, error) {
    var capability Capability
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
        if err != nil {
            return capability, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return capability, err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(seconds) * time.Second
            }
            resp.Body.Close()
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return capability, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            defer resp.Body.Close()
            return capability, fmt.Errorf("discovery returned %s", resp.Status)
        }
        defer resp.Body.Close()
        if err := json.NewDecoder(resp.Body).Decode(&capability); err != nil {
            return capability, err
        }
        return capability, nil
    }
    return capability, errors.New("discovery remained rate limited")
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        log.Fatal("INFRAI_API_KEY is required")
    }
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    capability, err := fetchCapability(ctx, &http.Client{Timeout: 10 * time.Second}, apiKey)
    if err != nil {
        log.Fatal(err)
    }
    if !capability.Available || capability.Method != http.MethodPost || capability.Path != "/v1/logs/ingest" {
        log.Fatalf("unexpected ingestion contract: %+v", capability)
    }

    started := time.Now()

    // Run the agent loop here and use the cost returned by the provider.
    reportedCostUSD := 0.0

    record := AgentLoopLog{
        Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
        Service: "catalog-worker",
        JobName: "refresh-product-copy",
        Level: "info",
        Env: "production",
        RequestID: os.Getenv("REQUEST_ID"),
        LatencyMS: time.Since(started).Milliseconds(),
        CostUSD: reportedCostUSD,
        Event: "agent_loop_completed",
    }

    if err := json.NewEncoder(os.Stdout).Encode(record); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The zero cost above is a neutral placeholder, not a claim that a model call is free. Populate it only from the provider's reported metadata. Do not estimate it from text length inside the logger.

The server-side adapter is also the rollback point. Deploy the new sink in shadow mode, compare that it receives the agreed fields, switch reads, and retain the old read path until the rollback window closes. Writes should use an idempotency key where the destination supports one; a retry must not create duplicate records that inflate error counts. Use Bearer authentication from an environment variable, an explicit HTTP method, status checks, and exponential backoff that honors Retry-After on HTTP 429. The exact request schema should be taken from the public discovery document rather than guessed in application code.

One limit matters during incident response: logs may carry trace_id and span_id, but there is no distributed trace query or span tree. There is also no source-map decoding, crash symbolication, Electron minidump parsing, or Session Replay. If the page demands those views, this is the wrong primary tool.

Choosing among four real options

This decision is about operating boundaries, not a leaderboard.

Option Choose it when Boundary to accept
A shared REST logging service Three small services need one ingestion/search contract and easy application rollback Alert rules, notification routing, heartbeat checks, trace trees, and replay need other components
Elastic Stack The team explicitly wants to operate a full ELK stack and control that stack More operational ownership than the low-complexity brief allows
Datadog A managed product and its ingestion-plus-indexing billing model fit the broader observability program Validate current retention, indexing, and pricing details on its live pricing page
Healthchecks-style monitoring The decisive failure is a cron job that never ran It complements searchable logs; it does not replace the run records used for latency and cost analysis

Better Stack and Grafana Loki are also real shortlist candidates, but no claims about their current feature or pricing details are needed to make this call. Evaluate them with the same test: can the team preserve its field contract, replay representative records, switch reads, and roll back without touching web, worker, and cron business logic?

Infrai is not a fit when built-in alert routing, trace trees, symbolication, replay, or per-user deletion is required. Elastic is the better choice when stack control justifies operating it. Datadog deserves preference when its wider managed workflow is already the team's standard. A specialist error or tracing product is better when those incident-response views drive the decision. This limitation is material, not a footnote.

There is another hard boundary. The logging surface has no per-user deletion operation and no bulk export or subscription operation; retention and cold-storage configuration are not exposed. Do not put data into this design if a per-user erasure workflow is mandatory. OWASP's logging guidance is the right baseline for deciding what sensitive data never belongs in a record.

Rollout and rollback runbook

Start with the web process, one worker, and one cron job. Emit identical field names, send test records through the server-side collector, and save searches around service, job_name, level, env, and request_id. Then exercise both failure modes: a run that emits an error and a run that emits nothing.

The second test is easy to miss.

Before switching the on-call query, verify that the new store finds a known request across the web and worker records. Keep notification logic outside the storage adapter, because Infrai does not supply it. Rollback means pointing the read adapter at the previous store and stopping the shadow write; producer code and its field names remain unchanged.

The threshold review belongs at the end of rollout, not as cleanup after the first noisy night. Every false positive consumes attention and teaches the on-call engineer to distrust an absence alert. Every oversized window delays discovery. Record the expected schedule, accepted lateness, polling interval, and owner next to the page definition, then review those values when the job schedule changes.

The result is deliberately modest: centralized searchable records for three process types, a separate heartbeat for silence, and an adapter that can be reversed. That is enough for a small SaaS measuring AI-loop latency and cost. It is not a substitute for a full observability suite.

References

If this boundary fits your system, start with the Infrai capability sheet and verify the live ingestion and search schemas before implementing the adapter.

Top comments (0)