Observability — OpenTelemetry, Prometheus, Loki, Grafana
Module lib :
shared/observability-jvm/— Kotlin lib pure intégrée dans chaque service Ktor en 1 ligne (Application.configureObservability(serviceName, serviceVersion)).ADR : ADR-035.
Infra :
infra/observability/— overlay docker-compose dev + Helm charts prod (Tempo, Prometheus, Loki, Grafana, Alertmanager, OTel Collector).Standards : OpenTelemetry Spec, W3C Trace Context, OpenMetrics, Google SRE golden signals, RED method, USE method.
Cette page fixe la spec d’ingénierie complète de l’observabilité VitaKYC. Un dev qui active un nouveau module doit pouvoir, sans question résiduelle :
- Brancher le service à la stack en 1 ligne (
configureObservability(...)) - Émettre traces, métriques, logs conformes aux conventions (cardinality, naming, attributs)
- Voir le service dans le dashboard Grafana automatiquement (provisionning JSON)
- Définir ses alertes en suivant le pattern golden signals + KPI métier
- Tester l’observabilité (tests d’intégration vérifient que les traces et métriques sortent)
1. Vue d’ensemble
Section intitulée « 1. Vue d’ensemble »Flow nominal d’une requête (ex. POST /v1/mrz/parse) :
- SDK Web envoie la requête avec un header
traceparent(W3C) — ou en génère un si absent mrz-svc(Ktor) reçoit, le plugin OTel extrait letraceIdet démarre un span server- Span attributes :
http.method,http.route,http.status_code,tenant.id,service.name - MDC enrichi :
traceId,spanId,tenantId,requestId→ toute ligne de log de cette requête est corrélée - Métriques Micrometer incrémentées :
http_server_requests_seconds{method,route,status}+vitakyc_mrz_parse_total{format,outcome} - Span fermé, exporté en OTLP gRPC vers Collector → Tempo
- Logs JSON envoyés sur stdout → collectés par Promtail/Fluentbit → Loki
Dans Grafana : le SRE clique sur une trace lente, voit les spans, clique sur un span → log Loki filtré par traceId, voit le contexte complet sans grep manuel.
2. Lib shared/observability-jvm
Section intitulée « 2. Lib shared/observability-jvm »2.1 Structure
Section intitulée « 2.1 Structure »shared/observability-jvm/├── build.gradle.kts└── src/ ├── main/kotlin/io/vitakyc/observability/ │ ├── Otel.kt // bootstrap OpenTelemetrySdk + tracer + meter │ ├── Conventions.kt // attribut keys, metric names standard │ ├── KtorObservabilityPlugin.kt // Ktor plugin: install() + extract traceparent + MDC │ ├── LoggingMdc.kt // helpers MDC propagation cross-coroutine │ ├── MetricsRegistry.kt // Micrometer + endpoint /metrics │ └── HealthRoutes.kt // /health, /ready, /metrics standardisés └── test/kotlin/io/vitakyc/observability/ ├── OtelTest.kt ├── KtorObservabilityTest.kt └── ConventionsTest.kt2.2 API publique
Section intitulée « 2.2 API publique »fun Application.configureObservability( serviceName: String, serviceVersion: String, deploymentEnvironment: String = System.getenv("DEPLOYMENT_ENV") ?: "dev", otlpEndpoint: String = System.getenv("OTLP_ENDPOINT") ?: "http://localhost:4317", samplingRatio: Double = (System.getenv("TRACE_SAMPLING_RATIO")?.toDoubleOrNull() ?: 0.05), extraResourceAttributes: Map<String, String> = emptyMap())Ce que la lib installe automatiquement :
| Composant | Effet |
|---|---|
| OpenTelemetrySdk + OTLP exporter | démarrage du tracer + meter, exporters OTLP gRPC vers Collector |
Plugin Ktor Tracing | extrait traceparent, démarre span server par requête, ferme à la response |
Plugin Ktor CallId (lié) | utilise X-Request-Id ou génère un UUID, propagé en MDC |
| MDC enrichment | traceId, spanId, tenantId (depuis X-Tenant-Id), requestId, userId (depuis JWT) |
| Micrometer composite registry | Prometheus registry + JVM metrics (memory, GC, threads, classloader) |
Routes /health, /ready, /metrics | exposition standardisée (à monter dans routing { observabilityRoutes() }) |
2.3 Exemple d’usage
Section intitulée « 2.3 Exemple d’usage »Application.kt d’un service (extrait mrz-svc après wire) :
fun Application.module() { configureObservability( serviceName = "mrz-svc", serviceVersion = BuildConfig.VERSION, ) configureSerialization() configureMonitoring() // existant : CallId, DefaultHeaders, CORS configureStatusPages() configureRouting()}Routes / metrics exposition :
fun Application.configureRouting() { routing { observabilityRoutes() // /health /ready /metrics — fournie par la lib mrzRoute() }}Span manuel pour une opération métier :
val tracer = GlobalOpenTelemetry.getTracer("io.vitakyc.mrz")val span = tracer.spanBuilder("mrz.parse") .setAttribute("mrz.format", format.name) .setAttribute(VitaKycAttrs.TENANT_ID, tenantId) .startSpan()try { span.makeCurrent().use { return MrzParser.parse(raw) }} catch (e: MrzError) { span.recordException(e) span.setStatus(StatusCode.ERROR, e.message ?: "MrzError") throw e} finally { span.end()}KPI métier custom :
val parsesTotal = Counter.builder("vitakyc_mrz_parse_total") .description("Total MRZ parse attempts") .tag("format", format.name) .tag("outcome", outcome) .register(meterRegistry)parsesTotal.increment()3. Conventions
Section intitulée « 3. Conventions »3.1 Resource attributes (obligatoires sur chaque service)
Section intitulée « 3.1 Resource attributes (obligatoires sur chaque service) »| Clé | Valeur | Source |
|---|---|---|
service.name | mrz-svc | argument configureObservability |
service.version | 0.1.0 | injecté au build (Gradle BuildConfig) |
service.namespace | vitakyc | constante lib |
service.instance.id | <podname>-<uuid> | HOSTNAME env ou UUID local |
deployment.environment | dev / staging / prod | env DEPLOYMENT_ENV |
vitakyc.module.kind | platform / module / shared | déduit du chemin Gradle |
3.2 Span attributes — naming
Section intitulée « 3.2 Span attributes — naming »Suivre la spec OTel Semantic Conventions :
| Domaine | Attributs |
|---|---|
| HTTP server | http.method, http.route, http.status_code, http.target, url.scheme, client.address |
| HTTP client | http.method, http.url, server.address, http.status_code |
| DB | db.system="postgresql", db.statement (rédacté), db.name |
| Messaging | messaging.system="kafka", messaging.destination.name, messaging.kafka.consumer.group |
| Custom VitaKYC | vitakyc.tenant.id, vitakyc.case.id, vitakyc.form.version, vitakyc.policy.version |
Constants Kotlin dans la lib (Conventions.kt) :
object VitaKycAttrs { val TENANT_ID = AttributeKey.stringKey("vitakyc.tenant.id") val CASE_ID = AttributeKey.stringKey("vitakyc.case.id") val FORM_VERSION = AttributeKey.stringKey("vitakyc.form.version") val POLICY_VERSION = AttributeKey.stringKey("vitakyc.policy.version") val MODULE_KIND = AttributeKey.stringKey("vitakyc.module.kind")}3.3 Metric naming
Section intitulée « 3.3 Metric naming »| Pattern | Exemple |
|---|---|
vitakyc_<domain>_<name>_<unit> | vitakyc_mrz_parse_total, vitakyc_form_publish_duration_seconds |
Histograms en _seconds (Prometheus convention) | vitakyc_http_server_requests_seconds |
Counters en _total | vitakyc_aml_alerts_emitted_total |
| Gauges sans suffix | vitakyc_kafka_consumer_lag |
3.4 Cardinality discipline
Section intitulée « 3.4 Cardinality discipline »Labels permis : tenant_id, service, route, method, status, outcome, format, region.
Labels interdits : tout identifiant haute cardinality (user_id, case_id, request_id, document_number, IP, email).
Règle de pouce : si la valeur a > 100 valeurs distinctes possibles dans la vie du système, elle ne va pas en label Prometheus — elle va en attribut de span (Tempo l’indexe).
3.5 Logs structurés
Section intitulée « 3.5 Logs structurés »Format JSON ligne (Logback LogstashEncoder), champs obligatoires :
{ "@timestamp": "2026-04-26T09:42:13.123Z", "level": "INFO", "logger": "io.vitakyc.mrz.routes.MrzRoute", "thread": "DefaultDispatcher-worker-3", "message": "MRZ parsed successfully", "service": "mrz-svc", "service_version": "0.1.0", "deployment_env": "dev", "trace_id": "a1b2c3d4e5f6071829304a5b6c7d8e9f", "span_id": "0102030405060708", "tenant_id": "TN-BANQUEX", "request_id": "550e8400-e29b-41d4-a716-446655440000", "user_id": "user-amine", "mrz_format": "TD3", "mrz_checksum_score": 1.0}Niveaux : ERROR / WARN / INFO / DEBUG. Pas de TRACE en prod.
Pas de PII dans les logs : pas de noms, pas d’emails, pas de numéros document. Si nécessaire pour debug, masquer (L***02C3 au lieu de L898902C3) et logger en DEBUG.
4. Stack infra (self-hosted)
Section intitulée « 4. Stack infra (self-hosted) »4.1 Composants
Section intitulée « 4.1 Composants »| Composant | Rôle | Image | Port | Volumes |
|---|---|---|---|---|
| otel-collector | receiver OTLP, batching, sampling tail-based, fan-out | otel/opentelemetry-collector-contrib:0.111.0 | 4317 (OTLP gRPC), 4318 (OTLP HTTP) | configmap |
| tempo | traces backend | grafana/tempo:2.6.0 | 3200 (HTTP), 9095 (gRPC) | local FS / S3 prod |
| prometheus | metrics backend, scraping | prom/prometheus:v2.55.0 | 9090 | local FS |
| loki | logs backend | grafana/loki:3.2.1 | 3100 | local FS / S3 prod |
| grafana | UI + dashboards + alerting | grafana/grafana:11.3.0 | 3000 | provisioning configmap |
| alertmanager | routes alertes (Slack, email, PagerDuty) | prom/alertmanager:v0.27.0 | 9093 | configmap |
| promtail (dev) ou Vector (prod) | shipper logs Docker → Loki | grafana/promtail:3.2.1 | — | docker.sock |
4.2 Topologie réseau
Section intitulée « 4.2 Topologie réseau »[ Service Ktor ] ├── stdout JSON ────────────► Promtail/Vector ──► Loki ├── /metrics scraped ◄──────── Prometheus └── OTLP gRPC :4317 ────────► OTel Collector ┬─► Tempo ├─► Prometheus (remote_write) └─► Loki (logs OTLP) │ Grafana (UI) │ Alertmanager4.3 OTel Collector — config minimale
Section intitulée « 4.3 OTel Collector — config minimale »receivers: otlp: protocols: grpc: { endpoint: 0.0.0.0:4317 } http: { endpoint: 0.0.0.0:4318 }
processors: batch: timeout: 5s send_batch_size: 512 tail_sampling: decision_wait: 10s policies: - { name: errors, type: status_code, status_code: { status_codes: [ERROR] } } - { name: slow, type: latency, latency: { threshold_ms: 500 } } - { name: random, type: probabilistic, probabilistic: { sampling_percentage: 5 } } resource: attributes: - { key: deployment.environment, action: upsert, from_attribute: deployment.environment }
exporters: otlp/tempo: endpoint: tempo:4317 tls: { insecure: true } prometheusremotewrite: endpoint: http://prometheus:9090/api/v1/write loki: endpoint: http://loki:3100/loki/api/v1/push
service: pipelines: traces: receivers: [otlp] processors: [tail_sampling, batch, resource] exporters: [otlp/tempo] metrics: receivers: [otlp] processors: [batch, resource] exporters: [prometheusremotewrite] logs: receivers: [otlp] processors: [batch, resource] exporters: [loki]4.4 docker-compose overlay
Section intitulée « 4.4 docker-compose overlay »infra/observability/docker-compose.observability.yml est un overlay — il s’utilise avec :
docker compose \ -f infra/docker-compose.dev.yml \ -f infra/observability/docker-compose.observability.yml \ up -dCela ajoute Tempo, Prometheus, Loki, Grafana, Alertmanager, OTel Collector au-dessus de la stack dev existante (Postgres, Keycloak, Kafka, OpenSearch, Vault, Temporal, MinIO).
5. Dashboards Grafana
Section intitulée « 5. Dashboards Grafana »Tous les dashboards sont versionnés JSON dans infra/observability/grafana/dashboards/ et provisionnés au démarrage Grafana.
5.1 Dashboards de référence
Section intitulée « 5.1 Dashboards de référence »| Dashboard | Cible |
|---|---|
| Service overview — Golden Signals | par service : latency p50/p95/p99, RPS, error rate, saturation |
| VitaKYC Tenant Health | par tenant : volume KYC, success rate, agent SLA, alerts |
| Tracing — Service Map | dépendances inter-services (auto Tempo) |
| JVM Health | mémoire, GC, threads par service |
| Kafka Health | consumer lag par topic + partition, producer rate |
| PostgreSQL Health | pool Hikari (active, idle, pending), query duration |
5.2 Dashboard Golden Signals — panneaux
Section intitulée « 5.2 Dashboard Golden Signals — panneaux »| Panneau | PromQL |
|---|---|
| RPS par route | sum by (route) (rate(http_server_requests_seconds_count{service="$svc"}[5m])) |
| p95 latency par route | histogram_quantile(0.95, sum by (route, le) (rate(http_server_requests_seconds_bucket{service="$svc"}[5m]))) |
| Error rate (5xx) | sum(rate(http_server_requests_seconds_count{service="$svc",status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count{service="$svc"}[5m])) |
| JVM heap | jvm_memory_used_bytes{service="$svc",area="heap"} |
| Pool DB saturation | hikaricp_connections_active{service="$svc"} / hikaricp_connections_max{service="$svc"} |
6. SLOs et alertes
Section intitulée « 6. SLOs et alertes »6.1 SLOs par service (V0)
Section intitulée « 6.1 SLOs par service (V0) »| Service | Availability | Latency p95 | Error budget mensuel |
|---|---|---|---|
auth-svc | 99,9 % | 200 ms | 43 min |
tenant-svc | 99,5 % | 300 ms | 3,6 h |
mrz-svc | 99,9 % | 100 ms | 43 min |
bio-svc | 99,5 % | 8 s (workflow complet) | 3,6 h |
tx-monitoring-svc | 99,9 % (streaming) | latence streaming p95 < 2 s | 43 min |
case-mgmt-svc | 99,5 % | 500 ms | 3,6 h |
6.2 Règles d’alerte (extrait infra/observability/prometheus-rules.yaml)
Section intitulée « 6.2 Règles d’alerte (extrait infra/observability/prometheus-rules.yaml) »groups: - name: vitakyc-slo rules: - alert: HighErrorRate expr: | sum by (service) (rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum by (service) (rate(http_server_requests_seconds_count[5m])) > 0.01 for: 5m labels: { severity: page } annotations: summary: "{{ $labels.service }} 5xx error rate > 1% (5 min)" runbook_url: "https://docs.vitakyc.io/operations/runbooks-on-call/"
- alert: SlowLatency expr: | histogram_quantile(0.95, sum by (service, le) (rate(http_server_requests_seconds_bucket[5m])) ) > 0.5 for: 10m labels: { severity: ticket } annotations: summary: "{{ $labels.service }} p95 latency > 500 ms (10 min)"
- alert: KafkaConsumerLag expr: kafka_consumer_lag_records > 5000 for: 10m labels: { severity: ticket } annotations: summary: "{{ $labels.consumer_group }} lag > 5k on {{ $labels.topic }}"
- alert: ServiceDown expr: up{job="vitakyc-services"} == 0 for: 2m labels: { severity: page } annotations: summary: "Service {{ $labels.instance }} is DOWN"6.3 Alertmanager routing
Section intitulée « 6.3 Alertmanager routing »route: receiver: default-slack group_by: [alertname, service] group_wait: 30s group_interval: 5m repeat_interval: 4h routes: - matchers: [severity="page"] receiver: oncall-pagerduty continue: true - matchers: [severity="ticket"] receiver: tickets-slack
receivers: - name: default-slack slack_configs: [{ api_url: "${SLACK_WEBHOOK}", channel: "#vita-alerts" }] - name: oncall-pagerduty pagerduty_configs: [{ service_key: "${PD_SERVICE_KEY}" }] - name: tickets-slack slack_configs: [{ api_url: "${SLACK_WEBHOOK}", channel: "#vita-tickets" }]7. Sécurité et privacy
Section intitulée « 7. Sécurité et privacy »- mTLS Collector ↔ services : en prod (cert-manager), pas en dev pour simplicité.
- Pas de body capture par défaut (HTTP/gRPC). Activable au flag par tenant + masquage PII automatique.
- Pas de PII en label Prometheus ni en attribut Tempo (cf §3.4). Validation CI : un test parcourt les métriques exposées et vérifie l’absence de patterns interdits.
- Pas de PII en log : champs interdits (
email,phone,name,dob,document_number). Logger automatique masque si détecté (logback filter custom). - Retention Loki : 30 j chaud + 90 j froid S3 chiffré. Logs avec
tenant_idfiltré pour droit à l’oubli RGPD si demande DSAR. - Tracing exemplars : un exemplar lie une métrique à une trace ; il contient le
traceIdmais pas de PII. - Audit accès Grafana : journalisé. Permissions par dossier (
tenant-Xne voit que dashboardstenant-X).
8. Performance et capacité
Section intitulée « 8. Performance et capacité »| Mesure | Cible |
|---|---|
| Overhead CPU instrumentation OTel + Micrometer | ≤ 3 % |
| Overhead latence p95 | ≤ 5 ms |
| Heap supplémentaire | ≤ 50 MB |
| Volume traces (sampled @ 5%) | ≈ 100 KB / req médiane → 5 GB / 100 RPS / jour |
| Volume logs structurés JSON | ≈ 2 KB / req → 17 GB / 100 RPS / jour |
| Volume métriques Prometheus | ≈ 200 séries / service → 50 MB / 30 j |
9. Tests d’observabilité
Section intitulée « 9. Tests d’observabilité »Chaque service doit avoir au moins ces tests d’intégration (dans src/test/) :
@Testfun `service exposes Prometheus metrics endpoint`() { testApplication { application { module() } val response = client.get("/metrics") assertThat(response.status).isEqualTo(HttpStatusCode.OK) assertThat(response.bodyAsText()).contains("jvm_memory_used_bytes") }}
@Testfun `health and ready endpoints respond`() { testApplication { application { module() } assertThat(client.get("/health").status).isEqualTo(HttpStatusCode.OK) assertThat(client.get("/ready").status).isEqualTo(HttpStatusCode.OK) }}
@Testfun `traceparent header is propagated`() { testApplication { application { module() } val response = client.get("/health") { header("traceparent", "00-a1b2c3d4e5f6071829304a5b6c7d8e9f-0102030405060708-01") } assertThat(response.status).isEqualTo(HttpStatusCode.OK) // Span émis avec le traceId reçu — vérifié via in-memory exporter }}10. Adoption par module — checklist
Section intitulée « 10. Adoption par module — checklist »Pour chaque service activé dans le monorepo :
-
build.gradle.kts: ajouterimplementation(project(":shared:observability-jvm")) -
Application.kt: ajouterconfigureObservability(serviceName, serviceVersion)au début du module -
configureRouting(): ajouterobservabilityRoutes()dansrouting { ... } -
logback.xml: utiliserLogstashEncoder(déjà dans la lib via classpath) - Spans manuels : ajouter pour les opérations métier critiques (ex
mrz.parse,bio.verify) - KPI métier : ajouter au moins 1 counter ou histogram custom (ex
vitakyc_mrz_parse_total) - Tests d’intégration : 3 tests minimum (cf §9)
- Dashboard Grafana : importer
service-overview.jsondepuis le repo - Règles d’alerte : ajouter au minimum
HighErrorRate+SlowLatencyàprometheus-rules.yaml - Runbook on-call : 1 page par alerte page-able (
severity=page)
11. Migration MVP → V2
Section intitulée « 11. Migration MVP → V2 »| Item | MVP (V0) | V2 (S+12) |
|---|---|---|
| Tracing backend | Tempo single-binary | Tempo distributed (S3) |
| Metrics backend | Prometheus single-node 30j | Mimir/Thanos long-term storage |
| Logs backend | Loki single-binary 30j | Loki distributed avec cold storage S3 |
| Sampling | tail-based 5 % + erreurs/slow | dynamic sampling par tenant |
| Alerting | Alertmanager + Slack | + PagerDuty + intégration ServiceNow |
| eBPF (network observability) | non | Cilium Hubble en option |
| Profiling continu | non | Pyroscope (Grafana) intégré |
| Anomaly detection | non | Grafana Cloud ML / Prometheus adaptive thresholds |
12. Références
Section intitulée « 12. Références »- ADR-035
- Monorepo VitaKYC — où la lib
observability-jvms’insère - Standards : OpenTelemetry Spec, Semantic Conventions, W3C Trace Context, OpenMetrics, Google SRE Book — Monitoring
- Helm charts : grafana/loki-stack, grafana/tempo-distributed, prometheus-community/kube-prometheus-stack
Spec observability — version 1.0 (2026-04-26). Mises à jour bloquantes nécessitent un ADR.