Evento de audit
Quando usar: toda mudança de domínio e toda negação de acesso. Leitura não audita — a trilha é do que mudou, não do que foi consultado.
A regra que sustenta tudo: o evento entra na mesma transação da escrita. Ou o dado e o evento existem, ou nenhum dos dois.
Declarar a trilha e o evento
nnp/audit.yaml:
apiVersion: console.nnumbers.io/v1alpha3
kind: AuditDescriptor
product: sample
trails:
items:
audience: tenant
description: Ciclo de vida dos itens (mudança de domínio + evento no outbox, na mesma transação).
events:
item.create: {schema_version: 1, description: Item criado, payload_schema: audit/items/item.create.v1.schema.json}
item.delete: {schema_version: 1, description: Item apagado, payload_schema: audit/items/item.delete.v1.schema.json}
operations:
audience: tenant
description: Operações assíncronas (Actions); o evento sai no estado terminal, com outcome.
events:
item.scan: {schema_version: 1, description: Varredura concluída ou falha, payload_schema: audit/operations/item.scan.v1.schema.json}
- A
audienceda trilha é a da rota que emite os eventos. Sem o campo, a trilha éplatforme o evento sai da Console do cliente. - O descritor é append-only: release nova só acrescenta evento; o
payload_schemade um evento já registrado nunca muda. - O schema do payload fica ao lado (
nnp/audit/<trilha>/<evento>.v<versão>.schema.json) e é o que o Audit usa para conferir o conteúdo:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "sample items/item.create v1",
"type": "object",
"required": ["name", "description_length"],
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 80},
"description_length": {"type": "integer", "minimum": 0, "maximum": 500}
}
}
Emitir
@NnsRoute("items.create")
ResponseEntity<byte[]> create(Ctx ctx, ServiceContext sc, ServiceChain.Dispatch d, NnsBody body) {
return service.mutation(
ctx,
sc,
d,
body,
(tx, b) -> {
SampleService.CreateInput in = decodeCreate(b);
String tenant = sc.requireTenantId();
Item it = Item.created(tenant, Correlation.newId(), in.name(), in.description(),
sc.principalId(), service.config.releaseId(), service.nowMillis());
ItemStore.create(ctx, tx, it); // a mudança de domínio…
AuditEvent ev =
AuditEvent.newEvent(ctx, Origin.publicRoute(sc), service.auditConfig, "items", "item.create", 1)
.withResource("item", it.id())
.withDetail("name", it.name())
.withDetail("description_length", in.description().codePointCount(0, in.description().length()));
service.emitter.emit(ctx, tx, ev); // …e o evento, NA MESMA transação
return Wire.stored(201, Map.of("item", Views.ItemView.of(it)), service.itemPath(it.id()));
});
}
O que cada peça significa:
| Peça | O que é |
|---|---|
"items" | A trilha do descritor. |
"item.create" | O evento. |
1 | A versão do payload (a mesma do schema_version). |
Origin.publicRoute(sc) | A origem: rota pública, com o contexto verificado. |
withResource("item", id) | O recurso afetado — é por ele que se busca "o que aconteceu com este item". |
withDetail(k, v) | O payload. É o que o payload_schema valida. |
emitter.emit(ctx, tx, ev) | Grava no outbox transacional. O tipo TxAuditEmitter obriga a passar a transação: não existe "emitir fora". |
O que o SDK completa sozinho: event_id, occurred_at, tenant, produto, serviço, release, ator,
correlation_id, request_id, admin_context e cliente. Você não preenche isso à mão.
O evento terminal de uma Action
Uma operação assíncrona não emite o evento no handler: quem o grava é o ActionRunner, na transação
da transição terminal — a mesma que muda o estado da Action. Por isso o Runner precisa do emissor e
do descritor:
ActionRunner.Builder b =
ActionRunner.builder(store, config.releaseId())
.handler(SampleService.SCAN_TYPE, service::runScan)
.logger(logger.with("worker", "action-runner"));
if (emitter.transactional() && module.audit().isPresent()) {
b.emitter(emitter).audit(module.audit().get());
}
return b.build();
O payload do evento terminal é o que o handler devolveu, com o desfecho:
{"trail":"operations","event_type":"item.scan","action":"sample.item.scan","outcome":"success",
"resource_type":"item","resource_id":"0be1d3c6-…","details":{"attempts":1,"status":"succeeded"}}
Como testar
Em MySQL real, o teste olha o outbox — a linha que o worker vai entregar ao Audit:
@Test
void createWritesTheAuditEventInTheSameTransactionAndReplays() {
MysqlIdempotency.Outcome out =
MysqlIdempotency.execute(ctx, db, Idempotency.scopeFrom(sc), key,
Idempotency.requestHash("POST", "/v1/sample/items", body), tx -> createInTx(ctx, sc, tx));
assertThat(out.replayed()).isFalse();
String payload =
db.queryOne(CTX, "SELECT payload FROM nn_audit_outbox WHERE payload LIKE ? LIMIT 1",
r -> r.string(1), "%\"event_type\":\"item.create\"%")
.orElseThrow();
assertThat(payload).contains("\"trail\":\"items\"").contains("\"name\":\"Assinatura\"");
}
@Test
void failingDomainStepLeavesNeitherTheItemNorTheEvent() {
// Nome repetido no tenant: o INSERT falha e a transação inteira desfaz — inclusive o evento.
assertThatThrownBy(() -> MysqlIdempotency.execute(…, tx -> createInTx(ctx, sc, tx)))
.isInstanceOf(ApiError.class);
assertThat(db.queryOne(CTX, "SELECT COUNT(*) FROM sample_items", r -> r.int64(1)).orElseThrow())
.isEqualTo(itemsBefore);
assertThat(db.queryOne(CTX, "SELECT COUNT(*) FROM nn_audit_outbox", r -> r.int64(1)).orElseThrow())
.isEqualTo(eventsBefore);
}
E, no descritor, um teste que confere que as trilhas e os eventos são os esperados:
@Test
void auditTrailsAreTenantAndDeclareTheActionEvent() {
var audit = MODULE.audit().orElseThrow();
assertThat(audit.trails().get("items").events()).containsKeys("item.create", "item.delete");
assertThat(audit.trails().get("operations").events()).containsKey("item.scan");
assertThat(audit.trails().get("items").audience()).isEqualTo("tenant");
}
Erros comuns
- Emitir fora da transação — o tipo
TxAuditEmitternão deixa; se você criar um emissor próprio, volta a ser possível e a garantia se perde. - Auditar leitura — enche a trilha de ruído e encarece a consulta; a trilha é de mudança.
- Payload com dado sensível — o evento é lido por auditoria e humano. Segredo nunca entra em
details. - Esquecer o evento no
audit.yaml— o tipo desconhecido é aceito e marcado pelo Audit, mas você perde a validação do payload e a documentação da trilha. Declare. - Mudar o
payload_schemade um evento publicado — recusa (conflict/audit_schema_immutable). A forma é acrescentar campo opcional ou subirschema_version. - Trilha
platformpor engano — semaudiencea trilha éplatform, e o evento não aparece para o cliente. Em módulo de produto, quase sempre étenant. - Emitir duas vezes a mesma mudança — o evento tem id próprio; repetir a mutação por retry é trabalho da idempotência, não da trilha.
withDetailcom o objeto inteiro do domínio — o payload é contrato público; publique o que descreve a mudança.
Was this page helpful?
Report a problem on this pageDo not send passwords, keys, tokens, or customer data.