@softize/opus 9.0.9 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +2 -2
- package/docs/adr/0001-vendor-neutral-observability-context.md +96 -0
- package/docs/protocol.md +101 -12
- package/package.json +16 -1
- package/src/audit/drivers/pg.ts +60 -8
- package/src/core/actions.ts +3 -1
- package/src/core/index.ts +4 -0
- package/src/core/runtime.ts +230 -26
- package/src/core/trace.ts +34 -0
- package/src/core/types.ts +44 -0
- package/src/observability/drivers/opentelemetry.ts +165 -0
- package/src/observability/index.ts +8 -0
- package/src/queue/drivers/bullmq.ts +47 -5
- package/src/server/drivers/fastify.ts +16 -1
- package/src/server/drivers/node.ts +23 -3
- package/src/server/index.ts +57 -0
- package/src/testing/index.ts +3 -0
- package/src/ui/docs/content/audit.md +17 -0
- package/src/ui/docs/content/observability.md +71 -0
- package/src/ui/docs/content/queue.md +29 -3
- package/src/ui/docs/content/runtime.md +18 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,33 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
11
11
|
> tinha ficado sem registro nenhum, o que é exatamente o caso que este arquivo existe
|
|
12
12
|
> pra cobrir.
|
|
13
13
|
|
|
14
|
+
## 9.1.0 — 2026-08-19
|
|
15
|
+
|
|
16
|
+
**Observabilidade ganha uma porta vendor-neutral no runtime.** `ObservabilityAdapter`
|
|
17
|
+
executa actions e reactions dentro de um span ativo sem acoplar o core a OpenTelemetry.
|
|
18
|
+
`TraceContext` passa de forma opcional por contextos, resultados, `AuditRecord.traceContext`,
|
|
19
|
+
eventos e envelopes de job fornecidos pelo chamador;
|
|
20
|
+
falhas do adapter são lenient e nunca repetem nem substituem o resultado da action.
|
|
21
|
+
|
|
22
|
+
`AuditRecord.trace` e as colunas PostgreSQL legadas permanecem inalterados. O carrier novo
|
|
23
|
+
não inclui baggage arbitrário. Os drivers Fastify e Node oferecem propagação HTTP W3C opt-in
|
|
24
|
+
com `traceContext: 'w3c'`: `traceparent` v00 entra como parent e o contexto efetivo é emitido
|
|
25
|
+
na resposta; o default legado não lê nem escreve esses headers. Causalidade runtime→job
|
|
26
|
+
não depende mais de montagem manual do envelope: actions `background` são enfileiradas por
|
|
27
|
+
`execute()`, e o worker usa `executeJob()` para executar um span filho sem reenfileirar.
|
|
28
|
+
O carrier desserializado é revalidado por allowlist; o actor reidratado deve coincidir com o
|
|
29
|
+
envelope. BullMQ mapeia attempts, backoff nativo e prioridade sem simular opções incompatíveis.
|
|
30
|
+
|
|
31
|
+
`pgAudit({ traceContextColumns: true })` persiste opt-in em `trace_id`/`span_id`; sem a
|
|
32
|
+
opção, o INSERT legado continua com 17 colunas e não exige migration. Nomes customizados
|
|
33
|
+
permitem rollout gradual. Operações observáveis declaram `resultKind`: drivers devem
|
|
34
|
+
inspecionar `ActionResult.ok` para actions, pois erro de negócio resolve com `ok:false`.
|
|
35
|
+
|
|
36
|
+
**Driver OpenTelemetry opt-in.** O subpath
|
|
37
|
+
`@softize/opus/observability/opentelemetry` implementa a porta usando apenas a API OTel e
|
|
38
|
+
aceita `Tracer`, health e shutdown injetados. Provider, context manager, sampling, resource,
|
|
39
|
+
OTLP exporter e Collector continuam sob controle do app; nenhum backend acompanha o SDK.
|
|
40
|
+
|
|
14
41
|
## 9.0.9 — 2026-08-19
|
|
15
42
|
|
|
16
43
|
**O composer do `Chat` passa a navegar pelas mensagens já enviadas.** Com o campo vazio,
|
package/README.md
CHANGED
|
@@ -104,8 +104,8 @@ pnpm test:cov # com coverage report
|
|
|
104
104
|
|
|
105
105
|
## Publicar
|
|
106
106
|
|
|
107
|
-
Publicado no
|
|
108
|
-
|
|
107
|
+
Publicado no npm público (`registry.npmjs.org`). `pnpm release
|
|
108
|
+
[patch|minor|major|x.y.z]` bumpa + publica. Passo a passo
|
|
109
109
|
(verificar, testar local, auth) em [docs/releasing.md](docs/releasing.md).
|
|
110
110
|
|
|
111
111
|
## Licença
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# ADR 0001 — Contexto de observabilidade vendor-neutral no runtime
|
|
2
|
+
|
|
3
|
+
- Status: aceita
|
|
4
|
+
- Data: 2026-08-18
|
|
5
|
+
|
|
6
|
+
## Contexto e forças
|
|
7
|
+
|
|
8
|
+
Actions já carregam identidade de execução, provenance, audit e eventos. Falta, porém, uma porta única para criar um span ativo e
|
|
9
|
+
um contexto de trace coerente entre essas fronteiras. A lacuna força integrações a acoplar o
|
|
10
|
+
core a um SDK de APM, instrumentar handlers por fora ou esconder trace em `meta`, impedindo
|
|
11
|
+
interoperabilidade e tornando audit/eventos/jobs divergentes.
|
|
12
|
+
|
|
13
|
+
O contrato precisa:
|
|
14
|
+
|
|
15
|
+
- manter o core sem dependência de OpenTelemetry ou de qualquer vendor;
|
|
16
|
+
- permitir que o handler execute dentro do mecanismo de contexto ativo do driver;
|
|
17
|
+
- propagar a mesma identidade de trace em `ActionContext`, `ActionResult`, audit,
|
|
18
|
+
eventos e reactions;
|
|
19
|
+
- preservar a causalidade pai→filho sem confundir `requestId` com `traceId`;
|
|
20
|
+
- não repetir efeitos nem mudar sucesso/erro da action quando a instrumentação falhar;
|
|
21
|
+
- deixar atributos, sampling, exportação e backend sob responsabilidade do driver.
|
|
22
|
+
|
|
23
|
+
## Alternativas consideradas
|
|
24
|
+
|
|
25
|
+
### Instrumentar diretamente com OpenTelemetry no runtime
|
|
26
|
+
|
|
27
|
+
Entrega contexto ativo e um ecossistema pronto, mas adiciona dependência e semântica de um
|
|
28
|
+
vendor/API específica ao protocolo. Também obriga consumidores que não usam OTel a carregar
|
|
29
|
+
essa escolha. Rejeitada para o core; um driver separado continua possível.
|
|
30
|
+
|
|
31
|
+
### Expor somente hooks `onStart`/`onEnd`
|
|
32
|
+
|
|
33
|
+
É simples e tolerante a falhas, mas não consegue manter um span ativo durante loaders,
|
|
34
|
+
autorização, handler e emits em runtimes com contexto assíncrono. Rejeitada.
|
|
35
|
+
|
|
36
|
+
### Guardar trace apenas em `meta`
|
|
37
|
+
|
|
38
|
+
Evita novos tipos, porém não cria contrato interoperável, mistura metadata de negócio com
|
|
39
|
+
propagação e deixa cada envelope com um shape diferente. Rejeitada.
|
|
40
|
+
|
|
41
|
+
### Porta que envolve a execução
|
|
42
|
+
|
|
43
|
+
Um `ObservabilityAdapter.runInSpan` recebe uma descrição neutra da operação e chama o
|
|
44
|
+
callback dentro de seu contexto ativo. O callback recebe o `TraceContext` efetivo do span,
|
|
45
|
+
que o runtime distribui pelos envelopes. Aceita.
|
|
46
|
+
|
|
47
|
+
## Decisão
|
|
48
|
+
|
|
49
|
+
O core define `TraceContext`, `ObservabilityOperation` e `ObservabilityAdapter` como
|
|
50
|
+
contratos de primeira classe. `TraceContext` contém identificadores e estado de propagação
|
|
51
|
+
neutros; não expõe objetos de span nem APIs de um SDK. O runtime envolve actions e reactions
|
|
52
|
+
com `runInSpan`, passando o trace recebido como `parent` e usando o trace efetivo retornado
|
|
53
|
+
ao callback em:
|
|
54
|
+
|
|
55
|
+
- `ActionContext.trace` e `ReactionContext.trace`;
|
|
56
|
+
- `ResultMeta.trace` e o novo `AuditRecord.traceContext`; `AuditRecord.trace` legado fica intacto;
|
|
57
|
+
- `DomainEvent.trace`, preservado ao iniciar uma reaction;
|
|
58
|
+
- envelopes `JobSpec.ctx.trace` e `JobHandle.trace`, quando produzidos por código externo.
|
|
59
|
+
|
|
60
|
+
`requestId` permanece uma identidade de transporte e pode coexistir com trace. Provenance
|
|
61
|
+
continua descrevendo quem/o quê disparou a execução; trace descreve causalidade técnica.
|
|
62
|
+
|
|
63
|
+
A instrumentação é lenient por contrato do runtime. Se o adapter falhar antes de chamar o
|
|
64
|
+
callback, o runtime executa uma vez sem instrumentação, preservando o trace pai quando houver.
|
|
65
|
+
Se falhar depois de chamar o callback, o runtime preserva o valor ou erro do callback e não o
|
|
66
|
+
executa novamente. O callback entregue ao adapter é memoizado antes de ceder controle; mesmo
|
|
67
|
+
um adapter que o dispare sem aguardar e rejeite em paralelo recebe sempre a mesma Promise.
|
|
68
|
+
A falha de instrumentação é registrada no logger. Isso impede que observabilidade altere a
|
|
69
|
+
semântica ou duplique efeitos.
|
|
70
|
+
|
|
71
|
+
O `TraceContext` público é uma allowlist pequena (`traceId`, `spanId`, `traceFlags`,
|
|
72
|
+
`traceState`); baggage arbitrário não atravessa resultados, audit, eventos ou jobs. O runtime
|
|
73
|
+
não extrai headers HTTP. Em incremento posterior, os drivers Fastify e Node ganharam opt-in
|
|
74
|
+
`traceContext: 'w3c'`: extraem somente `traceparent` v00 válido e `tracestate` limitado para
|
|
75
|
+
`ContextBase.trace`, e injetam o contexto efetivo na resposta. O default permanece desligado;
|
|
76
|
+
outros transportes continuam responsáveis por essa fronteira.
|
|
77
|
+
|
|
78
|
+
O primeiro incremento não incluiu driver OpenTelemetry. O incremento subsequente adicionou
|
|
79
|
+
um driver API-only em subpath separado, depois das garantias de causalidade e at-most-once
|
|
80
|
+
serem provadas. Exporter, Collector, backend APM e UI continuam fora do SDK.
|
|
81
|
+
|
|
82
|
+
O sink PostgreSQL conserva o schema legado por default. Persistência de `traceContext` é
|
|
83
|
+
opt-in por `traceContextColumns`, somente depois que o consumer criar colunas novas; os campos
|
|
84
|
+
legados nunca são semanticamente reutilizados. Para actions, `resultKind: 'action-result'`
|
|
85
|
+
avisa o driver de que outcome vem de `ActionResult.ok`, não apenas de resolve/reject.
|
|
86
|
+
|
|
87
|
+
## Consequências
|
|
88
|
+
|
|
89
|
+
O protocolo ganha campos opcionais compatíveis com consumidores existentes. O incremento
|
|
90
|
+
subsequente ligou actions `background` ao `QueueAdapter`: `execute()` cria o envelope com o
|
|
91
|
+
trace do enqueue, e `executeJob()` revalida o carrier e cria a execução filha no worker.
|
|
92
|
+
BullMQ persiste o envelope, mas o app continua dono do `Worker` e da reidratação de auth.
|
|
93
|
+
Event bus preserva o campo para reactions. Adapters de
|
|
94
|
+
observabilidade podem implementar contexto ativo com OTel, AsyncLocalStorage ou outro
|
|
95
|
+
mecanismo sem vazar essa escolha. Como o runtime não controla adapters remotos, perda de trace
|
|
96
|
+
por um driver que não persiste o envelope continua detectável, mas não corrigível pelo core.
|
package/docs/protocol.md
CHANGED
|
@@ -505,6 +505,7 @@ type ActionContext = {
|
|
|
505
505
|
db: unknown; // injetado pelo DataAdapter
|
|
506
506
|
log: LoggerAdapter; // injetado pelo LoggerAdapter (default console)
|
|
507
507
|
emit: EmitFn; // emite DomainEvent via EventBusAdapter
|
|
508
|
+
trace?: TraceContext; // span ativo, quando observabilidade está configurada
|
|
508
509
|
storage: StorageAdapter | null; // storage de arquivos (experimental; null sem adapter)
|
|
509
510
|
provenance: Provenance; // quem disparou (http/schedule/reaction/…)
|
|
510
511
|
meta: Record<string, unknown>; // adapter pode estender
|
|
@@ -626,10 +627,11 @@ type AuditRecord = {
|
|
|
626
627
|
error?: ActionError; // presente apenas em outcome=error
|
|
627
628
|
|
|
628
629
|
severity: 'info' | 'warning' | 'error';
|
|
629
|
-
trace?: {
|
|
630
|
+
trace?: { // campo legado — compatibilidade
|
|
630
631
|
requestId?: string;
|
|
631
|
-
parentActionId?: string;
|
|
632
|
+
parentActionId?: string;
|
|
632
633
|
};
|
|
634
|
+
traceContext?: TraceContext; // identidade distribuída do span da action
|
|
633
635
|
meta?: Record<string, unknown>;
|
|
634
636
|
}
|
|
635
637
|
```
|
|
@@ -718,7 +720,7 @@ Falha ao emitir audit **não** falha a action. Sink lançou → opus loga `{ cod
|
|
|
718
720
|
|
|
719
721
|
- Logs de debug do handler — usa logger do projeto.
|
|
720
722
|
- Métricas (counter de execuções, latência percentile) — usa observabilidade do projeto.
|
|
721
|
-
-
|
|
723
|
+
- Exportação de traces — pertence ao `ObservabilityAdapter`; audit apenas preserva `TraceContext`.
|
|
722
724
|
- Replay de actions — audit não foi desenhado pra reexecução. Event sourcing é outro contrato.
|
|
723
725
|
|
|
724
726
|
---
|
|
@@ -741,6 +743,7 @@ type ResultMeta = {
|
|
|
741
743
|
action: string; // nome da action
|
|
742
744
|
durationMs: number;
|
|
743
745
|
requestId?: string; // trace, populado pelo adapter se disponível
|
|
746
|
+
trace?: TraceContext; // span efetivo da execução
|
|
744
747
|
cached?: boolean; // resultado serviu do cache do client
|
|
745
748
|
}
|
|
746
749
|
```
|
|
@@ -924,6 +927,7 @@ Core do Opus é **abstrato** — não tem HTTP, não tem DB, não tem framework
|
|
|
924
927
|
| Auth | `@softize/opus/auth` | `/better-auth`, `/clerk` |
|
|
925
928
|
| Audit | `@softize/opus/audit` | `/pg`, `/sentry`, `/console` |
|
|
926
929
|
| Logger | `@softize/opus/log` | `/pino` (console fica no `core`) |
|
|
930
|
+
| Observability | `@softize/opus/observability` | `/opentelemetry` (API-only; exporter é do app) |
|
|
927
931
|
| Queue | `@softize/opus/queue` | `/bullmq`, `/inngest`, `/redis` |
|
|
928
932
|
| EventBus | `@softize/opus/events` | `/mitt` (default), `/redis`, `/nats` |
|
|
929
933
|
| Client | `@softize/opus/client` | `/fetch`, `/tanstack` |
|
|
@@ -951,7 +955,7 @@ Todo adapter implementa `Adapter`:
|
|
|
951
955
|
interface Adapter {
|
|
952
956
|
name: string;
|
|
953
957
|
kind: 'server' | 'data' | 'auth' | 'audit' | 'logger'
|
|
954
|
-
| 'queue' | 'eventbus' | 'client' | 'ui' | 'schema';
|
|
958
|
+
| 'queue' | 'eventbus' | 'client' | 'ui' | 'schema' | 'observability';
|
|
955
959
|
init?: (runtime: Runtime) => Promise<void> | void;
|
|
956
960
|
dispose?: () => Promise<void> | void;
|
|
957
961
|
healthCheck?: () => Promise<{ ok: boolean, details?: object }>;
|
|
@@ -1040,8 +1044,67 @@ interface LoggerAdapter extends Adapter {
|
|
|
1040
1044
|
fatal(msg: string, meta?: object): void;
|
|
1041
1045
|
child(bindings: object): LoggerAdapter; // pre-bind contexto (per-action, per-request)
|
|
1042
1046
|
}
|
|
1047
|
+
|
|
1048
|
+
type TraceContext = {
|
|
1049
|
+
traceId: string;
|
|
1050
|
+
spanId?: string;
|
|
1051
|
+
traceFlags?: number;
|
|
1052
|
+
traceState?: string;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
interface ObservabilityAdapter extends Adapter {
|
|
1056
|
+
kind: 'observability';
|
|
1057
|
+
runInSpan<T>(
|
|
1058
|
+
operation: {
|
|
1059
|
+
name: string;
|
|
1060
|
+
kind: 'action' | 'reaction';
|
|
1061
|
+
resultKind: 'action-result' | 'void';
|
|
1062
|
+
parent?: TraceContext;
|
|
1063
|
+
attributes?: Record<string, string | number | boolean>;
|
|
1064
|
+
},
|
|
1065
|
+
run: (trace: TraceContext) => Promise<T>,
|
|
1066
|
+
): Promise<T>;
|
|
1067
|
+
}
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
O runtime chama o callback dentro do contexto ativo do driver e propaga o trace recebido.
|
|
1071
|
+
Falha do adapter é sempre lenient: antes do callback, a execução segue sem novo span; depois
|
|
1072
|
+
do callback, o runtime preserva seu resultado/erro e nunca repete efeitos. Exporters,
|
|
1073
|
+
sampling, atributos adicionais e OpenTelemetry pertencem ao driver.
|
|
1074
|
+
|
|
1075
|
+
O callback é at-most-once: chamadas concorrentes ou tardias do adapter recebem a mesma
|
|
1076
|
+
Promise. `TraceContext` não aceita baggage arbitrário. Na entrada HTTP, o core não interpreta
|
|
1077
|
+
headers W3C. Os drivers Fastify e Node oferecem o opt-in `traceContext: 'w3c'`, desativado por
|
|
1078
|
+
default, para extrair `traceparent` v00 e `tracestate` limitado e injetar o contexto efetivo
|
|
1079
|
+
na resposta. Outros transportes fornecem `ContextBase.trace` em sua própria fronteira.
|
|
1080
|
+
|
|
1081
|
+
Outcome não é inferido apenas pela Promise: em `resultKind: 'action-result'`, o callback
|
|
1082
|
+
resolve com `ActionResult` e o driver deve inspecionar `result.ok` (`false` é span de erro).
|
|
1083
|
+
Em `resultKind: 'void'`, usado por reactions, falha rejeita a Promise.
|
|
1084
|
+
|
|
1085
|
+
O driver `@softize/opus/observability/opentelemetry` usa somente `@opentelemetry/api`.
|
|
1086
|
+
Provider, context manager, resource, sampling e exporter OTLP são configurados pelo app.
|
|
1087
|
+
Ele allowlista atributos de baixa cardinalidade e não grava input/output, actor, tenant,
|
|
1088
|
+
request ID, códigos/mensagens de erro ou baggage no span.
|
|
1089
|
+
|
|
1090
|
+
### Persistência PostgreSQL de trace
|
|
1091
|
+
|
|
1092
|
+
O `pgAudit` mantém por default o INSERT legado e não exige migration. Para materializar
|
|
1093
|
+
`AuditRecord.traceContext`, o consumer adiciona colunas próprias e habilita explicitamente:
|
|
1094
|
+
|
|
1095
|
+
```sql
|
|
1096
|
+
ALTER TABLE audit_log ADD COLUMN trace_id text;
|
|
1097
|
+
ALTER TABLE audit_log ADD COLUMN span_id text;
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
```ts
|
|
1101
|
+
pgAudit({ pool, traceContextColumns: true })
|
|
1043
1102
|
```
|
|
1044
1103
|
|
|
1104
|
+
Durante rollout, nomes customizados são aceitos com
|
|
1105
|
+
`traceContextColumns: { traceId: 'otel_trace_id', spanId: 'otel_span_id' }`. As colunas
|
|
1106
|
+
legadas `trace_request_id`/`trace_parent_action_id` nunca recebem `traceId`/`spanId`.
|
|
1107
|
+
|
|
1045
1108
|
Ver §10 (Background), §11 (Eventos), §15 (RuntimeConfig) para detalhes de uso.
|
|
1046
1109
|
|
|
1047
1110
|
### Server endpoints (auto-mount)
|
|
@@ -1098,6 +1161,7 @@ const runtime = createRuntime({
|
|
|
1098
1161
|
audit: [pgAuditSink({ table: 'audit_log' })],
|
|
1099
1162
|
queue: bullmqAdapter({ connection }), // opcional — só se houver background action
|
|
1100
1163
|
eventBus: mittEventBusAdapter(), // opcional — só se houver emits
|
|
1164
|
+
observability: observabilityAdapter(), // opcional — porta vendor-neutral
|
|
1101
1165
|
})
|
|
1102
1166
|
|
|
1103
1167
|
runtime.register(actions) // monta tudo
|
|
@@ -1313,7 +1377,31 @@ type JobHandle<T = unknown> = {
|
|
|
1313
1377
|
startedAt?: string
|
|
1314
1378
|
finishedAt?: string
|
|
1315
1379
|
attempts: number
|
|
1380
|
+
trace?: TraceContext
|
|
1381
|
+
}
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
O runtime enfileira actions background em `execute()`. O `JobSpec` recebe request ID, actor,
|
|
1385
|
+
tenant, provenance original e o trace efetivo do span de enqueue. No worker, `executeJob()`
|
|
1386
|
+
revalida o carrier, exige que o actor reidratado coincida com o envelope e executa um span
|
|
1387
|
+
filho sem reenfileirar a action.
|
|
1388
|
+
|
|
1389
|
+
```ts
|
|
1390
|
+
type JobSpec = {
|
|
1391
|
+
jobId: string
|
|
1392
|
+
action: string
|
|
1393
|
+
input: unknown
|
|
1394
|
+
ctx: {
|
|
1395
|
+
userId: string | null
|
|
1396
|
+
tenantId: string | null
|
|
1397
|
+
requestId?: string
|
|
1398
|
+
trace?: TraceContext
|
|
1399
|
+
originalProvenance?: Provenance
|
|
1400
|
+
}
|
|
1401
|
+
config: BackgroundConfig
|
|
1316
1402
|
}
|
|
1403
|
+
|
|
1404
|
+
runtime.executeJob(spec, rehydratedContext, progressReporter?)
|
|
1317
1405
|
```
|
|
1318
1406
|
|
|
1319
1407
|
ActionResult de background action:
|
|
@@ -1329,18 +1417,17 @@ type BackgroundResult<T> =
|
|
|
1329
1417
|
```
|
|
1330
1418
|
HTTP request → Server adapter
|
|
1331
1419
|
↓ (1) validate input
|
|
1332
|
-
↓ (2) authenticate
|
|
1333
|
-
↓ (3)
|
|
1334
|
-
↓ (4) enqueue job via QueueAdapter
|
|
1420
|
+
↓ (2) load + authenticate + authorize
|
|
1421
|
+
↓ (3) enqueue job via QueueAdapter + audit do enqueue
|
|
1335
1422
|
↓
|
|
1336
1423
|
Server retorna JobHandle síncrono → client polla ou subscribe
|
|
1337
1424
|
↓
|
|
1338
1425
|
Worker (processo separado) consome job:
|
|
1339
|
-
↓ (a)
|
|
1340
|
-
↓ (b)
|
|
1341
|
-
↓ (c)
|
|
1342
|
-
↓ (d) audit
|
|
1343
|
-
↓ (e) atualiza
|
|
1426
|
+
↓ (a) reidrata auth/can e chama runtime.executeJob(spec, ctx, progress)
|
|
1427
|
+
↓ (b) revalida input + load + authenticate + authorize
|
|
1428
|
+
↓ (c) handler (recebe ProgressReporter se config.progress)
|
|
1429
|
+
↓ (d) validate output + audit com provenance background
|
|
1430
|
+
↓ (e) processor retorna/lança; BullMQ atualiza status done/failed
|
|
1344
1431
|
```
|
|
1345
1432
|
|
|
1346
1433
|
### Adapter: `QueueAdapter`
|
|
@@ -1425,6 +1512,7 @@ type DomainEvent<T = unknown> = {
|
|
|
1425
1512
|
actionId: string // bate com AuditRecord.id
|
|
1426
1513
|
correlation?: string // request id / trace
|
|
1427
1514
|
}
|
|
1515
|
+
trace?: TraceContext // propagado para reactions/consumidores
|
|
1428
1516
|
meta?: Record<string, unknown>
|
|
1429
1517
|
}
|
|
1430
1518
|
```
|
|
@@ -1536,6 +1624,7 @@ type ReactionContext = {
|
|
|
1536
1624
|
log: LoggerAdapter // logger pre-bound com contexto da reaction
|
|
1537
1625
|
storage: StorageAdapter | null // storage de arquivos (experimental)
|
|
1538
1626
|
provenance: Provenance // { kind: 'reaction', … }
|
|
1627
|
+
trace?: TraceContext // span filho do evento recebido
|
|
1539
1628
|
meta: Record<string, unknown>
|
|
1540
1629
|
}
|
|
1541
1630
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "9.0
|
|
3
|
+
"version": "9.1.0",
|
|
4
4
|
"description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -92,6 +92,14 @@
|
|
|
92
92
|
"types": "./src/log/drivers/pino.ts",
|
|
93
93
|
"default": "./src/log/drivers/pino.ts"
|
|
94
94
|
},
|
|
95
|
+
"./observability": {
|
|
96
|
+
"types": "./src/observability/index.ts",
|
|
97
|
+
"default": "./src/observability/index.ts"
|
|
98
|
+
},
|
|
99
|
+
"./observability/opentelemetry": {
|
|
100
|
+
"types": "./src/observability/drivers/opentelemetry.ts",
|
|
101
|
+
"default": "./src/observability/drivers/opentelemetry.ts"
|
|
102
|
+
},
|
|
95
103
|
"./queue": {
|
|
96
104
|
"types": "./src/queue/index.ts",
|
|
97
105
|
"default": "./src/queue/index.ts"
|
|
@@ -222,6 +230,7 @@
|
|
|
222
230
|
"zod-to-json-schema": "^3.23.0"
|
|
223
231
|
},
|
|
224
232
|
"peerDependencies": {
|
|
233
|
+
"@opentelemetry/api": "^1.9.0",
|
|
225
234
|
"@anthropic-ai/sdk": ">=0.35.0",
|
|
226
235
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
227
236
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
@@ -240,6 +249,9 @@
|
|
|
240
249
|
"zod": "^3.24.0"
|
|
241
250
|
},
|
|
242
251
|
"peerDependenciesMeta": {
|
|
252
|
+
"@opentelemetry/api": {
|
|
253
|
+
"optional": true
|
|
254
|
+
},
|
|
243
255
|
"@hookform/resolvers": {
|
|
244
256
|
"optional": true
|
|
245
257
|
},
|
|
@@ -294,6 +306,9 @@
|
|
|
294
306
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
295
307
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
296
308
|
"@hookform/resolvers": "^3.10.0",
|
|
309
|
+
"@opentelemetry/api": "^1.9.1",
|
|
310
|
+
"@opentelemetry/context-async-hooks": "^2.10.0",
|
|
311
|
+
"@opentelemetry/sdk-trace-base": "^2.10.0",
|
|
297
312
|
"@tanstack/react-query": "^5.62.0",
|
|
298
313
|
"@testing-library/dom": "^10.4.0",
|
|
299
314
|
"@testing-library/react": "^16.1.0",
|
package/src/audit/drivers/pg.ts
CHANGED
|
@@ -22,6 +22,12 @@
|
|
|
22
22
|
* trace_parent_action_id text,
|
|
23
23
|
* meta jsonb
|
|
24
24
|
* );
|
|
25
|
+
*
|
|
26
|
+
* Para persistir `AuditRecord.traceContext`, adicione colunas novas e opte explicitamente:
|
|
27
|
+
*
|
|
28
|
+
* ALTER TABLE audit_log ADD COLUMN trace_id text;
|
|
29
|
+
* ALTER TABLE audit_log ADD COLUMN span_id text;
|
|
30
|
+
* pgAudit({ pool, traceContextColumns: true })
|
|
25
31
|
* CREATE INDEX audit_provenance_kind_idx ON audit_log(provenance_kind);
|
|
26
32
|
*
|
|
27
33
|
* Tabela é criada pelo consumer (migration). Adapter apenas insere.
|
|
@@ -61,6 +67,16 @@ export interface PgAuditOptions {
|
|
|
61
67
|
/** Nome do sink (default 'pg'). */
|
|
62
68
|
name?: string
|
|
63
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Opt-in para persistir `AuditRecord.traceContext` sem quebrar tabelas legadas.
|
|
72
|
+
* `true` usa `trace_id`/`span_id`; objeto permite nomes customizados durante rollout.
|
|
73
|
+
* Default `false`: mantém exatamente o INSERT legado de 17 colunas.
|
|
74
|
+
*/
|
|
75
|
+
traceContextColumns?: boolean | {
|
|
76
|
+
traceId?: string
|
|
77
|
+
spanId?: string
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
/**
|
|
65
81
|
* Redator aplicado a input/output ANTES de persistir — sem ele, os dois vão
|
|
66
82
|
* CRUS (user.create/setPassword gravaria senha em claro no log). Pra dado
|
|
@@ -74,14 +90,21 @@ export interface PgAuditOptions {
|
|
|
74
90
|
export function pgAudit(options: PgAuditOptions): AuditSink {
|
|
75
91
|
const { pool, table = 'audit_log', name = 'pg', redact } = options
|
|
76
92
|
validateTableName(table)
|
|
77
|
-
const
|
|
93
|
+
const traceColumns = resolveTraceColumns(options.traceContextColumns)
|
|
94
|
+
const columns = traceColumns === undefined
|
|
95
|
+
? [...LEGACY_COLUMNS]
|
|
96
|
+
: [...LEGACY_COLUMNS, traceColumns.traceId, traceColumns.spanId]
|
|
97
|
+
const sql = buildInsertSql(table, columns)
|
|
78
98
|
|
|
79
99
|
return {
|
|
80
100
|
name,
|
|
81
101
|
kind: 'audit',
|
|
82
102
|
|
|
83
103
|
async emit(record: AuditRecord) {
|
|
84
|
-
const values = recordToValues(
|
|
104
|
+
const values = recordToValues(
|
|
105
|
+
redact === undefined ? record : redactRecord(record, redact),
|
|
106
|
+
traceColumns !== undefined,
|
|
107
|
+
)
|
|
85
108
|
await pool.query(sql, values)
|
|
86
109
|
},
|
|
87
110
|
|
|
@@ -100,7 +123,7 @@ export function pgAudit(options: PgAuditOptions): AuditSink {
|
|
|
100
123
|
// SQL builder
|
|
101
124
|
// =============================================================================
|
|
102
125
|
|
|
103
|
-
const
|
|
126
|
+
const LEGACY_COLUMNS = [
|
|
104
127
|
'id',
|
|
105
128
|
'timestamp',
|
|
106
129
|
'action',
|
|
@@ -120,14 +143,14 @@ const COLUMNS = [
|
|
|
120
143
|
'meta',
|
|
121
144
|
] as const
|
|
122
145
|
|
|
123
|
-
function buildInsertSql(table: string): string {
|
|
124
|
-
const cols =
|
|
125
|
-
const params =
|
|
146
|
+
function buildInsertSql(table: string, columns: string[]): string {
|
|
147
|
+
const cols = columns.join(', ')
|
|
148
|
+
const params = columns.map((_, i) => `$${i + 1}`).join(', ')
|
|
126
149
|
return `INSERT INTO ${table} (${cols}) VALUES (${params})`
|
|
127
150
|
}
|
|
128
151
|
|
|
129
|
-
function recordToValues(r: AuditRecord): unknown[] {
|
|
130
|
-
|
|
152
|
+
function recordToValues(r: AuditRecord, includeTraceContext: boolean): unknown[] {
|
|
153
|
+
const values: unknown[] = [
|
|
131
154
|
r.id,
|
|
132
155
|
r.timestamp,
|
|
133
156
|
r.action,
|
|
@@ -146,6 +169,29 @@ function recordToValues(r: AuditRecord): unknown[] {
|
|
|
146
169
|
r.trace?.parentActionId ?? null,
|
|
147
170
|
jsonOrNull(r.meta),
|
|
148
171
|
]
|
|
172
|
+
if (includeTraceContext) {
|
|
173
|
+
values.push(r.traceContext?.traceId ?? null, r.traceContext?.spanId ?? null)
|
|
174
|
+
}
|
|
175
|
+
return values
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function resolveTraceColumns(
|
|
179
|
+
option: PgAuditOptions['traceContextColumns'],
|
|
180
|
+
): { traceId: string; spanId: string } | undefined {
|
|
181
|
+
if (option === undefined || option === false) return undefined
|
|
182
|
+
const columns = option === true
|
|
183
|
+
? { traceId: 'trace_id', spanId: 'span_id' }
|
|
184
|
+
: { traceId: option.traceId ?? 'trace_id', spanId: option.spanId ?? 'span_id' }
|
|
185
|
+
validateColumnName(columns.traceId)
|
|
186
|
+
validateColumnName(columns.spanId)
|
|
187
|
+
if (columns.traceId === columns.spanId) {
|
|
188
|
+
throw new Error('pgAudit: traceContext column names must be distinct')
|
|
189
|
+
}
|
|
190
|
+
if (LEGACY_COLUMNS.includes(columns.traceId as (typeof LEGACY_COLUMNS)[number]) ||
|
|
191
|
+
LEGACY_COLUMNS.includes(columns.spanId as (typeof LEGACY_COLUMNS)[number])) {
|
|
192
|
+
throw new Error('pgAudit: traceContext columns must not overlap legacy columns')
|
|
193
|
+
}
|
|
194
|
+
return columns
|
|
149
195
|
}
|
|
150
196
|
|
|
151
197
|
function jsonOrNull(value: unknown): string | null {
|
|
@@ -170,3 +216,9 @@ function validateTableName(table: string): void {
|
|
|
170
216
|
)
|
|
171
217
|
}
|
|
172
218
|
}
|
|
219
|
+
|
|
220
|
+
function validateColumnName(column: string): void {
|
|
221
|
+
if (!/^[a-z_][a-z0-9_]*$/i.test(column)) {
|
|
222
|
+
throw new Error(`pgAudit: invalid column name "${column}"`)
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/core/actions.ts
CHANGED
|
@@ -102,7 +102,9 @@ export function isViewAction(
|
|
|
102
102
|
/**
|
|
103
103
|
* `true` se a action declarou execução em background (apenas `simple` e `form`).
|
|
104
104
|
*/
|
|
105
|
-
export function isBackgroundAction(
|
|
105
|
+
export function isBackgroundAction(
|
|
106
|
+
action: ActionDef,
|
|
107
|
+
): action is SimpleAction<any, any> | FormAction<any, any> {
|
|
106
108
|
return (
|
|
107
109
|
(action.kind === 'simple' || action.kind === 'form') &&
|
|
108
110
|
action.background?.enabled === true
|
package/src/core/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type {
|
|
|
9
9
|
// primitivos / shared
|
|
10
10
|
I18nRef,
|
|
11
11
|
Unsubscribe,
|
|
12
|
+
TraceContext,
|
|
12
13
|
Logger,
|
|
13
14
|
ContextBase,
|
|
14
15
|
Schema,
|
|
@@ -91,6 +92,8 @@ export type {
|
|
|
91
92
|
AuthAdapter,
|
|
92
93
|
AuditSink,
|
|
93
94
|
LoggerAdapter,
|
|
95
|
+
ObservabilityAdapter,
|
|
96
|
+
ObservabilityOperation,
|
|
94
97
|
QueueAdapter,
|
|
95
98
|
EventBusAdapter,
|
|
96
99
|
SchedulerAdapter,
|
|
@@ -123,6 +126,7 @@ export type {
|
|
|
123
126
|
// — Erro ——————————————————————————————————————————————————————————————————————
|
|
124
127
|
export { error, isActionError, normalizeError } from './errors.ts'
|
|
125
128
|
export type { ErrorInput } from './errors.ts'
|
|
129
|
+
export { normalizeTraceContext } from './trace.ts'
|
|
126
130
|
|
|
127
131
|
// — Logical types ——————————————————————————————————————————————————————————————
|
|
128
132
|
// As factories `t.*` moram em `@softize/opus/schema`; o attach/get da meta é
|