Contexte : la stack de monitoring (VictoriaMetrics, vmalert, Alertmanager, Telegram) est décrite dans Observabilité v2, et la dernière relecture des tableaux Grafana a déjà fait l’objet d’un article. Ici, il est question d’un tableau qui ne manquait de rien : il affichait simplement des chiffres huit fois trop grands.
Le contexte : une audience sans une ligne de JavaScript
Le blog et les services publics du homelab passent tous par un tunnel Cloudflare. Cloudflare compte donc déjà chaque requête, avant même qu’elle n’atteigne le cluster. Plutôt qu’ajouter un script de mesure dans les pages, j’ai écrit fin juillet un petit collecteur Python : un CronJob qui, toutes les 15 minutes, interroge l’API GraphQL Analytics de Cloudflare (plan gratuit), aplatit les agrégats et les pousse dans VictoriaMetrics au format d’exposition Prometheus, avec un horodatage explicite pour chaque point.
Deux familles de séries en sortent :
- les agrégats horaires de la zone (
cfa_requests,cfa_bytes,cfa_threats…), tirés dehttpRequests1hGroups; - la ventilation fine par hôte, pays et statut (
cfa_host_requests), et les sondages de sous-domaines (cfa_scan_requests), tirés dehttpRequestsAdaptiveGroups.
Chaque passage relit les deux dernières heures closes. C’est voulu : un passage raté ne perd rien, le suivant relit la même plage. Le commentaire de la fonction d’export résumait la philosophie avec assurance :
# L'horodatage rend l'import IDEMPOTENT : rejouer la meme fenetre ecrit les
# memes points aux memes instants, sans doublon.
Cette phrase est fausse. Il a fallu dix semaines pour s’en apercevoir.
Le symptôme : 47 requêtes chez Cloudflare, 420 dans Grafana
Le 11 octobre 2026, en confrontant une heure précise du tableau « Audience » à la source, les deux chiffres refusent de se ressembler. Nextcloud, statut 302, heure 19:00Z du 9 octobre :
| Source | Requêtes |
|---|---|
| API GraphQL Cloudflare | 47 |
VictoriaMetrics (sum_over_time sur l’heure) | 372 à 420 |
Un facteur d’environ 8. Pas une dérive, pas un arrondi : un multiple. Et les agrégats horaires de la zone, calculés par un tout autre chemin, montraient le même facteur.
Un tableau d’audience ne déclenche aucune alerte quand il est trop optimiste. Personne ne se plaint d’avoir huit fois plus de visiteurs que prévu.
L’investigation : lire l’export brut, pas le tableau
Grafana agrège ; il cache donc précisément ce qu’on cherche. L’endpoint /api/v1/export de VictoriaMetrics, lui, rend chaque échantillon stocké, horodatage compris. Pour un seul point horaire de cfa_host_requests, il y avait 12 échantillons. Et 12, c’est exactement 3 chemins HTTP × 4 passages : chaque échantillon était égal à la somme d’un chemin sur une fenêtre de 2 h.
Trois causes se cumulaient.
- La fenêtre de 2 h était datée à sa fin. Le passage de 20:15 lisait 18:00-20:00 et écrivait tout à 20:00. Celui de 21:15 lisait 19:00-21:00 et écrivait à 21:00. L’heure 19:00 comptait donc dans deux points consécutifs : ×2.
- VictoriaMetrics ne dédupliquait pas. Les quatre passages d’une même heure écrivaient quatre fois le même point au même horodatage, et la base gardait les quatre copies.
sum_over_timeles additionnait toutes : ×4. Pour les agrégats horaires, correctement datés au début de l’heure mais relus dans deux fenêtres, c’étaient 8 copies du même point. - Un point par chemin, avec les mêmes labels. Le chemin n’est utile que pour les sondages ; sur le trafic légitime, il était lu mais pas exposé comme label. Trois chemins d’un même hôte produisaient trois points indiscernables, que l’absence de déduplication additionnait… par accident, et correctement.
une heure, un hôte, un statutone hour, one host, one status
Le compte de référence : 47 requêtes.The reference count: 47 requests.
toutes les 15 min, 2 h reluesevery 15 min, 2 h re-read
Chaque heure close est relue par 8 passages. Voulu : rien ne se perd si un passage échoue.Each closed hour is re-read by 8 runs. Intended: nothing is lost if one run fails.
fenêtre de 2 h datée à sa fin2 h window dated at its end
La même heure tombe dans deux points consécutifs : ×2.The same hour lands in two consecutive points: ×2.
4 passages, même horodatage4 runs, same timestamp
La base garde les 4 copies, sum_over_time les additionne : 372 à 420 au lieu de 47.The database keeps all 4 copies, sum_over_time adds them up: 372 to 420 instead of 47.
datetimeHour + dedup 1msdatetimeHour + 1ms dedup
Un point par heure du trafic et par série ; la base n'en garde qu'un. Retour à 47.One point per traffic hour and per series; the database keeps only one. Back to 47.
La troisième cause est la plus perfide. Tant que la base ne déduplique pas, elle se compense. Le jour où l’on active la déduplication pour corriger la deuxième, elle devient un bug à son tour : la base ne garde plus qu’un des trois chemins.
Le contexte juste nécessaire : ce que « dédupliquer » veut dire ici
Par défaut, VictoriaMetrics accepte plusieurs échantillons d’une même série au même horodatage. Pour des séries scrapées, la question ne se pose jamais : vmagent date chaque point à l’instant du scrape. Pour un import horodaté qui réécrit volontairement le passé, c’est un piège.
Le réglage -dedup.minScrapeInterval fusionne les échantillons d’une série tombant dans le même intervalle. Avec 1 ms, il ne fusionne que les points de même horodatage exact : sans effet sur les séries scrapées, décisif pour cet import. La règle de fusion n’est pas « le dernier écrit gagne » mais « la plus grande valeur gagne », à la requête comme lors des fusions sur disque.
# monitoring/vm/vmsingle-values.yaml
server:
retentionPeriod: "15" # sans unité : 15 mois, pas 15 jours
extraArgs:
dedup.minScrapeInterval: 1ms
Au passage, la documentation du dépôt annonçait 15 jours de rétention. retentionPeriod sans unité, c’est en mois. Une erreur dans le bon sens, pour une fois.
Le correctif : un point par heure du trafic et par série
Côté collecteur, trois changements :
- chaque ligne adaptive demande la dimension
datetimeHouret est datée à l’heure du trafic, plus à la fin de la fenêtre ; - les chemins sont sommés par série avant l’export : un passage n’émet jamais deux points de la même série au même instant ;
- la base déduplique, et la docstring dit enfin la vérité : l’import n’est idempotent que parce que VictoriaMetrics déduplique.
cle = (name, tuple(sorted(labels.items())), _ts_ms(heure))
totaux[cle] = totaux.get(cle, 0.0) + float(row.get("count") or 0)
Les tests ont été écrits d’abord, avec deux faux : un faux Cloudflare qui groupe réellement selon les dimensions demandées, et un faux VictoriaMetrics qui déduplique comme le vrai. 38 tests, puis 12 mutations volontaires du code, toutes détectées. Sans le faux qui déduplique, la troisième cause serait passée inaperçue : les tests auraient additionné les chemins comme la base le faisait.
Le piège dans le piège : une page de 500 lignes
En relisant la requête, un second défaut est apparu. La requête adaptive avait une limite de 500 groupes, dimensionnée le 29 juillet sur une mesure de 71 groupes pour 2 h, « marge ×7 ». Une rafale de sondage de sous-domaines plus tard, la pire heure mesurée comptait 9 021 groupes (le 22 septembre). La réponse était coupée en silence, plus gros groupes d’abord : les sondages les plus rares disparaissaient.
Les limites du plan gratuit, lues dans les réglages de l’API, sont les suivantes : maxPageSize de 10 000, 31 jours d’historique pour l’adaptive, 73 h pour les agrégats horaires, 365 jours pour les journaliers. La limite passe donc à 10 000, et une page pleine est redécoupée en deux, récursivement, jusqu’à l’heure. Si une heure seule déborde encore, une sentinelle cfa_query_truncated le dit : pas de plafond silencieux.
La réparation : quand « la plus grande valeur gagne » interdit de corriger
Activer la déduplication répare d’un coup les agrégats horaires : leurs huit copies portaient le même horodatage, la base garde la plus grande, qui est aussi la plus complète. Tout l’historique horaire redevient exact.
Pour cfa_host_requests et cfa_scan_requests, c’est l’inverse. Leurs anciens points, datés à la fin de la fenêtre de 2 h, tombent sur les mêmes heures pleines que les nouveaux. Dédupliqué, un ancien point vaut le plus gros chemin de sa fenêtre de 2 h, par exemple 70 au lieu de 50 pour Nextcloud 302. Il est plus grand que la bonne valeur, donc il gagne. Ni la collecte, ni un rattrapage ne peuvent le corriger.
Seule issue : supprimer ces séries et les reconstruire. Et VictoriaMetrics ne supprime que des séries entières, pas une période. La procédure, dans l’ordre :
- Sauvegarde brute d’abord, via
/api/v1/export: après les fusions, les copies disparaissent pour de bon. 2 361 séries, 589 906 échantillons, réimportables tels quels. - vmsingle relancé avec la déduplication, collecteur corrigé déployé, un passage du nouveau code terminé.
- Suppression exacte des deux noms de séries, aperçu compris. Jamais
cfa_.*, qui emporterait les agrégats horaires, que Cloudflare ne garde que 73 h :
M='match[]={__name__=~"cfa_host_requests|cfa_scan_requests"}'
curl -s "$VM/api/v1/series" --data-urlencode "$M" --data-urlencode 'start=2026-01-01T00:00:00Z' | jq '.data | length'
curl -s -X POST "$VM/api/v1/admin/tsdb/delete_series" --data-urlencode "$M"
- Un rattrapage (
MODE=backfill) qui rejoue les 30 derniers jours d’adaptive, heure close par heure close, exactement comme la collecte en direct. - Contrôle contre la source.
Résultat : 2 072 séries supprimées, les 289 autres séries cfa_* intactes, reconstruction à partir du 11 septembre. Contrôle heure par heure contre GraphQL : 3 346 couples hôte × heure identiques sur 3 346 (145 192 requêtes des deux côtés), et 72 heures sur 72 pour le total de la zone. Avant le 11 septembre, les panneaux par hôte sont vides. Un trou honnête vaut mieux qu’un historique faux.
La relecture a aussi corrigé la classification : une dizaine d’hôtes réels, dont les services multimédia du NAS, servis en 200 et 304 depuis des semaines, étaient comptés comme « sous-domaines sondés » parce qu’ils manquaient à la liste des hôtes légitimes.
Le lendemain : surveiller le collecteur, puis surveiller l’alerte
Un collecteur qui tombe en panne ne se voyait que sur le tableau. Le groupe vmalert cf-analytics corrige ça avec deux règles :
CfAnalyticsCollectorDown(warning) : une seule règle pour les deux pannes, passages en échec ou plus rien de publié. Elle sonne si et seulement si le troisième passage de suite est raté, 55 minutes après la dernière réussite. Chaque heure étant relue pendant 2 h, rien n’est perdu tant qu’une collecte réussit dans l’heure et 45 minutes qui suit la précédente : sonner plus tôt serait du bruit.CfAnalyticsQueryTruncated(info) : une heure déborde encore la page de 10 000 groupes.
Avant de la déployer, la règle a été rejouée sur l’historique, du 29 juillet au 11 octobre. Elle n’aurait jamais sonné pour une panne du collecteur (16 minutes d’échecs consécutifs au plus), seulement pendant les arrêts de VictoriaMetrics lui-même, où en direct rien n’aurait sonné de toute façon. Dans la foulée, KubeJobFailed ignore les Jobs de ce CronJob : chaque essai raté produisait un FIRING puis un RESOLVED, même quand le troisième essai avait publié.
Les relectures de ces règles ont fait remonter quatre angles morts plus anciens, dans la chaîne d’alerte elle-même :
- Alertmanager n’était pas scrapé. Un envoi Telegram refusé ne laissait aucune trace. Il a maintenant son job et une alerte sur
alertmanager_notifications_failed_total. - Au-delà de 4 096 caractères, Alertmanager remplace tout le message Telegram par un texte générique, sans compter d’échec. Un jour, 82 alertes ont sonné ensemble : 12 915 caractères. Le gabarit en liste désormais 15 et donne le total.
rate()rend un résultat vide, pas zéro, quand l’ingestion s’arrête complètement. La règle « moins d’une ligne insérée par seconde » posée lors de la relecture précédente ne pouvait donc pas sonner dans le pire cas. Une brancheabsent_over_timecomble le trou.- vmalert perdait l’état de ses alertes au redémarrage : un redémarrage ordinaire produisait un RESOLVED puis un FIRING. Le paramètre
-remoteRead.urlrestaure l’état depuis la base.
Leçons retenues
- Un import horodaté n’est idempotent que si la base déduplique. Vérifier le réglage de la base avant d’écrire « idempotent » dans un commentaire. Sur VictoriaMetrics :
-dedup.minScrapeInterval=1msne touche que les points de même horodatage. - Dater un agrégat au début de la période qu’il mesure, jamais à la fin de la fenêtre de lecture. Si l’API fournit la dimension temporelle (
datetimeHour), la demander. - Lire l’export brut quand un chiffre surprend. Compter les échantillons par point dit tout de suite combien d’écritures se superposent.
- « La plus grande valeur gagne » rend les erreurs par excès définitives. Une valeur trop petite se corrige en réécrivant ; une valeur trop grande impose de supprimer la série entière. Sauvegarder l’export brut avant, supprimer le nom exact, reconstruire dans la limite de rétention de la source.
- Les tests doivent simuler la base, pas seulement l’API. Un faux VictoriaMetrics qui déduplique a révélé un bug que la vraie base masquait.
- Surveiller la chaîne d’alerte comme un service : le notificateur, la taille des messages, le cas « plus aucune donnée ».
Le tableau « Audience » affiche aujourd’hui des chiffres huit fois plus modestes. Ils ont l’avantage d’être vrais.