# Observabilité crash — plan d'implémentation

> Rédigé le 2026-06-06. Faits vérifiés dans le code réel (chemins:lignes cités), pas spéculés.
> Objectif : capter les crashs en prod **sans dépendance externe tant qu'elle n'est pas nécessaire**.

> **✅ Approche A implémentée le 2026-06-06.** Capture (rendu + fatals + rejets) +
> log local borné + **push automatique vers le backend** (best-effort, tolérant
> 404), zéro dépendance externe. Fichiers : `src/core/crashLog.ts`,
> `src/core/crashHandlers.ts`, `src/shared/ErrorBoundary.tsx`, câblage dans
> [App.tsx](App.tsx), flush branché sur `syncOnReconnect` dans
> [src/services/syncService.ts](src/services/syncService.ts).
> Couvert par tests (crashLog / crashHandlers / ErrorBoundary).
>
> **Décision 2026-06-06 : envoi automatique, pas de mail manuel.** L'export par mail
> a été retiré au profit du push backend → visualisation future dans le SuperAdmin.
>
> ⚠️ **Dépendance backend (à faire) :** l'endpoint d'ingestion `POST /mobile/crash_report.php`
> n'existe pas encore (voir §4 « Contrat backend »). Tant qu'il renvoie 404, le mobile
> **conserve les incidents en local** et **réessaie** à chaque cycle de sync — aucune
> perte, mais **rien n'est visible côté équipe avant le déploiement de l'endpoint**.

## 1. Constat (vérifié — avant implémentation)

Aucun mécanisme d'observabilité crash n'existait :

- 0 occurrence de `ErrorBoundary` / `componentDidCatch` / `ErrorUtils.setGlobalHandler` /
  `unhandledrejection` dans `src/` et [App.tsx](App.tsx).
- Aucun SDK de monitoring (Sentry / Bugsnag / Crashlytics) — 0 occurrence dans
  [package.json](package.json) et `src/`.

Conséquence en build standalone (hors Expo Go) : une erreur de rendu React ou une
exception JS non rattrapée affiche un **écran blanc / crash silencieux**. Sur le
terrain, chez un technicien, l'incident est **invisible** côté équipe — on ne sait
ni qu'il a eu lieu, ni sur quelle instance, ni dans quel écran.

## 2. Ce qu'on veut capter

| Source | Mécanisme natif (zéro dépendance) |
|---|---|
| Erreur de rendu React (composant qui throw) | **Error Boundary** (API React intégrée : `componentDidCatch`) |
| Exception JS non rattrapée (fatale) | `global.ErrorUtils.setGlobalHandler` (intégré React Native) |
| Rejet de promesse non géré | hook RN `rejection-tracking` (intégré au polyfill RN) ou capture best-effort |

Les trois alimentent **un seul collecteur** qui : (a) journalise localement, (b)
montre à l'utilisateur un écran de repli propre (pas un écran blanc), (c) **pousse
les incidents au backend** au prochain cycle de sync.

## 3. Solution retenue — maison, zéro dépendance externe

**Décision : pas de service tiers de monitoring (type Sentry).** Tout est fait à la
main avec ce qui est **déjà installé**. Aucune lib, aucun compte externe, aucun
quota, aucune config native supplémentaire.

### Briques réutilisées (déjà présentes)

- Persistance fichier : API `Directory` / `File` / `Paths` de `expo-file-system`,
  patron déjà utilisé dans [src/core/fileStorage.ts:1-27](src/core/fileStorage.ts#L1-L27).
- Détection réseau : [src/services/syncService.ts:182](src/services/syncService.ts#L182)
  (`isNetworkOnline`).
- Envoi HTTP authentifié : [src/services/apiClient.ts:294-301](src/services/apiClient.ts#L294-L301)
  (`post`), avec gestion 401/refresh et garde HTTPS.
- Cycle de sync existant : `syncOnReconnect` (boot / reconnexion / foreground, avec
  garde de réentrance) [src/services/syncService.ts:310](src/services/syncService.ts#L310) —
  le flush des crashs s'y greffe.
- Cascade d'endpoints `.php` puis sans extension : patron déjà en place dans
  [src/services/authApi.ts:13-33](src/services/authApi.ts#L13-L33).

### Pièces à créer

1. **`src/core/crashLog.ts`** — collecteur + persistance.
   - Anneau borné (garder les N derniers, ex. 50) écrit en **JSONL** dans un fichier
     dédié (`Paths.document` + `crash-logs/`), même patron que `fileStorage.ts`.
   - Chaque entrée : `timestamp`, `kind` (`render` | `fatal` | `rejection`),
     `message`, `stack` tronquée, et un contexte non sensible
     (instance baseUrl, tenant_id, version app, plateforme). **Jamais de token ni
     de données client** — voir §6.
   - API : `recordCrash(entry)`, `readCrashLogs()`, `clearCrashLogs()`,
     `setCrashContext()`, `flushCrashReports()`.

2. **`src/shared/ErrorBoundary.tsx`** — `React.Component` avec `componentDidCatch`
   → appelle `recordCrash({ kind: "render", ... })` et rend un écran de repli
   (message + bouton « Réessayer » qui reset l'état). L'incident est transmis
   automatiquement au prochain cycle de sync (pas d'action utilisateur requise).
   Monté **à la racine** dans [App.tsx](App.tsx), autour de toute l'app.

3. **Handlers globaux** (init unique au démarrage, ex. en tête de `App.tsx` ou dans
   un `src/core/crashHandlers.ts` appelé une fois) :
   ```
   const prev = ErrorUtils.getGlobalHandler();
   ErrorUtils.setGlobalHandler((error, isFatal) => {
     recordCrash({ kind: "fatal", message: error.message, stack: error.stack, isFatal });
     prev?.(error, isFatal); // on NE casse PAS le comportement par défaut
   });
   ```
   Important : **chaîner** le handler précédent (sinon on masque l'affichage rouge
   de dev et le comportement natif).

4. **`flushCrashReports()`** — push automatique best-effort vers le backend.
   - Branché sur `syncOnReconnect` (même cycle que la sync : boot / reconnexion /
     foreground), après le push de la file et le pull delta.
   - `apiClient.post` le lot d'incidents en attente (cascade `.php` → sans
     extension), puis `clearCrashLogs()` **uniquement après succès**.
   - ⚠️ **Suppose un endpoint d'ingestion côté backend** — c'est au backend de
     l'exposer, le mobile ne fait que poster (règle « le mobile s'aligne sur le
     backend, il ne réclame pas »). Tant que l'endpoint renvoie 404 (ou hors-ligne,
     ou 401 non authentifié), les incidents **restent en local et sont réessayés**
     au cycle suivant. **Ne lance jamais. Aucune régression.**

### Pourquoi cette approche

- **Zéro dépendance externe** : rien de neuf dans `package.json`, pas de config
  native EAS, pas de compte tiers, pas de coût récurrent.
- **Cohérent avec l'archi existante** : on réemploie file-system, apiClient, le
  cycle de sync et la cascade d'endpoints — tous déjà testés.
- **Zéro friction utilisateur** : le technicien n'a rien à faire ; les incidents
  remontent seuls dès qu'une connexion est disponible.
- **Tolérant au déploiement** : l'endpoint peut arriver plus tard sans changer le
  mobile (même logique 404 que le reste de l'app).

## 4. Dashboard (plus tard) — dans le SuperAdmin existant, pas un service externe

**Décision : pas de service tiers (type Sentry).** Si un jour on veut visualiser les
crashs dans un tableau de bord, ce sera une **feature du SuperAdmin déjà en place**
(`missioflow-SuperAdmin`), pas une dépendance externe.

Avantages, et pourquoi c'est au moins aussi pratique qu'un outil externe :

- **Taillé pour le besoin** : on affiche exactement ce qu'on veut (crash par
  instance / tenant / version / écran), avec le vocabulaire métier déjà connu du
  SuperAdmin — pas le format générique d'un produit tiers.
- **Hébergé chez nous** : le SuperAdmin sera hébergé ailleurs à terme ; les données
  de crash restent dans notre périmètre, aucun compte ni quota externe.
- **Rien de neuf côté mobile** : le mobile ne fait que `apiClient.post` vers
  l'endpoint d'ingestion backend (cf. §3 sortie automatique). C'est le **backend**
  qui expose l'endpoint et le **SuperAdmin** qui lit et affiche — le mobile reste un
  simple producteur de logs (règle « le mobile s'aligne sur le backend »).

Chaîne cible :

```
mobile (crashLog.ts) --POST--> endpoint backend d'ingestion --> SuperAdmin (vue crashs)
```

### Contrat backend (à implémenter — coolcare + missioflow)

Le mobile **appelle déjà** cet endpoint (best-effort, tolérant 404). Côté backend,
reste à l'exposer :

- **Route** : `POST /mobile/crash_report.php` (le mobile tente aussi `/mobile/crash_report`
  sans extension, comme pour auth). Chemin réel attendu : `public/api/mobile/crash_report.php`.
- **Auth** : même schéma que les autres endpoints mobile (Bearer + `X-Tenant-Id`
  ajoutés automatiquement par `apiClient`). Idéalement **accepter aussi un envoi non
  authentifié** (un crash peut survenir avant login) — sinon les incidents pré-login
  restent en local et sont réessayés après login.
- **Body** (JSON) :
  ```json
  {
    "reports": [
      {
        "timestamp": "2026-06-06T10:00:00.000Z",
        "kind": "render | fatal | rejection",
        "message": "string",
        "stack": "string (tronquée à 4000 car.)",
        "isFatal": true,
        "componentStack": "string",
        "app": "1.0.0",
        "platform": "android 34",
        "instanceUrl": "https://instance/api/mobile",
        "tenantId": 7
      }
    ]
  }
  ```
  `reports` est un **lot** (jusqu'à 50). Aucune donnée sensible (cf. §6).
- **Réponse attendue** : `2xx` = ingéré (le mobile vide alors son journal local).
  Tout autre code → le mobile **conserve** et réessaie. Idempotence souhaitable
  (un même lot peut être renvoyé si la réponse se perd).
- **Stockage + SuperAdmin** : persister (table `mobile_crash_reports` ou équivalent),
  puis exposer une vue dans `missioflow-SuperAdmin` (filtrable par instance / tenant /
  version / kind).

> Cette spec est une **proposition mobile** ; le backend reste la source de vérité.
> Si le backend préfère un autre nommage/forme, le mobile s'aligne (1 seul point à
> changer : `CRASH_ENDPOINTS` dans `crashLog.ts`).

## 5. Fichiers touchés (récap)

| Fichier | Action | État |
|---|---|---|
| `src/core/crashLog.ts` | persistance JSONL bornée + contexte + `flushCrashReports` (push 404-tolérant) | ✅ créé |
| `src/shared/ErrorBoundary.tsx` | capture rendu + écran de repli (Réessayer) | ✅ créé |
| `src/core/crashHandlers.ts` | `setGlobalHandler` chaîné + rejets (init unique) | ✅ créé |
| [App.tsx](App.tsx) | `ErrorBoundary` à la racine + `installCrashHandlers()` au boot + `setCrashContext` | ✅ câblé |
| [src/services/syncService.ts](src/services/syncService.ts) | `flushCrashReports()` branché dans `syncOnReconnect` | ✅ câblé |
| **backend coolcare + missioflow** | endpoint `POST /mobile/crash_report.php` (§4 Contrat) | ⏳ à faire |
| **missioflow-SuperAdmin** | vue crashs (filtrable instance/tenant/version) | ⏳ à faire |

## 6. Garde-fous données (obligatoire)

- **Aucun secret dans le log** : jamais de `access_token` / `refresh_token` (stockés
  en SecureStore via [src/services/tokenStorage.ts](src/services/tokenStorage.ts)),
  jamais de signature ni de photo client, jamais de payload d'intervention complet.
- Contexte autorisé : baseUrl instance, `tenant_id`, version app, plateforme,
  message + stack technique. Mêmes infos (sûres à remonter) que le bloc diagnostic
  du mail support existant.
- Le log local est borné (anneau de N entrées) → pas de croissance disque illimitée.
- Cohérent HTTPS-only : l'envoi auto passe par `apiClient`, donc soumis au garde
  HTTPS de [src/core/environmentService.ts:22-30](src/core/environmentService.ts#L22-L30).

## 7. Plan de test (Jest) — fait

- `crashLog`: record → read ; anneau borné à 50 ; stack tronquée à 4000 ; `clear`
  vide ; contexte instance/tenant joint. **flush** : rien sans incident ; succès →
  POST `{ reports }` + journal vidé ; 404 sur `.php` → bascule sans extension ;
  404/401 partout → **logs conservés** (rien perdu). (mock `apiClient`.)
- `ErrorBoundary`: enfant qui throw → écran de repli + `recordCrash` appelé ;
  « Réessayer » remonte l'enfant.
- `crashHandlers`: `setGlobalHandler` chaîne le handler précédent ; conversion
  non-Error → Error ; suivi des rejets ; install unique.

## 8. Récap décision

| # | Quoi | Dépendance externe | État |
|---|---|---|---|
| 1 | Error Boundary + écran de repli | aucune | ✅ fait 2026-06-06 |
| 2 | Handlers globaux (fatal + rejets) | aucune | ✅ fait 2026-06-06 |
| 3 | Log local borné (anneau 50, JSONL) | aucune | ✅ fait 2026-06-06 |
| 4 | Push auto vers backend (mobile, 404-tolérant) | aucune (côté mobile) | ✅ fait 2026-06-06 |
| 5 | Endpoint d'ingestion `crash_report` | endpoint backend (pas une lib) | ⏳ backend coolcare + missioflow |
| 6 | Dashboard crashs dans le SuperAdmin | aucune (feature SuperAdmin) | ⏳ quand l'endpoint existe |
