DEV Community

oskarholm4968
oskarholm4968

Posted on

Frontend Backend Error Tracking: How to Choose Practical Exceptions Without Session Replay

A checkout error system should preserve enough evidence to answer one operational question: did the logistics order fail before acceptance, after acceptance, or only in the browser? Collecting traces and replay by default would add volume without necessarily improving that answer. The practical choice is a deliberately small exception pipeline with a shared correlation key, stable failure classes, aggressive field allowlisting, and separate alerts for customer impact and pipeline health. It covers browser and API exceptions while keeping signal quality ahead of noise.

Short answer: capture handled and unhandled exceptions at both boundaries, attach an opaque checkout attempt ID, normalize them into one vendor-neutral envelope, and alert only on failures that cross a defined impact threshold. Do not attach form values, addresses, authorization headers, cookies, stack-local variables, or replay data. A useful event explains the failed transition; it does not reconstruct the person.

How should frontend and backend error tracking shape the practical choice?

Start with the state machine, because an exception count alone cannot distinguish a rejected request from an accepted order whose response was lost. For a logistics checkout, the relevant transitions might be cart_ready -> submitting -> accepted, with explicit terminal failures such as validation_rejected and dependency_unavailable. The event should record the attempted transition and whether the backend assigned an order ID. That distinction protects reconciliation: retrying a request that was already accepted can be more damaging than showing an error.

A React or Next.js frontend and a Node API do not change this boundary. They change where adapters run, while the normalized event and the acceptance record remain technology-independent. The same design also avoids making session replay a prerequisite for simple exception tracking.

This is an exactly-once reasoning model, not a claim that exception delivery itself is exactly once. Error events can be duplicated, delayed, or dropped, so the checkout's idempotency key remains part of business correctness while the event ID serves only observability deduplication. Keep those identifiers separate.

The minimum envelope needs surprisingly little: event ID, observed timestamp, source, release, environment, failure class, operation, checkout attempt ID, order ID when one exists, and a sanitized error description. Severity also needs one shared interpretation. RFC 5424 defines ordered severity values from Emergency through Debug; using a documented subset across browser and backend prevents a frontend warning from competing with an API failure merely because two libraries chose different labels.

No payload dump.

The compliance boundary should be an allowlist enforced before transport, not a promise that engineers will avoid searching sensitive fields later. Treat free-form messages as suspect, cap their length, and reject unexpected attributes. An EU deployment and a US deployment may have different retention or routing policies, but both benefit from collecting fewer identifying fields. A GDPR review must set the actual retention and transfer limits for the deployment; the event schema should make those limits enforceable rather than pretending that a technical pattern settles a legal question.

Build one narrow event contract

The following Go types model the ingestion boundary even when the emitting client is a browser. The browser can send the same JSON shape; the receiving service validates it, supplies trusted deployment metadata, and refuses extra fields. FailureClass stays low-cardinality so it can drive aggregation, while Message remains diagnostic text and must never become a metric label.

package errorsignal

import "time"

type Event struct {
    EventID           string    `json:"event_id"`
    ObservedAt        time.Time `json:"observed_at"`
    Source            string    `json:"source"` // browser or api
    Release           string    `json:"release"`
    Environment       string    `json:"environment"`
    Operation         string    `json:"operation"`
    FailureClass      string    `json:"failure_class"`
    Severity          string    `json:"severity"`
    CheckoutAttemptID string    `json:"checkout_attempt_id"`
    OrderID           string    `json:"order_id,omitempty"`
    Message           string    `json:"message"`
}

var allowedFailureClasses = map[string]struct{}{
    "validation_rejected":    {},
    "dependency_unavailable": {},
    "unexpected_exception":   {},
    "response_lost":          {},
}
Enter fullscreen mode Exit fullscreen mode

The contract is intentionally boring. Adding carrier name, route, warehouse, customer country, or raw URL may feel helpful during the first incident, yet each extra dimension multiplies grouping choices and can expose business or personal context. Consider a browser timeout after the backend accepted the order: a raw request dump might contain everything needed to recognize the customer, but the operational decision depends on only three facts, namely that the attempt ID reached the API, an order ID was assigned, and the response did not reach the browser. Those facts direct the responder toward response delivery and suppress an unsafe retry. The remaining payload adds exposure without changing the decision, so it should not cross the capture boundary. Add a field only when an operator can name the decision it changes.

Validation belongs at ingestion. Decode exactly one object, reject unknown keys, verify bounded values, and overwrite fields such as environment from server-side configuration rather than trusting the caller.

Bound it early.

package errorsignal

import (
    "encoding/json"
    "errors"
    "io"
    "strings"
)

func Decode(r io.Reader) (Event, error) {
    dec := json.NewDecoder(io.LimitReader(r, 32<<10))
    dec.DisallowUnknownFields()

    var event Event
    if err := dec.Decode(&event); err != nil {
        return Event{}, err
    }
    if event.EventID == "" || event.CheckoutAttemptID == "" {
        return Event{}, errors.New("missing required identifier")
    }
    if _, ok := allowedFailureClasses[event.FailureClass]; !ok {
        return Event{}, errors.New("unsupported failure class")
    }
    if len(event.Message) > 512 || strings.Contains(strings.ToLower(event.Message), "authorization") {
        return Event{}, errors.New("unsafe error message")
    }
    return event, nil
}
Enter fullscreen mode Exit fullscreen mode

The 32 KiB body cap and 512-character message cap are capacity limits, not proof of safety. That substring check is a last guard, not a complete redaction system. The stronger control is constructing messages from approved templates and keeping arbitrary exception values out of the event. Stack frames can be retained only when the organization has reviewed their contents and retention; they should not be presumed harmless.

Separate capture, storage, and alerting

An implementation has three failure domains. Capture runs close to the browser or API boundary, ingestion authenticates and normalizes, and storage supports grouping and audit. Alert evaluation reads aggregated outcomes rather than every event. This division lets the checkout continue when observability is unavailable and prevents an alert rule from becoming part of request latency.

Noise wins otherwise.

Delivery should be bounded. In the browser, enqueue the sanitized envelope without blocking navigation; on the API, use a short buffered handoff and increment a local drop counter when the buffer is full. Never retry forever. The business request deserves the latency budget, while the signal pipeline must expose its own loss rate so silence is not mistaken for health.

Deduplicate storage by event_id, but group investigations by a fingerprint built from stable fields such as source, operation, failure class, and release. Do not hash the entire message: order numbers or dependency text can split one defect into thousands of groups. Conversely, grouping every exception by status code can merge unrelated defects. The right fingerprint follows the repair unit.

Metrics are derived views. Prometheus naming guidance recommends a common prefix, base units, and labels whose combinations do not grow without bound. A counter such as checkout_failures_total{source,failure_class} is defensible because both labels have controlled sets. checkout_failures_total{message,order_id} is not; those values belong in restricted event storage, if they belong anywhere.

For auditability, record schema version and policy outcome alongside accepted events, then make retention deletion observable as an aggregate operation rather than another copy of the deleted content. Access to raw exceptions should also be narrower than access to aggregated dashboards. These controls support review, but they do not by themselves establish compliance.

Choose thresholds from customer impact

Page on failed outcomes, not on exception novelty. One practical rule is a rolling ratio whose numerator is checkout attempts ending in an unexpected server failure and whose denominator is all eligible submission attempts; require a minimum denominator before firing. This avoids a single failure waking someone during a quiet interval while still catching a broad regression. The actual window, minimum volume, and threshold must come from the service objective and traffic pattern, not from a universal number.

Browser errors need a different route. A burst in one release can open an investigation, but it should page only when it correlates with abandoned submissions or missing backend acceptance. A browser exception after an order was accepted is still worth fixing, although its operational priority differs from a failure before acceptance.

Signal Group by Operational action
API unexpected exception operation, failure class, release Page when the impact rule is met
Browser submission exception release, operation Investigate; page only with checkout impact
Validation rejection stable reason class Review trend, usually no page
Dropped observability events source, environment Repair signal integrity

The limitation is deliberate: a simple exception system cannot answer request-waterfall or user-session questions, and in exchange it gives a smaller, reviewable data surface. This trade-off makes it unsuitable when responders repeatedly need cross-service timing to determine ownership; distributed tracing has then become justified. It is also unsuitable when teams must reproduce visual interaction defects that cannot be described by state transitions, in which case session replay may be justified after privacy review. Those are evidence-based expansions, not default checkboxes.

Roll out without losing the audit trail

Begin in report-only mode: validate events, count rejected fields, and compare accepted checkout states with captured failures without paging. Then enable one API impact alert, followed by browser correlation, while retaining the old operational signal during an overlap window defined by the team's release cadence. Rollback means disabling the new alert path; it must not alter checkout processing.

Before broader deployment, test duplicate event IDs, malformed JSON, oversized bodies, unknown fields, an accepted order followed by a lost response, and an unavailable ingestion service. The expected result is crisp: checkout correctness remains governed by its idempotency and reconciliation records, accepted events remain deduplicated, and observability loss appears in a bounded metric.

Finally, review the allowlist and access policy with security, privacy, and operations owners. The best practical choice is the smallest system that preserves the transition evidence responders actually use. Expand it only after a documented incident question proves the missing capability.

Sources

Top comments (0)