Le matin où Sentry m'a menti dans le bon sens
Un mardi matin de mi-avril, j'ouvre le digest des crons de Rembrandt, l'ERP que je code seul pour L'Atelier Palissy. La veille, j'avais wrappé quatre crons critiques avec Sentry.withMonitor pour enfin avoir des SLOs lisibles, et j'attendais ce premier digest comme un dimanche matin de résultats. Le tableau est rouge. 41 timeouts sur sync-formidable en cinq heures, 9 sur check-replies, 1 sur sync-pennylane, et un SLO API 5xx déclenché en prime. Je m'assieds.
Niran, le stagiaire de vingt ans qui passe ses samedis au dojo et ses semaines en hoodie sombre, est déjà là, canette de soda à côté du laptop. Il jette un œil à mon écran, jette un œil à Supabase Studio, et glisse sans lever les yeux : « Les leads d'hier sont en base. » Ils y sont. Les insertions dans contacts ont eu lieu, les pipelines lead-pipeline ont tourné, les notifications Slack sont parties. Le cron a fait son travail. Ce que Sentry me raconte, c'est l'inverse de ce que la base me confirme — un mensonge d'instrument qui m'oblige à comprendre l'instrument avant de toucher au code.
Le shutdown serverless et le buffer qui ne part jamais
Sentry.withMonitor(slug, fn, config) est un sandwich propre sur le papier. Il envoie un check-in in_progress au démarrage, exécute fn, puis envoie un check-in ok ou error à la sortie. Tant que le process vit assez longtemps pour que les deux check-ins soient effectivement envoyés, le monitor reflète la réalité. Le piège commence là où vit ta fonction.
Sur Vercel, une route est une lambda. Le runtime gèle le worker dès que le handler return — ou plus exactement, dès que le runtime estime que la réponse HTTP est partie. Or le transport Sentry est asynchrone par construction : les events sont mis dans un buffer en mémoire, qu'un worker en arrière-plan pousse vers l'ingest. Si le worker n'a pas eu sa fenêtre, le buffer part avec la lambda. Le check-in in_progress était parti pendant l'exécution, lui ; le check-in ok final ne l'a jamais été. Sentry voit un cron qui a démarré et qui n'a jamais répondu, attend maxRuntime, et déclare le monitor en timed out.
C'est la signature parfaite du bug silencieux et inversé : ton code fonctionne, ton observabilité ment dans le sens d'une fausse alerte, et tu vas perdre une journée à vérifier ce qui ne va pas avant d'admettre que ce qui ne va pas, c'est ton observabilité elle-même.
Le pattern correct, dans lib/sentry-monitors.ts
La règle tient en cinq lignes. await Sentry.flush(2000) dans un finally, autour de l'appel Sentry.withMonitor. Le flush force le buffer à se vider avant que la lambda rende la main, avec un timeout de deux secondes pour ne pas faire dépasser le maxDuration Vercel si le réseau Sentry tousse.
// lib/sentry-monitors.ts
export async function withCronMonitor<T>(
slug: string,
config: CronConfig,
fn: () => Promise<T>,
): Promise<T> {
if (!process.env.SENTRY_DSN) return fn()
try {
return await Sentry.withMonitor(slug, fn, {
schedule: { type: 'crontab', value: config.schedule },
checkinMargin: config.checkinMargin ?? 5,
maxRuntime: config.maxRuntime ?? 30,
timezone: config.timezone ?? 'Etc/UTC',
failureIssueThreshold: 1,
recoveryThreshold: 1,
})
} finally {
// Serverless : forcer le flush du check-in ok/error avant que la lambda
// soit freezée. Sans ça, Sentry n'envoie jamais le second check-in et
// déclare un timeout après maxRuntime.
try {
await Sentry.flush(2000)
} catch (flushErr) {
console.warn('[sentry-monitors] flush failed:', flushErr)
}
}
}
Le try/catch autour du flush n'est pas un excès de prudence. C'est l'invariant le plus important du wrapper : un échec de connexion à Sentry ne doit jamais bloquer le cron lui-même. Tu observes, tu n'intercèdes pas. Si l'instrument tombe, le métier passe.
L'appel côté handler reste sobre — c'est le wrapper qui porte toute la mécanique :
// app/api/cron/sync-formidable/route.ts
export async function GET(request: NextRequest) {
return withCronMonitor(
'sync-formidable',
CRON_CONFIGS['sync-formidable'],
() => handleSync(request),
)
}
Une autre cohérence à tenir, qu'on découvre le jour où elle manque : maxDuration côté Vercel doit être strictement inférieur à maxRuntime côté Sentry. Si la lambda est tuée par SIGTERM avant d'atteindre le finally, le flush n'a même pas la chance d'échouer — il n'est jamais appelé. Sur sync-formidable, ça s'est traduit plus tard par un second incident (TEF-ERP-2, commit 2282dd9) où une requête WordPress lente faisait dépasser les 60 s côté Vercel : on a ajouté un AbortSignal.timeout(8_000) par fetch pour garantir que le finally est toujours atteint.
La règle s'étend à tout serverless court qui produit des events Sentry
Le piège n'est pas réservé aux crons. Il vit dans toute fonction serverless suffisamment courte pour que la lambda soit gelée avant le worker Sentry. Webhooks rarement appelés, API routes utilitaires, callbacks OAuth, route de healthcheck instrumentée — partout où tu dépends d'un event qui devrait partir mais n'a pas le temps de quitter le buffer. La règle générique s'écrit en une phrase : toute route serverless qui produit des events Sentry termine son chemin par un await Sentry.flush(2000) dans un finally, sans exception. C'est l'écho côté observabilité de la règle bien connue côté business : await tout ce qui doit être terminé avant le retour, parce qu'en serverless, return veut dire die.
Coda
Quatre semaines après ce mardi rouge, le digest matinal est devenu un objet calme. Les check-ins ok arrivent, les check-ins error arrivent quand ils doivent arriver, et les SLOs reflètent ce que la base raconte. Cinq lignes de finally ont remis sous radar des incidents qui glissaient hors de mon champ de vision depuis le premier wrap, et m'ont surtout appris une chose plus large : un système d'observabilité qui n'est pas testé dans les conditions exactes de son runtime — cold start, gel de lambda, transport asynchrone — n'observe rien, il invente. Le vrai cron critique, c'est celui qui flushe.
Code compagnon : rembrandt-samples/sentry-cron-flush-serverless/ — pattern flush + handler exemple Vercel cron, MIT.

Top comments (0)