@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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @softize/opus/observability/opentelemetry
3
+ *
4
+ * Driver fino sobre `@opentelemetry/api`: não cria provider, exporter, collector nem
5
+ * context manager. O app configura seu SDK OTel e pode injetar um Tracer nos testes.
6
+ */
7
+
8
+ import {
9
+ context,
10
+ createTraceState,
11
+ SpanKind,
12
+ SpanStatusCode,
13
+ trace,
14
+ TraceFlags,
15
+ type Attributes,
16
+ type Span,
17
+ type SpanContext,
18
+ type Tracer,
19
+ } from '@opentelemetry/api'
20
+ import type {
21
+ ActionResult,
22
+ HealthStatus,
23
+ ObservabilityAdapter,
24
+ ObservabilityOperation,
25
+ TraceContext,
26
+ } from '../../core/index.ts'
27
+
28
+ export interface OpenTelemetryObservabilityOptions {
29
+ /** Tracer injetado; default usa o provider global configurado pelo app. */
30
+ tracer?: Tracer
31
+ /** Nome do instrumentation scope. */
32
+ instrumentationName?: string
33
+ /** Versão do instrumentation scope. */
34
+ instrumentationVersion?: string
35
+ /** Nome do adapter no health agregado. */
36
+ name?: string
37
+ /** Shutdown opt-in de recurso possuído pelo caller; nunca desliga provider global sozinho. */
38
+ shutdown?: () => Promise<void> | void
39
+ /** Health adicional do wiring/exporter possuído pelo caller. */
40
+ healthCheck?: () => Promise<HealthStatus> | HealthStatus
41
+ }
42
+
43
+ export function openTelemetryObservability(
44
+ options: OpenTelemetryObservabilityOptions = {},
45
+ ): ObservabilityAdapter {
46
+ const {
47
+ name = 'opentelemetry',
48
+ instrumentationName = '@softize/opus',
49
+ instrumentationVersion,
50
+ } = options
51
+ const tracer = options.tracer ?? trace.getTracer(instrumentationName, instrumentationVersion)
52
+
53
+ return {
54
+ name,
55
+ kind: 'observability',
56
+
57
+ async runInSpan<T>(
58
+ operation: ObservabilityOperation,
59
+ run: (traceContext: TraceContext) => Promise<T>,
60
+ ): Promise<T> {
61
+ const parent = parentContext(operation.parent)
62
+ return tracer.startActiveSpan(
63
+ spanName(operation),
64
+ {
65
+ kind: SpanKind.INTERNAL,
66
+ attributes: operationAttributes(operation),
67
+ },
68
+ parent,
69
+ async (span) => runOperation(operation, span, run),
70
+ )
71
+ },
72
+
73
+ async healthCheck(): Promise<HealthStatus> {
74
+ if (options.healthCheck !== undefined) return options.healthCheck()
75
+ return { ok: true, details: { api: 'configured' } }
76
+ },
77
+
78
+ async dispose(): Promise<void> {
79
+ await options.shutdown?.()
80
+ },
81
+ }
82
+ }
83
+
84
+ async function runOperation<T>(
85
+ operation: ObservabilityOperation,
86
+ span: Span,
87
+ run: (traceContext: TraceContext) => Promise<T>,
88
+ ): Promise<T> {
89
+ const spanContext = span.spanContext()
90
+ if (!trace.isSpanContextValid(spanContext)) {
91
+ span.end()
92
+ throw new Error(
93
+ 'OpenTelemetry tracer is not recording valid spans; configure a TracerProvider before the Opus runtime',
94
+ )
95
+ }
96
+ try {
97
+ const result = await run(fromSpanContext(spanContext))
98
+ const failed = operation.resultKind === 'action-result' && isFailedActionResult(result)
99
+ span.setAttribute('opus.outcome', failed ? 'error' : 'success')
100
+ if (failed) {
101
+ span.setAttribute('opus.error.category', result.error.category)
102
+ span.setStatus({ code: SpanStatusCode.ERROR })
103
+ } else {
104
+ span.setStatus({ code: SpanStatusCode.OK })
105
+ }
106
+ return result
107
+ } catch (err) {
108
+ span.setAttribute('opus.outcome', 'error')
109
+ span.setStatus({ code: SpanStatusCode.ERROR })
110
+ throw err
111
+ } finally {
112
+ span.end()
113
+ }
114
+ }
115
+
116
+ function parentContext(parent: TraceContext | undefined) {
117
+ if (parent === undefined) return context.active()
118
+ const spanContext = toSpanContext(parent)
119
+ return trace.isSpanContextValid(spanContext)
120
+ ? trace.setSpanContext(context.active(), spanContext)
121
+ : context.active()
122
+ }
123
+
124
+ function toSpanContext(value: TraceContext): SpanContext {
125
+ return {
126
+ traceId: value.traceId,
127
+ spanId: value.spanId ?? '0000000000000000',
128
+ traceFlags: value.traceFlags ?? TraceFlags.NONE,
129
+ ...(value.traceState !== undefined ? { traceState: createTraceState(value.traceState) } : {}),
130
+ isRemote: true,
131
+ }
132
+ }
133
+
134
+ function fromSpanContext(value: SpanContext): TraceContext {
135
+ const traceState = value.traceState?.serialize()
136
+ return {
137
+ traceId: value.traceId,
138
+ spanId: value.spanId,
139
+ traceFlags: value.traceFlags,
140
+ ...(traceState !== undefined && traceState.length > 0 ? { traceState } : {}),
141
+ }
142
+ }
143
+
144
+ function spanName(operation: ObservabilityOperation): string {
145
+ return `opus.${operation.kind} ${operation.name}`
146
+ }
147
+
148
+ /** Allowlist de baixa cardinalidade; IDs de execução/atores nunca viram atributos. */
149
+ function operationAttributes(operation: ObservabilityOperation): Attributes {
150
+ const attributes: Attributes = {
151
+ 'opus.operation.kind': operation.kind,
152
+ 'opus.result.kind': operation.resultKind,
153
+ }
154
+ const actionKind = operation.attributes?.['opus.action.kind']
155
+ if (typeof actionKind === 'string') attributes['opus.action.kind'] = actionKind
156
+ const eventType = operation.attributes?.['opus.event.type']
157
+ if (typeof eventType === 'string') attributes['opus.event.type'] = eventType
158
+ return attributes
159
+ }
160
+
161
+ function isFailedActionResult(value: unknown): value is Extract<ActionResult<unknown>, { ok: false }> {
162
+ return typeof value === 'object' && value !== null && 'ok' in value && value.ok === false &&
163
+ 'error' in value && typeof value.error === 'object' && value.error !== null &&
164
+ 'category' in value.error && typeof value.error.category === 'string'
165
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @softize/opus/observability — superfície dos drivers de observabilidade.
3
+ *
4
+ * A porta vendor-neutral (`ObservabilityAdapter`, `TraceContext`) permanece no core.
5
+ * Drivers concretos vivem em subpaths, como `./opentelemetry`.
6
+ */
7
+
8
+ export {}
@@ -6,8 +6,8 @@
6
6
  *
7
7
  * Worker (consumer-side) é responsabilidade do consumer — esse adapter
8
8
  * só **enfileira** e **consulta**. Pra processar, o consumer registra
9
- * um `new Worker(queueName, processor, { connection })` que chama
10
- * `runtime.execute()` no callback.
9
+ * um `new Worker(queueName, processor, { connection })`, reidrata auth e chama
10
+ * `runtime.executeJob()` no callback.
11
11
  *
12
12
  * Uso:
13
13
  * import { Queue } from 'bullmq'
@@ -23,6 +23,7 @@ import type {
23
23
  JobStatus,
24
24
  QueueAdapter,
25
25
  } from '../../core/index.ts'
26
+ import { normalizeTraceContext } from '../../core/index.ts'
26
27
 
27
28
  // =============================================================================
28
29
  // BullMQ-compatible interfaces (zero direct dep no tipo público)
@@ -58,11 +59,18 @@ export interface BullMQQueue {
58
59
  add(
59
60
  name: string,
60
61
  data: unknown,
61
- opts?: { jobId?: string },
62
+ opts?: BullMQAddOptions,
62
63
  ): Promise<BullMQJob>
63
64
  getJob(jobId: string): Promise<BullMQJob | undefined | null>
64
65
  }
65
66
 
67
+ export interface BullMQAddOptions {
68
+ jobId?: string
69
+ attempts?: number
70
+ priority?: number
71
+ backoff?: { type: 'fixed' | 'exponential'; delay: number }
72
+ }
73
+
66
74
  // =============================================================================
67
75
  // Adapter factory
68
76
  // =============================================================================
@@ -80,8 +88,8 @@ export function bullmqQueue(options: BullmqQueueOptions): QueueAdapter {
80
88
  kind: 'queue',
81
89
 
82
90
  async enqueue(spec: JobSpec): Promise<JobHandle> {
83
- const job = await queue.add(spec.action, spec, { jobId: spec.jobId })
84
- return jobToHandle(spec.action, job, 'queued')
91
+ const job = await queue.add(spec.action, spec, bullmqOptions(spec))
92
+ return jobToHandle(spec.action, job, 'queued', normalizeTraceContext(spec.ctx.trace))
85
93
  },
86
94
 
87
95
  async status(jobId: string): Promise<JobHandle | null> {
@@ -101,6 +109,31 @@ export function bullmqQueue(options: BullmqQueueOptions): QueueAdapter {
101
109
  }
102
110
  }
103
111
 
112
+ function bullmqOptions(spec: JobSpec): BullMQAddOptions {
113
+ const options: BullMQAddOptions = { jobId: spec.jobId }
114
+ if (spec.config.priority !== undefined) {
115
+ options.priority = { high: 1, normal: 5, low: 10 }[spec.config.priority]
116
+ }
117
+ const retry = spec.config.retry
118
+ if (retry !== undefined) {
119
+ options.attempts = retry.attempts
120
+ if (retry.backoff?.kind === 'fixed') {
121
+ options.backoff = { type: 'fixed', delay: retry.backoff.delayMs }
122
+ } else if (retry.backoff?.kind === 'exponential') {
123
+ if (
124
+ (retry.backoff.multiplier !== undefined && retry.backoff.multiplier !== 2) ||
125
+ retry.backoff.maxMs !== undefined
126
+ ) {
127
+ throw new Error(
128
+ 'BullMQ native exponential backoff supports multiplier 2 without maxMs; configure a worker backoffStrategy for custom behavior',
129
+ )
130
+ }
131
+ options.backoff = { type: 'exponential', delay: retry.backoff.initialMs }
132
+ }
133
+ }
134
+ return options
135
+ }
136
+
104
137
  // =============================================================================
105
138
  // Mapping
106
139
  // =============================================================================
@@ -136,6 +169,7 @@ function jobToHandle(
136
169
  action: string,
137
170
  job: BullMQJob,
138
171
  status: JobStatus,
172
+ trace = readTrace(job),
139
173
  ): JobHandle {
140
174
  const handle: JobHandle = {
141
175
  jobId: job.id ?? '',
@@ -144,6 +178,7 @@ function jobToHandle(
144
178
  enqueuedAt: toIso(job.timestamp),
145
179
  attempts: job.attemptsMade,
146
180
  }
181
+ if (trace !== undefined) handle.trace = trace
147
182
  if (job.processedOn !== undefined) handle.startedAt = toIso(job.processedOn)
148
183
  if (job.finishedOn !== undefined) handle.finishedAt = toIso(job.finishedOn)
149
184
  if (status === 'done' && job.returnvalue !== undefined) handle.data = job.returnvalue
@@ -161,6 +196,13 @@ function jobToHandle(
161
196
  return handle
162
197
  }
163
198
 
199
+ function readTrace(job: BullMQJob): JobHandle['trace'] | undefined {
200
+ if (typeof job.data !== 'object' || job.data === null || !('ctx' in job.data)) return undefined
201
+ const ctx = (job.data as { ctx?: unknown }).ctx
202
+ if (typeof ctx !== 'object' || ctx === null || !('trace' in ctx)) return undefined
203
+ return normalizeTraceContext((ctx as { trace?: unknown }).trace)
204
+ }
205
+
164
206
  function readAction(job: BullMQJob): string {
165
207
  if (
166
208
  typeof job.data === 'object' &&
@@ -35,12 +35,14 @@ import {
35
35
  } from '../../schema/openapi.ts'
36
36
  import {
37
37
  httpStatusFor,
38
+ extractW3CTraceContext,
38
39
  inputSourceFor,
39
40
  methodFor,
40
41
  pathFor,
41
42
  serializeResult,
42
43
  shouldMountPublicly,
43
44
  successStatusFor,
45
+ w3cTraceResponseHeaders,
44
46
  } from '../index.ts'
45
47
 
46
48
  // =============================================================================
@@ -62,6 +64,9 @@ export interface FastifyServerOptions {
62
64
 
63
65
  /** Servers do OpenAPI document. */
64
66
  openapiServers?: OpenAPIServer[]
67
+
68
+ /** Propagação HTTP opt-in. `'w3c'` extrai/injeta traceparent + tracestate. */
69
+ traceContext?: false | 'w3c'
65
70
  }
66
71
 
67
72
  // =============================================================================
@@ -74,6 +79,7 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
74
79
  apiPrefix = '/api',
75
80
  openapiInfo = { title: 'tbdlib API', version: '0.0.0' },
76
81
  openapiServers,
82
+ traceContext = false,
77
83
  } = options
78
84
  let runtime: RuntimeRef | undefined
79
85
  const mountedActions: ActionDef[] = []
@@ -104,7 +110,7 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
104
110
 
105
111
  const handler = async (req: FastifyRequest, reply: FastifyReply) => {
106
112
  const rt = requireRuntime()
107
- const ctx = await resolveCtx(rt, req)
113
+ const ctx = await resolveCtx(rt, req, traceContext)
108
114
  const input = source === 'body' ? req.body : req.query
109
115
 
110
116
  const result = await rt.execute(action.name, input, ctx)
@@ -112,6 +118,11 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
112
118
  ? successStatusFor(action)
113
119
  : httpStatusFor(result.error)
114
120
 
121
+ if (traceContext === 'w3c') {
122
+ for (const [key, value] of Object.entries(w3cTraceResponseHeaders(result.meta.trace))) {
123
+ reply.header(key, value)
124
+ }
125
+ }
115
126
  reply.code(status).send(serializeResult(result))
116
127
  }
117
128
 
@@ -138,11 +149,15 @@ export function fastifyServer(options: FastifyServerOptions): ServerAdapter {
138
149
  async function resolveCtx(
139
150
  rt: RuntimeRef,
140
151
  req: FastifyRequest,
152
+ traceContext: false | 'w3c',
141
153
  ): Promise<ContextBase> {
142
154
  const base = await resolveAuthBase(rt, req)
143
155
  // Fastify sempre popula req.id (auto-gen ou via genReqId opt). Não é optional.
144
156
  return {
145
157
  ...base,
158
+ ...(traceContext === 'w3c'
159
+ ? { trace: extractW3CTraceContext(req.headers as Record<string, string | string[] | undefined>) }
160
+ : {}),
146
161
  requestId: req.id,
147
162
  provenance: {
148
163
  kind: 'http',
@@ -42,12 +42,14 @@ import {
42
42
  } from '../../schema/openapi.ts'
43
43
  import {
44
44
  httpStatusFor,
45
+ extractW3CTraceContext,
45
46
  inputSourceFor,
46
47
  methodFor,
47
48
  pathFor,
48
49
  serializeResult,
49
50
  shouldMountPublicly,
50
51
  successStatusFor,
52
+ w3cTraceResponseHeaders,
51
53
  } from '../index.ts'
52
54
 
53
55
  // =============================================================================
@@ -72,6 +74,9 @@ export interface NodeServerOptions {
72
74
 
73
75
  /** Servers do OpenAPI document. */
74
76
  openapiServers?: OpenAPIServer[]
77
+
78
+ /** Propagação HTTP opt-in. `'w3c'` extrai/injeta traceparent + tracestate. */
79
+ traceContext?: false | 'w3c'
75
80
  }
76
81
 
77
82
  export interface NodeServerHandle {
@@ -98,6 +103,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
98
103
  endpoints,
99
104
  openapiInfo = { title: 'tbdlib API', version: '0.0.0' },
100
105
  openapiServers,
106
+ traceContext = false,
101
107
  } = options
102
108
 
103
109
  let runtime: RuntimeRef | undefined
@@ -142,7 +148,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
142
148
  routes.set(`${method} ${path}`, async (req, res, url) => {
143
149
  const rt = requireRuntime()
144
150
  const requestId = crypto.randomUUID()
145
- const ctx = await resolveCtx(rt, req, requestId)
151
+ const ctx = await resolveCtx(rt, req, requestId, traceContext)
146
152
 
147
153
  let input: unknown
148
154
  if (source === 'body') {
@@ -177,7 +183,7 @@ export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
177
183
  ? successStatusFor(action)
178
184
  : httpStatusFor(result.error)
179
185
 
180
- sendResult(res, status, result)
186
+ sendResult(res, status, result, traceContext === 'w3c')
181
187
  })
182
188
  },
183
189
 
@@ -235,10 +241,14 @@ async function resolveCtx(
235
241
  rt: RuntimeRef,
236
242
  req: IncomingMessage,
237
243
  requestId: string,
244
+ traceContext: false | 'w3c',
238
245
  ): Promise<ContextBase> {
239
246
  const base = await resolveAuthBase(rt, req)
240
247
  return {
241
248
  ...base,
249
+ ...(traceContext === 'w3c'
250
+ ? { trace: extractW3CTraceContext(req.headers) }
251
+ : {}),
242
252
  requestId,
243
253
  provenance: {
244
254
  kind: 'http',
@@ -275,9 +285,19 @@ async function readBody(req: IncomingMessage): Promise<string> {
275
285
  return body
276
286
  }
277
287
 
278
- function sendResult(res: ServerResponse, status: number, result: ActionResult<unknown>): void {
288
+ function sendResult(
289
+ res: ServerResponse,
290
+ status: number,
291
+ result: ActionResult<unknown>,
292
+ injectTrace = false,
293
+ ): void {
279
294
  res.statusCode = status
280
295
  res.setHeader('content-type', 'application/json; charset=utf-8')
296
+ if (injectTrace) {
297
+ for (const [key, value] of Object.entries(w3cTraceResponseHeaders(result.meta.trace))) {
298
+ res.setHeader(key, value)
299
+ }
300
+ }
281
301
  res.end(JSON.stringify(serializeResult(result)))
282
302
  }
283
303
 
@@ -15,8 +15,65 @@ import type {
15
15
  ActionError,
16
16
  ActionResult,
17
17
  ErrorCategory,
18
+ TraceContext,
18
19
  } from '../core/index.ts'
19
20
 
21
+ // =============================================================================
22
+ // W3C trace context
23
+ // =============================================================================
24
+
25
+ export type HttpHeaderValue = string | string[] | undefined
26
+
27
+ /** Extrai somente traceparent v00 válido; tracestate é opcional e limitado a 512 bytes. */
28
+ export function extractW3CTraceContext(
29
+ headers: Record<string, HttpHeaderValue>,
30
+ ): TraceContext | undefined {
31
+ const raw = firstHeader(headers['traceparent'])
32
+ if (raw === undefined) return undefined
33
+ const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/i.exec(raw.trim())
34
+ if (match === null) return undefined
35
+ const traceId = match[1]?.toLowerCase()
36
+ const spanId = match[2]?.toLowerCase()
37
+ const flags = match[3]
38
+ if (traceId === undefined || spanId === undefined || flags === undefined) return undefined
39
+ if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) return undefined
40
+ const traceState = validTraceState(firstHeader(headers['tracestate']))
41
+ return {
42
+ traceId,
43
+ spanId,
44
+ traceFlags: Number.parseInt(flags, 16),
45
+ ...(traceState !== undefined ? { traceState } : {}),
46
+ }
47
+ }
48
+
49
+ export function w3cTraceResponseHeaders(
50
+ traceContext: TraceContext | undefined,
51
+ ): Record<string, string> {
52
+ if (traceContext === undefined || !validTraceIds(traceContext)) return {}
53
+ const flags = (traceContext.traceFlags ?? 0) & 0xff
54
+ return {
55
+ traceparent: `00-${traceContext.traceId}-${traceContext.spanId}-${flags.toString(16).padStart(2, '0')}`,
56
+ ...(validTraceState(traceContext.traceState) !== undefined
57
+ ? { tracestate: traceContext.traceState as string }
58
+ : {}),
59
+ }
60
+ }
61
+
62
+ function firstHeader(value: HttpHeaderValue): string | undefined {
63
+ return Array.isArray(value) ? (value.length === 1 ? value[0] : undefined) : value
64
+ }
65
+
66
+ function validTraceState(value: string | undefined): string | undefined {
67
+ if (value === undefined || value.length === 0 || value.length > 512) return undefined
68
+ return /[^\x20-\x7e]/.test(value) ? undefined : value
69
+ }
70
+
71
+ function validTraceIds(value: TraceContext): value is TraceContext & { spanId: string } {
72
+ return /^[0-9a-f]{32}$/i.test(value.traceId) && !/^0+$/.test(value.traceId) &&
73
+ typeof value.spanId === 'string' && /^[0-9a-f]{16}$/i.test(value.spanId) &&
74
+ !/^0+$/.test(value.spanId)
75
+ }
76
+
20
77
  // =============================================================================
21
78
  // HTTP status mapping
22
79
  // =============================================================================
@@ -46,6 +46,7 @@ import type {
46
46
  StorageObject,
47
47
  StoragePutOptions,
48
48
  User,
49
+ TraceContext,
49
50
  } from '../core/index.ts'
50
51
 
51
52
  // =============================================================================
@@ -73,6 +74,7 @@ export interface TestContextOptions {
73
74
  storage?: StorageAdapter | null
74
75
  ai?: AiAdapter | null
75
76
  meta?: Record<string, unknown>
77
+ trace?: TraceContext
76
78
  }
77
79
 
78
80
  export interface TestContext {
@@ -133,6 +135,7 @@ export function testContext(options: TestContextOptions = {}): TestContext {
133
135
  storage: options.storage ?? null,
134
136
  ai: options.ai ? toBoundAi(options.ai) : null,
135
137
  provenance: { kind: 'system', source: 'test' },
138
+ ...(options.trace !== undefined ? { trace: options.trace } : {}),
136
139
  meta: options.meta ?? {},
137
140
  }
138
141
  return { ctx, emitted, logged }
@@ -31,6 +31,8 @@ interface AuditRecord {
31
31
  output?: unknown
32
32
  error?: ActionError
33
33
  severity: 'info' | 'warning' | 'error'
34
+ trace?: { requestId?: string; parentActionId?: string } // legado
35
+ traceContext?: TraceContext // vendor-neutral
34
36
  meta?: Record<string, unknown>
35
37
  }
36
38
  ```
@@ -48,6 +50,21 @@ const dev = consoleAudit()
48
50
  const prod = pgAudit({ pool, table: 'audit_log' })
49
51
  ```
50
52
 
53
+ O default continua compatível com a tabela legada. Para persistir o contexto novo, primeiro
54
+ adicione colunas próprias e depois habilite o opt-in — não reutilize as colunas legadas:
55
+
56
+ ```sql
57
+ ALTER TABLE audit_log ADD COLUMN trace_id text;
58
+ ALTER TABLE audit_log ADD COLUMN span_id text;
59
+ ```
60
+
61
+ ```ts
62
+ const prod = pgAudit({ pool, traceContextColumns: true })
63
+ ```
64
+
65
+ Rollouts com nomes existentes podem usar
66
+ `traceContextColumns: { traceId: 'otel_trace_id', spanId: 'otel_span_id' }`.
67
+
51
68
  ## Dado sensível: o redator global
52
69
 
53
70
  Sem redator, os sinks persistem input e output **crus** — `user.create`/`setPassword`
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: Observabilidade
3
+ ---
4
+
5
+ # Observabilidade
6
+
7
+ O core expõe uma porta vendor-neutral; o driver OpenTelemetry cria spans ativos para actions
8
+ e reactions sem escolher backend, exporter ou Collector.
9
+
10
+ ## Driver OpenTelemetry
11
+
12
+ Configure o SDK antes de criar o runtime. Exemplo com OTLP/HTTP apontando para um Collector:
13
+
14
+ ```ts
15
+ import { NodeSDK } from '@opentelemetry/sdk-node'
16
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
17
+ import { openTelemetryObservability } from '@softize/opus/observability/opentelemetry'
18
+ import { createRuntime } from '@softize/opus/core'
19
+
20
+ const sdk = new NodeSDK({
21
+ traceExporter: new OTLPTraceExporter({
22
+ url: 'http://otel-collector:4318/v1/traces',
23
+ }),
24
+ })
25
+ await sdk.start()
26
+
27
+ const runtime = createRuntime({
28
+ observability: openTelemetryObservability({
29
+ shutdown: () => sdk.shutdown(),
30
+ }),
31
+ })
32
+ ```
33
+
34
+ O app é dono do `NodeSDK`, sampling, resource (`service.name`), exporter, autenticação e
35
+ retry. A opção `shutdown` é explícita: sem ela, `runtime.dispose()` não desliga o provider
36
+ global. Um `Tracer` também pode ser injetado para testes ou providers não globais.
37
+
38
+ ## Semântica
39
+
40
+ - nomes de span: `opus.action <action>` e `opus.reaction <reaction>`;
41
+ - parent vem de `ContextBase.trace` ou do evento recebido;
42
+ - actions usam `ActionResult.ok` para status; reactions usam resolve/reject;
43
+ - somente atributos allowlisted de baixa cardinalidade são anexados;
44
+ - input, output, actor, tenant, request ID, error code/message e baggage não entram no span;
45
+ - `TraceContext` devolve apenas `traceId`, `spanId`, `traceFlags` e `traceState`.
46
+
47
+ ## Fronteira HTTP W3C
48
+
49
+ Os drivers Fastify e Node oferecem propagação explícita, desativada por default:
50
+
51
+ ```ts
52
+ fastifyServer({ app, traceContext: 'w3c' })
53
+ nodeServer({ traceContext: 'w3c' })
54
+ ```
55
+
56
+ O opt-in extrai um `traceparent` v00 válido (e `tracestate` limitado) para
57
+ `ContextBase.trace`. Depois da action, injeta na resposta o `traceparent` do contexto
58
+ efetivo retornado pelo runtime. `baggage` nunca é lido nem copiado. Headers inválidos são
59
+ ignorados; sem a opção, os drivers mantêm o comportamento anterior e não interpretam nem
60
+ emitem contexto de trace.
61
+
62
+ ## Limites
63
+
64
+ O driver OpenTelemetry não instrumenta HTTP automaticamente e não oferece backend APM.
65
+ Use o opt-in dos drivers Fastify/Node acima ou forneça `ContextBase.trace` na instrumentação
66
+ de outro transporte. A fronteira implementa somente `traceparent` v00 e uma validação
67
+ defensiva limitada de `tracestate`; não é um propagator OTel genérico.
68
+ Health do exporter/Collector só é reportado quando o app injeta `healthCheck`; o default
69
+ confirma apenas que a API do driver está configurada.
70
+ Sem um provider que produza spans válidos, o driver falha antes do callback e o runtime aplica
71
+ a degradação lenient — executa a action uma vez, sem trace — em vez de propagar IDs inválidos.
@@ -25,11 +25,14 @@ interface JobHandle<T = unknown> {
25
25
  data?: T
26
26
  error?: ActionError
27
27
  attempts: number
28
+ trace?: TraceContext
28
29
  }
29
30
  ```
30
31
 
31
- O core monta o `JobSpec` (action, input, ctx, retry/priority/timeout); o adapter persiste e
32
- processa.
32
+ `runtime.execute()` valida a chamada, cria o `JobSpec` e inclui o trace efetivo, request ID e
33
+ provenance original. O adapter persiste o envelope; o handle também expõe esse trace. No
34
+ worker, `runtime.executeJob()` revalida o envelope e executa um span filho, preservando a
35
+ cadeia request → enqueue → job sem aceitar baggage ou campos extras no carrier.
33
36
 
34
37
  ## Driver
35
38
 
@@ -52,10 +55,33 @@ const runtime = createRuntime({
52
55
  })
53
56
  ```
54
57
 
55
- A action declara `background: true` (+ retry/priority no contrato); chamá-la devolve um
58
+ A action declara `background: { enabled: true }` (+ retry/priority no contrato); chamá-la devolve um
56
59
  `JobHandle` em vez do resultado, e o cliente pola `status(jobId)` (ou `subscribe`) até
57
60
  `done`/`failed`.
58
61
 
62
+ ## Worker BullMQ
63
+
64
+ O app continua dono do `Worker` e da reidratação de autenticação. O processor chama
65
+ `executeJob()` — nunca `execute()`, que criaria outro job:
66
+
67
+ ```ts
68
+ new Worker('opus', async (job) => {
69
+ const spec = job.data as JobSpec
70
+ const ctx = await authContextForJob(spec.ctx)
71
+ const result = await runtime.executeJob(spec, ctx, {
72
+ report: (progress) => job.updateProgress(progress),
73
+ })
74
+ if (!result.ok) throw new Error(result.error.message)
75
+ return result.data
76
+ }, { connection })
77
+ ```
78
+
79
+ O runtime confirma que o actor reidratado corresponde a `spec.ctx.userId`; tenant, request,
80
+ trace e provenance vêm do envelope. O driver mapeia `attempts`, backoff nativo e prioridade.
81
+ Multiplicador exponencial diferente de 2 ou `maxMs` exige `backoffStrategy` custom do worker e
82
+ é rejeitado pelo driver em vez de ser silenciosamente ignorado. `timeout` permanece no
83
+ envelope para o worker aplicar, pois não é uma opção de execução do `Queue.add`.
84
+
59
85
  ## Limites (por enquanto)
60
86
 
61
87
  Driver hoje: `bullmq` (Redis). In-memory pra dev e outros backends (SQS, pg-boss…) entram