Aller au contenu

Constats de production — 2026-09-27

Dette documentée, non corrigée. Constats faits en lecture seule sur la production (api.alpimonitor.fr, alpimonitor.fr, image 9c18146) lors du diagnostic d'une panne de la couche IA. Aucune configuration ni aucun code n'a été modifié.

1. Trou d'observabilité de la couche IA

Constat

La couche IA (ADR-012) est en échec en production depuis le 2026-09-07. Dernier succès : 2026-07-10 ; depuis, 5 appels sur 5 sont en erreur. Diagnostiquer cette panne a demandé de lire les journaux bruts du conteneur, parce que l'observabilité prévue pour cela (extension D) ne conserve pas l'information utile :

  • LlmCallRun n'enregistre que la catégorie d'erreur (errorKind : config, network, http, timeout, parse), ni le code HTTP ni le message du fournisseur. Les 5 échecs apparaissent tous comme http.
  • Le client ne lit jamais le corps d'une réponse en erreur : LlmError porte seulement Mistral responded <code> <texte>. Le message explicatif du fournisseur est perdu, y compris dans les journaux.
  • La narration ne journalise rien : une erreur devient state: unavailable (reason: llm_error) sans trace. Seul le chat (POST /ask) journalise son erreur.

Ce que les journaux du chat ont permis d'établir : Mistral répond 429 Too Many Requests (2026-09-08, puis trois fois le 2026-09-27), en 70 à 230 ms. MISTRAL_API_KEY est présente dans le conteneur (32 caractères) ; le conteneur n'a pas été redéployé depuis environ le 2026-07-23 et n'a jamais réussi un appel. L'hypothèse d'un blocage lié au plan Free du compte Mistral reste non confirmée : un 429 ne suffit pas à la distinguer d'un épuisement de quota, et le corps de la réponse n'est pas disponible.

Correction minimale proposée (non appliquée)

a. Lire le corps de l'erreur — apps/api/src/ai/llm-client.ts, aux deux endroits if (!res.ok) :

export class LlmError extends Error {
  constructor(
    readonly kind: LlmErrorKind,
    message: string,
    override readonly cause?: unknown,
    readonly httpStatus: number | null = null,
    readonly providerMessage: string | null = null, // corps de réponse, tronqué
  ) { … }
}

// dans complete() et completeWithTools()
if (!res.ok) {
  const detail = (await res.text().catch(() => '')).slice(0, 500);
  throw new LlmError('http', `Mistral responded ${res.status} ${res.statusText}`,
    undefined, res.status, detail || null);
}

La ligne de journal existante du chat en profite sans autre changement : pino sérialise les propriétés de l'erreur.

b. Deux champs sur LlmCallRun, migration additive :

model LlmCallRun {
  …
  errorKind    String?
  httpStatus   Int?     // code HTTP du fournisseur, en échec http
  errorMessage String?  // corps de réponse du fournisseur, tronqué à 500 caractères
  …
}
-- migrations/<timestamp>_add_llm_call_error_detail/migration.sql
ALTER TABLE "LlmCallRun" ADD COLUMN "httpStatus" INTEGER, ADD COLUMN "errorMessage" TEXT;

Colonnes nullables, sans rétro-remplissage ; mêmes noms que sur IngestionRun. Dans observing-llm-client.ts, LlmCallRecord reçoit les deux champs, et les deux catch y copient err.httpStatus et err.providerMessage quand err est une LlmError.

c. Journaliser la narration, sur le modèle de /ask — services/narrative-service.ts, quand outcome.status === 'error' :

deps.logger.warn(
  { err: outcome.error, stationId, parameter },
  'narrative: AI layer unavailable'
);

logger s'ajoute aux dépendances du service ; la route /stations/:id/narrative passe req.log.

d. Optionnel : exposer lastError: { at, kind, httpStatus } sur GET /ai/status, pour qu'une panne soit visible de l'extérieur. Le message du fournisseur reste en base et dans les journaux : il peut contenir des informations de compte, et ce point d'accès est public.

Coût estimé : ~50 lignes sur 5 fichiers (client, décorateur, service de narration, route, schéma), une migration de 2 lignes, ~40 lignes de tests, une note dans l'ADR-012 (section D) ; une demi-journée avec la revue. À faire sur une branche dédiée depuis main. Ne stocker ni journaliser que le corps de la réponse, jamais la requête : elle contient les prompts, et ses en-têtes portent la clé.

2. Journal d'accès Traefik désactivé : aucune IP côté API

Constat

  • Le proxy coolify-proxy (Traefik v3.6) tourne sans journal d'accès : aucune option accesslog dans sa configuration ; ses journaux ne contiennent que des erreurs ACME.
  • L'API Fastify journalise chaque requête, mais avec l'adresse du proxy (10.0.3.2) : trustProxy n'est pas activé et X-Forwarded-For n'est pas journalisé.
  • Conséquence : aucune trace de l'IP réelle d'un client de l'API, que ce soit pour un scan, un abus du point d'accès public POST /ask ou un incident.
  • Côté site, nginx journalise l'IP réelle transmise par le proxy (X-Forwarded-For, dernier champ de la ligne) : c'est la seule source d'IP disponible aujourd'hui.

3. Scan automatisé des deux domaines

Constat — API (api.alpimonitor.fr, journaux du 2026-07-23 au 2026-09-27)

  • 18 811 requêtes de scan, soit 95 % du trafic de l'API, contre ~1 000 requêtes légitimes.
  • Réponses : 18 805 × 404 et 6 × 400. Les 400 sont des rejets du framework avant tout routage (corps JSON vide sur POST /api/v1/validate/code, pré-vérifications CORS OPTIONS / non valides). Aucune route n'a été exécutée.
  • Rythme : médiane de 44 requêtes par jour, un seul jour sans scan ; 93 % du volume en 63 rafales, la plus grosse de 3 023 requêtes (2026-08-24).
  • Familles de chemins :
Famille Requêtes Exemples
.env et variantes 6 810 /.env, /.env.local, /.env.production
WordPress / PHP 2 657 /wp-json/batch/v1, /info.php
Configuration, sauvegardes 2 187 /service-account.json, /firebase-adminsdk.json, /key.json
Clés cloud 1 401 /credentials.json, /.aws/credentials, /.config/gcloud/…
Debug, profileurs 1 122 /phpinfo.php, /_profiler/phpinfo
Pages d'administration 252 /admin, /login, /phpmyadmin/
.git, VCS 234 /.git/config, /.git/HEAD, /.git-credentials
Spring /actuator 183 /actuator/env, /actuator/heapdump
Outils IA (depuis le 2026-07-29) 105 /.claude.json, /.config/anthropic/credentials/default.json, /.env.openai
Docs d'API 83 /graphql, /api-docs

Certaines sondes visent la faille de lecture de fichiers du serveur de dev Vite (/@fs/…?raw??) ; la production ne sert pas Vite, elle est sans objet.

Constat — site (alpimonitor.fr, journaux du 2026-07-27 au 2026-09-27)

  • 104 288 requêtes de scan, 579 IP distinctes ; le sous-réseau 185.177.72.0/24 représente 58 % du volume (user-agent curl/8.7.1), puis 93.123.109.0/24, 45.148.10.0/24, 185.218.86.0/24 et 195.178.110.0/24 (~3 % chacun). Médiane : 3 requêtes par IP. Propriétaires des blocs non identifiés.
  • La SPA répond 200 sur tout chemin inconnu : location / fait try_files $uri $uri/ /index.html. /.env ou /.git/config renvoient donc la page HTML de l'application avec un 200.
  • Absence de fuite vérifiée : les 100 726 réponses 200/206 aux chemins de scan ont toutes la taille d'index.html (744 ou 1 791 octets) ou sont vides ; l'image web ne contient que la SPA compilée (index.html, un JS, un CSS, robots.txt, sitemap.xml, 50x.html). Vérifié aussi par requête directe : /.env et /.git/config renvoient index.html.
  • Effet secondaire : ces 200 font croire aux robots qu'un fichier existe, ce qui peut entretenir le scan et brouille la lecture des journaux.

Dette associée (non traitée)

  • Pas de réponse 404 pour les chemins manifestement hors SPA (fichiers cachés, extensions de configuration).
  • Pas de filtrage ni de limitation de débit au niveau du proxy (déjà listé en §10.4 : « Helmet + rate-limit côté API »).