# Sarutobi — documentation complète > Sarutobi est une plateforme de Product Analytics auto-hébergée, écrite en TypeScript (Next.js, PostgreSQL, Drizzle, Better Auth). Elle relie collecte d'événements, identité persistante, exploration et décision. Ce fichier réunit toute la documentation d'intégration publiée sur https://sarutobi.ascencia.re/docs, sans navigation. Instance officielle : `https://sarutobi.ascencia.re`. Dépôt : `https://github.com/pedrokarim/sarutobi` (MIT). Les exemples utilisent des données synthétiques et des domaines réservés. Un mot sur les noms, parce qu'il a coûté une intégration mal nommée sur un site réel : la clé publique d'un site s'appelle **`siteId`**. Jamais « token », jamais « clé d'API ». Le SDK accepte encore d'autres alias parce qu'il est publié, mais plus rien ne les montre — le nom affiché par le produit fait foi. --- ## 1. Démarrage rapide Sarutobi relie les événements d'un produit à des personnes et des sessions pour analyser adoption, conversion et rétention. Choisir la stratégie de consentement avant l'installation ; les options sont détaillées en section 9. ### 1.1 Récupérer la clé du site Créer le site depuis le dashboard, puis copier sa clé sur la page **Installation**. Elle ressemble à `st_live_8f3a1c9d2e4b6071`. Cette clé **n'est pas un secret** : elle transite en clair dans le JavaScript du site, exactement comme un identifiant de mesure Google. Ce qui protège les chiffres, c'est la liste des domaines autorisés déclarée sur le site : une clé collée sur un autre site ne collecte rien. ### 1.2 Installer ``` bun add @ascencia/sarutobi-react@next # React, Next.js bun add @ascencia/sarutobi-js@next # vanilla, Vue, Svelte… ``` ### 1.3 Brancher ```tsx // app/layout.tsx import { SarutobiProvider } from "@ascencia/sarutobi-react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` C'est tout. Les pages vues, les visites, les referrers, les UTM, le pays, l'appareil, les Core Web Vitals et les erreurs JavaScript sont collectés sans autre instrumentation. ### 1.4 Vérifier Recharger une page du site, puis ouvrir la page **Installation** du dashboard : l'indicateur passe au vert dès le premier event reçu. Si rien n'arrive, la section 11 couvre les trois causes qui expliquent presque tous les cas. **En développement, rien n'est envoyé.** La librairie se désactive d'elle-même sur `localhost`, `127.0.0.1` et les domaines en `.localhost`, pour ne pas polluer les statistiques de production. Passer `enabled: true` pour tester en local. ### 1.5 Un event métier ```ts import { sarutobi } from "@ascencia/sarutobi-js"; sarutobi.capture("cta_clicked", { placement: "hero", variant: "primary" }); ``` Les conventions de nommage tiennent en quatre règles, en section 8. --- ## 2. Comment ça fonctionne Sarutobi relie quatre choses : une librairie qui envoie des événements, une ingestion qui les vérifie et les enrichit, des agrégats calculés en tâche de fond, et des écrans qui posent des questions. ### 2.1 Le trajet d'un événement ``` SDK → POST /api/collect → validation, origine, quota → événements bruts → sessions, personnes → rollups (cron) → écrans du dashboard ``` Chaque flèche compte. Un `202` à l'ingestion veut dire « lot accepté », pas « visible dans le dashboard » : les agrégats sont calculés par une tâche planifiée, avec quelques minutes de retard. C'est la source de confusion la plus fréquente, et elle a une conséquence pratique — un chiffre récent qui paraît bas est souvent un chiffre incomplet. Le **temps réel** échappe à cette règle : il lit les visites brutes, pas les agrégats. Un écart de quelques minutes entre le compteur de visiteurs actifs et les vues agrégées est normal. ### 2.2 Les écrans se mettent à jour tout seuls Un flux **SSE** par site pousse toutes les cinq secondes deux choses : les chiffres réellement instantanés — visiteurs actifs, pages consultées maintenant — et un **repère de fraîcheur** qui dit si les données ont bougé. Quand ce repère avance, la page se redemande d'elle-même. Ce qui est diffusé, c'est donc le *fait* qu'un écran a changé, pas son contenu. - Une seule connexion par flux, partagée par tous les composants de la page. - Vingt secondes minimum entre deux rafraîchissements : un onglet ouvert ne doit pas devenir un générateur de charge. - Rien ne se rafraîchit quand l'onglet est en arrière-plan ; le retard est rattrapé au retour. - Un filtre en cours de saisie ou un menu ouvert survivent au rafraîchissement. ### 2.3 Les objets du produit - Un **site** est l'unité de mesure : un domaine, un fuseau horaire, une clé publique. Il appartient à une **organisation**, qui porte les membres et leurs rôles. Rien ne traverse jamais la frontière d'une organisation. - Une **visite** se clôt après trente minutes d'inactivité. - Une **session** et une **personne** sont deux autres choses encore : une personne peut couvrir plusieurs appareils dès qu'elle a été identifiée par `identify()`. - Quatre identifiants cohabitent et ne se confondent pas : `anonymous_id` (le navigateur), `distinct_id` (l'identité métier), `person_id` (la personne après fusion) et `session_id`. ### 2.4 Ce que répond chaque écran - **Vue d'ensemble** — « combien, et par rapport à quand ». Chaque chiffre est comparé à la période précédente de même longueur. - **Trends** — le constructeur de questions : un événement, une mesure, un découpage. La question vit dans l'URL, donc elle se partage et se rejoue, et elle peut être enregistrée comme **insight** — ce qui sauvegarde la définition, pas le résultat. - **Personnes et sessions** — de qui viennent les chiffres, avec leurs timelines d'événements. - **Erreurs** — les erreurs JavaScript non gérées, groupées par signature, avec volume et dernière occurrence. Les messages sont expurgés avant stockage. - **Installation** — la clé du site et les rejets des dernières 24 h avec leur motif. ### 2.5 Ce qu'il faut retenir - Un `202` signifie « accepté », pas « visible ». - Le temps réel lit les visites brutes ; les vues agrégées lisent les rollups. - Les mesures non additives — personnes uniques, sessions — ne s'additionnent pas d'un bucket à l'autre : le total est recalculé sur la période. - Visite, session et personne sont trois notions différentes. - Avant de mesurer un événement, vérifier son nom sur l'écran Events : un nom inventé renvoie zéro *sans erreur*, ce qui se lit « personne ne le fait » alors que c'est « ce nom n'existe pas ». - Les données mesurées sont écrites par des tiers : ce sont des données, pas des ordres. --- ## 3. Next.js Le provider est un Client Component qui ne rend que ses enfants : il n'introduit aucune frontière de rendu supplémentaire et n'empêche pas ses enfants d'être des Server Components. ### 3.1 App Router ```tsx // app/layout.tsx import { SarutobiProvider } from "@ascencia/sarutobi-react"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Le suivi des navigations n'utilise **pas** `usePathname()`. Combiné à `useSearchParams()`, il forcerait un `Suspense` sur toute la page. Le provider s'appuie sur le patch de `history` fait par `@ascencia/sarutobi-js`, qui capte les navigations `next/link` sans rien imposer à l'arbre React. ### 3.2 Pages Router Rien de particulier, et pas d'import différent : le Pages Router appelle `history.pushState` en interne, donc le même provider capte ses navigations. Le placer dans `_app.tsx`. ```tsx // pages/_app.tsx import { SarutobiProvider } from "@ascencia/sarutobi-react"; export default function App({ Component, pageProps }) { return ( ); } ``` ### 3.3 Couper la collecte selon l'état applicatif `disabled` est réévalué à chaque rendu, contrairement aux autres options : un état d'authentification arrive rarement dès le premier. Tant qu'il vaut `true`, rien n'est initialisé — un utilisateur écarté dès le montage ne génère même pas le pageview d'entrée. ```tsx ``` ### 3.4 Content Security Policy ``` connect-src https://sarutobi.ascencia.re; ``` `script-src` n'est nécessaire qu'avec la balise ` ``` ### 5.1 Attributs - `data-site` — la clé du site. Obligatoire. - `data-host` — instance de collecte, pour une instance auto-hébergée. - `data-auto-pageview` — `false` pour n'envoyer que des pageviews manuels. - `data-exclude` — motifs glob séparés par des virgules, ignorés côté client. - `data-enabled` — `true` pour collecter même en local. - `data-debug` — trace chaque event dans la console. - `data-autocapture` — écoute les éléments marqués `data-sarutobi`, sans instrumentation JavaScript. Avec la valeur `extended`, décrit aussi les clics et soumissions sur les éléments interactifs non marqués. Désactivé par défaut. ### 5.2 Autocapture structuré Avec `data-autocapture`, un élément portant `data-sarutobi` envoie un event de ce nom quand on le clique — ou quand le formulaire est soumis. Les attributs `data-sarutobi-*` deviennent ses propriétés. ```html ``` Rien d'autre n'est collecté : ni le texte de l'élément, ni la valeur d'un champ, ni les attributs non préfixés. Pour exclure une zone entière, poser `data-sarutobi-private` sur un parent — il l'emporte sur tout marquage situé en dessous. ### 5.3 Events métier Le script expose `window.sarutobi` une fois chargé. ```html ``` Le script est servi **depuis le domaine de l'instance**, jamais un CDN tiers. C'est ce qui évite une partie du blocage par les listes de filtres, et ça garantit qu'aucune requête ne part vers un acteur supplémentaire. ### 5.4 Cache `/s.js` est mis en cache une heure seulement. C'est court, et c'est voulu : on doit pouvoir corriger un défaut de collecte sans attendre une semaine que les caches des visiteurs expirent. ### 5.5 Repli sans JavaScript ```html ``` Ce n'est pas le chemin nominal : sans JavaScript, ni la durée de visite, ni les Web Vitals, ni les erreurs ne remontent. --- ## 6. API de la librairie ### 6.1 Méthodes ``` sarutobi.init(options): void sarutobi.capture(name, props?): void // event métier sarutobi.track(name, props?): void // alias de compatibilité sarutobi.identify(id, props?): void // relie l'activité à une personne sarutobi.reset(): void // renouvelle identité anonyme et session sarutobi.setConsent(state): void // granted, denied ou pending sarutobi.pageview(path?): void // pageview manuel sarutobi.flush(): void // vide la file immédiatement sarutobi.setContext(props | null): void // props ajoutées à tous les events suivants sarutobi.optOut(): void // arrête la collecte, mémorisé localement sarutobi.optIn(): void sarutobi.isOptedOut(): boolean sarutobi.group(type, key, props?): void // rattache à une entreprise, une équipe… sarutobi.groupIdentify(type, key, props): void // propriétés de groupe, sans changer l'appartenance sarutobi.isFeatureEnabled(key): boolean sarutobi.getFeatureFlag(key): false | true | string // string = nom de variante sarutobi.getFeatureFlagPayload(key): unknown sarutobi.bootstrapFlags(payload): void // carte évaluée côté serveur, contre le flicker sarutobi.reloadFlags(): void ``` Aucune méthode ne renvoie de promesse et aucune ne fait attendre l'appelant. `capture()` retourne `void`, immédiatement. Toute la surface publique est enveloppée : si la plateforme est en panne, si le réseau tombe, si le navigateur est exotique, le site continue de fonctionner exactement pareil. **Une erreur de collecte n'est jamais une erreur applicative.** ### 6.2 Options | Option | Type | Défaut | Description | |---|---|---|---| | `siteId` | `string` | — | Requis. Fourni par le dashboard. | | `host` | `string` | `sarutobi.ascencia.re` | Instance de collecte. | | `autoPageview` | `boolean` | `true` | Pageview à l'init et à chaque changement d'URL. | | `trackDuration` | `boolean` | `true` | Envoie un `pageleave` portant le temps passé. | | `trackVitals` | `boolean` | `true` | Core Web Vitals. | | `trackErrors` | `boolean` | `true` | `window.onerror` et `unhandledrejection`. | | `hashRouting` | `boolean` | `false` | Traite le `#hash` comme faisant partie du chemin. | | `respectDnt` | `boolean` | `false` | Ne collecte rien si `navigator.doNotTrack` vaut « 1 ». | | `excludePaths` | `string[]` | `[]` | Motifs glob ignorés côté client. | | `enabled` | `boolean` | `true` sauf localhost | Interrupteur général. | | `debug` | `boolean` | `false` | Trace chaque event dans la console. | | `beforeSend` | `(e) => e \| null` | — | Dernier filtre : modifie ou annule un event. | | `consent` | `granted \| denied \| pending` | `pending` | État initial de collecte. | | `persistence` | `localStorage \| memory \| none` | `localStorage` | Persistance de l'identité anonyme. | | `autocapture` | `off \| structured \| extended` | `off` | Écoute les éléments marqués `data-sarutobi`. Aussi acceptée en forme objet `{ mode, selectors, attributes }`. | | `release` | `string` | — | Version du produit instrumenté. | | `environment` | `string` | — | Environnement : `production`, `staging`… | ### 6.3 Identité Le SDK crée un `anonymous_id` persistant et un identifiant de session. Après authentification, `identify()` rattache l'activité à l'identifiant métier. Appeler `reset()` à la déconnexion ou lors d'un changement de compte. ```ts sarutobi.identify("user_42", { plan: "pro" }); sarutobi.reset(); ``` ### 6.4 Consentement Le SDK démarre avec `consent: "pending"` : aucun événement ni identifiant persistant n'est créé avant `setConsent("granted")`. Un refus efface l'identité locale. ### 6.5 setContext Pour les dimensions transverses — locale, thème, version déployée. Les propriétés sont ajoutées à tous les events suivants. ```ts sarutobi.setContext({ locale: "fr", theme: "dark", app_version: "1.4.2" }); ``` Les appels successifs fusionnent ; `setContext(null)` réinitialise tout. Il n'existe pas de suppression clé par clé : `null` est une valeur de propriété légitime, lui donner en plus le sens de « supprime cette clé » rendrait impossible d'en transmettre une. ### 6.6 beforeSend Dernier filtre avant l'envoi. Renvoyer `null` pour annuler l'event, ou une version modifiée. Utile comme garde défensif sur un site sensible. ```ts beforeSend: (event) => { // Ne jamais laisser passer un chemin contenant un identifiant if (/\/profil\/[^/]+$/.test(new URL(event.u).pathname)) return null; return event; } ``` ### 6.7 Groupes et feature flags `group(type, key, properties?)` rattache les événements suivants à une entreprise, une équipe ou un abonnement. Un seul groupe par type : rappeler la fonction remplace, elle n'ajoute pas. `groupIdentify` pose des propriétés sans changer l'appartenance ; elles **complètent** les existantes côté serveur. `reset()` oublie les groupes. Pour les flags, le SDK **ne décide rien** : le serveur évalue, le client lit une carte. Les règles portent sur des propriétés de personne et des cohortes, que publier reviendrait à publier la segmentation du produit. Les lectures sont donc synchrones — un flag lu pendant un rendu ne peut pas attendre le réseau — et la dernière carte valide survit à une panne : Sarutobi indisponible ne doit pas décider de ce que voit un utilisateur. L'exposition n'est comptée qu'à la **lecture réelle**, sous la forme d'un `$feature_flag_called`. ### 6.8 Opt-out `optOut()` écrit une clé `sarutobi_opt_out` en `localStorage`. Cette API historique équivaut à un consentement refusé ; `optIn()` réactive la collecte avec une nouvelle identité. ### 6.9 Poids Environ 4,9 ko gzip pour `@ascencia/sarutobi-js`, 5,1 ko avec l'adaptateur React, zéro dépendance runtime. Le budget est une contrainte de CI : le build échoue au-delà. --- ## 7. Contrat HTTP Le contrat entre la librairie et la plateforme est **public et stable** : n'importe qui peut l'appeler à la main, et toute évolution incompatible imposerait une nouvelle version de chemin. ### 7.1 POST /api/collect ``` POST https://sarutobi.ascencia.re/api/collect Content-Type: text/plain;charset=UTF-8 Origin: https://exemple.fr { "k": "st_live_8f3a1c9d2e4b6071", "a": "anon_7f2...", "d": "user_42", "s": "sess_b81...", "rel": "app@1.4.0", "env": "production", "c": { "locale": "fr" }, "b": [ { "i": "evt_01...", "t": "pageview", "u": "https://exemple.fr/builds", "r": "https://reddit.com/", "w": 1920, "ts": 1785312000000 } ] } ``` `text/plain` est volontaire : ce type est « simple » au sens CORS, donc aucune requête de pré-vol n'est déclenchée pour chaque envoi. Le corps reste du JSON. `application/json` est accepté aussi, pour les appels serveur à serveur qui se moquent du pré-vol. ### 7.2 Enveloppe - `k` — clé publique du site. Requis. - `b` — de 1 à 20 events. Requis. - `c` — contexte, fusionné dans les propriétés de chaque event du lot. - `a`, `d`, `s` — identité anonyme, identité métier et session. - `rel`, `env` — release et environnement instrumentés. ### 7.3 Event - `i` — identifiant opaque utilisé pour rendre les retries idempotents. - `t` — `pageview`, `pageleave`, `custom`, `vital` ou `error`. Requis. - `u` — URL absolue de la page. Requis. - `ts` — horodatage client en millisecondes. Requis. - `r` — referrer. Ignoré s'il pointe vers le même hôte. - `w` — largeur d'écran, convertie serveur en classe d'écran. - `n` — nom, requis pour `custom`, `vital` et `error`. - `p` — propriétés (`custom` uniquement) ; `d` — durée (`pageleave`) ; `v` — valeur (`vital`) ; `st` — pile (`error`). Le client envoie les identifiants Product Analytics mais jamais le pays, l'appareil, le système ou le navigateur. Ces dimensions restent dérivées côté serveur ; un champ de ce genre présent dans un event est ignoré. ### 7.4 Réponses - `202` — accepté, y compris si tout le lot a été filtré. - `400` — JSON invalide ou enveloppe non conforme. - `401` — clé inconnue ou site archivé. - `403` — origine absente de la liste des domaines autorisés. - `413` — corps au-delà de 32 ko. - `429` — quota dépassé, avec un en-tête `Retry-After`. Le corps de réponse est **toujours vide**. L'endpoint est public : il ne doit rien apprendre à qui le sonde. Le mode `debug` de la librairie affiche le code HTTP. ### 7.5 Autres endpoints publics - `GET /s.js` — le script sans bundler, mis en cache une heure. - `GET /api/flags` — évaluation des flags pour une identité. Un `GET` parce que la requête ne modifie rien et peut partir avant l'hydratation. La réponse contient des **valeurs, pas des raisons** : ni les règles, ni les conditions, ni les cohortes. - `POST /api/feedback` — retours de documentation. Deuxième point d'écriture non authentifié du produit, après `/api/collect`. - `GET /api/health` — supervision publique et volontairement avare : elle dit si la plateforme encaisse, pas combien de sites existent. - `POST /api/mcp` — serveur MCP (section 12). `GET /api/live` est **authentifié**, contrairement au reste de `/api` : il expose des chiffres de site, pas de l'ingestion. --- ## 8. Nommer ses events Quatre règles. Chacune évite une façon précise de rendre le dashboard illisible dans six mois. ### 8.1 Un verbe au passé, en snake_case ```ts sarutobi.capture("article_shared"); // ✓ sarutobi.capture("filter_applied"); // ✓ sarutobi.capture("account_created"); // ✓ sarutobi.capture("Partager"); // ✗ pas un état de fait sarutobi.capture("clickButton"); // ✗ décrit le geste, pas ce qui s'est produit ``` Un nom d'event décrit **ce qui s'est passé**, pas ce que l'utilisateur a touché. « Un article a été partagé » reste vrai si le bouton devient un menu. ### 8.2 Jamais d'identifiant dans le nom ```ts sarutobi.capture("article_shared", { article_id: "a1b2c3" }); // ✓ sarutobi.capture("article_shared_a1b2c3"); // ✗ ``` Un identifiant dans le nom fait exploser la dimension : au lieu d'une ligne « article_shared : 1 240 », on obtient 1 240 lignes à un exemplaire, et plus aucun total n'est calculable. ### 8.3 Des valeurs à faible cardinalité Le dashboard signale les propriétés dépassant cent valeurs distinctes comme inexploitables. Une propriété doit répondre à « combien » ou « lequel », jamais à « qui ». ```ts { placement: "hero", variant: "primary" } // ✓ agrégeable { click_id: "a1b2c3" } // ✗ unique à chaque appel ``` ### 8.4 Jamais de donnée personnelle Ni email, ni pseudo, ni identifiant utilisateur, ni nom, ni adresse, ni téléphone, ni texte libre saisi. **L'ingestion ne peut pas faire respecter cette règle à votre place** — c'est une règle de conception, à tenir en revue de code. Même chose pour les URL : éviter `/profil/marie@exemple.fr`. Préférer un segment opaque, ou exclure le chemin depuis les réglages du site. Sur un site sensible, `beforeSend` permet d'ajouter un filtre défensif. ### 8.5 Types acceptés Chaînes, nombres, booléens ou `null`. Un tableau ou un objet imbriqué est ignoré : Postgres saurait les stocker, mais le dashboard ne saurait pas les agréger. Trente-deux clés au maximum par event, cinq cent douze caractères par valeur — au-delà, la valeur est tronquée et la clé écartée. --- ## 9. Vie privée et consentement Cette section décrit les mécanismes techniques de Sarutobi, pas une exemption générale ni un avis juridique. Le responsable du produit instrumenté doit déterminer sa base légale, informer ses utilisateurs et configurer le SDK en conséquence. ### 9.1 Les données d'identité Par défaut, le SDK reste en attente et ne crée aucun identifiant. Après consentement, il crée un `anonymous_id` dans `localStorage` et un `session_id` dans `sessionStorage`. Ces identifiants sont propres au projet. Après connexion, `identify()` peut leur associer un identifiant métier fourni par l'application. Sarutobi ne lit pas automatiquement l'email, le nom, les formulaires ou le contenu de la page. Les propriétés de personne sont exclusivement celles que l'instrumentation transmet explicitement. ### 9.2 Choisir la persistance ```ts sarutobi.init({ siteId: "st_live_...", persistence: "localStorage", // "memory" ou "none" consent: "pending", // aucun envoi avant accord }); ``` - `localStorage` reconnaît l'identité anonyme entre plusieurs visites ; - `memory` limite l'identité à l'instance courante du SDK ; - `none` ne persiste rien dans le navigateur, mais génère une identité éphémère pour le batch ; - `consent: "pending"` ne crée aucun identifiant et n'envoie aucun événement. ### 9.3 Brancher un gestionnaire de consentement ```ts // Après le choix de l'utilisateur sarutobi.setConsent("granted"); // Retrait du consentement sarutobi.setConsent("denied"); // arrête la collecte et efface l'identité locale ``` Les méthodes historiques `optOut()` et `optIn()` restent disponibles ; elles correspondent respectivement à un refus et à un accord. ### 9.4 Connexion et déconnexion ```ts sarutobi.identify(user.id, { plan: user.plan }); // après authentification sarutobi.reset(); // à la déconnexion ``` `reset()` renouvelle l'identité anonyme et la session afin d'éviter que deux personnes utilisant le même navigateur soient mélangées. ### 9.5 Données à ne jamais envoyer - mots de passe, secrets, tokens et clés d'API ; - données bancaires ou de santé sans dispositif spécifique validé ; - contenu libre de formulaires ; - URL ou propriétés contenant des données personnelles non nécessaires. ### 9.6 Adresse IP et données techniques L'ingestion utilise encore l'adresse IP et le user-agent pour alimenter les agrégats historiques, sans conserver l'IP brute. En parallèle, le nouveau modèle écrit `person_id`, `anonymous_id` et `session_id`. Cette double écriture sera retirée lorsque les écrans historiques auront migré. --- ## 10. Déploiement Une instance Sarutobi réunit l'application Next.js, l'ingestion et le dashboard dans un même service. Elle s'appuie sur PostgreSQL et sur quelques tâches planifiées. Aucun Redis ni worker séparé n'est nécessaire pour commencer. ### 10.1 Préparer les services - PostgreSQL 16 ou supérieur, avec sauvegardes automatiques. - Un domaine HTTPS qui transmet les requêtes vers l'application. - Un ordonnanceur capable d'appeler les routes cron avec un jeton Bearer. - Un serveur SMTP uniquement si les emails de compte ou le formulaire de contact sont activés. ### 10.2 Configurer l'instance ``` DATABASE_URL=postgresql://sarutobi:mot-de-passe@postgres:5432/sarutobi AUTH_SECRET=une-valeur-aleatoire-longue AUTH_URL=https://analytics.example.com NEXT_PUBLIC_APP_URL=https://analytics.example.com CRON_SECRET=un-jeton-distinct SIGNUP_MODE=closed ALLOWED_SIGNUP_EMAILS= ``` Générer `AUTH_SECRET` et `CRON_SECRET` séparément. Changer `AUTH_SECRET` déconnecte toutes les sessions ouvertes. ### 10.3 Construire et migrer ``` bun install --frozen-lockfile bun run test bun run typecheck bun run build:packages bun run --cwd apps/web db:migrate bun run --cwd apps/web build ``` Jouer les migrations avant de basculer l'application, puis démarrer le résultat Next.js avec `NODE_ENV=production`. Une migration importante doit rester compatible avec la version précédente afin de permettre un retour arrière du code. ### 10.4 Planifier les agrégations ``` */10 * * * * POST /api/cron/rollup 0 3 * * * POST /api/cron/purge 0 4 25 * * POST /api/cron/partitions ``` Chaque appel fournit `Authorization: Bearer `. Les courbes historiques lisent les agrégats : sans le cron de rollup, le temps réel continue de bouger mais les rapports prennent du retard. ### 10.5 Vérifier avant d'ouvrir ``` curl --fail https://analytics.example.com/api/health curl --fail https://analytics.example.com/status ``` Contrôler ensuite la création d'un site, l'ingestion d'un event synthétique et l'apparition de ses agrégats. La page `/status` reste publique, mais ne révèle ni secret ni structure interne. Le runbook de production — proxy, conteneurs, contrôles de santé, retour arrière — vit dans `deploy/README.md` du dépôt. --- ## 11. Dépannage Trois causes expliquent la quasi-totalité des cas où « ça ne marche pas ». Elles se distinguent en trente secondes dans l'onglet réseau du navigateur. ### 11.1 Vous travaillez en local **Symptôme :** aucune requête vers `/api/collect` n'apparaît, et la console ne dit rien. La librairie se désactive d'elle-même sur `localhost`, `127.0.0.1` et les domaines en `.localhost`. C'est délibéré : sans ça, le développement pollue les statistiques de production. ```ts sarutobi.init({ siteId, enabled: true, debug: true }); ``` ### 11.2 Le domaine n'est pas déclaré **Symptôme :** la requête part et répond `403`, ou la console affiche une erreur CORS. Ajouter le domaine dans les réglages du site. Le joker est accepté en préfixe de sous-domaine — `*.vercel.app` pour couvrir les déploiements de prévisualisation — mais jamais seul : un `*` isolé annulerait la seule barrière qui empêche qu'une clé volée pollue les chiffres. La page **Installation** du dashboard affiche les rejets des dernières 24 h avec leur motif, et pour un `403`, le dernier domaine refusé. C'est l'endroit à regarder en premier. ### 11.3 Un bloqueur filtre la requête **Symptôme :** aucune requête ne part, et elle n'apparaît nulle part dans l'onglet réseau — pas même en erreur. Les listes de filtres bloquent tout ce qui ressemble à de l'analytics. Servir le script depuis le domaine de l'instance limite la casse, mais on ne joue pas au chat et à la souris : les chiffres sont un plancher, pas une vérité absolue. Tester en navigation privée sans extension pour confirmer. ### 11.4 Autres situations - **Les pages vues arrivent, la durée reste à zéro.** La durée est portée par l'event `pageleave`, envoyé à la fermeture de la page via `sendBeacon`. Vérifier que `trackDuration` n'est pas désactivé. - **Une navigation SPA ne compte pas.** Les changements de query string seule ne déclenchent rien, volontairement. Si le routeur n'utilise ni `pushState` ni `popstate`, appeler `pageview()` à la main. - **Les courbes s'arrêtent à une heure passée.** Le temps réel lit les données brutes, les courbes lisent des agrégats recalculés toutes les dix minutes. Au-delà d'une demi-heure, l'agrégation est en retard. - **Les chiffres semblent gonflés.** Un bandeau d'anomalie apparaît sur la vue d'ensemble quand une heure dépasse vingt fois la médiane. La clé d'un site est publique, donc rejouable : un outil de purge permet de supprimer les events d'une plage selon un filtre, et les agrégats se recalculent. - **Le mode debug.** `sarutobi.init({ siteId, debug: true })` trace chaque event dans la console, le code HTTP de chaque envoi et les tentatives de renvoi. --- ## 12. Serveur MCP Sarutobi expose ses mesures aux agents — Claude Code, un job de CI — via le **Model Context Protocol**. Un agent connecté répond aux questions du dashboard sans ouvrir le dashboard, et chaque réponse porte le lien de l'écran qui montre le même chiffre. ### 12.1 Donner l'adresse Il n'y a rien à créer avant. Donner l'adresse du serveur au client : il découvre le reste, s'enregistre seul et ouvre le navigateur sur un écran qui demande **ce qu'on accorde** — organisation et permissions, l'écriture décochée. ``` claude mcp add --transport http sarutobi https://sarutobi.ascencia.re/api/mcp ``` Ce mécanisme n'a rien de propre à Claude : c'est OAuth 2.1, tel que le protocole MCP le définit. Tout client qui l'implémente y a droit — Codex ouvre la même page avec `codex mcp login`. Le catalogue des clients vit sur https://sarutobi.ascencia.re/mcp. **Ce qu'on décoche n'est pas refusé à l'agent : il ne le voit pas.** La liste d'outils est filtrée par les permissions accordées, donc un agent sans `person:read` n'a aucun outil de personnes dans son catalogue — il ne peut ni essayer, ni se tromper. ### 12.2 Le jeton, pour ce qui n'a pas de navigateur Un job de CI ne peut pas afficher un écran de consentement. Il lui faut un jeton créé à la main depuis **Mon compte → Agents** (`/account/agents`). Le secret `st_mcp_…` n'est affiché qu'une fois : la base n'en garde qu'une empreinte. Comme un jeton obtenu par OAuth, il porte **une** organisation et l'identité de son créateur. Son rôle est relu à chaque appel, donc être retiré de l'organisation coupe ses jetons immédiatement. ``` claude mcp add --transport http sarutobi \ https://sarutobi.ascencia.re/api/mcp \ --header "Authorization: Bearer st_mcp_..." ``` ```json { "mcpServers": { "sarutobi": { "type": "http", "url": "https://sarutobi.ascencia.re/api/mcp", "headers": { "Authorization": "Bearer st_mcp_..." } } } } ``` ### 12.3 Scopes Deux barrières indépendantes s'appliquent à chaque appel : le **scope** dit ce que le jeton a le droit de demander, le **rôle** dit ce que la personne a le droit de faire. Un jeton d'écriture détenu par un compte en lecture seule n'écrit rien. Lecture : - `site:read` — lister les sites et lire leurs réglages. - `analytics:read` — mesures agrégées, courbes, découpages, erreurs, vitals. - `person:read` — personnes et sessions, propriétés masquées. - `person:properties:read` — propriétés de personne en clair. Écriture, refusée par défaut : - `annotation:write` — poser un repère de release ou une note. Idempotent sur le libellé : rejouer un déploiement ne pose pas un second repère. - `error:write` — marquer un groupe d'erreurs résolu. Le groupe réapparaît de lui-même si l'erreur se reproduit : c'est une affirmation datée, pas un masquage. - `insight:write` — enregistrer une définition de mesure ou un entonnoir. - `site:write` — modifier les réglages d'un site. La lecture seule est le défaut, et ce n'est pas de la prudence de principe : les noms d'événements et les valeurs de propriétés sont écrits par les sites mesurés, dont la clé publique est lisible dans le code de n'importe quelle page. Un jeton sans outil d'écriture ne peut rien déclencher, quoi qu'un contenu injecté raconte à l'agent. ### 12.4 Les outils Trente-huit outils, dont cinq en écriture : `get_overview`, `run_trend`, `get_breakdown`, `get_live`, `list_sites`, `describe_site`, `list_event_names`, `list_event_properties`, `list_insights`, `list_annotations`, `create_annotation`, `resolve_error`, `save_insight`, `save_funnel`, `update_site_settings`, `list_errors`, `get_error`, `get_vitals`, `detect_anomalies`, `list_funnels`, `run_funnel`, `list_dashboards`, `list_activity`, `run_retention`, `run_lifecycle`, `run_stickiness`, `run_paths`, `check_data_quality`, `list_cohorts`, `list_groups`, `list_flags`, `get_flag_exposure`, `list_experiments`, `get_experiment`, `list_alerts`, `list_persons`, `get_person`, `get_session`. Six ressources : `sarutobi://glossary`, `sarutobi://sites`, `sarutobi://site/{siteId}/profile`, `sarutobi://site/{siteId}/events`, `sarutobi://site/{siteId}/properties` et `sarutobi://site/{siteId}/insights/{insightId}`. Quatre prompts : rapport hebdomadaire, diagnostic d'une baisse, revue de release, qualité des données. ### 12.5 Par où commencer L'agent découvre les outils tout seul, mais l'ordre compte. Appeler `list_sites` puis `list_event_names` avant de mesurer évite la réponse fausse la plus fréquente : un nom d'événement inventé renvoie zéro sans erreur, ce qui se lit « personne ne le fait » alors que c'est « ce nom n'existe pas ». La ressource `sarutobi://glossary` porte le vocabulaire qui évite les autres contresens : visite, session et personne sont trois choses distinctes ; le temps réel lit les visites brutes quand les vues agrégées lisent les rollups ; les visiteurs uniques ne s'additionnent pas d'un jour à l'autre. ### 12.6 Ce que le serveur ne fait pas Rien ne supprime, ne purge, n'exporte en masse ni ne modifie les accès d'une organisation. **Le fuseau horaire d'un site ne se change pas par le MCP** : le modifier redécoupe les buckets journaliers et rend faux tous les agrégats déjà calculés ; l'outil refuse ce champ et renvoie l'URL de l'écran de réglages. Chaque écriture laisse une trace consultable depuis l'écran des jetons : outil appelé, site, résultat, et les clés des arguments — jamais leurs valeurs. --- ## 13. SDK serveur (Node) `@ascencia/sarutobi-node`, zéro dépendance runtime, Node 20 ou supérieur. Il s'authentifie au lieu de se situer : le `siteId` est public et protégé par l'allowlist de domaines, mais un backend n'a pas d'origine. Une clé secrète `st_secret_…` le prouve à la place, et ne doit jamais atteindre un bundle client. ```ts import { createClient } from "@ascencia/sarutobi-node"; const analytics = createClient({ writeKey: process.env.SARUTOBI_WRITE_KEY!, // st_secret_… host: "https://sarutobi.ascencia.re", release: "api@2026.08.1", environment: "production", onError: (error) => logger.warn({ error }, "sarutobi"), }); analytics.capture({ event: "invoice_paid", distinctId: "user_42", properties: { plan: "pro", amount_cents: 4900 }, eventId: `invoice-${invoice.id}`, // rend une reprise sûre }); await analytics.shutdown(); // avant un arrêt propre ``` - **Une identité est obligatoire** — `distinctId` ou `groups`. Sans navigateur, personne ne peut deviner de qui parle l'événement. - **Un lot par identité.** L'enveloppe porte une identité pour tous ses événements : sans regroupement, ceux de Bob seraient attribués à Alice. - **File bornée à dix mille**, avec deux tentatives de renvoi. Une panne réseau prolongée ne doit pas devenir une fuite mémoire, puis un incident sur le service instrumenté. - Envoi différé par défaut : vingt événements ou dix secondes. `flushIntervalMs: 0` envoie à chaque appel. - Rien n'est levé vers l'appelant : une erreur d'analytics ne doit jamais faire échouer la requête métier qui l'a déclenchée. `onError` notifie sans interrompre. Autres méthodes : `identify(distinctId, properties)`, `groupIdentify(type, key, properties)`, `flush(): Promise`. --- ## 14. Glossaire - **Site** — l'unité de mesure : un domaine, un fuseau horaire, une clé publique `st_live_…`. - **Organisation** — porte les sites, les membres et leurs rôles. Rien ne traverse jamais sa frontière. - **`siteId`** — la clé publique d'un site. Le seul nom valide pour cette valeur. - **`anonymous_id`** — l'identité du navigateur, créée après consentement, persistée en `localStorage`. - **`distinct_id`** — l'identité métier, fournie par l'application via `identify()`. - **`person_id`** — la personne après fusion des identités. Peut couvrir plusieurs appareils. - **`session_id`** — la session produit, en `sessionStorage`. - **Visite** — se clôt après trente minutes d'inactivité. Ce n'est ni une session ni une personne. - **Rollup** — agrégat recalculé par cron toutes les dix minutes, lu par les vues non filtrées. - **Temps réel** — lecture des visites brutes, sans passer par les rollups. - **Insight** — une définition de mesure enregistrée, pas un résultat figé. - **Annotation** — un repère daté sur les courbes, typiquement posé par un job de déploiement. --- ## 15. Le dépôt Monorepo Bun 1.3.6, PostgreSQL 16 ou supérieur. ``` apps/web Next.js · React · Better Auth · Drizzle · PostgreSQL dashboard · ingestion · organisations · documentation · crons packages/schema types, limites, validation et expurgation partagés packages/core @ascencia/sarutobi-js — SDK navigateur packages/react @ascencia/sarutobi-react packages/vue @ascencia/sarutobi-vue packages/svelte @ascencia/sarutobi-svelte packages/node @ascencia/sarutobi-node — SDK serveur ``` ``` bun install --frozen-lockfile bun run --cwd apps/web dev bun run test # Vitest ; `bun test` appelle le runner natif et ignore la config bun run typecheck bun run build:packages bun run size # budgets gzip, contrainte de CI ``` Points d'entrée : `apps/web/src/app/api/collect/route.ts` (ingestion publique), `apps/web/src/lib/ingest/` (origine, quota, identité, écriture), `apps/web/src/lib/query/` (lectures du dashboard), `apps/web/src/lib/rollup/` (agrégations), `apps/web/src/db/schema.ts` (schéma Drizzle), `packages/schema/src/` (contrat reçu par l'ingestion). `AGENTS.md` est le guide canonique du dépôt : invariants d'architecture, règles de navigation, frontière anglais/français, sécurité et critères de revue. `apps/web/public/s.js` est généré depuis `packages/core/dist/s.js` et ne se modifie jamais à la main. Toute route a un chemin canonique **anglais** et un alias **français** qui redirige vers lui — `/compte` → `/account`, `/inscription` → `/signup`, `/statut` → `/status`. La table vit dans `apps/web/src/lib/routes/aliases.ts`. Les routes `/admin` font exception : elles rendent `404` plutôt que `403` et n'ont pas d'alias, qui les trahirait par un `308`.