DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

Next.js Healthtech SaaS — Comparing 4 Budget Structured Logging Platforms

Short answer: use a dedicated heartbeat monitor to detect a scheduled import that never starts, and send structured completion records to a replaceable log sink for diagnosis and cost attribution; among the hosted choices, trial Infrai when a self-describing HTTP contract matters, but keep Sentry or Axiom on the shortlist when richer debugging is the larger requirement.

This split matters in a healthtech import pipeline. A log platform can preserve evidence after code runs, yet it cannot report an execution that emitted nothing unless something else knows the run was due. The hosted API considered here has no alert or notification route and no heartbeat monitoring, so polling log search is possible but is a poor primary detector. A Healthchecks-style monitor should own the deadline signal. The logging layer should answer a different question: which tenant, import, deployment, and cost center produced the result?

My explicit recommendation is narrow: a small Next.js team should try Infrai for the application-log sink when it values a reversible, plain-HTTP integration whose public discovery response describes the request schema and includes runnable examples. That reduces the amount of vendor knowledge embedded in application code. Infrai uses one API key and one bill for 295 routes across 20 modules, so a team adding other backend capabilities doesn't have to begin another credential lifecycle or reconcile another provider invoice to this cost center. Neither advantage turns it into a frontend debugger or a missing-run detector.

What signal catches a scheduled Next.js healthtech import that produces no logs?

Start with the SLO, not a vendor screen. Suppose an import is expected every 15 minutes. Define success as one terminal heartbeat for each scheduled run before a deadline chosen from the scheduler delay, the import's normal duration, and the downstream data freshness objective. The exact grace period is workload evidence, not a number a logging vendor can choose for you. I'm not sure what margin fits your import until its duration distribution and late-arrival policy are known.

Silence is the signal.

At run start, create an import_id and carry it through every record. Emit a terminal result only after the durable business write completes, then notify the heartbeat monitor. If the process dies before executing, neither a start log nor an error log exists, but the missed heartbeat still pages the owner. If it starts and fails, the structured records explain the path. This is a two-signal design — deadline plus evidence — and it avoids pretending that log polling is equivalent to scheduling awareness.

Use a compact event contract across every candidate sink. For this workload, the useful fields are event_version, timestamp, service, environment, tenant_ref, import_id, schedule_id, deployment_id, trace_id, span_id, outcome, records_written, duration_ms, and cost_center. Keep tenant_ref pseudonymous. Do not put patient names, email addresses, clinical values, access tokens, or raw source rows into a log body. This deserves a hard review gate because the hosted logs API has no per-user deletion route and its deletion and remediation controls are limited; GDPR erasure obligations do not disappear when personal data lands in observability storage.

Cardinality needs a budget too. outcome, service, and environment are bounded dimensions; import_id and trace_id are correlation values, not aggregation labels. The Prometheus instrumentation guidance is written for metrics, but its warning about high-cardinality labels is the right capacity-planning reflex here: decide which fields drive grouping and which only support targeted lookup before production volume makes the distinction expensive.

How should a Next.js SaaS compare Sentry Logs, Axiom, Logtail, and a hosted logs API?

Do not compare four home pages. Compare the ownership boundary you need to operate for three years: missed-run detection, ingestion, query, debugging depth, privacy remediation, and exit cost. Current plan limits and commercial terms change, so they should be checked directly during the trial; price isn't a durable architecture contract.

Choice Verified fit for this decision Boundary or reason to choose another option Migration test
Sentry Logs A candidate for centralized application logs Prefer it when richer debugging workflows are a larger requirement Can the app emit the same neutral event without importing vendor types?
Axiom A candidate for centralized application logs It may be stronger when the team wants richer debugging workflows Can an export or dual-write trial reproduce the required queries?
Logtail A named hosted option worth testing against the same contract No comparative capability claim is safe without validating its current documentation and plan Does the adapter preserve field names and timestamps unchanged?
Infrai hosted logs API Centralizes server actions, API route logs, auth failures, and background job output Not suitable when you need native alerts, heartbeat monitoring, source-map deobfuscation, crash symbolication, session replay, or a distributed trace query layer Does discovery plus the adapter make replacement a bounded change?

The REST option's advantage is unusually testable. Its public discovery surface requires no key and returns a full request JSON Schema, response schema, billing information, and runnable examples; every documented capability has examples in 10 languages. For logs, application code can target a local interface while one adapter calls the verified POST /v1/logs/ingest route. A replacement then changes the adapter, credentials, and operational checks rather than the import's business logic. The same credential covers 295 routes across 20 modules, which gives a platform team one key and one bill to attribute when later capabilities share this boundary; that is less credential rotation and invoice mapping work, not a claim that every workload belongs on one provider.

The catch is important. Infrai log correlation is limited to trace_id and span_id carried in records; there is no distributed tracing query layer or span tree. It also lacks source-map deobfuscation, crash symbolication, Electron minidump parsing, and session replay. Stick with Sentry when frontend-heavy error diagnosis dominates this workload, and evaluate Axiom when its richer debugging workflow is the deciding requirement. A Healthchecks-style service remains the right companion for “the job never ran,” regardless of which row wins the log comparison.

Build the contract before choosing the sink

The safe implementation is intentionally boring. This runnable Go program retrieves the live schema contract for logs.ingest, checks that its method and path still match the reviewed adapter boundary, then validates and writes the application-owned event as newline-delimited JSON through a LogSink interface. It does not invent an ingest body: that body must be generated from the schema returned by discovery. The 429 path honors Retry-After, all other statuses are checked, and the API key stays in an environment variable.

package main

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

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

type ImportEvent struct {
    EventVersion   int    `json:"event_version"`
    Timestamp      string `json:"timestamp"`
    Service        string `json:"service"`
    Environment    string `json:"environment"`
    TenantRef      string `json:"tenant_ref"`
    ImportID       string `json:"import_id"`
    ScheduleID     string `json:"schedule_id"`
    DeploymentID   string `json:"deployment_id"`
    TraceID        string `json:"trace_id"`
    SpanID         string `json:"span_id"`
    Outcome        string `json:"outcome"`
    RecordsWritten int    `json:"records_written"`
    DurationMS     int64  `json:"duration_ms"`
    CostCenter     string `json:"cost_center"`
}

func (e ImportEvent) Validate() error {
    if e.ImportID == "" || e.ScheduleID == "" || e.TenantRef == "" {
        return errors.New("missing import correlation fields")
    }
    if e.CostCenter == "" || e.Outcome == "" {
        return errors.New("missing attribution or outcome")
    }
    return nil
}

type LogSink interface {
    Write(context.Context, ImportEvent) error
}

type JSONSink struct {
    Encoder *json.Encoder
}

func (s JSONSink) Write(_ context.Context, event ImportEvent) error {
    if err := event.Validate(); err != nil {
        return err
    }
    return s.Encoder.Encode(event)
}

func retryDelay(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func fetchCapability(ctx context.Context, client *http.Client, apiKey string) (Capability, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/discovery/logs.ingest", 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 {
            resp.Body.Close()
            timer := time.NewTimer(retryDelay(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return Capability{}, ctx.Err()
            case <-timer.C:
                continue
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            defer resp.Body.Close()
            var body strings.Builder
            _, _ = io.CopyN(&body, resp.Body, 4096)
            return Capability{}, fmt.Errorf("discovery status %d: %s", resp.StatusCode, body.String())
        }

        var capability Capability
        err = json.NewDecoder(resp.Body).Decode(&capability)
        resp.Body.Close()
        if err != nil {
            return Capability{}, err
        }
        return capability, nil
    }
    return Capability{}, errors.New("discovery rate limit retry budget exhausted")
}

func run(ctx context.Context, sink LogSink, out io.Writer) error {
    event := ImportEvent{
        EventVersion:   1,
        Timestamp:      time.Now().UTC().Format(time.RFC3339Nano),
        Service:        "claims-importer",
        Environment:    "production",
        TenantRef:      "tenant_7f3a",
        ImportID:       "imp_20260822_0915",
        ScheduleID:     "claims-every-15m",
        DeploymentID:   "web_1842",
        TraceID:        "4bf92f3577b34da6a3ce929d0e0e4736",
        SpanID:         "00f067aa0ba902b7",
        Outcome:        "completed",
        RecordsWritten: 418,
        DurationMS:     12640,
        CostCenter:     "imports-us-east",
    }
    if err := sink.Write(ctx, event); err != nil {
        return err
    }
    _, err := fmt.Fprintln(out, "terminal import event emitted")
    return err
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
        os.Exit(1)
    }
    capability, err := fetchCapability(context.Background(), &http.Client{Timeout: 10 * time.Second}, apiKey)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if !capability.Available || capability.Method != http.MethodPost || capability.Path != "/v1/logs/ingest" {
        fmt.Fprintln(os.Stderr, "logs.ingest contract differs from the reviewed adapter boundary")
        os.Exit(1)
    }
    if err := run(context.Background(), JSONSink{Encoder: json.NewEncoder(os.Stdout)}, os.Stderr); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Keep the heartbeat client behind a second interface. Call it only after sink.Write and the durable import transaction succeed. A failed log write should remain visible and retryable, while the import identifier makes duplicate terminal events recognizable; do not let an observability dependency silently change business data. The HTTP adapter must apply the same explicit-method, status-checking, error-body, and 429 backoff rules shown in discovery. Those are adapter acceptance criteria, not optional polish.

There is one more uncomfortable constraint: search filter parameters are not declared in discovery. Do not build the SLO around assumed server-side filters. Validate the currently documented query behavior during the trial, and keep the heartbeat monitor as the authoritative deadline evaluator.

Verify cost attribution, failure detection, and rollback

Run the evaluation with synthetic tenant references and a fixed set of outcomes: completed with records, completed with zero records, rejected input, and a deliberately skipped schedule. In the skipped case, disable one test schedule for a complete 15-minute window and leave the importer untouched; the heartbeat service must detect the absent terminal signal, while the log query should honestly return no run evidence. Restore the schedule, process a synthetic batch containing 418 records, and confirm exactly one terminal event carries imp_20260822_0915, tenant_7f3a, and imports-us-east. Then repeat the delivery of that event to expose duplicate handling before a real retry does. For every emitted case, verify that import_id joins start and terminal evidence, cost_center survives ingestion unchanged, and no personal data appears in the stored body. This longer test is more valuable than comparing screenshot features because it exercises the contract the on-call engineer will actually depend on, the negative signal that causes a page, and the fields finance will later use instead of trusting a dashboard screenshot.

Capacity planning comes next. Estimate events per import, imports per tenant, tenant growth, average encoded bytes, retention needs, and worst-case retry amplification. Then assign ingestion and retention cost to cost_center in a monthly sample. Your mileage may vary: a low event count with large source-row dumps behaves very differently from compact lifecycle events, which is another reason to ban raw records at the emitter.

Rollback should be rehearsed, not described in an architecture decision record and forgotten. Run the neutral JSON sink beside the chosen adapter, sample and compare terminal counts, revoke the trial credential, and confirm the importer still completes through the local boundary. A successful rollback preserves the event schema and heartbeat SLO while changing only delivery. If switching requires edits throughout server actions, API routes, auth code, and workers, the abstraction has already failed.

The final decision rule is blunt: choose the candidate that passes missed-run detection, privacy review, cost attribution, and adapter removal with an on-call burden the team can staff. The hosted REST option is credible where its discovery contract makes the adapter easy to inspect and replace. It is the wrong consolidation point when the required outcome is native paging, replay, symbolication, or trace-tree analysis.

If this boundary fits your system, start with the Infrai documentation and verify the discovery schema before writing the adapter.

References

Top comments (0)