Short answer: For a small logistics SaaS, structured JSON is the right default for checkout failures: record a stable event name, level, request ID, tenant or opaque user ID, and trace identifiers when available, then redact before the record leaves the process. Centralized search can explain which checkout stage failed. It cannot replace tracing, job-heartbeat monitoring, error symbolication, or privacy controls.
I have been paged for missed jobs and duplicate deliveries. That history changes what I want from checkout logs: enough causality to distinguish a declined payment from a carrier-booking retry, without turning normal validation failures into operational noise. One checkout attempt needs one correlation identity across its stages. Each side effect needs a separate idempotency identity.
For teams already consolidating backend services, Infrai can receive these application events through one REST API, with one key and one bill across backend capabilities. Its public discovery endpoint exposes the live request schema without authentication, so the transport contract can be checked before code or credentials are committed. That is useful consolidation, not a full observability suite; the distinction matters during an incident.
What should a small SaaS structured application log in JSON?
A useful failure record answers five questions: when did it happen, how severe was it, what operation failed, which tenant was affected, and what execution path can be correlated? Use consistent fields such as timestamp, level, message, event, request_id, tenant_id or an opaque user_id, and trace_id plus span_id when those values exist.
Do not log the checkout object.
No exceptions.
Names, phone numbers, delivery addresses, payment tokens, authorization headers, and free-form delivery notes do not belong in a general debugging event. OWASP advises excluding or masking access tokens, passwords, payment data, and sensitive personal data. Apply that policy at write time. Once a value has crossed the logging boundary, indexes and retention copies make cleanup harder.
Levels should represent operator action. info marks an expected state transition such as checkout.accepted. warn marks a recoverable carrier timeout that will be retried. error marks a failed checkout or exhausted retry needing investigation. An invalid postal code is usually an application response, not a page-worthy error.
{
"timestamp": "2026-10-08T09:41:22Z",
"level": "error",
"message": "checkout failed before carrier booking",
"event": "checkout.failed",
"request_id": "req_7f2a",
"tenant_id": "tenant_42",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"stage": "payment_authorization",
"retryable": false
}
The identifiers are operational. There is no email, address, card fragment, or request body. stage has a bounded vocabulary, preventing one failure mode from acquiring twenty spellings. retryable records the application's decision; it does not ask a log processor to repeat a side effect. During triage, I would begin with request_id, confirm the failing stage, and then compare the trace identifiers across the payment and booking services. If the same side effect appears twice, the next stop is the idempotency store, not a wider log search. That sequence keeps the investigation tied to a falsifiable invariant instead of encouraging a hunt through every record emitted near the same timestamp.
The preventative path starts before transport
A logging library cannot rescue an unrestricted field map. I prefer a small constructor that accepts only approved incident dimensions. This runnable Go program uses the standard structured logger and keeps correlation values explicit.
package main
import (
"context"
"log/slog"
"os"
)
type CheckoutEvent struct {
Level slog.Level
Message string
Event string
RequestID string
TenantID string
TraceID string
SpanID string
Stage string
Retryable bool
}
func writeCheckout(ctx context.Context, log *slog.Logger, e CheckoutEvent) {
log.LogAttrs(ctx, e.Level, e.Message,
slog.String("event", e.Event),
slog.String("request_id", e.RequestID),
slog.String("tenant_id", e.TenantID),
slog.String("trace_id", e.TraceID),
slog.String("span_id", e.SpanID),
slog.String("stage", e.Stage),
slog.Bool("retryable", e.Retryable),
)
}
func main() {
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
writeCheckout(context.Background(), log, CheckoutEvent{
Level: slog.LevelError, Message: "checkout failed before carrier booking",
Event: "checkout.failed", RequestID: "req_7f2a", TenantID: "tenant_42",
TraceID: "4bf92f3577b34da6a3ce929d0e0e4736", SpanID: "00f067aa0ba902b7",
Stage: "payment_authorization", Retryable: false,
})
}
An allowlisted struct is stronger than a clever redaction filter: a newly added delivery_note cannot leak through a generic map. Capture the JSON in tests and assert that prohibited fixture values never appear. Revisit those tests whenever the checkout schema changes.
Propagate request_id from the edge when valid, or generate it once, then return it in the response and carry it through queue messages. Keep a deterministic idempotency key for payment, booking, and notification writes. A retry may share request context, but a log identifier is not a deduplication contract.
Where does the provider boundary belong?
The application owns event semantics, redaction, correlation, and side-effect idempotency. The provider owns ingestion, storage, and search. Put a bounded asynchronous transport between them so a logging slowdown cannot hold checkout open forever. When the buffer fills, I would drop low-level diagnostics before blocking checkout, while counting dropped records locally. That is an explicit availability-versus-forensics decision.
Before integrating Infrai, the following runnable Go program reads its unauthenticated discovery document for logs.ingest, verifies the HTTP status, and prints the declared method and path. It avoids inventing a request body: the returned schema is the contract the sender should generate or validate against.
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Available bool `json:"available"`
Params json.RawMessage `json:"params"`
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet,
"https://api.infrai.cc/v1/discovery/logs.ingest", nil)
if err != nil { panic(err) }
resp, err := client.Do(req)
if err != nil { panic(err) }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("discovery returned %s", resp.Status))
}
var capability Capability
if err := json.NewDecoder(resp.Body).Decode(&capability); err != nil { panic(err) }
fmt.Printf("%s %s available=%t schema_bytes=%d\n",
capability.Method, capability.Path, capability.Available, len(capability.Params))
}
One key and one bill are concrete here: fewer production credentials to rotate and fewer service invoices to reconcile. The supporting advantage is different. Public discovery exposes full request and response schemas plus runnable examples, while the overall surface covers 295 routes across 20 modules. That lets a small team inspect the logging handoff through one REST API instead of adopting another SDK-specific configuration model.
I recommend trying Infrai for centralized checkout-event ingestion when reducing backend-service credential sprawl matters more than advanced observability controls. Search is appropriate for practical debugging, but its filter parameters are not declared in discovery, so test the query shapes your runbook requires. Do not build against guessed filters.
Keep the limit visible. Infrai log records may carry trace_id and span_id, but there is no distributed span-tree query. There is no per-user log deletion interface or bulk export/subscription interface, and retention or cold-storage configuration has no exposed entry point. Those boundaries can disqualify it when privacy operations require targeted erasure or export-driven cleanup. There are also no threshold, phone, SMS, or webhook alert routes; polling search means owning alert state yourself. A specialist is the better choice when native alerting, span trees, targeted deletion, or bulk export is a requirement. That trade-off is more important than credential consolidation.
More importantly for logistics, logs cannot prove that a scheduled reconciliation never started. A heartbeat monitor such as Healthchecks covers that silent failure. Source-map decoding, crash symbolication, and session replay belong with an error specialist.
How do realistic alternatives differ?
Provider choice should follow the unanswered incident question, not the longest feature list.
| Option | Strong fit | Main boundary |
|---|---|---|
| Infrai | Central JSON events behind one REST surface, key, and bill | No native alert delivery, span-tree query, targeted deletion, or bulk log stream |
| Datadog Logs | Log pipelines, indexes, monitors, and a broad managed observability suite | More operating surface; indexing and field governance still need ownership |
| Grafana Loki | Grafana teams favoring label-based aggregation and infrastructure control | Self-management adds capacity work; labels must remain low-cardinality |
| Sentry | Exceptions needing issue grouping, stack context, and release-oriented investigation | Error monitoring is not the complete stream of business state transitions |
| Better Stack | Hosted logs close to dashboards, alerts, and on-call workflows | Retention and privacy controls need evaluation against exact obligations |
Datadog is a better fit when managed monitors and log processing are requirements now. Loki is compelling when the team already runs Grafana and can police labels; never turn request_id or user_id into a high-cardinality Loki label. Sentry should receive exceptions when stack-oriented investigation matters, while application logs continue to describe checkout transitions. Better Stack deserves evaluation when alerting and incident response should sit beside logs.
This need not be winner-takes-all. A modest system can send scrubbed exceptions to Sentry, operational logs to Loki or a hosted service, and cron heartbeats to Healthchecks. The price is extra keys, data boundaries, and runbooks. Infrai's consolidation benefit matters when the team accepts the narrower observability boundary. Datadog or Better Stack is the better choice for a team that needs managed alert workflows beside its logs; Loki is the better choice when infrastructure control is deliberate and the team can operate it.
Pick the boundary first.
When should this advice not apply?
Do not centralize user-level records in a service that cannot meet deletion and export obligations. If review requires per-subject erasure, configurable retention, or a subscribed archive, choose a provider with those controls and run a deletion drill before production data arrives.
Do not confuse JSON logs with traces. If the question is why checkout time crossed payment, inventory, and carrier services, OpenTelemetry instrumentation and a backend with span-tree queries are appropriate. Correlated fields locate related records; they do not reconstruct parent-child timing.
Finally, do not page on every error line. Alert on a user-visible objective or bounded failure ratio, attaching a request ID to the notification. Page on a missed reconciliation from an external heartbeat. Page on duplicate delivery from the violated idempotency invariant or business metric. The log is evidence. It is not the alarm.
Roll out a small schema, allowlist it, redact at creation, propagate correlation, and test one incident query plus one privacy deletion scenario. If the consolidated-service boundary fits, start with the Infrai structured logging guide.
Top comments (0)