@softize/opus 9.0.9 → 9.1.1

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 CHANGED
@@ -11,6 +11,42 @@ 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.1 — 2026-08-20
15
+
16
+ **`Ask` leva elicitação estruturada para `@softize/opus/ui/react`.** O componente
17
+ controlado apresenta de uma a quatro `AskQuestion`, deriva `AskAnswer[]` sem tipos
18
+ paralelos e suporta seleção única por radiogroup, seleção múltipla por pills e texto livre
19
+ sempre disponível. Descrições usam Tooltip; teclado, validação, estados `disabled`/`busy`
20
+ e submit acessível ficam no componente. Transporte, SSE, persistência e integração com
21
+ `ChatEvent` continuam sob responsabilidade do consumidor.
22
+
23
+ ## 9.1.0 — 2026-08-19
24
+
25
+ **Observabilidade ganha uma porta vendor-neutral no runtime.** `ObservabilityAdapter`
26
+ executa actions e reactions dentro de um span ativo sem acoplar o core a OpenTelemetry.
27
+ `TraceContext` passa de forma opcional por contextos, resultados, `AuditRecord.traceContext`,
28
+ eventos e envelopes de job fornecidos pelo chamador;
29
+ falhas do adapter são lenient e nunca repetem nem substituem o resultado da action.
30
+
31
+ `AuditRecord.trace` e as colunas PostgreSQL legadas permanecem inalterados. O carrier novo
32
+ não inclui baggage arbitrário. Os drivers Fastify e Node oferecem propagação HTTP W3C opt-in
33
+ com `traceContext: 'w3c'`: `traceparent` v00 entra como parent e o contexto efetivo é emitido
34
+ na resposta; o default legado não lê nem escreve esses headers. Causalidade runtime→job
35
+ não depende mais de montagem manual do envelope: actions `background` são enfileiradas por
36
+ `execute()`, e o worker usa `executeJob()` para executar um span filho sem reenfileirar.
37
+ O carrier desserializado é revalidado por allowlist; o actor reidratado deve coincidir com o
38
+ envelope. BullMQ mapeia attempts, backoff nativo e prioridade sem simular opções incompatíveis.
39
+
40
+ `pgAudit({ traceContextColumns: true })` persiste opt-in em `trace_id`/`span_id`; sem a
41
+ opção, o INSERT legado continua com 17 colunas e não exige migration. Nomes customizados
42
+ permitem rollout gradual. Operações observáveis declaram `resultKind`: drivers devem
43
+ inspecionar `ActionResult.ok` para actions, pois erro de negócio resolve com `ok:false`.
44
+
45
+ **Driver OpenTelemetry opt-in.** O subpath
46
+ `@softize/opus/observability/opentelemetry` implementa a porta usando apenas a API OTel e
47
+ aceita `Tracer`, health e shutdown injetados. Provider, context manager, sampling, resource,
48
+ OTLP exporter e Collector continuam sob controle do app; nenhum backend acompanha o SDK.
49
+
14
50
  ## 9.0.9 — 2026-08-19
15
51
 
16
52
  **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 registry compartilhado da Softize (`registry.softize.com.br`), não no
108
- npm público. `pnpm release [patch|minor|major|x.y.z]` bumpa + publica. Passo a passo
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; // quando esta action é disparada por outra
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
- - Traces distribuídosusa OpenTelemetry no adapter; opus carrega `trace.requestId` se o adapter populou.
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) authorize (sem load load roda no worker)
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) load
1340
- ↓ (b) handler (recebe ProgressReporter se config.progress)
1341
- ↓ (c) validate output
1342
- ↓ (d) audit
1343
- ↓ (e) atualiza JobHandle.status = done/failed
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.9",
3
+ "version": "9.1.1",
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",
@@ -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 sql = buildInsertSql(table)
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(redact === undefined ? record : redactRecord(record, redact))
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 COLUMNS = [
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 = COLUMNS.join(', ')
125
- const params = COLUMNS.map((_, i) => `$${i + 1}`).join(', ')
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
- return [
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
+ }
@@ -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(action: ActionDef): boolean {
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 é