Skip to content

Commit 66e9899

Browse files
fangxiu-wfclaude
andauthored
feat(upstream-link): link agent spans to upstream trace context (#166)
* feat(upstream-link): link agent spans to upstream trace context Opt-in via LOONGSUITE_PILOT_UPSTREAM_LINK (default off). Upstream traceparent is delivered through two schemes into a shared acp-correlate store; a central TraceLinker stamps trace_id (all turn events) + parent_span_id (the user-input "other" event) at collection time, so convertEventLogToTrace reparents each turn's span tree under the upstream span. - Scheme 2 (ACP, per-turn): adapter writes {sessionId, contentHash, contentPrefix, traceparent} - Scheme 1 (env, session first turn): hook/plugin reads TRACEPARENT, writes a session record - content match: exact hash or prefix (handles agent @file rewrite); consume-once by file order - wired: claude-code / codex / qwen / qoder hooks + opencode plugin - TTL cleanup for acp-correlate; fully fail-open; turn grouping preserved - unit tests + real integrated OTLP verification (qwen / qoder-cli / opencode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(readme): document upstream trace linking toggle Add an "Upstream Trace Linking" section to README (EN + zh-CN): the LOONGSUITE_PILOT_UPSTREAM_LINK switch (values/default), the TTL var, the config.json equivalents, and the two schemes (correlation file / env). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(readme): clarify upstream linking is protocol-agnostic The correlation-file scheme is not ACP-specific; the real requirement is that the record's sessionId matches the gen_ai.session.id Pilot collects and the content aligns. ACP is the primary case because session/new provides that id up front. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(upstream-link): add synthetic load/stress test for stamp path Covers correctness under interleaved cross-batch, cross-session traffic (N sessions x M turns, mixed exact/prefix/duplicate/miss records) with consume-once uniqueness assertions, plus measurements of resolveTurn per-session scan cost and cache retention. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * perf(upstream-link): index turn records by contentHash for exact match resolveTurn scanned all of a session's turn records on every lookup, giving O(turns^2) per session under many turns. Index records by contentHash for O(1) amortized exact match (the common path); keep contentPrefix as a linear fallback for prompt-rewrite turns. Consume-once file order preserved. Load test ratio for 2x turns dropped 2.68 -> 1.57. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(upstream-link): address PR review (hot-path sleep, ttl clamp, memory) - [High] resolveWithRetry short-circuits when the session has no correlation file (and skips retry for empty other text), so an enabled-but-unwired setup no longer burns retries×retryDelayMs (~300ms) per new turn on the collect path. - [Medium] clamp upstreamLink.ttlMs to a positive value; 0/negative previously made the retention cutoff now/future and deleted all fresh correlation files. - [Medium] evict idle per-session state (CorrelationStore.states, TraceLinker cache/firstTurnBySession) on the retention cadence with the same TTL, bounding memory in a long-running daemon. - [Low] treat an empty-string enable env as unset, not "true". - [Low] log the resolved acp-correlate dir when linking is enabled, so a diverging config.json dataDir is diagnosable. Adds unit coverage for all of the above. Full suite green (1843 passed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 6f15e18 commit 66e9899

24 files changed

Lines changed: 1509 additions & 1 deletion

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ Start with the guide that matches what you want to change:
113113
| See global config loading and retention settings | [Configuration Guide](docs/configuration.md) |
114114

115115

116+
### Upstream Trace Linking (optional)
117+
118+
Link collected agent spans to an **upstream** trace so each turn's span tree reparents under the upstream span. Disabled by default, and fully fail-open (never affects normal collection/reporting).
119+
120+
| Setting | Values | Default |
121+
| ------- | ------ | ------- |
122+
| `LOONGSUITE_PILOT_UPSTREAM_LINK` (env) · `upstreamLink.enabled` (config.json) | `true` / `1` to enable; unset, `false`, or `0` to disable | disabled |
123+
| `LOONGSUITE_PILOT_UPSTREAM_LINK_TTL_MS` (env) · `upstreamLink.ttlMs` (config.json) | cleanup TTL in ms for `acp-correlate` files | `86400000` (24h) |
124+
125+
When enabled, the upstream `traceparent` reaches Pilot via one of two schemes and is stamped onto collected records (`trace_id` on the turn, `parent_span_id` on the user-input event):
126+
127+
- **Correlation file** (per-turn): the caller writes `{sessionId, contentHash, contentPrefix, traceparent}` to `~/.loongsuite-pilot/acp-correlate/<sessionId>.jsonl` when it sends a prompt. Linking is protocol-agnostic — the only requirement is that `sessionId` matches the `gen_ai.session.id` Pilot collects for that turn, and the content (hash or prefix) matches the collected user text. ACP clients satisfy this naturally (the `session/new` id flows into collection), so ACP is the primary case.
128+
- **Environment** (`TRACEPARENT` on the agent process): applied to the session's first turn, via the agent's hook. Use this when the caller cannot obtain a per-turn `sessionId` up front.
129+
130+
116131
## Output Data
117132

118133

README.zh-CN.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,20 @@ loongsuite-pilot info
9696
| 输出前进行密钥脱敏 | [数据脱敏](docs/zh-CN/masking.md) |
9797
| 查看全局配置加载顺序和保留策略 | [配置总览](docs/zh-CN/configuration.md) |
9898

99+
### 上游 Trace 串联(可选)
100+
101+
把采集到的 agent span 挂到**上游** trace 下,使每一轮的 span 树重挂到上游 span。默认关闭,且全程 fail-open(绝不影响正常采集/上报)。
102+
103+
| 配置项 | 取值 | 默认 |
104+
| ------ | ---- | ---- |
105+
| `LOONGSUITE_PILOT_UPSTREAM_LINK`(环境变量)· `upstreamLink.enabled`(config.json) | `true` / `1` 开启;不设、`false``0` 关闭 | 关闭 |
106+
| `LOONGSUITE_PILOT_UPSTREAM_LINK_TTL_MS`(环境变量)· `upstreamLink.ttlMs`(config.json) | `acp-correlate` 文件清理 TTL(毫秒) | `86400000`(24 小时) |
107+
108+
开启后,上游 `traceparent` 经以下两种方案之一到达 Pilot,并在采集时 stamp 到记录(turn 打 `trace_id`、用户输入事件打 `parent_span_id`):
109+
110+
- **关联文件**(per-turn):调用方在发送 prompt 时,把 `{sessionId, contentHash, contentPrefix, traceparent}` 写入 `~/.loongsuite-pilot/acp-correlate/<sessionId>.jsonl`。串联与协议无关——唯一要求是 `sessionId` 等于 Pilot 采集该 turn 时的 `gen_ai.session.id`,且内容(hash 或前缀)能匹配采集到的用户文本。ACP client 天然满足(`session/new` 的 id 会贯穿采集),故 ACP 是主要场景。
111+
- **环境变量**(agent 进程上的 `TRACEPARENT`):经 agent 的 hook 作用于该会话的第一个 turn。适用于调用方无法预先拿到 per-turn `sessionId` 的情况。
112+
99113
## 输出数据
100114

101115
| 后端 | 用途 |

assets/hooks/claude-code-hook-processor.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
writeJsonlRecords,
3434
} from './shared/event-emitter.mjs';
3535
import { logHookError } from './shared/error-logger.mjs';
36+
import { recordUpstreamContextOnce } from './shared/upstream-context.mjs';
3637
import {
3738
sanitizeObject,
3839
toJsonValue,
@@ -260,6 +261,9 @@ async function cmdStop() {
260261
const sessionId = requireSessionId(event, 'cmd');
261262
if (!sessionId) return;
262263

264+
// 方案1(env):首个 turn 读 TRACEPARENT 写 session 级关联记录(fail-open, 每 session 一次)
265+
recordUpstreamContextOnce({ agentId: AGENT_ID, sessionId, dataDir: pilotDataDir() });
266+
263267
const state = loadState(sessionId);
264268
if (!state.transcript_path && event.transcript_path) {
265269
state.transcript_path = event.transcript_path;

assets/hooks/codex-hook-processor.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import os from 'node:os';
1414
import path from 'node:path';
1515
import crypto from 'node:crypto';
1616
import { logHookError } from './shared/error-logger.mjs';
17+
import { recordUpstreamContextOnce } from './shared/upstream-context.mjs';
1718
import {
1819
collectResourceAttributesFromEnv,
1920
} from './shared/resource-context.mjs';
@@ -52,6 +53,10 @@ function safePathPart(value) {
5253
function writeWakeupMarker(input) {
5354
const sessionId = typeof input.session_id === 'string' ? input.session_id : '';
5455
if (!sessionId) return;
56+
57+
// 方案1(env):首个 turn 读 TRACEPARENT 写 session 级关联记录(fail-open, 每 session 一次)
58+
recordUpstreamContextOnce({ agentId: AGENT_ID, sessionId, dataDir: pilotDataDir() });
59+
5560
const directory = path.join(pilotDataDir(), 'state', 'codex', 'transcript-wakeups');
5661
const marker = path.join(directory, `${safePathPart(sessionId)}.json`);
5762
const temporary = path.join(directory, `.${safePathPart(sessionId)}.${process.pid}.${crypto.randomUUID()}.tmp`);

assets/hooks/qoder-hook-processor.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
agentBaseFieldPatch,
3636
collectResourceAttributesFromEnv,
3737
} from './shared/resource-context.mjs';
38+
import { recordUpstreamContextOnce } from './shared/upstream-context.mjs';
3839

3940
const RESOURCE_ATTRIBUTES = collectResourceAttributesFromEnv(process.env, { agentId: 'qoder' });
4041
const RESOURCE_BASE_FIELD_PATCH = agentBaseFieldPatch(RESOURCE_ATTRIBUTES);
@@ -211,6 +212,12 @@ async function main() {
211212
if (!payload) return;
212213

213214
const { transcriptPath, sessionId, cwd } = payload;
215+
216+
// 方案1(env):首个 turn 读 TRACEPARENT 写 session 级关联记录(fail-open, 每 session 一次)
217+
if (sessionId) {
218+
recordUpstreamContextOnce({ agentId, sessionId, dataDir: path.dirname(LOONGSUITE_PILOT_LOGS_BASE_DIR) });
219+
}
220+
214221
const runtimeConfig = loadHookRuntimeConfig(path.join(HOOKS_DIR, '..'));
215222

216223
const range = getLineRange(agentId, transcriptPath, sessionId);

assets/hooks/qwen-code-cli-hook-processor.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
writeJsonlRecords,
3737
} from './shared/event-emitter.mjs';
3838
import { logHookError } from './shared/error-logger.mjs';
39+
import { recordUpstreamContextOnce } from './shared/upstream-context.mjs';
3940
import {
4041
sanitizeObject,
4142
loadHookRuntimeConfig,
@@ -167,6 +168,9 @@ async function cmdStop() {
167168
const sessionId = requireSessionId(event, 'stop');
168169
if (!sessionId) return;
169170

171+
// 方案1(env):首个 turn 读 TRACEPARENT 写 session 级关联记录(fail-open, 每 session 一次)
172+
recordUpstreamContextOnce({ agentId: AGENT_ID, sessionId, dataDir: pilotDataDir() });
173+
170174
const state = loadState(sessionId);
171175
if (!state.transcript_path && event.transcript_path) {
172176
state.transcript_path = event.transcript_path;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2026 Alibaba Group Holding Limited
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
/**
5+
* upstream-context.mjs — 方案1(env 注入)hook 侧 helper。
6+
*
7+
* 非交互式 CLI(headless)或交互式会话首个 turn,读进程环境变量 TRACEPARENT
8+
* (由上游/适配层注入到 agent 子进程 env,hook 子进程继承),写一条 session 级
9+
* 关联记录到 ${dataDir}/acp-correlate/<sessionId>.jsonl,供 pilot 归一阶段 stamp。
10+
*
11+
* 每个 session 只写一次:用 <sessionId>.env.lock 的 O_CREAT|O_EXCL(fs 'wx')抢锁,
12+
* 已存在(EEXIST)即视为已写过,直接返回。
13+
*
14+
* 全程 fail-open:任何异常都不得影响宿主 agent。
15+
*/
16+
17+
import fs from 'node:fs';
18+
import path from 'node:path';
19+
20+
const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/i;
21+
const ZERO_TRACE = '0'.repeat(32);
22+
const ZERO_SPAN = '0'.repeat(16);
23+
24+
function safeName(value) {
25+
return path.basename(String(value)).replace(/[^a-zA-Z0-9_-]/g, '_') || 'unknown';
26+
}
27+
28+
function isValidTraceparent(tp) {
29+
if (typeof tp !== 'string') return false;
30+
const m = TRACEPARENT_RE.exec(tp.trim());
31+
if (!m) return false;
32+
return m[1].toLowerCase() !== ZERO_TRACE && m[2].toLowerCase() !== ZERO_SPAN;
33+
}
34+
35+
/**
36+
* @param {object} opts
37+
* @param {string} opts.agentId agent 标识(如 "claude-code")
38+
* @param {string} opts.sessionId 当前会话 id
39+
* @param {string} opts.dataDir pilot 数据目录(各 processor 的 pilotDataDir())
40+
*/
41+
export function recordUpstreamContextOnce({ agentId, sessionId, dataDir }) {
42+
try {
43+
if (!sessionId || !dataDir) return;
44+
const tp = (process.env.TRACEPARENT || '').trim();
45+
if (!isValidTraceparent(tp)) return;
46+
47+
const dir = path.join(dataDir, 'acp-correlate');
48+
fs.mkdirSync(dir, { recursive: true });
49+
50+
const base = safeName(sessionId);
51+
const lock = path.join(dir, `${base}.env.lock`);
52+
// O_CREAT|O_EXCL:抢锁成功者才写;已存在(EEXIST)= 已写过,直接返回。
53+
try {
54+
fs.closeSync(fs.openSync(lock, 'wx'));
55+
} catch (err) {
56+
if (err && err.code === 'EEXIST') return; // 正常路径,非错误
57+
throw err;
58+
}
59+
60+
const record = { type: 'session', sessionId, traceparent: tp, ts: new Date().toISOString() };
61+
fs.appendFileSync(path.join(dir, `${base}.jsonl`), JSON.stringify(record) + '\n', 'utf-8');
62+
} catch (err) {
63+
// fail-open: 记录标记失败绝不能影响宿主 agent
64+
try {
65+
process.stderr.write(`[${agentId || 'hook'}] upstream_correlate skip: ${String((err && err.message) || err)}\n`);
66+
} catch {
67+
// ignore
68+
}
69+
}
70+
}

assets/plugins/opencode/plugin.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,13 +372,38 @@ function buildOutputMessages(pendingParts, finishReason) {
372372
// Event handlers
373373
// ---------------------------------------------------------------------------
374374

375+
// 方案1(env):首个 turn 读 process.env.TRACEPARENT,写 session 级关联记录到
376+
// acp-correlate/<sessionId>.jsonl,每 session 只写一次(O_CREAT|O_EXCL 锁)。fail-open。
377+
const UPSTREAM_TP_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/i;
378+
function recordUpstreamEnvOnce(sessionID) {
379+
try {
380+
const tp = (process.env.TRACEPARENT || "").trim();
381+
const m = UPSTREAM_TP_RE.exec(tp);
382+
if (!m || m[1].toLowerCase() === "0".repeat(32) || m[2].toLowerCase() === "0".repeat(16)) return;
383+
const dir = path.join(resolveDataDir(), "acp-correlate");
384+
fs.mkdirSync(dir, { recursive: true });
385+
const base = path.basename(String(sessionID)).replace(/[^a-zA-Z0-9_-]/g, "_") || "unknown";
386+
try {
387+
fs.closeSync(fs.openSync(path.join(dir, `${base}.env.lock`), "wx"));
388+
} catch (e) {
389+
if (e && e.code === "EEXIST") return; // 已写过, 正常返回
390+
throw e;
391+
}
392+
const rec = { type: "session", sessionId: sessionID, traceparent: tp, ts: new Date().toISOString() };
393+
fs.appendFileSync(path.join(dir, `${base}.jsonl`), JSON.stringify(rec) + "\n", "utf-8");
394+
} catch {
395+
// fail-open: 绝不影响 opencode
396+
}
397+
}
398+
375399
function handleChatMessage(inp, out, userId) {
376400
const sessionID = inp.sessionID;
377401
if (!sessionID) return;
378402

379403
const session = getSession(sessionID);
380404

381405
session.turnSeq += 1;
406+
if (session.turnSeq === 1) recordUpstreamEnvOnce(sessionID);
382407
const turnId = `${sessionID}:t${session.turnSeq}`;
383408
const traceId = generateTraceId();
384409

src/core/config-loader.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
SlsEndpoint,
2020
SlsMode,
2121
StatusBarConfig,
22+
UpstreamLinkConfig,
2223
} from '../types/index.js';
2324
import { readJsonFile, resolveHome } from '../utils/fs-utils.js';
2425
import { createLogger } from '../utils/logger.js';
@@ -112,6 +113,11 @@ export interface ConfigFile {
112113
collectTrace?: boolean;
113114
serviceNamePrefix?: string;
114115

116+
upstreamLink?: {
117+
enabled?: boolean;
118+
ttlMs?: number;
119+
};
120+
115121
mask?: {
116122
mode?: string;
117123
types?: string[];
@@ -176,7 +182,7 @@ function env(key: string): string | undefined {
176182

177183
function envBool(key: string, fallback: boolean): boolean {
178184
const v = env(key);
179-
if (v === undefined) return fallback;
185+
if (v === undefined || v.trim() === '') return fallback; // empty string == unset, not "true"
180186
return v !== 'false' && v !== '0';
181187
}
182188

@@ -242,10 +248,21 @@ export async function loadConfig(): Promise<AnalyticsConfig> {
242248
fileCollection: buildFileCollectionConfig(file),
243249
pipeline: buildPipelineConfig(file),
244250
statusBar: buildStatusBarConfig(file),
251+
upstreamLink: buildUpstreamLinkConfig(file),
245252
globalSpanAttributes: resolveGlobalSpanAttributes(file),
246253
};
247254
}
248255

256+
function buildUpstreamLinkConfig(file: ConfigFile | null): UpstreamLinkConfig {
257+
const ttlMs = envInt('LOONGSUITE_PILOT_UPSTREAM_LINK_TTL_MS', file?.upstreamLink?.ttlMs ?? 86_400_000); // 24h
258+
return {
259+
enabled: envBool('LOONGSUITE_PILOT_UPSTREAM_LINK', file?.upstreamLink?.enabled ?? false),
260+
// Clamp: ttlMs <= 0 would make the retention cutoff Date.now() (or the future),
261+
// deleting all freshly-written correlation files and silently breaking linking.
262+
ttlMs: ttlMs > 0 ? ttlMs : 86_400_000,
263+
};
264+
}
265+
249266
/**
250267
* User-defined global span attributes: config.json `globalSpanAttributes` merged
251268
* with the `OTEL_SPAN_ATTRIBUTES` env (key1=value1,key2=value2). Env wins over

src/core/input-manager.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { applyAgentContentPolicy } from '../normalization/agent-content-policy.j
1414
import { maskAgentActivityEntry } from '../mask/entry-masker.js';
1515
import { loadEnabledRules } from '../mask/rule-loader.js';
1616
import type { CompiledMaskRule } from '../mask/types.js';
17+
import type { TraceLinker } from './upstream-link/trace-linker.js';
1718

1819
const logger = createLogger('InputManager');
1920

@@ -48,6 +49,7 @@ export class InputManager extends EventEmitter {
4849
private agentsConfig: AgentsConfig = {};
4950
private maskConfig: MaskConfig = { mode: 'none', types: [] };
5051
private maskRules: CompiledMaskRule[] = [];
52+
private traceLinker: TraceLinker | null = null;
5153

5254
setFlusher(flusher: BaseFlusher): void {
5355
this.flusher = flusher;
@@ -74,6 +76,10 @@ export class InputManager extends EventEmitter {
7476
this.maskRules = loadEnabledRules(config);
7577
}
7678

79+
setTraceLinker(linker: TraceLinker): void {
80+
this.traceLinker = linker;
81+
}
82+
7783
registerInput(input: BaseInput): void {
7884
if (this.inputs.has(input.id)) {
7985
logger.warn('input already registered', { id: input.id });
@@ -223,6 +229,16 @@ export class InputManager extends EventEmitter {
223229
}
224230
}
225231

232+
// Upstream trace linking: stamp trace_id / parent_span_id from correlation
233+
// store so agent spans reparent under the upstream span. Fully fail-open.
234+
if (this.traceLinker) {
235+
try {
236+
await this.traceLinker.stamp(entries);
237+
} catch (err) {
238+
logger.warn('trace linker stamp failed (skipped)', { inputId, error: String(err) });
239+
}
240+
}
241+
226242
const policyAppliedEntries = entries.map(entry =>
227243
applyAgentContentPolicy(entry, this.agentsConfig),
228244
);

0 commit comments

Comments
 (0)