Skip to content

Observabilité & maintenance

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 }
}
ChampSens
users.confirmedcomptes avec confirmed_at renseigné.
users.bannedbannissement actif (banned_until > NOW()).
users.deletedcomptes soft-deleted RGPD (en grâce, pas encore purgés).
media.pendingni 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.pendingbacklog de modération ouvert.

Exemple

Terminal window
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.


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étriqueDomainekindSens
users.registeredusersflowinscriptions du jour (joined_at).
users.confirmedusersflowe-mails confirmés le jour (confirmed_at).
users.deletedusersflowsoft-deletes RGPD du jour (deleted_at).
users.totaluserssnapshottotal de comptes.
users.confirmed.totaluserssnapshotcomptes confirmés.
users.verified.totaluserssnapshotcomptes badge bleu (is_verified).
users.banned.activeuserssnapshotbannissements actifs (banned_until > NOW()).
media.uploadedmediaflowmédias uploadés le jour (created_at).
media.totalmediasnapshottotal de médias.
media.published.totalmediasnapshotmédias publiés.
media.rejected.totalmediasnapshotmédias rejetés.
media.pending.totalmediasnapshotmédias en attente de verdict.
reports.createdreportsflowsignalements ouverts le jour (created_at).
reports.pending.totalreportssnapshotbacklog de modération ouvert.
describe.queue.sizedescribesnapshottaille de la file IA media_to_describe.

Paramètres

ParamètreDéfautSens
metrictoutesliste CSV de slugs (ex. users.registered,media.uploaded). Un slug inconnu → 400.
days30longueur 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

Terminal window
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).


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. offersoffers_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

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/search/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) :

ValeurSens
oktoutes les bases et Meili joignables.
degradedMeili down mais toutes les bases up (recherche dégradée, l’API cœur répond).
downau 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 } }
}
}
ChampSens
databases.connections.<db>.availablela base a répondu au SELECT 1.
databases.connections.<db>.latencyMstemps du round-trip (résolution + ping) en ms, null si injoignable.
databases.connections.<db>.errorraison de l’échec (message PDO), null si OK.
searchbloc identique à GET /admin/search/health (détail par index).

Exemple

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/admin/health"

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) :

ParamTypeDescription
operatorstringempreinte de token (token_fp, 12 hex) — cible un opérateur
methodstringverbe HTTP exact (POST / PUT / PATCH / DELETE)
pathPrefixstringpréfixe de chemin (match LIKE échappé, ex. /admin/users)
statusintcode HTTP final exact (ex. 403)
fromISO 8601borne basse created_at (incluse)
toISO 8601borne haute created_at (incluse)
cursorIdintid de la dernière ligne de la page précédente (keyset)
limitint1..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) :

Terminal window
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"

É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)

{
"active": true,
"source": "runtime",
"lockedByEnv": false,
"retryAfter": 900,
"allowedIps": ["1.2.3.4"],
"since": "2026-06-18T20:39:20+00:00",
"reason": "deploy v2"
}
ChampDescription
activetrue si l’app est en maintenance (par env OU runtime).
source"env" (kill-switch), "runtime" (fichier flag) ou "off".
lockedByEnvtrue si MAINTENANCE_MODE=true force la coupure → le PUT est verrouillé (409).
retryAfterSecondes annoncées dans Retry-After (null = défaut env appliqué au runtime).
allowedIpsIP autorisées à contourner la coupure.
sinceISO 8601 de la dernière activation runtime (null hors runtime).
reasonNote libre fournie à l’activation (audit).

Exemple

Terminal window
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
"http://hydrogen.dev.com/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"
}
ChampRequisDescription
enabledouitrue active, false désactive (idempotent).
retryAfternonSecondes pour Retry-After (>0). Omis ⇒ défaut env (MAINTENANCE_RETRY_AFTER).
allowedIpsnonAllowlist de bypass (remplace, ne fusionne pas avec l’env).
reasonnonNote 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

Terminal window
# 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"