Tips & Tricks Statnive Live · Parhum Khoshbakht

Un million de pages vues par minute sur un seul serveur : comment Statnive Live a été conçu pour passer à l'échelle

Comment un binaire Go, des rollups ClickHouse et un traceur de 687 octets gèrent un million de pages vues par minute sur un serveur 8 cœurs — sans ralentir votre site.

La performance d’analyse web est un problème de vitesse de site

La plupart des articles sur la performance d’analyse web s’intéressent au backend — combien d’événements par seconde le serveur peut-il absorber. Ce n’est pas le bon indicateur pour commencer. Ce que le propriétaire d’un site paie vraiment, c’est ce que votre script d’analyse fait au temps de chargement de vos visiteurs — et donc aux Core Web Vitals, au taux de conversion et au référencement.

Les Core Web Vitals de Google (INP a remplacé FID le 12 mars 2024) — LCP, INP, CLS — constituent un signal de classement. L’analyse du JavaScript sur mobile est environ 2 à 5 fois plus lente que sur ordinateur, ce qui signifie qu’un script d’analyse de 50 Ko sur ordinateur peut représenter un coût d’analyse équivalant à 200 Ko sur un téléphone. Les scripts d’analyse bloquant le rendu sont de loin le principal facteur de dégradation des performances dans cette catégorie.

Statnive Live a été conçu en tenant compte de cette asymétrie. Les chiffres phares — 200 M d’événements par jour et par nœud, un traceur de 687 octets, une latence p99 inférieure à 500 ms — sont tous au service d’un seul objectif : la couche d’analyse ne doit jamais être la raison pour laquelle votre paiement ralentit. Cet article explique comment, avec les chemins de fichiers pour que vous puissiez vérifier chaque affirmation.

Il s’agit du dernier article de notre série de lancement en quatre parties de statnive.live. Chaque fois que nous avançons un chiffre vérifiable, vous trouverez le fichier ou la commande qui le prouve.

Le traceur de 687 octets

Le traceur Statnive Live mesurait 1 394 octets minifiés / 687 octets gzippés au 28 avril 2026. Ce ne sont pas des chiffres aspirationnels — ce sont les octets que la directive go:embed de Go a gravés dans le binaire, et vous pouvez les retrouver dans n’importe quel clone du dépôt :

$ wc -c internal/tracker/dist/tracker.js
    1394 internal/tracker/dist/tracker.js

$ gzip -9 -c internal/tracker/dist/tracker.js | wc -c
     687

Ces chiffres ne dérivent pas, parce que le fichier est intégré via go:embed dans le binaire — vous ne pouvez pas livrer un traceur différent de celui de votre dépôt sans recompiler. Et ils ne peuvent pas grossir sans être détectés : un test Go dans internal/tracker/tracker_test.go impose le budget à 1 500 octets minifiés / 700 octets gzippés, et fait échouer la compilation si l’un ou l’autre seuil est dépassé :

const (
    maxMinifiedBytes = 1500
    maxGzippedBytes  = 700
)

Ce même test interdit toute la forme d’un traceur non trivial — XMLHttpRequest, localStorage, sessionStorage, indexedDB, document.cookie, URL en clair, imports CDN — par grep de chaîne sur le bundle embarqué. Si un refactoring importait accidentellement une bibliothèque de transport plus volumineuse, ou si une nouvelle fonctionnalité faisait appel à localStorage, la CI rejette la PR.

Pour comparaison, le script gtag.js de GA4 pèse environ 110 Ko compressé, et le chiffre publié par Plausible est de 135 Ko gzippés pour le même script. Dans les deux cas : le traceur de Statnive Live est deux ordres de grandeur plus léger — plus de 50 fois moins lourd que GA4, quelle que soit la référence GA4 retenue.

Le transport utilise sendBeacon plus fetch keepalive — tous deux en mode fire-and-forget, aucun ne bloque le thread principal. La structure est un IIFE en JavaScript vanilla ; il n’y a pas de framework, parce que 1 394 octets n’en acceptent aucun. Le traceur est livré en première partie via go:embed : pas de CDN externe, pas de résolution DNS en dehors du domaine de l’opérateur, pas de gestionnaire de tags tiers. La règle d’air-gap-validator en CI rejette tout changement de traceur qui réintroduirait une référence externe.

La chaîne d’ingestion — fire-and-forget, WAL en premier

Le contrat du traceur avec le propriétaire du site, c’est « cela ne doit jamais bloquer ma page ». Le contrat du serveur avec le traceur, c’est « cela ne doit jamais perdre votre événement ». La chaîne d’ingestion de Statnive Live est construite pour rendre les deux contrats peu coûteux à tenir.

Chaque requête d’ingestion passe par un Write-Ahead Log avant que le handler réponde 202. Le handler attend le fsync — mais sur un ticker de commit groupé à 100 ms, et non par événement, car un fsync par événement plafonnerait le débit à ~100 événements/s sur un disque standard, alors que nous avons besoin de ~7 K EPS soutenus sur le palier SaaS. Le WAL est tidwall/wal (licence MIT, vendorisé), ouvert avec NoSync: true ; le ticker à 100 ms gère la durabilité. Le handler attend via AppendAndWait avant d’envoyer son ack 202. Si le sync échoue, le processus se termine — l’analyse web n’est pas le bon endroit pour corrompre silencieusement l’historique.

Le handler plafonne les corps de requête à 8 Ko via http.MaxBytesReader de Go :

const (
    maxBodyBytes  = 8 * 1024  // 8 KB MaxBytesReader
    maxArrayItems = 10        // batch at most 10 events per request
    uaMinLen      = 16
    uaMaxLen      = 500
)

Avant le WAL, une porte de rejet rapide écarte les requêtes manifestement indésirables avec un HTTP 204 — longueur d’User-Agent hors de la plage 16–500, UA non-ASCII, IP-as-UA, UUID-as-UA, en-têtes de préchargement (X-Purpose, X-Moz). Ces requêtes n’atteignent jamais l’enrichissement, le WAL ni les rollups. L’insertion asynchrone ClickHouse existe, mais uniquement sur un endpoint /ingest-fallback séparé — jamais sur le chemin principal /api/event.

La limitation de débit est compatible CGNAT : les requêtes provenant d’ASN d’opérateurs mobiles reçoivent une clé composée (ip, site_id) à 1 K req/s soutenu / 2 K en rafale, tandis que les autres retombent sur 100 req/s par IP. Un plafond global par site_id de 25 K req/s empêche qu’un client ne sature l’hôte. La gestion du CGNAT est importante car une passerelle de réseau téléphonique peut se trouver derrière une seule IP — une limite naïve par IP blacklisterait des milliers de visiteurs légitimes sur le même opérateur.

L’IP brute n’est jamais persistée. Elle entre dans la chaîne uniquement pour la recherche GeoIP, puis est supprimée avant que le batch writer ne voie la ligne. Le journal d’audit est également sans IP par conception — le rate-limiter continue d’utiliser l’IP pour la décision de limitation, mais la sérialisation du journal d’audit l’efface. La règle gdpr-code-review applique cela en CI.

La chaîne de requête — trois rollups + HyperLogLog

Le tableau de bord ne requête jamais les événements bruts. Toutes les lectures du tableau de bord proviennent des tables de rollup — c’est la Règle d’Architecture 1, appliquée par un point de contrôle CI. La table brute events_raw est en écriture seule, à l’exception des fenêtres d’entonnoir qui appellent windowFunnel() avec un résultat mis en cache une heure.

Les trois rollups v1 sont des vues AggregatingMergeTree, toutes indexées en premier sur site_id :

  • hourly_visitorsENGINE = AggregatingMergeTree() PARTITION BY toYYYYMM(hour) ORDER BY (site_id, hour)
  • daily_pagesORDER BY (site_id, day, pathname)
  • daily_sourcesORDER BY (site_id, day, channel, referrer_name, utm_source, utm_medium)

La cardinalité des visiteurs est mesurée par HyperLogLog via AggregateFunction(uniqCombined64, FixedString(16)) — environ 0,5 % d’erreur, mémoire sous-linéaire. Le FixedString(16) est un hash BLAKE3-128, tronqué à 16 octets ; l’identité est BLAKE3(daily_salt || identity_input), avec le sel quotidien dérivé comme HMAC(master_secret, site_id || YYYY-MM-DD), renouvelé chaque jour et jamais persisté. Même visiteur, hash différent chaque jour — et les rollups ne portent que l’état du hash, jamais l’entrée.

Chaque requête du tableau de bord passe par un seul helper :

// whereTimeAndTenant emits the WHERE clause every read query MUST start
// with: site_id = ? AND <timeColumn> >= ? AND <timeColumn> < ?.
// site_id is the first WHERE term so the (site_id, …) ORDER BY prefix
// can prune partitions cleanly.
func whereTimeAndTenant(f *Filter, timeColumn string) (string, []any) {
    clause := fmt.Sprintf("WHERE site_id = ? AND %s >= ? AND %s < ?",
        timeColumn, timeColumn)
    return clause, []any{f.SiteID, f.From, f.To}
}

Une règle CI rejette toute nouvelle requête qui contourne whereTimeAndTenant ou ne commence pas par WHERE site_id = ?. Cela peut sembler pointilleux ; en pratique, c’est la différence entre des partitions correctement élaguées et un ClickHouse multi-tenant qui scanne les données de tous les clients à chaque affichage du tableau de bord.

Nullable(...) est interdit pour les colonnes d’analyse — son coût mesuré est de 10 à 200 % sur les agrégations (le document de projet 20 a mesuré 2× sur Nullable(Int8)). Les rollups utilisent DEFAULT '' et DEFAULT 0 à la place, ce qui maintient des chemins d’écriture et de fusion rapides.

Les chiffres

Le bandeau de preuves publié sur la page /live en liste quatre :

  • 600 o gzippés de traceur (la version marketing arrondie de 687 o)
  • 200 M d’événements par jour et par nœud
  • <500 ms p99
  • Données UE/EEE uniquement

Annotations honnêtes sur chacun :

  • Traceur : mesuré à 1 394 o min / 687 o gz le 28 avril 2026 ; budget 1 500 o / 700 o gz, vérifié en CI.
  • 200 M d’événements/jour : plafond de conception, pas une mesure en production. Source : l’enveloppe de classe Hetzner du document de projet 19 ; le palier SaaS est un Hetzner AX42 (8 cœurs / 64 Go) avec de la marge. 200 M/jour = ~2 300 EPS soutenus, bien en dessous de l’enveloppe de débit publiée par ClickHouse (Cloudflare gère une ingestion de 11 M de lignes/sec sur 36 nœuds ; Plausible a migré depuis PostgreSQL car ClickHouse est incontournable au-delà de ~1 M d’événements/jour).
  • <500 ms p99 : plafond de conception, pas une mesure en production. Le p99 de production Phase-11a sera publié après l’ouverture des inscriptions publiques ; l’affirmation dans le ProofStrip est un seuil de validation, pas une mesure.
  • Données UE/EEE uniquement : traitées à Nuremberg, en Allemagne, sur un Netcup VPS 2000 G12 NUE — mesurées au sens où il existe un test d’intégration qui exécute le binaire sous iptables -P OUTPUT DROP et prouve qu’il n’y a pas d’egress requis.

Le tableau de bord respecte un budget de 16 Ko gzippés pour le JS initial, vérifié par size-limit sur le chunk index-*.js compilé. Le chunk de graphiques chargé en différé est limité à 25 Ko, les chunks de panneaux en différé à 10 Ko, le CSS à 5 Ko / 3 Ko. Vous pouvez relancer cette vérification localement :

$ npm --prefix web run bundle-gate

Les SLO invariants d’analyse appliqués par la porte de test :

  • Perte d’événements ≤ 0,05 % côté serveur / ≤ 0,5 % côté client
  • Doublons ≤ 0,1 %
  • Exactitude de l’attribution ≥ 99,5 %
  • Fuites de consentement / données personnelles = 0
  • Surcharge TTFB ≤ +10 % / +25 ms

Chaque seuil bloque une mise en production, est vérifié à chaque PR par la CI, et est en outre contrôlé lors d’un test de charge de 72 heures et d’une matrice de chaos en 6 scénarios avant toute bascule en production. Quel que soit le prochain pic de trafic, il devra franchir ces portes avant d’être livré.

La contrepartie honnête — 1 heure de délai

Le délai d’une heure est la partie de Statnive Live que certains lecteurs n’apprécieront pas, alors nommons-la clairement. La Règle d’Architecture 3 énonce :

1 heure de délai, PAS de temps réel — économise 98 % du coût de requête. Ne jamais construire un pipeline temps réel à 5 minutes.

Le « 98 % » est ancré par rapport à un pipeline hypothétique à 5 minutes sur la même infrastructure — maintenir les écritures de rollup bon marché, maintenir l’empreinte de rollup par site sous 100 Ko/jour/site (3 rollups v1 ; jusqu’à 6 en v1.1), permettre aux requêtes du tableau de bord de servir depuis des agrégats compacts plutôt que de scanner des tables chaudes. Si vous consultez vos statistiques une fois par heure ou une fois par jour, le délai d’une heure est invisible. Si vous avez besoin d’un retour en moins d’une minute pour surveiller un pic en direct, Live n’est pas le bon outil — choisissez un produit d’analyse en temps réel, acceptez le coût de requête ~50 fois plus élevé, et passez à autre chose.

Le panneau Temps réel existe toujours, et expose les visiteurs actifs au cours de la dernière heure depuis le même rollup hourly_visitors que tout le reste. Il n’y a pas de pipeline séparé à 5 minutes derrière lui, intentionnellement. La contrepartie est au cœur de l’architecture, ce n’est pas un coût caché.

Ce que cela signifie pour votre site

L’architecture décrite ci-dessus est ce qui rend l’histoire du propriétaire de site sans surprise :

Le traceur ne peut pas bloquer votre paiement. sendBeacon plus fetch keepalive est fire-and-forget — même si l’origine analytique est hors ligne, la page continue de naviguer et le client continue de payer. Vérifiez-le en coupant l’endpoint analytique et en observant que la page fonctionne.

L’impact sur les Core Web Vitals est borné par 687 octets plus un IIFE inline. C’est bien en dessous de tout seuil documenté de « blocage du rendu » dans cette catégorie. Nous avons mesuré l’impact LCP du traceur du plugin WordPress dans un article séparé ; nous n’avons pas encore publié de delta LCP mesuré pour le traceur Live, et nous n’en revendiquerons pas un que nous n’avons pas.

La charge côté serveur vit sur une origine séparée. Le traceur envoie ses données vers un endpoint Statnive Live, pas vers votre application web. Le ticker de fsync WAL à 100 ms permet ~7 K EPS soutenus sur le palier SaaS — rien de cela n’entre en concurrence avec le budget de requêtes PHP, Node ou Rails de votre application.

Questions fréquentes

Passera-t-il à 10 M de pages vues par jour ?

Oui. 10 M de PV/jour représentent environ 115 événements/seconde soutenus — bien en dessous du plafond de conception de 200 M/jour (~2 300 EPS soutenus) sur un serveur 8 cœurs / 32 Go. Si vous dépassez les capacités d’un seul nœud, les migrations utilisent déjà des templates Go {{if .Cluster}} de sorte que la transition nœud unique → Distributed est un changement de configuration, pas une refonte.

Puis-je l’utiliser sur un hébergement mutualisé ?

Non. ClickHouse nécessite un vrai serveur (8 cœurs / 32 Go minimum). Pour l’hébergement mutualisé, le plugin WordPress est la bonne réponse — il stocke dans votre MySQL/MariaDB existant et n’ajoute aucune surface opérationnelle.

Comment cela se compare-t-il au script de 110 Ko de GA4 ?

Le script gtag.js de GA4 pèse entre 110 Ko compressé (Stape) et 135 Ko gzippés (Plausible) selon la version du payload. Le traceur de Statnive Live fait 687 o gzippés. Plus de 50 fois plus léger, quelle que soit la référence GA4 retenue. La différence de temps d’analyse sur mobile est déterminante ; sur un téléphone Android milieu de gamme, le traceur disparaît dans le bruit.

Sur quel matériel fonctionne le plan SaaS ?

Le palier SaaS publié est un Hetzner AX42 (8 cœurs / 64 Go). Le VPS de production SaaS actif est un Netcup VPS 2000 G12 NUE à Nuremberg, en Allemagne — traitement UE/EEE uniquement, sans transfert au titre du Chapitre V. L’article 3 couvre le volet contractuel ; l’article 2 couvre le volet réglementaire.

Comment le budget de taille est-il appliqué ?

Deux portes CI s’exécutent à chaque PR. (a) go test ./internal/tracker/... applique le budget du traceur à 1 500 o / 700 o gz ainsi que le rejet des tokens interdits. (b) npm --prefix web run bundle-gate exécute size-limit sur les cinq entrées du tableau de bord dans web/.size-limit.json. Les deux font partie de make ci-local, que le workflow GitHub Actions exécute de bout en bout contre un vrai ClickHouse en 8 à 12 minutes.

Montrer les preuves

Chaque affirmation ci-dessus est reproductible depuis un clone de statnive-live :

# Tracker size budget — 1,500 B min / 700 B gz, asserted by Go test
$ wc -c internal/tracker/dist/tracker.js
    1394 internal/tracker/dist/tracker.js
$ gzip -9 -c internal/tracker/dist/tracker.js | wc -c
     687
$ go test ./internal/tracker/...
ok      github.com/statnive/statnive.live/internal/tracker      0.32s

# Dashboard bundle budget — five size-limit entries
$ npm --prefix web run bundle-gate

# Whole gate — ClickHouse + integration + smoke + e2e (~8–12 min)
$ make ci-local

Ces mêmes commandes s’exécutent dans GitHub Actions à chaque PR. Il n’existe pas de « benchmark de release » séparé — si une PR dépasse un budget, elle n’est pas fusionnée. Si une mise en production dépasse un SLO lors du test de charge de 72 heures, elle n’est pas livrée. Concevoir pour un million de pages vues par minute est peu spectaculaire en pratique ; c’est surtout des portes CI, des filtres de rejet rapide et des tables de rollup, et très peu de l’héroïsme.

En résumé

La couche d’analyse que vous livrez en 2026 est principalement jugée sur ce qu’elle fait à votre site, pas sur ce qu’elle fait pour lui. Les choix de conception de Statnive Live rendent la contrepartie explicite : un traceur première partie de 687 octets, une chaîne de rollup à délai d’une heure qui économise 98 % du coût de requête que le temps réel aurait exigé, et un ensemble de SLO vérifiés par CI qui bloquent une mise en production avant qu’elle ne vous atteigne jamais. Nous ne revendiquons pas des chiffres p99 de production que nous n’avons pas encore livrés, ni des deltas LCP que nous n’avons pas encore mesurés — mais chaque chiffre ci-dessus renvoie à un chemin de fichier que vous pouvez vérifier.

Statnive Live arrive bientôt sur fr.statnive.com/live. Cette série en quatre parties est l’introduction progressive : plugin WP vs Statnive Live pour l’arbre de décision, l’analyse web conforme au RGPD en 2026 pour le volet réglementaire, posséder ses données analytiques pour le volet architecture de déploiement, et cet article pour le volet ingénierie. La page fonctionnalités est le résumé en une page. Si un chiffre de cet article s’avère inexact, écrivez-moi — chaque affirmation est adossée à un fichier ou une commande, et nous préférons corriger une erreur plutôt que de livrer une demi-vérité bien présentée.

Installer Statnive gratuitement