jguillaumesio
prod-opsdevops

Les utilisateurs m'écrivaient pour des 500 avant que je les voie

Chaque erreur en production était loguée avec console.error puis mourait dans un fichier de log que personne ne lisait. Les clients étaient devenus mon suivi d'erreurs. Voici comment j'ai branché un suivi compatible Sentry dans Express et React, avec sourcemaps, regroupement, et une seule règle d'alerte qui se déclenche vraiment.

L’objet du mail était « la page fait un 500 quand je sauvegarde ». Poli, précis, et envoyé par une cliente qui rencontrait le bug depuis deux jours. Deux jours pendant lesquels l’API avait consciencieusement logué la stack trace, une soixante-dizaine de fois, dans un fichier que je n’avais jamais ouvert, parce que rien ne m’avait dit qu’il y avait une raison de le faire.

Cette cliente était mon suivi d’erreurs. Pas un outil, une personne, et une personne qui avait mieux à faire.

Ceci est la partie 7 d’une série sur le durcissement d’un SaaS construit en solo en production. Le contexte et la liste complète des points faibles sont dans l’article pilier. La partie 6 a rendu les logs visibles ; celle-ci explique pourquoi des logs visibles ne sont toujours pas du suivi d’erreurs, et ce que c’est.

Des logs ne sont pas des erreurs

L’application avait ce que j’aurais décrit comme de la gestion d’erreurs. Cherchez console.error dans l’API et vous obtenez vingt résultats. La plupart ressemblent à ça :

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

Et l’attrape-tout au niveau du processus, dans le provider Redis de tous les endroits possibles, parce que c’est le fichier qui était ouvert quand ça a été écrit :

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

Chacune de ces lignes est du code honnête. Il attrape l’erreur, il l’écrit, il ne l’avale pas. Et chacune a le même défaut : l’écriture va sur stdout, stdout va dans un fichier de log JSON plafonné à 10 Mo, et le fichier ne va nulle part. Une erreur qui survient soixante-dix fois ressemble exactement à une erreur qui survient une fois, étalée sur des lignes que je dois grepper. Une erreur toute neuve introduite par le déploiement d’hier ressemble exactement à la vieille qui est là depuis le lancement.

La distinction que j’avais ratée : les logs répondent à « que s’est-il passé à 14 h 32 » ; le suivi d’erreurs répond à « qu’est-ce qui est cassé maintenant, depuis quand, et combien de fois ». Le premier est une chronologie qu’on lit après que quelqu’un vous a dit de regarder. Le second est une liste qui vous le dit.

Remarquez aussi ce que fait ce handler uncaughtException après avoir logué : process.exit(1). Avec restart: no sur chaque service, ce que la partie 5 couvre, une exception non attrapée voulait dire que l’API loguait une ligne puis restait morte. Les 500 de la cliente étaient la version polie de ce mode de défaillance.

Ce que je voulais d’un outil

Quatre choses, et rien d’autre :

  1. Le regroupement. Soixante-dix occurrences d’un bug doivent être une ligne avec un compteur, pas soixante-dix lignes.
  2. « Nouveau depuis le dernier déploiement ». La question après chaque mise en production est de savoir si elle a cassé quelque chose, et l’outil doit y répondre sans que je cherche.
  3. De vraies stack traces depuis le navigateur. Le dashboard est un build Vite. Un app-3f2a.js:1:48213 minifié n’est pas un emplacement, et j’avais sourcemap: false dans la config.
  4. Une alerte qui m’atteint, pour les nouvelles erreurs seulement. Pas chaque occurrence. Les nouvelles.

Sentry fait les quatre. GlitchTip aussi, qui est open source, parle le protocole Sentry, et tourne en deux conteneurs sur le même VPS que tout le reste de cette série si vous voulez zéro coût récurrent. Le palier gratuit de Sentry couvre confortablement un produit de cette taille, donc c’est ce que j’ai pris ; chaque extrait ci-dessous fonctionne à l’identique contre un DSN GlitchTip auto-hébergé.

Côté API

Trois changements. D’abord, initialiser le plus tôt possible, avant que quoi que ce soit d’autre soit importé :

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

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.ENVIRONMENT,      // 'prod' ou 'dev', existait déjà
  release: process.env.IMAGE_TAG,            // le SHA du commit venu de la CI, voir partie 4
  tracesSampleRate: 0,                        // erreurs seulement, pas de données de performance
});

La ligne release est celle qui paie tout le reste. La partie 4 a fait porter à chaque image déployée son SHA de commit dans IMAGE_TAG ; le passer ici veut dire que Sentry sait quelles erreurs sont apparues dans quelle version, et « nouveau depuis le dernier déploiement » devient un filtre plutôt qu’une intuition.

Ensuite, le handler Express vient après les routes et avant tout autre middleware d’erreur :

// apps/api/server.ts, élagué
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' });
});

Enfin, et c’est celui que tout le monde oublie, les handlers au niveau du processus. Ils restent, parce qu’un processus qui ne peut pas continuer doit quand même sortir, mais la sortie attend maintenant que le rapport ait quitté la machine :

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

Sans le flush, le processus meurt avant que la requête HTTP vers Sentry se termine, et la classe d’erreur la plus grave est la seule qui n’est jamais rapportée. Deux secondes suffisent largement.

Les vingt appels console.error de la base de code n’ont pas eu besoin de changer. Tout ce qui atteint le handler d’erreur Express est capturé automatiquement. Pour ceux qui attrapent et renvoient eux-mêmes un 500, comme le middleware de permissions ci-dessus, j’ai ajouté une seule ligne Sentry.captureException(error) à côté du log existant. Quinze minutes de grep et de copier-coller.

Côté dashboard

L’app React avait besoin d’un DSN, d’un error boundary, et de sourcemaps que Sentry peut lire mais que le public ne peut pas télécharger.

// 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>

Puis les sourcemaps. La config avait sourcemap: false, qui est le bon choix pour un site public et le mauvais pour déboguer. La solution est hidden, qui génère les maps sans les référencer depuis le bundle, plus le plugin Vite de Sentry qui les téléverse au build puis les supprime :

// apps/dashboard/vite.config.mjs, élagué
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'] },
    }),
  ],
});

Ça tourne dans le build d’image en CI de la partie 4, où SENTRY_AUTH_TOKEN est un secret et IMAGE_TAG est déjà défini. Le navigateur reçoit du code minifié, Sentry reçoit des traces lisibles, et les maps ne traînent jamais sur le serveur.

La seule règle d’alerte

Sentry alertera volontiers sur tout, et la deuxième semaine de ça est le moment où les gens coupent le canal pour toujours. J’ai exactement une règle par projet :

Quand une issue est vue pour la première fois dans l’environnement prod, envoyer une notification push.

C’est tout. Pas « quand une issue est vue », qui se déclencherait soixante-dix fois pour le bug de sauvegarde. Vue pour la première fois. Les régressions après un déploiement la déclenchent parce que Sentry traite une issue résolue qui réapparaît comme nouvelle. Une erreur qui se produit discrètement depuis le lancement ne la déclenche pas, et elle ne devrait pas : elle va sur la liste que je relis le lundi, pas sur mon téléphone à 2 h du matin.

Le bug de sauvegarde, rejoué : première occurrence, une notification push avec la stack trace et la requête qui l’a provoquée, environ dix secondes après que la cliente a cliqué sur sauvegarder pour la première fois. Pas deux jours plus tard par email.

Ce qui n’est toujours pas corrigé

Pas de suivi de performance. tracesSampleRate: 0 est délibéré : je veux savoir ce qui est cassé, pas ce qui est lent, et lent peut attendre que le quota gratuit ne soit plus une contrainte.

Le chemin des exceptions non attrapées fait toujours sortir le processus, et tant que chaque service n’a pas une politique de redémarrage saine, une sortie veut dire de l’indisponibilité. Rapporter le crash est un progrès ; ne pas crasher serait mieux, et la bonne politique de redémarrage est un changement de deux lignes que je repousse depuis trop longtemps sur tout le fichier compose.

Et il y a une classe d’échec que Sentry ne peut pas voir : la requête qui n’atteint jamais l’application, parce que nginx l’a rejetée ou que le conteneur ne tournait pas. C’est le boulot de la partie 6, et le désaccord entre les deux outils est souvent le signal le plus utile de tous.

La leçon

J’avais confondu écrire les erreurs et être au courant. Vingt appels console.error soigneux produisaient un enregistrement parfait de chaque défaillance et ne m’en disaient aucune, parce qu’un enregistrement n’est pas une notification, et qu’une ligne de log n’est pas un compteur.

Le suivi d’erreurs, c’est la différence entre « la cliente a envoyé un mail » et « mon téléphone a vibré dix secondes après le premier échec ». Pour un produit construit en solo, cette différence est l’essentiel de ce que « professionnel » veut dire, et ça a pris un après-midi.