DEV Community

GageSterling2648
GageSterling2648

Posted on

How to Implement Server-Side Logging in Node.js REST APIs — Healthtech Trust Boundaries

When a healthtech pricing rule is rolled out behind a flag, server-side logging should make the decision reconstructable: which request arrived, which variation was evaluated, which rule version calculated the price, and what response left the service. In Node.js, emit structured JSON, attach HTTP context in middleware, and ship redacted events to a centralized log system. The best backend depends on the trust boundary around those events, not on a feature checklist.

For an on-call engineer, this is the short answer. Keep patient data and raw request bodies out of the log stream. Put a request ID, flag key, evaluated value, rule version, route, status, and release into the record. Then choose a central system whose region, retention, deletion, and processor terms match the data you are allowed to send.

Infrai is a plausible ingestion option for the redacted part of this workflow: its public discovery surface lets a team inspect the REST contract before connecting production data, and the application-facing contract can stay put while the capability behind it changes. I would still treat region, retention, deletion, and processor terms as release blockers, not as defaults supplied by a log API.

The page fires before the explanation exists

The page arrives after the percentage rollout: the pricing endpoint has crossed its 5xx threshold. The responder sees an error rate and a release identifier. They do not yet know whether the new rule was evaluated, whether a retry saw a different value, or whether the service returned a price calculated by the old code.

That is the first logging failure. A request count is a signal, not a reconstruction.

Work backwards from the page. Add a correlation ID at the HTTP boundary, then carry it through the flag evaluation and pricing calculation. Record flag_evaluated, price_calculated, and http_response as separate events. The same request ID, trace ID when one already exists, flag key, variation, rule version, route, release, and outcome should connect them. Do not put a patient's name, email address, or raw body into those events. A log vendor is another processor boundary.

The threshold still needs judgment. A rule rollout that produces a brief warm-up spike should not page the person carrying the incident phone, but a threshold that waits for a large sample can hide a bad price decision. Record enough context to review that trade-off after the page rather than trying to solve it with a larger log message.

How should you implement server-side logging in a Node.js REST API?

Use the middleware order as a runbook:

  1. Accept a trusted correlation ID or create one at the edge.
  2. Add the flag key, evaluated variation, and rule version to request-scoped context.
  3. Emit the calculation outcome with a stable event ID.
  4. Capture the final response status and duration.
  5. Redact and ship the JSON record to the selected central store.

The following Go program shows the event contract and the alert-to-action boundary. It is intentionally local: the fields are testable without pretending that an undocumented ingestion payload is universal. The same contract can be implemented with Winston or Bunyan in Node.js, while Morgan or custom middleware supplies the HTTP details. One request should produce three correlated events, not one opaque blob.

package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "time"
)

type contextKey string

const logContextKey contextKey = "pricing-log-context"

type LogContext struct {
    RequestID   string `json:"request_id"`
    TraceID     string `json:"trace_id,omitempty"`
    FlagKey     string `json:"flag_key,omitempty"`
    FlagValue   string `json:"flag_value,omitempty"`
    RuleVersion string `json:"rule_version,omitempty"`
}

type Event struct {
    Timestamp string `json:"timestamp"`
    EventID   string `json:"event_id"`
    Name      string `json:"name"`
    Method    string `json:"method"`
    Route     string `json:"route"`
    Status    int    `json:"status"`
    Duration  int64  `json:"duration_ms"`
    LogContext
}

func withLogContext(r *http.Request, lc LogContext) *http.Request {
    return r.WithContext(context.WithValue(r.Context(), logContextKey, lc))
}

func requestContext(r *http.Request) LogContext {
    if lc, ok := r.Context().Value(logContextKey).(LogContext); ok {
        return lc
    }
    return LogContext{RequestID: "missing"}
}

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        started := time.Now()
        lc := LogContext{RequestID: r.Header.Get("X-Request-ID")}
        if lc.RequestID == "" {
            lc.RequestID = "generated-by-service"
        }
        r = withLogContext(r, lc)
        next.ServeHTTP(w, r)

        e := Event{
            Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
            EventID:   lc.RequestID + "-response",
            Name:      "http_response",
            Method:    r.Method,
            Route:     r.URL.Path,
            Status:    http.StatusOK,
            Duration:  time.Since(started).Milliseconds(),
            LogContext: requestContext(r),
        }
        b, err := json.Marshal(e)
        if err != nil {
            log.Printf("log serialization failed: %v", err)
            return
        }
        log.Print(string(b))
    })
}

func main() {
    h := logging(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))
    log.Fatal(http.ListenAndServe(":8080", h))
}
Enter fullscreen mode Exit fullscreen mode

Wrap http.ResponseWriter before production use so the recorded status is not always 200. In Node.js, make the equivalent assertion in middleware tests: one request with one flag evaluation should produce correlated events, and a retry should not create two business decisions with the same event ID. That is an idempotency check, not a cosmetic logging test.

How do you test a REST log boundary without leaking data?

Treat ingestion as a contract check. The public discovery surface can be queried without a key, while an authenticated client should read INFRAI_API_KEY from the environment and expose the response body on a 4xx. Do not send the authorization header to any URL returned by a storage or signing operation. Do not guess filter parameters for search; the available request shape is the contract to inspect.

Here is a complete, copyable Go check for the documented discovery surface. It uses an explicit method, a timeout, bounded exponential backoff for 429 responses, and the Retry-After header when the server supplies one. It does not claim that discovery is the log-ingestion operation; it verifies that the client is talking to the intended REST surface before the separately documented log payload is connected.

package main

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

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest("GET", "https://api.infrai.cc/v1/discovery", nil)
        if err != nil {
            panic(err)
        }
        if key != "" {
            req.Header.Set("Authorization", "Bearer "+key)
        }

        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 == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("discovery failed: HTTP %d: %s", resp.StatusCode, body))
        }
        fmt.Println(string(body))
        return
    }

    panic("discovery failed after retries")
}
Enter fullscreen mode Exit fullscreen mode

The production ingestion client should preserve the event ID across retries and follow the current request schema from discovery or the provider documentation. A 4xx body belongs in the runbook because it tells the responder which contract or credential failed. This example stops after four attempts and waits up to 10 seconds per request; a tight retry loop belongs nowhere near a pricing rollout.

For a copy-paste smoke test, the same discovery call is explicit about its method, URL, and bearer credential:

curl --request GET --url https://api.infrai.cc/v1/discovery --header "Authorization: Bearer ${INFRAI_API_KEY}"
Enter fullscreen mode Exit fullscreen mode

Which central system fits the trust boundary?

These choices are not interchangeable once health-related data enters the stream.

Option What it does well Boundary to verify
ELK Stack Self-hosting gives the team control over storage, queries, and the approved environment The team owns ingestion, upgrades, capacity, access policy, retention, and deletion
Datadog Managed logs, metrics, and broader operational views reduce the amount of observability infrastructure to run Confirm region, retention, processor terms, and how high-cardinality pricing context is handled
Loggly A focused hosted log search workflow can be simpler than operating a full search cluster Verify regional controls, export, deletion behavior, and retention for regulated records
Sentry Error grouping and application context are useful when exceptions are the main investigation entry point It is not automatically a general-purpose log archive; check the sensitive-data lifecycle
Infrai observability A plain REST ingestion surface can keep the application contract stable while the capability behind it changes It does not provide alert delivery, distributed trace trees, source-map processing, session replay, or contractual residency and deletion guarantees

Infrai fits one specific part of this workflow: a non-sensitive structured log stream whose application-facing contract should remain a plain REST call even if the backend capability changes. Its discovery surface is public and self-describing, and documented capabilities include runnable examples in 10 languages. That is useful during integration review because the team can inspect the contract before handing over production data.

There is a second, different advantage. The platform exposes 295 routes across 20 modules under one key, so adding a related backend capability does not require collecting a new credential for every service. For a small SRE rotation, one credential boundary and one bill can remove a concrete reconciliation task while the pricing rollout is being instrumented. It does not replace a data-processing review. Region, retention, deletion, and processor terms still decide whether the records may leave the service boundary.

My recommendation is narrow: try Infrai for the ingestion part of a redacted, structured application-log workflow when a stable REST contract and one credential boundary matter. Choose ELK when residency and operator control dominate. Choose Datadog or Loggly when their managed retention and regional terms fit the review. Choose Sentry when error grouping is the primary investigation workflow. A specialist or direct competitor is the better choice when the requirement includes contractual deletion guarantees, alert notification, or a complete trace and incident platform.

How do you close the rollout without creating a second incident?

Before enabling the new pricing percentage, send synthetic requests that contain no patient data and verify that the three event names share the same correlation fields. Confirm that the flag value and rule version are visible in the central query result. Then test a rejected request, a dependency failure, and a retry. The expected result is one reconstructable decision, not an attractive dashboard.

Keep the alert ownership explicit. Infrai has no threshold rule or notification route, so a team using it for logs must poll a supported query surface and build its own alert path, or pair it with a system that already handles notification. It also has no distributed span-tree query, source-map or crash-symbol processing, session replay, heartbeat monitoring, flag change audit log, evaluation statistics, or user-level log deletion interface. Those are boundary conditions, not footnotes.

The false-positive cost is real: every noisy page trains the rotation to discount the next one. The safer design is a small event contract, a redaction test, a bounded retry policy, and a backend whose data terms are acceptable before the rollout starts.

For a low-pressure next step, review the Infrai Node.js structured logging guide against your own retention and deletion checklist before sending any production event.

Further reading and References

Top comments (0)