Skip to content

ADR 0019 — Analytics produit PostHog, strictement opt-in derrière une façade

Date : 2026-07-27 Statut : accepté

Contexte

La branche feat/posthog a introduit PostHog (posthog-js, instance EU) pour l'analytics produit : pageviews/autocapture + 11 events custom (recherche, détail voyage, tunnel de réservation, paiement, compte). L'intégration initiale (générée par le wizard PostHog) initialisait PostHog inconditionnellement au top-level de main.ts et appelait posthog.capture/identify directement depuis 8 fichiers. Problèmes :

  1. Aucun consentement : cookies/persistence posés et collecte démarrée dès le premier lancement — inacceptable (exigence produit : PostHog ne doit rien collecter sans consentement explicite).
  2. opt_out_capturing_by_default ne suffit pas : vérifié dans les sources de posthog-jsposthog.init() déclenche toujours une requête remote-config (/array/.../config) et pose sa persistence au moment de l'init, opt-out ou pas. La seule garantie « zéro collecte » est de ne jamais appeler init() avant consentement.
  3. La suite e2e entière était cassée : le catch-all de MockBackend enregistrait la requête remote-config vers eu.i.posthog.com comme non-mockée → assertNoUnmatched() faisait échouer le teardown de chaque spec.
  4. Imports posthog-js éparpillés : aucun point de contrôle unique (même problème que Faro avant ADR 0016).

Détail vérifié : capture/identify/reset sur l'instance non-initialisée ne queuent/n'envoient rien (early-return sur __loaded) mais émettent un console.error — que la ConsoleInstrumentation de Faro shipperait comme signal d'erreur en dev/staging.

Décision

  1. Façade feuille services/analytics.ts — miroir de la doctrine services/telemetry.ts (ADR 0016) : unique importeur de posthog-js dans tout le repo, flag module initialized, wrappers (captureEvent, identifyUser, resetUser, captureError) no-op tant que initAnalytics() n'a pas tourné, try/catch avaleurs (l'analytics ne fait jamais tomber l'app). Token falsy (VITE_POSTHOG_PROJECT_TOKEN) → init refusée (console.error en dev).
  2. Consentement opt-in persisté — store stores/analyticsConsent.ts (persist: true) : status: 'unknown' | 'accepted' | 'refused'. promptIfUndecided() ouvre le modal système global (ADR 0018) « Améliorer l'application » avec Accepter/Refuser, appelé fire-and-forget depuis le onMounted de BuchardApp (le router modal est déjà injecté à ce point).
    • Accepter → persiste + initAnalytics() immédiat + identifyUser(customer) si connecté (le $pageview initial part sur la page courante ; defaults: '2026-01-30' ⇒ pageviews history_change ensuite).
    • Refuser → persiste ; PostHog n'est jamais initialisé.
    • Dismiss (backdrop / ✕ / back natif) → reste unknown : pas de tracking cette session, re-demandé au prochain cold start.
  3. Cold start : main.ts n'initialise PostHog que si analyticsConsent.isAccepted (décision persistée d'un lancement précédent) — plus rien au top-level du module (contrainte deadlock top-level-await, cf. overview.md).
  4. e2e : VITE_POSTHOG_PROJECT_TOKEN vidé dans .env.e2e (défense en profondeur — l'init serait refusée même sur le chemin accepté) + fixture auto seedAnalyticsConsent qui seed 'refused' pour tous les specs (option test.use({ analyticsConsent: 'none' }) pour le spec du prompt). « Zéro requête PostHog » devient une assertion automatique de chaque test (catch-all mockBackend).

Alternatives rejetées

  • opt_out_capturing_by_default: true : insuffisant — l'init émet quand même la remote-config et pose la persistence (point 2 du contexte).
  • Init immédiate + opt_in_capturing() au consentement : même défaut.
  • Bannière/écran de consentement dédié : le modal système existe déjà, est back-able (ADR 0009/0018) et suffit à un choix binaire.
  • UI de révocation (settings) : reportée — pas dans cette itération ; la révocation demanderait aussi posthog.opt_out_capturing() + purge de persistence. (Amendement juillet 2026 : implémentée depuis — collapsible « Confidentialité » de l'écran Autres, radios Accepter/Refuser sur les actions accept()/refuse() du store ; refus en session = opt_out_capturing(), ré-acceptation = levée d'opt-out dans initAnalytics(). Pas de purge de persistence : rien ne part, le résidu local est assumé. Cf. business-rules.md → « Consentement analytics modifiable depuis “Autres” ».)

Conséquences

  • grep -rn "posthog-js" src/ ne doit matcher que services/analytics.ts — toute émission d'event passe par captureEvent (cf. patterns.md → « Émettre de l'analytics produit »).
  • Avant consentement (ou après refus), aucun octet ne part vers PostHog — pas même la remote-config. Les events émis pendant ce temps sont perdus (pas de queue) : assumé, c'est le sens du refus.
  • Le consentement accepté couvre l'identify (email + nom liés au compte) — le texte du modal ne promet volontairement pas d'anonymat.
  • La suite e2e repasse au vert (plus de requête posthog non-mockée) ; les specs existants ne voient jamais le modal (refus auto-seedé).
  • Tests : unit services/__tests__/analytics.test.ts + stores/__tests__/analyticsConsent.test.ts, e2e e2e/specs/analytics-consent.spec.ts.
  • (Amendement juillet 2026) La config d'init doit rester compatible avec le patch fetch/XHR de CapacitorHttp (CapacitorHttp: { enabled: true } dans capacitor.config.ts, patch global) : disable_compression: true est obligatoire. Sans lui, posthog-js poste ses batches gzip en Blob, que le convertBody() du bridge ne sait pas convertir (pas de branche Blob) — côté natif, CapacitorUrlRequest.getRequestData() jette serializationError (« error 0 ») et aucun event ne part sur device (iOS et Android, constaté sur device iOS). Sans compression, les bodies sont des strings, toujours sérialisables par le bridge.
  • Le rapport wizard posthog-setup-report.md (racine, non commité) est remplacé par cet ADR.

Contributors

No contributors

Changelog

No recent changes