# 001 — HistoMeteo MVP — Spec Technique v21

> **Source fonctionnelle** : `001-histometeo-mvp.md` + brief admin « Interface admin bêta pour suivi des recherches et des appels API » (2026-03-14)
> **Base technique** : `001-histometeo-mvp.tech.v20.md`
> **Feedback intégré** : `feedback-to-architect-001-v20.md` (validé ✅ — aucune correction requise) + R62 (recommandation CI intégrée dans le design admin)
> **Date** : 2026-03-14

---

## 0) Contract

- **Source of truth** : cette spec technique (`001-histometeo-mvp.tech.v21.md`)
- **Functional integrity** : AC1–AC30 inchangés. Nouveaux AC31–AC42 (admin/tracking).
- **Scope** — fichiers modifiables :
  - `src/config.py` (ajout constantes admin/tracking)
  - `src/main.py` (ajout admin router + instrumentation tracking des routes de recherche)
  - `src/weather_service.py` (ajout hooks de tracking conditionnels)
  - `src/normals_service.py` (ajout hooks de tracking conditionnels)
- **Scope** — fichiers à créer :
  - `src/tracking_service.py` (service SQLite de tracking)
  - `admin/index.html` (interface admin HTML)
  - `admin/admin.js` (logique frontend admin)
  - `tests/test_tracking_service.py` (tests unitaires tracking)
  - `tests/test_admin_api.py` (tests intégration routes admin)
- **Forbidden changes** :
  - `public/app.js`, `public/index.html`, `public/style.css` — aucune modification
  - `src/cache.py`, `src/commune_service.py`, `src/prefetch_service.py`, `src/og_service.py` — aucune modification
  - Tous les fichiers de tests existants (110 tests) — ne pas modifier, tous doivent continuer à passer
  - Le comportement observable des routes utilisateur (API + SEO + homepage) ne change pas
- **Invariants** :
  - INV-1 à INV-33 : tous préservés (cf. v20)
  - INV-34 : le tracking ne doit JAMAIS propager une exception vers la réponse utilisateur — toute erreur de tracking est silencieusement ignorée et loggée via `logger.warning()`
  - INV-35 : les données de tracking ne contiennent aucune donnée personnelle (pas d'IP, pas de User-Agent, pas de cookies, pas de headers utilisateur)
  - INV-36 : la base SQLite de tracking est stockée dans `DATA_DIR` (dossier `data/`), jamais dans le code source
  - INV-37 : toutes les routes admin sont préfixées par `/admin/`
  - INV-38 : les réponses des routes admin incluent le header `X-Robots-Tag: noindex`
  - INV-39 : la comparaison du mot de passe admin utilise `secrets.compare_digest()` (protection timing-attack)
  - INV-40 : les routes utilisateur existantes (API + SEO + homepage) conservent leur comportement observable identique — le tracking est un effet de bord invisible
- **Done when** :
  - D33–D39 : acquis (v20 validée)
  - D40 : `src/tracking_service.py` existe et contient la classe `TrackingService`
  - D41 : la base SQLite `data/tracking.db` est créée automatiquement au démarrage avec les tables `search_logs` et `api_call_logs`
  - D42 : `GET /api/weather` crée une entrée dans `search_logs` avec statut success/error et durée
  - D43 : les routes SEO `/meteo/{slug}/{start}/{end}` et `/meteo/{slug}/{year}/{month}` créent une entrée dans `search_logs` avec commune, dates, type "period" ou "month"
  - D44 : la route SEO `/comparaison/...` crée une entrée dans `search_logs` avec search_type="comparison" et les deux communes
  - D45 : la route SEO `/ville/...` crée une entrée dans `search_logs` avec search_type="town"
  - D46 : chaque appel HTTP vers Open-Meteo dans `weather_service` crée une entrée dans `api_call_logs` avec cache_status="miss"
  - D47 : un cache hit dans `weather_service.get_weather()` crée une entrée dans `api_call_logs` avec cache_status="hit" et status_code=NULL
  - D48 : `GET /admin/` avec authentification valide retourne la page HTML admin
  - D49 : `GET /admin/api/searches` avec authentification retourne la liste paginée des recherches (JSON)
  - D50 : `GET /admin/api/searches/{id}` avec authentification retourne le détail + appels API liés
  - D51 : `GET /admin/api/dashboard` avec authentification retourne les indicateurs synthétiques
  - D52 : sans variable `ADMIN_PASSWORD`, toutes les routes `/admin/*` retournent HTTP 503
  - D53 : l'interface admin HTML affiche les 3 vues (liste, détail, synthèse) via navigation JS
  - D54 : les 110 tests hérités (≤ v20) passent sans modification
  - D55 : les nouveaux tests (tracking + admin) passent
  - D56 : le header `X-Robots-Tag: noindex` est présent sur toutes les réponses `/admin/*`

---

## 1) Objectif technique

Ajouter un système de **tracking des recherches et appels API** avec stockage SQLite, et une **interface admin légère** protégée par authentification, permettant de visualiser les recherches, les appels API associés, et des indicateurs de fonctionnement.

Aucune fonctionnalité utilisateur n'est modifiée. Le tracking est un effet de bord non bloquant ajouté aux routes de recherche existantes.

---

## 2) Analyse du brief

### Besoins principaux

| #   | Besoin                                                                                 | Origine        |
| --- | -------------------------------------------------------------------------------------- | -------------- |
| B26 | Journaliser chaque recherche utilisateur (commune, période, type, statut, durée)       | Brief admin §2 |
| B27 | Journaliser chaque appel API vers Open-Meteo (cache status, HTTP code, durée)          | Brief admin §3 |
| B28 | Lier chaque appel API à la recherche qui l'a déclenché via `search_id`                 | Brief admin §4 |
| B29 | Interface admin : tableau des recherches récentes (tri, filtre, recherche par commune) | Brief admin §5 |
| B30 | Interface admin : détail d'une recherche avec ses appels API                           | Brief admin §5 |
| B31 | Interface admin : vue synthèse (indicateurs de fonctionnement)                         | Brief admin §5 |
| B32 | Accès admin protégé par authentification, non indexable                                | Brief admin §8 |

### Contraintes

- **Zéro nouvelle dépendance externe** : `sqlite3` est dans la bibliothèque standard Python ; `HTTPBasic` est intégré à FastAPI
- **Pas de serveur de base de données** : SQLite fichier, cohérent avec l'approche FileCache existante
- **Tracking non bloquant** : ne doit pas dégrader la performance des routes utilisateur
- **Simplicité** : interface admin tabulaire, pas de dashboard complexe

### Risques

Voir §7.

---

## 3) Design minimal proposé

### 3.1 Choix de stockage : SQLite

**Justification** :

- La bibliothèque standard Python inclut `sqlite3` → zéro dépendance ajoutée
- SQLite supporte nativement les requêtes d'agrégation (COUNT, GROUP BY, ORDER BY) nécessaires au dashboard
- Fichier unique `data/tracking.db` → cohérent avec l'approche fichier existante (`data/weather_cache/`)
- Performance suffisante : les inserts sont < 1ms, largement dans le budget de tracking

**Configuration** :

- `check_same_thread=False` sur la connexion SQLite (les routes FastAPI s'exécutent potentiellement dans des threads différents)
- WAL mode (`PRAGMA journal_mode=WAL`) pour permettre des lectures concurrentes pendant les écritures
- Toutes les écritures de tracking sont enveloppées dans `try/except` → jamais de propagation d'erreur vers l'utilisateur

### 3.2 Schéma de données

#### Table `search_logs`

```sql
CREATE TABLE IF NOT EXISTS search_logs (
    id              TEXT PRIMARY KEY,           -- UUID v4
    created_at      TEXT NOT NULL,              -- ISO 8601 (UTC)
    environment     TEXT NOT NULL,              -- "dev" | "beta" | "prod"
    source          TEXT NOT NULL,              -- "api" | "seo"
    search_type     TEXT NOT NULL,              -- "period" | "month" | "comparison" | "town"
    commune_1       TEXT,                       -- nom de la commune (NULL si appel API direct)
    commune_1_slug  TEXT,                       -- slug normalisé
    commune_2       TEXT,                       -- nom de la 2e commune (comparison uniquement)
    commune_2_slug  TEXT,                       -- slug normalisé de la 2e commune
    latitude        REAL,                       -- coordonnée latitude
    longitude       REAL,                       -- coordonnée longitude
    start_date      TEXT,                       -- date début (YYYY-MM-DD)
    end_date        TEXT,                       -- date fin (YYYY-MM-DD)
    status          TEXT NOT NULL DEFAULT 'pending', -- "pending" | "success" | "error"
    total_api_calls INTEGER NOT NULL DEFAULT 0,
    duration_ms     INTEGER,                    -- durée totale de la recherche
    error_message   TEXT                        -- message d'erreur si échec
);

CREATE INDEX IF NOT EXISTS idx_search_created ON search_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_search_status ON search_logs(status);
CREATE INDEX IF NOT EXISTS idx_search_env ON search_logs(environment);
CREATE INDEX IF NOT EXISTS idx_search_commune ON search_logs(commune_1_slug);
```

#### Table `api_call_logs`

```sql
CREATE TABLE IF NOT EXISTS api_call_logs (
    id              TEXT PRIMARY KEY,           -- UUID v4
    search_id       TEXT NOT NULL,              -- FK vers search_logs.id
    created_at      TEXT NOT NULL,              -- ISO 8601 (UTC)
    provider        TEXT NOT NULL,              -- "open-meteo"
    endpoint        TEXT NOT NULL,              -- URL complète ou constante (ex: OPEN_METEO_ARCHIVE_URL)
    params_summary  TEXT,                       -- résumé des paramètres (lat, lon, dates) — pas de dump complet
    cache_key       TEXT,                       -- clé de cache utilisée
    cache_status    TEXT NOT NULL,              -- "hit" | "miss"
    status_code     INTEGER,                    -- code HTTP (NULL si cache hit)
    duration_ms     INTEGER NOT NULL,           -- durée de l'appel (0 si cache hit)
    success         INTEGER NOT NULL DEFAULT 1, -- 1 = succès, 0 = erreur
    error_message   TEXT,                       -- message d'erreur si échec
    FOREIGN KEY (search_id) REFERENCES search_logs(id)
);

CREATE INDEX IF NOT EXISTS idx_api_search ON api_call_logs(search_id);
CREATE INDEX IF NOT EXISTS idx_api_created ON api_call_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_api_cache ON api_call_logs(cache_status);
```

### 3.3 Service de tracking : `src/tracking_service.py`

Classe `TrackingService` responsable de tout l'accès SQLite.

**Responsabilités** :

| Méthode                   | Rôle                                                       |
| ------------------------- | ---------------------------------------------------------- |
| `__init__(db_path)`       | Ouvre la connexion SQLite, crée les tables si inexistantes |
| `create_search(**kwargs)` | Insère une entrée dans `search_logs` (statut "pending")    |
| `complete_search(id, …)`  | Met à jour statut, durée, total_api_calls, error_message   |
| `log_api_call(**kwargs)`  | Insère une entrée dans `api_call_logs`                     |
| `list_searches(…)`        | Requête paginée + filtres pour l'écran admin               |
| `get_search_detail(id)`   | Retourne une recherche + ses `api_call_logs` liés          |
| `get_dashboard()`         | Requête d'agrégation pour les indicateurs synthèse         |
| `close()`                 | Ferme la connexion SQLite                                  |

**Garanties** :

- Chaque méthode d'écriture (`create_search`, `complete_search`, `log_api_call`) est enveloppée dans `try/except Exception` avec `logger.warning()` en cas d'erreur — aucune exception ne remonte à l'appelant
- Les méthodes de lecture (`list_searches`, `get_search_detail`, `get_dashboard`) peuvent lever des exceptions (utilisées uniquement par les routes admin)

### 3.4 Contexte de recherche : `contextvars`

Pour relier les appels API à la recherche qui les a déclenchés sans modifier les signatures de fonctions existantes :

```python
# Dans src/tracking_service.py (en haut du module)
import contextvars

_current_search_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
    "current_search_id", default=None
)

def set_current_search_id(search_id: str) -> None:
    _current_search_id.set(search_id)

def get_current_search_id() -> str | None:
    return _current_search_id.get()
```

**Flux** :

1. Le handler de route génère un `search_id = str(uuid4())`
2. Il appelle `set_current_search_id(search_id)`
3. Les services instrumentés (weather, normals) lisent `get_current_search_id()` pour associer leurs appels API
4. Le handler met à jour le search_log avec le statut final

**Sécurité async** : `contextvars` est nativement compatible avec `asyncio` — chaque coroutine a son propre contexte, pas de risque de collision entre requêtes concurrentes.

### 3.5 Référence globale au tracker

Le `TrackingService` est instancié une seule fois dans `main.py` (comme les autres services) et rendu accessible aux services via une variable de module :

```python
# Dans src/tracking_service.py
_tracker: TrackingService | None = None

def set_tracker(tracker: TrackingService) -> None:
    global _tracker
    _tracker = tracker

def get_tracker() -> TrackingService | None:
    return _tracker
```

Dans `main.py` (au démarrage) :

```python
from src.tracking_service import TrackingService, set_tracker

tracker = TrackingService(TRACKING_DB_PATH)
set_tracker(tracker)
```

Dans `weather_service.py` / `normals_service.py` :

```python
from src.tracking_service import get_tracker, get_current_search_id
```

Si `get_tracker()` retourne `None`, aucun tracking n'est effectué (zéro overhead). Cela permet de désactiver le tracking en ne configurant simplement pas le service (utile pour les tests).

### 3.6 Instrumentation de `WeatherService`

Modifications dans `src/weather_service.py`, méthode `get_weather()` :

**Au niveau du cache hit** (après `cached = self.cache.get(cache_key)`) :

```python
if cached is not None:
    # --- TRACKING (conditionnel) ---
    tracker = get_tracker()
    search_id = get_current_search_id()
    if tracker and search_id:
        tracker.log_api_call(
            search_id=search_id,
            provider="open-meteo",
            endpoint=OPEN_METEO_ARCHIVE_URL,
            params_summary=f"lat={latitude:.2f},lon={longitude:.2f},{start}/{end}",
            cache_key=cache_key,
            cache_status="hit",
            status_code=None,
            duration_ms=0,
            success=True,
            error_message=None,
        )
    # --- FIN TRACKING ---
    return cached
```

**Au niveau de l'appel HTTP** (avant/après `self.client.get(…)`) :

```python
import time

_api_start = time.monotonic()
try:
    response = await self.client.get(OPEN_METEO_ARCHIVE_URL, params=params)
    response.raise_for_status()
    payload = response.json()
    _api_duration = int((time.monotonic() - _api_start) * 1000)

    # --- TRACKING (conditionnel) ---
    tracker = get_tracker()
    search_id = get_current_search_id()
    if tracker and search_id:
        tracker.log_api_call(
            search_id=search_id,
            provider="open-meteo",
            endpoint=OPEN_METEO_ARCHIVE_URL,
            params_summary=f"lat={latitude:.2f},lon={longitude:.2f},{start}/{end}",
            cache_key=cache_key,
            cache_status="miss",
            status_code=response.status_code,
            duration_ms=_api_duration,
            success=True,
            error_message=None,
        )
    # --- FIN TRACKING ---

except (httpx.TimeoutException, httpx.HTTPError, ValueError) as exc:
    _api_duration = int((time.monotonic() - _api_start) * 1000)

    # --- TRACKING (conditionnel) ---
    tracker = get_tracker()
    search_id = get_current_search_id()
    if tracker and search_id:
        _status_code = getattr(getattr(exc, "response", None), "status_code", None)
        tracker.log_api_call(
            search_id=search_id,
            provider="open-meteo",
            endpoint=OPEN_METEO_ARCHIVE_URL,
            params_summary=f"lat={latitude:.2f},lon={longitude:.2f},{start}/{end}",
            cache_key=cache_key,
            cache_status="miss",
            status_code=_status_code,
            duration_ms=_api_duration,
            success=False,
            error_message=str(exc),
        )
    # --- FIN TRACKING ---

    raise WeatherUpstreamError from exc
```

**Règle clé** : le code de tracking est toujours à l'intérieur d'un `if tracker and search_id:` — zéro overhead si le tracking n'est pas configuré. Et `tracker.log_api_call()` est lui-même protégé par un `try/except` interne (voir §3.3).

### 3.7 Instrumentation de `NormalsService`

Même approche que §3.6, appliquée de manière analogue dans `normals_service.py` :

- Dans `get_normals()` : tracking du cache hit/miss au niveau du cache en mémoire
- Dans `get_annual_normals()` : idem

Le tracking se fait **au niveau du point d'entrée** (`get_normals` / `get_annual_normals`), pas à chaque chunk individuel de `_fetch_reference_normals()`. Un seul `api_call_log` par appel à `get_normals()`, avec la durée totale.

**Justification** : le détail par chunk est inutile pour le besoin d'observabilité bêta. L'information pertinente est « cette recherche a nécessité un recalcul des normales (cache miss, 500ms) » vs « les normales étaient en cache (hit, 0ms) ».

### 3.8 Instrumentation des routes de recherche dans `main.py`

Pour chaque route de recherche, ajouter un bloc d'instrumentation qui :

1. Génère un `search_id`
2. Enregistre le début de la recherche (`create_search`)
3. Exécute la logique existante (inchangée)
4. Met à jour le search_log avec le résultat (`complete_search`)

**Routes instrumentées** :

| Route                                         | `search_type`  | `source` | Communes extraites de                |
| --------------------------------------------- | -------------- | -------- | ------------------------------------ |
| `GET /api/weather`                            | `"period"`     | `"api"`  | Optionnel (params `commune`, `slug`) |
| `GET /meteo/{slug}/{start}/{end}`             | `"period"`     | `"seo"`  | Slug URL                             |
| `GET /meteo/{slug}/{year}/{month}`            | `"month"`      | `"seo"`  | Slug URL                             |
| `GET /comparaison/{s1}/vs/{s2}/{start}/{end}` | `"comparison"` | `"seo"`  | Les 2 slugs URL                      |
| `GET /ville/{slug}`                           | `"town"`       | `"seo"`  | Slug URL                             |

**Patron d'instrumentation (pseudo-code, commun à toutes les routes)** :

```python
import time
from uuid import uuid4
from src.tracking_service import get_tracker, set_current_search_id

# Au début du handler
search_id = str(uuid4())
set_current_search_id(search_id)
_start = time.monotonic()

tracker = get_tracker()
if tracker:
    tracker.create_search(
        search_id=search_id,
        environment=ENVIRONMENT,
        source="seo",  # ou "api"
        search_type="period",  # selon la route
        commune_1=commune_name,
        commune_1_slug=slug_dept,
        # ... autres champs
    )

try:
    # --- logique existante de la route (INCHANGÉE) ---
    result = ...

    if tracker:
        duration_ms = int((time.monotonic() - _start) * 1000)
        tracker.complete_search(
            search_id=search_id,
            status="success",
            total_api_calls=...,  # compté via api_call_logs
            duration_ms=duration_ms,
        )
    return result

except Exception as exc:
    if tracker:
        duration_ms = int((time.monotonic() - _start) * 1000)
        tracker.complete_search(
            search_id=search_id,
            status="error",
            total_api_calls=...,
            duration_ms=duration_ms,
            error_message=str(exc),
        )
    raise  # re-raise — le handling d'erreur existant gère la réponse
finally:
    set_current_search_id(None)  # nettoyage du contexte
```

**Pour `GET /api/weather`** : ajouter des query params optionnels `commune` et `slug` (string, défaut `None`). Si présents, ils sont enregistrés dans le search_log. S'ils sont absents, `commune_1` et `commune_1_slug` restent `NULL`. Cela permet au frontend d'enrichir le tracking dans une future itération sans casser l'API existante.

**Note** : `total_api_calls` est calculé en comptant les `api_call_logs` avec le même `search_id`. Deux approches possibles :

- (A) Incrémenter un compteur dans le handler — simple mais fragile
- (B) Calculer via `SELECT COUNT(*) FROM api_call_logs WHERE search_id = ?` au moment du `complete_search`

**Recommandation** : approche (B) — plus fiable, le coût d'un COUNT sur quelques dizaines de lignes est négligeable.

### 3.9 Authentification admin : HTTP Basic

**Mécanisme** :

```python
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import secrets

security = HTTPBasic(auto_error=False)

def verify_admin(credentials: HTTPBasicCredentials | None = Depends(security)) -> bool:
    if not ADMIN_PASSWORD:
        raise HTTPException(status_code=503, detail="Admin non configuré")
    if credentials is None:
        raise HTTPException(
            status_code=401,
            detail="Authentification requise",
            headers={"WWW-Authenticate": 'Basic realm="HistoMeteo Admin"'},
        )
    password_ok = secrets.compare_digest(credentials.password, ADMIN_PASSWORD)
    username_ok = secrets.compare_digest(credentials.username, ADMIN_USERNAME)
    if not (password_ok and username_ok):
        raise HTTPException(
            status_code=401,
            detail="Identifiants invalides",
            headers={"WWW-Authenticate": 'Basic realm="HistoMeteo Admin"'},
        )
    return True
```

**Configuration** (dans `config.py`) :

```python
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")  # vide = admin désactivé
```

**Choix HTTP Basic** (plutôt qu'une page de login avec cookie) :

- Zéro dépendance supplémentaire
- Zéro code frontend pour l'authentification (le navigateur gère la modale nativement)
- Suffisant pour une phase bêta
- Compatible avec les appels API depuis curl/Postman/scripts

### 3.10 Routes admin API

Toutes les routes admin sont regroupées dans un `APIRouter` avec le préfixe `/admin` et la dépendance `verify_admin` :

```python
from fastapi import APIRouter

admin_router = APIRouter(
    prefix="/admin",
    dependencies=[Depends(verify_admin)],
)
```

**Routes définies** :

| Route                      | Méthode | Description                         | Réponse                               |
| -------------------------- | ------- | ----------------------------------- | ------------------------------------- |
| `/admin/`                  | GET     | Page HTML admin (index)             | `text/html` + `X-Robots-Tag: noindex` |
| `/admin/admin.js`          | GET     | Script JS de l'interface admin      | `application/javascript`              |
| `/admin/api/searches`      | GET     | Liste paginée des recherches        | JSON                                  |
| `/admin/api/searches/{id}` | GET     | Détail d'une recherche + appels API | JSON                                  |
| `/admin/api/dashboard`     | GET     | Indicateurs synthétiques            | JSON                                  |

**`GET /admin/api/searches`** — Query params :

| Param         | Type   | Défaut   | Description                                    |
| ------------- | ------ | -------- | ---------------------------------------------- |
| `limit`       | int    | 50       | Nombre de résultats (max 200)                  |
| `offset`      | int    | 0        | Décalage pour la pagination                    |
| `status`      | string | (tous)   | Filtre par statut (`success`, `error`)         |
| `environment` | string | (tous)   | Filtre par environnement                       |
| `commune`     | string | (tous)   | Recherche partielle sur commune_1_slug         |
| `sort`        | string | `"date"` | Champ de tri (`date`, `duration`, `api_calls`) |
| `order`       | string | `"desc"` | Ordre de tri (`asc`, `desc`)                   |

Réponse :

```json
{
  "searches": [
    {
      "id": "uuid",
      "created_at": "2026-03-14T10:30:00Z",
      "environment": "beta",
      "source": "seo",
      "search_type": "period",
      "commune_1": "Gap",
      "commune_1_slug": "gap-05",
      "commune_2": null,
      "commune_2_slug": null,
      "start_date": "2026-03-01",
      "end_date": "2026-03-07",
      "status": "success",
      "total_api_calls": 2,
      "duration_ms": 340
    }
  ],
  "total": 142,
  "limit": 50,
  "offset": 0
}
```

**`GET /admin/api/searches/{id}`** — Réponse :

```json
{
  "search": {
    "id": "uuid",
    "created_at": "2026-03-14T10:30:00Z",
    "environment": "beta",
    "source": "seo",
    "search_type": "period",
    "commune_1": "Gap",
    "commune_1_slug": "gap-05",
    "commune_2": null,
    "commune_2_slug": null,
    "latitude": 44.56,
    "longitude": 6.08,
    "start_date": "2026-03-01",
    "end_date": "2026-03-07",
    "status": "success",
    "total_api_calls": 2,
    "duration_ms": 340,
    "error_message": null
  },
  "api_calls": [
    {
      "id": "uuid",
      "created_at": "2026-03-14T10:30:00Z",
      "provider": "open-meteo",
      "endpoint": "archive-api.open-meteo.com/v1/archive",
      "cache_key": "weather:44.56:6.08:2026-03-01:2026-03-07",
      "cache_status": "miss",
      "status_code": 200,
      "duration_ms": 280,
      "success": true,
      "error_message": null
    },
    {
      "id": "uuid",
      "created_at": "2026-03-14T10:30:01Z",
      "provider": "open-meteo",
      "endpoint": "archive-api.open-meteo.com/v1/archive",
      "cache_key": "normals:44.56:6.08",
      "cache_status": "hit",
      "status_code": null,
      "duration_ms": 0,
      "success": true,
      "error_message": null
    }
  ]
}
```

**`GET /admin/api/dashboard`** — Query params :

| Param  | Type   | Défaut        | Description     |
| ------ | ------ | ------------- | --------------- |
| `date` | string | (aujourd'hui) | Date YYYY-MM-DD |

Réponse :

```json
{
  "date": "2026-03-14",
  "searches_today": 42,
  "api_calls_today": 87,
  "cache_hit_ratio": 0.62,
  "error_rate": 0.05,
  "top_communes": [
    { "slug": "paris-75", "commune": "Paris", "count": 12 },
    { "slug": "lyon-69", "commune": "Lyon", "count": 8 },
    { "slug": "gap-05", "commune": "Gap", "count": 5 }
  ],
  "most_expensive_searches": [
    {
      "id": "uuid",
      "commune_1": "Gap",
      "start_date": "2026-03-01",
      "end_date": "2026-03-07",
      "total_api_calls": 5,
      "duration_ms": 1200
    },
    {
      "id": "uuid",
      "commune_1": "Lyon",
      "start_date": "2025-01-01",
      "end_date": "2025-01-31",
      "total_api_calls": 4,
      "duration_ms": 980
    }
  ]
}
```

### 3.11 Interface admin frontend

Les fichiers admin sont stockés dans le dossier `admin/` à la racine du projet (PAS dans `public/` — ils ne doivent pas être servis par le montage statique).

Les routes `/admin/` et `/admin/admin.js` lisent ces fichiers depuis le disque et les servent après vérification de l'authentification.

#### `admin/index.html`

Page unique avec 3 vues gérées par JavaScript (navigation par onglets/liens) :

**Vue 1 — Recherches récentes** (vue par défaut) :

- Tableau avec colonnes : Date, Commune, Période, Type, Statut, Appels API, Durée
- Barre de filtres : select environnement, select statut, input recherche commune
- Tri cliquable sur les en-têtes de colonnes
- Pagination (boutons Précédent/Suivant)
- Clic sur une ligne → navigation vers la vue Détail

**Vue 2 — Détail d'une recherche** :

- Bloc résumé : tous les champs du search_log
- Tableau des appels API associés : Provider, Endpoint, Cache, HTTP Code, Durée, Statut
- Bouton retour vers la liste

**Vue 3 — Synthèse** :

- Indicateurs en blocs : Recherches aujourd'hui, Appels API, Cache hit ratio, Taux d'erreur
- Tableau : Top communes (commune, nombre de recherches)
- Tableau : Recherches les plus coûteuses (commune, période, appels API, durée)

**Stack frontend** :

- HTML sémantique, pas de framework JS
- Vanilla JavaScript (fetch API pour les appels aux routes admin)
- CSS inline ou `<style>` embarqué dans le HTML (pas de fichier CSS séparé)
- Design minimaliste : tableaux HTML natifs, couleurs sobre, responsive basique

**Choix de ne pas réutiliser `style.css`** : l'admin est un outil interne, pas une extension du produit. Découpler le style évite toute régression visuelle sur le site public.

### 3.12 Cycle de vie (lifespan)

Dans le `lifespan` de `main.py`, ajouter :

- **Au démarrage** : instancier `TrackingService(TRACKING_DB_PATH)`, appeler `set_tracker(tracker)`
- **À l'arrêt** : appeler `tracker.close()` pour fermer proprement la connexion SQLite

```python
@asynccontextmanager
async def lifespan(_app: FastAPI):
    global _html_template
    _html_template = index_file.read_text(encoding="utf-8")

    # Tracking init
    tracker = TrackingService(TRACKING_DB_PATH)
    set_tracker(tracker)

    yield

    # Cleanup
    tracker.close()
    await commune_service.client.aclose()
    await weather_service.client.aclose()
    await normals_service.client.aclose()
```

### 3.13 Configuration ajoutée dans `config.py`

```python
# Admin
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "")

# Tracking
TRACKING_DB_PATH = DATA_DIR / "tracking.db"

# Environment
ENVIRONMENT = os.getenv("HISTOMETEO_ENV", "dev")
```

---

## 4) Plan d'implémentation

| Étape | Description                                                                                                                       | Fichiers                             | Testable                                                                         |
| ----- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------- |
| E1    | Ajouter les constantes de configuration (ADMIN_PASSWORD, TRACKING_DB_PATH, ENVIRONMENT, ADMIN_USERNAME)                           | `src/config.py`                      | Import sans erreur                                                               |
| E2    | Créer `TrackingService` : init SQLite, create_search, complete_search, log_api_call, contextvars, get/set tracker                 | `src/tracking_service.py`            | `pytest tests/test_tracking_service.py` — tables créées, CRUD fonctionne         |
| E3    | Ajouter les méthodes de lecture admin : list_searches, get_search_detail, get_dashboard                                           | `src/tracking_service.py`            | Tests unitaires sur données de test insérées                                     |
| E4    | Instrumenter `weather_service.py` : tracking conditionnel du cache hit/miss et des appels HTTP                                    | `src/weather_service.py`             | Tests existants passent (tracker=None → aucun effet) ; tests avec tracker mockés |
| E5    | Instrumenter `normals_service.py` : tracking conditionnel du cache hit/miss                                                       | `src/normals_service.py`             | Tests existants passent                                                          |
| E6    | Intégrer dans `main.py` : admin router (auth + routes API + serving HTML/JS), instrumentation des 5 routes de recherche, lifespan | `src/main.py`                        | `pytest tests/test_admin_api.py` — auth, endpoints, 503 sans password            |
| E7    | Créer l'interface admin HTML/JS                                                                                                   | `admin/index.html`, `admin/admin.js` | Test manuel dans le navigateur                                                   |

**Ordre strict** : E1 → E2 → E3 → E4 → E5 → E6 → E7

Chaque étape est autonome et testable. Les tests hérités (110) doivent passer après chaque étape.

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Ne jamais laisser une erreur de tracking remonter à l'utilisateur.** Chaque méthode d'écriture de `TrackingService` (`create_search`, `complete_search`, `log_api_call`) DOIT avoir un `try/except Exception` interne qui log l'erreur via `logger.warning()` et retourne silencieusement. C'est l'invariant le plus critique (INV-34).

2. **L'ordre du montage statique dans `main.py` est crucial.** Le `app.mount("/", StaticFiles(...))` est en dernière position et capture tout ce qui n'est pas matché. Le `app.include_router(admin_router)` DOIT être placé AVANT ce montage, sinon les routes admin seront interceptées par le StaticFiles.

3. **`check_same_thread=False` est obligatoire** sur la connexion SQLite. FastAPI utilise un pool de threads pour les handlers sync et l'event loop pour les handlers async. Sans cette option, SQLite lèvera `ProgrammingError`.

4. **`contextvars` et `asyncio`** : chaque `await` peut reprendre dans un thread différent, mais `contextvars` suit le contexte de la tâche async, PAS le thread. C'est le comportement attendu — ne pas utiliser `threading.local()`.

5. **Les fichiers admin sont dans `admin/`, pas dans `public/`.** Si un fichier admin est placé dans `public/`, il sera servi publiquement sans authentification par le montage StaticFiles.

6. **Ne pas stocker de données personnelles** dans les logs de tracking (INV-35). Pas d'IP, pas de User-Agent, pas de headers HTTP. Seules les données fonctionnelles (commune, dates, statut) sont enregistrées.

### Zones de dérive

- Ne pas transformer cet outil admin en dashboard full-featured. Pas de graphiques, pas de chartjs, pas d'exports CSV — tabulaire uniquement.
- Ne pas ajouter de tracking aux routes qui ne sont pas des recherches (`/api/communes`, `/api/og-image`, `/api/resolve`).
- Ne pas modifier le comportement des routes utilisateur (même pour "améliorer" le tracking).
- Ne pas ajouter d'authentification aux routes utilisateur existantes.

### Simplifications autorisées

- Le CSS de l'admin peut être entièrement inline dans le HTML (`<style>` tag).
- Les top communes du dashboard sont limités à 10 entrées.
- Les "recherches les plus coûteuses" sont limitées à 10 entrées.
- La pagination admin est basique (offset/limit), pas de curseur-based.
- Pas besoin de websocket ni de rafraîchissement automatique.

### Décisions explicitement interdites

- Ne pas utiliser de base PostgreSQL, MySQL ou autre serveur de BDD. SQLite uniquement.
- Ne pas ajouter de dépendance externe (pas de `aiosqlite`, pas de `sqlalchemy`, pas de `jinja2`).
- Ne pas modifier le schéma de cache existant (TTLCache, FileCache).
- Ne pas modifier `public/app.js` (le frontend utilisateur est hors scope).

---

## 6) Stratégie de tests

### Tests existants à préserver

Les 110 tests existants (≤ v20) doivent tous passer sans modification. L'instrumentation de tracking dans `weather_service.py` et `normals_service.py` ne doit avoir aucun effet quand le tracker n'est pas configuré (`get_tracker() → None`).

### Nouveaux tests unitaires — `tests/test_tracking_service.py`

| #   | Test                                 | Vérification                                                                                                |
| --- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
| T18 | `test_init_creates_tables`           | L'instanciation de `TrackingService` crée les tables `search_logs` et `api_call_logs` dans un DB temporaire |
| T19 | `test_create_and_complete_search`    | `create_search()` insère un enregistrement, `complete_search()` met à jour statut et durée                  |
| T20 | `test_log_api_call`                  | `log_api_call()` insère un enregistrement lié au search_id                                                  |
| T21 | `test_search_error_does_not_raise`   | `create_search()` avec une base corrompue/fermée ne lève pas d'exception (log warning)                      |
| T22 | `test_api_call_error_does_not_raise` | `log_api_call()` avec une base corrompue/fermée ne lève pas d'exception                                     |
| T23 | `test_list_searches_pagination`      | `list_searches(limit=10, offset=0)` retourne les 10 premières recherches, triées par date desc              |
| T24 | `test_list_searches_filter_status`   | `list_searches(status="error")` retourne uniquement les recherches avec status="error"                      |
| T25 | `test_list_searches_filter_commune`  | `list_searches(commune="gap")` retourne les recherches dont commune_1_slug contient "gap"                   |
| T26 | `test_get_search_detail`             | `get_search_detail(id)` retourne la recherche + ses api_call_logs liés                                      |
| T27 | `test_get_dashboard`                 | `get_dashboard()` retourne les indicateurs agrégés corrects pour le jour courant                            |
| T28 | `test_contextvars_isolation`         | Deux appels concurrents avec des search_id différents ne se mélangent pas                                   |

### Nouveaux tests d'intégration — `tests/test_admin_api.py`

| #   | Test                                   | Vérification                                                                           |
| --- | -------------------------------------- | -------------------------------------------------------------------------------------- |
| T29 | `test_admin_requires_auth`             | `GET /admin/` sans credentials retourne 401 avec header `WWW-Authenticate`             |
| T30 | `test_admin_wrong_password`            | `GET /admin/` avec mauvais mot de passe retourne 401                                   |
| T31 | `test_admin_no_password_configured`    | Sans variable `ADMIN_PASSWORD`, `GET /admin/` retourne 503                             |
| T32 | `test_admin_page_served`               | `GET /admin/` avec credentials valides retourne 200 et du HTML                         |
| T33 | `test_admin_js_served`                 | `GET /admin/admin.js` avec credentials retourne 200 et du JavaScript                   |
| T34 | `test_admin_searches_api`              | `GET /admin/api/searches` avec credentials retourne 200 et une liste JSON              |
| T35 | `test_admin_search_detail_api`         | `GET /admin/api/searches/{id}` avec credentials retourne le détail + appels API        |
| T36 | `test_admin_search_not_found`          | `GET /admin/api/searches/{invalid_id}` retourne 404                                    |
| T37 | `test_admin_dashboard_api`             | `GET /admin/api/dashboard` avec credentials retourne les indicateurs                   |
| T38 | `test_admin_noindex_header`            | Toutes les réponses admin contiennent `X-Robots-Tag: noindex`                          |
| T39 | `test_weather_search_creates_log`      | `GET /api/weather?lat=...&lon=...&start=...&end=...` crée une entrée dans search_logs  |
| T40 | `test_tracking_failure_does_not_break` | Avec une base SQLite corrompue, `GET /api/weather` retourne toujours les données météo |

### Total tests attendu

110 (hérités) + 23 nouveaux = **133 tests minimum**

---

## 7) Risques techniques

| #   | Risque                                                                           | Probabilité | Mitigation                                                                                                                                                                                                                   |
| --- | -------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R1  | L'instrumentation de tracking ralentit les requêtes utilisateur                  | Faible      | Les inserts SQLite sont < 1ms. Le tracking est conditionnel (`if tracker`). Mesurer le delta de latence dans les tests de performance.                                                                                       |
| R2  | Le fichier SQLite grossit indéfiniment                                           | Moyen       | Pour la bêta, acceptable (1000 recherches/jour × 0.5 KB ≈ 500 KB/jour). Ajouter une purge automatique (> 90 jours) comme amélioration future.                                                                                |
| R3  | Accès concurrent à SQLite depuis plusieurs workers (si déploiement multi-worker) | Moyen       | WAL mode supporte la concurrence lecture/écriture. Pour un déploiement multi-worker (uvicorn `--workers N`), chaque worker a sa propre connexion. SQLite gère le verrouillage fichier. Suffisant pour la bêta (1-2 workers). |

---

## Nouveaux Acceptance Criteria (AC31–AC42)

| AC   | Description                                                                                                                                               |
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AC31 | Chaque recherche via route SEO (`/meteo/…`, `/comparaison/…`, `/ville/…`) crée une entrée dans `search_logs` avec les champs requis.                      |
| AC32 | Chaque recherche via `GET /api/weather` crée une entrée dans `search_logs` avec au minimum les coordonnées, dates, statut et durée.                       |
| AC33 | Chaque appel HTTP vers Open-Meteo (weather ou normals) crée une entrée dans `api_call_logs` avec les champs requis.                                       |
| AC34 | Les entrées `api_call_logs` sont liées aux `search_logs` via le champ `search_id`.                                                                        |
| AC35 | Un cache hit (TTLCache en mémoire) est loggé dans `api_call_logs` avec `cache_status="hit"` et `status_code=NULL`.                                        |
| AC36 | L'interface admin est accessible à `/admin/` et protégée par HTTP Basic Auth. Le mot de passe est configuré via `ADMIN_PASSWORD`.                         |
| AC37 | Si `ADMIN_PASSWORD` n'est pas défini, les routes admin retournent HTTP 503.                                                                               |
| AC38 | L'écran « Recherches récentes » affiche un tableau triable par date, filtrable par statut, par environnement et par commune.                              |
| AC39 | L'écran « Détail d'une recherche » affiche les paramètres complets et la liste des appels API associés.                                                   |
| AC40 | L'écran « Synthèse » affiche les indicateurs : recherches/jour, appels API/jour, ratio cache hit/miss, taux d'erreur, top communes, recherches coûteuses. |
| AC41 | Le tracking n'ajoute pas plus de 5ms d'overhead par requête utilisateur.                                                                                  |
| AC42 | En cas d'erreur de tracking, la requête utilisateur n'est pas impactée.                                                                                   |
