Especificação: Métricas OpenTelemetry a partir de Eventos Leves
:::info Status Documento de especificação para uma feature ainda não implementada. Descreve o design proposto e as superfícies de código a tocar, para servir de ponto de partida da próxima rodada de implementação — não documenta comportamento existente. :::
Objetivo
Publicar métricas de execução do motor (duração de nó, throughput, incidentes, retries, etc.) via um
MeterProvider OpenTelemetry, reaproveitando o canal de eventos leves já existente
(ExecutionEventListener/EventPublisher, kikwi-lightweight-events-api) — que
já é a recomendação explícita da doc de eventos para "dashboard de latência
por nó, sem garantias fortes".
Escopo é só métricas, entregues por OTLP — o próprio ponto do OpenTelemetry é ser neutro de fornecedor. A configuração de destino (endpoint/headers) é assunto de deploy, não desta spec: aponte para um Collector, um Agent, ou o intake de qualquer backend compatível com OTLP (Datadog, Grafana/Prometheus via OTel Collector, Honeycomb, New Relic, etc.) — nenhuma dessas integrações específicas faz parte do desenho aqui.
Correção de rumo em relação à primeira versão desta spec
A primeira versão deste documento propunha reaproveitar os próprios payloads de CriticalEvent
(FlowNodeFinished, ProcessInstanceStarted, etc. — os DTOs que hoje alimentam o outbox) fazendo-os também
implements LightweightEvent, publicados pelos dois canais a partir do mesmo objeto. Isso estava errado: trata
métrica e evento de auditoria como a mesma coisa, quando são artefatos com propósitos, formas e riscos
diferentes.
CriticalEventé auditoria — precisa dos dados brutos completos para reconstrução de histórico (ProcessInstanceFinished.variables(),errorStackTrace,businessKey, timestamps de início/fim separados). Alguns desses campos são livres/não-limitados (stack trace, valor de variável) — aceitável ali porque cada entrada é um registro individual endereçável por id, não uma dimensão de série temporal.- Métrica precisa do oposto: forma mínima, pré-agregada, com dimensões previsíveis e de cardinalidade limitada por construção — não por convenção documentada num comentário, mas pelo próprio shape do tipo.
Reaproveitar o mesmo objeto para os dois canais acopla a evolução de um ao outro (um campo novo em
ProcessInstanceFinished pensado para auditoria vira, sem querer, uma tag de métrica em produção) e criava
exatamente o risco de cardinalidade que a spec original só tentava mitigar por aviso (seção "nunca use
processInstanceId como tag"), quando o design correto é não dar ao tipo a possibilidade de carregar esse
campo.
Design corrigido: eventos leves de métrica são um catálogo de DTOs próprios, pequenos e pré-agregados,
que existem só neste canal — nunca implementam CriticalEvent, nunca compartilham classe com o payload do
outbox. Os dois catálogos continuam nascendo do mesmo fato de execução (ex.: um nó terminou), mas cada um decide
sua própria forma a partir dos dados brutos (FlowNodeExecutionSnapshot, Incident, ExecutableTask, etc.) —
não um a partir do outro.
Achado de codebase que continua válido: só existe 1 evento leve hoje
SyncContinuationFailed é o único LightweightEvent implementado — um evento de falha, não uma fonte de
métricas de performance. O grosso do trabalho desta feature é popular esse canal com um catálogo de métrica de
verdade, não só plugar um exporter nele.
Modelo: novo catálogo lightweight.metric
Pacote novo em kikwi-model, io.kikwiflow.model.event.lightweight.metric — cada tipo carrega só as dimensões
necessárias para tag de métrica mais o(s) valor(es) já pré-agregado(s) (duração já calculada como long, não um
par de Instant para o consumidor calcular):
public record FlowNodeExecutionMetric(
String processDefinitionKey, String flowNodeDefinitionId, String flowNodeType,
NodeExecutionStatus status, String errorType, long durationMs, Instant timestamp
) implements LightweightEvent {
@Override public Instant getTimestamp() { return timestamp; }
}
public record GatewayAnswerMetric(
String processDefinitionKey, String gatewayNodeId, AnswerProviderType answerProviderType,
String resolvedAnswer, Instant timestamp
) implements LightweightEvent { /* ... */ }
public record ProcessInstanceStartedMetric(String processDefinitionKey, String origin, Instant timestamp) implements LightweightEvent { /* ... */ }
public record ProcessInstanceFinishedMetric(String processDefinitionKey, ProcessInstanceStatus status, long durationMs, Instant timestamp) implements LightweightEvent { /* ... */ }
public record IncidentCreatedMetric(String processDefinitionKey, String taskDefinitionId, String type, Instant timestamp) implements LightweightEvent { /* ... */ }
public record IncidentResolvedMetric(String processDefinitionKey, String taskDefinitionId, Instant timestamp) implements LightweightEvent { /* ... */ }
public record RetryScheduledMetric(String processDefinitionKey, String taskDefinitionId, long executionsSoFar, long retriesLeft, Instant timestamp) implements LightweightEvent { /* ... */ }
public record ExternalTaskClaimedMetric(String processDefinitionKey, String taskDefinitionId, Instant timestamp) implements LightweightEvent { /* ... */ }
public record ExternalTaskUnclaimedMetric(String processDefinitionKey, String taskDefinitionId, Instant timestamp) implements LightweightEvent { /* ... */ }
public record ExternalTaskCompletedMetric(String processDefinitionKey, String taskDefinitionId, boolean assigneeMismatch, Instant timestamp) implements LightweightEvent { /* ... */ }
public record TimerFiredMetric(String processDefinitionKey, String flowNodeDefinitionId, Instant timestamp) implements LightweightEvent { /* ... */ }
Nenhum desses tipos tem campo para processInstanceId, externalTaskId, incidentId, businessKey,
actorId ou valor de variável — a restrição de cardinalidade fica garantida pelo compilador, não por
convenção. SyncContinuationFailed (já existente) ganha processDefinitionId/processDefinitionKey — mesmo
ajuste já identificado na primeira versão desta spec, continua necessário — mas fica como está fora isso; não
existe motivo pra reformular um tipo que já é lightweight-only desde sempre.
Quem constrói: ExecutionMetricsRecorder, novo e separado de CriticalEventRecorder
Um colaborador novo em kikwi-core (io.kikwiflow.execution.event), irmão de CriticalEventRecorder — mesma
ideia de "um único ponto de construção, gatilhado uma vez" —, mas sem nenhuma relação de código com ele:
não recebe List<OutboxEventEntity>, não conhece CriticalEventType, não é chamado condicionalmente a partir
do outro.
public class ExecutionMetricsRecorder {
private final KikwiflowConfig kikwiflowConfig;
private final EventPublisher eventPublisher;
public boolean isEnabled() { return kikwiflowConfig.isStatsEnabled(); }
public void recordFlowNodeExecution(FlowNodeExecutionSnapshot snapshot) {
if (!isEnabled()) return;
eventPublisher.publishEvent(new FlowNodeExecutionMetric(
snapshot.processDefinitionKey(), snapshot.flowNodeDefinitionId(), snapshot.flowNodeType(),
snapshot.nodeExecutionStatus(), snapshot.errorType(),
Duration.between(snapshot.startedAt(), snapshot.finishedAt()).toMillis(),
snapshot.finishedAt()));
}
// ... um record* por tipo do catálogo acima, mesmo padrão
}
Os dois recorders são chamados lado a lado, nos mesmos pontos onde CriticalEventRecorder já é chamado
hoje (ProcessExecutionManager, ContinuationService, FailureHandler, KikwiflowEngine) — cada um lendo o
mesmo dado bruto de execução e decidindo, de forma totalmente independente, o que fazer com ele. stats.enabled
continua sendo o único gatilho do lado leve, outbox.events-enabled o único gatilho do lado outbox — nenhum dos
dois enxerga o flag do outro.
Achado: processDefinitionKey nem sempre está à mão nos call sites atuais
Mesmo problema identificado na primeira versão: FailureHandler.handleFailure(ExecutableTask task, ...) recebe
só a ExecutableTask — que carrega processDefinitionId(), não processDefinitionKey(). Isso já acontece hoje
com tenantId, que também não está em ExecutableTask: o método recebe um parâmetro tenantId extra,
repassado pelo chamador (que já tem a ProcessInstance completa em mãos). A solução consistente é a mesma —
threadear processDefinitionKey como mais um parâmetro explícito nesses call sites (FailureHandler.handleFailure,
KikwiflowEngine.claim/unclaim), não adicionar um lookup de ProcessDefinitionService.getById(...) dentro do
recorder só para resolver uma tag de métrica.
Módulos novos
Mesmo padrão trio já usado por kikwi-runtime-persistence-mongodb:
| Módulo | Conteúdo |
|---|---|
kikwi-observability-opentelemetry | OpenTelemetryMetricsListener implements ExecutionEventListener, instrumentos OTel pré-criados, mapeamento evento-métrica→instrumento. Puro Java, sem Spring — depende de kikwi-lightweight-events-api, kikwi-model e opentelemetry-api/opentelemetry-sdk (via opentelemetry-bom no dependencyManagement do root pom.xml). |
kikwi-observability-opentelemetry-spring-boot-autoconfigure | KikwiflowOpenTelemetryProperties; constrói o SdkMeterProvider/OpenTelemetry bean a partir das properties; registra OpenTelemetryMetricsListener como bean ExecutionEventListener — descoberto automaticamente por KikwiflowAutoConfiguration via o ObjectProvider<List<ExecutionEventListener>> que já existe hoje (zero mudança em kikwi-spring-boot-autoconfigure). |
kikwi-observability-opentelemetry-spring-boot-starter | Agregador de dependência. |
Configuração — genérica, sem fornecedor embutido
kikwiflow:
stats:
enabled: true # pré-requisito — sem isso, ExecutionMetricsRecorder nunca publica
observability:
opentelemetry:
enabled: true
exporter: otlp-grpc # otlp-grpc | otlp-http | logging (console, dev local)
endpoint: http://localhost:4317 # Collector, Agent, ou intake do backend escolhido — indiferente aqui
headers: {} # ex.: token/api-key do backend de destino, se exigido
export-interval: PT30S
include-tenant-tag: false
resource-attributes:
deployment.environment: production
service.name do Resource OTel vem de kikwiflowConfig.getInstanceName() (reaproveita
kikwiflow.instance-name, já existente).
:::warning stats.enabled=false com observability.opentelemetry.enabled=true é um erro de configuração silencioso
Sem stats.enabled, ExecutionMetricsRecorder nunca publica, e o listener registrado nunca recebe nada — sem
erro, sem log. KikwiflowOpenTelemetryAutoConfiguration deve falhar o boot (ou logar WARN alto) quando essa
combinação for detectada.
:::
Exportação via OTLP: destino é configuração, não design
O exporter/endpoint/headers acima são o contrato inteiro de saída — qualquer coletor ou backend que fale
OTLP funciona sem nenhum código específico deste módulo. Um Collector local (otel/opentelemetry-collector,
usado no exemplo abaixo) pode rotear de lá para onde for preciso (Prometheus, Datadog, Honeycomb, New Relic,
etc.) via configuração do próprio Collector — decisão de infraestrutura de cada ambiente, fora do escopo desta
spec.
Catálogo de métricas (v1)
Todas prefixadas kikwiflow..
| Instrumento | Nome | Tipo/Unidade | Tags | Fonte (lightweight.metric) |
|---|---|---|---|---|
| Histograma | flow_node.duration | ms | processDefinitionKey, flowNodeDefinitionId, flowNodeType, status | FlowNodeExecutionMetric |
| Contador | flow_node.executions | 1 | mesmas acima | FlowNodeExecutionMetric |
| Contador | flow_node.errors | 1 | processDefinitionKey, flowNodeDefinitionId, errorType | FlowNodeExecutionMetric quando status == ERROR |
| Contador | gateway.answers | 1 | processDefinitionKey, gatewayNodeId, answerProviderType, resolvedAnswer | GatewayAnswerMetric |
| Contador | process_instance.started | 1 | processDefinitionKey, origin | ProcessInstanceStartedMetric |
| Histograma | process_instance.duration | ms | processDefinitionKey, status | ProcessInstanceFinishedMetric |
| Contador | process_instance.finished | 1 | processDefinitionKey, status | ProcessInstanceFinishedMetric |
| Contador | incident.created | 1 | processDefinitionKey, taskDefinitionId, type | IncidentCreatedMetric |
| Contador | incident.resolved | 1 | processDefinitionKey, taskDefinitionId | IncidentResolvedMetric |
| Contador | retry.scheduled | 1 | processDefinitionKey, taskDefinitionId | RetryScheduledMetric |
| Histograma | retry.retries_left | 1 | processDefinitionKey, taskDefinitionId | RetryScheduledMetric |
| Contador | external_task.claimed | 1 | processDefinitionKey, taskDefinitionId | ExternalTaskClaimedMetric |
| Contador | external_task.unclaimed | 1 | processDefinitionKey, taskDefinitionId | ExternalTaskUnclaimedMetric |
| Contador | external_task.completed | 1 | processDefinitionKey, taskDefinitionId, assigneeMismatch | ExternalTaskCompletedMetric |
| Contador | timer.fired | 1 | processDefinitionKey, flowNodeDefinitionId | TimerFiredMetric |
| Contador | sync_continuation.failed | 1 | processDefinitionKey, failedNodeDefinitionId | SyncContinuationFailed (existente) |
tenantId só entra como tag se include-tenant-tag: true for setado explicitamente (ver cardinalidade).
Cardinalidade
:::danger Garantida pelo shape do tipo, não só por convenção
Diferente da primeira versão desta spec, aqui a restrição não depende de "lembrar de não usar tal campo como
tag" — os records de lightweight.metric simplesmente não têm campo para processInstanceId/
externalTaskId/incidentId/businessKey/actorId/valor de variável. Quem precisa investigar uma instância
específica usa o outbox (GET /process-instances/{id}/events), não este canal.
:::
Tags aceitas (processDefinitionKey, flowNodeDefinitionId/taskDefinitionId, flowNodeType, status,
answerProviderType/resolvedAnswer, errorType) são limitadas pelo desenho do .kikwi implantado — número
finito de nós/gateways/branches, mesmo com muitas execuções. tenantId continua opt-in por poder variar de
dezenas a milhares dependendo do ambiente.
Confiabilidade: herda o mesmo contrato do canal leve
PeriodicMetricReader do SDK OTel agrega e exporta em lote num intervalo fixo, desacoplado da taxa de chegada
de eventos — um endpoint OTLP lento ou fora do ar não bloqueia AsynchronousEventPublisher (cada listener já
roda em submissão de executor de virtual thread separada, fora do caminho crítico de persistência). Agregação em
memória se perde se a JVM cair antes do próximo export — mesma garantia (nenhuma) que o canal leve já documenta
hoje para SyncContinuationFailed. Nunca trate estas métricas como fonte auditável — para isso, outbox.
Fora de escopo nesta rodada
- Qualquer integração específica de backend (Datadog, Grafana, Honeycomb, New Relic, ...) — é configuração
de
endpoint/headers/roteamento de Collector, não uma decisão desta spec. - Traces/spans OTel — o pedido é métricas; tracing exigiria propagar contexto através do modelo de execução
assíncrona (
TaskAcquirer/worker separado do chamador original), mudança bem mais invasiva. ProcessVariableChangedcomo métrica — mesmo risco de masking já documentado emdocs/engine/09; métrica não precisa do valor da variável.OrphanedChildCompletion— construído fora dos pontos ondeCriticalEventRecorder/ExecutionMetricsRecorderseriam chamados hoje; precisa de wiring próprio.- Dashboards prontos para qualquer backend específico.
Superfícies a tocar (checklist)
| Módulo | Mudança |
|---|---|
kikwi-model | Novo pacote event.lightweight.metric com os 11 records; processDefinitionId/processDefinitionKey novos em SyncContinuationFailed |
kikwi-core | ExecutionMetricsRecorder novo (paralelo a CriticalEventRecorder, não relacionado a ele); chamado nos mesmos pontos que já chamam CriticalEventRecorder (ProcessExecutionManager, ContinuationService, FailureHandler, KikwiflowEngine); processDefinitionKey threadeado como parâmetro extra em FailureHandler.handleFailure/KikwiflowEngine.claim/unclaim (mesmo padrão já usado para tenantId) |
kikwi-observability-opentelemetry (novo) | OpenTelemetryMetricsListener, instrumentos, mapeamento evento-métrica→instrumento |
kikwi-observability-opentelemetry-spring-boot-autoconfigure (novo) | KikwiflowOpenTelemetryProperties, construção do SdkMeterProvider/exporter/Resource, registro do bean listener, validação stats.enabled |
kikwi-observability-opentelemetry-spring-boot-starter (novo) | Agregador de dependência |
pom.xml (root) | opentelemetry-bom em dependencyManagement |
sample-onboarding-process | Dependência do novo starter no perfil sample; application.yml de exemplo; docker-compose com otel/opentelemetry-collector (exporter de console/logging) para validar localmente sem depender de nenhuma conta externa |
kikwi-core-tests ou módulo novo de teste | InMemoryMetricReader (utilitário de teste do próprio SDK OTel) para asserts determinísticos por tipo de evento, sem rede |
Decisões a confirmar antes de implementar
- Nome/prefixo das métricas —
kikwiflow.*como proposto? include-tenant-tagdefault — propostofalse; confirmar se o caso de uso real tem poucos tenants o suficiente para inverter o default.- Buckets de histograma de duração — usar os defaults do SDK OTel, ou declarar boundaries explícitos via
View/ExplicitBucketHistogramAggregation? Se sim, vale alinhar com osthreshold.sla.warning/threshold.sla.criticaljá usados emextensionPropertiesdo.kikwi(ex.know-your-customer-async.json). processDefinitionKeythreadeado por parâmetro — confirmar que estender as assinaturas deFailureHandler.handleFailure/KikwiflowEngine.claim/unclaim(mesmo padrão dotenantIdexistente) é aceitável, em vez de alguma alternativa (ex.: guardarprocessDefinitionKeytambém emExecutableTask/ExternalTask, mudança de schema maior).