@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 +36 -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/components/primitives/ask.tsx +186 -0
- package/src/ui/docs/content/ask.md +31 -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/src/ui/docs/registry.tsx +2 -0
- package/src/ui/meta.ts +6 -0
- package/src/ui/react.tsx +2 -0
|
@@ -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
|
+
}
|
|
@@ -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 })
|
|
10
|
-
* `runtime.
|
|
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?:
|
|
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,
|
|
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(
|
|
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
|
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
// =============================================================================
|
package/src/testing/index.ts
CHANGED
|
@@ -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 }
|