9 min de lecturemis à jour le
Mesurer la fréquentation de son site sans cookie ni bandeau de consentement
Savoir combien de personnes visitent un site, quelles pages elles lisent et d'où elles viennent, sans cookie, sans service tiers et sans suivre personne. Le code de ma mesure d'audience maison, ce que dit la CNIL, et ses limites.

Quand on ajoute Google Analytics à un site, il faut aussi ajouter un bandeau de consentement. Une bonne partie des visiteurs refuse, et les chiffres qui restent ne disent plus grand-chose. Pour mon portfolio puis pour Candidash, je voulais trois réponses simples : combien de personnes passent chaque jour, quelles pages elles lisent, et d'où elles arrivent. Je ne voulais ni suivre qui que ce soit, ni envoyer ces données à un service tiers.
J'ai donc écrit ma propre mesure d'audience, côté serveur, dans l'API NestJS de chaque projet. Cet article montre comment elle fonctionne, ce que je stocke et ce que je refuse de stocker, ce qu'en dit la CNIL, et ce que ce choix ne permet pas de savoir.
Ce que je voulais savoir, et ce que je ne voulais pas savoir
Pour un site personnel ou une petite application, les questions utiles sont peu nombreuses. Combien de visiteurs par jour, et combien de pages vues ? Quelles pages sont lues ? Les visiteurs arrivent-ils par Google, par LinkedIn, par un lien partagé dans un message ?
Je n'ai besoin de savoir ni qui est une personne, ni ce qu'elle a fait la semaine dernière, ni sur quels autres sites elle est allée. C'est cette limite qui guide tout le reste : chaque donnée enregistrée doit servir l'une des trois questions, et aucune ne doit permettre de retrouver quelqu'un.
Un identifiant qui change chaque jour
Pour compter des visiteurs plutôt que des pages vues, il faut pouvoir reconnaître qu'un ensemble de pages vient de la même personne. Sans cookie, je pars de ce que le serveur reçoit de toute façon à chaque requête : l'adresse IP et le user-agent du navigateur. Je ne les enregistre pas. Je calcule à partir d'eux une empreinte, en y ajoutant la date du jour :
/**
* Identifiant de visite : HMAC(secret, ip|ua|jour). Il change chaque jour et ne peut pas être
* recalculé depuis une IP sans le secret ; ni l'IP ni le user-agent ne sont stockés.
*/
export function toVisitorHash(
secret: string,
ip: string,
userAgent: string,
day: string,
): string {
return createHmac('sha256', secret)
.update(`${ip}|${userAgent}|${day}`)
.digest('hex');
}La date change le résultat chaque jour : un visiteur de lundi et le même visiteur de mardi ont deux empreintes différentes, sans lien entre elles. On peut compter les visiteurs d'une journée, mais pas suivre une personne d'un jour à l'autre.
Le secret compte autant que la date. Ma première version, sur le portfolio, se contentait d'un SHA-256 de ip|user-agent|jour. Ça a l'air anonyme, mais il n'existe qu'environ quatre milliards d'adresses IPv4. Quelqu'un qui récupérerait la base pourrait, pour un user-agent courant, calculer l'empreinte de chacune d'elles et retrouver l'adresse en quelques minutes sur une carte graphique. Avec un HMAC, il lui faudrait aussi la clé secrète, qui ne se trouve pas dans la base. Candidash a été écrit directement avec un HMAC, et le portfolio est passé à la même méthode.
Pour ne pas ajouter un secret de plus à configurer, la clé est dérivée d'un secret existant de l'application, avec un préfixe qui la rend distincte de celle qui signe les sessions :
this.secret = createHash('sha256')
.update(`analytics|${config.getOrThrow<string>('JWT_SECRET')}`)
.digest('hex');Ce qui est enregistré, et ce qui ne l'est pas
Sur Candidash, chaque page vue enregistre quatre informations, en plus de la date : l'empreinte du jour, le chemin de la page, le domaine d'où vient le visiteur s'il est connu, et une source lisible (« Google », « LinkedIn »…). Le portfolio garde aussi le pays, déduit de l'adresse IP par une base installée sur le serveur (aucun service extérieur n'est interrogé), ainsi que la famille du navigateur et du système.
Le chemin est nettoyé avant d'être enregistré. Les paramètres d'URL et les ancres sont retirés, parce qu'ils contiennent parfois un e-mail, un code ou un identifiant de campagne. Les identifiants techniques dans le chemin sont masqués : /dashboard/jobtrack/3f2a… devient /dashboard/jobtrack/:id. Les pages d'administration ne sont pas mesurées.
const masked = trimmed
.split('/')
.map((segment) => (ID_SEGMENT.test(segment) ? ':id' : segment))
.join('/');Du referrer, je ne garde que le nom de domaine : www.google.com, jamais l'URL complète, qui peut contenir une recherche. Les robots sont écartés à partir de leur user-agent (bot, crawl, headless, lighthouse, curl…), pour ne pas compter les moteurs de recherche ni mes propres outils de test.
Enfin, l'enregistrement ne doit jamais gêner la navigation. S'il échoue, l'erreur est notée dans les logs et la page s'affiche quand même :
/** Ne lève jamais : une mesure perdue ne doit pas casser la navigation. */
async track(dto: TrackVisitDto, context: VisitContext): Promise<void> {
if (isBot(context.userAgent)) return;
const path = toTrackedPath(dto.path);
if (!path) return;
// …
}D'où viennent les visiteurs
C'est la partie qui m'a demandé le plus d'essais. Ma première version ne regardait que le referrer, c'est-à-dire la page précédente que le navigateur transmet. En pratique, beaucoup de visites arrivaient en « accès direct ». J'ai fait le test en ouvrant un lien vers Candidash depuis l'application LinkedIn de mon téléphone : la visite est bien arrivée, mais sans aucune provenance. Les applications mobiles ouvrent les liens dans leur propre navigateur intégré, et celui-ci ne transmet souvent pas de referrer.
La détection regarde donc maintenant quatre indices, du plus fiable au plus indirect :
/**
* Origine lisible d'une visite, du signal le plus explicite au plus indirect :
* 1. tag posé sur le lien (utm_source, ref) ;
* 2. referrer transmis par le navigateur ;
* 3. navigateur intégré d'une appli (user-agent, jamais stocké) ;
* 4. identifiant de clic ajouté par une plateforme (fbclid, gclid…).
* null = accès direct ou origine masquée (mail, messageries, favoris, QR code).
*/
export function toVisitSource(
rawPath: string,
referrerHost: string | null,
userAgent: string,
): string | null {
const params = queryOf(rawPath);
const tag = tagOf(params);
if (tag) {
return (
KNOWN_SOURCES.find((source) => source.tags.includes(tag))?.label ?? tag
);
}
if (referrerHost) return referrerSource(referrerHost);
const inApp = IN_APP_BROWSERS.find(({ pattern }) => pattern.test(userAgent));
if (inApp) return inApp.label;
return (
CLICK_IDS.find(({ params: names }) =>
names.some((name) => params.has(name)),
)?.label ?? null
);
}Le premier indice est celui qu'on contrôle : il suffit d'ajouter ?ref=linkedin ou ?utm_source=newsletter aux liens qu'on partage soi-même. Le deuxième est le referrer, ramené à un nom lisible : mail.google.com devient « Gmail », tous les domaines google. deviennent « Google ». L'ordre de la liste compte, puisque Gmail doit être testé avant Google.
Le troisième a corrigé mon test LinkedIn. Le navigateur intégré d'une application signe son user-agent : LinkedInApp pour LinkedIn, FBAN ou FBAV pour Facebook, Instagram, musical_ly pour TikTok. Là aussi l'ordre compte, parce qu'Instagram et Messenger embarquent la signature de Facebook. Le user-agent sert à ce calcul, puis il est oublié.
Le dernier indice est un identifiant que certaines plateformes ajoutent d'office aux liens sortants : fbclid pour Facebook, gclid pour Google Ads, msclkid pour Microsoft. Il est lu au passage, puis retiré du chemin enregistré avec le reste des paramètres.
Après cette correction, j'ai refait le test depuis LinkedIn sur mon téléphone, et la visite est apparue avec la bonne source.
Pour le tableau de bord, une personne qui a vu cinq pages ne doit pas compter cinq fois dans les sources. La requête retient, pour chaque empreinte et chaque jour, la première source connue :
-- day : date locale de la visite ; since : début de la période affichée
WITH per_visit AS (
SELECT (array_agg(source ORDER BY "createdAt") FILTER (WHERE source IS NOT NULL))[1] AS source
FROM "Visites"
WHERE day >= since
GROUP BY "visitorHash", day
)
SELECT source AS label, COUNT(*) AS count FROM per_visit
GROUP BY source ORDER BY count DESC, label NULLS LAST LIMIT 10;Combien de temps je garde ces données
Sur Candidash, une tâche planifiée supprime chaque nuit les visites de plus de 90 jours. Sur le portfolio, les visites détaillées sont gardées 30 jours ; au-delà, il ne reste que des totaux par jour, qui ne contiennent plus aucune empreinte. Dans les deux cas, je n'ai pas besoin de plus pour voir les tendances, et ce qui n'existe plus ne peut pas fuiter.
Ce qu'en dit la CNIL
Je ne suis pas juriste, et cette partie ne remplace pas la lecture des textes. Voici ce que j'en retiens et comment j'ai construit l'outil en conséquence.
La CNIL admet que certains outils de mesure d'audience se passent du consentement des visiteurs, à des conditions précises, décrites sur sa page « Cookies : solutions pour les outils de mesure d'audience ». L'outil doit servir uniquement à mesurer l'audience du site, pour le compte de son seul éditeur. Il doit produire des statistiques anonymes. Ses données ne doivent pas être recoupées avec d'autres traitements ni transmises à des tiers, et il ne doit pas permettre de suivre la navigation d'une personne d'un site à l'autre. La CNIL recommande aussi d'informer les visiteurs, par exemple dans la politique de confidentialité, et de limiter la durée de conservation.
On pourrait se demander si une empreinte calculée côté serveur, sans rien écrire sur l'appareil du visiteur, est seulement un « traceur » au sens de la loi. Je n'ai pas voulu parier là-dessus. J'ai plutôt fait en sorte que l'outil respecte de toute façon chacune de ces conditions : une seule finalité, aucune donnée transmise à qui que ce soit, une empreinte propre à chaque site et renouvelée chaque jour, une conservation courte, et un paragraphe qui explique tout ça, dans la page de confidentialité du portfolio et dans les conditions d'utilisation de Candidash.
Pour aller plus loin, la CNIL publie aussi une page plus générale, « Mesurer la fréquentation de vos sites web et de vos applications ».
Ce que cette méthode ne permet pas de savoir
Comme l'empreinte change chaque jour, un visiteur qui revient trois jours de suite compte pour trois. Le nombre de visiteurs sur un mois est donc une somme de visiteurs quotidiens, pas un nombre de personnes différentes.
L'empreinte repose sur l'adresse IP et le navigateur, ce qui crée deux erreurs, dans un sens et dans l'autre. Deux collègues derrière la même connexion d'entreprise, avec le même navigateur à jour, peuvent être comptés comme une seule personne. À l'inverse, quelqu'un qui passe du Wi-Fi à la 4G dans la journée est compté deux fois. Sur un petit site, ces écarts restent faibles, mais il faut les connaître avant de commenter un chiffre.
Une partie des visites reste en « accès direct » malgré tout : un lien envoyé par e-mail dans un logiciel de messagerie, par SMS, un favori ou un QR code ne laissent aucune trace. C'est pour ça qu'il vaut la peine d'ajouter un ?ref= aux liens qu'on partage soi-même.
Enfin, l'outil ne mesure ni le temps passé, ni le parcours précis d'une page à l'autre, ni le comportement sur plusieurs semaines. C'est voulu : pour mes besoins, ces informations ne valaient pas ce qu'il aurait fallu collecter pour les obtenir.
Le résultat
Le tableau de bord de l'administration affiche les visiteurs et les pages vues par jour, les pages les plus lues et les sources. Il répond aux trois questions de départ, sans bandeau, sans service tiers, et sans que je sache qui sont mes visiteurs. Pour un site personnel ou une application qui démarre, je n'ai pas eu besoin de plus.