Quiconque a travaillé sous Rails connaît probablement Ahoy : une petite bibliothèque d'analytics first-party qui enregistre visites et événements directement dans votre propre base, sans script tiers. Je voulais la même ergonomie sous Laravel, selon mes propres exigences de vie privée — et plutôt que de tordre un outil existant pour y arriver, je l'ai écrit.
Le résultat, c'est Sillage : du tracking d'événements first-party et respectueux de la vie privée pour Laravel 11/12. Il est sur Packagist sous le nom beubeucode/sillage, sous licence MIT. Cet article fait le tour de ce qu'il fait, et de son fonctionnement interne.
La forme générale
Sillage est délibérément minimaliste. Deux tables — visits et events — un modèle de visiteur à deux cookies, et un unique endpoint de tracking. Rien ne quitte votre infrastructure : les données atterrissent dans votre base, et vous en êtes propriétaire de bout en bout.
Pour une application Blade, le suivi des pages vues est automatique. Un middleware TrackVisit est ajouté au groupe web au moment de l'installation, et chaque chargement de page complet enregistre une visite plus un événement $view — aucun JavaScript d'analytics requis. Pour une application Inertia, il se passe la même chose sur les chargements durs (hard loads), tandis que les rechargements partiels sont ignorés pour ne pas compter deux fois la navigation côté client.
L'installation tient en une commande Composer suivie d'un installeur interactif :
composer require beubeucode/sillage
php artisan sillage:install
L'installeur publie la config et les migrations, te guide dans le choix du masquage d'IP, et propose de lancer les migrations. Pour la CI, la même commande accepte des flags non interactifs (--mask-strategy, --no-mask, --force).
Le modèle de données
Toute la bibliothèque repose sur deux tables, et la relation entre elles est la première décision de conception qui mérite une explication.
visits contient une ligne par visite : un visit_token et un visitor_token (deux UUID), un user_id optionnel, l'ip masquée, un user_agent tronqué, le referrer et la landing_page, ainsi qu'un timestamp started_at. events contient une ligne par action suivie : un visit_token nullable, un user_id optionnel, le name de l'événement, un sac de properties en JSON, et un time.
Ce qui relie les deux mérite l'attention : non pas une clé étrangère sur visits.id, mais l'UUID visit_token. Cette indirection est délibérée. Le token est ce que le navigateur transporte dans un cookie et ce que la requête courante expose via le container (j'y reviens plus bas) ; ainsi, n'importe quel événement — y compris un événement déclenché depuis un endroit qui n'a jamais chargé de modèle Visit — peut malgré tout être rattaché à la bonne visite par simple copie d'une chaîne. Cela signifie aussi que l'anonymisation peut effacer le user_id sans rompre le lien visite-événement, si bien que les funnels agrégés survivent à une demande d'effacement.
Comment une visite est reconstituée
C'est le cœur de tout tracker à la Ahoy, et l'endroit où réside l'essentiel de la logique intéressante.
Deux cookies, aux durées de vie volontairement différentes :
sillage_visitor— longue durée (environ deux ans par défaut). C'est l'identité du navigateur qui revient.sillage_visit— courte durée (quatre heures par défaut). C'est ce qui définit les frontières d'une visite unique.
À chaque requête, le middleware lit les deux cookies et génère un nouvel UUID pour celui qui manque. Une nouvelle ligne Visit n'est créée que lorsque le cookie de visite a expiré — pas à chaque requête — et c'est ce qui transforme un flux de chargements de pages en visites discrètes et découpées en sessions. Les deux cookies sont réémis en httpOnly, secure, SameSite=Lax ; parce qu'ils sont httpOnly, le JavaScript côté client ne peut pas lire les tokens du tout — ils existent uniquement pour permettre au serveur de corréler les requêtes.
L'astuce élégante, c'est le passage de relais. Le middleware place le token de visite courant dans le container Laravel :
app()->instance('sillage.visit_token', $visitToken);de sorte que partout en aval — une page vue dans la même requête, un événement custom depuis un contrôleur, un événement au fond d'un service — track() puisse récupérer le bon token de visite sans retoucher aux cookies. Si le binding est absent (un événement déclenché depuis un job de queue ou une commande Artisan, hors de toute requête HTTP), le token vaut simplement null et l'événement est enregistré non rattaché plutôt que de faire échouer l'opération.
Les pages vues elles-mêmes sont gérées par le même middleware, mais uniquement sur les requêtes GET. Chacune enregistre un événement $view portant l'URL complète, le nom de la route, et un type valant full ou spa — ce dernier déduit de l'en-tête X-Inertia. Les rechargements partiels d'Inertia portent un en-tête X-Inertia-Partial-Data, et le middleware s'arrête tôt sur ceux-là, si bien que la navigation partielle côté client ne gonfle jamais le compteur de vues.
Événements custom
Au-delà des pages vues, on enregistre des événements via une façade ou un helper global :
use Beubeucode\Sillage\Facades\Sillage;
Sillage::track('checkout-completed', ['order_id' => 42]);
// ou le helper global
sillage_track('checkout-completed', ['order_id' => 42]);Les noms d'événements sont normalisés en snake_case : les tirets et underscores sont réduits à des espaces, puis le tout est passé en snake_case, si bien que Checkout Completed, checkoutCompleted et checkout-completed aboutissent tous au même checkout_completed. Nul besoin de faire la police des conventions de nommage dans une équipe ; la bibliothèque s'en charge. Chaque événement capture aussi auth()->id() et l'heure courante automatiquement.
Les modèles Eloquent peuvent adopter un trait Trackable qui injecte leur clé. Il déduit le nom de la propriété depuis la table — au singulier, suffixé de _id :
use Beubeucode\Sillage\Concerns\Trackable;
class Order extends Model
{
use Trackable;
}
$order->track('checkout-completed'); // properties : { order_id: <clé> }Côté client, Blade dispose d'une directive @sillage qui rend un petit client window.sillage(), et Inertia/React d'un hook useSillage. Les deux postent vers le même endpoint POST /sillage/events, qui valide la charge utile et retourne un 204 No Content.
La vie privée par défaut
C'est la partie qui me tient le plus à cœur, et là où la conception est tranchée plutôt que neutre.
Les adresses IP brutes ne sont jamais stockées, et seules les visites portent une IP — les événements n'ont pas de colonne IP du tout, ils sont donc intrinsèquement moins identifiants. Le masquage a lieu à l'écriture, dans le middleware, avant même que la ligne ne soit persistée ; pas de traitement par lot ultérieur laissant une fenêtre d'exposition. Deux stratégies sont fournies :
truncate(par défaut) met à zéro le dernier octet IPv4 ou le dernier bloc IPv6, conservant une géolocalisation grossière tout en supprimant l'identité de l'hôte.hashcalcule un HMAC-SHA256 de l'IP, clé par votre clé applicative, si bien qu'une même adresse reste stable d'une ligne à l'autre mais irréversible sans la clé.
Pour les flux de rétention de données et de crypto-shredding, une commande anonymize met à null les colonnes identifiantes :
php artisan sillage:anonymize # au-delà de la fenêtre de rétention configurée
php artisan sillage:anonymize --user=42 # un seul utilisateur, quel que soit l'âge des données
Sans argument, elle opère sur les enregistrements plus vieux qu'une fenêtre de rétention configurable (un an par défaut), effaçant ip, user_agent et user_id sur les visites correspondantes, et user_id sur les événements correspondants — de quoi honorer une demande d'effacement sans jeter votre historique agrégé.
L'endpoint d'événements est rate-limité (throttle:60,1) par défaut, et peut être désactivé entièrement via la config si l'on préfère câbler sa propre route.
Quelques décisions qui méritent d'être soulignées
Certains choix, dans un petit package, en disent plus long que la liste des fonctionnalités.
L'adapter Inertia poste avec fetch, jamais avec router.post. Faire passer un appel d'analytics par Inertia enregistrerait une visite et polluerait l'historique du navigateur — précisément le genre d'effet de bord qu'on ne veut pas d'une instrumentation. Utiliser fetch directement garde le tracking invisible pour la couche de navigation. Il lit le token CSRF depuis le cookie XSRF-TOKEN et envoie avec keepalive: true, pour que les événements survivent au déchargement d'une page.
Le helper global est préfixé sillage_track, pas un simple track. Un package public qui s'approprie un nom aussi générique que track() dans l'espace de noms global, c'est une collision qui n'attend qu'à se produire. Pour la forme courte dans sa propre application, le README montre l'alias de quatre lignes à ajouter soi-même — un opt-in explicite plutôt qu'une surprise.
Quand le tracking est désactivé, track() est un no-op silencieux qui construit et retourne malgré tout un Event non persisté. Le code appelant n'a pas à brancher selon l'environnement ; il fonctionne tel quel en local et dans les tests, sans écrire de lignes.
À qui ça s'adresse
Sillage est un projet perso, sous licence MIT, et pas encore en production où que ce soit — je le publie parce qu'il comble un vide que je voulais combler : un vrai drop-in pour du tracking first-party et soucieux de la vie privée dans une application Laravel, sans avoir à se tourner vers un produit d'analytics hébergé ou une plateforme auto-hébergée plus lourde. Si c'est la forme du manque dans votre stack, il pourrait vous épargner l'après-midi que j'ai passé à le construire.
Tout est externalisé dans config/sillage.php — noms de tables, durées des cookies, modèle utilisateur, stratégie de masquage, routes — de sorte qu'on peut le plier à un schéma existant sans toucher au code source.
Le code est sur GitHub et le package sur Packagist. Issues et retours bienvenus.