Pular para o conteúdo principal

Eventos e Observabilidade

O Kikwiflow expõe dois canais de eventos completamente independentes, com garantias e propósitos diferentes. Confundir os dois é o erro de design mais comum ao integrar observabilidade — escolha o canal errado e você acaba com telemetria que promete consistência que não entrega, ou com um outbox transacional pesado demais para simples métricas de performance.

Eventos leves (lightweight)Eventos críticos (outbox)
Flag de ativaçãokikwiflow.stats.enabledkikwiflow.outbox.events-enabled
EntregaFire-and-forget, assíncrona, em memóriaPersistida na mesma transação do UnitOfWork (implementação MongoDB)
GarantiaNenhuma — pode se perder em crash/restartDurável — commit atômico junto com a mudança de estado
Uso recomendadoMétricas de performance, dashboards, APMAuditoria/histórico de execução, e como base para um consumidor de relay (ver nota sobre escopo abaixo)

:::info Escopo: esta lib entrega a persistência, não o relay O Kikwiflow OSS garante que o evento crítico existe e é consultável — de forma transacional e durável. A drenagem/entrega desses eventos para um sistema externo (Kafka, webhook, outro banco) — o "R" do padrão outbox-relay — é deliberadamente deixada como um ponto de extensão (kikwi-critical-event-relay-api), sem uma implementação embutida nesta versão. O schema da coleção outbox_events (campos relayStatus/lockedUntil, descritos abaixo) já foi desenhado para suportar um consumidor de relay construído pela sua equipe, sem precisar de migração de dados. :::

Eventos leves: ExecutionEventListener​

A interface pública que uma aplicação implementa para observar a execução é:

@FunctionalInterface
public interface ExecutionEventListener {
void onEvents(List<LightweightEvent> events);
}

public interface LightweightEvent {
Instant getTimestamp();
}

Qualquer bean Spring que implemente ExecutionEventListener é automaticamente registrado no AsynchronousEventPublisher pelo KikwiflowAutoConfiguration (via ObjectProvider<List<ExecutionEventListener>>) — não é preciso fiação manual:

@Component
public class MetricsListener implements ExecutionEventListener {
@Override
public void onEvents(List<LightweightEvent> events) {
events.forEach(event -> {
if (event instanceof SyncContinuationFailed failure) {
log.warn("Continuação síncrona falhou: instância={}, nó={}, erro={}",
failure.processInstanceId(), failure.failedNodeDefinitionId(), failure.errorMessage());
}
});
}
}

AsynchronousEventPublisher: entrega via pool de virtual threads​

public class AsynchronousEventPublisher implements EventPublisher {
private final List<ExecutionEventListener> listeners = new CopyOnWriteArrayList<>();
private final ExecutorService listenerExecutor; // Executors.newVirtualThreadPerTaskExecutor()

public void publishEvents(List<LightweightEvent> events) {
for (ExecutionEventListener listener : listeners) {
listenerExecutor.submit(() -> listener.onEvents(events));
}
}
}

Cada listener é notificado em uma submissão de executor separada — um listener lento ou que lança exceção não bloqueia nem afeta outros listeners, nem a execução do processo em si, já que a publicação acontece de forma totalmente desacoplada do caminho crítico de persistência.

:::danger Sem garantia de entrega SyncContinuationFailed, o único evento leve emitido pelo motor (disparado quando uma continuação síncrona falha após a conclusão de uma tarefa externa — ver KikwiflowEngine.completeExternalTask), é publicado via fire-and-forget. Se a JVM cair entre a falha e a entrega do evento ao listener, o evento é perdido silenciosamente. Nunca implemente lógica de negócio essencial (ex.: reverter um efeito colateral, notificar um cliente) dentro de um ExecutionEventListener — use o outbox para isso. :::

O catálogo de eventos críticos: CriticalEventType​

Todo evento crítico conhecido pelo motor está registrado em um único enum, usado tanto para construir eventos (em vez de literais de string soltos pelo código) quanto para o dispatch polimórfico na camada de persistência:

public enum CriticalEventType {
FLOW_NODE_FINISHED(FlowNodeFinished.class),
GATEWAY_ANSWER_RESOLVED(GatewayAnswerResolved.class),
PROCESS_INSTANCE_FINISHED(ProcessInstanceFinished.class),
PROCESS_INSTANCE_STARTED(ProcessInstanceStarted.class),
INCIDENT_CREATED(IncidentCreated.class),
INCIDENT_RESOLVED(IncidentResolved.class),
EXTERNAL_TASK_CLAIMED(ExternalTaskClaimed.class),
EXTERNAL_TASK_UNCLAIMED(ExternalTaskUnclaimed.class),
EXTERNAL_TASK_COMPLETED(ExternalTaskCompleted.class),
RETRY_SCHEDULED(RetryScheduled.class),
PROCESS_VARIABLE_CHANGED(ProcessVariableChanged.class),
TIMER_FIRED(TimerFired.class),
ORPHANED_CHILD_COMPLETION(OrphanedChildCompletion.class);

private final Class<? extends CriticalEvent> payloadType;
}
public interface CriticalEvent {
String processInstanceId();
String processDefinitionId();
}

Toda implementação de CriticalEvent expõe processInstanceId()/processDefinitionId() — isso é o que permite à camada de persistência indexar e consultar eventos por instância sem precisar de uma cadeia de instanceof.

Eventos críticos: o padrão Outbox​

Cada mudança de estado relevante do processo é serializada como um OutboxEventEntity — com identidade própria (id, gerado automaticamente) — e incluída no mesmo UnitOfWork transacional que persiste a mudança de estado em si:

public class OutboxEventEntity {
private String id; // UUID gerado no construtor — vira o _id do documento persistido
private Instant timestamp;
private String event; // nome do CriticalEventType, ex. "FLOW_NODE_FINISHED"
private CriticalEvent payload;

public OutboxEventEntity(CriticalEventType type, CriticalEvent payload) { /* ... */ }
}

Quando kikwiflow.outbox.events-enabled=true, a implementação MongoDB (MongoKikwiEngineRepository.commitWork) grava cada OutboxEventEntity do UnitOfWork na coleção outbox_events, dentro da mesma transação nativa (ClientSession.withTransaction) que persiste a mudança de estado da instância:

if (outboxPersistenceEnabled && unitOfWork.events() != null && !unitOfWork.events().isEmpty()) {
List<InsertOneModel<Document>> eventWrites = unitOfWork.events().stream()
.map(OutboxEventMapper::toDocument)
.map(InsertOneModel::new)
.toList();
outboxEvents.bulkWrite(clientSession, eventWrites);
}

Isso significa que, ao contrário dos eventos leves, um evento crítico nunca é perdido silenciosamente: ou a transação inteira (mudança de estado + evento) é commitada, ou nenhuma das duas é — consistência garantida pelo mesmo mecanismo transacional descrito em Persistência e Consistência Transacional.

:::info A checagem da flag acontece de novo, no ponto de persistência kikwiflow.outbox.events-enabled é verificada tanto na construção do evento quanto, independentemente, dentro de commitWork, imediatamente antes da escrita física na coleção. Isso garante que "outbox desabilitado" signifique de fato "nada é escrito", mesmo que algum caminho upstream decida montar o UnitOfWork com eventos preenchidos. :::

Tipos de evento crítico​

Todo evento crítico é montado dentro do motor, que checa kikwiflow.outbox.events-enabled num único ponto — com o outbox desligado, nada é construído nem gravado.

eventPayload (CriticalEvent)Quando é emitido
FLOW_NODE_FINISHEDFlowNodeFinishedAo final de cada execução de nó — sucesso, erro ou interrupção (boundary timer/error handler). Inclui nodeExecutionStatus (SUCCESS/INTERRUPTED/ERROR), flowNodeName/flowNodeDescription, startedAt/finishedAt, interruptedByNodeDefinitionId quando aplicável, e errorType/errorMessage/errorStackTrace quando nodeExecutionStatus == ERROR.
GATEWAY_ANSWER_RESOLVEDGatewayAnswerResolvedSempre que um EXCLUSIVE_GATEWAY resolve uma resposta — carrega providerType, providerBean/providerVariable, a resposta resolvida e o id da aresta escolhida. Ver Navegação e Gateways de Decisão.
PROCESS_INSTANCE_FINISHEDProcessInstanceFinishedQuando uma instância atinge ProcessInstanceStatus.COMPLETED. Carrega o snapshot final de variáveis, businessValue, tenantId, origin, parentInstanceId/callerTaskId/callerBranchId (call activity) e getDurationMs() derivado de startedAt/endedAt.
PROCESS_INSTANCE_STARTEDProcessInstanceStartedContraparte de PROCESS_INSTANCE_FINISHED — emitido uma única vez, na primeira continuação da instância (ProcessStarter.execute()). Carrega o mesmo tipo de snapshot inicial (variáveis, businessValue, tenantId, origin), sem endedAt/status. actorId vem de ProcessStarter.byActor(...) — null se não informado.
INCIDENT_CREATEDIncidentCreatedQuando FailureHandler.handleFailure abre um incidente (retries esgotados, ou ProcessErrorException não tratado por nenhum boundary). actorId é sempre IdentityContext.system().
INCIDENT_RESOLVEDIncidentResolvedQuando KikwiflowEngine.retryIncident resolve manualmente um incidente aberto. actorId vem do IdentityContext do chamador.
EXTERNAL_TASK_CLAIMEDExternalTaskClaimedQuando KikwiflowEngine.claim atribui uma external/human task a um assignee. actorId vem do IdentityContext do chamador — normalmente igual a assignee, mas podem divergir (ex.: um supervisor atribuindo em nome de outro).
EXTERNAL_TASK_UNCLAIMEDExternalTaskUnclaimedQuando KikwiflowEngine.unclaim remove o assignee de uma external/human task — carrega previousAssignee e actorId (do IdentityContext do chamador).
EXTERNAL_TASK_COMPLETEDExternalTaskCompletedQuando KikwiflowEngine.completeExternalTask completa a tarefa. Carrega assignee (quem estava atribuído no momento, null se nunca foi clamada) e actorId (quem comandou o complete) lado a lado — o motor não valida um contra o outro, o complete é soberano; esse par existe só para que quem consome o evento detecte "completado por alguém diferente do atribuído" (ver nota abaixo).
RETRY_SCHEDULEDRetryScheduledQuando FailureHandler.handleFailure reagenda uma tarefa que falhou mas ainda tem retries disponíveis (o ramo que não abre incidente). actorId é sempre IdentityContext.system().
PROCESS_VARIABLE_CHANGEDProcessVariableChangedUm evento por variável alterada via KikwiflowEngine.setVariables ou removida via KikwiflowEngine.unsetVariables. No caso de remoção, o payload traz removed=true e value=null; caso contrário, removed=false e o valor bruto — masking é responsabilidade de quem consome o outbox, não deste ponto de construção (ver nota sobre escopo/masking abaixo). actorId vem do IdentityContext do chamador.
TIMER_FIREDTimerFiredQuando um timer não-interruptivo dispara e se reagenda (ContinuationService, ramo NON_INTERRUPTIVE_TIMER). Timers interruptivos são cobertos por FLOW_NODE_FINISHED(INTERRUPTED). actorId é sempre IdentityContext.system().
ORPHANED_CHILD_COMPLETIONOrphanedChildCompletionQuando uma instância filha de um CALL_ACTIVITY_COORDINATOR conclui, mas a coordenadora do pai já não existe mais (cancelada por timeout antes). processInstanceId/processDefinitionId/tenantId identificam a instância filha — não o pai. Construído pela camada de persistência. Sem actorId — nunca é uma ação de usuário.

:::info Quem comandou a ação: actorId e o sentinel IdentityContext.system() 9 dos 13 tipos carregam actorId. Para os que nascem de uma chamada explícita de um usuário/API (claim, unclaim, completeExternalTask, retryIncident, setVariables, ProcessStarter.byActor), actorId vem do IdentityContext.actorId() do chamador. Para os que o motor dispara sozinho, sem nenhum humano por trás (IncidentCreated, RetryScheduled, TimerFired — todos originados em FailureHandler/ContinuationService reagindo a uma falha ou um timer), actorId é sempre o sentinel IdentityContext.system().actorId() ("__KIKWIFLOW_SYSTEM__"). FLOW_NODE_FINISHED, GATEWAY_ANSWER_RESOLVED e PROCESS_INSTANCE_FINISHED não carregam actorId — eles nascem dentro da execução do fluxo, onde nem sempre há uma identidade para propagar (ex.: um nó retomado por um worker em segundo plano). :::

:::info EXTERNAL_TASK_COMPLETED: soberania preservada, auditoria separada assignee/actorId divergentes em EXTERNAL_TASK_COMPLETED não é um erro — completeExternalTask sempre completa a tarefa, atribuída ou não, para quem quer que a chame (esse é um requisito de produto: o complete não pode depender de estar clamada). O evento só existe para que consumidores de auditoria/observabilidade possam perguntar "isso foi completado por alguém diferente do atribuído?" sem que o motor precise saber ou se importar com a resposta. :::

:::info tenantId está em todos os 13 tipos CriticalEvent.tenantId() é obrigatório em todos os payloads — dá para filtrar o histórico por tenant sem exceções. :::

Todos os payloads carregam tanto processDefinitionId (o id interno, específico da versão implantada) quanto, quando disponível, processDefinitionKey (o identificador estável usado em startProcess().byKey(...), o mesmo entre versões) — para agrupar/filtrar execuções de "o mesmo processo" ao longo de reimplantações, use processDefinitionKey.

Todos os tipos só são construídos quando kikwiflow.outbox.events-enabled está ligado — kikwiflow.stats.enabled não afeta o outbox.

:::info claim/unclaim/setVariables/unsetVariables não passam por UnitOfWork Diferente da maioria dos eventos (que viajam dentro de um UnitOfWork já existente), EXTERNAL_TASK_CLAIMED, EXTERNAL_TASK_UNCLAIMED e PROCESS_VARIABLE_CHANGED (tanto o set quanto o unset) são construídos em KikwiflowEngine e passados diretamente para KikwiEngineRepository.claim/unclaim/addVariables/unsetVariables — métodos que hoje mutam estado fora do padrão UnitOfWork/commitWork. A implementação MongoDB mesmo assim garante atomicidade: cada um desses métodos abre sua própria ClientSession.withTransaction para gravar a mudança de estado e o evento crítico juntos. :::

:::warning Masking na escrita: nunca. Na leitura via API: sempre PROCESS_VARIABLE_CHANGED grava value sem aplicar VariableSecurityPolicyManager.applyReadPoliciesAndMasking — decisão de escopo deliberada: o motor não decide o que é sensível no momento de persistir o evento. Isso não significa que ninguém aplica masking: a API REST de histórico (GET /process-instances/{id}/events, abaixo) aplica o policy manager no momento da leitura, antes de devolver a resposta. Um relay/consumer que leia outbox_events diretamente (sem passar por essa API) ainda recebe o valor bruto — é responsabilidade desse consumidor aplicar sua própria política. :::

API REST de histórico: GET /process-instances/{id}/events​

kikwi-management-rest expõe a timeline completa de uma instância — a mesma lista de findEventHistoryByProcessInstanceId, mapeada para HistoryEventSummary (kikwi-model, io.kikwiflow.model.event):

public record HistoryEventSummary(
String id,
CriticalEventType eventType,
String processInstanceId,
String processDefinitionId,
String tenantId,
String actorId,
Instant timestamp,
CriticalEvent payload
) {}

HistoryEventSummaryMapper (kikwi-management-rest) promove ao topo os campos comuns (para não obrigar o cliente a fazer instanceof só para montar uma linha de timeline) e mantém o payload completo, específico do tipo, para o detalhamento. Quando payload é ProcessVariableChanged, o mapper aplica VariableSecurityPolicyManager.applyReadPoliciesAndMasking antes de devolver — se a policy negar leitura da variável, value vem como null na resposta, nunca o bruto.

:::info Sem enforcement de tenant neste endpoint — de propósito Diferente do que seria natural supor, este endpoint não valida identityContext.tenantId() contra o tenantId da instância. É uma decisão consciente: este endpoint é para uso backoffice/suporte/TI (o "Cockpit" do Kikwiflow), não uma API de uso geral de aplicação cliente — quem acessa o monitor já tem permissão independente de tenant. Aplicações que expõem esse dado para usuários finais devem implementar seu próprio enforcement de tenant na camada de aplicação, não esperar que o Kikwiflow faça isso aqui. :::

Registro do endpoint é condicional a kikwiflow.history.enabled (default true, KikwiflowHistoryProperties, módulo kikwi-management-rest-spring-boot-autoconfigure) — desligar essa flag remove o endpoint inteiro, mesmo com kikwiflow.outbox.events-enabled=true (que controla se o dado existe, não se é servido). 404 se a instância não existir; lista vazia (não erro) se o outbox nunca foi habilitado para essa instância.

Reconstruindo por onde uma instância passou​

A coleção outbox_events não é apenas uma fila de relay transiente — entradas nunca são deletadas quando confirmadas/relayadas (apenas marcadas), então ela dobra como histórico durável e consultável de execução. QueryRepository.findEventHistoryByProcessInstanceId(String processInstanceId) retorna todos os eventos de uma instância, ordenados por timestamp ascendente:

List<OutboxEventEntity> history = kikwiEngineRepository.findEventHistoryByProcessInstanceId(processInstanceId);
// ex.: CONSULTA_BUREAU (FLOW_NODE_FINISHED) -> CALCULAR_SCORE (FLOW_NODE_FINISHED)
// -> GATEWAY_FAIXA_RISCO (GATEWAY_ANSWER_RESOLVED, resposta "ALTO") -> ...

Isso só retorna dados quando kikwiflow.outbox.events-enabled=true — sem persistência habilitada, a lista é sempre vazia. Note que apagar uma instância concluída (deleteProcessInstanceById) não apaga seu histórico em outbox_events — é uma decisão consciente de design: o histórico sobrevive à instância.

Retenção: TTL nativo do MongoDB​

Por padrão, outbox_events retém tudo indefinidamente. Se isso não for desejável (auditoria de curto prazo, custo de armazenamento, ou o consumo é feito por uma ferramenta de relay que já move os dados para outro lugar), configure kikwiflow.outbox.ttl com uma duração ISO-8601:

kikwiflow:
outbox:
events-enabled: true
ttl: P30D # eventos expiram 30 dias após o timestamp — MongoDB TTL nativo (expireAfterSeconds)

Isso cria (ou ajusta, via collMod, se o valor mudar entre deploys) um índice TTL real sobre o campo timestamp — a expiração é feita pelo próprio MongoDB em background, sem nenhum job/scheduler do Kikwiflow envolvido. Se ttl não for declarado, nenhum índice TTL é criado e a retenção é indefinida (comportamento padrão). Diminuir ou remover o TTL depois de já criado não apaga o índice automaticamente — isso exige um passo manual (db.outbox_events.dropIndex("outbox_ttl_idx")).

Ponto de extensão para relay​

O Kikwiflow não traz um relay embutido: nenhum processo drena outbox_events para fora em segundo plano. O schema, porém, já suporta um consumidor construído pela sua equipe — cada documento tem relayStatus (PENDING/LOCKED/RELAYED) e lockedUntil, pensados para um claim atômico via findOneAndUpdate (o mesmo padrão de Workers e Tarefas Externas), com o índice relay_status_timestamp_idx já criado. A interface OutboxReader (kikwi-critical-event-relay-api) define o contrato esperado (readAndLockNextBatch/confirmBatch), sem implementação fornecida.

Escolhendo o canal certo​

:::tip Regra prática

  • Precisa de um dashboard de latência por nó, sem garantias fortes? Eventos leves.
  • Precisa de auditoria durável, ou consultar por onde uma instância específica passou (depuração, suporte)? Outbox, via findEventHistoryByProcessInstanceId.
  • Precisa entregar esses eventos para um sistema externo (Kafka, webhook, data warehouse)? Outbox como base de dados de origem — mas você (ou uma lib de relay dedicada) precisa implementar o consumidor; esta versão não inclui um pronto para uso.
  • Só precisa consultar o estado atual de uma instância (não seu histórico)? Nenhum dos dois — use o lado de query do CQRS (QueryRepository/ExternalTaskQueryService), descrito na Visão Geral. :::