jguillaumesio
prod-opsdevops

Users emailed me about 500s before I ever saw them

Every error in production was logged with console.error and then died in a log file nobody read. Customers became my error tracker. Here is how I wired Sentry-compatible tracking into Express and React with sourcemaps, grouping, and one alert rule that actually fires.

The subject line was “the page does a 500 when I save”. Polite, specific, and sent by a customer who had been hitting the bug for two days. Two days during which the API had dutifully logged the stack trace, seventy or so times, into a file I had never opened, because nothing told me there was a reason to.

That customer was my error tracking. Not a tool, a person, and one with better things to do.

This is part 7 of a series on hardening a solo-built SaaS in production. The setup and the full list of weak spots are in the pillar article. Part 6 made the logs visible; this one is about why visible logs are still not error tracking, and what is.

Logs are not errors

The application had what I would have described as error handling. Search the API for console.error and you get twenty hits. Most look like this:

// apps/api/src/middlewares/permissions.middleware.ts, trimmed
} catch (error) {
  console.error('ERROR_GETTING_CURRENT_USER', error);
  return res.status(500).json({ error: 'Internal error' });
}

And the process-level catch-all, in the Redis provider of all places, because that is the file that happened to be open when it was written:

process.on('uncaughtException', (error) => {
  console.error('Uncaught exception:', error);
  redis.disconnect();
  process.exit(1);
});

Every one of these is honest code. It catches the error, it writes it down, it does not swallow it. And every one of them has the same flaw: the write goes to stdout, stdout goes to a JSON log file capped at 10 MB, and the file goes nowhere. An error that happens seventy times looks exactly like an error that happens once, spread across lines I have to grep for. A brand new error introduced by yesterday’s deploy looks exactly like the old one that has been there since launch.

The distinction I had missed: logs answer “what happened at 14:32”; error tracking answers “what is broken right now, since when, and how often”. The first is a timeline you read after someone tells you to look. The second is a list that tells you.

Notice also what that uncaughtException handler does after logging: process.exit(1). With restart: no on every service, which part 5 covers, an uncaught exception meant the API logged one line and then stayed dead. The customer’s 500s were the polite version of the failure mode.

What I wanted from a tool

Four things, and nothing else:

  1. Grouping. Seventy occurrences of one bug should be one row with a count, not seventy lines.
  2. “New since last deploy”. The question after every release is whether it broke something, and the tool should answer it without me searching.
  3. Real stack traces from the browser. The dashboard is a Vite build. A minified app-3f2a.js:1:48213 is not a location, and I had sourcemap: false in the config.
  4. One alert that reaches me, for new errors only. Not every occurrence. New ones.

Sentry does all four. So does GlitchTip, which is open source, speaks the Sentry protocol, and runs as a couple of containers on the same VPS as everything else in this series if you want zero recurring cost. Sentry’s free tier covers a product this size comfortably, so that is what I went with; every snippet below works unchanged against a self-hosted GlitchTip DSN.

The API side

Three changes. First, initialise as early as possible, before anything else imports:

// apps/api/src/instrument.ts
import * as Sentry from '@sentry/node';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.ENVIRONMENT,      // 'prod' or 'dev', already existed
  release: process.env.IMAGE_TAG,            // the commit SHA from CI, see part 4
  tracesSampleRate: 0,                        // errors only, no performance data
});

The release line is the one that pays for everything else. Part 4 made every deployed image carry its commit SHA as IMAGE_TAG; passing it here means Sentry knows which errors first appeared in which release, and “new since last deploy” becomes a filter rather than a hunch.

Second, the Express handler goes after the routes and before any other error middleware:

// apps/api/server.ts, trimmed
import './src/instrument';
import * as Sentry from '@sentry/node';

// ... routes ...

Sentry.setupExpressErrorHandler(app);

app.use((err, _req, res, _next) => {
  res.status(500).json({ error: 'Internal error' });
});

Third, and this is the one people forget, the process-level handlers. They stay, because a process that cannot continue should still exit, but the exit now waits for the report to leave the box:

process.on('uncaughtException', async (error) => {
  Sentry.captureException(error);
  await Sentry.flush(2000);
  redis.disconnect();
  process.exit(1);
});

Without the flush, the process dies before the HTTP request to Sentry completes, and the most serious class of error is the one class that never gets reported. Two seconds is plenty.

The twenty console.error calls in the codebase did not need to change. Anything that reaches the Express error handler is captured automatically. For the ones that catch and return a 500 themselves, like the permissions middleware above, I added a single Sentry.captureException(error) line next to the existing log. Fifteen minutes of grep-and-paste.

The dashboard side

The React app needed a DSN, an error boundary, and sourcemaps that Sentry can read but the public cannot download.

// apps/dashboard/src/main.tsx
import * as Sentry from '@sentry/react';

Sentry.init({
  dsn: import.meta.env.VITE_SENTRY_DSN,
  environment: import.meta.env.VITE_ENVIRONMENT,
  release: import.meta.env.VITE_RELEASE,
});
<Sentry.ErrorBoundary fallback={<SomethingWentWrong />}>
  <App />
</Sentry.ErrorBoundary>

Then the sourcemaps. The config had sourcemap: false, which is the right choice for a public site and the wrong one for debugging. The fix is hidden, which generates the maps without referencing them from the bundle, plus the Sentry Vite plugin that uploads them at build time and then deletes them:

// apps/dashboard/vite.config.mjs, trimmed
import { sentryVitePlugin } from '@sentry/vite-plugin';

export default defineConfig({
  build: {
    sourcemap: 'hidden',
  },
  plugins: [
    react(),
    sentryVitePlugin({
      org: 'my-org',
      project: 'dashboard',
      authToken: process.env.SENTRY_AUTH_TOKEN,
      release: { name: process.env.IMAGE_TAG },
      sourcemaps: { filesToDeleteAfterUpload: ['./dist/**/*.map'] },
    }),
  ],
});

This runs in the CI image build from part 4, where SENTRY_AUTH_TOKEN is a secret and IMAGE_TAG is already set. The browser gets minified code, Sentry gets readable traces, and the maps never sit on the server.

The one alert rule

Sentry will happily alert on everything, and the second week of that is when people mute the channel forever. I have exactly one rule per project:

When an issue is first seen in environment prod, send a push notification.

That is it. Not “when an issue is seen”, which fires seventy times for the saving bug. First seen. Regressions after a deploy trigger it because Sentry treats a resolved issue reappearing as new. An error that has been happening quietly since launch does not trigger it, and it should not: it goes on the list I review on Monday, not on my phone at 2am.

The saving bug, replayed: first occurrence, one push notification with the stack trace and the request that caused it, about ten seconds after the customer clicked save for the first time. Not two days later by email.

What is still not fixed

No performance monitoring. tracesSampleRate: 0 is deliberate: I want to know what is broken, not what is slow, and slow can wait until the free quota is not a constraint.

The uncaught-exception path still exits the process, and until every service has a sane restart policy, an exit means downtime. Reporting the crash is progress; not crashing would be better, and the right restart policy is a two-line change I keep meaning to make across the whole compose file.

And there is a class of failure Sentry cannot see: the request that never reaches the application, because nginx rejected it or the container was not running. That is part 6’s job, and the two tools disagreeing is often the most useful signal of all.

The lesson

I had confused writing errors down with knowing about them. Twenty careful console.error calls produced a perfect record of every failure and told me about none of them, because a record is not a notification, and a log line is not a count.

Error tracking is the difference between “the customer emailed” and “my phone buzzed ten seconds after the first failure”. For a solo-built product, that difference is most of what “professional” means, and it took an afternoon.