@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
|
@@ -7,6 +7,28 @@
|
|
|
7
7
|
* 「内容治理属调用方」口径);设计微调登记:规划原文「runTurn opts.telemetry 注入面」改为装配层
|
|
8
8
|
* 闭包挂点,语义等价(turn/step 层级与出站面不变),loop 层保持纯停止语义;
|
|
9
9
|
* - attributes 零内容面:turn_id/step 序号/outcome 枚举——无会话内容/提示词/路径。
|
|
10
|
+
*
|
|
11
|
+
* 1.7-S3(D-28):mcp-serve turn span 面——sessionTurn 挂 turn 级 span(无 step span:mcp-serve
|
|
12
|
+
* 无 model() 循环,step span 面外推登记为不适用)。facade 内部自增派生 turnId(T{n}),装配层
|
|
13
|
+
* 调用前不可知——TurnSpanHandle 加法 `setTurnId` 回填面(car run 直传 turnId 路径不受影响);
|
|
14
|
+
* startTurn('') 空值不设 car.turn_id 属性(回填前出站形态无占位内容)。
|
|
15
|
+
*
|
|
16
|
+
* 1.8(D-29/D-30/D-31 · W3C traceparent 跨进程传播):自研 traceId(16B hex)与 spanId(8B hex)
|
|
17
|
+
* 格式天然满足 W3C Trace Context(trace-id 32hex / span-id 16hex),1.8 补齐编解码与三边界载体:
|
|
18
|
+
* - formatTraceparent/parseTraceparent(D-29):纯字符串编解码,零依赖;parse 严格校验
|
|
19
|
+
* (version 00 / 32hex / 16hex / 全零拒绝),畸形返回 null——**协议面 fail-open**(挂链放弃、
|
|
20
|
+
* 新 trace,遥测面禁 fail-hard——BD-05 同源);flags 出站恒 '01'(本端记录中——采样为端点
|
|
21
|
+
* 本地决策,不跨端传播语义);
|
|
22
|
+
* - mcpToolSpan(D-29):car run 外呼 MCP 工具的 client span(parent = turn span——step span 已于
|
|
23
|
+
* model() 返回时收口,工具执行在 step 外);attributes car.mcp_server/car.tool 为**登记标识符面**
|
|
24
|
+
* (零内容口径扩展:注册表枚举标识,非会话内容);traceparent 供 gateway.callTool 注入
|
|
25
|
+
* params._meta(otel 关 = '' 不注入,线上 JSON-RPC 字节面不变);
|
|
26
|
+
* - startTurn 加法 opts.trace(D-30):mcp-serve 服务端挂链远端宿主——traceId 复用远端 +
|
|
27
|
+
* parentSpanId = 远端 spanId,car.turn span 加入宿主 trace(fail-open:畸形 traceparent 由
|
|
28
|
+
* 调用方 parse 为 null 后不传,新 trace);
|
|
29
|
+
* - ptcRelay(D-31):PTC worker 桥挂链——workerTrace 载荷(traceId/parentSpanId)经 workerData
|
|
30
|
+
* 进隔离体,worker 计时经 done 消息回传后 complete() 主线程单点收口 car.ptc span(facade
|
|
31
|
+
* time.startMs/end(atMs) 时刻覆盖;单 exporter 纪律——worker 零独立出站通道)。
|
|
10
32
|
*/
|
|
11
33
|
import { randomBytes } from 'node:crypto'
|
|
12
34
|
import type { TelemetryFacade } from './telemetry.ts'
|
|
@@ -16,11 +38,53 @@ export interface StepSpanHandle {
|
|
|
16
38
|
recordException(e: unknown): void
|
|
17
39
|
}
|
|
18
40
|
|
|
41
|
+
/** 1.8-D29:MCP 外呼 client span 句柄——traceparent = '' 表示无遥测(不注入 _meta) */
|
|
42
|
+
export interface McpClientSpanHandle {
|
|
43
|
+
readonly traceparent: string
|
|
44
|
+
end(outcome: string): void
|
|
45
|
+
recordException(e: unknown): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 1.8-D31:PTC worker 桥挂链句柄——workerTrace=null 表示无遥测(worker 零感知零改动) */
|
|
49
|
+
export interface PtcRelayHandle {
|
|
50
|
+
readonly workerTrace: { traceId: string; parentSpanId: string } | null
|
|
51
|
+
/** worker 计时回传收口(主线程单点出站;error 路径 recordException D-26 形态) */
|
|
52
|
+
complete(startMs: number, endMs: number, outcome: string, error?: unknown): void
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
export interface TurnSpanHandle {
|
|
20
56
|
traceId: string
|
|
21
57
|
spanId: string
|
|
58
|
+
/** 1.7-D28:turnId 事后回填(mcp-serve——facade 内部自增派生,调用前不可知;car run 直传不受影响) */
|
|
59
|
+
setTurnId(turnId: string): void
|
|
60
|
+
/** 1.7-D28:turn 级异常留痕(D-26 exception 形态在 turn span 同样成立) */
|
|
61
|
+
recordException(e: unknown): void
|
|
22
62
|
end(outcome: string): void
|
|
23
63
|
stepSpan(step: number): StepSpanHandle
|
|
64
|
+
/** 1.8-D29:MCP 外呼 client span(parent = turn span;noop 门面 traceparent='' 零注入) */
|
|
65
|
+
mcpToolSpan(serverId: string, tool: string): McpClientSpanHandle
|
|
66
|
+
/** 1.8-D31:PTC worker 桥挂链面(noop 门面 workerTrace=null 零载荷) */
|
|
67
|
+
ptcRelay(): PtcRelayHandle
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 1.8-D29:W3C traceparent 出站形态(flags '01' = 本端记录中;采样为端点本地决策不跨端传播) */
|
|
71
|
+
export function formatTraceparent(traceId: string, spanId: string): string {
|
|
72
|
+
return `00-${traceId}-${spanId}-01`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const W3C_TP_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 1.8-D29:W3C traceparent 解析——严格校验(version 00 / trace-id 32hex 非 / span-id 16hex 非 / 全零拒绝)。
|
|
79
|
+
* 畸形返回 null = 挂链放弃(调用方走新 trace——协议面 fail-open,禁 fail-hard)。
|
|
80
|
+
*/
|
|
81
|
+
export function parseTraceparent(tp: string): { traceId: string; parentSpanId: string } | null {
|
|
82
|
+
const m = W3C_TP_RE.exec(tp.trim())
|
|
83
|
+
if (!m) return null
|
|
84
|
+
const traceId = m[1]!
|
|
85
|
+
const parentSpanId = m[2]!
|
|
86
|
+
if (/^0+$/.test(traceId) || /^0+$/.test(parentSpanId)) return null
|
|
87
|
+
return { traceId, parentSpanId }
|
|
24
88
|
}
|
|
25
89
|
|
|
26
90
|
export class TurnTracer {
|
|
@@ -31,16 +95,27 @@ export class TurnTracer {
|
|
|
31
95
|
this.#facade = facade?.enabled ? facade : null
|
|
32
96
|
}
|
|
33
97
|
|
|
34
|
-
|
|
98
|
+
/** 1.8-D30:opts.trace = 远端挂链(mcp-serve session_turn `_meta.traceparent` 提取后传入;缺省新 trace 不变) */
|
|
99
|
+
startTurn(turnId: string, opts?: { trace?: { traceId: string; parentSpanId: string } }): TurnSpanHandle {
|
|
35
100
|
if (!this.#facade) {
|
|
36
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
traceId: '', spanId: '', setTurnId() {}, recordException() {}, end() {},
|
|
103
|
+
stepSpan: () => ({ end() {}, recordException() {} }),
|
|
104
|
+
mcpToolSpan: () => ({ traceparent: '', end() {}, recordException() {} }),
|
|
105
|
+
ptcRelay: () => ({ workerTrace: null, complete() {} }),
|
|
106
|
+
}
|
|
37
107
|
}
|
|
38
108
|
const tracer = this.#facade.getTracer()
|
|
39
|
-
const traceId = randomBytes(16).toString('hex')
|
|
40
|
-
const turn = tracer.startSpan('car.turn', {
|
|
109
|
+
const traceId = opts?.trace?.traceId ?? randomBytes(16).toString('hex')
|
|
110
|
+
const turn = tracer.startSpan('car.turn', {
|
|
111
|
+
attributes: turnId ? { 'car.turn_id': turnId } : {},
|
|
112
|
+
trace: { traceId, ...(opts?.trace?.parentSpanId ? { parentSpanId: opts.trace.parentSpanId } : {}) },
|
|
113
|
+
})
|
|
41
114
|
return {
|
|
42
115
|
traceId,
|
|
43
116
|
spanId: turn.spanId,
|
|
117
|
+
setTurnId(id) { turn.setAttribute('car.turn_id', id) },
|
|
118
|
+
recordException(e) { turn.recordException(e) },
|
|
44
119
|
end(outcome) {
|
|
45
120
|
turn.setAttribute('car.outcome', outcome)
|
|
46
121
|
turn.end()
|
|
@@ -58,6 +133,38 @@ export class TurnTracer {
|
|
|
58
133
|
recordException(e) { s.recordException(e) },
|
|
59
134
|
}
|
|
60
135
|
},
|
|
136
|
+
// 1.8-D29:client span 挂 turn(工具执行在 step span 收口后——turn 为最近存活父)
|
|
137
|
+
mcpToolSpan(serverId, tool) {
|
|
138
|
+
const s = tracer.startSpan('car.mcp.tool', {
|
|
139
|
+
attributes: { 'car.mcp_server': serverId, 'car.tool': tool },
|
|
140
|
+
trace: { traceId, parentSpanId: turn.spanId },
|
|
141
|
+
})
|
|
142
|
+
return {
|
|
143
|
+
traceparent: formatTraceparent(traceId, s.spanId),
|
|
144
|
+
end(outcome) {
|
|
145
|
+
s.setAttribute('car.outcome', outcome)
|
|
146
|
+
s.end()
|
|
147
|
+
},
|
|
148
|
+
recordException(e) { s.recordException(e) },
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
// 1.8-D31:worker 侧计时 relay——span 生成收口在主线程(单 exporter 纪律)
|
|
152
|
+
ptcRelay(): PtcRelayHandle {
|
|
153
|
+
return {
|
|
154
|
+
workerTrace: { traceId, parentSpanId: turn.spanId },
|
|
155
|
+
complete(startMs, endMs, outcome, error) {
|
|
156
|
+
// 负时长防线(同时钟域不应出现——防御下游转换器语义错乱)
|
|
157
|
+
const s = tracer.startSpan('car.ptc', {
|
|
158
|
+
attributes: { 'car.turn_id': turnId },
|
|
159
|
+
trace: { traceId, parentSpanId: turn.spanId },
|
|
160
|
+
time: { startMs },
|
|
161
|
+
})
|
|
162
|
+
if (error !== undefined) s.recordException(error instanceof Error ? error : new Error(String(error)))
|
|
163
|
+
s.setAttribute('car.outcome', outcome)
|
|
164
|
+
s.end(Math.max(endMs, startMs))
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
},
|
|
61
168
|
}
|
|
62
169
|
}
|
|
63
170
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7-S1 · OTLP span 语义标准化(D-26):exception 标准事件 + status / addEvent 标准 events 编码
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.7-迭代规划 TELE-1;1.4 遗留「遥测 OTLP 增强」出清主体):
|
|
5
|
+
* - recordException → OTel 规范形态:span event(name='exception',exception.type/exception.message
|
|
6
|
+
* 事件属性 + timeUnixNano)+ span status → STATUS_CODE_ERROR——替换 1.1 起「仅 exception.message
|
|
7
|
+
* 属性 + 恒 UNSET」非标准形态;stacktrace 缺省不出站(D-26 口径:防路径/源码面外泄)
|
|
8
|
+
* - addEvent → 标准 events 数组编码(name + 原形态属性 + 时刻)——替换 `event.<name>` JSON 属性串
|
|
9
|
+
* - 出站体加法字段:无事件 span 不出 events 键(空形态合法);正常 span 恒 STATUS_CODE_UNSET(行为不变面)
|
|
10
|
+
* - noop 门面零出站不变(默认关三原则;s29/s32 回归由全量保证)
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
|
|
15
|
+
|
|
16
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
17
|
+
|
|
18
|
+
function capture() {
|
|
19
|
+
const bodies: any[] = []
|
|
20
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
21
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
22
|
+
})
|
|
23
|
+
return { f, bodies }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const spansOf = (bodies: any[]) => bodies.filter(b => b.scopeSpans).flatMap(b => b.scopeSpans[0].spans)
|
|
27
|
+
|
|
28
|
+
test('S39: recordException → 标准 exception 事件 + STATUS_CODE_ERROR(stacktrace 不出站)', async () => {
|
|
29
|
+
const { f, bodies } = capture()
|
|
30
|
+
const s = f.getTracer().startSpan('car.step', { attributes: { 'car.step': 1 } })
|
|
31
|
+
s.recordException(new TypeError('boom'))
|
|
32
|
+
s.end('error')
|
|
33
|
+
await f.flush()
|
|
34
|
+
const span = spansOf(bodies)[0]
|
|
35
|
+
assert.equal(span.status.code, 'STATUS_CODE_ERROR', 'recordException → status ERROR(1.7 前恒 UNSET)')
|
|
36
|
+
assert.ok(Array.isArray(span.events) && span.events.length === 1, 'exception 为 span event')
|
|
37
|
+
const ev = span.events[0]
|
|
38
|
+
assert.equal(ev.name, 'exception')
|
|
39
|
+
assert.ok(ev.timeUnixNano, '事件时刻出站')
|
|
40
|
+
const attrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
41
|
+
assert.equal(attrs['exception.type'], 'TypeError', 'err.name → exception.type')
|
|
42
|
+
assert.equal(attrs['exception.message'], 'boom', 'err.message → exception.message')
|
|
43
|
+
const allKeys = [...span.attributes, ...span.events.flatMap((e: any) => e.attributes)].map((a: any) => a.key)
|
|
44
|
+
assert.equal(allKeys.some((k: string) => /stack/i.test(k)), false, 'stacktrace 缺省不出站(D-26)')
|
|
45
|
+
assert.equal(allKeys.includes('exception.message') && span.attributes.some((a: any) => a.key === 'exception.message'), false,
|
|
46
|
+
'exception.message 不再落于 span attributes(1.1 非标准形态退役)')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('S39: recordException 非 Error 值兜底——type=Error / message=String(err)', async () => {
|
|
50
|
+
const { f, bodies } = capture()
|
|
51
|
+
const s = f.getTracer().startSpan('car.step')
|
|
52
|
+
s.recordException('plain failure')
|
|
53
|
+
s.end('error')
|
|
54
|
+
await f.flush()
|
|
55
|
+
const ev = spansOf(bodies)[0].events[0]
|
|
56
|
+
const attrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
57
|
+
assert.deepEqual(attrs, { 'exception.type': 'Error', 'exception.message': 'plain failure' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('S39: addEvent → 标准 events 编码(原形态属性 + 时刻;attributes 不再承载事件)', async () => {
|
|
61
|
+
const { f, bodies } = capture()
|
|
62
|
+
const s = f.getTracer().startSpan('car.turn')
|
|
63
|
+
s.addEvent('cache-hit', { key: 'x', n: 1, flag: true })
|
|
64
|
+
s.end('completed')
|
|
65
|
+
await f.flush()
|
|
66
|
+
const span = spansOf(bodies)[0]
|
|
67
|
+
assert.equal(span.status.code, 'STATUS_CODE_UNSET', '正常 span 恒 UNSET(行为不变面)')
|
|
68
|
+
assert.equal(span.events.length, 1)
|
|
69
|
+
const ev = span.events[0]
|
|
70
|
+
assert.equal(ev.name, 'cache-hit')
|
|
71
|
+
assert.ok(ev.timeUnixNano)
|
|
72
|
+
const map = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value]))
|
|
73
|
+
assert.deepEqual(map, { key: { stringValue: 'x' }, n: { doubleValue: 1 }, flag: { boolValue: true } },
|
|
74
|
+
'原形态属性出站(1.7 前为 event.<name>=JSON 串)')
|
|
75
|
+
const attrKeys = span.attributes.map((a: any) => a.key)
|
|
76
|
+
assert.equal(attrKeys.includes('event.cache-hit'), false, 'attributes 不再承载事件(非标准形态退役)')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('S39: 无事件 span 不出 events 键(出站体加法字段——空形态合法)', async () => {
|
|
80
|
+
const { f, bodies } = capture()
|
|
81
|
+
f.getTracer().startSpan('plain').end()
|
|
82
|
+
await f.flush()
|
|
83
|
+
const span = spansOf(bodies)[0]
|
|
84
|
+
assert.equal('events' in span, false, '无事件不出 events 键')
|
|
85
|
+
assert.equal(span.status.code, 'STATUS_CODE_UNSET')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('S39: 多事件按序出站(exception 与 addEvent 混排——事件数组保序)', async () => {
|
|
89
|
+
const { f, bodies } = capture()
|
|
90
|
+
const s = f.getTracer().startSpan('car.step')
|
|
91
|
+
s.addEvent('retry')
|
|
92
|
+
s.recordException(new Error('net'))
|
|
93
|
+
s.addEvent('fallback')
|
|
94
|
+
s.end('completed')
|
|
95
|
+
await f.flush()
|
|
96
|
+
const names = spansOf(bodies)[0].events.map((e: any) => e.name)
|
|
97
|
+
assert.deepEqual(names, ['retry', 'exception', 'fallback'])
|
|
98
|
+
// exception 事件混排下 status 仍 ERROR
|
|
99
|
+
assert.equal(spansOf(bodies)[0].status.code, 'STATUS_CODE_ERROR')
|
|
100
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7-S2 · metrics CUMULATIVE 时序语义修正(D-27):累计基数保持
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.7-迭代规划 TELE-2;1.2-S3 起 clear() 与 AGGREGATION_TEMPORALITY_CUMULATIVE 标注矛盾出清):
|
|
5
|
+
* - 同计数器双 flush:第二次导出仍为全量累计(5 → 7),非增量(5 → 2)——真 CUMULATIVE;
|
|
6
|
+
* intervalMs>0 自动导出场景下下游(Prometheus 转换器等)不再解读为总量回落;
|
|
7
|
+
* - labels 形态与 temporality 标注不变;DELTA 备选不采纳(D-27:生态主流 + S17 计数语义即累计);
|
|
8
|
+
* - 内存有界由 label 基数保证(零内容枚举红线——AllowedLabels 3 计数器)。
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test'
|
|
11
|
+
import assert from 'node:assert/strict'
|
|
12
|
+
import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
|
|
13
|
+
|
|
14
|
+
const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
|
|
15
|
+
|
|
16
|
+
test('S40: 累计基数保持——同计数器双 flush 导出全量累计快照(真 CUMULATIVE,非增量)', async () => {
|
|
17
|
+
const bodies: any[] = []
|
|
18
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
19
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
20
|
+
})
|
|
21
|
+
const c = f.getMeter().createCounter('car_test_total')
|
|
22
|
+
c.add(5, { result: 'ok' })
|
|
23
|
+
await f.flush()
|
|
24
|
+
c.add(2, { result: 'ok' })
|
|
25
|
+
await f.flush()
|
|
26
|
+
assert.equal(bodies.length, 2, '两次 flush 各一次 metrics 出站')
|
|
27
|
+
const values = bodies.map(b => b.scopeMetrics[0].metrics[0].sum.dataPoints[0].asInt)
|
|
28
|
+
assert.deepEqual(values, [5, 7], '第二次导出 = 累计 7(1.7 前为增量 2——CUMULATIVE 语义错误)')
|
|
29
|
+
for (const b of bodies) {
|
|
30
|
+
const m = b.scopeMetrics[0].metrics[0]
|
|
31
|
+
assert.equal(m.name, 'car_test_total')
|
|
32
|
+
assert.equal(m.sum.aggregationTemporality, 'AGGREGATION_TEMPORALITY_CUMULATIVE')
|
|
33
|
+
const dp = m.sum.dataPoints[0]
|
|
34
|
+
assert.equal(dp.attributes[0].key, 'result', 'labels 形态不变')
|
|
35
|
+
assert.equal(dp.attributes[0].value.stringValue, 'ok')
|
|
36
|
+
}
|
|
37
|
+
assert.equal(f.stats().metricsExported, 2)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('S40: 无新增计数 interval 下重复 flush——全量快照幂等重出(CUMULATIVE 口径正确形态)', async () => {
|
|
41
|
+
const bodies: any[] = []
|
|
42
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
43
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
44
|
+
})
|
|
45
|
+
f.getMeter().createCounter('car_load_total').add(3, { result: 'ok' })
|
|
46
|
+
await f.flush()
|
|
47
|
+
await f.flush()
|
|
48
|
+
const values = bodies.map(b => b.scopeMetrics[0].metrics[0].sum.dataPoints[0].asInt)
|
|
49
|
+
assert.deepEqual(values, [3, 3], '无新增 = 同值重出(累计快照,非清零/缺席)')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('S40: 多计数器并存——各自独立累计(互不串值)', async () => {
|
|
53
|
+
const bodies: any[] = []
|
|
54
|
+
const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
|
|
55
|
+
fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
|
|
56
|
+
})
|
|
57
|
+
const c1 = f.getMeter().createCounter('car_load_total')
|
|
58
|
+
const c2 = f.getMeter().createCounter('car_registry_decision')
|
|
59
|
+
c1.add(1, { result: 'ok' })
|
|
60
|
+
c2.add(2, { source: 'registry' })
|
|
61
|
+
c1.add(1, { result: 'ok' })
|
|
62
|
+
await f.flush()
|
|
63
|
+
const metrics = bodies[0].scopeMetrics[0].metrics
|
|
64
|
+
assert.equal(metrics.length, 2)
|
|
65
|
+
const byName = Object.fromEntries(metrics.map((m: any) => [m.name, m.sum.dataPoints[0].asInt]))
|
|
66
|
+
assert.deepEqual(byName, { car_load_total: 2, car_registry_decision: 2 })
|
|
67
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 1.7-S3 · mcp-serve turn span 面(D-28):sessionTurn 挂 turn 级 span(装配层拦截)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(1.7-迭代规划 TELE-3;1.2-S3「span 面随 W1」mcp-serve 半边出清):
|
|
5
|
+
* - spawn E2E:mcp-serve 真进程 + 本地 http 捕获端点——session_turn 产 car.turn span 出站
|
|
6
|
+
* (car.turn_id=T1 回填〔facade 内部自增派生,装配层调用前不可知〕/ outcome=归一化 reason /
|
|
7
|
+
* traceId 生成 / parentSpanId 缺席=根 span);turn span 落 /v1/traces;
|
|
8
|
+
* - 非会话工具(session_start)不产 span——拦截面仅 session_turn(D-28 口径);
|
|
9
|
+
* - 既有 meter 桥与 S17 快照面不受影响(s29 全量回归保证);遥测审计禁混流红线(静态断言)s29 保持。
|
|
10
|
+
*/
|
|
11
|
+
import { test } from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { spawn } from 'node:child_process'
|
|
14
|
+
import { createServer, type Server } from 'node:http'
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
16
|
+
import { tmpdir } from 'node:os'
|
|
17
|
+
import { join, dirname } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
import { deriveSessionId } from '../src/host/mappings.ts'
|
|
20
|
+
|
|
21
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
|
|
22
|
+
|
|
23
|
+
function withTempDir(name: string, fn: (dir: string) => Promise<void>): Promise<void> {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), `car-s41-${name}-`))
|
|
25
|
+
return fn(dir).finally(() => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('S41: spawn E2E——mcp-serve sessionTurn → car.turn span 出站(turnId 回填 / outcome=reason)', async () => {
|
|
29
|
+
await withTempDir('e2e', async dir => {
|
|
30
|
+
const traceBodies: any[] = []
|
|
31
|
+
let done: (v: void) => void = () => {}
|
|
32
|
+
const closed = new Promise<void>(r => { done = r })
|
|
33
|
+
const server: Server = createServer((req, res) => {
|
|
34
|
+
let body = ''
|
|
35
|
+
req.on('data', c => { body += c })
|
|
36
|
+
req.on('end', () => {
|
|
37
|
+
if (req.url === '/v1/traces') traceBodies.push(JSON.parse(body))
|
|
38
|
+
res.writeHead(200).end()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
42
|
+
const port = (server.address() as { port: number }).port
|
|
43
|
+
try {
|
|
44
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
45
|
+
cwd: dir,
|
|
46
|
+
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
47
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
48
|
+
})
|
|
49
|
+
let err = ''
|
|
50
|
+
child.stderr.on('data', d => { err += d })
|
|
51
|
+
// 确定性派生(s36 同款):session_start 返回的 CAR 会话 id 进程内预计算——双请求可并发下发
|
|
52
|
+
const sid = deriveSessionId('claude-code', 's41-span')
|
|
53
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'session_start', arguments: { hostSessionId: 's41-span' } } }) + '\n')
|
|
54
|
+
child.stdin.write(JSON.stringify({
|
|
55
|
+
jsonrpc: '2.0', id: 2, method: 'tools/call',
|
|
56
|
+
params: {
|
|
57
|
+
name: 'session_turn',
|
|
58
|
+
arguments: { sessionId: sid, input: { events: [{ hostEvent: 'user_message', payload: { content: 'hi' } }, { hostEvent: 'turn_complete' }] } },
|
|
59
|
+
},
|
|
60
|
+
}) + '\n')
|
|
61
|
+
child.stdin.end()
|
|
62
|
+
child.on('exit', () => done())
|
|
63
|
+
// macOS runner 慢启动容差(s29 同款);落选 race timer 必须 clear
|
|
64
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
65
|
+
try {
|
|
66
|
+
await Promise.race([
|
|
67
|
+
closed,
|
|
68
|
+
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
69
|
+
])
|
|
70
|
+
} finally {
|
|
71
|
+
if (raceTimer) clearTimeout(raceTimer)
|
|
72
|
+
}
|
|
73
|
+
const spans = traceBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
74
|
+
const turnSpan = spans.find(s => s.name === 'car.turn')
|
|
75
|
+
assert.ok(turnSpan, 'session_turn 产 car.turn span 出站(v1/traces 面可达)')
|
|
76
|
+
const attrs = Object.fromEntries(turnSpan.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
77
|
+
assert.equal(attrs['car.turn_id'], 'T1', 'turnId 事后回填(facade 内部自增派生)')
|
|
78
|
+
assert.equal(attrs['car.outcome'], 'completed', 'outcome = 归一化 turnEnd reason')
|
|
79
|
+
assert.ok(turnSpan.traceId && turnSpan.spanId, 'traceId/spanId 生成')
|
|
80
|
+
assert.equal('parentSpanId' in turnSpan, false, '根 span 无 parent')
|
|
81
|
+
assert.equal('events' in turnSpan, false, '正常路径无 exception 事件')
|
|
82
|
+
assert.equal(turnSpan.status.code, 'STATUS_CODE_UNSET')
|
|
83
|
+
// 非会话工具(session_start)不产 span——拦截面仅 session_turn(D-28)
|
|
84
|
+
assert.equal(spans.length, 1, '单 session_turn = 单 turn span(session_start 不产 span)')
|
|
85
|
+
// 零内容口径:span 出站体无会话内容(user_message payload 不出站)
|
|
86
|
+
assert.equal(/hi|user_message/.test(JSON.stringify(traceBodies)), false, 'span 出站体零内容(事件批内容不出站)')
|
|
87
|
+
} finally {
|
|
88
|
+
server.closeAllConnections()
|
|
89
|
+
server.close()
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('S41: session_turn 未知会话显式报错——span 收口 error + exception 事件出站', async () => {
|
|
95
|
+
await withTempDir('err', async dir => {
|
|
96
|
+
const traceBodies: any[] = []
|
|
97
|
+
let done: (v: void) => void = () => {}
|
|
98
|
+
const closed = new Promise<void>(r => { done = r })
|
|
99
|
+
const server: Server = createServer((req, res) => {
|
|
100
|
+
let body = ''
|
|
101
|
+
req.on('data', c => { body += c })
|
|
102
|
+
req.on('end', () => {
|
|
103
|
+
if (req.url === '/v1/traces') traceBodies.push(JSON.parse(body))
|
|
104
|
+
res.writeHead(200).end()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
|
|
108
|
+
const port = (server.address() as { port: number }).port
|
|
109
|
+
try {
|
|
110
|
+
const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'mcp-serve'], {
|
|
111
|
+
cwd: dir,
|
|
112
|
+
env: { ...process.env, CAR_OTEL_ENDPOINT: `http://127.0.0.1:${port}`, CAR_OTEL_INTERVAL_MS: '0' },
|
|
113
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
114
|
+
})
|
|
115
|
+
let err = ''
|
|
116
|
+
child.stderr.on('data', d => { err += d })
|
|
117
|
+
child.on('exit', () => done())
|
|
118
|
+
child.stdin.write(JSON.stringify({
|
|
119
|
+
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
|
120
|
+
params: { name: 'session_turn', arguments: { sessionId: 'SH-nonexistent', input: { events: [] } } },
|
|
121
|
+
}) + '\n')
|
|
122
|
+
child.stdin.end()
|
|
123
|
+
let raceTimer: ReturnType<typeof setTimeout> | undefined
|
|
124
|
+
try {
|
|
125
|
+
await Promise.race([
|
|
126
|
+
closed,
|
|
127
|
+
new Promise<never>((_, rej) => { raceTimer = setTimeout(() => rej(new Error(`mcp-serve 超时\n${err}`)), 60_000) }),
|
|
128
|
+
])
|
|
129
|
+
} finally {
|
|
130
|
+
if (raceTimer) clearTimeout(raceTimer)
|
|
131
|
+
}
|
|
132
|
+
const spans = traceBodies.flatMap(b => b.scopeSpans[0].spans)
|
|
133
|
+
const turnSpan = spans.find(s => s.name === 'car.turn')
|
|
134
|
+
assert.ok(turnSpan, '失败路径 turn span 仍出站')
|
|
135
|
+
assert.equal(turnSpan.status.code, 'STATUS_CODE_ERROR', 'handle 异常 → status ERROR')
|
|
136
|
+
const ev = turnSpan.events?.find((e: any) => e.name === 'exception')
|
|
137
|
+
assert.ok(ev, 'exception 事件出站(D-26 形态在 mcp-serve 路径成立)')
|
|
138
|
+
const evAttrs = Object.fromEntries(ev.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
139
|
+
assert.match(evAttrs['exception.message'] ?? '', /CAR-E-HOST/, '异常消息进 exception.message(内容治理属调用方)')
|
|
140
|
+
const attrs = Object.fromEntries(turnSpan.attributes.map((a: any) => [a.key, a.value.stringValue]))
|
|
141
|
+
assert.equal(attrs['car.outcome'], 'error')
|
|
142
|
+
assert.equal('car.turn_id' in attrs, false, '回填前失败:无占位 turnId(startTurn(\'\') 空值不设属性)')
|
|
143
|
+
} finally {
|
|
144
|
+
server.closeAllConnections()
|
|
145
|
+
server.close()
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
})
|