# 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`.