Pular para o conteúdo principal
Página não listada
Esta página não está listada. Mecanismos de busca não irão indexá-la, e somente usuários que possuam o link direto poderão acessá-la

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óduloConteúdo
kikwi-observability-opentelemetryOpenTelemetryMetricsListener 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-autoconfigureKikwiflowOpenTelemetryProperties; 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-starterAgregador 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..

InstrumentoNomeTipo/UnidadeTagsFonte (lightweight.metric)
Histogramaflow_node.durationmsprocessDefinitionKey, flowNodeDefinitionId, flowNodeType, statusFlowNodeExecutionMetric
Contadorflow_node.executions1mesmas acimaFlowNodeExecutionMetric
Contadorflow_node.errors1processDefinitionKey, flowNodeDefinitionId, errorTypeFlowNodeExecutionMetric quando status == ERROR
Contadorgateway.answers1processDefinitionKey, gatewayNodeId, answerProviderType, resolvedAnswerGatewayAnswerMetric
Contadorprocess_instance.started1processDefinitionKey, originProcessInstanceStartedMetric
Histogramaprocess_instance.durationmsprocessDefinitionKey, statusProcessInstanceFinishedMetric
Contadorprocess_instance.finished1processDefinitionKey, statusProcessInstanceFinishedMetric
Contadorincident.created1processDefinitionKey, taskDefinitionId, typeIncidentCreatedMetric
Contadorincident.resolved1processDefinitionKey, taskDefinitionIdIncidentResolvedMetric
Contadorretry.scheduled1processDefinitionKey, taskDefinitionIdRetryScheduledMetric
Histogramaretry.retries_left1processDefinitionKey, taskDefinitionIdRetryScheduledMetric
Contadorexternal_task.claimed1processDefinitionKey, taskDefinitionIdExternalTaskClaimedMetric
Contadorexternal_task.unclaimed1processDefinitionKey, taskDefinitionIdExternalTaskUnclaimedMetric
Contadorexternal_task.completed1processDefinitionKey, taskDefinitionId, assigneeMismatchExternalTaskCompletedMetric
Contadortimer.fired1processDefinitionKey, flowNodeDefinitionIdTimerFiredMetric
Contadorsync_continuation.failed1processDefinitionKey, failedNodeDefinitionIdSyncContinuationFailed (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.
  • ProcessVariableChanged como métrica — mesmo risco de masking já documentado em docs/engine/09; métrica não precisa do valor da variável.
  • OrphanedChildCompletion — construído fora dos pontos onde CriticalEventRecorder/ExecutionMetricsRecorder seriam chamados hoje; precisa de wiring próprio.
  • Dashboards prontos para qualquer backend específico.

Superfícies a tocar (checklist)

MóduloMudança
kikwi-modelNovo pacote event.lightweight.metric com os 11 records; processDefinitionId/processDefinitionKey novos em SyncContinuationFailed
kikwi-coreExecutionMetricsRecorder 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-processDependê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 testeInMemoryMetricReader (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

  1. Nome/prefixo das métricaskikwiflow.* como proposto?
  2. include-tenant-tag default — proposto false; confirmar se o caso de uso real tem poucos tenants o suficiente para inverter o default.
  3. 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 os threshold.sla.warning/ threshold.sla.critical já usados em extensionProperties do .kikwi (ex. know-your-customer-async.json).
  4. processDefinitionKey threadeado por parâmetro — confirmar que estender as assinaturas de FailureHandler.handleFailure/KikwiflowEngine.claim/unclaim (mesmo padrão do tenantId existente) é aceitável, em vez de alguma alternativa (ex.: guardar processDefinitionKey também em ExecutableTask/ ExternalTask, mudança de schema maior).