Aller au contenu

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 :

  1. Brancher le service à la stack en 1 ligne (configureObservability(...))
  2. Émettre traces, métriques, logs conformes aux conventions (cardinality, naming, attributs)
  3. Voir le service dans le dashboard Grafana automatiquement (provisionning JSON)
  4. Définir ses alertes en suivant le pattern golden signals + KPI métier
  5. Tester l’observabilité (tests d’intégration vérifient que les traces et métriques sortent)

Flow nominal d’une requête (ex. POST /v1/mrz/parse) :

  1. SDK Web envoie la requête avec un header traceparent (W3C) — ou en génère un si absent
  2. mrz-svc (Ktor) reçoit, le plugin OTel extrait le traceId et démarre un span server
  3. Span attributes : http.method, http.route, http.status_code, tenant.id, service.name
  4. MDC enrichi : traceId, spanId, tenantId, requestId → toute ligne de log de cette requête est corrélée
  5. Métriques Micrometer incrémentées : http_server_requests_seconds{method,route,status} + vitakyc_mrz_parse_total{format,outcome}
  6. Span fermé, exporté en OTLP gRPC vers Collector → Tempo
  7. 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.


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.kt
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 :

ComposantEffet
OpenTelemetrySdk + OTLP exporterdémarrage du tracer + meter, exporters OTLP gRPC vers Collector
Plugin Ktor Tracingextrait 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 enrichmenttraceId, spanId, tenantId (depuis X-Tenant-Id), requestId, userId (depuis JWT)
Micrometer composite registryPrometheus registry + JVM metrics (memory, GC, threads, classloader)
Routes /health, /ready, /metricsexposition standardisée (à monter dans routing { observabilityRoutes() })

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.1 Resource attributes (obligatoires sur chaque service)

Section intitulée « 3.1 Resource attributes (obligatoires sur chaque service) »
CléValeurSource
service.namemrz-svcargument configureObservability
service.version0.1.0injecté au build (Gradle BuildConfig)
service.namespacevitakycconstante lib
service.instance.id<podname>-<uuid>HOSTNAME env ou UUID local
deployment.environmentdev / staging / prodenv DEPLOYMENT_ENV
vitakyc.module.kindplatform / module / shareddéduit du chemin Gradle

Suivre la spec OTel Semantic Conventions :

DomaineAttributs
HTTP serverhttp.method, http.route, http.status_code, http.target, url.scheme, client.address
HTTP clienthttp.method, http.url, server.address, http.status_code
DBdb.system="postgresql", db.statement (rédacté), db.name
Messagingmessaging.system="kafka", messaging.destination.name, messaging.kafka.consumer.group
Custom VitaKYCvitakyc.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")
}
PatternExemple
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 _totalvitakyc_aml_alerts_emitted_total
Gauges sans suffixvitakyc_kafka_consumer_lag

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).

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.


ComposantRôleImagePortVolumes
otel-collectorreceiver OTLP, batching, sampling tail-based, fan-outotel/opentelemetry-collector-contrib:0.111.04317 (OTLP gRPC), 4318 (OTLP HTTP)configmap
tempotraces backendgrafana/tempo:2.6.03200 (HTTP), 9095 (gRPC)local FS / S3 prod
prometheusmetrics backend, scrapingprom/prometheus:v2.55.09090local FS
lokilogs backendgrafana/loki:3.2.13100local FS / S3 prod
grafanaUI + dashboards + alertinggrafana/grafana:11.3.03000provisioning configmap
alertmanagerroutes alertes (Slack, email, PagerDuty)prom/alertmanager:v0.27.09093configmap
promtail (dev) ou Vector (prod)shipper logs Docker → Lokigrafana/promtail:3.2.1docker.sock
[ Service Ktor ]
├── stdout JSON ────────────► Promtail/Vector ──► Loki
├── /metrics scraped ◄──────── Prometheus
└── OTLP gRPC :4317 ────────► OTel Collector ┬─► Tempo
├─► Prometheus (remote_write)
└─► Loki (logs OTLP)
Grafana (UI)
Alertmanager
infra/observability/otel-collector.yaml
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]

infra/observability/docker-compose.observability.yml est un overlay — il s’utilise avec :

Fenêtre de terminal
docker compose \
-f infra/docker-compose.dev.yml \
-f infra/observability/docker-compose.observability.yml \
up -d

Cela ajoute Tempo, Prometheus, Loki, Grafana, Alertmanager, OTel Collector au-dessus de la stack dev existante (Postgres, Keycloak, Kafka, OpenSearch, Vault, Temporal, MinIO).


Tous les dashboards sont versionnés JSON dans infra/observability/grafana/dashboards/ et provisionnés au démarrage Grafana.

DashboardCible
Service overview — Golden Signalspar service : latency p50/p95/p99, RPS, error rate, saturation
VitaKYC Tenant Healthpar tenant : volume KYC, success rate, agent SLA, alerts
Tracing — Service Mapdépendances inter-services (auto Tempo)
JVM Healthmémoire, GC, threads par service
Kafka Healthconsumer lag par topic + partition, producer rate
PostgreSQL Healthpool Hikari (active, idle, pending), query duration
PanneauPromQL
RPS par routesum by (route) (rate(http_server_requests_seconds_count{service="$svc"}[5m]))
p95 latency par routehistogram_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 heapjvm_memory_used_bytes{service="$svc",area="heap"}
Pool DB saturationhikaricp_connections_active{service="$svc"} / hikaricp_connections_max{service="$svc"}

ServiceAvailabilityLatency p95Error budget mensuel
auth-svc99,9 %200 ms43 min
tenant-svc99,5 %300 ms3,6 h
mrz-svc99,9 %100 ms43 min
bio-svc99,5 %8 s (workflow complet)3,6 h
tx-monitoring-svc99,9 % (streaming)latence streaming p95 < 2 s43 min
case-mgmt-svc99,5 %500 ms3,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"
infra/observability/alertmanager.yaml
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" }]

  • 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_id filtré pour droit à l’oubli RGPD si demande DSAR.
  • Tracing exemplars : un exemplar lie une métrique à une trace ; il contient le traceId mais pas de PII.
  • Audit accès Grafana : journalisé. Permissions par dossier (tenant-X ne voit que dashboards tenant-X).

MesureCible
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

Chaque service doit avoir au moins ces tests d’intégration (dans src/test/) :

@Test
fun `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")
}
}
@Test
fun `health and ready endpoints respond`() {
testApplication {
application { module() }
assertThat(client.get("/health").status).isEqualTo(HttpStatusCode.OK)
assertThat(client.get("/ready").status).isEqualTo(HttpStatusCode.OK)
}
}
@Test
fun `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
}
}

Pour chaque service activé dans le monorepo :

  • build.gradle.kts : ajouter implementation(project(":shared:observability-jvm"))
  • Application.kt : ajouter configureObservability(serviceName, serviceVersion) au début du module
  • configureRouting() : ajouter observabilityRoutes() dans routing { ... }
  • logback.xml : utiliser LogstashEncoder (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.json depuis 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)

ItemMVP (V0)V2 (S+12)
Tracing backendTempo single-binaryTempo distributed (S3)
Metrics backendPrometheus single-node 30jMimir/Thanos long-term storage
Logs backendLoki single-binary 30jLoki distributed avec cold storage S3
Samplingtail-based 5 % + erreurs/slowdynamic sampling par tenant
AlertingAlertmanager + Slack+ PagerDuty + intégration ServiceNow
eBPF (network observability)nonCilium Hubble en option
Profiling continunonPyroscope (Grafana) intégré
Anomaly detectionnonGrafana Cloud ML / Prometheus adaptive thresholds


Spec observability — version 1.0 (2026-04-26). Mises à jour bloquantes nécessitent un ADR.