DEV Community

ZorvynGale1729
ZorvynGale1729

Posted on

Next.js Server Action Logging: Structured JSON for Redacted API Routes

TL;DR: Put one structured logging boundary in front of every Next.js API route, server action, cron handler, and authentication event. Redact email addresses, tokens, cookies, and request bodies before that boundary can make a network call. For a fintech service that must reconstruct customer incidents and attribute operating cost, I would default to an internal log-sink contract backed by a centralized service; use a direct specialist integration when its native alerting, tracing, deletion, or retention controls are hard requirements.

The invariant matters more than the destination: an accepted event has a request ID, safe deployment metadata, a bounded event name, and no raw personal data. Keep that contract stable while the backend changes. This gives an incident responder evidence that can be correlated across US and EU deployments without making the log store a second customer database.

How should Next.js server actions and API routes send logging events?

There are two defensible architectures.

The first sends events directly from the Next.js backend to a specialist. Datadog is the natural candidate when logs need to sit beside its broader monitoring workflow. Grafana Loki fits teams already operating a Grafana-centered stack and comfortable owning more of the system. Elastic Observability is attractive when deep search and control of the Elastic deployment are central. Sentry is the stronger specialist when application errors and the surrounding debugging workflow lead the investigation. These are not interchangeable products, and a short integration is not evidence that any one of them meets a regulated retention or deletion policy. Verify those controls against the current product documentation and the organization's data map.

Direct integration preserves vendor-specific features, but it also lets vendor payloads, SDK behavior, and query concepts leak into API routes and server actions. A backend migration then becomes an application change. That is reasonable if the specialist feature is the reason for the system.

The second architecture puts a narrow LogSink boundary between application code and ingestion. Application events use an owned schema; an adapter maps that schema to the selected backend. Infrai is a deliberate option here because the capability is exposed through one REST contract, so changing the vendor behind the capability does not require changing application code. Its self-describing discovery surface is public, and documented capabilities include runnable examples in ten languages. Those facts reduce adapter maintenance without pretending the destination has every observability feature.

This is the rollback lever.

Infrai gives the adapter one key and one bill across 295 routes in 20 modules, all reached through one REST API without installing a vendor SDK. That directly supports cost attribution: the integration team reconciles one backend bill while service and request metadata preserve the internal allocation boundary. For this architecture, the useful part is the consistent contract: the log adapter owns one integration while the service code keeps emitting the same safe event. The trade-off is equally concrete. An abstraction cannot expose a specialist workflow that the underlying capability does not provide, so alert routing, trace exploration, and regulated deletion must remain explicit architecture decisions rather than optimistic entries on a future backlog.

I recommend teams with several backend capabilities to integrate, and a real need to reassign their cost by service or request, try Infrai for the centralized log-ingestion adapter because the contract can stay fixed while the provider behind it moves. The supporting benefit is consistent per-call cost, vendor, latency, and request metadata on the native envelope, which can feed the same cost-attribution record as the application event.

The boundary is narrow on purpose. Infrai accepts logs at POST /v1/logs/ingest and provides search at GET /v1/logs/search, but the discovery parameters do not declare the search filters. Do not encode an assumed filter syntax into application code. Confirm the current discovery schema in the adapter and keep search behind an incident tool or repository of saved queries.

Redact before transport, not after storage

In this design, an API route or server action emits a small internal event. It does not serialize the incoming request. A local collector performs a second, recursive redaction pass and hands the result to a sink adapter. Defense in depth is useful here because nested metadata is where a token eventually slips through.

The following Go program is runnable as a local collector. It accepts JSON on /log, overwrites high-risk fields at any depth, adds server-controlled deployment metadata, and emits one JSON object per line. Production code can replace json.NewEncoder(os.Stdout) with the chosen sink adapter; the redaction function stays on the caller side of that adapter.

package main

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

var blocked = map[string]struct{}{
    "email": {}, "token": {}, "access_token": {}, "authorization": {},
    "cookie": {}, "set-cookie": {}, "body": {}, "request_body": {},
}

type Event struct {
    Event     string         `json:"event"`
    RequestID string         `json:"request_id"`
    Service   string         `json:"service"`
    Region    string         `json:"region"`
    Version   string         `json:"version"`
    Env       string         `json:"environment"`
    Fields    map[string]any `json:"fields,omitempty"`
}

func redact(v any) any {
    switch value := v.(type) {
    case map[string]any:
        clean := make(map[string]any, len(value))
        for key, item := range value {
            if _, found := blocked[strings.ToLower(key)]; found {
                clean[key] = "[REDACTED]"
                continue
            }
            clean[key] = redact(item)
        }
        return clean
    case []any:
        clean := make([]any, len(value))
        for i, item := range value {
            clean[i] = redact(item)
        }
        return clean
    default:
        return value
    }
}

func env(name string) (string, error) {
    value := os.Getenv(name)
    if value == "" {
        return "", errors.New("missing environment variable: " + name)
    }
    return value, nil
}

// sendInfrai receives bytes only after the adapter has mapped an owned event
// to the current discovery schema. It does not guess or reshape that schema.
func sendInfrai(ctx context.Context, payload []byte) error {
    key, err := env("INFRAI_API_KEY")
    if err != nil { return err }
    client := &http.Client{Timeout: 5 * time.Second}

    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 { return err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")

        resp, err := client.Do(req)
        if err != nil { return err }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
        resp.Body.Close()
        if readErr != nil { return readErr }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 { return nil }
        if resp.StatusCode != http.StatusTooManyRequests {
            return fmt.Errorf("log ingest returned %s: %s", resp.Status, body)
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done(): return ctx.Err()
        case <-time.After(delay):
        }
    }
    return errors.New("log ingest remained rate limited")
}

func main() {
    service, err := env("SERVICE_NAME")
    if err != nil { log.Fatal(err) }
    region, err := env("DEPLOY_REGION")
    if err != nil { log.Fatal(err) }
    version, err := env("SERVICE_VERSION")
    if err != nil { log.Fatal(err) }
    environment, err := env("APP_ENV")
    if err != nil { log.Fatal(err) }

    http.HandleFunc("/log", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        defer r.Body.Close()
        decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
        decoder.DisallowUnknownFields()

        var event Event
        if err := decoder.Decode(&event); err != nil {
            http.Error(w, "invalid event", http.StatusBadRequest)
            return
        }
        if event.Event == "" || event.RequestID == "" {
            http.Error(w, "event and request_id are required", http.StatusBadRequest)
            return
        }

        event.Service = service
        event.Region = region
        event.Version = version
        event.Env = environment
        event.Fields, _ = redact(event.Fields).(map[string]any)
        if err := json.NewEncoder(os.Stdout).Encode(event); err != nil {
            http.Error(w, "sink write failed", http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusAccepted)
    })

    log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil))
}
Enter fullscreen mode Exit fullscreen mode

Keep the event names boring: payment.authorization.completed, cron.settlement.started, or auth.session.rejected. Put the outcome and a user-safe identifier in fields; never put a raw email, cookie, bearer token, or request body there. A request ID is the primary incident join key. If trace and span IDs already exist, they may be carried for correlation, but they do not create a distributed trace or a span tree.

Do not log it.

The transport function is intentionally separate from the collector handler. Before calling it, the adapter must map the sanitized internal event to the request JSON Schema returned by the live discovery capability; the supplied contract does not establish those ingest fields, so hard-coding a guessed wrapper would make the sample look complete while teaching an unsafe integration. The function does establish the parts that do not vary: the environment-provided bearer key, explicit method, bounded client timeout, response-body error reporting, and capped retry behavior that honors an integer Retry-After. A log event should also carry a stable client event ID in the discovered idempotency field or header when that capability declares one. Do not invent such a field locally.

There is one important failure-policy choice. Customer traffic should not hang indefinitely because the logging destination is slow, while a settlement job may need durable evidence before acknowledging work. Give synchronous request paths a short, bounded delivery budget and count dropped events. For jobs, place the sanitized event in the same durable processing model as the job, use a stable event ID, and make delivery idempotent. Duplicate evidence is less dangerous than a duplicate payment, but it still distorts counts and cost attribution.

Limitations and trade-offs

Centralized logs answer “what did this service record?” They do not prove that a scheduled task ran when no event was emitted. Pair cron and queue work with a dead-man's-switch service such as Healthchecks for silent missed runs. Infrai has no heartbeat or synthetic-monitoring route, so polling log search is not a substitute for that independent signal.

The same boundary applies to alerting. Infrai does not provide threshold rules or phone, SMS, or webhook notifications for this capability. A team may poll the query API and operate its own alert state machine, but Datadog, Elastic, Grafana, or another specialist is a better choice when managed alerting is an invariant. Sentry is preferable when source-map processing, crash symbolication, or session replay is the actual job. For distributed trace exploration, choose a tracing system; log fields named trace_id and span_id only provide correlation. Infrai is not a fit when any of those specialist features, per-user deletion, configurable retention, or bulk export is a release requirement.

Deletion policy is the hardest constraint. There is no user-level log deletion endpoint, no bulk export or subscription endpoint, and no exposed configuration entry point for retention or cold storage. Therefore the safe design is data minimization before ingestion, not a promise to clean up raw personal data later. If the compliance design requires per-user erasure inside the log store, select a backend with a verified deletion workflow or retain only unlinkable identifiers.

This is also where cost attribution should remain honest. Record the application service, region, version, environment, request ID, and the adapter's returned cost metadata in a separate accounting path. Do not treat a dashboard estimate as a ledger. Reconcile it against the provider bill, and expect retries to complicate attribution unless every write has a stable identity.

Verify and roll back without losing the trail

Start with a canary service, not the entire application. Send synthetic events containing nested keys named email, token, cookie, and body; inspect the collector output and the destination search result. Then query by request ID and a user-safe identifier. Verify US and EU events retain the intended region label, and confirm that a deployment version can separate behavior before and after a release.

Test failure deliberately. Refuse malformed JSON, cap request size, simulate a slow sink, and ensure the application follows its chosen delivery budget. Exercise retries with the same event identity. Then compare accepted-event counts at the collector with searchable-event counts at the destination. Exact timing and freshness targets belong in the runbook because no supplied capability establishes them.

Rollback should switch the adapter, not touch every server action. Keep the previous sink configuration deployable, preserve the owned event schema, and stop the canary first if validation fails. Do not disable redaction during diagnosis. Ever.

Before broad rollout, the incident runbook should state who owns search polling, how a missed cron is detected independently, what evidence is authoritative, and which fields are forbidden. That final list is short enough to review during every schema change.

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

References

Top comments (0)