❯ monitoring · Running

Mesurer son audience sans traceur : quand un import horodaté multiplie le trafic par 8 Counting Visitors Without a Tracker: How a Timestamped Import Multiplied My Traffic by Eight

Un collecteur rapatrie les agrégats horaires de l'API GraphQL de Cloudflare dans VictoriaMetrics. Pendant dix semaines, chaque compte par hôte valait environ huit fois la réalité : une fenêtre datée à sa fin, une base qui ne dédupliquait pas, et un commentaire qui promettait l'idempotence. Le diagnostic, le correctif, la reconstruction de l'historique, et pourquoi la règle « la plus grande valeur gagne » imposait de supprimer des séries. A collector pulls hourly aggregates from Cloudflare's GraphQL API into VictoriaMetrics. For ten weeks, every host-level count was about eight times too high: a window dated at its end, a database that did not deduplicate, and a docstring that promised idempotence. How the bug was found, fixed and the history rebuilt, and why the max-wins rule made deleting series the only way out.

· 10 min de lecture 10 min read ·
monitoringvictoriametricscloudflarepromqlgrafanavmalert
🇬🇧 Read this article in English

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 de httpRequests1hGroups ;
  • la ventilation fine par hôte, pays et statut (cfa_host_requests), et les sondages de sous-domaines (cfa_scan_requests), tirés de httpRequestsAdaptiveGroups.

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 :

SourceRequêtes
API GraphQL Cloudflare47
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.

  1. 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.
  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_time les 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.
  3. 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.

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 datetimeHour et 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 :

  1. 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.
  2. vmsingle relancé avec la déduplication, collecteur corrigé déployé, un passage du nouveau code terminé.
  3. 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"
  1. Un rattrapage (MODE=backfill) qui rejoue les 30 derniers jours d’adaptive, heure close par heure close, exactement comme la collecte en direct.
  2. 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 :

  1. 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.
  2. 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.
  3. 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 branche absent_over_time comble le trou.
  4. vmalert perdait l’état de ses alertes au redémarrage : un redémarrage ordinaire produisait un RESOLVED puis un FIRING. Le paramètre -remoteRead.url restaure l’état depuis la base.

Leçons retenues

  1. 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=1ms ne touche que les points de même horodatage.
  2. 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.
  3. Lire l’export brut quand un chiffre surprend. Compter les échantillons par point dit tout de suite combien d’écritures se superposent.
  4. « 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.
  5. 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.
  6. 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.

monitoringvictoriametricscloudflarepromqlgrafanavmalert