ArchitectureLa mesure de la plateforme

La mesure de la plateforme

Ce que BoostEcom sait de son propre trafic, de son propre funnel et de son propre coût d'acquisition — et par quel chemin.

Ce que BoostEcom sait de son propre trafic, de son propre funnel et de son propre coût d'acquisition — et par quel chemin.

Pourquoi ce fichier existe

Plus de cinquante fichiers de docs/ parlent de tracking, d'analytics, d'attribution ou de télémétrie. Aucun ne parlait de la NÔTRE : ils décrivent le scan tracking vendu au client (src/features/tracking/CLAUDE.md), l'espionnage des boutiques tierces (docs/architecture/intelligence-pipeline.md), le Radar, le marketplace. La mesure de la plateforme elle-même — DataFast, le catalogue d'événements, les milestones, la chaîne d'acquisition — vit dans une trentaine de fichiers de src/ et n'était écrite nulle part.

North star GTM (intention owner, ADR 0018) : Weekly Operated Stores — boutiques avec une tâche e-commerce significative terminée ou réellement avancée dans la semaine. Définition produit : gtm-operating-system.md §9. Pas encore un compteur dérivé ici : instrumenter = backlog/growth-web/2700. Jusque-là, aucun chiffre « WOS » marketing.

C'est le défaut que ce dépôt traque ailleurs sous le nom de dérive de documentation, à sa forme la plus dure : pas une doc fausse, une doc absente. Quatre incidents de cette famille sont déjà au dossier — growth-web/0212 (les événements serveur partaient vers un hôte NXDOMAIN), growth-web/0307 (SITE_CONFIG publiait une clé de consent qui n'existait pas), l'entrée CSP pour un CDN que rien n'a jamais chargé, et les 37 noms d'événements déclarés que rien n'émettait. Tous partagent la même cause : personne n'avait d'endroit où lire la chaîne en entier.

Les quatre plans de mesure, et pourquoi il y en a quatre

PlanSourceDisponible quandSert à
DataFast (navigateur)tiers, datafa.stle visiteur a accepté les cookies ET la requête porte datafast_visitor_idacquisition, sources, goals, funnel marketing
UserMilestonenotre Postgrestoujoursfunnel produit : où les utilisateurs s'arrêtent
DataFast Bot traffictiers, datafa.st, côté serveurla requête porte un user-agent de crawler connuquel moteur d'IA lit quoi chez nous
BrandCitation (self)notre Postgres, en interrogeant des LLMune exécution du cron aeo-citationest-ce qu'un moteur d'IA nous NOMME quand on lui pose nos questions

Le second n'est pas une redondance : il existe précisément parce que le premier a des conditions de disponibilité qu'on ne contrôle pas. Un cron, un visiteur qui refuse les cookies : DataFast ne voit rien. Le webhook Stripe était dans cette liste jusqu'à growth-web/2711 : il connaît l'utilisateur, et User.datafastVisitorId, capturé à la connexion, est rendu à emitServer pour que le revenu atterrisse sur un canal. C'est une attribution au dernier navigateur connecté, pas au navigateur qui a payé, et c'est assumé. UserMilestone voit tout, parce que c'est une table à nous, écrite dans la même transaction que le fait métier.

Corollaire à retenir avant d'ouvrir un tableau de bord : tout chiffre de funnel qui doit être exact vient de UserMilestone, jamais de DataFast.

Le quatrième répond à la question que les trois autres laissent entière. Le plan Bot traffic dit qu'un robot est venu lire ; il ne dit rien de ce que le moteur répond ensuite à un humain. Un crawler peut nous lire tous les jours et ChatGPT ne jamais nous citer. Section dédiée plus bas.

Le troisième mesure un sujet que ni l'un ni l'autre ne peut voir, et ce n'est pas un réglage : un crawler n'exécute pas le script du navigateur, et il ne franchit aucun milestone. recordVisit va plus loin et les jette explicitement (isBot(ua)), à raison — le compteur public de visiteurs ne doit pas être gonflé par des robots. Section dédiée plus bas.

Les trois regimes de donnees, et qui n'a jamais rien signe

La section precedente decoupe par SOURCE de mesure. Celle-ci decoupe par SUJET, et c'est la decoupe qui a des consequences juridiques. Les deux sont vraies en meme temps ; les confondre est l'erreur que cette section existe pour empecher.

RegimeQui est mesureCe qui l'autoriseSa porte de sortie
1. Plateformenos propres visiteursleur consentement cookieThirdPartyConsentGate, seule barriere depuis growth-web/0609
2. Marchandsnos clients ET leurs clients finauxle contrat marchand + Shopify3 webhooks GDPR + cron gdpr-erasure
3. Boutiques tiercesdes storefronts publicsrien : ils n'ont jamais rien signe/about/scanner, IntelligenceOptOut, IntelligenceSuppression

Le regime 3 est le plus expose et le moins intuitif. Ces gens ne sont pas nos utilisateurs, ne le seront peut-etre jamais, et n'ont accepte aucune condition. On les lit parce que leur vitrine est publique. C'est exactement pourquoi BoostEcom-Scanner/1.0 cite /about/scanner dans son user-agent a chaque requete : c'est la page qu'un proprietaire de site trouve dans ses logs, et elle DOIT repondre 200.

Le regime 2 contient des personnes qui n'ont jamais entendu parler de nous : les clients finaux de nos marchands, via ShopifyCustomer et ShopifyOrder. Leur effacement n'est pas negociable et il est drainé par gdpr-erasure, avec completedAt comme preuve de fin.

La regle qui tombe de la

L'analytics de la plateforme ne touche jamais aux regimes 2 et 3.

DataFast mesure NOS visiteurs. Y faire transiter un evenement portant la donnee d'un client final de marchand, ou d'une boutique tierce scannee, sortirait cette donnee de son regime d'effacement sans que rien ne le signale : ni le webhook GDPR ni l'opt-out scanner ne peuvent atteindre un tiers.

Ce n'est pas un risque theorique corrige apres coup. Les 43 sites d'appel a emitServer( sont aujourd'hui tous dans le regime 1, et src/test/analytics-planes-stay-separate.test.ts existe pour que ca reste vrai : il refuse tout import de l'analytics plateforme depuis un chemin des regimes 2 ou 3.

Le plan DataFast, de bout en bout

Le navigateur ne voit jamais un domaine tiers

/js/script.js      → https://datafa.st/js/script.js              (next.config.mjs, afterFiles)
/datafast-events   → https://datafa.st/api/events                (next.config.mjs, afterFiles)

Les deux sont des rewrites serveur. Conséquences, toutes vérifiables :

  • src/lib/security/csp.ts n'a aucune entrée DataFast, ni en script-src ni en connect-src : 'self' suffit. Les deux qui y étaient (cdn.datafast.dev, api.datafast.dev) pointaient vers des hôtes NXDOMAIN et ont été retirées.
  • src/proxy.ts exclut /js/ et /datafast-events de son matcher : le middleware ne doit pas s'interposer sur un rewrite de tiers.

Le chargement est derrière le consentement

src/app/layout.tsx monte le <Script> sous trois conditions cumulatives : NODE_ENV === "production", DATAFAST_WEBSITE_ID non vide, et <ThirdPartyConsentGate> (src/components/integrations/consent/consent-gate.tsx).

Le gate n'est pas un excès de prudence. DataFast pose datafast_visitor_id, et src/modules/analytics/server.ts relit ce cookie exact pour attribuer les goals serveur : l'argument « pas d'identifiant persistant, donc pas de consentement » n'est pas disponible ici, le code du dépôt prouve le contraire.

GTM passe par un autre mécanisme — Consent Mode (src/modules/analytics/tracking/gtm-script.tsx pousse consent/default tout refusé, src/modules/analytics/tracking/gtm-consent-bridge.tsx rejoue la réponse stockée). Depuis le portage de la bannière Orbit (growth-web/3163), les choix analytics et marketing sont indépendants, datés et valables 180 jours. DataFast et bec_acq lisent analytics ; les grants publicitaires GTM lisent marketing. Consent Mode ne couvre que les tags du conteneur ; DataFast reste derrière le gate. Le lien « Cookies » du pied de page rouvre les préférences. Le retrait supprime les cookies de la catégorie et, pour la mesure, l'identifiant DataFast du compte connecté. Contrat et correspondance avec Orbit : consentement cookies.

Côté client : trois helpers, pas plus

src/modules/analytics/client.ts — trackGoal, identifyUser, trackFunnel. Cinq autres exports ont vécu ici sans aucun appelant, dont un getScriptProps qui pointait le script vers cdn.datafast.dev : c'est lui qui a fait mettre l'entrée CSP.

Le funnel Spy a ses noms de goals centralisés dans src/modules/analytics/spy-funnel.ts (SPY_GOAL), pour qu'un renommage silencieux ne casse pas la vue DataFast.

Lecture qualifiée de la thèse

/thesis ne traite pas une page vue comme une lecture. Les noms de goals et seuils sont centralisés dans src/config/commerce-intelligence-thesis.ts → COMMERCE_INTELLIGENCE_THESIS.measurement; le composant src/app/(marketing)/thesis/_components/thesis-engagement-observer.tsx les dérive et émet deux goals client, derrière le même consentement DataFast que le reste de la plateforme :

GoalCondition observableCe que ça signifie / ne signifie pas
thesis_qualified_read60 s écoulées ET 50 % de profondeur de scrollProxy d'attention qualifiée. Ne prouve ni compréhension, ni accord, ni intention d'achat.
thesis_complete_read90 % de profondeur de scrollProxy de lecture profonde. Ne prouve pas que chaque paragraphe a été lu.

Les deux portent path=/thesis et la version de la thèse. Les seuils sont explicites pour pouvoir comparer les sources sans réinterpréter a posteriori ce qu'était une « lecture qualifiée ».

La mesure paid/sociale en amont reste soumise à growth-web/2920 : tant qu'un provider réel n'observe pas spend, impressions ou reach, ces valeurs restent inconnues, jamais reconstruites depuis ces goals.

Le même fichier porte INSTALL_GOAL, les goals du parcours d'installation de l'extension. Ils existent pour une raison précise : extension_click (badge home, CTA du chat) était le dernier pas mesuré, donc tout ce qui se passait après le Chrome Web Store était noir. Lus ensemble, ils disent quelle moitié du relais est cassée :

Ce qu'on observeCe que ça désigne
extension_click fort, extension_install_view ~0l'extension n'ouvre pas notre page : son onInstalled, ou le champ Website de la fiche CWS
extension_install_view fort, extension_install_detected ~0la page s'ouvre mais le content script ne tourne pas sur cette origine

Un troisième goal, extension_install_cta, fermait le dernier maillon en mesurant le clic sur le lien d'inscription de la page. Ce lien n'existe plus (2026-09-12) : le panneau de l'extension s'ouvre desormais tout seul, dans son état non connecté, dès que cette page charge (Extensions/@BoostEcom/background/index.js, chrome.tabs.onUpdated — voir CLAUDE.md de ce dépôt, section « L'arrivée sur /extension/install »), donc la connexion se fait dans le panneau, hors page, sans clic à instrumenter ici. L'extension elle-même n'envoie aucune télémétrie (par conception), donc aucun remplacement direct.

Le repli le plus proche existe déjà et ne demande aucun câblage neuf : auth_signup (src/app/(minimal)/auth/page.tsx) porte landing et utm_campaign en premier-contact, capturés depuis window.location sur la page ou la session a commencé (src/modules/analytics/acquisition.ts). Pour qui est arrivé par cette page, c'est landing = "/extension/install" et utm_campaign = "boostecom_extension" (la valeur envoyée par l'onInstalled de l'extension). Filtrer auth_signup sur l'un des deux répond a « l'install a-t-elle converti », un saut plus tard qu'un clic de page, sans dupliquer le CTA du panneau.

La dimension source de extension_install_view est lue sur DEUX clés de query, src puis from. Ce n'est pas de la tolérance gratuite : src est le nom que cette plateforme documente, mais l'extension qui expédie envoie ?from=cws (son onInstalled, dans Private/Extensions/@BoostEcom/Development, background/index.js, sur BoostEcom/Ecosystem). Ne lire que src scorait chaque install réelle en direct — exactement ce que la dimension existe pour empêcher, puisqu'une redirection cassée se cache alors derrière du trafic organique. Le cas réel que ce trou a laissé vivre est suivi dans backlog/growth-web/0620.

Côté serveur : un goal, et seulement dans une requête

emitServer(event)                  src/services/events/server.ts
  └─ sendEvent()                   src/modules/analytics/server.ts
       └─ createDatafastGoal()     src/lib/datafast-api.ts
            └─ POST https://datafa.st/api/v1/goals

growth-web/0609 est tranché : le cookie est de retour. La réécriture sert script.js et non plus script.cookieless.js, donc datafast_visitor_id existe et les 43 sites d'appel à emitServer( enregistrent réellement, dans une requête qui porte le cookie.

Ce qui a tranché n'est pas l'argument vie privée, qui restait un arbitrage défendable des deux côtés. C'est une contrainte dure : l'attribution de revenu de DataFast exige datafast_visitor_id en metadata de checkout Stripe. Sans ce cookie, aucun euro ne peut être rattaché à un canal. Une plateforme qui mise ses lancements sur cette question précise ne peut pas faire tourner la variante qui la rend insoluble.

Conséquence : ThirdPartyConsentGate n'est plus une précaution, c'est la seule barrière entre un visiteur qui n'a pas répondu à la bannière et un vrai cookie tiers. Gardé par src/test/datafast-cookie-stays-behind-consent.test.ts, dans les deux sens : si quelqu'un revient au cookieless sans remettre le marqueur, il échoue aussi.

Les noms d'événements internes sont pointés (auth.signup) ; DataFast accepte [a-z0-9_:-]{1,64} (les deux-points sont permis, initiate:checkout est un exemple de sa doc), donc toGoalName() les normalise — il replie aussi le : sur _, ce qui est plus strict que nécessaire mais stable.

Les jetons : quatre préfixes, pas deux

Cette section a annoncé deux familles. DataFast en émet quatre, et elle affirmait de surcroît une chose fausse sur l'une d'elles.

PréfixeCe que c'estPortéeSecret ?RESTMCP
dfid_Website tracking IDun sitenon, il est dans la balise <script> que chaque visiteur télécharge——
df_Website API keyun siteouiouioui, lié à ce site
dft_Account access tokenle compte, ou une liste de sitesouiouioui
dfbot_Bot traffic tokenun site, l'ingestion des crawlersouioui (/api/ai-crawls)—

Correction : un df_ fonctionne avec le MCP. La première version de cette page écrivait « non » dans cette case. La doc de l'éditeur dit l'inverse — « the general MCP endpoint accepts those too, with access tied to that website » — et l'écran API / MCP du tableau de bord le dit aussi, mot pour mot : « Generate a website key for the DataFast API or connect one website to the DataFast MCP server ». La différence n'est pas l'accès au MCP, c'est la portée : un df_ y expose un site, un dft_ le compte et ses outils d'administration.

Règle pratique :

  • un df_ suffit pour écrire — poser un goal sur le site auquel il appartient, ce que fait createDatafastGoal — et pour brancher ce site au MCP ;
  • un dft_ est nécessaire pour ce qui est niveau compte : gérer les sites, les clés, les équipes, ou lire plusieurs sites d'un seul jeton.

resolveDatafastToken() lit DATAFAST_API_TOKEN puis, à défaut, DATAFAST_API_KEY uniquement comme pont de migration isolé dans canonical-contract.ts. Il ne regarde pas le préfixe, donc rien dans le dépôt ne signale lequel a été posé : une surface de niveau compte servie par un df_ rend un EmptyState, pas une erreur.

DATAFAST_WEBSITE_ID porte le dfid_, qui n'est pas un secret : il voyage dans le HTML de chaque page. Le ranger dans une variable d'environnement est une commodité de configuration, pas une protection, et le traiter comme un secret ferait perdre du temps à sa rotation.

Le MCP DataFast

DataFast expose un serveur MCP officiel, qui rend l'analytics interrogeable en langage naturel depuis Claude Code.

Endpoint   https://datafa.st/api/mcp
Transport  Streamable HTTP
Auth       OAuth (recommandé), ou Bearer <dft_…> / <df_…>

Branchement local :

claude mcp add --transport http datafast https://datafa.st/api/mcp

Ce serveur ne peut pas être branché depuis une session distante ni depuis la CI : le proxy d'egress refuse datafa.st (403 sur le CONNECT, alors que le DNS résout — vérifié le 2026-09-11). C'est la même contrainte qui bloque growth-web/0363, et la raison pour laquelle aucun test de ce dépôt ne peut valider un schéma de réponse DataFast. Le MCP se branche sur le poste, pas ici.

Les surfaces de lecture dans le produit

PageSourceÉtat
/admin/platform/funnelUserMilestone via getFunnelReport() (src/services/setup/funnel-report.ts)fiable, aucune dépendance externe
/admin/content/growth (section Launch)loadLaunchCockpit() (src/services/growth/launch-cockpit.ts)objectifs opérateur dans launch-goals.json ; observé = AttributionEvent (inscriptions Bulletin), Organization.acquisition + UserMilestone (boutiques, premier outil), relais extension via landing=/extension/install ; visiteurs = GET /api/v1/analytics/campaigns filtré utm_campaign=is:<vague> (getDatafastCampaigns), non observable avec sa raison écrite sur la ligne tant que la réponse n'est pas reconnue
/admin/content/growth (bandeau Opérations)getDatafastOverview + getDatafastGoals sur 7 jours, listPostizIntegrations, listRenderRuns (GitHub)trois tuiles, lues à chaque chargement : DataFast (visiteurs, complétions), Postiz (canaux listés, « Relire Postiz »), Remotion (dernier run, « Lancer un rendu »)
/admin/platform/eventsGET /api/v1/analytics/goals sur DataFast (getDatafastGoals)complétions et visiteurs par goal sur 7 / 30 / 90 jours. Réorientée par growth-web/0363 : l'ancien GET /api/v1/events n'a jamais été documenté et n'a jamais rendu une ligne

Ce que les lecteurs DataFast savent, et ce qu'ils avouent

src/lib/datafast-api.ts n'appelle plus que la famille analytics/* documentée. La forme de la requête est celle que le serveur MCP de l'éditeur publie pour les mêmes endpoints (websiteId, startAt / endAt, timezone, limit, offset, et filter_<nom>=<opérateur>:<valeur>), donc DATAFAST_WEBSITE_ID est désormais requis pour lire, pas seulement pour le script client. La forme de la réponse ne peut pas être observée depuis la CI (datafa.st est bloqué par le proxy d'egress) : chaque lecteur reconnaît les lignes documentées (name, completions, visitors ; campaign, visitors ; visitors, pageviews, …) sous les enveloppes usuelles ([], { data }, { data: { goals } }, { goals }), et rend sinon unrecognised avec les clés de premier niveau reçues. Les pages les impriment. Le premier chargement en production avec un token montre des chiffres ou nomme la branche à ajouter ; jamais une table vide qui ressemble à une semaine calme. C'est l'inverse de la façon dont la page events est restée vide un an.

Le trajet render → Postiz → URL observée

Jusqu'à growth-web/2657, un ContentRender sur linkedin / x / instagram / facebook sortait par un panneau « copier le texte » et un bouton « J'ai publié » qu'un opérateur cochait de mémoire. Le trajet existe maintenant, en trois temps, et chaque état est lu, jamais supposé :

TempsOùCe qui s'écrit
BrouillonsendRenderToPostiz (_actions) → services/growth/postiz.ts → POST /public/v1/posts avec un corps construit par features/growth/postiz-draft.tsstate = scheduled, externalId = id du post Postiz. type: "draft" est un littéral : rien ici ne sait programmer
Relecture« Relire Postiz » (bandeau Opérations) → syncPostizPublications → GET /public/v1/posts sur la fenêtre des rendus en attentestate = published seulement si Postiz rapporte PUBLISHED avec un releaseURL public ; publishedUrl, publishedAt, publicationProof nomment la relecture. Un post en ERROR ou disparu de Postiz est compté et laissé tel quel
Attributioninchangé : cron growth-attribution sur utm_content = GrowthUnit.idla CTA d'un brouillon est l'URL déjà UTMée du rendu, en premier commentaire (X / LinkedIn / Facebook) ou dans la légende (Instagram, pas de lien cliquable)

Les règles de schéma par plateforme (une pièce jointe obligatoire sur Instagram, 280 caractères par élément sur X, HTML <p> par ligne, settings TikTok DIRECT_POST + video_made_with_ai) vivent dans postiz-draft.ts et ses tests, et sont les mêmes que .claude/skills/boostecom-content/references/postiz.md §1 : le connecteur refuse à la porte ce que le MCP refuserait en output.errors. Fail-closed : sans POSTIZ_API_KEY, aucun bouton Postiz n'existe et le handoff manuel reste.

Le Media desk suit la même règle pour une vidéo : un artefact rendu par le workflow creative-render.yml (Vercel Blob, URL publique) peut devenir un brouillon Postiz vidéo (draftMediaToPostiz) ; l'acte est enregistré dans AdminAuditLog (growth.media.postiz_draft, clé = le chemin de l'artefact) et relu par le desk. Un fichier local n'a pas d'URL que Postiz puisse lire : son exit reste creative/pipeline/postiz.mjs et le ledger.

La chaîne d'acquisition — le substrat du CAC

C'est le seul chemin par lequel une dépense d'acquisition peut un jour être rapprochée d'un revenu.

?ref=<code> / ?utm_*
  └─ cookie bec_acq            src/modules/analytics/acquisition.ts (first-touch, 90 j, consent-gated)
       └─ src/services/billing/referral-link.ts   à la création de l'org
            ├─ Organization.acquisition   (JSON, la source qui a amené le proprietaire)
            └─ AffiliateRedemption        (fenêtre d'attribution 60 j)
                 └─ webhook Stripe        src/services/webhooks.ts
                      └─ subscription_started { plan, cents, currency, refCode }
                           └─ AffiliateCommission   30 % du net, 12 factures

Deux propriétés à connaître avant de bâtir un tableau de CAC dessus :

  • Premier contact gagne. captureAcquisition() ne réécrit jamais un bec_acq déjà posé. Une navigation interne portant ses propres UTM ne déplace pas la source d'origine.

  • Le utm_source sortant nomme la PLATEFORME, jamais nous. C'est ce que channelOf réduit en utm:<source>, donc c'est ce que la ligne du CAC affiche. utmFor a écrit utm_source=boostecom sur les six canaux : l'expéditeur, vrai des six à la fois, donc n'en séparant aucun. Tout ce qu'un render a jamais amené arrivait en un seul utm:boostecom, et un lancement X était indistinguable d'une newsletter. Corrigé dans growth-web/0613, gardé par src/test/growth-utm-reaches-the-cac.test.ts.

  • Le coût d'acquisition mesuré n'existe que du côté affiliation. AffiliateCommission est la seule ligne de dépense d'acquisition que la plateforme enregistre automatiquement. Une dépense publicitaire (Meta, Google) n'a aujourd'hui aucun chemin d'entrée : elle passerait par PlatformCost, qui est un registre à saisie mensuelle manuelle.

La convention UTM sortante

Un seul constructeur, utmFor, pour que deux liens du même lancement ne partent jamais avec deux orthographes. utm_source = d'où vient le clic, utm_medium = la classe de trafic, utm_campaign = l'unité distribuée, utm_content = l'unité elle-même (pas le render : une vérité dite trois fois est une chose qui a marché, pas trois).

Surfaceutm_sourceutm_mediumClé dans le CAC
xxsocialutm:x
linkedinlinkedinsocialutm:linkedin
instagraminstagramsocialutm:instagram
facebookfacebooksocialutm:facebook
redditredditsocialutm:reddit
tiktoktiktoksocialutm:tiktok
youtubeyoutubesocialutm:youtube
discorddiscordcommunityutm:discord
gmbgmblocalutm:gmb
emailnewsletteremailutm:newsletter
easyconnectoreasyconnectorownedutm:easyconnector
pagebuilderpagebuilderownedutm:pagebuilder
boostecom-frboostecom-frownedutm:boostecom-fr
christopherlasgichristopherlasgiownedutm:christopherlasgi
chrome-extensionchrome-extensionproductutm:chrome-extension
shopify-themeshopify-themeproductutm:shopify-theme
shopify-appshopify-appproductutm:shopify-app
shopify-partnersshopify-partnersdirectoryutm:shopify-partners
klaviyo-directoryklaviyo-directorydirectoryutm:klaviyo-directory
articleaucunaucununattributed

Ce que cette convention atteint AUJOURD'HUI

Le tableau ci-dessus décrit un vocabulaire, pas un état. Deux faits le bornent, et les taire ferait lire la table comme « voilà ce que nos liens portent » :

  • utmFor a UN seul appelant, services/growth/derive.ts. Aucune autre surface du dépôt ne l'invoque.
  • ContentRender contient zéro ligne. Le composeur Growth n'a jamais produit de rendu, donc ce seul appelant n'a jamais rien construit.

Conséquence : aucun lien sortant de cette plateforme ne porte aujourd'hui un tag issu de cette convention. Elle est correcte et prête ; elle attend un producteur.

Activer ce producteur (ops) : import plan → Derive ContentRender → une série Postiz draft avec utm_content = GrowthUnit.id → vérifier DataFast + cron growth-attribution + AttributionEvent. Commandes, chemins et modes d'échec : creative-media-os.md §14 (Remotion = train 2680+, pas un prérequis).

Le Bulletin, seul canal e-mail câblé de bout en bout, ne la traverse pas et n'a rien à taguer : compose-weekly.ts et compose-store-weekly.ts ne posent aucun ctaUrl, et le seul lien vers nous dans un envoi est celui de désabonnement. Celui-là ne doit jamais être tagué : compter un désabonnement comme une acquisition est exactement l'inversion que growth-web/0616 a corrigée sur les liens internes.

src/test/utm-convention-reach.test.ts tient cette section honnête : si utmFor gagne des appelants, elle doit cesser de dire « un seul ».

Poser les tags, côté opérateur

Les neuf surfaces owned, product et directory vivent HORS de ce dépôt : ce sont leurs liens sortants qui doivent porter le tag. La liste exacte, pour qu'elle survive à la PR qui l'a introduite :

PropriétéLien à poser
easyconnector.app?utm_source=easyconnector&utm_medium=owned&utm_campaign=<page>
pagebuilder.store?utm_source=pagebuilder&utm_medium=owned&utm_campaign=<page>
boostecom.fr?utm_source=boostecom-fr&utm_medium=owned&utm_campaign=<page>
christopherlasgi.fr?utm_source=christopherlasgi&utm_medium=owned&utm_campaign=<page>
Extension Chrome?utm_source=chrome-extension&utm_medium=product&utm_campaign=<ecran>
Thème Shopify?utm_source=shopify-theme&utm_medium=product&utm_campaign=<ecran>
App Shopify?utm_source=shopify-app&utm_medium=product&utm_campaign=<ecran>
Fiche Shopify Partners?utm_source=shopify-partners&utm_medium=directory&utm_campaign=profile
Fiche Klaviyo?utm_source=klaviyo-directory&utm_medium=directory&utm_campaign=profile

utm_content reste facultatif hors Growth : il pointe l'unité de vérité, ce qu'un lien de pied de page n'a pas.

Nos propres propriétés étaient le trou le plus cher. Le cookie de premier contact bec_acq est first-party à boostecom.app : un clic depuis easyconnector.app est donc un premier contact tout neuf et sans nom. Tout ce que le reste du réseau nous envoyait arrivait en unattributed.

Ce n'est pas cosmétique, et le calendrier le dit : config/ecosystem.ts porte status: "for_sale" sur easy-connector et page-builder. Un acheteur qui demande combien la propriété envoie pose une question à laquelle on ne savait pas répondre, et un referral non mesuré est de la valeur de revente qui n'apparaît pas au prix.

Trois classes, séparées exprès :

MediumCe que ça mesurePourquoi séparé
ownednos sites qui pointent icic'est la ligne qui bouge le jour d'une vente
productnotre logiciel qui renvoie ici (extension, thème, app)mesure la boucle, pas l'entonnoir
directoryannuaires Shopify Partners, Klaviyoon y figure, on ne les contrôle pas

Surface n'est pas canal, et les confondre a coûté cinq lignes. CHANNELS répond à « pour quoi renderBody sait composer » : en ajouter un veut dire écrire une recette native pour ce canal. SURFACES répond à « où un lien peut partir », ce qui est plus large. Les comptes de publication connectés sont discord, linkedin, facebook, x, youtube, instagram, reddit, gmb et tiktok-business (relevés le 2026-09-12) : cinq surfaces sur lesquelles on peut poster aujourd'hui n'avaient aucune place dans le vocabulaire UTM. Un lien déposé à la main sur Reddit arrivait non tagué, ou orthographié au feeling.

surfaceOfPostizPlatform fait la traduction depuis les identifiants de l'outil de publication, qui ne sont pas ce que le tag doit dire : tiktok-business devient tiktok. Une plateforme connectée après cette date rend null plutôt qu'une source devinée.

article est la ligne à comprendre, et ce n'est pas une préférence de nommage. Son appel à l'action est un lien de notre site vers notre app. captureAcquisition() écrit un premier contact pour toute URL portant de l'attribution et n'en réécrit jamais un : un lecteur venu de la recherche sans tags n'a donc pas encore de cookie, et taguer ce lien interne le marquerait utm:boostecom au clic. Une arrivée organique réétiquetée en un canal qui ne l'a pas amenée, c'est-à-dire précisément ce que la doctrine Growth refuse (« Aucune invention de métrique ou d'attribution »).

Pour la dépense payante, la clé se saisit à la main dans /admin/revenue/platform-costs et doit s'écrire pareil : utm:meta, utm:google. DataFast recommande utm_source=meta&utm_medium=paid-social côté Meta, donc utm:meta est bien la clé attendue.

La division, et ce qu'elle refuse de dire

src/services/billing/cac.ts joint les deux colonnes ci-dessus — le coût (AffiliateCommission) et le canal (Organization.acquisition) — et se lit sur /admin/revenue/cac. Pure : ni Prisma, ni Next, ni horloge, comme platform-costs.ts et mrr.ts, donc l'arithmétique se prouve par fixtures. La lecture Prisma vit à part dans src/services/billing/cac-report.ts.

Quatre refus sont inscrits dans la page elle-même, parce que chacun est une façon de lire ces chiffres comme meilleurs qu'ils ne sont :

RefusRaison
Ce n'est pas un CAC blendél'acquisition payante n'a aucune entrée automatique (voir juste au-dessus)
revenuePerPayingOrg n'est pas une LTVune ligne de commission s'arrête à la douzième facture, donc le revenu du mois 13 est invisible ICI. Aucun ratio LTV:CAC n'est calculé : il sous-estimerait le vrai et présenterait la sous-estimation comme une mesure
unattributed n'est pas directla capture attend le consentement, donc un refus et une visite directe laissent la même colonne vide
Pas de tuile « payback »une commission est une part d'une facture déjà encaissée, pas une avance : l'acquisition est cash-positive dès la première facture

Ce qui remplace le payback est la part effective, coût sur revenu, qui doit rester sous les 30 % publiés. Au-dessus, c'est un défaut en amont, et la page le signale au lieu de le rendre comme un prix.

La garde qui tient cette chaîne branchée est src/test/acquisition-column-has-a-reader.test.ts : elle refuse que Organization.acquisition redevienne une colonne écrite et lue par personne, ce qu'elle a été pendant des mois.

Le plan Bot traffic : ce que les moteurs d'IA lisent chez nous

src/modules/analytics/ai-crawl.ts, appelé depuis src/proxy.ts à côté de recordVisit. Paquet éditeur @datafast/ai-crawl.

Pourquoi c'était le trou le plus cher

Il portait exactement sur ce que la plateforme VEND. src/app/robots.ts ouvre nommément vingt-quatre user-agents d'IA, llms.txt, llms-full.txt et llms.json n'existent que pour eux, et features/aeo/crawler-audit.ts audite chez le MARCHAND la même accessibilité pendant que aeo-citation compte ses citations.

On conseillait donc une discipline dont on n'avait, chez nous, aucune observation. Pas « peu de trafic IA » : zéro mesure, et rien ne le signalait, parce qu'une absence de mesure ne lève pas d'erreur.

Ce qui part, et ce qui ne part jamais

Le paquet classe le user-agent avant toute requête réseau. Un visiteur humain n'est jamais classé, donc rien ne part le concernant : c'est la propriété qui autorise ce chemin à tourner hors du bandeau cookie, et ce n'est pas une dispense. Aucun cookie posé, aucun identifiant de visiteur lu, et l'IP transmise est celle d'un robot d'entreprise.

src/test/ai-crawl-tracking.test.ts tient la phrase : il refuse que le module gagne une lecture de cookie ou de consentement.

/drop/, /oauth/ et /datafast-events sont exclus en plus des défauts du paquet. Le premier est la raison de la liste : /drop/<token> livrait les créatives d'une marque et le jeton ÉTAIT le contrôle d'accès, donc publier l'URL chez un tiers publierait le jeton. La page est supprimée depuis le retrait du plan C (ADR 0043, 2026-09-26), mais des liens restent dans des boîtes mail : l'exclusion est gardée.

L'IP passe par notre extracteur, pas par celui du paquet

DataFast décide qu'un crawler est « vérifié » en comparant l'IP reçue aux plages publiées de l'éditeur. Cette comparaison ne vaut que ce que vaut l'IP : un extracteur générique lit la position 0 de x-forwarded-for, donc n'importe qui pourrait se déclarer GPTBot depuis une IP d'OpenAI et ressortir vérifié dans notre propre tableau de bord.

getIp reçoit donc explicitement getTrustedClientIp, qui lit le DERNIER saut — celui que Vercel appose (security-identity/0366). Sans ça, la perte n'est pas une mesure absente, c'est un chiffre faux présenté comme une preuve. Même famille de défaut que l'inflation du compteur de visiteurs, corrigée au même endroit.

Ce que ce plan ne couvre pas, et pourquoi

SurfaceMesuréeRaison
toutes les pagesouielles traversent le matcher du proxy
/llms.txt, /llms-full.txt, /llms.jsonouice sont de vraies routes
/robots.txt, /sitemap.xmlnonvoir ci-dessous
/api/**nonignoré par défaut par le paquet

robots.txt et sitemap.xml sont exclus du matcher de proxy.ts et le RESTENT. Les y ramener ferait passer les deux fichiers les plus cachés du site par ensureLocaleCookie, donc leur collerait un Set-Cookie : on échangerait une mesure contre une régression de cache sur la surface que les crawlers lisent en premier. Les deux sont par ailleurs des Metadata Routes Next (src/app/robots.ts), dont le handler ne reçoit aucune requête — il n'y a pas d'user-agent à lire sans les convertir en route handlers.

C'est une limite connue, pas un oubli : backlog/growth-web/0651.

Le plan Citations LLM : est-ce qu'une IA nous NOMME

src/features/aeo/citation-tracker.ts pose une question d'achat à deux moteurs — un Anthropic, un OpenAI — et compte si la marque apparaît dans la réponse. C'est la seule boucle de retour du dépôt sur le GEO, et ni Search Console ni aucun outil SEO ne sait la produire.

Pourquoi il n'avait jamais tourné

Le cron aeo-citation ouvrait sur plan IN (PAID_PLANS) et sortait immédiatement. Il y a zéro organisation payante. Le code était écrit, testé, plafonné à ~5 ¢ par exécution — et il n'avait produit aucune ligne depuis qu'il existe, tous les mois, sans que rien ne le signale : une mesure qui n'a pas lieu ne lève aucune erreur. C'est la même famille de défaut que le trou crawler ci-dessus, à ceci près qu'ici le code de mesure existait déjà.

Pendant ce temps growth-web/0654 et 0655 publient des pages dont l'objectif explicite est d'être citées par ces moteurs. Une ligne de base mesurée APRÈS leur publication n'est pas une ligne de base.

Le sujet de la mesure est une MARQUE, pas une boutique

BrandCitation était clé par storeId NOT NULL. BoostEcom n'est pas une boutique cliente, donc le seul sujet dont on avait besoin d'une ligne de base était le seul que la table ne pouvait pas porter.

storeId est désormais nullable et chaque ligne porte son brandName. Ce n'est pas une entorse pour un cas unique : toutes les tables d'Intelligence qui mesurent un sujet NON-client le font déjà par sujet plutôt que par ligne Store — StoreSignalIndex, StoreMetricDaily, IntelligenceOptOut, IntelligenceSuppression, StoreTracker. BrandCitation était l'exception.

L'alternative pesée était une organisation interne portant un store « BoostEcom ». Elle a été écartée pour une raison qui n'est pas esthétique : pour que le cron la ramasse, cette organisation devrait figurer dans PAID_PLANS — donc une organisation payante fictive dans une base qui en compte zéro, lue telle quelle par calculate-kpis, kpi-snapshot, platform-alerts et les tableaux de bord admin. On aurait acheté une mesure en corrompant les seuls chiffres business du produit. Elle aurait par ailleurs porté framework: "shopify" sur un site Next.js, et intelligence-tick — qui n'a aucun filtre — aurait scanné notre propre site comme une boutique Shopify.

L'élargissement NOT NULL → NULL vit dans EXTRA_STEPS (services/database/pending-migrations.ts) : le guard généré sait AJOUTER une colonne, jamais en ALTÉRER une. Même forme et même raison que BrowserSession.storeId.

La persona, qui est le vrai piège

Le prompt système par défaut dit « you are a shopping assistant, the user is researching products to buy ». Un assistant d'achat à qui on demande quel outil d'espionnage Shopify utiliser répond sur des boutiques. Scorer nos requêtes sous cette persona aurait mesuré la persona et pas la marque — et aurait rendu un taux parfaitement plausible. Un chiffre faux est pire que le chiffre manquant qu'il remplace.

Le sujet plateforme court donc sous software-buyer (features/aeo/self-citation.ts). Les boutiques clientes gardent shopper, leur comportement d'origine.

Le jeu de requêtes est FIGÉ, pas dérivé

Cinq requêtes, ancrées sur les trois catégories que déclare le registre des comparatifs (ad-research, store-intelligence, ai-agent), plus notre différenciateur MCP et la question de substitution.

Elles ne sont volontairement PAS dérivées de ce registre. Une série temporelle n'est comparable que tant que ses questions ne bougent pas : un jeu qui changerait à chaque édition d'une fiche concurrent redéfinirait la métrique en silence, et la chute se lirait comme une perte de visibilité au lieu d'un changement de question. Changer le libellé démarre une nouvelle série, et doit le dire.

Cinq requêtes × deux moteurs = dix appels, exactement le plafond maxCalls existant. Le budget n'a pas été élargi pour nous.

Prendre la ligne de base

Le cron est mensuel (0 7 1 * *), et une ligne de base qui arrive après la première page /compare ne sert à rien. Le run plateforme est donc enqueué avant tout ce qui peut retourner tôt, et dédupliqué sur le mois — aeo-citation:self:<YYYY-MM> — de sorte qu'un déclenchement manuel ne peut ni doubler la facture ni écrire la série deux fois :

curl -sS -H "Authorization: Bearer $CRON_SECRET" \
  https://www.boostecom.app/api/cron/aeo-citation

La réponse porte selfEnqueued. Lecture ensuite via loadSelfCitationSummary({ days }).

Ce que ce plan ne mesure pas

  • La part de voix réelle. On observe ce que le modèle répond à NOS questions, pas ce qu'il répond à celles de vrais acheteurs. C'est un indicateur de présence, pas une mesure d'audience.
  • La stabilité du moteur. Un même modèle répond différemment d'un jour à l'autre. Une exécution isolée ne prouve rien ; c'est la série qui porte l'information, et c'est pourquoi les questions sont figées.
  • Le trafic qui en découle. Une citation n'est pas un clic. Ce que les moteurs envoient réellement se lit sur le plan Bot traffic, et le lien entre les deux n'est pas mesuré.

Le catalogue d'événements

src/services/events/types.ts : un dictionnaire typé, une charge utile stricte par événement, 17 domaines.

La garde qui le tient honnête est src/test/events-taxonomy-emitted.test.ts : tout nom déclaré doit avoir un site d'appel emit( / emitServer( quelque part dans src. Avant elle, le catalogue annonçait 59 noms pour 22 réellement câblés — un tableau de bord construit sur ce fichier aurait mesuré 37 événements qui ne partaient jamais.

Conséquence pratique : on ajoute un événement après avoir écrit son émetteur, jamais avant.

Les variables d'environnement

VariableRôleSans elle
DATAFAST_API_TOKENjeton REST + MCP. Poser un dft_.les goals serveur sont abandonnés
DATAFAST_API_KEYalias de migration temporaire, à retirer après migration Vercel vers DATAFAST_API_TOKEN—
DATAFAST_API_URLsurcharge de base. Laisser vide.défaut https://datafa.st/api/v1
DATAFAST_WEBSITEdomaine injecté dans la balise scriptle script monte sans site
DATAFAST_WEBSITE_IDid du site ; il conditionne le montage du script ET le plan Bot trafficaucun tracking client, aucune mesure crawler
DATAFAST_BOT_TOKENjeton Bot traffic (dfbot_…), signe les rapports crawler serveur. Une TROISIÈME forme de jeton, à créer dans les réglages Bot traffic du site, pas dans les clés d'API du compterien, tant que « Reject unauthenticated requests » est off. Une fois activé : tous les rapports sont jetés et la carte reste vide
NEXT_PUBLIC_GTM_IDconteneur GTM (Consent Mode)pas de conteneur

DATAFAST_API_URL a valu un incident à elle seule : elle a porté https://api.datafast.dev pendant des mois, un hôte NXDOMAIN, et le catch {} de l'émetteur a rendu la panne invisible.

Ce que la plateforme ne mesure pas, et l'assume

  • Les impressions. AttributionEvent n'a délibérément pas de type impression : rien ici ne sait en observer une. Les goals slot_view / slot_buy_intent ont été retirés pour la même raison.
  • La déconnexion. auth_signout a été déclaré sans émetteur ; events.signOut de NextAuth est un vrai crochet, donc c'est un manque connu, pas une fonctionnalité retirée.
  • Le coût d'acquisition payant. Voir plus haut : aucune entrée automatique. Le CAC affiliation, lui, est mesuré depuis billing/0610 et vit sur /admin/revenue/cac — ce n'est pas le même chiffre et la page le dit en en-tête.

Questions ouvertes

ItemQuestion
growth-web/0363tranchée le 2026-09-17 : réorientée vers GET /api/v1/analytics/goals. Reste à faire le premier chargement en production avec un token, qui confirme ou corrige l'enveloppe (voir « Ce que les lecteurs DataFast savent »)