Skip to content
← All writing
September 24, 2026·7 min read

Logging and Error Tracking for App Backends

Every production incident starts the same way: something is wrong, and you weren't there to see it. The user who hit the bug is gone, the request that failed is history, and everything you'll ever know about the failure is what your system wrote down at the time. That makes logging less a chore than a bet you place in advance — you're paying a little effort now for the ability to answer questions later, questions you can't yet know you'll need to ask. Most backends, especially solo-built ones, are under-betting until the first bad outage teaches them; this post is the setup that lets you skip the lesson.

Structured logs, or grep forever

The single biggest upgrade is switching from prose to structure. A log line like "Payment failed for user 42" is readable by you, tonight, while the context is fresh. A structured event is readable by machines, forever:

{"level":"error","event":"payment_failed","user_id":42,
 "order_id":"ord_981","reason":"card_declined","request_id":"req_c81f"}

Now "how many payment failures this week, grouped by reason?" is a query, not an archaeology project involving regexes against prose that three different developers phrased three different ways. Every mainstream stack has a good structured logger — Python's structlog, Go's standard-library slog, pino for Node — and they all support binding context once (request ID, user ID) so every subsequent log line in that request carries the fields automatically.

The habit that matters more than the library is consistent field names across the whole codebase: decide once that it's user_id (not userId, not uid, not all three), keep a short conventions doc, and treat event names like an API — because your future queries and dashboards are the API's consumers.

Levels earn their keep only if you're strict about what they mean: error means something failed and a human should eventually care; warning means surprising but handled; info marks business events (signup, purchase, sync completed); debug is for development and stays off in production. The universal failure mode is a codebase where everything logs at info — including errors someone downgraded to stop a noisy alert — until the signal drowns. If a level doesn't imply an action, it's decoration.

The request ID is the thread you'll pull

One user action fans out inside your system: a request hits the API, calls two services, touches the database, maybe enqueues a background job that runs minutes later. When it fails, you don't need all the logs — you need every line from that one journey, in order. That's only possible if they share a correlation ID: generate a request ID at the edge (or accept one from the client), bind it into the logger's context, pass it along to every downstream call and queued job, and return it in the response — commonly as an X-Request-ID header.

With it, debugging is "filter by req_c81f, read the story." Without it, debugging is triangulating timestamps across services and hoping only one user did that thing at 14:32. The implementation is a middleware and a convention — an hour of work that upgrades every future incident.

For app backends there's a twist worth adopting: surface the ID in the client. When your Flutter app shows "Something went wrong," include the request ID in the error details — display it subtly, or attach it silently to support messages. A user reporting "error, ref req_c81f" turns a fifteen-message support thread ("what were you doing? what time? what account?") into a thirty-second log lookup. It's the cheapest support tooling you will ever build, and users perceive the fast, specific resolution as competence — which it is.

Error tracking is not logging

Logs record everything that happens; an error tracker (Sentry and its peers) answers a different, sharper question: what's broken, is it new, and how many users does it affect? It groups identical exceptions into one issue with a count, a first-seen version, affected-user numbers, and a full stack trace with local context — turning ten thousand identical log lines into "one bug, introduced in v2.3.1, hit 47 users, spiking." That's triage information, and triage is what you actually do when things break.

You want both systems, and on both sides of the wire: crash reporting in the mobile app, error tracking in the backend — ideally tagged with the same request ID, so a client crash and the server error that caused it become one story instead of two mysteries investigated separately. (The analytics post covers the privacy side of client telemetry; the same restraint applies to what error reports carry.)

Two rules keep a tracker useful past the first month. Treat the inbox with zero-tolerance discipline: every issue gets resolved, muted with a reason, or ticketed — because a tracker with 400 ignored issues is a swamp, and swamps hide the new bug that matters. And tag every event with the release version, so "did the deploy fix it?" is a filter you check, not a feeling you have.

What not to write down

Logs quietly become a liability when they capture the wrong things, and the wrong things love to hide inside "just log the whole request object for debugging":

  • Never log credentials — passwords, tokens, API keys, session cookies. The Authorization header inside a logged request is the classic leak: it turns your log store into a credential store with worse access controls, and a leaked log becomes a breach.
  • Minimize personal data. Log user_id: 42, not emails, names, and addresses; the ID lets you investigate anything while keeping the personal data in exactly one governed place. Otherwise your logs are a second personal-data store — subject to GDPR-style access and deletion rights, breach disclosure, all of it — that nobody is managing. Record what happened, not who someone is.
  • Redact at the framework level. A middleware or logger processor that strips known-sensitive field names (password, token, authorization, card_number) beats hoping every future endpoint author remembers the rules at 6 p.m. on a Friday. Defaults outlast discipline.

Retention follows the same logic: recent logs hot and searchable, older logs cheap or deleted on a schedule. Nearly all debugging happens within days of the event; most log-storage bills — and log-related privacy exposure — come from pretending otherwise.

Alert on symptoms, sparingly

The last layer is finding out without looking. The discipline that keeps it sane: alert on user-facing symptoms — error-rate spike, latency past a threshold, background queue stalled, zero successful payments in an hour — not on every individual exception. Symptom alerts catch whole categories of failure, including the ones you never predicted; per-exception alerts catch you ignoring your phone by Thursday.

Make every alert actionable: it fires → you do something specific. Anything else is noise, and each false alarm trains you to skim the channel — an ignored pager is strictly worse than none, because it provides confidence instead of coverage. Three alerts you trust beat thirty you don't; the error tracker's daily digest handles the long tail at coffee time rather than 3 a.m. And test the path once end-to-end — trigger a fake error, watch it actually reach your phone — because an alerting system that has never fired is a rumor, not a system.

The takeaway

Observability for an app backend is four habits, each modest on its own: structured events with consistent field names, a request ID that travels from the mobile client through every service and back into support conversations, an error tracker whose inbox you keep ruthlessly clean, and a small set of symptom alerts you genuinely trust. Together they change the texture of running software — incidents shift from panicked reconstruction to reading a story that was written down as it happened. Set it up in the quiet weeks; by definition, you can't add it during the incident it was for.

Further reading