@softize/opus 9.0.8 → 9.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -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/chat.tsx +48 -2
- package/src/ui/components/primitives/composer.tsx +14 -0
- package/src/ui/docs/content/audit.md +17 -0
- package/src/ui/docs/content/chat.md +1 -1
- package/src/ui/docs/content/composer.md +1 -1
- 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
|
@@ -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 }
|
|
@@ -212,7 +212,8 @@ const ChatTranscript = React.memo(function ChatTranscript({
|
|
|
212
212
|
})
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
* Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha
|
|
215
|
+
* Chat da casa: lista de mensagens + composer (Enter envia / Shift+Enter quebra linha;
|
|
216
|
+
* com o campo vazio, ↑/↓ navegam pelas mensagens anteriores do usuário),
|
|
216
217
|
* transcript em turnos, fala do usuário em fundo discreto, assistente em Markdown direto
|
|
217
218
|
* no corpo, tool como indicador vivo
|
|
218
219
|
* (nunca mensagem) e artefatos via `renderArtifact`. Dois modos:
|
|
@@ -246,11 +247,52 @@ export function Chat({
|
|
|
246
247
|
const [input, setInput] = React.useState('')
|
|
247
248
|
const [ownBusy, setOwnBusy] = React.useState(false)
|
|
248
249
|
const [ownActivity, setOwnActivity] = React.useState<string | null>(null)
|
|
250
|
+
const historyCursor = React.useRef<number | null>(null)
|
|
251
|
+
const historyDraft = React.useRef('')
|
|
249
252
|
|
|
250
253
|
const items = controlled ? messages : ownItems
|
|
251
254
|
const busy = controlled ? (busyProp ?? false) : ownBusy
|
|
252
255
|
// Indicador: no controlado o app manda (undefined esconde); no autogerenciado segue o busy.
|
|
253
256
|
const indicator = controlled ? activityProp : ownBusy ? ownActivity : undefined
|
|
257
|
+
const inputHistory = React.useMemo(
|
|
258
|
+
() => items.filter((item): item is ChatMessage => item.role === 'user').map((item) => item.content),
|
|
259
|
+
[items],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
function changeInput(value: string): void {
|
|
263
|
+
historyCursor.current = null
|
|
264
|
+
setInput(value)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function showPreviousInput(): boolean {
|
|
268
|
+
if (inputHistory.length === 0) return false
|
|
269
|
+
|
|
270
|
+
if (historyCursor.current === null) {
|
|
271
|
+
// Não rouba ↑ do cursor em mensagens novas ou multilinha. A navegação começa no
|
|
272
|
+
// composer vazio, como em shells; depois disso ↑/↓ pertencem ao histórico.
|
|
273
|
+
if (input !== '') return false
|
|
274
|
+
historyDraft.current = input
|
|
275
|
+
historyCursor.current = inputHistory.length - 1
|
|
276
|
+
} else if (historyCursor.current > 0) {
|
|
277
|
+
historyCursor.current -= 1
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
setInput(inputHistory[historyCursor.current])
|
|
281
|
+
return true
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function showNextInput(): boolean {
|
|
285
|
+
if (historyCursor.current === null) return false
|
|
286
|
+
|
|
287
|
+
if (historyCursor.current < inputHistory.length - 1) {
|
|
288
|
+
historyCursor.current += 1
|
|
289
|
+
setInput(inputHistory[historyCursor.current])
|
|
290
|
+
} else {
|
|
291
|
+
historyCursor.current = null
|
|
292
|
+
setInput(historyDraft.current)
|
|
293
|
+
}
|
|
294
|
+
return true
|
|
295
|
+
}
|
|
254
296
|
|
|
255
297
|
const kickoffRan = React.useRef(false)
|
|
256
298
|
React.useEffect(() => {
|
|
@@ -264,6 +306,8 @@ export function Chat({
|
|
|
264
306
|
async function submit(): Promise<void> {
|
|
265
307
|
const text = input.trim()
|
|
266
308
|
if (text === '' || busy) return
|
|
309
|
+
historyCursor.current = null
|
|
310
|
+
historyDraft.current = ''
|
|
267
311
|
setInput('')
|
|
268
312
|
|
|
269
313
|
if (controlled) {
|
|
@@ -339,8 +383,10 @@ export function Chat({
|
|
|
339
383
|
{notice}
|
|
340
384
|
<Composer
|
|
341
385
|
value={input}
|
|
342
|
-
onChange={
|
|
386
|
+
onChange={changeInput}
|
|
343
387
|
onSubmit={() => void submit()}
|
|
388
|
+
onHistoryPrevious={showPreviousInput}
|
|
389
|
+
onHistoryNext={showNextInput}
|
|
344
390
|
busy={busy}
|
|
345
391
|
placeholder={placeholder}
|
|
346
392
|
actions={composerActions}
|
|
@@ -12,6 +12,10 @@ export interface ComposerProps {
|
|
|
12
12
|
onChange: (value: string) => void
|
|
13
13
|
/** Enter (sem Shift) ou o botão enviar. Só dispara quando dá pra enviar (ver `submitDisabled`). */
|
|
14
14
|
onSubmit: () => void
|
|
15
|
+
/** Navegação opcional pelo histórico do dono do composer. Retorne `true` quando a tecla
|
|
16
|
+
* foi consumida; o `<Chat>` usa isso para ↑/↓ sem interferir no cursor normal. */
|
|
17
|
+
onHistoryPrevious?: () => boolean
|
|
18
|
+
onHistoryNext?: () => boolean
|
|
15
19
|
/** Trava o composer enquanto o turno/ação corre — o enviar vira spinner. */
|
|
16
20
|
busy?: boolean
|
|
17
21
|
/** Gate EXTRA de envio além de "vazio" e "busy" (ex.: falta escolher o app). Desabilita o
|
|
@@ -39,6 +43,8 @@ export function Composer({
|
|
|
39
43
|
value,
|
|
40
44
|
onChange,
|
|
41
45
|
onSubmit,
|
|
46
|
+
onHistoryPrevious,
|
|
47
|
+
onHistoryNext,
|
|
42
48
|
busy = false,
|
|
43
49
|
submitDisabled = false,
|
|
44
50
|
placeholder = 'Escreva uma mensagem…',
|
|
@@ -59,6 +65,14 @@ export function Composer({
|
|
|
59
65
|
value={value}
|
|
60
66
|
onChange={(e) => onChange(e.target.value)}
|
|
61
67
|
onKeyDown={(e) => {
|
|
68
|
+
if (e.key === 'ArrowUp' && onHistoryPrevious?.()) {
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (e.key === 'ArrowDown' && onHistoryNext?.()) {
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
62
76
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
63
77
|
e.preventDefault()
|
|
64
78
|
fire()
|
|
@@ -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`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
## Básico
|
|
2
2
|
|
|
3
|
-
Um chat mínimo: lista de mensagens + composer. A conversa é gerenciada por dentro (estado, loading, auto-scroll; **Enter** envia, **Shift+Enter** quebra linha) — a inteligência vem da prop `send`. O `greeting` é o estado vazio (centrado; some quando a conversa começa e NÃO entra no transcript). Dê altura ao container.
|
|
3
|
+
Um chat mínimo: lista de mensagens + composer. A conversa é gerenciada por dentro (estado, loading, auto-scroll; **Enter** envia, **Shift+Enter** quebra linha) — a inteligência vem da prop `send`. Com o composer vazio, **↑** recupera as mensagens anteriores do usuário e **↓** volta em direção ao rascunho; durante a edição, as setas continuam movendo o cursor normalmente. O `greeting` é o estado vazio (centrado; some quando a conversa começa e NÃO entra no transcript). Dê altura ao container.
|
|
4
4
|
|
|
5
5
|
```tsx preview
|
|
6
6
|
<div className="h-96 rounded-lg border">
|