opencode-tracekit 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jiqi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # TraceKit (OpenCode Tracepoint Plugin)
2
+
3
+ TraceKit 是一个面向 OpenCode 的可观测性插件,支持:
4
+
5
+ - 自动拦截 `tool/skill` 调用并记录 span(start/end)
6
+ - 在任意 agent 和 skill 中显式打点(`trace.emit`)
7
+ - 记录自定义指标(`trace.counter`)与业务阶段(`trace.span`)
8
+ - 将 trace 以 `ndjson` 落盘,并支持离线导出和分析
9
+
10
+ 安装与接入步骤见:`plugins/tracekit/docs/installation.md`
11
+ 发布到 npm 见:`plugins/tracekit/docs/publish.md`
12
+
13
+ ## npm 安装(发布后)
14
+
15
+ ```bash
16
+ npm install opencode-tracekit
17
+ ```
18
+
19
+ ## 目录结构
20
+
21
+ ```text
22
+ plugins/tracekit/
23
+ index.ts
24
+ src/
25
+ tracekit_plugin.ts
26
+ trace_record.ts
27
+ ndjson_writer.ts
28
+ span_context.ts
29
+ hooks_tool.ts
30
+ hooks_session.ts
31
+ tools_trace_emit.ts
32
+ tools_trace_counter.ts
33
+ tools_trace_span.ts
34
+ analyzer.ts
35
+ exporter.ts
36
+ cli.ts
37
+ ```
38
+
39
+ ## 快速接入
40
+
41
+ ```ts
42
+ import { createTraceKitPlugin } from "./plugins/tracekit";
43
+
44
+ const tracekit = createTraceKitPlugin({
45
+ outPath: "./trace.ndjson",
46
+ includeCounterTool: true,
47
+ includeSpanTool: true,
48
+ });
49
+
50
+ // 伪代码:把 hooks / tools 注册给 OpenCode runtime
51
+ opencode.registerPlugin({
52
+ hooks: tracekit.hooks,
53
+ tools: tracekit.tools,
54
+ shutdown: tracekit.shutdown,
55
+ });
56
+ ```
57
+
58
+ ## 在 Agent / Skill 中插入 trace 点
59
+
60
+ ### Tracepoint
61
+
62
+ ```json
63
+ {
64
+ "tool": "trace.emit",
65
+ "args": {
66
+ "name": "agent.plan.generated",
67
+ "level": "info",
68
+ "attrs": { "agent": "planner", "stepCount": 4 }
69
+ }
70
+ }
71
+ ```
72
+
73
+ ### 自定义 Span(业务阶段)
74
+
75
+ ```json
76
+ { "tool": "trace.span", "args": { "op": "start", "name": "ipd.phase.spec" } }
77
+ { "tool": "trace.emit", "args": { "name": "spec.ready" } }
78
+ { "tool": "trace.span", "args": { "op": "end", "status": "ok" } }
79
+ ```
80
+
81
+ ### Counter
82
+
83
+ ```json
84
+ {
85
+ "tool": "trace.counter",
86
+ "args": {
87
+ "name": "spec.requirements.count",
88
+ "value": 23
89
+ }
90
+ }
91
+ ```
92
+
93
+ ## 导出与分析
94
+
95
+ ```bash
96
+ # 统计分析
97
+ node dist/cli.js analyze ./trace.ndjson
98
+
99
+ # 导出 JSON
100
+ node dist/cli.js export ./trace.ndjson --out ./trace.json --format json
101
+
102
+ # 导出 CSV
103
+ node dist/cli.js export ./trace.ndjson --out ./trace.csv --format csv
104
+ ```
105
+
106
+ ## Record 协议
107
+
108
+ 参考 `src/trace_record.ts`,核心事件类型:
109
+
110
+ - `capture_start` / `capture_end`
111
+ - `session`
112
+ - `span_start` / `span_end`
113
+ - `tracepoint`
114
+ - `marker`
115
+ - `counter`
@@ -0,0 +1,5 @@
1
+ import { createTraceKitPlugin } from "./src/tracekit_plugin.js";
2
+ export { createTraceKitPlugin };
3
+ export default createTraceKitPlugin;
4
+ export type { TraceKitConfig, TraceKitPlugin } from "./src/tracekit_plugin.js";
5
+ export type { TraceRecord } from "./src/trace_record.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { createTraceKitPlugin } from "./src/tracekit_plugin.js";
2
+ export { createTraceKitPlugin };
3
+ export default createTraceKitPlugin;
@@ -0,0 +1,20 @@
1
+ import type { TraceRecord } from "./trace_record.js";
2
+ export interface TraceSummary {
3
+ totalRecords: number;
4
+ totalSessions: number;
5
+ totalSpans: number;
6
+ errorSpans: number;
7
+ totalTracepoints: number;
8
+ totalCounters: number;
9
+ avgSpanDurationMs: number;
10
+ p95SpanDurationMs: number;
11
+ topSlowSpans: Array<{
12
+ name: string;
13
+ count: number;
14
+ avgDurationMs: number;
15
+ errorRate: number;
16
+ }>;
17
+ }
18
+ export declare function loadTrace(path: string): Promise<TraceRecord[]>;
19
+ export declare function analyzeTrace(records: TraceRecord[]): TraceSummary;
20
+ export declare function saveSummary(path: string, summary: TraceSummary): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
4
+ export async function loadTrace(path) {
5
+ const records = [];
6
+ const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
7
+ for await (const line of rl) {
8
+ const text = line.trim();
9
+ if (!text)
10
+ continue;
11
+ const parsed = JSON.parse(text);
12
+ records.push(parsed);
13
+ }
14
+ return records;
15
+ }
16
+ export function analyzeTrace(records) {
17
+ const sessions = new Set();
18
+ const spans = records.filter((r) => r.type === "span_end");
19
+ const spanStarts = new Map();
20
+ for (const record of records) {
21
+ if ("sessionId" in record && record.sessionId)
22
+ sessions.add(record.sessionId);
23
+ if (record.type === "span_start")
24
+ spanStarts.set(record.spanId, record);
25
+ }
26
+ const durations = [];
27
+ const stats = new Map();
28
+ let errorSpans = 0;
29
+ for (const end of spans) {
30
+ if (end.status === "error")
31
+ errorSpans += 1;
32
+ if (typeof end.durationMs === "number")
33
+ durations.push(end.durationMs);
34
+ const name = spanStarts.get(end.spanId)?.name ?? String(end.attrs?.toolName ?? "unknown");
35
+ const current = stats.get(name) ?? { count: 0, totalDuration: 0, errors: 0 };
36
+ current.count += 1;
37
+ current.totalDuration += end.durationMs ?? 0;
38
+ if (end.status === "error")
39
+ current.errors += 1;
40
+ stats.set(name, current);
41
+ }
42
+ durations.sort((a, b) => a - b);
43
+ const avg = durations.length ? durations.reduce((a, b) => a + b, 0) / durations.length : 0;
44
+ const p95 = durations.length ? durations[Math.min(durations.length - 1, Math.floor(durations.length * 0.95))] : 0;
45
+ const topSlowSpans = [...stats.entries()]
46
+ .map(([name, s]) => ({
47
+ name,
48
+ count: s.count,
49
+ avgDurationMs: s.count ? s.totalDuration / s.count : 0,
50
+ errorRate: s.count ? s.errors / s.count : 0,
51
+ }))
52
+ .sort((a, b) => b.avgDurationMs - a.avgDurationMs)
53
+ .slice(0, 10);
54
+ return {
55
+ totalRecords: records.length,
56
+ totalSessions: sessions.size,
57
+ totalSpans: spans.length,
58
+ errorSpans,
59
+ totalTracepoints: records.filter((r) => r.type === "tracepoint").length,
60
+ totalCounters: records.filter((r) => r.type === "counter").length,
61
+ avgSpanDurationMs: avg,
62
+ p95SpanDurationMs: p95,
63
+ topSlowSpans,
64
+ };
65
+ }
66
+ export async function saveSummary(path, summary) {
67
+ await writeFile(path, JSON.stringify(summary, null, 2), "utf8");
68
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ import { analyzeTrace, loadTrace } from "./analyzer.js";
3
+ import { exportTrace } from "./exporter.js";
4
+ async function main() {
5
+ const args = process.argv.slice(2);
6
+ const cmd = args[0];
7
+ if (!cmd || cmd === "--help" || cmd === "-h") {
8
+ printHelp();
9
+ return;
10
+ }
11
+ if (cmd === "analyze") {
12
+ const input = args[1];
13
+ if (!input)
14
+ throw new Error("missing input trace file path");
15
+ const summaryPath = readFlag(args, "--summary");
16
+ const records = await loadTrace(input);
17
+ const summary = analyzeTrace(records);
18
+ if (summaryPath) {
19
+ const fs = await import("node:fs/promises");
20
+ await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf8");
21
+ }
22
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
23
+ return;
24
+ }
25
+ if (cmd === "export") {
26
+ const input = args[1];
27
+ const out = readFlag(args, "--out");
28
+ const format = (readFlag(args, "--format") ?? "json");
29
+ if (!input || !out)
30
+ throw new Error("usage: tracectl export <trace.ndjson> --out <file> [--format json|csv]");
31
+ const records = await loadTrace(input);
32
+ await exportTrace(out, records, format);
33
+ process.stdout.write(`exported ${records.length} records to ${out} (${format})\n`);
34
+ return;
35
+ }
36
+ throw new Error(`unknown command: ${cmd}`);
37
+ }
38
+ function readFlag(args, key) {
39
+ const idx = args.indexOf(key);
40
+ if (idx < 0)
41
+ return undefined;
42
+ return args[idx + 1];
43
+ }
44
+ function printHelp() {
45
+ process.stdout.write([
46
+ "tracectl - TraceKit helper",
47
+ "",
48
+ "Commands:",
49
+ " tracectl analyze <trace.ndjson> [--summary summary.json]",
50
+ " tracectl export <trace.ndjson> --out <file> [--format json|csv]",
51
+ "",
52
+ ].join("\n"));
53
+ }
54
+ main().catch((err) => {
55
+ process.stderr.write(`tracectl failed: ${err instanceof Error ? err.message : String(err)}\n`);
56
+ process.exitCode = 1;
57
+ });
@@ -0,0 +1,3 @@
1
+ import type { TraceRecord } from "./trace_record.js";
2
+ export type TraceExportFormat = "json" | "csv";
3
+ export declare function exportTrace(path: string, records: TraceRecord[], format: TraceExportFormat): Promise<void>;
@@ -0,0 +1,47 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ export async function exportTrace(path, records, format) {
3
+ if (format === "json") {
4
+ await writeFile(path, JSON.stringify(records, null, 2), "utf8");
5
+ return;
6
+ }
7
+ const header = [
8
+ "type",
9
+ "ts",
10
+ "sessionId",
11
+ "spanId",
12
+ "parentSpanId",
13
+ "name",
14
+ "kind",
15
+ "status",
16
+ "durationMs",
17
+ "level",
18
+ "value",
19
+ ];
20
+ const rows = records.map((r) => {
21
+ const base = r;
22
+ return [
23
+ base.type,
24
+ base.ts,
25
+ base.sessionId,
26
+ base.spanId,
27
+ base.parentSpanId,
28
+ base.name ?? base.label,
29
+ base.kind,
30
+ base.status,
31
+ base.durationMs,
32
+ base.level,
33
+ base.value,
34
+ ]
35
+ .map(toCsvCell)
36
+ .join(",");
37
+ });
38
+ await writeFile(path, `${header.join(",")}\n${rows.join("\n")}\n`, "utf8");
39
+ }
40
+ function toCsvCell(value) {
41
+ if (value === undefined || value === null)
42
+ return "";
43
+ const str = String(value);
44
+ if (!str.includes(",") && !str.includes('"') && !str.includes("\n"))
45
+ return str;
46
+ return `"${str.replace(/"/g, '""')}"`;
47
+ }
@@ -0,0 +1,18 @@
1
+ import type { NDJSONWriter } from "./ndjson_writer.js";
2
+ import type { SpanContext } from "./span_context.js";
3
+ export interface SessionStartEvent {
4
+ ts?: number;
5
+ sessionId: string;
6
+ parentSessionId?: string;
7
+ label?: string;
8
+ attrs?: Record<string, unknown>;
9
+ }
10
+ export interface SessionEndEvent {
11
+ ts?: number;
12
+ sessionId: string;
13
+ attrs?: Record<string, unknown>;
14
+ }
15
+ export declare function makeSessionHooks(writer: NDJSONWriter, ctx: SpanContext): {
16
+ onSessionStart: (ev: SessionStartEvent) => void;
17
+ onSessionEnd: (ev: SessionEndEvent) => void;
18
+ };
@@ -0,0 +1,24 @@
1
+ export function makeSessionHooks(writer, ctx) {
2
+ function onSessionStart(ev) {
3
+ writer.write({
4
+ type: "session",
5
+ op: "upsert",
6
+ ts: ev.ts ?? Date.now(),
7
+ sessionId: ev.sessionId,
8
+ parentSessionId: ev.parentSessionId,
9
+ label: ev.label,
10
+ attrs: ev.attrs,
11
+ });
12
+ }
13
+ function onSessionEnd(ev) {
14
+ writer.write({
15
+ type: "marker",
16
+ ts: ev.ts ?? Date.now(),
17
+ sessionId: ev.sessionId,
18
+ label: "session.completed",
19
+ attrs: ev.attrs,
20
+ });
21
+ ctx.clear(ev.sessionId);
22
+ }
23
+ return { onSessionStart, onSessionEnd };
24
+ }
@@ -0,0 +1,27 @@
1
+ import type { NDJSONWriter } from "./ndjson_writer.js";
2
+ import type { SpanContext } from "./span_context.js";
3
+ import type { TraceUsage } from "./trace_record.js";
4
+ export interface ToolStartEvent {
5
+ ts?: number;
6
+ sessionId?: string;
7
+ agentId?: string;
8
+ toolName?: string;
9
+ toolCallId?: string;
10
+ input?: unknown;
11
+ attrs?: Record<string, unknown>;
12
+ }
13
+ export interface ToolEndEvent {
14
+ ts?: number;
15
+ sessionId?: string;
16
+ agentId?: string;
17
+ toolName?: string;
18
+ toolCallId?: string;
19
+ output?: unknown;
20
+ error?: unknown;
21
+ usage?: TraceUsage;
22
+ attrs?: Record<string, unknown>;
23
+ }
24
+ export declare function makeToolHooks(writer: NDJSONWriter, ctx: SpanContext): {
25
+ onToolStart: (ev: ToolStartEvent) => void;
26
+ onToolEnd: (ev: ToolEndEvent) => void;
27
+ };
@@ -0,0 +1,62 @@
1
+ import { asRecord, id, preview, safeError } from "./utils.js";
2
+ export function makeToolHooks(writer, ctx) {
3
+ function onToolStart(ev) {
4
+ const ts = ev.ts ?? Date.now();
5
+ const sessionId = ev.sessionId ?? ev.agentId ?? "unknown-session";
6
+ const spanId = ev.toolCallId ?? id();
7
+ const parentSpanId = ctx.current(sessionId)?.spanId;
8
+ const isSkill = ev.toolName === "skill";
9
+ const skillInput = asRecord(ev.input);
10
+ const attrs = {
11
+ toolName: ev.toolName ?? "unknown-tool",
12
+ inputPreview: preview(ev.input),
13
+ ...ev.attrs,
14
+ };
15
+ if (isSkill) {
16
+ attrs.skill = {
17
+ name: skillInput?.name,
18
+ path: skillInput?.path,
19
+ version: skillInput?.version,
20
+ };
21
+ }
22
+ writer.write({
23
+ type: "span_start",
24
+ ts,
25
+ spanId,
26
+ sessionId,
27
+ parentSpanId,
28
+ kind: isSkill ? "skill" : "tool",
29
+ name: isSkill ? `skill:${String(skillInput?.name ?? "unknown")}` : String(ev.toolName ?? "unknown-tool"),
30
+ attrs,
31
+ });
32
+ ctx.push(sessionId, {
33
+ spanId,
34
+ kind: isSkill ? "skill" : "tool",
35
+ name: String(ev.toolName ?? "unknown-tool"),
36
+ startedAt: ts,
37
+ });
38
+ }
39
+ function onToolEnd(ev) {
40
+ const ts = ev.ts ?? Date.now();
41
+ const sessionId = ev.sessionId ?? ev.agentId ?? "unknown-session";
42
+ const spanId = ev.toolCallId ?? ctx.current(sessionId)?.spanId ?? "unknown-span";
43
+ const frame = ctx.pop(sessionId, spanId);
44
+ writer.write({
45
+ type: "span_end",
46
+ ts,
47
+ spanId,
48
+ sessionId,
49
+ status: ev.error ? "error" : "ok",
50
+ durationMs: frame ? ts - frame.startedAt : undefined,
51
+ tokensIn: ev.usage?.promptTokens,
52
+ tokensOut: ev.usage?.completionTokens,
53
+ attrs: {
54
+ toolName: ev.toolName ?? frame?.name ?? "unknown-tool",
55
+ outputPreview: preview(ev.output),
56
+ error: ev.error ? safeError(ev.error) : undefined,
57
+ ...ev.attrs,
58
+ },
59
+ });
60
+ }
61
+ return { onToolStart, onToolEnd };
62
+ }
@@ -0,0 +1,13 @@
1
+ import type { TraceRecord } from "./trace_record.js";
2
+ export declare class NDJSONWriter {
3
+ private readonly path;
4
+ private stream?;
5
+ private queue;
6
+ private opened;
7
+ constructor(path: string);
8
+ open(): Promise<void>;
9
+ write(record: TraceRecord): Promise<void>;
10
+ flush(): Promise<void>;
11
+ close(): Promise<void>;
12
+ private writeLine;
13
+ }
@@ -0,0 +1,61 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { createWriteStream } from "node:fs";
4
+ export class NDJSONWriter {
5
+ path;
6
+ stream;
7
+ queue = Promise.resolve();
8
+ opened = false;
9
+ constructor(path) {
10
+ this.path = path;
11
+ }
12
+ async open() {
13
+ if (this.opened)
14
+ return;
15
+ await mkdir(dirname(this.path), { recursive: true });
16
+ this.stream = createWriteStream(this.path, { flags: "a" });
17
+ this.opened = true;
18
+ }
19
+ write(record) {
20
+ this.queue = this.queue.then(async () => {
21
+ if (!this.opened)
22
+ await this.open();
23
+ const line = `${JSON.stringify(record)}\n`;
24
+ await this.writeLine(line);
25
+ });
26
+ return this.queue;
27
+ }
28
+ async flush() {
29
+ await this.queue;
30
+ }
31
+ async close() {
32
+ await this.queue;
33
+ if (!this.stream)
34
+ return;
35
+ await new Promise((resolve, reject) => {
36
+ this.stream.end((err) => {
37
+ if (err)
38
+ return reject(err);
39
+ resolve();
40
+ });
41
+ });
42
+ this.stream = undefined;
43
+ this.opened = false;
44
+ }
45
+ async writeLine(line) {
46
+ await new Promise((resolve, reject) => {
47
+ if (!this.stream)
48
+ return reject(new Error("NDJSON stream not opened"));
49
+ const ok = this.stream.write(line, "utf8", (err) => {
50
+ if (err)
51
+ return reject(err);
52
+ });
53
+ if (ok) {
54
+ resolve();
55
+ }
56
+ else {
57
+ this.stream.once("drain", resolve);
58
+ }
59
+ });
60
+ }
61
+ }
@@ -0,0 +1,11 @@
1
+ export interface TraceToolRuntime {
2
+ sessionId?: string;
3
+ agentId?: string;
4
+ ts?: number;
5
+ }
6
+ export interface TraceToolDefinition {
7
+ name: string;
8
+ description: string;
9
+ inputSchema: Record<string, unknown>;
10
+ handler: (args: Record<string, unknown>, runtime: TraceToolRuntime) => Promise<Record<string, unknown>>;
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import type { TraceKind } from "./trace_record.js";
2
+ export interface SpanFrame {
3
+ spanId: string;
4
+ kind: TraceKind;
5
+ name: string;
6
+ startedAt: number;
7
+ }
8
+ export declare class SpanContext {
9
+ private readonly stackBySession;
10
+ push(sessionId: string, frame: SpanFrame): void;
11
+ pop(sessionId: string, spanId: string): SpanFrame | undefined;
12
+ current(sessionId: string): SpanFrame | undefined;
13
+ clear(sessionId: string): void;
14
+ }
@@ -0,0 +1,27 @@
1
+ export class SpanContext {
2
+ stackBySession = new Map();
3
+ push(sessionId, frame) {
4
+ const stack = this.stackBySession.get(sessionId) ?? [];
5
+ stack.push(frame);
6
+ this.stackBySession.set(sessionId, stack);
7
+ }
8
+ pop(sessionId, spanId) {
9
+ const stack = this.stackBySession.get(sessionId);
10
+ if (!stack?.length)
11
+ return undefined;
12
+ const top = stack[stack.length - 1];
13
+ if (top.spanId === spanId)
14
+ return stack.pop();
15
+ const idx = stack.findIndex((item) => item.spanId === spanId);
16
+ if (idx < 0)
17
+ return undefined;
18
+ return stack.splice(idx, 1)[0];
19
+ }
20
+ current(sessionId) {
21
+ const stack = this.stackBySession.get(sessionId);
22
+ return stack?.[stack.length - 1];
23
+ }
24
+ clear(sessionId) {
25
+ this.stackBySession.delete(sessionId);
26
+ }
27
+ }
@@ -0,0 +1,3 @@
1
+ import type { NDJSONWriter } from "./ndjson_writer.js";
2
+ import type { TraceToolDefinition } from "./plugin_types.js";
3
+ export declare function makeTraceCounterTool(writer: NDJSONWriter): TraceToolDefinition;
@@ -0,0 +1,26 @@
1
+ export function makeTraceCounterTool(writer) {
2
+ return {
3
+ name: "trace.counter",
4
+ description: "Emit a numeric counter metric for current session.",
5
+ inputSchema: {
6
+ type: "object",
7
+ properties: {
8
+ name: { type: "string" },
9
+ value: { type: "number" },
10
+ attrs: { type: "object" },
11
+ },
12
+ required: ["name", "value"],
13
+ },
14
+ async handler(args, runtime) {
15
+ writer.write({
16
+ type: "counter",
17
+ ts: runtime.ts ?? Date.now(),
18
+ sessionId: runtime.sessionId ?? runtime.agentId ?? "unknown-session",
19
+ name: String(args.name),
20
+ value: Number(args.value),
21
+ attrs: args.attrs ?? {},
22
+ });
23
+ return { ok: true };
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,4 @@
1
+ import type { NDJSONWriter } from "./ndjson_writer.js";
2
+ import type { SpanContext } from "./span_context.js";
3
+ import type { TraceToolDefinition } from "./plugin_types.js";
4
+ export declare function makeTraceEmitTool(writer: NDJSONWriter, ctx: SpanContext): TraceToolDefinition;
@@ -0,0 +1,33 @@
1
+ import { id } from "./utils.js";
2
+ export function makeTraceEmitTool(writer, ctx) {
3
+ return {
4
+ name: "trace.emit",
5
+ description: "Emit a tracepoint event that is attached to the current active span.",
6
+ inputSchema: {
7
+ type: "object",
8
+ properties: {
9
+ name: { type: "string" },
10
+ level: { type: "string", enum: ["info", "warn", "error"], default: "info" },
11
+ attrs: { type: "object" },
12
+ links: { type: "array" },
13
+ },
14
+ required: ["name"],
15
+ },
16
+ async handler(args, runtime) {
17
+ const sessionId = String(runtime.sessionId ?? runtime.agentId ?? "unknown-session");
18
+ const tpId = id();
19
+ writer.write({
20
+ type: "tracepoint",
21
+ ts: runtime.ts ?? Date.now(),
22
+ tpId,
23
+ sessionId,
24
+ parentSpanId: ctx.current(sessionId)?.spanId,
25
+ name: String(args.name),
26
+ level: args.level ?? "info",
27
+ attrs: args.attrs ?? {},
28
+ links: args.links ?? [],
29
+ });
30
+ return { ok: true, tpId };
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,4 @@
1
+ import type { NDJSONWriter } from "./ndjson_writer.js";
2
+ import type { SpanContext } from "./span_context.js";
3
+ import type { TraceToolDefinition } from "./plugin_types.js";
4
+ export declare function makeTraceSpanTool(writer: NDJSONWriter, ctx: SpanContext): TraceToolDefinition;
@@ -0,0 +1,56 @@
1
+ import { id } from "./utils.js";
2
+ export function makeTraceSpanTool(writer, ctx) {
3
+ return {
4
+ name: "trace.span",
5
+ description: "Start or end a manual span to capture custom business phases.",
6
+ inputSchema: {
7
+ type: "object",
8
+ properties: {
9
+ op: { type: "string", enum: ["start", "end"] },
10
+ spanId: { type: "string" },
11
+ name: { type: "string" },
12
+ kind: { type: "string", enum: ["manual", "message", "model", "tool", "skill"], default: "manual" },
13
+ status: { type: "string", enum: ["ok", "error", "unknown"], default: "ok" },
14
+ attrs: { type: "object" },
15
+ },
16
+ required: ["op"],
17
+ },
18
+ async handler(args, runtime) {
19
+ const ts = runtime.ts ?? Date.now();
20
+ const sessionId = String(runtime.sessionId ?? runtime.agentId ?? "unknown-session");
21
+ const op = String(args.op);
22
+ if (op === "start") {
23
+ const spanId = String(args.spanId ?? id());
24
+ const parentSpanId = ctx.current(sessionId)?.spanId;
25
+ const kind = args.kind ?? "manual";
26
+ const name = String(args.name ?? "manual-span");
27
+ writer.write({
28
+ type: "span_start",
29
+ ts,
30
+ spanId,
31
+ sessionId,
32
+ parentSpanId,
33
+ kind,
34
+ name,
35
+ attrs: args.attrs ?? {},
36
+ });
37
+ ctx.push(sessionId, { spanId, kind, name, startedAt: ts });
38
+ return { ok: true, spanId };
39
+ }
40
+ const explicitSpanId = args.spanId ? String(args.spanId) : undefined;
41
+ const currentSpanId = ctx.current(sessionId)?.spanId;
42
+ const spanId = explicitSpanId ?? currentSpanId ?? id();
43
+ const frame = currentSpanId ? ctx.pop(sessionId, spanId) : undefined;
44
+ writer.write({
45
+ type: "span_end",
46
+ ts,
47
+ spanId,
48
+ sessionId,
49
+ status: args.status ?? "ok",
50
+ durationMs: frame ? ts - frame.startedAt : undefined,
51
+ attrs: args.attrs ?? {},
52
+ });
53
+ return { ok: true, spanId };
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,67 @@
1
+ export type TraceLevel = "info" | "warn" | "error";
2
+ export type TraceKind = "tool" | "skill" | "model" | "message" | "manual";
3
+ export type TraceStatus = "ok" | "error" | "unknown";
4
+ export type TraceRecord = {
5
+ type: "capture_start";
6
+ captureId: string;
7
+ ts: number;
8
+ attrs?: Record<string, unknown>;
9
+ } | {
10
+ type: "capture_end";
11
+ captureId: string;
12
+ ts: number;
13
+ } | {
14
+ type: "session";
15
+ op: "upsert";
16
+ ts: number;
17
+ sessionId: string;
18
+ parentSessionId?: string;
19
+ label?: string;
20
+ attrs?: Record<string, unknown>;
21
+ } | {
22
+ type: "span_start";
23
+ ts: number;
24
+ spanId: string;
25
+ sessionId: string;
26
+ parentSpanId?: string;
27
+ kind: TraceKind;
28
+ name: string;
29
+ attrs?: Record<string, unknown>;
30
+ } | {
31
+ type: "span_end";
32
+ ts: number;
33
+ spanId: string;
34
+ sessionId: string;
35
+ status: TraceStatus;
36
+ durationMs?: number;
37
+ tokensIn?: number;
38
+ tokensOut?: number;
39
+ attrs?: Record<string, unknown>;
40
+ } | {
41
+ type: "tracepoint";
42
+ ts: number;
43
+ tpId: string;
44
+ sessionId: string;
45
+ parentSpanId?: string;
46
+ name: string;
47
+ level: TraceLevel;
48
+ attrs?: Record<string, unknown>;
49
+ links?: Array<Record<string, unknown>>;
50
+ } | {
51
+ type: "marker";
52
+ ts: number;
53
+ label: string;
54
+ sessionId?: string;
55
+ attrs?: Record<string, unknown>;
56
+ } | {
57
+ type: "counter";
58
+ ts: number;
59
+ name: string;
60
+ sessionId?: string;
61
+ value: number;
62
+ attrs?: Record<string, unknown>;
63
+ };
64
+ export interface TraceUsage {
65
+ promptTokens?: number;
66
+ completionTokens?: number;
67
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { type SessionEndEvent, type SessionStartEvent } from "./hooks_session.js";
2
+ import { type ToolEndEvent, type ToolStartEvent } from "./hooks_tool.js";
3
+ import type { TraceToolDefinition } from "./plugin_types.js";
4
+ export interface TraceKitConfig {
5
+ enabled?: boolean;
6
+ outPath?: string;
7
+ includeCounterTool?: boolean;
8
+ includeSpanTool?: boolean;
9
+ attrs?: Record<string, unknown>;
10
+ }
11
+ export interface TraceKitPlugin {
12
+ hooks: {
13
+ onToolStart: (ev: ToolStartEvent) => void;
14
+ onToolEnd: (ev: ToolEndEvent) => void;
15
+ onSessionStart: (ev: SessionStartEvent) => void;
16
+ onSessionEnd: (ev: SessionEndEvent) => void;
17
+ };
18
+ tools: TraceToolDefinition[];
19
+ shutdown: () => Promise<void>;
20
+ api: {
21
+ emitTracepoint: (input: {
22
+ sessionId: string;
23
+ name: string;
24
+ level?: "info" | "warn" | "error";
25
+ attrs?: Record<string, unknown>;
26
+ links?: Array<Record<string, unknown>>;
27
+ ts?: number;
28
+ }) => Promise<string>;
29
+ };
30
+ }
31
+ export declare function createTraceKitPlugin(config?: TraceKitConfig): TraceKitPlugin;
@@ -0,0 +1,68 @@
1
+ import { resolve } from "node:path";
2
+ import { NDJSONWriter } from "./ndjson_writer.js";
3
+ import { SpanContext } from "./span_context.js";
4
+ import { makeSessionHooks } from "./hooks_session.js";
5
+ import { makeToolHooks } from "./hooks_tool.js";
6
+ import { makeTraceCounterTool } from "./tools_trace_counter.js";
7
+ import { makeTraceEmitTool } from "./tools_trace_emit.js";
8
+ import { makeTraceSpanTool } from "./tools_trace_span.js";
9
+ import { id } from "./utils.js";
10
+ export function createTraceKitPlugin(config = {}) {
11
+ const enabled = config.enabled ?? true;
12
+ const outPath = resolve(config.outPath ?? "./trace.ndjson");
13
+ const writer = new NDJSONWriter(outPath);
14
+ const ctx = new SpanContext();
15
+ const captureId = id();
16
+ if (enabled) {
17
+ writer.write({
18
+ type: "capture_start",
19
+ captureId,
20
+ ts: Date.now(),
21
+ attrs: {
22
+ plugin: "tracekit",
23
+ outPath,
24
+ ...config.attrs,
25
+ },
26
+ });
27
+ }
28
+ const toolHooks = makeToolHooks(writer, ctx);
29
+ const sessionHooks = makeSessionHooks(writer, ctx);
30
+ const tools = [makeTraceEmitTool(writer, ctx)];
31
+ if (config.includeCounterTool ?? true)
32
+ tools.push(makeTraceCounterTool(writer));
33
+ if (config.includeSpanTool ?? true)
34
+ tools.push(makeTraceSpanTool(writer, ctx));
35
+ return {
36
+ hooks: {
37
+ onToolStart: enabled ? toolHooks.onToolStart : () => undefined,
38
+ onToolEnd: enabled ? toolHooks.onToolEnd : () => undefined,
39
+ onSessionStart: enabled ? sessionHooks.onSessionStart : () => undefined,
40
+ onSessionEnd: enabled ? sessionHooks.onSessionEnd : () => undefined,
41
+ },
42
+ tools,
43
+ shutdown: async () => {
44
+ if (!enabled)
45
+ return;
46
+ await writer.write({ type: "capture_end", captureId, ts: Date.now() });
47
+ await writer.flush();
48
+ await writer.close();
49
+ },
50
+ api: {
51
+ emitTracepoint: async (input) => {
52
+ const tpId = id();
53
+ await writer.write({
54
+ type: "tracepoint",
55
+ ts: input.ts ?? Date.now(),
56
+ tpId,
57
+ sessionId: input.sessionId,
58
+ parentSpanId: ctx.current(input.sessionId)?.spanId,
59
+ name: input.name,
60
+ level: input.level ?? "info",
61
+ attrs: input.attrs ?? {},
62
+ links: input.links ?? [],
63
+ });
64
+ return tpId;
65
+ },
66
+ },
67
+ };
68
+ }
@@ -0,0 +1,7 @@
1
+ export declare function now(): number;
2
+ export declare function id(): string;
3
+ export declare function safeError(err: unknown): Record<string, unknown>;
4
+ export declare function safeStringify(input: unknown): string;
5
+ export declare function redactSecrets(raw: string): string;
6
+ export declare function preview(input: unknown, max?: number): string;
7
+ export declare function asRecord(value: unknown): Record<string, unknown> | undefined;
@@ -0,0 +1,47 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const SECRET_PATTERNS = [
3
+ /sk-[a-zA-Z0-9]{20,}/g,
4
+ /api[_-]?key["']?\s*[:=]\s*["'][^"']+["']/gi,
5
+ /authorization["']?\s*[:=]\s*["'][^"']+["']/gi,
6
+ /password["']?\s*[:=]\s*["'][^"']+["']/gi,
7
+ ];
8
+ export function now() {
9
+ return Date.now();
10
+ }
11
+ export function id() {
12
+ return randomUUID();
13
+ }
14
+ export function safeError(err) {
15
+ if (err instanceof Error) {
16
+ return {
17
+ name: err.name,
18
+ message: err.message,
19
+ stack: err.stack,
20
+ };
21
+ }
22
+ return { value: String(err) };
23
+ }
24
+ export function safeStringify(input) {
25
+ try {
26
+ return JSON.stringify(input);
27
+ }
28
+ catch {
29
+ return String(input);
30
+ }
31
+ }
32
+ export function redactSecrets(raw) {
33
+ let result = raw;
34
+ for (const pattern of SECRET_PATTERNS) {
35
+ result = result.replace(pattern, "[REDACTED]");
36
+ }
37
+ return result;
38
+ }
39
+ export function preview(input, max = 800) {
40
+ const compact = redactSecrets(safeStringify(input));
41
+ return compact.length > max ? `${compact.slice(0, max)}...` : compact;
42
+ }
43
+ export function asRecord(value) {
44
+ if (!value || typeof value !== "object")
45
+ return undefined;
46
+ return value;
47
+ }
@@ -0,0 +1,123 @@
1
+ # TraceKit 安装指南(OpenCode)
2
+
3
+ 本文用于在 OpenCode 中安装并启用 `tracekit` 插件,让你可以采集 `agent/skill/tool` 的 trace。
4
+
5
+ ## 1. 前置条件
6
+
7
+ - Node.js `>= 20`
8
+ - OpenCode 可用的插件注册入口(代码注册或配置注册)
9
+
10
+ ## 2. 安装方式
11
+
12
+ ### 方式 A:项目内源码安装(推荐)
13
+
14
+ 适合你当前这个仓库,最快可用。
15
+
16
+ ```bash
17
+ cd plugins/tracekit
18
+ npm install
19
+ npm run build
20
+ ```
21
+
22
+ 然后在 OpenCode 启动代码里注册:
23
+
24
+ ```ts
25
+ import { createTraceKitPlugin } from "./plugins/tracekit/dist/index.js";
26
+
27
+ const tracekit = createTraceKitPlugin({
28
+ outPath: "./trace.ndjson",
29
+ includeCounterTool: true,
30
+ includeSpanTool: true,
31
+ });
32
+
33
+ opencode.registerPlugin({
34
+ hooks: tracekit.hooks,
35
+ tools: tracekit.tools,
36
+ shutdown: tracekit.shutdown,
37
+ });
38
+ ```
39
+
40
+ ### 方式 B:独立目录安装(多项目复用)
41
+
42
+ 把 `plugins/tracekit` 放到你的公共插件目录,再在各项目的 OpenCode 启动入口中 `import` 该目录的 `dist/index.js` 注册即可。
43
+
44
+ ```bash
45
+ cd /path/to/tracekit
46
+ npm install
47
+ npm run build
48
+ ```
49
+
50
+ ### 方式 C:npm 包安装(最简)
51
+
52
+ 发布后可直接安装:
53
+
54
+ ```bash
55
+ npm install opencode-tracekit
56
+ ```
57
+
58
+ 在 OpenCode 配置中按包名启用(示例):
59
+
60
+ ```json
61
+ {
62
+ "plugin": ["opencode-tracekit"]
63
+ }
64
+ ```
65
+
66
+ ## 3. 启动后验证
67
+
68
+ 1. 启动 OpenCode 会话,执行一次会触发 tool/skill 的任务。
69
+ 2. 检查是否生成 `trace.ndjson`。
70
+ 3. 检查是否有以下记录类型:
71
+ - `capture_start`
72
+ - `span_start` / `span_end`
73
+ - `tracepoint`(调用 `trace.emit` 后出现)
74
+
75
+ 你也可以用内置 CLI 验证:
76
+
77
+ ```bash
78
+ cd plugins/tracekit
79
+ node dist/src/cli.js analyze ./trace.ndjson
80
+ ```
81
+
82
+ ## 4. 在 Agent/Skill 中使用打点工具
83
+
84
+ ```json
85
+ { "tool": "trace.emit", "args": { "name": "agent.plan.generated", "level": "info" } }
86
+ ```
87
+
88
+ ```json
89
+ { "tool": "trace.span", "args": { "op": "start", "name": "ipd.phase.spec" } }
90
+ ```
91
+
92
+ ```json
93
+ { "tool": "trace.counter", "args": { "name": "token.estimate", "value": 128 } }
94
+ ```
95
+
96
+ ## 5. 导出与分析
97
+
98
+ ```bash
99
+ cd plugins/tracekit
100
+
101
+ # 分析
102
+ node dist/src/cli.js analyze ./trace.ndjson
103
+
104
+ # 导出 JSON
105
+ node dist/src/cli.js export ./trace.ndjson --out ./trace.json --format json
106
+
107
+ # 导出 CSV
108
+ node dist/src/cli.js export ./trace.ndjson --out ./trace.csv --format csv
109
+ ```
110
+
111
+ ## 6. 常见问题
112
+
113
+ - 没有生成 `trace.ndjson`:
114
+ - 确认 `createTraceKitPlugin({ enabled: true })`
115
+ - 确认插件已真正注册到 OpenCode runtime
116
+ - 确认进程结束前调用了 `shutdown`
117
+
118
+ - 有 `span_start` 没有 `span_end`:
119
+ - 检查 runtime 是否正确触发了 `onToolEnd`
120
+ - 检查 `toolCallId` 是否在 start/end 事件里一致
121
+
122
+ - `trace.emit` 不可用:
123
+ - 检查 `tools: tracekit.tools` 是否传入 runtime
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "opencode-tracekit",
3
+ "version": "0.1.0",
4
+ "description": "Tracepoint and tracing plugin for OpenCode (agent/skill/tool spans, emit/export/analyze).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "tracectl": "dist/src/cli.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "docs/installation.md"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "prepublishOnly": "npm run typecheck && npm run build",
28
+ "build": "tsc -p tsconfig.json",
29
+ "typecheck": "tsc -p tsconfig.json --noEmit",
30
+ "pack:check": "npm pack --dry-run"
31
+ },
32
+ "keywords": [
33
+ "opencode",
34
+ "trace",
35
+ "tracepoint",
36
+ "plugin",
37
+ "observability"
38
+ ],
39
+ "author": "jiqi",
40
+ "devDependencies": {
41
+ "@types/node": "^22.13.10",
42
+ "typescript": "^5.8.2"
43
+ }
44
+ }