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/0334etplatform-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
| Panne | Ce qui se passe | Comment on l'apprend |
|---|---|---|
| Une statuspage tierce devient injoignable | Rien 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'arrete | Plus aucune ligne. Les barres grisent jour apres jour, aucun pourcentage n'est invente | La tuile « Never run » du moniteur de crons, qui ne ment plus (voir §3) |
| Postgres est injoignable au rendu | getUptimeStrips attrape, journalise status.uptime.read_failed, et rend une bande no-data avec la sonde live du jour | La 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 :
| Cause | kind | Statut public |
|---|---|---|
| Allowance journaliere epuisee | quota | degraded — se leve a minuit UTC, le travail critique declare inlineOnQuotaExhausted |
QSTASH_TOKEN ou l'origine absents | token / origin | outage — 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'etaitPOST /v2/dlq/{id}— la route DELETE — donc chaque retry revenait405, 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.backlogn'est enerrorque si le tick n'a fait AUCUN progres. Une file pleine qui se vide logguejobs.dlq.drainingenwarn.
Quand ca casse a 3 h du matin
| Panne | Ce qui se passe | Comment on l'apprend |
|---|---|---|
| Un type de job echoue en masse | Les messages s'accumulent, le cron en rejoue 10 par tick | byType + byStatus dans l'outcome du run disent AVEC QUOI et POURQUOI |
| La file depasse une page et le tick avance | Le tick sert le budget, le reste attend le suivant | jobs.dlq.draining en warn |
| La file depasse une page et rien ne bouge | Chaque retry du budget est refuse | jobs.dlq.backlog en error : c'est la ligne que le drain doit reveiller |
| Le quota QStash est epuise | Le tick s'arrete avant le listing | cron.intelligence.dlq_retry.queue_blocked |
| QStash refuse le listing | Le cron rend ok: false avec la raison, il n'invente pas une file vide | cron.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.