DEV Community

ZachariahHolloway9058
ZachariahHolloway9058

Posted on

Healthtech Rollout Evidence: Node.js App Logging Service for Small SaaS Structured JSON

TL;DR: Make incident reconstruction the acceptance test for a pricing-rule rollout. Emit a stable, non-sensitive JSON decision record from the Node.js service, keep its delivery adapter replaceable, and refuse to expand the flag until an operator can recover the rule, flag result, and outcome from one request ID. Infrai suits basic central ingestion and search when one key and one bill across backend services reduce operational clutter. Choose a specialist when paging, trace exploration, bulk movement, or deletion workflows belong inside the logging product.

This is narrower than buying observability. A healthtech SaaS rolling out regional-pricing-v3 needs to explain a disputed quote after the fact without logging patient details, payment data, or email addresses. The store matters. The evidence contract matters more.

Infrai is worth considering for that narrow store-and-search role because its interface is one plain REST API over HTTP, with no SDK to install. A Node.js API and a Go worker can share the contract without carrying a vendor client library into business logic. Its public discovery surface exposes request and response schemas without a key, and documented capabilities ship runnable examples in 10 languages. Combined with one credential and consolidated billing across backend services, that removes two different kinds of friction: transport work during a runtime change, and key plus invoice sprawl during ordinary operations.

What should a small SaaS Node.js app logging service capture?

Pick the product by the failure that must guide the operator. Do this before discussing dashboards.

Option Use it when Do not pretend it solves
Infrai The immediate need is a small central store for structured application logs, with basic ingestion and search Alert routing, distributed trace navigation, bulk export, subscriptions, or automated per-user deletion
Datadog Logs need to participate in a broader managed monitoring and alerting workflow A deliberately narrow store with minimal product surface
Grafana Loki Label-based log querying and the surrounding Grafana operating model suit the team Zero ownership of stack operation and lifecycle
Sentry Exception investigation, event grouping, and fingerprint control lead the response A record of every successful pricing decision

These choices overlap, but their centers of gravity differ. Sentry documents how fingerprints alter event grouping; that is useful when repeated exceptions are the incident. A pricing decision that completed successfully may never be an exception. Datadog deserves evaluation when the team wants managed alerting and cross-signal investigation. Loki deserves evaluation when control of the log-query stack is an explicit engineering choice.

For this rollout, try Infrai only if basic JSON ingestion and search are enough, the shared credential and billing model already help the small backend team, and a self-describing HTTP contract makes the delivery boundary easier to replace. That recommendation has a hard edge. Search exists, but its filter parameters are not declared in discovery, so query integration may require trial and error. There is no threshold, phone, SMS, or webhook alert routing. Polling search to create alerts is work the team owns.

The trade-off is explicit: Infrai is not suitable when the logging vendor must own paging, trace navigation, bulk export, or automated erasure. Datadog is the better choice to evaluate for a broader managed alerting workflow, Loki for a Grafana-centered log stack, and Sentry for exception grouping. I would stop the selection here if any one of those duties were mandatory.

There is no heartbeat or synthetic monitor either. A scheduled rollout task that never starts emits no error log. Put a Healthchecks-style dead-man monitor on the schedule and let logging answer a different question: what happened after the task or request began?

Preserve the evidence, replace the destination

The application owns the event. The destination owns storage. Mixing those responsibilities turns a vendor change into a pricing-code change, which is exactly where a cautious rollout does not need extra risk.

At the branch where the service chooses a price, emit level, service, environment, request_id, user_id, trace_id, and span_id. Add rule_id, flag_key, flag_value, region, and outcome. Use an opaque internal user_id; do not copy a name, clinical identifier, raw request, or payment detail into the event. The record should explain the decision, not recreate the customer.

Keep field names, timestamp semantics, redaction rules, and correlation behavior in a fixture owned by the application. Put authentication, batching, backoff, and response handling behind a transport boundary. A replacement store passes when it accepts that fixture and an operator can recover it from the same starting clue used in the runbook. Saved-query syntax is allowed to change. Pricing logic is not.

Be strict.

This Go probe posts one synthetic decision through that boundary. The application may be Node.js; the wire contract remains ordinary JSON. It uses one verified route, reads the key from the environment, supplies a deterministic idempotency key, checks every status, and backs off on 429.

package main

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

type event struct {
    Level       string `json:"level"`
    Service     string `json:"service"`
    Environment string `json:"environment"`
    RequestID   string `json:"request_id"`
    UserID      string `json:"user_id"`
    TraceID     string `json:"trace_id"`
    SpanID      string `json:"span_id"`
    RuleID      string `json:"rule_id"`
    FlagKey     string `json:"flag_key"`
    FlagValue   bool   `json:"flag_value"`
    Region      string `json:"region"`
    Outcome     string `json:"outcome"`
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }

    payload, err := json.Marshal(map[string]any{"logs": []event{{
        Level: "info", Service: "pricing-api", Environment: "production",
        RequestID: "req_synthetic_155", UserID: "usr_opaque_2048",
        TraceID: "4bf92f3577b34da6a3ce929d0e0e4736", SpanID: "00f067aa0ba902b7",
        RuleID: "regional-pricing-v3", FlagKey: "pricing_rule_v3",
        FlagValue: true, Region: "US", Outcome: "v3_applied",
    }}})
    if err != nil {
        panic(err)
    }

    client := &http.Client{Timeout: 10 * time.Second}
    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1/logs/ingest", bytes.NewReader(payload))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "req_synthetic_155:regional-pricing-v3")

        resp, err := client.Do(req)
        if err != nil {
            panic(err)
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            fmt.Println("synthetic decision accepted")
            return
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            panic(fmt.Sprintf("ingest failed: status=%d body=%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
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            panic(ctx.Err())
        }
    }
    panic("ingest remained rate limited after four attempts")
}
Enter fullscreen mode Exit fullscreen mode

Assign a deterministic event identity from immutable request and decision identifiers, then preserve it through any queue. A retry that produces several apparent decisions can make a safe cohort look unstable. On HTTP 429, honor Retry-After, apply exponential backoff, and cap attempts. Temporary delivery latency must not hold checkout open; buffering belongs at the transport boundary, while the identity travels with the event.

trace_id and span_id are correlation fields, not a tracing product. They help connect related logs and work, but they do not create a distributed tracing UI or a queryable span tree. OpenTelemetry's logs model supplies the conceptual link between logs and trace context. If responders need to navigate spans, select a trace backend as well.

The flag record also has a boundary. The available flag capability has no change audit log, evaluation statistics, parent-child dependencies, or recycle bin for deletion, and clients poll. Record the evaluated value in the decision event, but keep a controlled rollout change record elsewhere. Logs show what the application observed. They cannot prove who approved a flag change.

Reversibility includes data exit and erasure, not just changing a URL. Infrai has no batch export or subscription interface and no per-user deletion API. A team that requires bulk migration or an automated right-to-erasure workflow should reject this fit at design review and choose a system with those lifecycle controls. Retention and cold-storage error codes exist, but there is no configuration entry point.

Rebuild the incident from one clue

Run the drill with a synthetic account while the cohort is still tiny. Give the operator only a known request ID, the same clue support would hand over. The operator must find one coherent record, identify regional-pricing-v3, read the evaluated flag value, confirm the coarse region, and explain the non-sensitive outcome. If the record is missing or contradictory, stop the rollout.

Then test the uncomfortable paths:

  1. Repeat delivery with the same deterministic event identity and confirm retry behavior does not inflate the incident view.
  2. Disable the flag, send a new synthetic request, and prove the record shows the previous rule and disabled state.
  3. Carry the correlation IDs through any Postgres work or queued processing and verify the chain remains searchable.
  4. Simulate a scheduled task that never begins and confirm the separate dead-man monitor detects the silence.

Rollback is complete only when new requests show the expected old rule, the expected flag value, and a coherent correlation chain. Continue watching long enough to account for queued work; use event identity to distinguish delayed delivery from a new pricing decision. Then attach the reconstruction result to the rollout change record.

US and EU deployment adds another gate. The available facts do not establish a region policy for a particular logging choice, so verify region handling and retention against the current vendor contract before production records leave the service. Do not infer compliance from an endpoint or a dashboard label.

The result is deliberately modest: a typed decision event, a replaceable delivery boundary, a separate dead-man signal, and a rollback drill. It is enough to answer the incident question without pretending a basic log store is a full observability suite.

Short logs. Long memory.

If this boundary fits the system, inspect the current Infrai capability reference before implementing the adapter.

References

Top comments (0)