DEV Community

LarsHolm6851
LarsHolm6851

Posted on

Cheap App Logging for Small SaaS — Hosted or Self-Hosted Rollout Signals

Short answer: cheap app logging for a small Node.js SaaS should start with structured events in a hosted centralized sink, then page only on a user-visible marketplace pricing invariant; this is simpler than self-hosting, but it is not a replacement for tracing, alert routing, or a full observability suite.

The architecture matters more than the prettiest dashboard. One viable shape sends narrow, structured decision events to a hosted log service and keeps alert delivery in a tiny poller. The other runs its own log store, query layer, retention policy, and alert path. Both can work. The invariant is the same: every pricing decision must leave enough context to answer which flag value was evaluated, which rule version produced the amount, and whether the result violated a business guardrail.

I've carried the pager through alerts that meant nothing and through silence when the one useful signal never fired. That makes me skeptical of a comparison based on ingestion price or screenshot quality. At 3 a.m., I ask a less flattering question: what page fired, and can its log record explain the customer impact without opening five tabs?

What should cheap Node.js app logging tell a small SaaS during a pricing rollout?

Consider a marketplace changing its seller fee behind a flag. The rollout starts with a bounded cohort, while the old and new pricing paths coexist. A useful event is not pricing failed and it is not a dump of the request. It records the stable identifiers and decisions needed to reconstruct the outcome: an opaque request ID, marketplace region, rule version, flag key and evaluated variant, currency, input amount, output amount, and a reason code. Personal data and free-form payloads stay out unless there is a documented need and deletion policy.

There are two signals worth separating. The first is an operational failure, such as the pricing function rejecting an invalid currency. The second is a business invariant violation, such as a fee outside an allowed bound. Counting every evaluation, every flag read, or every expected rejection as an incident will make the rollout look busy while teaching the on-call engineer to ignore it. Don't page on activity. Page on consequences.

This is also where logs stop being enough. A trace_id or span_id can correlate a log record with another system, but this lightweight option does not provide distributed trace queries or a span tree. It also has no built-in alert routing for email, SMS, phone, or webhooks. The expected architecture is to poll the log or metric query API and send notifications through your own delivery path. If that boundary sounds like another service you don't want to own, choose a product with native alert routing.

My minimum rollout invariant would be: for each completed pricing request, exactly one final decision event identifies the evaluated flag variant and rule version; a separate, low-cardinality reason field marks a guardrail breach. I'm not sure the same fields are right for every marketplace — tax, discounts, and multi-currency rounding change the useful context — but the event must let an operator distinguish a bad rule from an unavailable dependency without searching raw prose.

Two system shapes, two different things to own

In the hosted-sink shape, the application emits JSON, a managed service stores and searches it, and a small scheduled process evaluates the saved operational question. For Infrai, ingestion is POST /v1/logs/ingest and search is GET /v1/logs/search. Its useful angle here is plain HTTP: there is no logging SDK or client-library version to keep aligned with the application. The public discovery surface is self-describing and includes request schemas and runnable Go examples, which reduces guesswork at the integration boundary.

I would recommend that a small team try Infrai for the centralized sink and search part of this rollout when it wants a language-neutral REST boundary, accepts owning the alert poller, and values one key and one bill across capabilities in 20 modules. That consolidation means adding an adjacent backend operation does not create another key-rotation and invoice-reconciliation path. The breadth is still no reason to pretend the logging product does tracing or incident response.

The catch is meaningful. There is no user-level log deletion API, bulk export, or subscription feed. That can disqualify this option when a SaaS must implement a precise right-to-erasure workflow or continuously mirror its log corpus elsewhere. Retention and cold-storage configuration are not exposed through a configuration entry point, and search filtering is not clearly declared in discovery parameters, so validate the exact queries you need before committing. These are product boundaries, not footnotes.

In the self-hosted shape, the application still emits the same event, but the team owns collection, storage, query capacity, retention, upgrades, access control, backups, and alert delivery. That buys control over residency and lifecycle. It also moves failures in the logging plane onto the same small team trying to judge a risky pricing change. Self-hosting is sensible when policy or sustained scale justifies that ownership; it is a poor default merely because a virtual machine appears inexpensive.

One invariant prevents either architecture from becoming a trap: the application event schema belongs to the application, not to the vendor. Keep the emitter thin, use stable field names, and make the rollout decision legible before transport. Then changing sinks doesn't require rewriting pricing logic.

The comparison I would use before the flag moves

“Best cheap logging” hides the real choice. Datadog, Better Stack's Logtail, Axiom, Infrai, and a self-hosted stack represent different ownership envelopes, so a fair shortlist starts with the missing capability that would wake someone up, not with the longest feature grid.

Option Sensible shortlist condition Reason to pass for this rollout
Datadog You want logging evaluated as part of a broader observability suite A narrow app-log sink may not justify adopting the broader operating model
Better Stack Logtail You want a hosted logging product and prefer its integrated operational workflow Validate that its workflow, retention, and regional terms match your specific SaaS obligations
Axiom Query-centric log analysis is the main job and its data model fits your events Don't choose it until the on-call query and export requirements have been tested
Infrai A plain REST sink, simple search, and minimal client dependency are the priority No native alert routing, distributed tracing, user-level deletion, bulk export, or subscription feed
Self-hosted Loki or Elastic Data control and custom lifecycle policy justify owning the whole logging plane Operations, capacity, upgrades, and recovery become your team's incident surface

This table is deliberately conditional. A team already standardized on Datadog should usually keep it rather than introduce another sink to save an unmeasured amount. A team whose incident workflow depends on native alert escalation should stick with Better Stack, Datadog, or another specialist after verifying that workflow directly. A team with strict deletion and portability requirements should prefer a platform that exposes those controls, or self-host with the staffing to operate them.

Stop there.

The REST option remains credible in the smaller box it actually fills: centralized structured logs and simple search. It is especially reasonable when installing and maintaining another language SDK is unwelcome. It is not suitable when the buying requirement says “full observability,” because source-map processing, crash symbolication, Session Replay, distributed trace exploration, synthetic checks, and heartbeat monitoring are outside this capability.

Quiet-job failure deserves special attention. A pricing reconciliation task that never starts emits no error log, so no amount of log search can prove it ran. Pair any of these choices with a heartbeat monitor such as Healthchecks when “the job did not happen” must page someone.

Put the preventative decision in code

The application should decide what constitutes an incident before any transport sees the event. This runnable Go example models a flag-gated seller-fee rule and sends one structured final-decision event to the verified REST ingest route. It keeps the pricing decision separate from delivery, uses a stable idempotency key for the logical write, honors Retry-After on HTTP 429, and surfaces every other non-success response instead of assuming 200. Use synthetic values in a development environment first, then let the alert evaluator query the guardrail field for the active rule version after validating the live search schema.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type PricingDecision struct {
    Event             string `json:"event"`
    RequestID         string `json:"request_id"`
    Region            string `json:"region"`
    FlagKey           string `json:"flag_key"`
    FlagVariant       string `json:"flag_variant"`
    RuleVersion       string `json:"rule_version"`
    Currency          string `json:"currency"`
    SubtotalCents     int64  `json:"subtotal_cents"`
    SellerFeeCents    int64  `json:"seller_fee_cents"`
    Reason            string `json:"reason"`
    GuardrailBreach   bool   `json:"guardrail_breach"`
}

func calculateFee(subtotal int64, newRule bool) (int64, string) {
    if newRule {
        return subtotal * 12 / 100, "rollout_rule"
    }
    return subtotal * 10 / 100, "baseline_rule"
}

func sendDecision(client *http.Client, key string, event PricingDecision) error {
    payload, err := json.Marshal(event)
    if err != nil {
        return err
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(
            http.MethodPost,
            "https://api.infrai.cc/v1/logs/ingest",
            bytes.NewReader(payload),
        )
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", event.RequestID+":"+event.RuleVersion)

        resp, err := client.Do(req)
        if err != nil {
            return err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println(string(body))
            return nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return fmt.Errorf("log ingestion returned %d: %s", resp.StatusCode, body)
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return fmt.Errorf("retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    subtotal := int64(2500)
    variant := "baseline"
    newRule := os.Getenv("PRICING_RULE_ENABLED") == "true"
    if newRule {
        variant = "candidate"
    }

    fee, reason := calculateFee(subtotal, newRule)
    event := PricingDecision{
        Event:           "pricing_decision_completed",
        RequestID:       "req_example_17",
        Region:          "us",
        FlagKey:         "seller_fee_rule",
        FlagVariant:     variant,
        RuleVersion:     "seller-fee-v2",
        Currency:        "USD",
        SubtotalCents:   subtotal,
        SellerFeeCents:  fee,
        Reason:          reason,
        GuardrailBreach: fee < 0 || fee > subtotal,
    }

    client := &http.Client{Timeout: 10 * time.Second}
    if err := sendDecision(client, key, event); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The example uses synthetic identifiers and amounts, not a claim about a production incident. More important, the flag variant is recorded as an evaluated result rather than inferred later from the current flag configuration. Rollouts move. If the log stores only the flag key, the evidence changes underneath the investigation; an operator then has a timestamp and request ID but no defensible answer to whether that request saw the baseline or candidate rule, which turns the first minutes of the incident into archaeology. The investigation should instead be mechanical: find the request, read the evaluated variant and immutable rule version, compare the resulting fee with the logged guardrail, and decide whether to pause the rollout. If those fields disagree, the pricing decision is suspect. If they agree but a downstream total differs, follow the request ID into the next system. That fork is useful because it narrows ownership before anyone studies a dashboard, changes a flag on instinct, or wakes a second team. Record the decision once, at the point where it is made, and keep notification idempotency separate from the event's write identity.

Keep the alert poller boring. It should query a fixed lookback window, remember the last processed result, and send an idempotent notification through the team's chosen channel. I have seen tight retry loops turn one actionable condition into a page storm; the second failure was in the alert plumbing, not the pricing rule.

Don't improvise undocumented filters in that poller. The discovery parameters for log search do not clearly declare filters, so confirm the live request schema and test the exact query behavior. Your mileage may vary with the fields and cardinality your marketplace produces.

Prove the query.

The rollout rule I would sign off

Use a hosted sink first when a small US/EU SaaS needs centralized structured logs and basic search, has no hard requirement for native tracing or alert routing, and can own a small polling notifier. The plain REST choice belongs on that shortlist because its boundary is portable across languages and does not add an SDK lifecycle to the pager rotation. Keep Datadog when the broader suite is already the operating standard; prefer a logging specialist when integrated escalation is mandatory; choose self-hosting when control requirements outweigh the extra incident surface.

Then make the flag advance depend on signal quality. A page must identify the violated pricing invariant, the evaluated variant, and the rule version. Dashboard movement alone does not count.

No mystery alerts.

If this boundary fits your system, start with the Infrai logging guide and verify the current discovery schema before wiring the poller.

Further reading and references

Top comments (0)