@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.
@@ -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 }