# Observabilité de Lucarne

:::tip{title="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/metrics` est fermé par jeton, contrairement au `/v1/metrics` public relevé par l'audit de l'existant.
  :::

Décision de principe : [ADR 006](/decisions/analytics). Exploitation courante : [runbook](/exploitation/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`](https://github.com/suitenumerique/st-home/blob/main/src/pages/_app.tsx)).
- **Prometheus** lit `/metrics`, qui expose des métriques HTTP, SQL et métier ([docs/metrics.md](https://github.com/suitenumerique/docs/blob/main/documentation/metrics.md)).
- **Grafana** affiche des tableaux versionnés en JSON dans Git ([exemples](https://github.com/suitenumerique/docs/tree/main/src/loadtest/dashboards)).
- **cAdvisor et Grafana Alloy** remontent métriques et logs ([architecture](https://github.com/suitenumerique/st-ansible/blob/main/docs/monitoring.md)).
- **Quelques métriques métier** complètent l'ensemble ([exemple Drive](https://github.com/suitenumerique/drive/blob/main/docs/prometheus.md)).

Lucarne reprend ce modèle à l'échelle d'une vitrine.

<Mermaid
  title="Qui mesure quoi : les flux de mesure autour de la vitrine"
  chart={`flowchart LR
  V[Visiteur] -->|pages| APP[Next.js Lucarne]
  V -->|matomo.php, sans cookie| MAT[Matomo]
  V -->|sendBeacon catégorie| APP
  APP -->|appels instrumentés| UP[API amont : CRISalid]
  APP -->|stdout JSON| LOGS[Alloy → Loki]
  PROM[Prometheus] -->|/api/metrics + jeton| APP
  PROM -->|/probe| BB[Blackbox Exporter] -->|HTTP| APP
  PROM --> AM[Alertmanager]
  GRAF[Grafana] --> PROM`}
/>

---

## 2. Ce qui est livré dans le dépôt

```text
src/
  instrumentation.ts                     démarrage (build_info) + onRequestError (erreurs serveur)
  server/observability/
    metrics.ts                           registre Prometheus et métriques de l'application
    collectors.ts                        métriques métier calculées au scrape (cache 1–5 min)
    access.ts                            contrôle d'accès de /api/metrics
    logger.ts                            logs JSON, clés sensibles masquées
  server/data/withSearchMetrics.ts       toute liste avec `query` = une recherche mesurée
  server/data/providers/                 fournisseur amont : appels mesurés et journalisés
  app/api/metrics/route.ts               exposition Prometheus
  app/api/telemetry/route.ts             réception des clics sortants (catégorie seule)
  app/api/health/route.ts                sonde de vie (version, commit)
  features/observability/                Matomo + suivi des liens sortants, montés dans le layout
  utils/telemetry.ts                     classement des liens sortants, nettoyage des termes
ops/observability/
  docker-compose.yml                     pile locale : Prometheus, Grafana, Blackbox (+ Matomo)
  prometheus/prometheus.yml, alerts.yml  scrape, sondes et règles d'alerte (source unique)
  blackbox/blackbox.yml                  module http_2xx
  grafana/build-dashboards.mjs           générateur des tableaux (source de vérité)
  grafana/dashboards/*.json              tableaux générés : santé, qualité éditoriale, usage
  grafana/provisioning/                  datasource et chargement automatique des tableaux
  kustomization.yaml + k8s/              ServiceMonitor, Probe, PrometheusRule, ConfigMap des tableaux
tests/unit/observability/                accès, métriques, télémétrie, cohérence tableaux/alertes
```

Commandes :

- `pnpm ops:k8s` : régénère les tableaux JSON et `k8s/prometheusrule.yaml` après une modification du générateur ou de `alerts.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](/securite/politique-dependances). 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.**
- `operation` est un libellé fixe (`centers.list`, `centers.get`).
- `route_path` est 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 :

```promql
sum(rate(saclay_research_searches_total{outcome="results"}[1d])) / sum(rate(saclay_research_searches_total[1d]))
```

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 par `trackSiteSearch(terme, false, nbRésultats)`. C'est la recommandation de Matomo pour éviter le double comptage. Le terme passe d'abord par `sanitizeSearchTerm`, 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.
- 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 dans `script-src` et `connect-src`.

### Matomo en local

```bash
docker compose -f ops/observability/docker-compose.yml --profile matomo up -d
# http://localhost:8081 : assistant d'installation (base : matomo-db / matomo / matomo)
# puis, dans .env.local :
NEXT_PUBLIC_ANALYTICS_ENABLED=true
NEXT_PUBLIC_MATOMO_URL=http://localhost:8081/
NEXT_PUBLIC_MATOMO_SITE_ID=1
```

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

```bash
# 1. L'application, avec le jeton attendu par la pile locale
METRICS_TOKEN=local-dev-metrics-token pnpm dev

# 2. La pile
docker compose -f ops/observability/docker-compose.yml up -d
```

- **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](https://github.com/prometheus-community/helm-charts/blob/main/charts/kube-prometheus-stack/README.md)).

```bash
# Une fois par cluster
helm install monitoring oci://ghcr.io/prometheus-community/charts/kube-prometheus-stack \
  --namespace monitoring --create-namespace
helm install blackbox oci://ghcr.io/prometheus-community/charts/prometheus-blackbox-exporter \
  --namespace monitoring

# Dans le namespace de l'application
kubectl create secret generic saclay-research-metrics --from-literal=token="$(openssl rand -hex 32)"
# … injecter la même valeur dans le Deployment : env METRICS_TOKEN depuis ce Secret
kubectl apply -k ops/observability
```

Hypothèses des manifestes, à adapter au chart de l'application :

- **Service** : nommé `saclay-research`, label `app.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` (label `grafana_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` :

```json
{
  "time": "2026-10-24T05:10:27.105Z",
  "level": "error",
  "service": "saclay-research",
  "msg": "Réponse amont en erreur",
  "operation": "centers.get",
  "endpoint": "/centers/42",
  "status": 503
}
```

Messages émis aujourd'hui :

- démarrage du serveur ;
- erreurs serveur Next.js (`onRequestError`, avec `digest`, 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`, `cookie` ou `email`, 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=error` avec 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 :

1. ajouter `@sentry/nextjs` (respecter la [politique des dépendances](/securite/politique-dependances)) ;
2. l'initialiser dans `register()` avec :
   - `sendDefaultPii: false` ;
   - `tracesSampleRate` ≤ 0,1 ;
   - **sans** l'intégration Replay ;
3. appeler `Sentry.captureRequestError` dans `onRequestError`, à côté du log existant ;
4. 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

1. **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.
2. **Pods** : `kubectl get pods`, puis `kubectl logs deploy/saclay-research --tail=200 | jq 'select(.level!="info")'`.
3. **Déploiement récent** : panneau « Version déployée ». En cas de doute, rollback sur l'image précédente ([runbook](/exploitation/runbook) § 1).

### API amont en erreur

- Les pages déjà générées restent servies par le cache ([ADR 002](/decisions/rendu-et-cache)) : 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 contrat` listent 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 colonnes `status` et `error_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** : `/recherche` est un écran statique. Les métriques de recherche s'alimenteront dès qu'une liste sera appelée avec `query` ; `SearchResults` envoie déjà `trackSiteSearch` à Matomo.
- **Aucun job n'écrit encore dans `sync_runs`** : `last_successful_sync_timestamp_seconds` reste 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_records` n'affiche que les types réellement servis ; un type non servi est absent, pas à zéro.
- **`/api/telemetry` non authentifié** (par nature) : un robot peut gonfler `outbound_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 de `src/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-only` est neutralisé dans `vitest.config.ts` pour pouvoir tester `src/server`.

**Décisions à prendre**

- [ ] **DPO** : mode des termes de recherche (`sanitized` ou `none`), 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.
