---
title: "Observabilité & maintenance"
description: "Compteurs globaux pour un tableau de bord back-office. Agrège 4 sections réparties sur les 3 bases de l'app : user / media (base hxa), la file du pipeline…"
---

## `GET /admin/stats`

Compteurs globaux pour un tableau de bord back-office. Agrège **4 sections** réparties sur les 3 bases de l'app : `user` / `media` (base `hxa`), la file du pipeline IA `media_to_describe` (base `work`), la file de modération `report` (base `hxa_bo`).

Chaque section est collectée **isolément** : si une base est injoignable, seule sa section est remplacée par `{ "error": "<raison>" }` — le reste du tableau de bord répond quand même. L'endpoint renvoie **toujours 200** ; la présence d'une clé `error` dans une section EST le signal de santé.

Aucun paramètre.

**Réponse (200)**

```json
{
  "users":         { "total": 1284, "confirmed": 1190, "verified": 12, "banned": 3, "deleted": 7 },
  "media":         { "total": 53120, "published": 51002, "rejected": 88, "pending": 2030 },
  "describeQueue": { "size": 2030, "oldestEnqueuedAt": "2026-06-18T09:12:44+00:00" },
  "reports":       { "total": 145, "pending": 9, "resolved": 136 }
}
```

| Champ | Sens |
|---|---|
| `users.confirmed` | comptes avec `confirmed_at` renseigné. |
| `users.banned` | bannissement **actif** (`banned_until > NOW()`). |
| `users.deleted` | comptes soft-deleted RGPD (en grâce, pas encore purgés). |
| `media.pending` | ni publié ni rejeté (en attente du verdict describe/modération). |
| `describeQueue.oldestEnqueuedAt` | âge de la tête de file FIFO (`null` si vide) ; un écart croissant à « maintenant » = worker en retard. |
| `reports.pending` | backlog de modération ouvert. |

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/stats"
```

> `GET /admin/stats` est un **instantané live** (compteurs au moment de l'appel). Pour suivre des **tendances dans le temps**, voir `GET /admin/stats/trends` ci-dessous, qui sert des séries **journalières** précalculées.

---


## `GET /admin/stats/trends`

Séries **agrégées par jour** des KPI de la plateforme, pour suivre les tendances multi-domaines (inscriptions, uploads, signalements, croissance de la base…). Lecture seule.

Les valeurs ne sont **pas** calculées à la volée : elles sont précalculées une fois par jour par le worker `bin/platform-metrics-rollup.php` dans la table `hxa_bo.platform_metric_daily`. Cet endpoint ne lit donc **que** `hxa_bo` — il ne touche jamais les tables de production `user` / `media` (c'est tout l'intérêt : le `COUNT(*)` lourd est déplacé hors du chemin requête, exécuté une seule fois en heure creuse).

**Deux familles de métriques** (champ `kind`) :

- `flow` — un **nombre d'évènements survenus ce jour-là** (ex. `users.registered`), dérivé d'une colonne date indexée via `GROUP BY DATE(...)`. Série **continue** : les jours sans évènement sont renvoyés à `0`. Historiquement reconstructible (le worker re-plie une fenêtre glissante, cf. `PLATFORM_METRICS_LOOKBACK_DAYS`).
- `snapshot` — un **stock** compté une fois par exécution (ex. `users.total`). La série ne contient **que** les jours déjà enregistrés : un trou = un jour où le worker n'a pas tourné (ce n'est PAS un `0`). Non reconstructible dans le passé — la série se construit point par point à partir de la 1re exécution.

**Métriques disponibles**

| Métrique | Domaine | `kind` | Sens |
|---|---|---|---|
| `users.registered` | users | flow | inscriptions du jour (`joined_at`). |
| `users.confirmed` | users | flow | e-mails confirmés le jour (`confirmed_at`). |
| `users.deleted` | users | flow | soft-deletes RGPD du jour (`deleted_at`). |
| `users.total` | users | snapshot | total de comptes. |
| `users.confirmed.total` | users | snapshot | comptes confirmés. |
| `users.verified.total` | users | snapshot | comptes badge bleu (`is_verified`). |
| `users.banned.active` | users | snapshot | bannissements **actifs** (`banned_until > NOW()`). |
| `media.uploaded` | media | flow | médias uploadés le jour (`created_at`). |
| `media.total` | media | snapshot | total de médias. |
| `media.published.total` | media | snapshot | médias publiés. |
| `media.rejected.total` | media | snapshot | médias rejetés. |
| `media.pending.total` | media | snapshot | médias en attente de verdict. |
| `reports.created` | reports | flow | signalements ouverts le jour (`created_at`). |
| `reports.pending.total` | reports | snapshot | backlog de modération ouvert. |
| `describe.queue.size` | describe | snapshot | taille de la file IA `media_to_describe`. |

**Paramètres**

| Paramètre | Défaut | Sens |
|---|---|---|
| `metric` | toutes | liste CSV de slugs (ex. `users.registered,media.uploaded`). Un slug inconnu → **400**. |
| `days` | `30` | longueur de la fenêtre en jours, bornée **1..366**. |

**Réponse (200)**

```json
{
  "from": "2026-05-22",
  "to":   "2026-06-20",
  "days": 30,
  "metrics": {
    "users.registered": {
      "domain": "users",
      "kind":   "flow",
      "latest": { "date": "2026-06-20", "value": 42 },
      "series": [
        { "date": "2026-05-22", "value": 0 },
        { "date": "2026-05-23", "value": 17 }
      ]
    },
    "users.total": {
      "domain": "users",
      "kind":   "snapshot",
      "latest": { "date": "2026-06-20", "value": 987325 },
      "series": [
        { "date": "2026-06-19", "value": 987018 },
        { "date": "2026-06-20", "value": 987325 }
      ]
    }
  }
}
```

`latest` est le **dernier point connu** de la métrique, indépendamment de la fenêtre `days` (pratique pour afficher la valeur courante sans série). `null` tant qu'aucun rollup n'a tourné.

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/stats/trends?metric=users.registered,users.total&days=90"
```

**Worker** — planifier `php bin/platform-metrics-rollup.php` **une fois par jour** en heure creuse. Réexécution idempotente (UPSERT) ; fenêtre de re-pliage des métriques `flow` via `PLATFORM_METRICS_LOOKBACK_DAYS` (défaut 7). Migration : `database/migrations/2026_06_20_120000_create_platform_metric_daily.sql` (à jouer manuellement).

---


## `GET /admin/search/health`

Nombre de documents et état d'indexation de **chaque index Meilisearch** lu par l'app (`media`, `users`, `establishments`, `offers`, `brands`, `countries`, `regions`, `subregions`, `cities`). Permet de repérer une dérive d'indexation (un index `media` bloqué très en dessous du `COUNT(*)` MySQL, ou un index resté en `isIndexing`).

Un seul appel : l'endpoint global `/stats` de Meilisearch renvoie les stats de tous les index d'un coup, projetées sur la map *label logique → uid physique* (le uid est piloté par l'env et peut être versionné, ex. `offers` → `offers_v2`).

**Fail-soft** : si Meilisearch est injoignable, la réponse passe `reachable: false` et marque chaque index `available: false` plutôt que de renvoyer une 500 (un endpoint de santé ne doit pas masquer le signal). Réponse **toujours 200**.

Aucun paramètre.

**Réponse (200)**

```json
{
  "reachable":    true,
  "databaseSize": 13631488,
  "lastUpdate":   "2026-06-18T09:30:00.000000Z",
  "indexes": {
    "media":          { "index": "media_dev", "available": true, "numberOfDocuments": 51002, "isIndexing": false },
    "users":          { "index": "users_dev", "available": true, "numberOfDocuments": 1190, "isIndexing": false },
    "establishments": { "index": "establishments_dev", "available": true, "numberOfDocuments": 348221, "isIndexing": false },
    "offers":         { "index": "offers_v2", "available": true, "numberOfDocuments": 4120, "isIndexing": false },
    "brands":         { "index": "brands", "available": true, "numberOfDocuments": 612, "isIndexing": false },
    "countries":      { "index": "countries", "available": true, "numberOfDocuments": 250, "isIndexing": false },
    "regions":        { "index": "regions", "available": true, "numberOfDocuments": 5300, "isIndexing": false },
    "subregions":     { "index": "subregions", "available": true, "numberOfDocuments": 99, "isIndexing": false },
    "cities":         { "index": "cities", "available": true, "numberOfDocuments": 10000, "isIndexing": false }
  }
}
```

Quand un index n'existe pas encore côté Meili : `available: false`, `numberOfDocuments: null`, `isIndexing: null` (mais `reachable` reste `true`).

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/search/health"
```

---


## `GET /admin/health`

Sonde de **disponibilité de l'infrastructure** dont dépend l'app : les **4 bases MySQL** (`hxa`, `geo`, `hxa_bo`, `work`) et **Meilisearch**, en un seul appel. Pour savoir d'un coup d'œil si une techno est joignable.

Chaque base est testée par un `SELECT 1` chronométré ; Meili via son `/stats` global (mêmes données que `GET /admin/search/health`).

**Fail-soft** : les connexions sont résolues **paresseusement** et chaque sonde est isolée (`try/catch`) — une base injoignable n'apparaît qu'en `available: false` sur sa ligne, sans faire échouer l'endpoint. Réponse **toujours 200** ; les drapeaux `status` / `healthy` / `available` portent le signal (une 500 masquerait justement ce qu'on cherche à mesurer).

Aucun paramètre.

**Statut agrégé** (`status`) :

| Valeur | Sens |
|---|---|
| `ok` | toutes les bases **et** Meili joignables. |
| `degraded` | Meili down mais toutes les bases up (recherche dégradée, l'API cœur répond). |
| `down` | au moins une base injoignable (API cœur impactée). |

**Réponse (200)**

```json
{
  "status":  "ok",
  "healthy": true,
  "databases": {
    "healthy": true,
    "connections": {
      "hxa":    { "available": true, "latencyMs": 0.8, "error": null },
      "geo":    { "available": true, "latencyMs": 1.2, "error": null },
      "hxa_bo": { "available": true, "latencyMs": 0.9, "error": null },
      "work":   { "available": true, "latencyMs": 1.0, "error": null }
    }
  },
  "search": {
    "reachable": true,
    "databaseSize": 13631488,
    "lastUpdate": "2026-06-21T09:30:00.000000Z",
    "indexes": { "media": { "index": "media_dev", "available": true, "numberOfDocuments": 51002, "isIndexing": false } }
  }
}
```

| Champ | Sens |
|---|---|
| `databases.connections.<db>.available` | la base a répondu au `SELECT 1`. |
| `databases.connections.<db>.latencyMs` | temps du round-trip (résolution + ping) en ms, `null` si injoignable. |
| `databases.connections.<db>.error` | raison de l'échec (message PDO), `null` si OK. |
| `search` | bloc identique à `GET /admin/search/health` (détail par index). |

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/health"
```

---


## `GET /admin/audit`

Relecture **filtrée et paginée** du [journal d'audit](#journal-daudit) (`var/admin_audit.sqlite`). C'est le pendant lecture de ce qui est déjà écrit automatiquement : enquêter sur les actions mutantes passées sans ouvrir le fichier SQLite à la main.

Cet endpoint étant un `GET`, il **n'est pas lui-même audité** (lire le journal ne doit pas le polluer).

Tri implicite : `id DESC` (événements les plus récents d'abord). Pagination **keyset** mono-colonne sur `id` (auto-incrément strictement monotone) : reporter `nextCursor.id` dans `?cursorId=` pour la page suivante.

**Paramètres de requête** (tous optionnels, combinés en `AND`) :

| Param | Type | Description |
|---|---|---|
| `operator` | string | empreinte de token (`token_fp`, 12 hex) — cible **un** opérateur |
| `method` | string | verbe HTTP exact (`POST` / `PUT` / `PATCH` / `DELETE`) |
| `pathPrefix` | string | préfixe de chemin (match `LIKE` échappé, ex. `/admin/users`) |
| `status` | int | code HTTP final exact (ex. `403`) |
| `from` | ISO 8601 | borne basse `created_at` (incluse) |
| `to` | ISO 8601 | borne haute `created_at` (incluse) |
| `cursorId` | int | `id` de la dernière ligne de la page précédente (keyset) |
| `limit` | int | `1..200` (défaut `50`) |

Un `from`/`to` non vide mais illisible ⇒ `400`. `pathPrefix` neutralise les jokers `LIKE` (`%`, `_`).

**Réponse (200)**

```json
{
  "items": [
    {
      "id":        4821,
      "createdAt": "2026-06-18T09:30:00+00:00",
      "method":    "DELETE",
      "path":      "/admin/media/0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "query":     null,
      "status":    200,
      "ip":        "127.0.0.1",
      "tokenFp":   "9f86d081884c",
      "userAgent": "Insomnia/2023.5.8"
    }
  ],
  "nextCursor": { "id": 4821 }
}
```

`nextCursor` est `null` dès qu'une page renvoie moins de `limit` lignes (fin de scan).

**Exemple** (les actions `DELETE` d'un opérateur depuis une date) :

```bash
curl -s -G -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  --data-urlencode "method=DELETE" \
  --data-urlencode "operator=9f86d081884c" \
  --data-urlencode "from=2026-06-01T00:00:00Z" \
  "http://hydrogen.dev.com/admin/audit"
```

---


## `GET /admin/maintenance`

État courant du **mode maintenance**. Combine deux leviers, par priorité :

1. **Kill-switch env** `MAINTENANCE_MODE=true` — coupe l'app au niveau du déploiement, **prioritaire** et non désactivable au runtime (`lockedByEnv: true`).
2. **Toggle runtime** — fichier flag (`var/maintenance.flag`) basculé à chaud via `PUT /admin/maintenance`, **sans redéploiement**. Volontairement hors base de données : la coupure doit fonctionner même DB injoignable.

Quand la maintenance est active, **toute** requête reçoit un `503` + en-tête `Retry-After` : page HTML pour le web, document JSON:API pour `/api/*`, JSON plat pour `/admin/*`. Une allowlist d'IP (`allowedIps`, lue sur `REMOTE_ADDR`) permet aux ops de contourner la coupure.

Aucun paramètre.

**Réponse (200)**

```json
{
  "active":      true,
  "source":      "runtime",
  "lockedByEnv": false,
  "retryAfter":  900,
  "allowedIps":  ["1.2.3.4"],
  "since":       "2026-06-18T20:39:20+00:00",
  "reason":      "deploy v2"
}
```

| Champ | Description |
|---|---|
| `active` | `true` si l'app est en maintenance (par env OU runtime). |
| `source` | `"env"` (kill-switch), `"runtime"` (fichier flag) ou `"off"`. |
| `lockedByEnv` | `true` si `MAINTENANCE_MODE=true` force la coupure → le `PUT` est verrouillé (409). |
| `retryAfter` | Secondes annoncées dans `Retry-After` (`null` = défaut env appliqué au runtime). |
| `allowedIps` | IP autorisées à contourner la coupure. |
| `since` | ISO 8601 de la dernière activation runtime (`null` hors runtime). |
| `reason` | Note libre fournie à l'activation (audit). |

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/maintenance"
```

---


## `PUT /admin/maintenance`

Active ou désactive la maintenance **runtime** à chaud (écrit/supprime `var/maintenance.flag`). Le kill-switch env est prioritaire : tant que `MAINTENANCE_MODE=true`, cet endpoint répond **409** (on ne peut pas rouvrir le site par fichier flag quand l'env force la coupure).

**Body**

```json
{
  "enabled":    true,
  "retryAfter": 900,
  "allowedIps": ["1.2.3.4", "5.6.7.8"],
  "reason":     "deploy v2"
}
```

| Champ | Requis | Description |
|---|---|---|
| `enabled` | oui | `true` active, `false` désactive (idempotent). |
| `retryAfter` | non | Secondes pour `Retry-After` (>0). Omis ⇒ défaut env (`MAINTENANCE_RETRY_AFTER`). |
| `allowedIps` | non | Allowlist de bypass (remplace, ne fusionne pas avec l'env). |
| `reason` | non | Note libre conservée dans le flag. |

Sur `enabled: false`, les autres champs sont ignorés.

**Réponses**

- `200` — même forme que `GET /admin/maintenance` (état après bascule).
- `400` — `{ "error": "Body must be JSON object with 'enabled' boolean." }`
- `409` — `{ "error": "Maintenance is forced by MAINTENANCE_MODE env; runtime toggle is locked." }`

**Exemples**

```bash
# Activer (avec bypass ops + raison)
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true,"retryAfter":900,"allowedIps":["1.2.3.4"],"reason":"deploy v2"}' \
  "http://hydrogen.dev.com/admin/maintenance"

# Désactiver
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"enabled":false}' \
  "http://hydrogen.dev.com/admin/maintenance"
```

---
