# 001 — HistoMeteo MVP — Spec Technique v23

> **Source fonctionnelle** : `001-histometeo-mvp.md`
> **Base technique** : `001-histometeo-mvp.tech.v22.md`
> **Feedback intégré** : `feedback-to-architect-001-v22.md` (validé ✅ — R1 scope README, R2 tracking routes SPA)
> **Date** : 2026-03-14

---

## 0) Contract

- **Source of truth** : cette spec technique (`001-histometeo-mvp.tech.v23.md`)
- **Functional integrity** : AC1–AC46 inchangés. Nouveaux AC47–AC51.
- **Scope** — fichiers modifiables :
  - `src/tracking_service.py` (migration schéma : `search_id` nullable, signature `log_api_call`, dashboard query ajustée)
  - `src/weather_service.py` (changement du guard de tracking : `if tracker:` au lieu de `if tracker and search_id:`)
  - `src/normals_service.py` (idem dans `_fetch_chunk`)
  - `src/commune_service.py` (idem dans `search_communes`)
  - `admin/admin.js` (affichage compteurs attribués/standalone dans le dashboard)
  - `tests/test_tracking_service.py` (ajout tests tracking standalone)
  - `tests/test_admin_api.py` (ajout tests routes SPA, dashboard standalone)
  - `README.md` (mise à jour documentation — régularisation R1 v22)
- **Forbidden changes** :
  - `public/app.js`, `public/index.html`, `public/style.css` — aucune modification
  - `src/cache.py`, `src/prefetch_service.py`, `src/og_service.py`, `src/main.py` — aucune modification
  - `src/config.py` — aucune modification
  - `admin/index.html` — aucune modification
  - Tous les fichiers de tests existants hérités (≤ v22, 143 tests) — ne pas modifier sémantiquement, tous doivent continuer à passer
  - Le comportement observable des routes utilisateur (API + SEO + homepage) ne change pas
- **Invariants** :
  - INV-1 à INV-43 : tous préservés (cf. v22)
  - INV-44 (MODIFIÉ) : l'instrumentation des services suit la règle `if tracker:` — le tracking est effectué dès que le tracker est disponible, que `search_id` soit défini ou non. Jamais de propagation d'exception (le `try/except` dans `log_api_call()` absorbe les erreurs — INV-34).
  - INV-45 (NOUVEAU) : `search_id` dans `api_call_logs` peut être `NULL`. Un `search_id = NULL` signifie que l'appel API a été effectué hors contexte de recherche trackée (routes SPA, crawlers sociaux).
  - INV-46 (NOUVEAU) : `_count_api_calls(search_id)` ne compte que les entrées avec ce `search_id` spécifique (non-NULL). Les entrées avec `search_id = NULL` ne sont jamais comptées dans le `total_api_calls` d'une recherche.
  - INV-47 (NOUVEAU) : le dashboard `api_calls_today` agrège TOUTES les entrées de `api_call_logs` du jour, avec ou sans `search_id`.
- **Done when** :
  - D33–D66 : acquis (v22 validée)
  - D67 : un appel `GET /api/normals` en cache miss produit 3 entrées dans `api_call_logs` avec `search_id = NULL` et `service = "normals"`
  - D68 : un appel `GET /api/normals/annual` en cache miss produit 3 entrées dans `api_call_logs` avec `search_id = NULL` et `service = "normals"`
  - D69 : un appel `GET /api/og-image/{slug}/{start}/{end}` en cache miss produit des entrées dans `api_call_logs` avec `search_id = NULL` pour chaque appel HTTP effectué (weather et/ou communes)
  - D70 : le dashboard `api_calls_today` compte l'intégralité des appels API du jour (avec et sans `search_id`)
  - D71 : `_count_api_calls(search_id)` exclut les entrées avec `search_id = NULL` (SQL : `NULL != ?` → non-match naturel)
  - D72 : le dashboard retourne `attributed` et `standalone` dans `api_calls_today`
  - D73 : les 143 tests existants continuent à passer

---

## 1) Objectif technique

**Delta par rapport à v22** : combler l'angle mort de tracking identifié par la review v22 (recommandation R2).

**Problème** : les 4 routes SPA (`/api/normals`, `/api/normals/annual`, `/api/resolve/{slug}`, `/api/og-image/`) effectuent des appels HTTP vers des APIs externes sans les logger dans `api_call_logs`, car elles n'ont pas de contexte de recherche (`search_id`). Le guard `if tracker and search_id:` dans les services bloque le logging.

**Conséquence mesurée** : le dashboard sous-estime la consommation réelle du quota Open-Meteo d'environ **75%** pour les recherches SPA avec normales (3 appels normals non comptés sur 4 appels total).

| Route SPA non trackée                    | API externe appelée          | Appels HTTP / requête | Risque quota |
| ---------------------------------------- | ---------------------------- | --------------------- | ------------ |
| `GET /api/normals`                       | Open-Meteo (×3 chunks)       | 3                     | **Élevé**    |
| `GET /api/normals/annual`                | Open-Meteo (×3 chunks)       | 3                     | **Élevé**    |
| `GET /api/resolve/{slug}`                | geo.api.gouv.fr              | 1                     | Faible       |
| `GET /api/og-image/{slug}/{start}/{end}` | geo.api.gouv.fr + Open-Meteo | 1–2                   | Moyen        |

**Solution** : rendre le tracking des appels API indépendant du contexte de recherche. Le guard passe de `if tracker and search_id:` à `if tracker:`. Un appel HTTP est logué dès que le tracker est disponible, avec `search_id = NULL` quand aucun contexte de recherche n'est actif.

Secondairement, régulariser le scope du `README.md` (recommandation R1 v22).

---

## 2) Analyse du brief

### Besoins principaux (nouveaux / modifiés)

| #   | Besoin                                                                                                                             | Origine                  |
| --- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| B43 | Tout appel HTTP vers une API externe doit être comptabilisé dans `api_call_logs`, que l'appel provienne d'une route trackée ou non | Feedback reviewer R2 v22 |
| B44 | Le dashboard doit refléter la consommation TOTALE de quota, pas seulement les appels dans un contexte de recherche                 | Feedback reviewer R2 v22 |
| B45 | Régulariser le scope du `README.md`                                                                                                | Feedback reviewer R1 v22 |

### Contraintes

- Zéro nouvelle dépendance externe (inchangé)
- `src/main.py` est hors scope — le changement doit être effectué dans les services eux-mêmes (guard `if tracker:`)
- `public/app.js` est hors scope — aucun changement frontend utilisateur
- Migration SQLite : `search_id` doit devenir nullable sans perte de données existantes

### Risques

Voir §7.

---

## 3) Design minimal proposé

### 3.1 Changement de schéma : `search_id` nullable dans `api_call_logs`

**Avant (v22)** : `search_id TEXT NOT NULL` dans le `CREATE TABLE`.

**Après (v23)** : `search_id TEXT` (nullable) dans le `CREATE TABLE`.

**Migration des bases existantes** : SQLite ne supporte pas `ALTER COLUMN`. Pour les bases v22, la table doit être recréée.

```python
def _migrate_search_id_nullable(self):
    """Migration v22 → v23 : rendre search_id nullable dans api_call_logs."""
    pragma = self.conn.execute("PRAGMA table_info(api_call_logs)").fetchall()
    for col in pragma:
        if col["name"] == "search_id" and col["notnull"]:
            self.conn.executescript("""
                CREATE TABLE api_call_logs_v23 (
                    id TEXT PRIMARY KEY,
                    search_id TEXT,
                    created_at TEXT NOT NULL,
                    service TEXT NOT NULL DEFAULT 'weather',
                    provider TEXT NOT NULL,
                    endpoint TEXT NOT NULL,
                    params_summary TEXT,
                    cache_key TEXT,
                    cache_status TEXT NOT NULL,
                    status_code INTEGER,
                    duration_ms INTEGER NOT NULL,
                    success INTEGER NOT NULL,
                    error_message TEXT
                );
                INSERT INTO api_call_logs_v23 SELECT * FROM api_call_logs;
                DROP TABLE api_call_logs;
                ALTER TABLE api_call_logs_v23 RENAME TO api_call_logs;
                CREATE INDEX IF NOT EXISTS idx_api_search_id ON api_call_logs(search_id);
                CREATE INDEX IF NOT EXISTS idx_api_service ON api_call_logs(service);
            """)
            break
```

**Placement** : dans `_init_db()`, après les migrations v22 existantes (ajout colonne `service`).

**Détection** : `PRAGMA table_info` retourne les métadonnées de chaque colonne, dont le flag `notnull`. Si `search_id` a `notnull = 1`, la migration s'exécute. Sinon (base v23 neuve), rien ne se passe.

### 3.2 Modification de `log_api_call()` : `search_id` optionnel

**Avant (v22)** :

```python
def log_api_call(self, *, search_id: str, service: str, ...):
```

**Après (v23)** :

```python
def log_api_call(self, *, search_id: str | None, service: str, ...):
```

L'INSERT SQL reste identique — SQLite accepte `NULL` pour une colonne `TEXT` nullable.

**Aucun autre changement dans le corps de la méthode.**

### 3.3 Changement du guard dans les trois services

Ce changement est identique dans les trois fichiers. La modification est mécanique : retirer `and search_id` du guard.

**Avant (v22)** — pattern répété dans `weather_service.py`, `normals_service.py` (`_fetch_chunk`), `commune_service.py` (`search_communes`) :

```python
tracker = get_tracker()
search_id = get_current_search_id()
# ...
if tracker and search_id:
    tracker.log_api_call(search_id=search_id, ...)
```

**Après (v23)** :

```python
tracker = get_tracker()
search_id = get_current_search_id()
# ...
if tracker:
    tracker.log_api_call(search_id=search_id, ...)
```

Le paramètre `search_id` est passé tel quel : c'est la valeur retournée par `get_current_search_id()`, qui est soit une string (contexte de recherche actif), soit `None` (hors contexte).

#### Détail par fichier

**`src/weather_service.py`** — 2 occurrences du guard à modifier :

1. Bloc success (après `response.raise_for_status()`) : `if tracker and search_id:` → `if tracker:`
2. Bloc except (erreur HTTP) : `if tracker and search_id:` → `if tracker:`

**`src/normals_service.py`** — 2 occurrences dans `_fetch_chunk()` :

1. Bloc success : `if tracker and search_id:` → `if tracker:`
2. Bloc except : `if tracker and search_id:` → `if tracker:`

**`src/commune_service.py`** — 2 occurrences dans `search_communes()` :

1. Bloc success : `if tracker and search_id:` → `if tracker:`
2. Bloc except : `if tracker and search_id:` → `if tracker:`

**Total** : 6 modifications mécaniques (retrait de `and search_id`).

#### Impact sur les routes

| Route                             | Avant v23                                       | Après v23                    |
| --------------------------------- | ----------------------------------------------- | ---------------------------- |
| `GET /api/weather`                | ✅ Tracké (`search_id` défini dans `main.py`)   | ✅ Tracké (inchangé)         |
| `GET /api/normals`                | ❌ Non tracké (`search_id=None` → guard bloque) | ✅ Tracké (`search_id=NULL`) |
| `GET /api/normals/annual`         | ❌ Non tracké                                   | ✅ Tracké (`search_id=NULL`) |
| `GET /api/resolve/{slug}`         | ❌ Non tracké                                   | ✅ Tracké (`search_id=NULL`) |
| `GET /api/og-image/…`             | ❌ Non tracké                                   | ✅ Tracké (`search_id=NULL`) |
| `GET /api/communes?q=…`           | ❌ Non tracké                                   | ✅ Tracké (`search_id=NULL`) |
| Routes SEO (`/ville/`, `/meteo/`) | ✅ Tracké (`search_id` défini)                  | ✅ Tracké (inchangé)         |

**Note sur `GET /api/communes?q=…`** (autocomplete) : cette route fait aussi des appels HTTP à `geo.api.gouv.fr`. Avec le changement de guard, ces appels seront désormais loggés (avec `search_id=NULL`). C'est un effet de bord acceptable :

- Le cache `communes:query` limite la volumétrie (seuls les cache miss produisent des entrées)
- La visibilité totale sur les appels API est préférable à un tracking partiel
- Le dashboard ventilé (weather/normals/communes) permet d'isoler par service
- `geo.api.gouv.fr` n'a pas de quota strict

### 3.4 Dashboard : comptage total et distinction attribué/standalone

La requête v22 pour `api_calls_today` ne filtre pas par `search_id` — elle compte déjà toutes les entrées. Pas de changement structurel nécessaire pour le total et la ventilation par service.

**Ajout** : deux compteurs supplémentaires pour distinguer les appels rattachés à une recherche des appels standalone :

```json
{
  "api_calls_today": {
    "total": 120,
    "by_service": {
      "weather": 50,
      "normals": 45,
      "communes": 25
    },
    "attributed": 87,
    "standalone": 33
  }
}
```

**Requête SQL complémentaire** :

```sql
SELECT
    COUNT(*) AS total,
    SUM(CASE WHEN search_id IS NOT NULL THEN 1 ELSE 0 END) AS attributed,
    SUM(CASE WHEN search_id IS NULL THEN 1 ELSE 0 END) AS standalone
FROM api_call_logs
WHERE substr(created_at, 1, 10) = ? AND cache_status = 'miss'
```

Cette requête peut être fusionnée avec la requête existante (ventilation par service) ou exécutée séparément — au choix du développeur.

### 3.5 Admin JS : affichage des compteurs attribués/standalone

Dans `admin/admin.js`, la section dashboard ajoute une ligne sous les cards de ventilation par service :

```
Appels API aujourd'hui
┌──────┬─────────┬─────────┬──────────┐
│ Total│ Weather │ Normals │ Communes │
│  120 │    50   │    45   │    25    │
├──────┴─────────┴─────────┴──────────┤
│  87 attribués  │  33 non-attribués  │
└────────────────┴────────────────────┘
```

Utiliser `escapeHtml()` (existant v22) sur toutes les valeurs injectées via `innerHTML`.

### 3.6 `_count_api_calls()` : inchangé

La méthode `_count_api_calls(search_id)` reste identique :

```python
def _count_api_calls(self, search_id: str) -> int:
    row = self.conn.execute(
        "SELECT COUNT(*) AS c FROM api_call_logs WHERE search_id = ? AND cache_status = 'miss'",
        (search_id,),
    ).fetchone()
    return int(row["c"]) if row else 0
```

Les entrées avec `search_id = NULL` ne matchent pas (`NULL != ?` en SQL) — elles sont naturellement exclues. Le `total_api_calls` dans `search_logs` continue de ne compter que les appels rattachés à la recherche spécifique.

---

## 4) Plan d'implémentation

| Étape | Description                                                                                                                   | Fichiers                                                    | Testable                                                                |
| ----- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------- |
| E1    | Migration schéma : `search_id` nullable dans `api_call_logs` (PRAGMA + recreate table)                                        | `src/tracking_service.py`                                   | Test migration sur DB existante ; les 143 tests existants passent       |
| E2    | Modifier `log_api_call()` : `search_id` devient `str \| None`                                                                 | `src/tracking_service.py`                                   | Test insertion avec `search_id=None`                                    |
| E3    | Modifier le guard dans `weather_service.py` : `if tracker:` au lieu de `if tracker and search_id:` (2 occurrences)            | `src/weather_service.py`                                    | Test que `get_weather()` sans contexte produit 1 entrée `api_call_logs` |
| E4    | Modifier le guard dans `normals_service.py` (`_fetch_chunk`) : idem (2 occurrences)                                           | `src/normals_service.py`                                    | Test que `_fetch_chunk()` sans contexte produit 1 entrée                |
| E5    | Modifier le guard dans `commune_service.py` (`search_communes`) : idem (2 occurrences)                                        | `src/commune_service.py`                                    | Test que `search_communes()` sans contexte produit 1 entrée             |
| E6    | Modifier dashboard dans `tracking_service.py` : ajout compteurs `attributed` / `standalone`. Affichage dans `admin/admin.js`. | `src/tracking_service.py`, `admin/admin.js`                 | Test dashboard structure JSON                                           |
| E7    | Ajout tests tracking standalone et comptage total                                                                             | `tests/test_tracking_service.py`, `tests/test_admin_api.py` | Tous les tests passent (143 hérités + 9 nouveaux)                       |

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

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

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Tous les pièges de v22 restent valides** (INV-34, ordre de montage, `check_same_thread=False`, `contextvars` vs `threading.local`, fichiers admin pas dans `public/`, `escapeHtml`, pas de données personnelles).

2. **`PRAGMA table_info` retourne des Row objects** : vérifier que l'accès par nom (`col["notnull"]`) fonctionne avec `row_factory = sqlite3.Row`. En cas de doute, utiliser l'index positionnel (`col[3]` pour `notnull` — c'est la 4ème colonne du PRAGMA, index 3).

3. **La migration `executescript()` auto-commit en SQLite** : les transactions ne sont pas gérées explicitement dans `executescript()`. Si le processus est interrompu entre le `DROP TABLE` et le `RENAME`, la table est perdue. En phase beta, c'est acceptable (données non critiques). Mais ne pas exécuter cette migration en production sans sauvegarde.

4. **`NULL` ≠ chaîne vide** : ne pas confondre `search_id = None` (appel hors contexte, correct) avec `search_id = ""` (erreur). Le code doit passer `None` explicitement via `get_current_search_id()`. Ne jamais hardcoder `""`.

5. **Tests existants appellent `log_api_call(search_id="some-id", ...)` avec des strings non-NULL** — ils continuent à fonctionner sans changement. La seule modification de signature est le type hint (`str` → `str | None`).

6. **`resolve_slug()` appelle `search_communes()` en interne** → le tracking est hérité. Ne pas ajouter de tracking supplémentaire dans `resolve_slug()` sous peine de double-comptage.

### Zones de dérive

- **Ne pas ajouter de logique dans `main.py`** pour créer des search contexts dans les routes SPA. Le tracking standalone (`search_id = NULL`) est la solution. `main.py` est hors scope.
- **Ne pas filtrer par `search_id IS NOT NULL`** dans les requêtes dashboard — le but est de compter TOUS les appels pour la mesure de quota.
- **Ne pas ajouter de tracking dans `og_service.py`** — les appels HTTP sont effectués par `weather_service` et `commune_service`, qui sont déjà instrumentés. `og_service.py` est hors scope.
- **Ne pas créer de table séparée** pour les appels standalone — `NULL` dans `search_id` est suffisant.
- **Ne pas ajouter de colonnes** (pas de `route`, `user_agent`, etc.) — hors scope.

### Simplifications autorisées

- La migration PRAGMA + recreate est suffisante (pas besoin de table de versions de schéma).
- Les compteurs `attributed` / `standalone` sont des entiers simples dans le JSON — pas de ratio supplémentaire.
- Pas besoin de test E2E pour la route `og-image` — les tests unitaires des services couvrent le cas.

### Décisions explicitement interdites

- Ne pas rendre `search_id` obligatoire à nouveau dans une future version sans migration inverse.
- Ne pas modifier le calcul de `cache_hit_ratio` — il reste basé sur `search_logs` (cf. v22).
- Ne pas modifier les routes dans `main.py` — aucune modification du routage ni du setup de contexte.

---

## 6) Stratégie de tests

### Tests existants à préserver

Les 143 tests hérités (≤ v22) doivent tous passer. La migration `search_id` nullable est transparente pour les tests existants (ils passent tous des `search_id` non-NULL). Le changement de type hint (`str` → `str | None`) ne casse pas les appels existants.

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

| #   | Test                                           | Vérification                                                                                                                                                       |
| --- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| T51 | `test_log_api_call_with_null_search_id`        | `log_api_call(search_id=None, service="weather", ...)` insère une entrée avec `search_id = NULL` en base.                                                          |
| T52 | `test_count_api_calls_excludes_null_search_id` | Insérer des entrées avec `search_id = NULL` et `search_id = "abc"`. Vérifier que `_count_api_calls("abc")` ne compte que les entrées `"abc"`.                      |
| T53 | `test_dashboard_counts_standalone_calls`       | Insérer des entrées avec et sans `search_id`. `get_dashboard()` retourne un total incluant les deux types.                                                         |
| T54 | `test_dashboard_attributed_vs_standalone`      | `get_dashboard()` retourne `attributed` et `standalone` dans `api_calls_today`. Vérifier les valeurs.                                                              |
| T55 | `test_migration_search_id_nullable`            | Créer une DB avec l'ancien schéma (`search_id TEXT NOT NULL`), instancier `TrackingService` → la migration permet l'insertion avec `search_id = NULL` sans erreur. |

### Nouveaux tests — `tests/test_admin_api.py`

| #   | Test                                                     | Vérification                                                                                                                |
| --- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| T56 | `test_normals_api_tracks_without_search_context`         | `GET /api/normals` en cache miss produit 3 entrées `api_call_logs` avec `search_id = NULL` et `service = "normals"`.        |
| T57 | `test_annual_normals_api_tracks_without_search_context`  | `GET /api/normals/annual` en cache miss produit 3 entrées `api_call_logs` avec `search_id = NULL` et `service = "normals"`. |
| T58 | `test_weather_in_og_image_tracks_without_search_context` | `GET /api/og-image/{slug}/{start}/{end}` en cache miss produit au moins 1 entrée `api_call_logs` avec `search_id = NULL`.   |
| T59 | `test_dashboard_api_includes_standalone`                 | `GET /admin/api/dashboard` retourne `api_calls_today.standalone` avec le nombre d'appels sans `search_id`.                  |

### Total tests attendu

143 (hérités v22) + 9 nouveaux (T51–T59) = **152 tests minimum**

---

## 7) Risques techniques

| #   | Risque                                                                                 | Probabilité | Mitigation                                                                                                                                                                                                                                                                                    |
| --- | -------------------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| R7  | La migration `DROP TABLE` + `RENAME` perd les index existants                          | Faible      | Les index `idx_api_search_id` et `idx_api_service` sont recréés explicitement dans le script de migration. Le test T55 vérifie la migration complète.                                                                                                                                         |
| R8  | La volumétrie des `api_call_logs` augmente (appels autocomplete communes + routes SPA) | Faible      | Les appels autocomplete sont filtrés par le cache communes (seuls les cache miss sont loggés). Volume estimé : +10–20 entrées/jour pour autocomplete, +30–50 pour normals SPA. Négligeable vs les 500+ appels existants. La colonne `service` permet de monitorer et d'ajuster si nécessaire. |
| R9  | `PRAGMA table_info` ne fonctionne pas comme attendu avec `row_factory = sqlite3.Row`   | Très faible | Tester explicitement dans T55. Alternative : accéder par index positionnel (`col[3]` pour `notnull`).                                                                                                                                                                                         |

---

## Acceptance Criteria mis à jour

### AC modifiés (par rapport à v22)

Aucun AC existant n'est modifié.

### Nouveaux AC

| AC   | Description                                                                                                                                                                                                                                                             |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| AC47 | Tout appel HTTP réel vers une API externe (Open-Meteo weather, Open-Meteo normals, geo.api.gouv.fr communes) est enregistré dans `api_call_logs`, que l'appel soit effectué dans un contexte de recherche (`search_id` non-NULL) ou hors contexte (`search_id = NULL`). |
| AC48 | `GET /api/normals` en cache miss produit 3 entrées dans `api_call_logs` avec `service = "normals"` et `search_id = NULL`.                                                                                                                                               |
| AC49 | `GET /api/normals/annual` en cache miss produit 3 entrées dans `api_call_logs` avec `service = "normals"` et `search_id = NULL`.                                                                                                                                        |
| AC50 | `GET /api/og-image/{slug}/{start}/{end}` en cache miss produit des entrées dans `api_call_logs` pour chaque appel HTTP réel (weather et/ou communes) avec `search_id = NULL`.                                                                                           |
| AC51 | Le dashboard `GET /admin/api/dashboard` retourne dans `api_calls_today` les compteurs `attributed` (appels rattachés à une recherche) et `standalone` (appels hors contexte) en plus du `total` et de la ventilation `by_service`.                                      |
