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
| Plan | Source | Disponible quand | Sert à |
|---|---|---|---|
| DataFast (navigateur) | tiers, datafa.st | le visiteur a accepté les cookies ET la requête porte datafast_visitor_id | acquisition, sources, goals, funnel marketing |
UserMilestone | notre Postgres | toujours | funnel produit : où les utilisateurs s'arrêtent |
| DataFast Bot traffic | tiers, datafa.st, côté serveur | la requête porte un user-agent de crawler connu | quel moteur d'IA lit quoi chez nous |
BrandCitation (self) | notre Postgres, en interrogeant des LLM | une exécution du cron aeo-citation | est-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.
| Regime | Qui est mesure | Ce qui l'autorise | Sa porte de sortie |
|---|---|---|---|
| 1. Plateforme | nos propres visiteurs | leur consentement cookie | ThirdPartyConsentGate, seule barriere depuis growth-web/0609 |
| 2. Marchands | nos clients ET leurs clients finaux | le contrat marchand + Shopify | 3 webhooks GDPR + cron gdpr-erasure |
| 3. Boutiques tierces | des storefronts publics | rien : 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.tsn'a aucune entrée DataFast, ni enscript-srcni enconnect-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.tsexclut/js/et/datafast-eventsde 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 :
| Goal | Condition observable | Ce que ça signifie / ne signifie pas |
|---|---|---|
thesis_qualified_read | 60 s écoulées ET 50 % de profondeur de scroll | Proxy d'attention qualifiée. Ne prouve ni compréhension, ni accord, ni intention d'achat. |
thesis_complete_read | 90 % de profondeur de scroll | Proxy 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 observe | Ce que ça désigne |
|---|---|
extension_click fort, extension_install_view ~0 | l'extension n'ouvre pas notre page : son onInstalled, ou le champ Website de la fiche CWS |
extension_install_view fort, extension_install_detected ~0 | la 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/0609est tranché : le cookie est de retour. La réécriture sertscript.jset non plusscript.cookieless.js, doncdatafast_visitor_idexiste 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_iden 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 :
ThirdPartyConsentGaten'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é parsrc/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éfixe | Ce que c'est | Portée | Secret ? | REST | MCP |
|---|---|---|---|---|---|
dfid_ | Website tracking ID | un site | non, il est dans la balise <script> que chaque visiteur télécharge | — | — |
df_ | Website API key | un site | oui | oui | oui, lié à ce site |
dft_ | Account access token | le compte, ou une liste de sites | oui | oui | oui |
dfbot_ | Bot traffic token | un site, l'ingestion des crawlers | oui | oui (/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 faitcreateDatafastGoal— 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
| Page | Source | État |
|---|---|---|
/admin/platform/funnel | UserMilestone 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/events | GET /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é :
| Temps | Où | Ce qui s'écrit |
|---|---|---|
| Brouillon | sendRenderToPostiz (_actions) → services/growth/postiz.ts → POST /public/v1/posts avec un corps construit par features/growth/postiz-draft.ts | state = 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 attente | state = 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 |
| Attribution | inchangé : cron growth-attribution sur utm_content = GrowthUnit.id | la 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 unbec_acqdéjà posé. Une navigation interne portant ses propres UTM ne déplace pas la source d'origine. -
Le
utm_sourcesortant nomme la PLATEFORME, jamais nous. C'est ce quechannelOfréduit enutm:<source>, donc c'est ce que la ligne du CAC affiche.utmFora écritutm_source=boostecomsur 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 seulutm:boostecom, et un lancement X était indistinguable d'une newsletter. Corrigé dansgrowth-web/0613, gardé parsrc/test/growth-utm-reaches-the-cac.test.ts. -
Le coût d'acquisition mesuré n'existe que du côté affiliation.
AffiliateCommissionest 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 parPlatformCost, 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).
| Surface | utm_source | utm_medium | Clé dans le CAC |
|---|---|---|---|
x | x | social | utm:x |
linkedin | linkedin | social | utm:linkedin |
instagram | instagram | social | utm:instagram |
facebook | facebook | social | utm:facebook |
reddit | reddit | social | utm:reddit |
tiktok | tiktok | social | utm:tiktok |
youtube | youtube | social | utm:youtube |
discord | discord | community | utm:discord |
gmb | gmb | local | utm:gmb |
email | newsletter | email | utm:newsletter |
easyconnector | easyconnector | owned | utm:easyconnector |
pagebuilder | pagebuilder | owned | utm:pagebuilder |
boostecom-fr | boostecom-fr | owned | utm:boostecom-fr |
christopherlasgi | christopherlasgi | owned | utm:christopherlasgi |
chrome-extension | chrome-extension | product | utm:chrome-extension |
shopify-theme | shopify-theme | product | utm:shopify-theme |
shopify-app | shopify-app | product | utm:shopify-app |
shopify-partners | shopify-partners | directory | utm:shopify-partners |
klaviyo-directory | klaviyo-directory | directory | utm:klaviyo-directory |
article | aucun | aucun | unattributed |
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 » :
utmFora UN seul appelant,services/growth/derive.ts. Aucune autre surface du dépôt ne l'invoque.ContentRendercontient 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 :
| Medium | Ce que ça mesure | Pourquoi séparé |
|---|---|---|
owned | nos sites qui pointent ici | c'est la ligne qui bouge le jour d'une vente |
product | notre logiciel qui renvoie ici (extension, thème, app) | mesure la boucle, pas l'entonnoir |
directory | annuaires Shopify Partners, Klaviyo | on 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 :
| Refus | Raison |
|---|---|
| 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 LTV | une 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 direct | la 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
| Surface | Mesurée | Raison |
|---|---|---|
| toutes les pages | oui | elles traversent le matcher du proxy |
/llms.txt, /llms-full.txt, /llms.json | oui | ce sont de vraies routes |
/robots.txt, /sitemap.xml | non | voir ci-dessous |
/api/** | non | ignoré 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
| Variable | Rôle | Sans elle |
|---|---|---|
DATAFAST_API_TOKEN | jeton REST + MCP. Poser un dft_. | les goals serveur sont abandonnés |
DATAFAST_API_KEY | alias de migration temporaire, à retirer après migration Vercel vers DATAFAST_API_TOKEN | — |
DATAFAST_API_URL | surcharge de base. Laisser vide. | défaut https://datafa.st/api/v1 |
DATAFAST_WEBSITE | domaine injecté dans la balise script | le script monte sans site |
DATAFAST_WEBSITE_ID | id du site ; il conditionne le montage du script ET le plan Bot traffic | aucun tracking client, aucune mesure crawler |
DATAFAST_BOT_TOKEN | jeton 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 compte | rien, tant que « Reject unauthenticated requests » est off. Une fois activé : tous les rapports sont jetés et la carte reste vide |
NEXT_PUBLIC_GTM_ID | conteneur 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.
AttributionEventn'a délibérément pas de typeimpression: rien ici ne sait en observer une. Les goalsslot_view/slot_buy_intentont été retirés pour la même raison. - La déconnexion.
auth_signouta été déclaré sans émetteur ;events.signOutde 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/0610et vit sur/admin/revenue/cac— ce n'est pas le même chiffre et la page le dit en en-tête.
Questions ouvertes
| Item | Question |
|---|---|
growth-web/0363 | tranché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 ») |