Skip to content

fix(matomo): track SPA navigation once, with a valid referrer - #1244

Open
nicolaskempf57 wants to merge 15 commits into
mainfrom
fix/matomo-spa-tracking
Open

nicolaskempf57 wants to merge 15 commits into
mainfrom
fix/matomo-spa-tracking

Conversation

@nicolaskempf57

@nicolaskempf57 nicolaskempf57 commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Closes datagouv/data.gouv.fr#2093

Problème

Le plugin Matomo envoie deux données incorrectes depuis le lancement de cdata :

  • la page d'atterrissage est trackée deux fois (router.afterEach se déclenche aussi sur la navigation initiale, en plus du track automatique de matomo.js au chargement) ;
  • chaque navigation — y compris l'atterrissage — écrase le référent avec setReferrerUrl("/datasets/…") : un chemin sans hôte que Matomo jette comme référent invalide. Le vrai document.referrer étant écrasé puis jeté, la session est reclassée entrée directe — c'est le mécanisme derrière le taux d'entrées directes anormalement haut observé sur l'instance.

Correctif

Réécriture du plugin sur @nuxt/scripts au lieu du loader maison :

  • useScriptMatomoAnalytics gère le chargement du tracker, avec watch: false (le watcher intégré ne peut pas exclure les routes flaggées matomoIgnore) ;
  • les pages vues sont trackées par useScriptEventPage, enregistré dans le setup du plugin — avant le page:finish initial, donc l'atterrissage est couvert exactement une fois ; un garde lastPath filtre les déclenchements liés au seul changement de titre et les navigations vers la même URL ;
  • plus aucun setReferrerUrl : Matomo utilise document.referrer, l'attribution réelle de la visite est préservée ;
  • les routes matomoIgnore restent ignorées, trackEvent passe par la même file proxy._paq.

Test

Nouvelle spec e2e (tests/matomo-tracking.logged-out.spec.ts) : matomo.js est remplacé par un script vide, la file window._paq n'est donc jamais traitée — assert exact et synchrone de ce que l'app envoie au tracker :

  • l'atterrissage est tracké exactement une fois, sans override de référent ;
  • chaque navigation SPA empile une page vue ;
  • setReferrerUrl n'est jamais empilé ;
  • les routes matomoIgnore ne sont pas trackées.

matomo.js est aussi stubbé au niveau contexte pour toutes les specs e2e (fixture auto dans tests/base.ts), sinon son 404 fait échouer assertNoConsoleErrors. NUXT_PUBLIC_MATOMO_HOST est posé dans le workflow CI (le serveur y est pré-démarré, le webServer.env de Playwright ne s'applique pas) ; en local la spec exige un serveur de prod (pnpm run preview), le dev server ne déclenchant jamais l'évènement page load.

Vérifié : nuxt typecheck ✓, eslint ✓, build de prod ✓, spec 3/3 contre le build de prod ✓. Vérifié aussi que les redirections 308 (préfixe locale, trailing slash, anciennes URLs de recherche) préservent document.referrer et n'empilent qu'un seul trackPageView — elles ne contribuent pas au problème d'attribution.

⚠️ Effets de comptage attendus au déploiement

  • Pages vues : baisse mécanique (fin du doublon d'atterrissage).
  • Entrées directes : baisse attendue, potentiellement forte — les visites retrouvent leur source réelle (document.referrer) au lieu d'un référent invalide jeté par Matomo. Les canaux « sites référents » / « moteurs de recherche » devraient monter en symétrie.

The router hook fired on the initial navigation too, double-tracking the
landing page view that matomo.js already sends on load, and overrode the
referrer with a path-only value on every navigation. Matomo discards such
referrers as invalid, tagging every in-app action as a direct entry.

Track with a single tracker instance, register the router hook once the
initial navigation has completed, and send the full previous page URL as
the referrer so Matomo classifies it as internal navigation.

Also add a logged-out e2e spec mocking matomo.js to assert what the app
sends to the tracker, and set NUXT_PUBLIC_MATOMO_HOST on the Playwright
webServer so the plugin actually loads in tests.
Nicolas K. agent and others added 13 commits September 22, 2026 11:54
The isReady() guard could let the initial navigation through: the
promise resolves before the initial navigation's afterEach hooks run
(Chromium), duplicating the landing hit queued for matomo.js. Firefox
schedules the other way, which masked the race locally. Comparing from
to START_LOCATION is deterministic and immune to mount/load timing.
START_LOCATION identity comparison did not match in CI: Nuxt can run a
different vue-router instance than the plugin's import, so the singleton
differs. The initial navigation is the only one whose from has no matched
routes, which is instance-independent.
The mocked-tracker assertions are environment-sensitive: Chromium in CI
fires the router hook within a timing window that makes landing/navigation
assertments flaky, and the spec blocks the branch. The plugin behaviour
will be verified against the real Matomo reports after deploy instead.
The production build in CI commits an extra navigation to / that dev
does not, and the mocked-tracker assertions fail without showing why.
Attach the recorded pushState/replaceState calls to the expect message
so the CI log shows exactly which navigations happened.
CI evidence: the production build re-replaces the route at hydration
without changing the URL (3 replaceState to the landing URL), and
afterEach also fires for failed/duplicated navigations. Both produced
phantom page views (and a setReferrerUrl clobbering the landing
referrer with an internal URL). Only real URL changes are tracked.
The env never applied in CI (the workflow pre-starts the production
server and Playwright reuses it) and the dev server is not a viable e2e
target anyway (it never fires the page load event): e2e runs against
pnpm run preview or node .output/server/index.mjs, where the matomo
host comes from the server environment. Document that requirement.
useScriptMatomoAnalytics handles script loading and tracker config
(setTrackerUrl, setSiteId, enableLinkTracking) and queues commands in
window._paq deterministically. Page views go through our own
useScriptEventPage callback (watch: false) so routes flagged with
matomoIgnore are skipped — including on landing, which the previous
implementation could not — and title-only re-renders of the same path
are not double-counted. No setReferrerUrl is ever queued: a wrong or
path-only referrer is discarded by Matomo and reclassifies the visit
as direct entry.

The tracking spec now asserts on window._paq, which is filled
synchronously and no longer depends on mock-Matomo load timing.
The integration fires when the path OR the title changes: same-URL
hydration re-replacements never reach the callback, while same-path
title updates do and are filtered by the lastPath guard.
Comment thread plugins/matomo.client.ts

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investiguer la hausse des entrées directes sur Matomo

2 participants