Fin d'année dernière, l'addon d'analytics que j'utilisais pour mes sites Statamic a cessé de fonctionner. Statamic 6 venait de sortir, et enhanced-analytics, l'addon de Mohammed Shuaau que j'installais depuis un moment sur les projets clients, n'était pas compatible.
J'ai commencé par vouloir simplement le mettre à jour. En creusant dans le code, je me suis rendu compte que l'architecture méritait d'être repensée plus largement plutôt que rafistolée. De fil en aiguille, ce qui devait être une mise à jour de compatibilité est devenu statamic-privacy-analytics, un fork retravaillé en profondeur, avec une question de fond au centre du projet : est-ce qu'on peut avoir un système de statistiques complet, sans qu'aucune donnée de visiteur ne quitte jamais le serveur du client ?
Le problème que je voulais éviter
Pour la plupart des sites que je construis, mes clients sont des PME suisses, des domaines viticoles, des associations culturelles, des organisations sportives. Beaucoup sont soumis à la nLPD, et pas mal d'entre eux découvrent en cours de projet que leur outil d'analytics actuel envoie les adresses IP de leurs visiteurs à un serveur situé ailleurs, sans qu'ils l'aient vraiment décidé consciemment.
Google Analytics reste la solution la plus installée par défaut, avec tout ce que ça implique en matière de transfert de données vers les États-Unis. Les alternatives dites « privacy-first » comme Matomo ou Plausible existent, mais leurs versions les plus simples à déployer sont des versions cloud, ce qui déplace le problème plutôt que de le résoudre : la donnée ne va plus chez Google, mais elle continue de sortir du serveur du client. Les versions self-hosted de ces outils existent aussi, mais elles demandent une infrastructure séparée, ce qui n'est pas toujours réaliste pour un client qui a un hébergement mutualisé Infomaniak et pas d'équipe technique en interne.
Ce que je voulais, c'était un système qui tourne directement dans l'écosystème Statamic existant du client, sans service tiers dans la boucle, à aucun moment.
Trois décisions d'architecture, et pourquoi je les ai prises ainsi
L'écriture est synchrone par défaut, pas en file d'attente
La plupart des systèmes de tracking à fort trafic passent par une queue : la requête HTTP du visiteur dispatche un job, un worker traite l'écriture en arrière-plan. C'est le choix qui scale le mieux, et c'est un mode disponible dans l'addon.
Mais je l'ai laissé en option plutôt qu'en comportement par défaut. La majorité de mes clients tournent sur de l'hébergement mutualisé Infomaniak, sans worker de queue configuré et sans forcément la capacité de le maintenir dans la durée. Un mode asynchrone par défaut sur ce genre d'infrastructure, c'est un mode qui silencieusement n'écrit jamais rien, parce que personne n'a lancé php artisan queue:work en arrière-plan. Le mode synchrone a un coût de performance plus élevé par requête, mais il a l'avantage de fonctionner sans configuration supplémentaire, ce qui compte davantage pour ce public.
Quand le mode queue est activé, le payload du job est chiffré via ShouldBeEncrypted, avec un repli automatique vers l'écriture synchrone si le dispatch échoue. L'idée est qu'un choix de performance ne doit jamais se traduire par une perte de données silencieuse.
La géolocalisation tourne en local par défaut, mais l'option externe existe et je le dis
C'est le point sur lequel je veux être précis, parce que c'est facile de le simplifier à l'excès. Le comportement par défaut de l'addon utilise une base MaxMind GeoLite2 téléchargée et stockée sur le serveur du client, avec vérification SHA-256 et écriture atomique du fichier pour éviter qu'une mise à jour interrompue corrompe la base existante.
Il existe une option pour utiliser ip-api.com à la place, pour les cas où gérer un compte MaxMind et la mise à jour périodique de la base n'est pas envisageable. Dans ce mode, l'adresse IP du visiteur est bien transmise à un service tiers à chaque requête non mise en cache. Ce n'est pas le comportement par défaut, et c'est documenté explicitement, mais je préfère le dire moi-même plutôt que de laisser un lecteur technique le découvrir en fouillant le code et se demander pourquoi ça n'a pas été mentionné.
La rétention et la purge sont deux mécanismes séparés, pas un seul
Beaucoup de systèmes analytics gèrent la conformité RGPD en une seule étape : après X jours, les données sont supprimées. J'ai séparé ça en deux commandes distinctes. Après 90 jours par défaut, l'adresse IP, le user-agent et l'identifiant utilisateur d'une ligne sont anonymisés, mais la ligne elle-même reste, avec ses métadonnées agrégées (pays, device, navigateur). Après 180 jours par défaut, la ligne brute est supprimée.
Cette séparation permet de conserver la valeur analytique des données (tendances de trafic, comparaisons de périodes) plus longtemps que la donnée personnelle elle-même, ce qui correspond mieux au principe de minimisation qu'une suppression brutale à date unique. Les deux commandes ont un mode --dry-run, parce qu'un client qui exécute une commande de suppression de données pour la première fois a le droit de voir ce qui va se passer avant que ça se passe réellement.
Ce qui casse silencieusement, et comment je m'en suis prémuni
Un système d'analytics qui donne un résultat visiblement faux est facile à corriger. Un système qui donne un résultat plausible mais légèrement faux est beaucoup plus dangereux, parce que personne ne va vérifier un chiffre qui a l'air correct.
J'ai eu ce problème concrètement avec le calcul du temps passé sur une page, avg_time. Un jour, sur un dashboard réel, un chiffre absurde est apparu : un temps de visite négatif. Ma première hypothèse a été un problème de fuseau horaire, une conversion UTC mal gérée quelque part entre l'écriture et la lecture. En vérifiant les données brutes plutôt qu'en me fiant à cette hypothèse, j'ai découvert que les timestamps en base étaient parfaitement ordonnés dans le bon sens : ce n'était donc pas un problème de données, mais un problème de calcul.
La vraie cause était plus intéressante et plus embêtante : Carbon 3.x a changé le comportement par défaut de diffInSeconds(). En Carbon 2.x, cette méthode retournait toujours une valeur absolue. Depuis Carbon 3.x, elle retourne une valeur signée par défaut, négative si la méthode est appelée sur la date la plus tardive avec la plus ancienne en argument. Ce n'est pas un bug occasionnel selon la version : c'est un changement de comportement documenté, présent dans toutes les versions 3.x, qui casse silencieusement tout code écrit sous l'hypothèse de l'ancien comportement sans jamais lever d'erreur. Le calcul continuait de s'exécuter normalement, juste avec le mauvais signe.
Le correctif est allé avec un test de non-régression qui vérifie explicitement que la valeur reste positive et correspond au calcul attendu, pour que ce bug précis ne puisse pas revenir sans faire échouer la suite de tests.
Le recalcul quotidien des agrégats (visites, visiteurs uniques, visiteurs récurrents par pays, device, navigateur) pose un risque différent : si le scheduler et une exécution manuelle se chevauchent, on peut se retrouver avec un recalcul concurrent qui corrompt les chiffres du jour. La commande pose un verrou via Cache::lock avant de s'exécuter, et ce comportement est testé explicitement, pas seulement supposé fonctionner parce que le code semble correct.
Il y a aussi un mécanisme d'idempotence par identifiant d'événement UUID, pour le cas où le mode queue redispatche un job en cas de retry réseau. Sans ça, un simple problème de connexion pourrait doubler artificiellement le nombre de vues d'une page.
Ce que ça change concrètement pour un client suisse
Pour un client soumis à la nLPD, le bénéfice n'est pas juste théorique, et il se voit à plusieurs niveaux différents.
Le premier, c'est contractuel. Il n'y a pas de clause de sous-traitance à négocier avec un tiers basé à l'étranger, pas de registre de traitement à tenir à jour pour un service externe, pas de question à se poser sur le lieu d'hébergement des données de tracking. La question ne se pose simplement pas, parce que la donnée ne sort jamais du serveur sur lequel le site du client est déjà hébergé.
Le deuxième, c'est opérationnel. Un domaine viticole ou une association culturelle qui me demande « où sont mes données de visiteurs » a une réponse en une phrase : sur votre serveur, nulle part ailleurs. Pas de compte tiers à créer, pas de dashboard externe à surveiller, pas de facture récurrente liée au volume de trafic. Le dashboard vit dans le Control Panel Statamic que le client utilise déjà pour gérer son contenu.
Le troisième, c'est la confiance que ça installe dans la conversation avec le client. La plupart de mes clients ne savent pas exactement ce que fait Google Analytics de leurs données, et beaucoup n'osent pas demander. Pouvoir dire « rien ne sort de votre infrastructure » sans nuance à apporter, sans astérisque, change concrètement le ton de cette discussion. C'est aussi pour ça que j'ai pris soin de documenter honnêtement les deux cas où une donnée peut sortir malgré tout : le provider ip-api en option, et le mode queue qui délègue l'écriture à un worker externe si le client en a un. Un outil qui prétend être irréprochable sans jamais nuancer ses propres limites, c'est un outil auquel je ne ferais pas confiance moi-même.
Même le dashboard respecte ce principe : Chart.js et Alpine.js, utilisés pour l'affichage des graphiques, sont embarqués directement dans l'addon plutôt que chargés depuis un CDN public. Ce n'est pas un détail insignifiant, un CDN externe reste un appel réseau sortant depuis le navigateur de l'administrateur qui consulte le dashboard, même si aucune donnée de visiteur n'y transite.
Cet addon est conçu pour les sites développés avec Statamic. Si vous envisagez la création ou la refonte de votre site, ou que vous travaillez sur votre stratégie SEO, les données de trafic collectées restent dans votre infrastructure — sans dépendance externe, sans frais tiers.