Observabilité de Lucarne
En bref
- Audience : Matomo auto-hébergé, sans cookie, paramétré pour l'exemption de consentement CNIL.
- Exploitation : Prometheus + Grafana + Blackbox Exporter, avec 3 tableaux et 10 alertes versionnés dans le dépôt.
- Logs : JSON sur stdout, prêts pour Loki.
- Le modèle est celui de La Suite numérique : un kit d'exploitation livré avec l'application, pas des tableaux construits à la main.
- Aucune donnée personnelle ne sort vers un tiers.
/api/metricsest fermé par jeton, contrairement au/v1/metricspublic relevé par l'audit de l'existant.
Décision de principe : ADR 006. Exploitation courante : runbook.
1. Qui mesure quoi
| Besoin | Outil | Où c'est branché |
|---|---|---|
| Visites, pages vues, provenance, appareils, termes de recherche | Matomo (auto-hébergé) | src/features/observability/MatomoTracker.tsx |
| Recherches, recherches sans résultat, sorties vers HAL/ORCID/DOI | Prometheus (compteurs agrégés) | src/server/data/withSearchMetrics.ts, /api/telemetry |
| Fiches publiées, fraîcheur de la synchronisation, rejets de contrat | Prometheus | src/server/observability/collectors.ts, fournisseur amont |
| Santé de l'API amont (CRISalid) : statuts, latence | Prometheus | src/server/data/providers/, appels amont |
| CPU, mémoire, boucle d'événements, redémarrages | Prometheus (métriques Node par défaut) | prom-client |
| Disponibilité vue de l'extérieur | Blackbox Exporter | ops/observability/prometheus/prometheus.yml, k8s/probe.yaml |
| Tableaux de bord | Grafana | ops/observability/grafana/dashboards/*.json |
| Alertes | Prometheus / Alertmanager | ops/observability/prometheus/alerts.yml |
| Erreurs serveur détaillées | Logs JSON + compteur ; GlitchTip en option | src/instrumentation.ts |
| Rapports trimestriels | Export Matomo + export Grafana | § 9 |
Ce qui est exclu :
- Mixpanel et Sentry Session Replay : constats de l'audit de l'existant, et ADR 006.
- PostHog : trop lourd pour une vitrine sans parcours produit complexe.
- Metabase : utile plus tard, si Paris-Saclay veut des rapports BI croisant plusieurs bases.
Le modèle La Suite numérique
Les dépôts publics de La Suite numérique (DINUM) montrent le même découpage :
- Matomo est configuré selon les recommandations de la CNIL (st-home
_app.tsx). - Prometheus lit
/metrics, qui expose des métriques HTTP, SQL et métier (docs/metrics.md). - Grafana affiche des tableaux versionnés en JSON dans Git (exemples).
- cAdvisor et Grafana Alloy remontent métriques et logs (architecture).
- Quelques métriques métier complètent l'ensemble (exemple Drive).
Lucarne reprend ce modèle à l'échelle d'une vitrine.
2. Ce qui est livré dans le dépôt
Code
Commandes :
pnpm ops:k8s: régénère les tableaux JSON etk8s/prometheusrule.yamlaprès une modification du générateur ou dealerts.yml.pnpm test: vérifie, entre autres, que chaque métrique citée dans un tableau ou une alerte est bien exposée par l'application.
Une seule dépendance a été ajoutée : prom-client (15.1.3), la bibliothèque Prometheus de référence pour Node.js. Elle respecte la politique des dépendances. Le logger est écrit à la main pour éviter une dépendance de plus.
3. Variables d'environnement
| Variable | Côté | Défaut | Rôle |
|---|---|---|---|
METRICS_TOKEN | serveur | vide | Jeton Bearer exigé par /api/metrics. À mettre dans un Secret, avec la même valeur côté Prometheus. |
METRICS_ALLOW_UNAUTHENTICATED | serveur | false | true ouvre /api/metrics sans jeton en production. N'utiliser que si l'ingress ne route pas ce chemin. |
LOG_LEVEL | serveur | info | debug, info, warn ou error. |
APP_VERSION, GIT_COMMIT_SHA | serveur | package.json, unknown | Affichés dans build_info et /api/health. À injecter par la CI au build de l'image. |
NEXT_PUBLIC_ANALYTICS_ENABLED | build | false | Active Matomo, à condition que les deux variables suivantes soient renseignées. |
NEXT_PUBLIC_MATOMO_URL | build | vide | URL de l'instance Matomo, ex. https://stats.universite-paris-saclay.fr/. |
NEXT_PUBLIC_MATOMO_SITE_ID | build | vide | Identifiant du site dans Matomo. |
NEXT_PUBLIC_MATOMO_SEARCH_KEYWORDS | build | sanitized | sanitized : termes nettoyés. none : termes masqués, seuls le nombre de recherches et de résultats remonte. |
OTEL_EXPORTER_OTLP_ENDPOINT, SENTRY_DSN | serveur | vide | Réservées, non branchées (§ 7). |
Les variables NEXT_PUBLIC_* sont inlinées au build. Une image Docker est donc construite pour un environnement donné : recette et production ont deux sites Matomo, donc deux images ou deux builds.
Comportement de /api/metrics :
| Situation | Réponse |
|---|---|
METRICS_TOKEN défini, bon jeton | 200, format texte Prometheus |
METRICS_TOKEN défini, jeton absent ou faux | 401 |
Pas de jeton, NODE_ENV=production | 404, sauf si METRICS_ALLOW_UNAUTHENTICATED=true |
| Pas de jeton, développement | 200 |
En production, bloquer aussi /api/metrics à l'ingress pour le trafic venant d'Internet. Le jeton est une deuxième barrière, pas la seule.
4. Métriques exposées
Toutes les métriques portent le préfixe saclay_research_. Les métriques Node par défaut aussi (saclay_research_process_cpu_seconds_total, saclay_research_nodejs_eventloop_lag_p99_seconds, etc.).
| Métrique | Type | Labels | Signification |
|---|---|---|---|
build_info | gauge | version, commit, data_source | Version déployée ; data_source vaut fixtures ou api. |
searches_total | counter | entity_type, outcome | Recherches. outcome : results, zero_results ou error. |
search_duration_seconds | histogram | entity_type | Durée des recherches côté serveur. |
outbound_clicks_total | counter | target | Clics sortants vers hal, orcid, doi, idref, scanr, openalex, ror, theses, paris_saclay ou other. |
upstream_requests_total | counter | operation, status_class | Appels à l'API amont. status_class : 2xx, 4xx, 5xx ou network_error. |
upstream_request_duration_seconds | histogram | operation | Latence de l'API amont. |
contract_violations_total | counter | operation | Réponses amont rejetées par les schémas Zod, donc non publiées. |
revalidations_total | counter | outcome | Appels à /api/revalidate : success, unauthorized, bad_request ou error. |
request_errors_total | counter | route_type, route_path | Erreurs serveur capturées par Next.js. |
published_records | gauge | entity_type | Fiches servies par le fournisseur (center, researcher, project, laboratory). |
last_successful_sync_timestamp_seconds | gauge | source | Fin de la dernière synchronisation réussie, lue dans la table sync_runs. |
metric_collector_errors_total | counter | collector | Échecs de calcul des deux gauges précédentes. |
Règles de cardinalité :
- Aucun label ne contient une URL, un terme de recherche ou un identifiant.
operationest un libellé fixe (centers.list,centers.get).route_pathest le chemin du fichier de route (/centres/[id]), pas l'URL visitée.
Les six indicateurs métier de la note initiale
| Indicateur | Requête PromQL |
|---|---|
searches_total | sum(increase(saclay_research_searches_total[1d])) |
successful_searches_total | sum(increase(saclay_research_searches_total{outcome="results"}[1d])) |
zero_result_searches_total | sum(increase(saclay_research_searches_total{outcome="zero_results"}[1d])) |
official_outbound_clicks_total | sum(increase(saclay_research_outbound_clicks_total{target!~"other|paris_saclay"}[1d])) |
published_records | sum(saclay_research_published_records) |
last_successful_sync_timestamp | max(saclay_research_last_successful_sync_timestamp_seconds) |
Les trois premiers sont réunis dans un seul compteur à label outcome, plus idiomatique pour Prometheus. Le taux de succès s'obtient par division :
Code
Avec plusieurs instances, chaque pod a ses propres compteurs : on les additionne toujours avec sum(...), comme dans les requêtes ci-dessus.
Comment une recherche est comptée
dataProvider est enveloppé par withSearchMetrics. Tout appel de liste avec un paramètre query non vide compte comme une recherche, quel que soit le fournisseur (fixtures, API amont ou un futur OpenSearch). Un appel de liste sans query, par exemple l'annuaire des centres, n'est pas une recherche. Rien n'est à ajouter dans les pages quand la recherche sera branchée.
Comment un clic sortant est compté
OutboundLinkTracker écoute les clics sur tout le document. Pour un lien externe, il envoie {"type":"outbound_click","target":"hal"} à /api/telemetry par navigator.sendBeacon.
- Ce qui est envoyé : la catégorie seule, sans URL, cookie ni identifiant. Ce suivi n'est donc pas soumis au consentement et fonctionne même si Matomo est désactivé.
- Ce que l'endpoint refuse : tout ce qui n'est pas une catégorie connue (400), et les corps de plus de 256 octets (413).
5. Matomo : mesure d'audience exemptée de consentement
Ce que fait le code
disableCookies: aucun cookie, donc aucun bandeau.disableBrowserFeatureDetection: pas de collecte des plugins ou de la résolution (moins d'empreinte).enableLinkTracking: liens sortants et téléchargements, dans les rapports natifs de Matomo.- Navigation côté client : une vue de page est envoyée à chaque changement de route, avec le référent précédent.
- URL sans query string : les paramètres, dont
q, ne sont jamais envoyés dans l'URL suivie. - Recherche interne : sur
/recherche?q=…, la vue de page est remplacée partrackSiteSearch(terme, false, nbRésultats). C'est la recommandation de Matomo pour éviter le double comptage. Le terme passe d'abord parsanitizeSearchTerm, qui :- remplace par
[filtré]les termes contenant une adresse e-mail, une URL, un ORCID ou une longue suite de chiffres ; - met en minuscules et tronque à 64 caractères.
- remplace par
- Le rapport Comportement › Recherche interne › Mots-clés sans résultat est le premier outil d'amélioration éditoriale.
Les noms de personnes ne sont pas détectables automatiquement. Chercher « Jean Dupont » dans un annuaire public de chercheurs remonte « jean dupont » dans Matomo. C'est une donnée déjà publique sur la vitrine, mais la décision revient à la DPO : soit sanitized (par défaut), soit none.
Ce qu'il faut régler dans l'administration Matomo
Ces réglages sont à faire sur l'instance et ne dépendent pas du code. Ils sont à valider par la DPO au regard des lignes directrices de la CNIL sur les traceurs exemptés.
- Anonymisation des IP : au moins 2 octets.
- Durée de conservation : suppression automatique des données brutes, rapports et journaux au-delà de 25 mois au plus.
- Fonctions désactivées : User ID, e-commerce et Heatmaps/Session Recording. Ces fonctions ne sont jamais utilisées par le code.
- Isolement du site : pas de croisement avec d'autres sites ni de suivi multi-domaines.
- Accès restreint : comptes nominatifs, réservés à la communication et au pilotage.
- Paramètres d'URL exclus :
q,page,utm_*si nécessaire. Ceinture et bretelles, puisque le code les retire déjà. - Recherche interne : activer « Recherche interne » sur le site.
Ce qui reste à faire côté site
- Mécanisme d'opposition sur
/donnees-personnelles: le module « Opt-out » de Matomo (Administration › Confidentialité › Opt-out des utilisateurs) fournit le code à intégrer. La CNIL demande que l'utilisateur puisse s'opposer à la mesure, même exemptée. - Texte d'information sur
/donnees-personnelles: finalité, outil, hébergement, durée de conservation, droit d'opposition. - CSP : si une Content-Security-Policy est ajoutée dans
next.config.ts, autoriser l'hôte Matomo dansscript-srcetconnect-src.
Matomo en local
Code
En production, vérifier d'abord si la DSI de Paris-Saclay héberge déjà une instance Matomo : un site de plus suffit alors.
6. Prometheus, Grafana et Blackbox
En local
Code
- Grafana : http://localhost:3030 (admin / admin au premier lancement), dossier Saclay Research.
- Prometheus : http://localhost:9090, menus Status › Targets et Alerts.
- Vérification manuelle :
curl -H "Authorization: Bearer local-dev-metrics-token" localhost:3000/api/metrics.
Le fichier prometheus/metrics-token ne contient qu'une valeur de développement. Ne jamais y mettre le jeton de production.
La pile locale ne contient pas d'Alertmanager : les alertes sont visibles dans l'onglet Alerts de Prometheus, sans notification.
En production (Kubernetes)
kube-prometheus-stack fournit en un seul chart Prometheus, Grafana, Alertmanager, les métriques du cluster, et des tableaux et alertes Kubernetes préconfigurés (documentation).
Code
Hypothèses des manifestes, à adapter au chart de l'application :
- Service : nommé
saclay-research, labelapp.kubernetes.io/name: saclay-research, port nomméhttp. - Label
release: monitoring: nom du release Helm, utilisé par défaut par le Prometheus du chart pour choisir ses ServiceMonitor, Probe et PrometheusRule. - URL de
k8s/probe.yaml: à remplacer par le domaine public réel de la vitrine. - Tableaux : le sidecar Grafana du chart les charge depuis la ConfigMap
saclay-research-dashboards(labelgrafana_dashboard: "1").
Hors Kubernetes, avec une VM et Docker, la même pile se déploie avec le docker-compose.yml adapté : cibles, jeton et volumes persistants.
Métriques HTTP par route (taux de 5xx, latence par chemin) : Next.js ne permet pas de mesurer proprement chaque réponse dans l'application. On les prend au niveau de l'ingress, qui les expose déjà :
- ingress-nginx :
nginx_ingress_controller_requests,nginx_ingress_controller_request_duration_seconds; - Traefik : équivalents
traefik_*.
kube-prometheus-stack les collecte une fois le ServiceMonitor de l'ingress activé.
Les trois tableaux
| Tableau | Contenu |
|---|---|
| Santé du service | Disponibilité sur la période (cible 99,5 %), latence des sondes, erreurs serveur, version déployée, statuts et latence p95 de l'API amont, CPU, mémoire, boucle d'événements |
| Qualité éditoriale | Fiches publiées (total et par type), âge de la dernière synchronisation, rejets de contrat, révalidations |
| Usage | Recherches, taux de succès, taux sans résultat, durée p95, sorties par source ; renvoi vers Matomo pour l'audience détaillée |
Pour modifier un tableau, éditer grafana/build-dashboards.mjs, puis lancer pnpm ops:k8s. Les modifications faites dans l'interface Grafana ne sont pas conservées (allowUiUpdates: false). Un tableau modifié à la main peut être exporté en JSON puis reporté dans le générateur.
Alertes
| Alerte | Condition | Gravité |
|---|---|---|
SaclayResearchIndisponible | Sonde en échec pendant 5 min | critical |
SaclayResearchLent | Sonde moyenne > 2 s pendant 10 min | warning |
SaclayResearchMetriquesAbsentes | /api/metrics illisible pendant 10 min | warning |
SaclayResearchErreursServeur | Plus de 5 erreurs serveur en 10 min | warning |
SaclayResearchAmontEnErreur | Plus de 20 % d'appels amont en 5xx ou erreur réseau, avec trafic minimal | warning |
SaclayResearchContratRompu | Au moins une réponse amont non conforme en 1 h | warning |
SaclayResearchMemoireHaute | Mémoire résidente > 1,5 Go pendant 15 min | warning |
SaclayResearchBoucleSaturee | Latence p99 de la boucle d'événements > 0,5 s pendant 10 min | warning |
SaclayResearchSynchroAncienne | Dernière synchronisation réussie il y a plus de 26 h | warning |
SaclayResearchRecherchesSansResultat | Plus de 30 % de recherches sans résultat sur 24 h, avec plus de 50 recherches | info |
Le routage (e-mail, Mattermost, Tchap…) se configure dans l'Alertmanager de kube-prometheus-stack, à décider avec l'exploitant.
7. Logs et erreurs applicatives
Format
Chaque ligne est un objet JSON écrit sur stdout, ou sur stderr pour warn et error :
Code
Messages émis aujourd'hui :
- démarrage du serveur ;
- erreurs serveur Next.js (
onRequestError, avecdigest, route, méthode et chemin sans query string) ; - appels amont en échec, réponses non conformes au contrat (10 premières anomalies Zod) ;
- révalidations ;
- échecs de collecte des métriques.
Ce qui n'est jamais journalisé :
- les clés dont le nom contient
token,secret,password,authorization,cookieouemail, remplacées par[redacted]; - les query strings, donc les termes de recherche ;
- les corps des requêtes de télémétrie.
Collecte : en Kubernetes, Grafana Alloy ou Promtail lit les logs des conteneurs et les envoie à Loki, interrogeable dans le même Grafana. Exemple de requête : {app="saclay-research"} | json | level="error". Même sans Loki, kubectl logs deploy/saclay-research | jq 'select(.level=="error")' suffit.
Erreurs : ce qui est branché
src/instrumentation.ts exporte onRequestError, le hook natif de Next.js. Chaque erreur de rendu, de route ou d'action :
- incrémente
request_errors_total{route_type, route_path}; - produit une ligne
level=erroravec la pile d'appels.
C'est suffisant pour alerter et diagnostiquer, sans outil tiers.
Option GlitchTip / Sentry (non installée)
SENTRY_DSN et OTEL_EXPORTER_OTLP_ENDPOINT sont présents dans .env.example mais ne sont lus nulle part aujourd'hui.
Si un regroupement des erreurs avec historique et notifications devient nécessaire, la recommandation est GlitchTip, open source, auto-hébergeable et compatible avec le SDK Sentry. Il faudrait alors :
- ajouter
@sentry/nextjs(respecter la politique des dépendances) ; - l'initialiser dans
register()avec :sendDefaultPii: false;tracesSampleRate≤ 0,1 ;- sans l'intégration Replay ;
- appeler
Sentry.captureRequestErrordansonRequestError, à côté du log existant ; - compléter l'ADR 006.
La même logique vaut pour OpenTelemetry (@vercel/otel), si l'exploitant dispose d'un collecteur de traces.
8. Runbook : réagir aux alertes
Incident site indisponible
- Erreur réelle ou panne de sonde :
curl -I https://<domaine>/api/health. Si l'appel réussit depuis l'extérieur, vérifier le Blackbox Exporter et le DNS. - Pods :
kubectl get pods, puiskubectl logs deploy/saclay-research --tail=200 | jq 'select(.level!="info")'. - Déploiement récent : panneau « Version déployée ». En cas de doute, rollback sur l'image précédente (runbook § 1).
API amont en erreur
- Les pages déjà générées restent servies par le cache (ADR 002) : pas d'urgence côté visiteurs.
- Identifier l'opération touchée (tableau Santé, « Appels amont par statut »), puis prévenir l'exploitant de CRISalid avec l'heure de début et la classe d'erreur.
Contrat rompu
- L'API a changé de format. Les logs
Réponse amont non conforme au contratlistent les champs en cause. - Corriger le schéma dans
src/contracts/si le changement est légitime, ou signaler une régression à l'amont.
Synchronisation ancienne
- Lire
SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 10;, puis les colonnesstatuseterror_log. - Relancer la synchronisation une fois la cause corrigée.
Recherches sans résultat
Il ne s'agit pas d'un incident mais d'un signal éditorial. Dans Matomo, ouvrir Comportement › Recherche interne › Mots-clés sans résultat. Selon les termes trouvés :
- ajouter des synonymes ;
- compléter les fiches ;
- signaler une lacune de couverture au comité éditorial.
9. Rapport trimestriel
| Section | Source | Extraction |
|---|---|---|
| Audience : visites, pages, provenance, appareils | Matomo | Rapport planifié par e-mail, ou export PDF/CSV |
| Recherches et recherches sans résultat (top 20) | Matomo | Recherche interne |
| Disponibilité, incidents, temps de réponse | Grafana › Santé | Période = trimestre, export PDF ou capture |
| Fiches publiées, fraîcheur, rejets | Grafana › Qualité éditoriale | Idem |
| Sorties vers HAL, ORCID, DOI… | Grafana › Usage | Idem |
Ces indicateurs alimentent les jalons « continuer, pivoter ou arrêter » définis dans l'audit (page Exploitation et pilotage).
10. Limites connues et prochaines étapes
Limites des mesures actuelles
- Recherche pas encore branchée :
/rechercheest un écran statique. Les métriques de recherche s'alimenteront dès qu'une liste sera appelée avecquery;SearchResultsenvoie déjàtrackSiteSearchà Matomo. - Aucun job n'écrit encore dans
sync_runs:last_successful_sync_timestamp_secondsreste absente tant que la synchronisation n'existe pas. L'alerte « synchro ancienne » ne se déclenche donc pas. - Fournisseur amont incomplet : il n'implémente que les centres.
published_recordsn'affiche que les types réellement servis ; un type non servi est absent, pas à zéro. /api/telemetrynon authentifié (par nature) : un robot peut gonfleroutbound_clicks_total. L'impact est faible, et une limite de débit à l'ingress suffit.- Métriques HTTP par route : elles viennent de l'ingress, pas de l'application (§ 6).
Points de configuration
- Lint (Biome,
biome.jsonc) : la règle « pas d'import desrc/server» ne s'applique qu'aux dossiers clients (components,features,utils,theme,contracts). Avant, elle bloquait aussi les pages et le serveur, ce qui n'était pas son intention. - Tests unitaires :
server-onlyest neutralisé dansvitest.config.tspour pouvoir testersrc/server.
Décisions à prendre
- DPO : mode des termes de recherche (
sanitizedounone), réglages Matomo (§ 5), texte et opt-out de/donnees-personnelles. - DSI : instance Matomo existante, cluster et chart de monitoring, routage des alertes, collecte des logs (Loki ou autre).
- Cibles : 99,5 % de disponibilité, seuil de 26 h pour la synchronisation, 30 % de recherches sans résultat. Ce sont des valeurs de travail, à confirmer avec le pilotage.
- ADR 006 : la passer de « Proposé » à « Accepté » une fois ces choix validés.