Runbooks & opérationsLa page de statut et la file de lettres mortes

La page de statut et la file de lettres mortes

Pilier platform-ops. Deux mecanismes qui n'ont rien en commun sauf le moment ou ils comptent : 3 h du matin, quelque chose casse, et la seule question est de savoir si un humain l'apprend. Ecrit avec platform-ops/0334…

Pilier platform-ops. Deux mecanismes qui n'ont rien en commun sauf le moment ou ils comptent : 3 h du matin, quelque chose casse, et la seule question est de savoir si un humain l'apprend.

Ecrit avec platform-ops/0334 et platform-ops/0335.

1. Le registre d'uptime ne contient que des observations

/status rend une barre par jour et par service sur 90 jours. Ce qu'il y a derriere chaque barre est une ligne StatusCheckDay, ecrite par le cron status-snapshot toutes les 30 minutes.

La regle : une sonde qui n'a rien observe n'ecrit rien.

probeStatusPage repond operational quand elle ne parvient pas a JOINDRE la statuspage d'un fournisseur. C'est un choix delibere et il est bon : la couche de reporting d'un tiers qui tombe ne doit pas nous peindre en rouge. Mais ce repli vaut pour le rendu live, jamais pour le registre. Il porte donc observed: false, et recordProbeBatch saute integralement une verification qui le declare : ni worstStatus, ni totalChecks, ni operationalChecks. La cellule du jour reste no-data.

Avant cette regle, une statuspage injoignable produisait 48 lignes vertes par jour et par service. La barre publique verdissait sur zero mesure, et totalChecks faisait croire a un echantillon dense. Ecrit en base, donc irrattrapable : la page de statut mentait a un client pendant un incident.

Et un pourcentage a besoin de quelque chose dont etre le pourcentage. uptimeOverMeasuredDays rend null quand aucun jour n'a ete mesure, et la page affiche « Pas de donnees » au lieu de « 100,00 % ». Une decimale, pas deux : 90 seaux quotidiens ne resolvent pas mieux que 1,1 point.

Et le denominateur est AFFICHE, pas seulement calcule. La fonction rend { pct, days } : days voyage avec le chiffre parce que 100 % sur un jour mesure et 100 % sur quatre-vingt-dix ne sont pas la meme phrase. Son unique lecteur jetait days pendant des mois, donc un service dont le registre ne tenait qu'un jour publiait « 100.0% uptime » sous un axe de 90 barres avec 89 cellules grises juste au-dessus du chiffre. La page rend desormais {pct}% · {days}/90 j, la phrase entiere en title (platform-ops/0624). Le garde null ci-dessus et celui-ci repondent a la meme question : ne jamais publier une certitude plus large que l'echantillon.

Les libelles des 90 cellules sont en UTC, parce que les 90 seaux le sont. getUptimeStrips part de startOfUtcDay(now) et recordProbeBatch classe chaque sonde sous utcDayKey() — caste en TEXT precisement pour qu'une session non-UTC ne classe pas une sonde le mauvais jour. Le formateur des libelles n'avait pas de timeZone : juste sur Vercel, decale d'un jour sur chaque cellule ailleurs. Les horodatages d'incident nomment desormais leur zone dans le texte, pas seulement dans l'attribut dateTime.

Le merge « le pire gagne » est fait par Postgres. Un unique INSERT … ON CONFLICT DO UPDATE dont le worstStatus est un maximum par rang. La paire lecture / calcul / ecriture qu'il remplace laissait deux passages concurrents — le cron toutes les 30 min et un declenchement manuel depuis l'admin — ramener une journee outage a operational : la barre publique perdait l'incident qu'elle existe pour conserver.

Quand ca casse a 3 h du matin

PanneCe qui se passeComment on l'apprend
Une statuspage tierce devient injoignableRien n'est ecrit pour ce service. La cellule du jour reste no-data, la barre est grise, le pourcentage tombe a « Pas de donnees »skipped > 0 dans l'outcome du run, visible sur /admin/platform/crons sans ouvrir un log, plus status.uptime.unobserved_checks_skipped en warn
Le cron status-snapshot s'arretePlus aucune ligne. Les barres grisent jour apres jour, aucun pourcentage n'est inventeLa tuile « Never run » du moniteur de crons, qui ne ment plus (voir §3)
Postgres est injoignable au rendugetUptimeStrips attrape, journalise status.uptime.read_failed, et rend une bande no-data avec la sonde live du jourLa page rend quand meme : une page de statut qui plante est un incident de plus

La ligne a ne pas franchir. Si une sonde future doit repondre par defaut, elle porte observed: false. Le repli est une decision d'affichage ; le registre, lui, n'accepte que ce qui a ete vu.

Deux listes d'incidents, et « passe » veut dire clos

getRecentIncidents(days) est une FENETRE, pas un etat : son where est { startedAt: { gte: since } }, donc un incident ouvert hier en fait partie. C'est voulu, et incidents.ts le documente. Ce qui ne l'etait pas : la page rendait getActiveIncidents() sous « Active incidents » et cette meme fenetre sous « Past Incidents ». Pendant un incident — le seul moment ou quelqu'un lit cette page — la carte ouverte apparaissait deux fois, la seconde sous un titre qui affirme que c'est termine.

Le filtre est au site de rendu (resolvedAt !== null), pas un second where : la page et /status/incidents.rss doivent continuer a s'accorder sur quels incidents EXISTENT, sinon un item du flux pointe vers une page qui ne le liste pas.

Consequence : un etat vide ne suffit plus. « Aucun incident signale au cours des 90 derniers jours » est FAUX quand une carte ouverte est posee au-dessus. Deux messages, donc : status.noIncidents pour un trimestre calme, status.onlyActiveIncidents pour un incident en cours dont rien n'est encore clos.

Le flux RSS a des permaliens, donc la page a des ancres

/status/incidents.rss emet le <link> et le <guid> de chaque item comme /status#incident-<id>. La carte ne portait qu'un data-incident-id, donc le fragment ne resolvait sur rien : le seul moyen d'atteindre un incident depuis un lecteur atterrissait en haut de la page. La carte porte maintenant l'id correspondant, plus scroll-mt-6 — l'ancre resout dans le conteneur de scroll de la page, MinimalShell interdisant le scroll du viewport, donc sans marge la carte colle au bord haut.

Le flux est aussi declare en autodiscovery (alternates.types dans le generateMetadata de la page) : il existait, la carte d'inscription y renvoyait, et un visiteur qui ne scrollait pas jusqu'a cette carte n'avait aucun moyen de le trouver. Pas de canonical en revanche — le layout racine ne declare aucun alternates, et en poser une moitie donnerait a cette page les six hreflang qu'il retient volontairement.

backgroundJobs : quota epuise ≠ plateforme down

La sonde lit readQueueBlock() et heavyJobQueueReadiness(), pas la statuspage Vercel (qui ne sait rien de QStash). Deux causes, deux mots :

CausekindStatut public
Allowance journaliere epuiseequotadegraded — se leve a minuit UTC, le travail critique declare inlineOnQuotaExhausted
QSTASH_TOKEN ou l'origine absentstoken / originoutage — rien ne s'enfile tant qu'un humain n'agit pas

Traiter le premier comme le second a peint /status, la pastille du pied de page et les barres 90 jours en rouge pendant une semaine pendant que signup, chat et checkout marchaient (platform-ops/0575).

2. La file de lettres mortes est balayee pour TOUS les types

QStash retente trois fois avec backoff, puis depose le message dans la DLQ. Le cron intelligence-dlq-retry (30 min) est le seul mecanisme qui l'en sort automatiquement.

Il filtrait sur /api/jobs/intelligence-scan-store : un type sur les dix-sept enregistres. Les seize autres s'accumulaient la ou personne ne regarde, /admin/platform/jobs etant une page qu'un operateur doit ouvrir, pas une alerte.

Le cas cher etait circulaire : cost-alerts confie son avertissement de marge a enqueue("send-email", …). L'alerte censee prevenir d'une derive de cout pouvait donc mourir, en silence, dans la file qu'elle aurait du declencher. Meme forme pour le digest hebdomadaire, un rafraichissement de jeton OAuth, un chiffrement de credentials, une indexation de connaissance.

Le cron balaie desormais tout /api/jobs/. Le filtre est applique cote code sur message.url : les semantiques de correspondance du parametre url de l'API QStash pour un prefixe de chemin ne sont pas un pari a faire sur la totalite du balayage.

Ce qui n'a pas change, et ne doit pas : la regle des sept jours (un message qui echoue depuis plus d'une semaine est un bug permanent, pas une panne passagere ; le retenter chaque demi-heure brule du quota pour toujours).

Ce qui a change avec platform-ops/0575 :

  • L'URL de retry est POST /v2/dlq/retry/{id}. Avant, c'etait POST /v2/dlq/{id} — la route DELETE — donc chaque retry revenait 405, et la file ne se vidait jamais.
  • Le SCAN reste une page (DLQ_RETRY_LIMIT_PER_TICK = 100, c'est le signal de profondeur). Le REPLAY est budgete (DLQ_RETRY_BUDGET_PER_TICK = 10) : 10 × 48 ticks = 480 messages/jour, sous la moitie du plafond QStash de 1 000. Sans ce plafond, une DLQ pleine aurait mange tout le quota du jour des le petit-matin.
  • Quand le quota est deja epuise (readQueueBlock()), le tick s'arrete sans rien retenter : un retry EST un publish.
  • jobs.dlq.backlog n'est en error que si le tick n'a fait AUCUN progres. Une file pleine qui se vide loggue jobs.dlq.draining en warn.

Quand ca casse a 3 h du matin

PanneCe qui se passeComment on l'apprend
Un type de job echoue en masseLes messages s'accumulent, le cron en rejoue 10 par tickbyType + byStatus dans l'outcome du run disent AVEC QUOI et POURQUOI
La file depasse une page et le tick avanceLe tick sert le budget, le reste attend le suivantjobs.dlq.draining en warn
La file depasse une page et rien ne bougeChaque retry du budget est refusejobs.dlq.backlog en error : c'est la ligne que le drain doit reveiller
Le quota QStash est epuiseLe tick s'arrete avant le listingcron.intelligence.dlq_retry.queue_blocked
QStash refuse le listingLe cron rend ok: false avec la raison, il n'invente pas une file videcron.intelligence.dlq_retry.list_failed

Le nom intelligence-dlq-retry et le chemin restent, alors que le cron ne balaie plus seulement l'intelligence. Les changer coute un slot vercel.json et orpheline l'historique CronExecution de ce cron, qui est le registre qu'un operateur lit pour savoir si le balayage est sain. Un nom un peu plus etroit que son role est le moindre des deux maux.

3. Le nom d'un cron est son chemin

/admin/platform/crons resout le nom qu'un cron ecrit dans CronExecution en lisant withCronAuth("<nom>", …) dans la source de la route, a l'execution :

readFileSync(join(process.cwd(), "src/app", cronPath…, "route.ts"))

Ce join est construit sur une variable d'execution. @vercel/nft ne peut pas le tracer, next.config.mjs ne nomme pas ces sources dans outputFileTracingIncludes, donc en production la lecture echoue et le panneau retombe sur le nom DERIVE du chemin.

Tant que les deux noms coincident, le repli est invisible. Un seul cron divergeait (intelligence/prune-history s'enregistrait en intel-prune-history), et sa tuile « Never run » etait donc bloquee a >= 1 pour toujours, pour un cron parfaitement sain. Un indicateur d'alerte qui ment en permanence apprend a l'operateur a ignorer l'indicateur.

src/test/cron-registered-name-matches-path.test.ts derive la regle de vercel.json pour les 59 crons : nom enregistre == chemin avec les / remplaces par des -. La classe est fermee, pas seulement le cas.

4. Les notifications d'incident passent par la file

Creer un incident depuis /admin/platform/status declenche un fan-out email vers les abonnes confirmes. Il chargeait TOUS les abonnes en un findMany sans borne et les envoyait a Resend en un Promise.all, dans le cycle de vie de la server action de l'admin. Passe quelques centaines d'abonnes : mur de rate-limit, chaque refus devenu un warn, et le mail perdu — le mail qui annonce a un client que la plateforme est en panne.

Desormais : curseur par pages de 200, un job status-incident-notify par destinataire, un deduplicationId par (incident, evenement, abonne). L'operateur recoit la main immediatement, QStash porte les trois retentatives, et ce qui leur survit tombe dans la DLQ que le §2 balaie.

Le handler relit l'incident en base plutot que de transporter son texte : un incident est edite pendant qu'il est ouvert — c'est ce que l'evenement updated EST — et une charge utile figee a l'enfilement enverrait une description que l'operateur a deja corrigee. Un incident supprime est un no-op, pas un echec : retenter un mail sur une ligne disparue depenserait trois tentatives puis un slot de DLQ pour quelque chose qui ne peut pas reussir.

Sans QSTASH_TOKEN (dev local), enqueue() execute le handler en ligne : le flux marche de bout en bout sans provisionner QStash.