Implemente e evolua a observabilidade deste projeto com OpenTelemetry, Prometheus e Grafana. Use este skill ao adicionar métricas, traces, logs estruturados, health checks com dependências, ou ao configurar o stack local de observabilidade. Cobre o caminho incremental desde o estado atual (MDC + logs de texto) até observabilidade completa (logs JSON → métricas Prometheus → traces distribuídos → alertas Grafana).
"Observability is not about collecting data — it's about being able to ask new questions of your system without deploying new code." — Charity Majors
"OpenTelemetry is a collection of APIs, SDKs, and tools. Use it to instrument, generate, collect, and export telemetry data (metrics, logs, and traces) to help you analyze your software's performance and behavior." — opentelemetry.io
Observability.kt
├── RequestIdPlugin → gera/propaga X-Request-ID
└── CallLogging → loga método, path, status com MDC (requestId)
logback.xml
└── Appender STDOUT → texto: "%d{HH:mm:ss} [%thread] %-5level [rid=%X{requestId}] - %msg%n"
O que falta:
/metrics com métricas de aplicação (GAP-D)trace_id / span_id correlacionados nos logs (GAP-O)┌──────────────────────────────────────────────────────────┐
│ Camada 4 — Alertas (Grafana Alerting / Alertmanager) │
├──────────────────────────────────────────────────────────┤
│ Camada 3 — Traces (OpenTelemetry → Grafana Tempo) │
├──────────────────────────────────────────────────────────┤
│ Camada 2 — Métricas (Micrometer → Prometheus → Grafana) │
├──────────────────────────────────────────────────────────┤
│ Camada 1 — Logs JSON (Logback → Grafana Loki) │
└──────────────────────────────────────────────────────────┘
Abordagem incremental: cada camada é independente e entrega valor imediato. Implemente nessa ordem; não salte etapas.
Registros discretos de eventos. O pilar mais maduro do projeto — já existe com
requestId no MDC. A evolução é trocar o formato de texto por JSON estruturado.
Quando usar: debug de erros específicos, auditoria de operações, contexto de negócio que não cabe em métricas.
Medidas numéricas agregadas ao longo do tempo. Permitem dashboards, SLOs e alertas. São o sinal mais barato operacionalmente (série temporal compacta).
Quando usar: monitoramento contínuo de latência, throughput, taxas de erro, uso de recursos.
Rastreamento do caminho completo de uma requisição através de múltiplos componentes. Cada span registra uma operação com duração, atributos e erros.
Quando usar: diagnóstico de latência, identificar qual serviço/operação está lento, correlacionar comportamento entre componentes distribuídos.
Metadados propagados junto com o contexto de trace entre serviços (ex: tenantId,
userId). Diferente de atributos de span — baggage flui automaticamente sem ser
repassado manualmente.
Escolher o tipo correto é crítico para a semântica e a queryabilidade.
Valor que só aumenta (ou reinicia em zero no restart). Jamais use para valores que podem diminuir.
# Exemplos corretos
http_requests_total{method="POST", path="/api/v1/boards", status="201"}
simulation_days_executed_total{scenario_id="..."}
domain_errors_total{type="ValidationError"}
Valor que sobe e desce livremente. Use para snapshots do estado atual.
# Exemplos corretos
db_connection_pool_active{pool="hikari"}
jvm_memory_used_bytes{area="heap"}
simulation_wip_count{scenario_id="..."}
Amostra observações em buckets configurados. Ideal para latência e tamanhos.
Expõe _bucket, _sum, _count. Use histogram_quantile() no PromQL.
# Exemplos corretos
http_request_duration_seconds_bucket{le="0.05", path="/api/v1/scenarios/.../run"}
http_request_duration_seconds_sum
http_request_duration_seconds_count
simulation_day_duration_seconds_bucket{le="0.1"}
Similar ao Histogram, mas calcula quantis no cliente (não no servidor). Prefira Histogram quando puder — permite agregação entre instâncias. Use Summary apenas quando os buckets de Histogram não são conhecidos antecipadamente.
Logs em texto livre (%msg%n) requerem parsing com regex no Loki/CloudWatch/Datadog.
Logs JSON são parseados nativamente, permitem filtros por campo (level, traceId,
requestId) e correlação automática com traces.
// http_api/build.gradle.kts
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
<!-- http_api/src/main/resources/logback.xml -->
<configuration>
<!-- Appender para desenvolvimento: texto legível -->
<appender name="STDOUT_TEXT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} [rid=%X{requestId}] [tid=%X{traceId}] - %msg%n</pattern>
</encoder>
</appender>
<!-- Appender para produção: JSON estruturado -->
<appender name="STDOUT_JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeContext>false</includeContext>
<includeMdcKeyName>requestId</includeMdcKeyName>
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<customFields>{"service":"kanban-vision-api","version":"${APP_VERSION:-local}"}</customFields>
</encoder>
</appender>
<!-- Seleciona appender via variável de ambiente LOG_FORMAT=json -->
<if condition='property("LOG_FORMAT").equals("json")'>
<then>
<root level="INFO">
<appender-ref ref="STDOUT_JSON"/>
</root>
</then>
<else>
<root level="INFO">
<appender-ref ref="STDOUT_TEXT"/>
</root>
</else>
</if>
<logger name="io.ktor" level="INFO"/>
<logger name="com.kanbanvision" level="DEBUG"/>
</configuration>
{
"@timestamp": "2026-03-16T14:23:11.432Z",
"level": "INFO",
"logger_name": "com.kanbanvision.usecases.board.CreateBoardUseCase",
"message": "Board created: id=abc-123 name=Meu Board duration=12ms",
"service": "kanban-vision-api",
"requestId": "req-uuid-here",
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"spanId": "00f067aa0ba902b7"
}
Os campos traceId e spanId são preenchidos automaticamente pelo OTel Log Bridge
(Camada 3) — os logs ficam correlacionados aos traces sem código adicional.
O Ktor tem plugin nativo para Micrometer (ktor-server-metrics-micrometer) que
instrumenta automaticamente todas as rotas HTTP (latência, contagem, status codes).
Micrometer pode exportar para múltiplos backends — Prometheus, OTel, Datadog — via
registry. É o bridge perfeito para este stack.
// http_api/build.gradle.kts
implementation("io.ktor:ktor-server-metrics-micrometer-jvm:3.1.2")
implementation("io.micrometer:micrometer-registry-prometheus:1.14.4")
implementation("io.micrometer:micrometer-core:1.14.4")
// http_api/src/main/kotlin/com/kanbanvision/httpapi/plugins/Metrics.kt
package com.kanbanvision.httpapi.plugins
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.metrics.micrometer.MicrometerMetrics
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics
import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics
import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics
import io.micrometer.core.instrument.binder.system.ProcessorMetrics
import io.micrometer.prometheusregistry.PrometheusConfig
import io.micrometer.prometheusregistry.PrometheusMeterRegistry
fun Application.configureMetrics(): MeterRegistry {
val registry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT).apply {
config().commonTags(
"service", "kanban-vision-api",
"version", System.getenv("APP_VERSION") ?: "local",
)
}
// Métricas automáticas de JVM e sistema
JvmMemoryMetrics().bindTo(registry)
JvmGcMetrics().bindTo(registry)
JvmThreadMetrics().bindTo(registry)
ProcessorMetrics().bindTo(registry)
// Plugin Ktor: instrumenta TODAS as rotas HTTP automaticamente
install(MicrometerMetrics) {
this.registry = registry
// distribui latência em buckets de 1ms a 10s
distributionStatisticConfig {
percentilesHistogram = true
}
}
// Endpoint /metrics para scraping do Prometheus
routing {
get("/metrics") {
call.respond(registry.scrape())
}
}
return registry
}
// http_api/src/main/kotlin/com/kanbanvision/httpapi/plugins/DomainMetrics.kt
package com.kanbanvision.httpapi.plugins
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import java.util.concurrent.TimeUnit
class DomainMetrics(private val registry: MeterRegistry) {
// Counter: total de simulações executadas
fun recordSimulationDayExecuted(scenarioId: String) {
registry.counter(
"kanban.simulation.days.executed.total",
"scenario_id", scenarioId,
).increment()
}
// Histogram: duração de um dia de simulação
fun recordSimulationDayDuration(durationMs: Long) {
registry.timer("kanban.simulation.day.duration")
.record(durationMs, TimeUnit.MILLISECONDS)
}
// Counter: erros de domínio por tipo
fun recordDomainError(errorType: String) {
registry.counter(
"kanban.domain.errors.total",
"type", errorType,
).increment()
}
// Gauge: WIP atual de um cenário (snapshot)
fun recordWipCount(scenarioId: String, count: Int) {
registry.gauge(
"kanban.scenario.wip.current",
listOf(
io.micrometer.core.instrument.Tag.of("scenario_id", scenarioId),
),
count,
)
}
}
# Latência de requisições HTTP (histogram)
http_server_requests_seconds_bucket{method="POST",status="201",uri="/api/v1/boards",le="0.05"}
http_server_requests_seconds_sum{method="POST",status="201",uri="/api/v1/boards"}
http_server_requests_seconds_count{method="POST",status="201",uri="/api/v1/boards"}
# JVM
jvm_memory_used_bytes{area="heap",id="G1 Eden Space"}
jvm_gc_pause_seconds_count{action="end of minor GC",cause="G1 Evacuation Pause"}
jvm_threads_live_threads
# Sistema
system_cpu_usage
process_cpu_usage
O health check atual retorna sempre 200 OK sem verificar o banco. Isso é um
falso positivo — o load balancer considera o serviço saudável mesmo se o PostgreSQL
estiver fora.
// http_api/src/main/kotlin/com/kanbanvision/httpapi/routes/HealthRoutes.kt
package com.kanbanvision.httpapi.routes
import com.kanbanvision.persistence.DatabaseFactory
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
fun Route.healthRoutes() {
// Liveness: o processo está vivo?
get("/health/live") {
call.respond(HttpStatusCode.OK, mapOf("status" to "UP"))
}
// Readiness: o serviço está pronto para receber tráfego?
get("/health/ready") {
val dbStatus = runCatching {
DatabaseFactory.dataSource.connection.use { conn ->
conn.prepareStatement("SELECT 1").executeQuery().next()
}
"UP"
}.getOrElse { "DOWN: ${it.message}" }
val status = if (dbStatus == "UP") HttpStatusCode.OK else HttpStatusCode.ServiceUnavailable
call.respond(
status,
mapOf(
"status" to if (dbStatus == "UP") "UP" else "DOWN",
"checks" to mapOf("database" to dbStatus),
),
)
}
// Mantém /health para compatibilidade retroativa
get("/health") {
call.respond(HttpStatusCode.OK, mapOf("status" to "UP"))
}
}
Dois endpoints separados é a convenção Kubernetes:
/health/live → liveness probe: se falhar, reinicia o pod/health/ready → readiness probe: se falhar, remove do load balancer sem reiniciarO OpenTelemetry Java Agent instrumenta automaticamente o Ktor (Netty), JDBC, HikariCP, Koin e Logback sem alterar o código da aplicação. É o caminho com melhor relação custo/benefício para este projeto.
O que o agente instrumenta automaticamente:
traceparent header)trace_id e span_id no MDC do Logback automaticamente# docker-compose.yml ou Kubernetes env
JAVA_TOOL_OPTIONS="-javaagent:/opt/opentelemetry-javaagent.jar"
# Exportador: envia para Grafana Alloy / OTel Collector
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
# Identificação do serviço (aparece em todos os spans)
OTEL_SERVICE_NAME=kanban-vision-api
OTEL_RESOURCE_ATTRIBUTES=service.version=1.0.0,deployment.environment=production
# Sampling: 100% em dev, ajustar em prod (ex: 0.1 = 10%)
OTEL_TRACES_SAMPLER=parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG=1.0
# Métricas via OTel (alternativa ao Micrometer/Prometheus)
OTEL_METRICS_EXPORTER=none # desabilitar se usando Micrometer
OTEL_LOGS_EXPORTER=none # desabilitar se usando logstash-logback-encoder
# http_api/Dockerfile
FROM eclipse-temurin:21-jre AS runtime
# Download do agente (fixar versão para builds reproduzíveis)
ARG OTEL_AGENT_VERSION=2.12.0
ADD https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v${OTEL_AGENT_VERSION}/opentelemetry-javaagent.jar \
/opt/opentelemetry-javaagent.jar
WORKDIR /app
COPY build/libs/kanban-vision-api.jar app.jar
ENV JAVA_TOOL_OPTIONS="-javaagent:/opt/opentelemetry-javaagent.jar"
ENV LOG_FORMAT=json
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
O agente cobre HTTP e JDBC. Para lógica de domínio relevante (ex: execução de simulação), adicione spans manuais:
// Dependência adicional para instrumentação manual
// implementation("io.opentelemetry:opentelemetry-api:1.47.0")
import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.api.trace.StatusCode
class RunDayUseCase(
private val scenarioRepository: ScenarioRepository,
private val snapshotRepository: SnapshotRepository,
) {
private val tracer = GlobalOpenTelemetry.getTracer("com.kanbanvision.usecases")
suspend fun execute(command: RunDayCommand): Either<DomainError, DailySnapshot> {
val span = tracer.spanBuilder("simulation.runDay")
.setAttribute("scenario.id", command.scenarioId.value)
.setAttribute("simulation.day", command.day.value.toLong())
.startSpan()
return try {
span.makeCurrent().use {
either {
// lógica existente sem alteração
val scenario = scenarioRepository.findById(command.scenarioId).bind()
val state = scenarioRepository.findState(command.scenarioId).bind()
val result = SimulationEngine.runDay(scenario.id, state, emptyList(), state.currentDay.value.toLong())
span.setAttribute("simulation.wip_count", result.snapshot.metrics.wipCount.toLong())
span.setAttribute("simulation.throughput", result.snapshot.metrics.throughput.toLong())
snapshotRepository.save(result.snapshot).bind()
result.snapshot
}.also { either ->
either.fold(
ifLeft = { error ->
span.setStatus(StatusCode.ERROR, error.toString())
},
ifRight = { span.setStatus(StatusCode.OK) },
)
}
}
} finally {
span.end()
}
}
}
Stack completo para desenvolvimento e homologação, usando o Grafana OSS.
# docker-compose.observability.yml