Skip to main content
How-to Ship & operate

Set up error tracking and logs

A console.log survives exactly as long as the terminal window it printed to. Once an app runs on a server nobody is watching, that stops being a debugging tool and becomes nothing at all. This is the setup that actually tells you when something breaks: structured logs, an error tracker for anything unhandled, context attached without leaking a secret or a person's data, alerts that fire before a user complains, searchable logs on a sane retention window, and a short triage flow for the five minutes after an alert goes off.

6 steps13 min readLast verified August 2026

What you’ll learn

  • Replace scattered print statements with structured, machine-readable logs that carry request context
  • Wire up an error tracker that groups repeat errors, keeps stack traces, and tags each one with its release
  • Attach request id, route, and user context to every error, with no secrets or personal data logged
  • Set alerts on error-rate spikes and brand-new error types, so you hear about it before users do
  • Keep logs centralized and searchable with a retention window that fits how long you need to investigate
  • Run a repeatable triage flow the moment an alert fires, instead of improvising mid-incident

Print statements are how most projects start logging, and how most stay blind to what actually breaks once real users show up. A scattered console.log works fine, watched live, on your own machine. It stops working the moment the app runs on a server nobody is watching, across more than one instance, at 3 a.m. Six pieces turn "no idea something broke" into "found it in ninety seconds": structured logs, an error tracker for anything unhandled, the context worth attaching to each error, alerts that page you before a user does, a searchable setup with sane retention, and a short triage flow for the moment an alert fires.

New to this? A log level is a label on every log line that says how serious it is. DEBUG is step-by-step detail useful only while you are actively chasing one issue. INFO is a normal event worth a permanent record. WARN is something odd that the app recovered from on its own. ERROR means a specific operation actually failed. Picking the right level on each line is what later lets you filter out the noise and see only what matters.

Log levels, and when to reach for each one

LevelWhen to use it
DEBUGStep-by-step detail useful only while actively debugging one issue; off by default in production
INFOA normal event worth a permanent record: a job started, a user signed up, a scheduled task ran
WARNSomething unexpected happened but the app recovered on its own: a retry succeeded, a fallback kicked in
ERRORA specific operation failed and a user or a process was affected; worth investigating
FATAL / CRITICALThe process cannot continue and is about to crash or exit
Log level, and when to use it

1. Add structured logging instead of scattered prints

A print statement is a sentence for a human watching a terminal in that moment, with no fixed shape, so nothing downstream can search or alert on it. Structured logging fixes that: each line becomes data, usually JSON, with a fixed set of fields. The Twelve-Factor App treats logs as "the stream of aggregated, time-ordered events collected from the output streams of all running processes and backing services,"1 and says a well-built app "never concerns itself with routing or storage of its output stream,"1 it writes to stdout and lets the environment route it. OpenTelemetry adds what actually makes a log structured: not the JSON encoding, but "a defined, consistent schema... that downstream systems can reliably parse and interpret."2

One structured log line: a JSON object, not a sentence written for a human
{
  "timestamp": "2026-08-11T14:32:07.441Z",
  "level": "error",
  "message": "payment charge failed",
  "service": "checkout-api",
  "request_id": "req_9f2a1c",
  "route": "POST /api/checkout",
  "user_id": "usr_4471",
  "duration_ms": 812,
  "error": {
    "type": "CardDeclinedError",
    "message": "card_declined"
  }
}
  • A timestamp in a fixed format, ISO 8601, UTC, not the server's local time zone.
  • A level (DEBUG, INFO, WARN, ERROR) set on purpose, not left at the framework default.
  • The same wording every time the same event happens, so identical failures group instead of scattering under fifty near-duplicate strings.
  • The service or process name, so a shared log stream still filters back to one component.
  • A request id and route (step 3), plus only the fields that actually explain what happened.
Check: pull a random log line from your own app right now. If a script could not pull out the level, the timestamp, and what failed without parsing an English sentence, it is not structured yet.

2. Capture unhandled errors with an error tracker

Structured logs catch what you thought to log. They miss the exception nobody wrote a log line for, the one that crashes a background job overnight while nobody is watching. An error tracker closes that gap, a category, not one product: an SDK for your language that hooks into the runtime and reports every unhandled exception automatically, wrapped in a try/catch or not.

A good one does three things. It groups repeat occurrences of the same error into one issue by fingerprinting the type, message, and stack trace shape, a hundred crashes from one bug reads as one issue, not a hundred alerts. It keeps the full stack trace readable through a minified or compiled build, source maps for JavaScript, debug symbols elsewhere, the exact line that failed. And it tags every event with the release that shipped it, so a spike points straight at the deploy that caused it.

  • Automatic grouping (fingerprinting) of repeat errors into one issue, with an accurate count.
  • Full stack traces, readable through minification or compilation.
  • Release or version tagging on every captured event.
  • Custom context attached per error (step 3), not just default runtime data.
  • A real SDK for your language, not a bare HTTP endpoint you wire up by hand.
Check: throw a deliberate test error in a non-production environment and confirm it shows up in the tracker within a minute or two, grouped as its own issue, with a full stack trace and the release tag you expect.

3. Attach useful context, never secrets or personal data

An error with no context is a stack trace and a shrug. Attach a request id generated once at the start of a request and threaded through every downstream call, so one failure can be traced end to end. The route that failed. An opaque user identifier if the app is authenticated, an id, never a name or an email. And how long the request had been running when it failed.

Nothing that identifies a person beyond a system-generated id, and nothing that grants access on its own, belongs in a log line or an error's context. The OWASP Logging Cheat Sheet is direct: certain data should not be recorded "unless it is legally sanctioned,"3 naming passwords, session identifiers, access tokens, encryption keys, and full payment card data among what should never be written into logs.3 A field that is useful but still sensitive should be "removed, masked, sanitized, hashed, or encrypted"3 instead: the last four digits of a card, not the card; a hash of an email, not the address.

  • Safe to attach: request id, route or endpoint, HTTP status code, an opaque user id, duration, error type and message, release version.
  • Never log: passwords, API keys and access tokens, session identifiers, full card numbers, government ID numbers, health data, or a raw request body from a login or payment endpoint.
Check: search your logs and your error tracker for the strings "password", "token", and "authorization", plus a test credit card number if you have one. Any hit is a leak to fix now, not a backlog item.

4. Set alerts on error spikes and brand-new error types

A dashboard nobody watches catches nothing. An alert is the difference between finding out from your error tracker and finding out from a user. The Google SRE book sets the bar directly: an alert should catch "an otherwise undetected condition that is urgent, actionable, and actively or imminently user-visible,"4 and "if a page merely merits a robotic response, it shouldn't be a page."4 That rules out paging on every single error; alert on what actually changed.

Two triggers cover most of what a small team needs: a spike in the error rate for something already known, and a brand-new error type never seen before, often the earliest sign a deploy went wrong. Favor symptoms over causes, "the server is returning 500s to clients" over "the database is refusing connections,"4 a symptom says users are affected right now, a cause is just one possible reason. This is one of the SRE book's four golden signals, latency, traffic, errors, and saturation,4 worth alerting on together once error tracking alone is solid.

Check: in staging, throw the same error fifty times inside a minute and confirm the alert actually fires and reaches you, not just the dashboard, before you rely on it in production.

5. Keep logs searchable, with a retention window that fits

Logs you cannot search are logs you will not use during an incident. Centralize application logs, error-tracker events, and infrastructure logs in one place, queryable by request id, route, user id, or time range, instead of SSHing into a box to tail a file. Set retention deliberately: long enough to investigate something reported days later, commonly two to four weeks for a small team's application logs, no longer without a reason, since old logs past that are mostly cost and risk. Vary it by type: debug logs can expire in days, an error tracker's far lower-volume issues can live for months.

Check: pick an error from a week ago and search for its request id across every log source you have. If that takes more than a minute, or turns up nothing, fix the indexing now, before you need it during a real incident.

6. Run a simple triage flow when an alert fires

The alert firing is the easy part. What happens in the next five minutes decides whether it stays a minor blip or turns into an outage nobody caught in time. A short sequence, decided in advance, beats improvising it fresh every time.

  • Read the error first: message, stack trace, how often it has fired in the last hour.
  • Check what changed: the release tag on the error, any deploy around when the spike started.
  • Check the blast radius: one user or many, one route or many, using the fields from step 3.
  • Decide fast: roll back a clear-cause deploy first and investigate after; do not debug live in production.
  • No deploy explains it: look for a pattern instead, one bad input, one dependency, one region.
  • Once fixed, close the loop: resolve the issue, and add an alert if one should have caught it sooner.
Check: run this sequence as a drill, with a fake alert, and time it. If nobody gets through all six steps in under ten minutes without opening a chat to ask "what do we do," write it down somewhere the whole team can find.
Going further: once a request touches more than one service, a request id alone is not enough, you want a trace across every hop. OpenTelemetry frames a distributed trace as "the path of a request through your application,"5 built from spans, each "a unit of work or operation," linked by a shared trace id propagated across service boundaries.5 At real volume, logging every event also stops being affordable, sampling keeps a representative slice of normal traffic while still capturing every error. Automate PII scrubbing too: a redaction step that strips known-sensitive field names before a line is written catches what a token left in one pull request usually leaks six months later.

None of this needs expensive tooling: a structured JSON logger, one error-tracking SDK, and an alert on spikes and new error types covers most of what a small team needs. The rest, retention, symptom-based alerts, a written triage flow, is process, and it costs an afternoon to set up once. Skipping it does not make errors happen less often, it just moves the moment you find out from a dashboard to an angry review.

Key idea
Structured logs over scattered prints. An error tracker that groups issues, keeps stack traces, and tags releases. Context on every error, request id, route, opaque user id, with secrets and personal data left out entirely. Alerts on spikes and brand-new error types, not on every single error. Logs centralized and retained on purpose, not by accident. And a five-minute triage flow written down before you need it, not invented live during an incident.
  1. Replace print statements with structured JSON logs carrying a timestamp, level, message, and request context.
  2. Install an error-tracking SDK that groups repeat errors, keeps full stack traces, and tags each one with its release.
  3. Attach request id, route, and an opaque user id to every error, and keep passwords, tokens, and personal data out entirely.
  4. Set alerts on error-rate spikes and brand-new error types, not on every single error.
  5. Centralize logs in one searchable place with a retention window sized to how long you actually need to investigate.
  6. Write down a short triage flow: read the error, check what changed, check the blast radius, decide fast, close the loop.
Read next: The pre-launch checklist covers error tracking and logs as one of thirteen areas to confirm before you ship, and Plan for downtime and recovery picks up from here for the bigger failures, an outage, a bad deploy, a backup you actually need to restore.

Sources

Verified against primary sources: August 2026.

  1. Logs: Treat logs as event streams. The Twelve-Factor App, Factor XI. https://12factor.net/logs
  2. Logs. OpenTelemetry Documentation. https://opentelemetry.io/docs/concepts/signals/logs/
  3. Logging Cheat Sheet. OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
  4. Monitoring Distributed Systems. Google SRE Book. https://sre.google/sre-book/monitoring-distributed-systems/
  5. Traces. OpenTelemetry Documentation. https://opentelemetry.io/docs/concepts/signals/traces/
Read nextPlan for downtime and recovery