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 :
- 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).
opt_out_capturing_by_defaultne suffit pas : vérifié dans les sources deposthog-js—posthog.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 appelerinit()avant consentement.- La suite e2e entière était cassée : le catch-all de
MockBackendenregistrait la requête remote-config verseu.i.posthog.comcomme non-mockée →assertNoUnmatched()faisait échouer le teardown de chaque spec. - 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
- Façade feuille
services/analytics.ts— miroir de la doctrineservices/telemetry.ts(ADR 0016) : unique importeur deposthog-jsdans tout le repo, flag moduleinitialized, wrappers (captureEvent,identifyUser,resetUser,captureError) no-op tant queinitAnalytics()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). - 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 leonMounteddeBuchardApp(le router modal est déjà injecté à ce point).- Accepter → persiste +
initAnalytics()immédiat +identifyUser(customer)si connecté (le$pageviewinitial part sur la page courante ;defaults: '2026-01-30'⇒ pageviewshistory_changeensuite). - Refuser → persiste ; PostHog n'est jamais initialisé.
- Dismiss (backdrop / ✕ / back natif) → reste
unknown: pas de tracking cette session, re-demandé au prochain cold start.
- Accepter → persiste +
- Cold start :
main.tsn'initialise PostHog que sianalyticsConsent.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). - e2e :
VITE_POSTHOG_PROJECT_TOKENvidé dans.env.e2e(défense en profondeur — l'init serait refusée même sur le chemin accepté) + fixture autoseedAnalyticsConsentqui seed'refused'pour tous les specs (optiontest.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 actionsaccept()/refuse()du store ; refus en session =opt_out_capturing(), ré-acceptation = levée d'opt-out dansinitAnalytics(). 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 queservices/analytics.ts— toute émission d'event passe parcaptureEvent(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, e2ee2e/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 }danscapacitor.config.ts, patch global) :disable_compression: trueest obligatoire. Sans lui, posthog-js poste ses batches gzip enBlob, que leconvertBody()du bridge ne sait pas convertir (pas de branche Blob) — côté natif,CapacitorUrlRequest.getRequestData()jetteserializationError(« 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

