Observabilité & maintenance
GET /admin/stats
Section titled “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)
{ "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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/stats"
GET /admin/statsest un instantané live (compteurs au moment de l’appel). Pour suivre des tendances dans le temps, voirGET /admin/stats/trendsci-dessous, qui sert des séries journalières précalculées.
GET /admin/stats/trends
Section titled “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 viaGROUP 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 un0). 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)
{ "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
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
Section titled “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)
{ "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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/search/health"GET /admin/health
Section titled “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)
{ "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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/health"GET /admin/audit
Section titled “GET /admin/audit”Relecture filtrée et paginée du journal d’audit (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)
{ "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) :
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
Section titled “GET /admin/maintenance”État courant du mode maintenance. Combine deux leviers, par priorité :
- Kill-switch env
MAINTENANCE_MODE=true— coupe l’app au niveau du déploiement, prioritaire et non désactivable au runtime (lockedByEnv: true). - Toggle runtime — fichier flag (
var/maintenance.flag) basculé à chaud viaPUT /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)
{ "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
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \ "http://hydrogen.dev.com/admin/maintenance"PUT /admin/maintenance
Section titled “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
{ "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 queGET /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
# 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ésactivercurl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"enabled":false}' \ "http://hydrogen.dev.com/admin/maintenance"