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, image9c18146) 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 :
LlmCallRunn'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 commehttp.- Le client ne lit jamais le corps d'une réponse en erreur :
LlmErrorporte seulementMistral 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 optionaccesslogdans 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) :trustProxyn'est pas activé etX-Forwarded-Forn'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 /askou 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 CORSOPTIONS /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/24représente 58 % du volume (user-agentcurl/8.7.1), puis93.123.109.0/24,45.148.10.0/24,185.218.86.0/24et195.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 /faittry_files $uri $uri/ /index.html./.envou/.git/configrenvoient 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 :/.envet/.git/configrenvoientindex.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 »).