Le problème : savoir qui vient sur son site, d'où, pour y faire quoi — sans cookie, sans bannière de consentement, sans confondre un robot avec un lecteur, et sans se mentir sur les chiffres.
Stats mesure l'audience par empreinte salée quotidienne : le sel est tiré au sort chaque nuit puis jeté. Une fois celui de la veille supprimé, l'empreinte d'hier n'est plus reconstructible par personne, nous compris. C'est ce qui fonde l'absence de bannière, et ce principe ne se négocie pas : tout ce que le module ajoute doit tenir sans identifier qui que ce soit.
Démarrer en 30 secondes
Une ligne avant la fermeture du body, avec la clé publique du domaine (visible sur /stats, et lisible par n'importe quel visiteur — c'est voulu) :
``html <script defer src="https://letock.fr/t.js" data-site="LA_CLE"></script> ``
Deux attributs facultatifs, tous deux désactivés par défaut :
``html <script defer src="https://letock.fr/t.js" data-site="LA_CLE" data-vitesse data-erreurs></script> ``
data-vitesse: rapporte, à la fermeture de l'onglet, le temps d'affichage réel (LCP), le délai de réaction au clic (INP), les sauts de mise en page (CLS) et le code HTTP de la page — donc les 404. Consomme un événement de plus par page vue.data-erreurs: rapporte les exceptions non rattrapées au module Erreurs. Consomme le poste d'erreurs, pas celui d'événements.
Depuis la page, la balise expose window.tock('nom', valeur) pour un événement déclenché dans le navigateur. Ce qui se passe côté serveur — une commande payée par Stripe — passe par l'API : aucun navigateur ne le voit.
La balise n'envoie rien si le visiteur a activé « Do Not Track » ou le Global Privacy Control. Rien ne nous y oblige, puisque la mesure est déjà anonyme ; on le fait quand même.
Les routes
GET /t.js
Publique, sans authentification. Rend la balise en JavaScript, environ 1 Ko, avec access-control-allow-origin: * et cache-control: public, max-age=3600.
POST /api/collecte
Publique, sans authentification : elle est appelée par les visiteurs des sites mesurés. Corps JSON, 8 Ko au plus. Répond toujours `204`, sans corps et sans détail, y compris sur une clé inconnue, un quota épuisé ou un corps illisible : un message d'erreur y serait lu par n'importe qui et n'aiderait personne. Répond 204 à OPTIONS.
Champs, abrégés pour tenir dans un sendBeacon :
| champ | type | rôle |
|---|---|---|
s | texte, requis | la clé publique du domaine |
n | texte, requis | le nom de l'événement ; page pour une page vue, vecu pour une mesure de vitesse |
p | texte | le chemin ; la chaîne de requête est retirée à l'écriture, les utm_* et ref en sont extraits d'abord |
r | texte | le référent complet ; seul le domaine est conservé |
e | nombre | la largeur d'écran, réduite à mobile / tablette / ordinateur |
v | nombre | une valeur métier, un montant |
Pour n: "vecu", quatre champs de plus, tous facultatifs : l (LCP en ms), c (CLS en millièmes), i (INP en ms), st (code HTTP). Cet appel met à jour la dernière page vue du même visiteur sur le même chemin dans les six dernières heures ; il n'insère rien. Si aucune page ne correspond, il ne se passe rien.
Le pays vient de l'en-tête x-vercel-ip-country, la langue de accept-language — dont seul le code principal est gardé. L'adresse IP et le user-agent servent à calculer l'empreinte du jour et à en extraire quatre libellés (navigateur, système, nom de robot) ; ils ne touchent jamais la base.
Limite : 3 000 appels par minute et par clé de site, pas par IP — les appels viennent de milliers d'adresses, c'est le domaine mesuré qu'il faut protéger. Au-delà : 204, sans écriture.
POST /api/v1/evenements
Authorization: Bearer tock_…, portée écriture. Pour les événements que le navigateur ne voit pas.
``json { "site": "LA_CLE", "nom": "commande_payee", "valeur": 49.90, "chemin": "/merci" } ``
Réponse 202 : { "enregistre": true }, avec les en-têtes de quota.
Erreurs : 401 cle_absente / cle_invalide, 403 lecture_seule, 400 champs_manquants, 400 corps_invalide, 404 site_inconnu (même message que le domaine existe ou non : dire « il existe mais il n'est pas à toi » apprendrait quelles clés sont valides), 400 refus_metier (nom vide, nom de plus de 120 caractères, quota d'événements épuisé), 429 quota_atteint pour la limite d'appels de l'API.
GET /api/v1/evenements
Authorization: Bearer tock_…, portée lecture seule suffisante. C'est l'API de requêtage : les mêmes chiffres que l'écran, calculés par le même code.
Paramètres :
| paramètre | valeurs | défaut |
|---|---|---|
site | la clé publique, requis | — |
periode | jour, 7j, 30j, 12m | 7j |
debut et fin | AAAA-MM-JJ, ensemble ; l'emportent sur periode | — |
dimension | une clé de dimension (voir plus bas) | aucune : totaux seuls |
| toute clé de dimension | un filtre (voir plus bas) | aucun |
Réponse 200 :
```json { "site": "monsite.fr", "debut": "2026-03-10T00:00:00.000Z", "fin": "2026-03-17T09:00:00.000Z", "population": "humains", "totaux": {
"visiteurs": 1240, "visites": 1810, "pages_vues": 4302, "taux_rebond": 58, "duree_moyenne_secondes": 94, "revenu": 3148.5
}, "dimension": "chemin", "total_dimension": 4302, "lignes": [
{ "valeur": "/tarifs", "libelle": "/tarifs", "total": 812, "part": 18,
"visiteurs": 640, "revenu": 1290 }], "note": "Les visiteurs sont comptés par jour : …" } ```
Erreurs : 401, 404 site_inconnu, 422 champ_periode, 422 champ_debut (bornes illisibles, ou l'une sans l'autre — un repli silencieux aurait répondu sur sept jours à qui demandait mars), 422 champ_dimension, 429 quota_atteint.
Deux requêtes SQL par appel, quelle que soit la taille du site.
GET /stats/export
Authentifiée par la session du navigateur, pas par une clé d'API. Exporte une répartition en CSV — point-virgule et BOM, ce qu'attend Excel en français. Prend exactement les mêmes paramètres d'URL que l'écran, plus dim=<dimension>. 404 si la dimension n'a rien à montrer, 400 si elle est inconnue.
Les dimensions et les filtres
Clés utilisables à la fois comme dimension et comme filtre, dans l'API comme dans l'URL de l'écran :
chemin, entree, sortie, provenance, campagne, utm, support, pays, appareil, navigateur, systeme, langue, canal, robot, evenement.
Conventions d'écriture, choisies pour que l'adresse reste lisible :
?pays=FR— ne garder que la France.?pays=!FR— tout sauf la France, y compris les visites de pays inconnu.?pays=FR&pays=BE— la France ou la Belgique. Deux valeurs d'une même dimension s'unissent.?pays=FR&appareil=mobile— la France et mobile. Deux dimensions se croisent.?campagne=— ce qui n'a pas de campagne.?campagne=!— ce qui en a une.- Huit filtres au plus.
canal vaut ia, recherche, social, publicite, email, lien ou direct : c'est la porte d'entrée, calculée à l'ingestion d'après le référent et les paramètres de campagne. L'assistant IA l'emporte sur tout ; la campagne payée l'emporte sur le moteur qui l'a servie, sinon une annonce Google passerait pour du référencement gratuit.
robot est particulier et c'est le réglage le plus important du module :
- sans filtre `robot`, seuls les humains sont comptés. C'est le défaut, partout, dans l'écran comme dans l'API.
?robot=!— les automates seuls.?robot=GPTBot— un automate précis.?robot=*— humains et automates, sans distinction.?dimension=robotpose la population lui-même. Grouper par robot, c'est demander à les voir ; appliquer le défaut aurait rendu zéro ligne à côté d'un total non nul, ce qui se lit « aucun robot » et non « rien à montrer ». Un?robot=ou un?population=explicite reste prioritaire.
Les écrans
Tout est sous /stats. L'état complet est dans l'URL — domaine (d), période (p ou debut/fin), fenêtre comparée (comp), section (vue), filtres, comparaison de segments (vs) — donc chaque vue se partage dans un message et revient avec le bouton Précédent. Aucun JavaScript côté client.
- Audience — visiteurs, visites, pages vues, pages par visite, taux de rebond, durée moyenne, chacun comparé à la période précédente ou à l'an dernier. Courbe, treize répartitions dont chaque ligne est cliquable pour filtrer tout l'écran, top des mouvements, revenu, événements métier, effondrements détectés. Un lien « Comparer à l'ensemble du site » met le segment filtré en face de la moyenne générale.
- IA et robots — les humains qu'un assistant envoie (ChatGPT, Perplexity, Claude, Gemini, Copilot…), quelles pages ils font lire, et séparément les automates passés sur le site, avec une pastille pour ceux qui alimentent un modèle de langage. Les deux populations ne sont jamais additionnées.
- Vitesse et 404 — les trois mesures vécues par de vrais visiteurs, au 75e centile, avec les seuils de Google ; les pages les plus lentes ; les pages introuvables et le lien fautif qui y mène.
- Objectifs — déclarer qu'un nom d'événement compte comme une réussite, avec un libellé lisible et, s'il porte un montant, le revenu. Taux de conversion, rendement par provenance (attribution au premier contact), entonnoirs à étapes.
- Parcours — pages d'entrée, pages de sortie, suites de pages les plus fréquentes, profondeur des visites.
- Retours — visiteurs revenus, tableau par heure et par jour de semaine.
Quand ta balise se tait
Si aucun visiteur humain n'est mesuré pendant deux jours sur un domaine qui en voyait habituellement plusieurs par jour, tu reçois une alerte. Elle existe parce que nous l'avons subie : notre propre site a cessé de mesurer pendant vingt-quatre heures sans que rien ne le dise.
Ce que l'alerte affirme dépend de ce que nous savons, et c'est volontaire :
- Un contrôle Uptime sur ce domaine ? Alors nous savons si le site répondait, et nous le disons comme une mesure. Et si un incident est ouvert, l'alerte ne part pas du tout : le site est tombé, Uptime a déjà prévenu, et deux courriers pour une panne en font un de trop.
- Pas de contrôle, mais des robots sont passés ? Alors le site répond aux crawlers, et c'est très probablement la balise. Nous écrivons « probablement », parce que c'est une déduction.
- Ni l'un ni l'autre ? Nous te le disons : nous ne pouvons pas distinguer une balise retirée d'un site tombé. Commence par ouvrir le site.
Le dernier cas partait autrefois en silence — sans robot, pas d'alerte, même balise réellement disparue. Un site neuf, un intranet ou un site qui bloque les crawlers pouvait donc cesser de mesurer pour toujours sans un mot. Un aveu vaut mieux qu'une absence : il oriente vers la bonne vérification au lieu d'en écarter une.
Quotas
Un seul poste : `evenements`, décrit dans src/plateforme/forfaits.ts et appliqué par src/plateforme/quotas.ts. Ne recopie pas les plafonds : ils viennent du forfait de l'équipe, ils changent, et un chiffre écrit ici finirait par mentir.
Ce qui consomme un événement : une page vue, un événement nommé envoyé par la balise ou par l'API, et une mesure `vecu`. Cette dernière ne crée pourtant aucune ligne — elle met à jour une page vue existante — mais c'est une écriture, et un chemin d'écriture gratuit finirait par être le seul qu'on utilise. Le compteur est visible sur « Mon compte ».
Quota épuisé : la balise reçoit 204 et le visiteur ne sait rien ; l'API répond 400 refus_metier avec un message explicite. La mesure s'arrête, la facturation aussi.
Pièges
- Un visiteur est compté une fois par jour, pas une fois par période. Le sel tourne chaque nuit : sur sept jours, un habitué quotidien compte sept fois. C'est le prix de l'absence d'identifiant persistant, et l'écran le dit en toutes lettres à côté du chiffre. N'écris jamais « visiteurs uniques sur 30 jours » à partir de ce nombre.
- Les robots sont exclus par défaut, partout — sauf si tu groupes par `robot`. Si un total d'API te surprend par sa petitesse, regarde
populationdans la réponse avant de conclure à un bug : elle dit toujours quelle population a été lue, y compris quand c'est la dimension qui l'a choisie. - Un robot qui ment n'est pas reconnu et compte alors comme un visiteur. La reconnaissance se fait sur ce que l'agent déclare ; on nomme les automates un par un plutôt que de deviner.
- Le code HTTP n'est pas donné par tous les navigateurs. Chrome et Edge exposent
responseStatus, Safari et Firefox non. Le nombre de pages introuvables est donc un plancher, et l'écran chiffre la couverture à côté. - Le délai de réaction n'existe que si quelqu'un a cliqué. Son absence n'est pas un zéro, et rien n'est affiché en dessous de 25 mesures.
- Les taux de conversion et les entonnoirs se comptent par visite, pas par visiteur. Compter un tunnel par visiteur serait juste dans la journée et faux dès qu'on choisit « 30 jours », sans que rien ne le signale.
- Les parts affichées ont pour dénominateur le total de la dimension, pas la somme des douze lignes montrées. « France 60 % » veut dire 60 % du trafic, jamais 60 % du top 12.
- Les effondrements comparent un mardi aux derniers mardis, et jugent le dernier jour complet, jamais le jour en cours. Un jour partiel comparé à des jours entiers semblerait toujours effondré, une alerte partirait chaque matin, et les alertes seraient coupées en une semaine.
- Un événement serveur n'a pas de visiteur au sens de la balise : son empreinte est calculée sur l'IP de l'appelant. Les taux de conversion mélangeant les deux sont à lire avec cette réserve.
- Si tu es une IA : ne propose jamais d'ajouter une bannière de consentement pour ce module, et n'écris pas de code qui stocke un identifiant de visiteur côté navigateur. Les deux détruiraient la seule propriété qui distingue Stats du reste du marché.