@lqc123qwe/car-runtime 1.6.0 → 1.8.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/README.md +26 -1
- package/package.json +1 -1
- package/scripts/release-pipeline.ts +2 -2
- package/src/cli.ts +51 -4
- package/src/host/stdio.ts +8 -2
- package/src/mcp/gateway.ts +10 -2
- package/src/ptc/runCode.ts +24 -4
- package/src/ptc/worker-entry.ts +8 -2
- package/src/runtime-core/llm.ts +49 -2
- package/src/runtime-core/telemetry.ts +57 -8
- package/src/runtime-core/trace.ts +111 -4
- package/test/s39-otlp-span-semantics.spec.ts +100 -0
- package/test/s40-metrics-cumulative.spec.ts +67 -0
- package/test/s41-mcp-serve-turn-span.spec.ts +148 -0
- package/test/s42-traceparent-propagation.spec.ts +415 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.8 · W3C traceparent 跨进程传播(D-29/D-30/D-31 · S42)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.8-迭代规划 TRACE-1/2/3):
|
|
5
|
+
* - 单元:W3C 编解码(合法/畸形/全零 fail-open);mcpToolSpan client span(挂 turn + traceparent
|
|
6
|
+
* 形态 + noop 零注入);startTurn opts.trace 远端挂链;ptcRelay relay span(worker 时刻覆盖 +
|
|
7
|
+
* error 路径);gateway.callTool `_meta` 注入与缺省不注入(字节面兼容);
|
|
8
|
+
* - E2E-A:car run --mcp --ptc 全链——假 MCP server 捕获 `_meta.traceparent` 回显进工具结果,
|
|
9
|
+
* 与 OTLP 出站 car.mcp.tool span 三点对拍闭合(traceId + spanId)+ car.ptc relay span 挂链
|
|
10
|
+
* turn + worker 计时窗口;零内容断言(结果文本/提示词不出站);
|
|
11
|
+
* - E2E-B:mcp-serve 收 `_meta.traceparent` → car.turn span 加入宿主 trace(traceId 复用 +
|
|
12
|
+
* parentSpanId = 远端 spanId);畸形 traceparent fail-open 新 trace。
|
|
13
|
+
*/
|
|
14
|
+
import { test } from 'node:test'
|
|
15
|
+
import assert from 'node:assert/strict'
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import { createServer, type Server } from 'node:http'
|
|
18
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { join, dirname } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
|
|
23
|
+
import { TurnTracer, formatTraceparent, parseTraceparent } from '../src/runtime-core/trace.ts'
|
|
24
|
+
import { McpGateway, type ClientTransport, type JsonRpcRequest, type JsonRpcResponse } from '../src/mcp/gateway.ts'
|
|
25
|
+
|
|
26
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
27
|
+
const CLI = join(ROOT, 'src', 'cli.ts')
|
|
28
|
+
|
|
29
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
30
|
+
|
|
31
|
+
// 密钥扫描口径:夹具凭据经变量注入(赋值行无引号字面量)
|
|
32
|
+
const TEST_KEY = 'test-key-123'
|
|
33
|
+
|
|
34
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void>): Promise<void> {
|
|
35
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s42-${name}-`))
|
|
36
|
+
return fn(dir).finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sse = (chunks: object[]) => chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n'
|
|
40
|
+
|
|
41
|
+
// ==================== 单元:W3C 编解码(D-29) ====================
|
|
42
|
+
|
|
43
|
+
test('S42: formatTraceparent/parseTraceparent——W3C 形态 + 畸形 fail-open(null 不抛)', () => {
|
|
44
|
+
const tid = 'a'.repeat(32)
|
|
45
|
+
const sid = 'b'.repeat(16)
|
|
46
|
+
assert.equal(formatTraceparent(tid, sid), `00-${tid}-${sid}-01`, 'flags 01(本端记录中)')
|
|
47
|
+
assert.deepEqual(parseTraceparent(`00-${tid}-${sid}-01`), { traceId: tid, parentSpanId: sid })
|
|
48
|
+
assert.deepEqual(parseTraceparent(`00-${tid}-${sid}-ff`), { traceId: tid, parentSpanId: sid }, 'flags 任意值接受(挂链不消费 flags)')
|
|
49
|
+
// 畸形 fail-open:全 null 不抛(遥测面禁 fail-hard——BD-05 同源)
|
|
50
|
+
assert.equal(parseTraceparent('garbage'), null, '非格式串')
|
|
51
|
+
assert.equal(parseTraceparent(`ff-${tid}-${sid}-01`), null, 'version 非 00')
|
|
52
|
+
assert.equal(parseTraceparent(`00-${'a'.repeat(31)}-${sid}-01`), null, 'trace-id 错长')
|
|
53
|
+
assert.equal(parseTraceparent(`00-${tid}-${'b'.repeat(15)}-01`), null, 'span-id 错长')
|
|
54
|
+
assert.equal(parseTraceparent(`00-${'0'.repeat(32)}-${sid}-01`), null, 'trace-id 全零(W3C 无效)')
|
|
55
|
+
assert.equal(parseTraceparent(`00-${tid}-${'0'.repeat(16)}-01`), null, 'span-id 全零(W3C 无效)')
|
|
56
|
+
assert.equal(parseTraceparent(`00-${'g'.repeat(32)}-${sid}-01`), null, '非 hex')
|
|
57
|
+
// 自研 id 形态天然合规:16B/8B hex 往返
|
|
58
|
+
const rt = parseTraceparent(formatTraceparent(tid, sid))
|
|
59
|
+
assert.deepEqual(rt, { traceId: tid, parentSpanId: sid }, 'format→parse 往返一致')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// ==================== 单元:mcpToolSpan client span(D-29) ====================
|
|
63
|
+
|
|
64
|
+
test('S42: mcpToolSpan——car.mcp.tool 挂 turn + traceparent 形态;noop 门面 traceparent 零注入', async () => {
|
|
65
|
+
await withTempDir('client-span', async () => {
|
|
66
|
+
const bodies: any[] = []
|
|
67
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
68
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
69
|
+
})
|
|
70
|
+
const tracer = new TurnTracer(f)
|
|
71
|
+
const turn = tracer.startTurn('T0')
|
|
72
|
+
const span = turn.mcpToolSpan('helper', 'upper')
|
|
73
|
+
// traceparent 形态 = 00-{turn traceId}-{本 span spanId}-01
|
|
74
|
+
const parsed = parseTraceparent(span.traceparent)
|
|
75
|
+
assert.ok(parsed, 'traceparent 可解析')
|
|
76
|
+
assert.equal(parsed!.traceId, turn.traceId, 'client span traceId = turn traceId')
|
|
77
|
+
span.end('ok')
|
|
78
|
+
const span2 = turn.mcpToolSpan('helper', 'upper')
|
|
79
|
+
span2.recordException(new Error('boom'))
|
|
80
|
+
span2.end('error')
|
|
81
|
+
turn.end('completed')
|
|
82
|
+
await f.flush()
|
|
83
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
84
|
+
const mcp = spans.filter((s: any) => s.name === 'car.mcp.tool')
|
|
85
|
+
assert.equal(mcp.length, 2, '两次外呼两个 client span')
|
|
86
|
+
assert.equal(mcp[0].parentSpanId, spans.find((s: any) => s.name === 'car.turn').spanId, 'client span 父 = turn spanId')
|
|
87
|
+
assert.equal(mcp[0].traceId, turn.traceId, '共享 trace')
|
|
88
|
+
const attrs = Object.fromEntries(mcp[0].attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
89
|
+
assert.equal(attrs['car.mcp_server'], 'helper', 'serverId 标识符面(零内容口径扩展登记)')
|
|
90
|
+
assert.equal(attrs['car.tool'], 'upper')
|
|
91
|
+
assert.equal(attrs['car.outcome'], 'ok')
|
|
92
|
+
// 本 span spanId 与 traceparent 三点一致(E2E-A 端到端对拍的单元基线)
|
|
93
|
+
const outSpanId = mcp[0].spanId
|
|
94
|
+
assert.equal(parsed!.parentSpanId, outSpanId, 'traceparent spanId = 出站 spanId')
|
|
95
|
+
const errAttrs = Object.fromEntries(mcp[1].attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
96
|
+
assert.equal(errAttrs['car.outcome'], 'error')
|
|
97
|
+
assert.equal(mcp[1].status.code, 'STATUS_CODE_ERROR', 'error 路径 D-26 形态')
|
|
98
|
+
// noop 门面:traceparent=''(调用方不注入 _meta——字节面兼容线)
|
|
99
|
+
const noopTracer = new TurnTracer(createTelemetryFacade(null))
|
|
100
|
+
const noopSpan = noopTracer.startTurn('T0').mcpToolSpan('helper', 'upper')
|
|
101
|
+
assert.equal(noopSpan.traceparent, '', 'noop 零注入面')
|
|
102
|
+
noopSpan.end('ok')
|
|
103
|
+
noopTracer.startTurn('T0').ptcRelay().complete(0, 0, 'ok') // noop relay 零抛
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ==================== 单元:startTurn 远端挂链(D-30) + ptcRelay(D-31) ====================
|
|
108
|
+
|
|
109
|
+
test('S42: startTurn opts.trace——turn span 加入远端 trace(traceId 复用 + parentSpanId)', async () => {
|
|
110
|
+
await withTempDir('server-link', async () => {
|
|
111
|
+
const bodies: any[] = []
|
|
112
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
113
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
114
|
+
})
|
|
115
|
+
const tracer = new TurnTracer(f)
|
|
116
|
+
const remoteTraceId = 'c'.repeat(32)
|
|
117
|
+
const remoteSpanId = 'd'.repeat(16)
|
|
118
|
+
const turn = tracer.startTurn('', { trace: { traceId: remoteTraceId, parentSpanId: remoteSpanId } })
|
|
119
|
+
turn.setTurnId('T1')
|
|
120
|
+
turn.end('completed')
|
|
121
|
+
await f.flush()
|
|
122
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
123
|
+
const t = spans.find((s: any) => s.name === 'car.turn')
|
|
124
|
+
assert.equal(t.traceId, remoteTraceId, 'traceId 复用远端(加入宿主 trace)')
|
|
125
|
+
assert.equal(t.parentSpanId, remoteSpanId, 'parentSpanId = 远端 spanId')
|
|
126
|
+
// step 子 span 同 trace 挂 turn(挂链后层级不破坏)
|
|
127
|
+
const turn2 = tracer.startTurn('', { trace: { traceId: remoteTraceId, parentSpanId: remoteSpanId } })
|
|
128
|
+
turn2.stepSpan(1).end('stop')
|
|
129
|
+
turn2.end('completed')
|
|
130
|
+
await f.flush()
|
|
131
|
+
const spans2 = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
132
|
+
const step = spans2.find((s: any) => s.name === 'car.step')
|
|
133
|
+
assert.equal(step.traceId, remoteTraceId)
|
|
134
|
+
assert.equal(step.parentSpanId, spans2.filter((s: any) => s.name === 'car.turn').at(-1)!.spanId)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('S42: ptcRelay——car.ptc span 主线程收口(worker 时刻覆盖 + 挂链 turn + error 形态)', async () => {
|
|
139
|
+
await withTempDir('ptc-relay', async () => {
|
|
140
|
+
const bodies: any[] = []
|
|
141
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
142
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
143
|
+
})
|
|
144
|
+
const tracer = new TurnTracer(f)
|
|
145
|
+
const turn = tracer.startTurn('T0')
|
|
146
|
+
const relay = turn.ptcRelay()
|
|
147
|
+
assert.deepEqual(relay.workerTrace, { traceId: turn.traceId, parentSpanId: turn.spanId }, 'workerTrace 载荷 = turn 挂链')
|
|
148
|
+
// ok 路径:worker 计时时刻覆盖(1000→1060 不经 now()——精确出站)
|
|
149
|
+
relay.complete(1000, 1060, 'ok')
|
|
150
|
+
// error 路径:recordException D-26 形态
|
|
151
|
+
turn.ptcRelay().complete(2000, 2250, 'error', new Error('boom'))
|
|
152
|
+
turn.end('completed')
|
|
153
|
+
await f.flush()
|
|
154
|
+
const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
|
|
155
|
+
const ptc = spans.filter((s: any) => s.name === 'car.ptc')
|
|
156
|
+
assert.equal(ptc.length, 2)
|
|
157
|
+
assert.equal(ptc[0].startTimeUnixNano, String(1000 * 1e6), 'startMs 时刻覆盖(非 now())')
|
|
158
|
+
assert.equal(ptc[0].endTimeUnixNano, String(1060 * 1e6), 'endMs 时刻覆盖')
|
|
159
|
+
assert.equal(ptc[0].parentSpanId, turn.spanId, 'car.ptc 挂 turn')
|
|
160
|
+
assert.equal(ptc[0].traceId, turn.traceId, '同 trace')
|
|
161
|
+
assert.equal(ptc[0].status.code, 'STATUS_CODE_UNSET')
|
|
162
|
+
assert.equal(ptc[1].status.code, 'STATUS_CODE_ERROR', 'error 路径 status')
|
|
163
|
+
const ev = ptc[1].events?.find((e: any) => e.name === 'exception')
|
|
164
|
+
assert.ok(ev, 'error 路径 exception 事件(D-26)')
|
|
165
|
+
// 负时长防线:endMs < startMs 收口为 startMs(防御下游转换器)
|
|
166
|
+
turn.ptcRelay().complete(3000, 2999, 'ok')
|
|
167
|
+
turn.end('completed')
|
|
168
|
+
await f.flush()
|
|
169
|
+
const ptc3 = bodies.flatMap(b => b.scopeSpans[0].spans).filter((s: any) => s.name === 'car.ptc').at(-1)!
|
|
170
|
+
assert.equal(ptc3.endTimeUnixNano, ptc3.startTimeUnixNano, '负时长钳制')
|
|
171
|
+
// noop 门面:workerTrace null + complete 零出站
|
|
172
|
+
const noopRelay = new TurnTracer(createTelemetryFacade(null)).startTurn('T0').ptcRelay()
|
|
173
|
+
assert.equal(noopRelay.workerTrace, null)
|
|
174
|
+
noopRelay.complete(0, 0, 'ok')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// ==================== 单元:gateway.callTool `_meta` 注入(D-29) ====================
|
|
179
|
+
|
|
180
|
+
test('S42: callTool traceparent 注入 params._meta;缺省无 _meta 键(字节面兼容)', async () => {
|
|
181
|
+
await withTempDir('meta', async () => {
|
|
182
|
+
const sent: JsonRpcRequest[] = []
|
|
183
|
+
const transport: ClientTransport = {
|
|
184
|
+
send: async (req) => {
|
|
185
|
+
sent.push(req)
|
|
186
|
+
// tools/list 需回工具面(登记制:无工具则 callTool 不发送直接 unknown tool)
|
|
187
|
+
const result = req.method === 'tools/list'
|
|
188
|
+
? { tools: [{ name: 'upper', sideEffect: 'readonly' }] }
|
|
189
|
+
: {}
|
|
190
|
+
return { jsonrpc: '2.0', id: req.id, result } as JsonRpcResponse
|
|
191
|
+
},
|
|
192
|
+
alive: () => true,
|
|
193
|
+
close: () => {},
|
|
194
|
+
}
|
|
195
|
+
const gw = new McpGateway()
|
|
196
|
+
await gw.register({ serverId: 'helper', transport })
|
|
197
|
+
sent.length = 0
|
|
198
|
+
await gw.callTool('helper', 'upper', { text: 'x' }, { traceparent: formatTraceparent('a'.repeat(32), 'b'.repeat(16)) })
|
|
199
|
+
assert.equal((sent[0].params as any)._meta?.traceparent, formatTraceparent('a'.repeat(32), 'b'.repeat(16)), '_meta.traceparent 注入')
|
|
200
|
+
assert.equal(sent[0].params && 'name' in (sent[0].params as object), true, 'params.name 保持')
|
|
201
|
+
await gw.callTool('helper', 'upper', { text: 'x' })
|
|
202
|
+
const p2 = sent[1].params as Record<string, unknown>
|
|
203
|
+
assert.equal('_meta' in p2, false, '缺省不注入(线上 JSON-RPC 字节面不变)')
|
|
204
|
+
await gw.callTool('helper', 'upper', { text: 'x' }, { traceparent: '' })
|
|
205
|
+
assert.equal('_meta' in (sent[2].params as object), false, '空串不注入(noop 门面路径)')
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ==================== E2E-A:car run --mcp --ptc 全链(D-29/D-31 生产调用点) ====================
|
|
210
|
+
|
|
211
|
+
/** 受控 stdio MCP server(node 脚本文本):tools/call 捕获 _meta.traceparent 回显进工具结果 */
|
|
212
|
+
const FAKE_MCP_SERVER = `
|
|
213
|
+
const lines = [];
|
|
214
|
+
let buf = '';
|
|
215
|
+
process.stdin.on('data', d => {
|
|
216
|
+
buf += d.toString();
|
|
217
|
+
let nl;
|
|
218
|
+
while ((nl = buf.indexOf('\\n')) >= 0) {
|
|
219
|
+
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
|
|
220
|
+
if (!line) continue;
|
|
221
|
+
const req = JSON.parse(line);
|
|
222
|
+
if (req.method === 'tools/list') {
|
|
223
|
+
reply(req, { tools: [{ name: 'upper', description: '大写工具', sideEffect: 'readonly', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }] });
|
|
224
|
+
} else if (req.method === 'tools/call') {
|
|
225
|
+
const tp = (req.params._meta || {}).traceparent || 'none';
|
|
226
|
+
reply(req, { content: [{ type: 'text', text: String(req.params.arguments.text).toUpperCase() + '|tp:' + tp }] });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
function reply(req, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); }
|
|
231
|
+
`
|
|
232
|
+
|
|
233
|
+
const RUN_CODE = `const r = await tools.mcp_helper_upper({ text: 'abc' })\nawait new Promise(res => setTimeout(res, 60))\nreturn r`
|
|
234
|
+
|
|
235
|
+
test('S42 E2E-A: car run 全链——_meta.traceparent 与 OTLP car.mcp.tool 三点闭合 + car.ptc relay span', async () => {
|
|
236
|
+
await withTempDir('full-chain', async dir => {
|
|
237
|
+
// 插件(run 首位参数——s32 同款装配面)
|
|
238
|
+
writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\n` +
|
|
239
|
+
`export default function apply() {}\n`)
|
|
240
|
+
writeFileSync(join(dir, 'car.config.json'), JSON.stringify({
|
|
241
|
+
llm: { model: 'test-model' },
|
|
242
|
+
mcp: { servers: { helper: { command: process.execPath, args: ['-e', FAKE_MCP_SERVER] } } },
|
|
243
|
+
}))
|
|
244
|
+
// 假 LLM:req1 → run_code(worker 桥调 MCP);req2 → 直调 MCP 工具;req3 → done
|
|
245
|
+
const llmBodies: any[] = []
|
|
246
|
+
let n = 0
|
|
247
|
+
const llmServer: Server = createServer((req, res) => {
|
|
248
|
+
let raw = ''
|
|
249
|
+
req.on('data', c => { raw += c })
|
|
250
|
+
req.on('end', () => {
|
|
251
|
+
llmBodies.push(JSON.parse(raw))
|
|
252
|
+
res.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
253
|
+
res.end(n++ === 0
|
|
254
|
+
? sse([
|
|
255
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'run_code', arguments: JSON.stringify({ code: RUN_CODE, description: '经桥调 MCP 大写' } ) } }] } }] },
|
|
256
|
+
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
|
257
|
+
])
|
|
258
|
+
: n === 2
|
|
259
|
+
? sse([
|
|
260
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'c2', function: { name: 'mcp_helper_upper', arguments: JSON.stringify({ text: 'xyz' } ) } }] } }] },
|
|
261
|
+
{ choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
|
|
262
|
+
])
|
|
263
|
+
: sse([{ choices: [{ delta: { role: 'assistant', content: '' } }] }, { choices: [{ delta: { content: 'done' } }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] }]))
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
await new Promise<void>(r => llmServer.listen(0, '127.0.0.1', r))
|
|
267
|
+
const llmPort = (llmServer.address() as { port: number }).port
|
|
268
|
+
// OTLP 捕获端点
|
|
269
|
+
const otlpBodies: any[] = []
|
|
270
|
+
const otlp: Server = createServer((req, res) => {
|
|
271
|
+
let raw = ''
|
|
272
|
+
req.on('data', c => { raw += c })
|
|
273
|
+
req.on('end', () => { if (req.url === '/v1/traces') otlpBodies.push(JSON.parse(raw)); res.writeHead(200).end() })
|
|
274
|
+
})
|
|
275
|
+
await new Promise<void>(r => otlp.listen(0, '127.0.0.1', r))
|
|
276
|
+
const otlpPort = (otlp.address() as { port: number }).port
|
|
277
|
+
|
|
278
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
|
|
279
|
+
'--prompt', 'uppercase please', '--base-url', `http://127.0.0.1:${llmPort}/v1`,
|
|
280
|
+
'--mcp', 'helper', '--ptc'], {
|
|
281
|
+
cwd: dir,
|
|
282
|
+
env: { ...process.env, OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1', CAR_OTEL_ENDPOINT: `http://127.0.0.1:${otlpPort}` },
|
|
283
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
284
|
+
})
|
|
285
|
+
let out = ''
|
|
286
|
+
let err = ''
|
|
287
|
+
child.stdout.on('data', d => { out += d })
|
|
288
|
+
child.stderr.on('data', d => { err += d })
|
|
289
|
+
const exitDone = new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
|
|
290
|
+
const code = await Promise.race([exitDone, new Promise<number>(r => setTimeout(() => r(-999), 60_000))])
|
|
291
|
+
llmServer.close()
|
|
292
|
+
otlp.close()
|
|
293
|
+
|
|
294
|
+
assert.equal(code, 0, `exit=${code}\nSTDOUT:\n${out}\nSTDERR:\n${err}`)
|
|
295
|
+
assert.match(out, /turnEnd=completed/)
|
|
296
|
+
// 工具结果含回显 traceparent(假 MCP server 捕获 _meta)——桥内与直调两条路径
|
|
297
|
+
const toolMsgs = llmBodies.flatMap(b => b.messages).filter((m: any) => m.role === 'tool')
|
|
298
|
+
const echoed: string[] = []
|
|
299
|
+
for (const m of toolMsgs) {
|
|
300
|
+
const s = JSON.stringify(m.content)
|
|
301
|
+
for (const g of s.matchAll(/tp:(00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2})/g)) echoed.push(g[1]!)
|
|
302
|
+
}
|
|
303
|
+
assert.ok(echoed.length >= 2, `两条 MCP 路径均回显 traceparent(${JSON.stringify(echoed)})`)
|
|
304
|
+
|
|
305
|
+
// OTLP 出站对拍(三点闭合:回显 traceparent ↔ 出站 car.mcp.tool spanId ↔ 同 traceId)
|
|
306
|
+
await new Promise(r => setTimeout(r, 300))
|
|
307
|
+
const spans = otlpBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
308
|
+
const turnSpan = spans.find((s: any) => s.name === 'car.turn')
|
|
309
|
+
assert.ok(turnSpan, 'turn span 出站')
|
|
310
|
+
const mcpSpans = spans.filter((s: any) => s.name === 'car.mcp.tool')
|
|
311
|
+
assert.equal(mcpSpans.length, 2, '两次 MCP 外呼两个 client span')
|
|
312
|
+
for (const s of mcpSpans) {
|
|
313
|
+
assert.equal(s.parentSpanId, turnSpan.spanId, 'client span 父 = turn')
|
|
314
|
+
assert.equal(s.traceId, turnSpan.traceId, '同 trace')
|
|
315
|
+
}
|
|
316
|
+
const outSpanIds = new Set(mcpSpans.map((s: any) => s.spanId))
|
|
317
|
+
const outTraceId = turnSpan.traceId
|
|
318
|
+
for (const tp of echoed) {
|
|
319
|
+
const p = parseTraceparent(tp)!
|
|
320
|
+
assert.ok(p, `回显 traceparent 可解析:${tp}`)
|
|
321
|
+
assert.equal(p.traceId, outTraceId, '回显 traceId == 出站 turn traceId(端到端同 trace)')
|
|
322
|
+
assert.ok(outSpanIds.has(p.parentSpanId), '回显 spanId == 出站 car.mcp.tool spanId(client span 对拍闭合)')
|
|
323
|
+
}
|
|
324
|
+
// car.ptc relay span:挂链 turn + worker 计时窗口(程序含 60ms sleep)
|
|
325
|
+
const ptcSpan = spans.find((s: any) => s.name === 'car.ptc')
|
|
326
|
+
assert.ok(ptcSpan, 'car.ptc relay span 出站')
|
|
327
|
+
assert.equal(ptcSpan.parentSpanId, turnSpan.spanId, 'car.ptc 挂 turn')
|
|
328
|
+
assert.equal(ptcSpan.traceId, turnSpan.traceId, 'car.ptc 同 trace')
|
|
329
|
+
const durMs = (Number(ptcSpan.endTimeUnixNano) - Number(ptcSpan.startTimeUnixNano)) / 1e6
|
|
330
|
+
assert.ok(durMs >= 50 && durMs < 5000, `worker 计时窗口 ≈ 程序 sleep(${durMs.toFixed(1)}ms)`)
|
|
331
|
+
assert.equal(ptcSpan.status.code, 'STATUS_CODE_UNSET', 'ok 路径无 exception')
|
|
332
|
+
// 零内容口径:出站体无会话内容(提示词/工具结果文本/程序体不出站)
|
|
333
|
+
const all = JSON.stringify(otlpBodies)
|
|
334
|
+
assert.equal(/uppercase please|大写工具|setTimeout/.test(all), false, 'span 出站体零内容')
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// ==================== E2E-B:mcp-serve 收 traceparent 挂链(D-30 生产调用点) ====================
|
|
339
|
+
|
|
340
|
+
test('S42 E2E-B: mcp-serve——_meta.traceparent 合法挂链远端 trace;畸形 fail-open 新 trace', async () => {
|
|
341
|
+
await withTempDir('mcp-serve', async dir => {
|
|
342
|
+
const traceBodies: any[] = []
|
|
343
|
+
let done: (v: void) => void = () => {}
|
|
344
|
+
const closed = new Promise<void>(r => { done = r })
|
|
345
|
+
const server: Server = createServer((req, res) => {
|
|
346
|
+
let body = ''
|
|
347
|
+
req.on('data', c => { body += c })
|
|
348
|
+
req.on('end', () => {
|
|
349
|
+
if (req.url === '/v1/traces') traceBodies.push(JSON.parse(body))
|
|
350
|
+
res.writeHead(200).end()
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
354
|
+
const port = (server.address() as { port: number }).port
|
|
355
|
+
try {
|
|
356
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
357
|
+
cwd: dir,
|
|
358
|
+
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
359
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
360
|
+
})
|
|
361
|
+
let err = ''
|
|
362
|
+
child.stderr.on('data', d => { err += d })
|
|
363
|
+
child.on('exit', () => done())
|
|
364
|
+
const { deriveSessionId } = await import('../src/host/mappings.ts')
|
|
365
|
+
const sid = deriveSessionId('claude-code', 's42-link')
|
|
366
|
+
const remoteTraceId = 'c'.repeat(32)
|
|
367
|
+
const remoteSpanId = 'd'.repeat(16)
|
|
368
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's42-link' } } }) + '\n')
|
|
369
|
+
// 合法 traceparent → turn span 加入宿主 trace
|
|
370
|
+
child.stdin.write(JSON.stringify({
|
|
371
|
+
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
|
372
|
+
params: {
|
|
373
|
+
name: 'session_turn',
|
|
374
|
+
arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'hi' } }, { hostEvent: 'turn_complete' }] } },
|
|
375
|
+
_meta: { traceparent: formatTraceparent(remoteTraceId, remoteSpanId) },
|
|
376
|
+
},
|
|
377
|
+
}) + '\n')
|
|
378
|
+
// 畸形 traceparent → fail-open 新 trace(不 fail-hard)
|
|
379
|
+
child.stdin.write(JSON.stringify({
|
|
380
|
+
jsonrpc: '2.0', id: 3, method: 'tools/call',
|
|
381
|
+
params: {
|
|
382
|
+
name: 'session_turn',
|
|
383
|
+
arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'ho' } }, { hostEvent: 'turn_complete' }] } },
|
|
384
|
+
_meta: { traceparent: 'not-a-traceparent' },
|
|
385
|
+
},
|
|
386
|
+
}) + '\n')
|
|
387
|
+
child.stdin.end()
|
|
388
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
389
|
+
try {
|
|
390
|
+
await Promise.race([
|
|
391
|
+
closed,
|
|
392
|
+
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
393
|
+
])
|
|
394
|
+
} finally {
|
|
395
|
+
if (raceTimer) clearTimeout(raceTimer)
|
|
396
|
+
}
|
|
397
|
+
const spans = traceBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
398
|
+
const turns = spans.filter((s: any) => s.name === 'car.turn')
|
|
399
|
+
assert.equal(turns.length, 2, '两次 session_turn 两个 turn span')
|
|
400
|
+
const linked = turns.find((s: any) => s.traceId === remoteTraceId)
|
|
401
|
+
assert.ok(linked, '合法 traceparent:turn span 加入宿主 trace(traceId 复用)')
|
|
402
|
+
assert.equal(linked.parentSpanId, remoteSpanId, 'parentSpanId = 远端 spanId')
|
|
403
|
+
const attrs = Object.fromEntries(linked.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
404
|
+
assert.equal(attrs['car.turn_id'], 'T1', 'turnId 回填不受挂链影响')
|
|
405
|
+
assert.equal(attrs['car.outcome'], 'completed')
|
|
406
|
+
const fallback = turns.find((s: any) => s.traceId !== remoteTraceId)
|
|
407
|
+
assert.ok(fallback, '畸形 traceparent:fail-open 新 trace')
|
|
408
|
+
assert.equal('parentSpanId' in fallback, false, 'fail-open 无 parent(新独立 trace)')
|
|
409
|
+
assert.equal(/hi|ho|user_message/.test(JSON.stringify(traceBodies)), false, 'span 出站体零内容')
|
|
410
|
+
} finally {
|
|
411
|
+
server.closeAllConnections()
|
|
412
|
+
server.close()
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
})
|