copilot-tracer 1.0.4 → 1.0.6
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 +136 -159
- package/dist/cli.js +95 -76
- package/dist/db.js +166 -3
- package/dist/otlpReceiver.js +222 -15
- package/dist/setup.js +81 -14
- package/dist/webServer.js +39 -8
- package/package.json +2 -2
- package/web/index.html +296 -73
package/dist/db.js
CHANGED
|
@@ -8,10 +8,20 @@ if (!fs.existsSync(DB_DIR))
|
|
|
8
8
|
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
9
9
|
const db = new Database(DB_PATH);
|
|
10
10
|
db.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
path TEXT NOT NULL UNIQUE,
|
|
14
|
+
repo_url TEXT,
|
|
15
|
+
local_path TEXT,
|
|
16
|
+
created_at TEXT NOT NULL,
|
|
17
|
+
updated_at TEXT NOT NULL
|
|
18
|
+
);
|
|
19
|
+
|
|
11
20
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
12
21
|
id TEXT PRIMARY KEY,
|
|
13
22
|
started_at TEXT NOT NULL,
|
|
14
|
-
ended_at TEXT
|
|
23
|
+
ended_at TEXT,
|
|
24
|
+
project_id TEXT REFERENCES projects(id)
|
|
15
25
|
);
|
|
16
26
|
|
|
17
27
|
CREATE TABLE IF NOT EXISTS traces (
|
|
@@ -38,8 +48,113 @@ db.exec(`
|
|
|
38
48
|
FOREIGN KEY(session_id) REFERENCES sessions(id)
|
|
39
49
|
);
|
|
40
50
|
`);
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
// Migrate existing DBs
|
|
52
|
+
try {
|
|
53
|
+
db.prepare('ALTER TABLE projects ADD COLUMN repo_url TEXT').run();
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
try {
|
|
57
|
+
db.prepare('ALTER TABLE projects ADD COLUMN local_path TEXT').run();
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
try {
|
|
61
|
+
db.prepare('ALTER TABLE sessions ADD COLUMN project_id TEXT REFERENCES projects(id)').run();
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
export function ensureProject(projectPath, repoUrl) {
|
|
65
|
+
const id = 'project:' + projectPath;
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
db.prepare('INSERT OR IGNORE INTO projects (id, path, repo_url, local_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, projectPath, repoUrl ?? null, projectPath, now, now);
|
|
68
|
+
if (repoUrl) {
|
|
69
|
+
db.prepare('UPDATE projects SET repo_url = ?, updated_at = ? WHERE id = ?').run(repoUrl, now, id);
|
|
70
|
+
}
|
|
71
|
+
db.prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(now, id);
|
|
72
|
+
return id;
|
|
73
|
+
}
|
|
74
|
+
export function ensureProjectByRepo(repoUrl) {
|
|
75
|
+
// Try to find existing project by repo URL
|
|
76
|
+
const existing = db.prepare('SELECT id FROM projects WHERE repo_url = ?').get(repoUrl);
|
|
77
|
+
if (existing) {
|
|
78
|
+
db.prepare('UPDATE projects SET updated_at = ? WHERE id = ?').run(new Date().toISOString(), existing.id);
|
|
79
|
+
return existing.id;
|
|
80
|
+
}
|
|
81
|
+
// Create new project with repo URL as path (local_path set later)
|
|
82
|
+
const id = 'project:' + repoUrl;
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
db.prepare('INSERT OR IGNORE INTO projects (id, path, repo_url, local_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(id, repoUrl, repoUrl, null, now, now);
|
|
85
|
+
return id;
|
|
86
|
+
}
|
|
87
|
+
export function updateProjectLocalPath(projectId, localPath) {
|
|
88
|
+
db.prepare('UPDATE projects SET local_path = ?, updated_at = ? WHERE id = ?').run(localPath, new Date().toISOString(), projectId);
|
|
89
|
+
}
|
|
90
|
+
export function findProjectByRepo(repoUrl) {
|
|
91
|
+
const row = db.prepare('SELECT id, path, local_path FROM projects WHERE repo_url = ?').get(repoUrl);
|
|
92
|
+
return row ? { id: row.id, path: row.path, local_path: row.local_path } : null;
|
|
93
|
+
}
|
|
94
|
+
export function createSession(id, projectId) {
|
|
95
|
+
// Backfill-safe upsert: insert if missing, set project_id only when a project is
|
|
96
|
+
// provided AND the session currently has none (never null out an existing link).
|
|
97
|
+
db.prepare(`
|
|
98
|
+
INSERT INTO sessions (id, started_at, project_id) VALUES (?, ?, ?)
|
|
99
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
100
|
+
project_id = CASE
|
|
101
|
+
WHEN excluded.project_id IS NOT NULL THEN COALESCE(sessions.project_id, excluded.project_id)
|
|
102
|
+
ELSE sessions.project_id
|
|
103
|
+
END
|
|
104
|
+
`).run(id, new Date().toISOString(), projectId ?? null);
|
|
105
|
+
}
|
|
106
|
+
export function getDashboard() {
|
|
107
|
+
const projects = db.prepare(`
|
|
108
|
+
SELECT
|
|
109
|
+
p.id, p.path, p.repo_url, p.local_path,
|
|
110
|
+
COUNT(DISTINCT s.id) as session_count,
|
|
111
|
+
COALESCE(SUM(t.tokens_total), 0) as total_tokens,
|
|
112
|
+
COALESCE(SUM(t.ai_credits), 0) as total_credits,
|
|
113
|
+
MAX(s.started_at) as last_active_at
|
|
114
|
+
FROM projects p
|
|
115
|
+
LEFT JOIN sessions s ON s.project_id = p.id
|
|
116
|
+
LEFT JOIN traces t ON t.session_id = s.id
|
|
117
|
+
GROUP BY p.id
|
|
118
|
+
ORDER BY last_active_at DESC
|
|
119
|
+
`).all();
|
|
120
|
+
const totals = db.prepare(`
|
|
121
|
+
SELECT
|
|
122
|
+
(SELECT COUNT(*) FROM projects) as projects,
|
|
123
|
+
(SELECT COUNT(*) FROM sessions) as sessions,
|
|
124
|
+
COALESCE((SELECT SUM(tokens_total) FROM traces), 0) as tokens,
|
|
125
|
+
COALESCE((SELECT SUM(ai_credits) FROM traces), 0) as credits
|
|
126
|
+
`).get();
|
|
127
|
+
const enriched = projects.map(p => {
|
|
128
|
+
const lastSession = db.prepare(`
|
|
129
|
+
SELECT s.id,
|
|
130
|
+
COALESCE((SELECT SUM(tokens_total) FROM traces WHERE session_id = s.id), 0) as tokens,
|
|
131
|
+
COALESCE((SELECT SUM(ai_credits) FROM traces WHERE session_id = s.id), 0) as credits
|
|
132
|
+
FROM sessions s
|
|
133
|
+
WHERE s.project_id = ?
|
|
134
|
+
ORDER BY s.started_at DESC
|
|
135
|
+
LIMIT 1
|
|
136
|
+
`).get(p.id);
|
|
137
|
+
return {
|
|
138
|
+
id: p.id,
|
|
139
|
+
path: p.path,
|
|
140
|
+
repoUrl: p.repo_url,
|
|
141
|
+
localPath: p.local_path,
|
|
142
|
+
sessionCount: p.session_count || 0,
|
|
143
|
+
totalTokens: p.total_tokens || 0,
|
|
144
|
+
totalCredits: p.total_credits || 0,
|
|
145
|
+
lastActiveAt: p.last_active_at,
|
|
146
|
+
lastSession: lastSession ?? null,
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
projects: enriched,
|
|
151
|
+
totals: {
|
|
152
|
+
projects: totals.projects,
|
|
153
|
+
sessions: totals.sessions,
|
|
154
|
+
tokens: totals.tokens,
|
|
155
|
+
credits: totals.credits,
|
|
156
|
+
},
|
|
157
|
+
};
|
|
43
158
|
}
|
|
44
159
|
export function upsertTrace(entry) {
|
|
45
160
|
db.prepare(`
|
|
@@ -156,3 +271,51 @@ function rowToEntry(row) {
|
|
|
156
271
|
export function deleteTrace(id) {
|
|
157
272
|
db.prepare('DELETE FROM traces WHERE id = ?').run(id);
|
|
158
273
|
}
|
|
274
|
+
export function getProjectTraces(projectId, limit = 200) {
|
|
275
|
+
const rows = db.prepare(`
|
|
276
|
+
SELECT t.* FROM traces t
|
|
277
|
+
JOIN sessions s ON s.id = t.session_id
|
|
278
|
+
WHERE s.project_id = ?
|
|
279
|
+
ORDER BY t.date_time DESC
|
|
280
|
+
LIMIT ?
|
|
281
|
+
`).all(projectId, limit);
|
|
282
|
+
return rows.map((r) => rowToEntry(r));
|
|
283
|
+
}
|
|
284
|
+
export function getProjectSessionSummary(projectId) {
|
|
285
|
+
const stats = db.prepare(`
|
|
286
|
+
SELECT
|
|
287
|
+
COUNT(*) as entries,
|
|
288
|
+
SUM(t.tokens_input) as input,
|
|
289
|
+
SUM(t.tokens_output) as output,
|
|
290
|
+
SUM(t.tokens_cached) as cached,
|
|
291
|
+
SUM(t.tokens_reasoning) as reasoning,
|
|
292
|
+
SUM(t.tokens_written) as written,
|
|
293
|
+
SUM(t.tokens_total) as total,
|
|
294
|
+
SUM(t.ai_credits) as credits,
|
|
295
|
+
SUM(t.duration_ms) as duration,
|
|
296
|
+
SUM(t.skill_count) as skills,
|
|
297
|
+
SUM(t.agent_count) as agents,
|
|
298
|
+
SUM(t.mcp_count) as mcps
|
|
299
|
+
FROM traces t
|
|
300
|
+
JOIN sessions s ON s.id = t.session_id
|
|
301
|
+
WHERE s.project_id = ?
|
|
302
|
+
`).get(projectId);
|
|
303
|
+
return {
|
|
304
|
+
sessionId: projectId,
|
|
305
|
+
startedAt: '',
|
|
306
|
+
totalEntries: stats.entries || 0,
|
|
307
|
+
totalTokens: {
|
|
308
|
+
input: stats.input || 0,
|
|
309
|
+
output: stats.output || 0,
|
|
310
|
+
cached: stats.cached || 0,
|
|
311
|
+
reasoning: stats.reasoning || 0,
|
|
312
|
+
written: stats.written || 0,
|
|
313
|
+
total: stats.total || 0,
|
|
314
|
+
},
|
|
315
|
+
totalCredits: stats.credits || 0,
|
|
316
|
+
totalDurationMs: stats.duration || 0,
|
|
317
|
+
totalSkillCalls: stats.skills || 0,
|
|
318
|
+
totalAgentCalls: stats.agents || 0,
|
|
319
|
+
totalMcpCalls: stats.mcps || 0,
|
|
320
|
+
};
|
|
321
|
+
}
|
package/dist/otlpReceiver.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* chat <model> span attrs: same as above
|
|
15
15
|
* User message event: github.copilot.user.message
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import { upsertTrace, createSession, deleteTrace, ensureProject, ensureProjectByRepo } from './db.js';
|
|
18
19
|
import { traceEvents } from './proxy.js';
|
|
19
20
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
20
21
|
function getAttr(attrs, key) {
|
|
@@ -36,21 +37,167 @@ function nanoToMs(nano) {
|
|
|
36
37
|
function nanoToIso(nano) {
|
|
37
38
|
return new Date(nanoToMs(nano)).toISOString();
|
|
38
39
|
}
|
|
40
|
+
function getStringAttr(attrs, ...keys) {
|
|
41
|
+
for (const key of keys) {
|
|
42
|
+
const value = getAttr(attrs, key);
|
|
43
|
+
if (value !== undefined && String(value).trim())
|
|
44
|
+
return String(value);
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
function getBodyText(body) {
|
|
49
|
+
if (!body)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (body.stringValue !== undefined)
|
|
52
|
+
return body.stringValue;
|
|
53
|
+
if (body.intValue !== undefined)
|
|
54
|
+
return String(body.intValue);
|
|
55
|
+
if (body.doubleValue !== undefined)
|
|
56
|
+
return String(body.doubleValue);
|
|
57
|
+
if (body.boolValue !== undefined)
|
|
58
|
+
return String(body.boolValue);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
39
61
|
const inFlight = new Map(); // traceId → InFlight
|
|
40
|
-
// Sessions
|
|
41
|
-
|
|
42
|
-
function ensureSession(sessionId) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
62
|
+
// Sessions are created lazily. Always upsert so project_id gets backfilled
|
|
63
|
+
// when the session was created earlier without a project.
|
|
64
|
+
function ensureSession(sessionId, projectId) {
|
|
65
|
+
createSession(sessionId, projectId);
|
|
66
|
+
}
|
|
67
|
+
// ── Project resolution ────────────────────────────────────────────────────────
|
|
68
|
+
// Precedence: repo URL > working dir > default (CLI) project.
|
|
69
|
+
// If none match, the session keeps no project (traces still appear on live page).
|
|
70
|
+
function resolveProjectId(repoUrl, workingDir, defaultProjectId) {
|
|
71
|
+
if (repoUrl)
|
|
72
|
+
return ensureProjectByRepo(String(repoUrl));
|
|
73
|
+
if (workingDir)
|
|
74
|
+
return ensureProject(workingDir);
|
|
75
|
+
return defaultProjectId;
|
|
76
|
+
}
|
|
77
|
+
function detectWorkingDir(attrs) {
|
|
78
|
+
for (const key of ['process.working_directory', 'github.copilot.working_dir', 'claude_code.working_dir']) {
|
|
79
|
+
const v = getAttr(attrs, key);
|
|
80
|
+
if (v && String(v).trim())
|
|
81
|
+
return String(v).trim();
|
|
46
82
|
}
|
|
83
|
+
return undefined;
|
|
47
84
|
}
|
|
48
85
|
// ── Process one batch of spans ────────────────────────────────────────────────
|
|
49
86
|
// Track standalone chat entries so invoke_agent can replace them (multiple chat spans per traceId)
|
|
50
87
|
const pendingChatIds = new Map(); // `chat:${traceId}` → list of entryIds
|
|
51
88
|
// Buffer tool calls that arrive before invoke_agent
|
|
52
89
|
const pendingToolCalls = new Map(); // traceId → tool calls
|
|
53
|
-
|
|
90
|
+
const claudePromptEntries = new Map(); // prompt.id → trace entry
|
|
91
|
+
const claudeInteractionEntries = new Map(); // traceId → interaction entry
|
|
92
|
+
const CLAUDE_STATE_LIMIT = 1000;
|
|
93
|
+
function rememberClaudeEntry(map, key, entry) {
|
|
94
|
+
map.set(key, entry);
|
|
95
|
+
if (map.size > CLAUDE_STATE_LIMIT) {
|
|
96
|
+
const oldest = map.keys().next().value;
|
|
97
|
+
if (oldest)
|
|
98
|
+
map.delete(oldest);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function claudeEventId(record, attrs, eventName) {
|
|
102
|
+
const messageId = getStringAttr(attrs, 'message.uuid', 'tool_use_id');
|
|
103
|
+
const promptId = getStringAttr(attrs, 'prompt.id');
|
|
104
|
+
return `claude:${messageId ?? promptId ?? record.traceId ?? randomUUID()}:${eventName}`;
|
|
105
|
+
}
|
|
106
|
+
function processClaudeLogRecord(record, resourceAttrs, sessionId, projectId, workingDir) {
|
|
107
|
+
const attrs = record.attributes ?? [];
|
|
108
|
+
const eventName = getStringAttr(attrs, 'event.name') ?? getBodyText(record.body);
|
|
109
|
+
if (!eventName || !eventName.startsWith('claude_code.'))
|
|
110
|
+
return;
|
|
111
|
+
const eventTime = getStringAttr(attrs, 'event.timestamp');
|
|
112
|
+
const dateTime = eventTime ?? (record.timeUnixNano ? nanoToIso(record.timeUnixNano) : new Date().toISOString());
|
|
113
|
+
const promptId = getStringAttr(attrs, 'prompt.id');
|
|
114
|
+
const resolvedProjectId = resolveProjectId(getStringAttr(resourceAttrs, 'github.copilot.git.repository', 'vcs.repository.url'), getStringAttr(attrs, 'process.working_directory', 'github.copilot.working_dir', 'claude_code.working_dir') ?? workingDir, projectId);
|
|
115
|
+
const eventSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
|
|
116
|
+
if (eventName === 'claude_code.user_prompt') {
|
|
117
|
+
const entry = {
|
|
118
|
+
id: claudeEventId(record, attrs, 'prompt'),
|
|
119
|
+
sessionId: eventSessionId,
|
|
120
|
+
dateTime,
|
|
121
|
+
prompt: getStringAttr(attrs, 'prompt') ?? '[Claude Code prompt]',
|
|
122
|
+
tokens: { input: 0, output: 0, cached: 0, reasoning: 0, written: 0, total: 0 },
|
|
123
|
+
aiCredits: 0,
|
|
124
|
+
durationMs: 0,
|
|
125
|
+
toolCalls: [],
|
|
126
|
+
skillCount: 0,
|
|
127
|
+
agentCount: 0,
|
|
128
|
+
mcpCount: 0,
|
|
129
|
+
status: 'running',
|
|
130
|
+
};
|
|
131
|
+
if (promptId)
|
|
132
|
+
rememberClaudeEntry(claudePromptEntries, promptId, entry);
|
|
133
|
+
ensureSession(eventSessionId, resolvedProjectId);
|
|
134
|
+
upsertTrace(entry);
|
|
135
|
+
traceEvents.emit('trace:update', entry);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (eventName === 'claude_code.assistant_response') {
|
|
139
|
+
const existingEntry = promptId ? claudePromptEntries.get(promptId) : undefined;
|
|
140
|
+
const entry = existingEntry ?? {
|
|
141
|
+
id: claudeEventId(record, attrs, 'response'),
|
|
142
|
+
sessionId: eventSessionId,
|
|
143
|
+
dateTime,
|
|
144
|
+
prompt: '[Claude Code response]',
|
|
145
|
+
tokens: { input: 0, output: 0, cached: 0, reasoning: 0, written: 0, total: 0 },
|
|
146
|
+
aiCredits: 0,
|
|
147
|
+
durationMs: 0,
|
|
148
|
+
toolCalls: [],
|
|
149
|
+
skillCount: 0,
|
|
150
|
+
agentCount: 0,
|
|
151
|
+
mcpCount: 0,
|
|
152
|
+
status: 'running',
|
|
153
|
+
};
|
|
154
|
+
entry.response = getStringAttr(attrs, 'response');
|
|
155
|
+
entry.status = 'done';
|
|
156
|
+
entry.durationMs = Number(getStringAttr(attrs, 'duration_ms') ?? 0);
|
|
157
|
+
ensureSession(eventSessionId, resolvedProjectId);
|
|
158
|
+
upsertTrace(entry);
|
|
159
|
+
traceEvents.emit('trace:update', entry);
|
|
160
|
+
traceEvents.emit('trace:done', entry);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (eventName === 'claude_code.tool_result') {
|
|
164
|
+
const entry = promptId ? claudePromptEntries.get(promptId) : undefined;
|
|
165
|
+
if (!entry)
|
|
166
|
+
return;
|
|
167
|
+
const toolName = getStringAttr(attrs, 'tool_name') ?? 'Claude Code tool';
|
|
168
|
+
const toolId = getStringAttr(attrs, 'tool_use_id') ?? randomUUID();
|
|
169
|
+
if (entry.toolCalls.some(call => call.id === toolId))
|
|
170
|
+
return;
|
|
171
|
+
entry.toolCalls.push({
|
|
172
|
+
id: toolId,
|
|
173
|
+
name: toolName,
|
|
174
|
+
type: detectToolType(toolName),
|
|
175
|
+
input: {},
|
|
176
|
+
startedAt: Date.parse(dateTime),
|
|
177
|
+
endedAt: Date.parse(dateTime),
|
|
178
|
+
durationMs: Number(getStringAttr(attrs, 'duration_ms') ?? 0),
|
|
179
|
+
error: getStringAttr(attrs, 'error'),
|
|
180
|
+
});
|
|
181
|
+
upsertTrace(entry);
|
|
182
|
+
traceEvents.emit('trace:update', entry);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function processClaudeLogs(payload, defaultSessionId, projectId) {
|
|
186
|
+
if (!payload || !Array.isArray(payload.resourceLogs)) {
|
|
187
|
+
throw new Error('OTLP logs payload must contain resourceLogs');
|
|
188
|
+
}
|
|
189
|
+
for (const resourceLogs of payload.resourceLogs ?? []) {
|
|
190
|
+
const resourceAttrs = resourceLogs.resource?.attributes ?? [];
|
|
191
|
+
const sessionId = getStringAttr(resourceAttrs, 'session.id') ?? defaultSessionId;
|
|
192
|
+
const workingDir = detectWorkingDir(resourceAttrs);
|
|
193
|
+
for (const scopeLogs of resourceLogs.scopeLogs ?? []) {
|
|
194
|
+
for (const record of scopeLogs.logRecords ?? []) {
|
|
195
|
+
processClaudeLogRecord(record, resourceAttrs, sessionId, projectId, workingDir);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
function processSpans(spans, sessionId, projectId, workingDir) {
|
|
54
201
|
for (const span of spans) {
|
|
55
202
|
// DEBUG — log raw span to stderr when COPILOT_TRACER_DEBUG=1
|
|
56
203
|
if (process.env.COPILOT_TRACER_DEBUG === '1') {
|
|
@@ -60,11 +207,61 @@ function processSpans(spans, sessionId) {
|
|
|
60
207
|
const spanName = span.name;
|
|
61
208
|
const traceId = span.traceId;
|
|
62
209
|
const spanId = span.spanId;
|
|
210
|
+
if (spanName === 'claude_code.interaction') {
|
|
211
|
+
const attrs = span.attributes ?? [];
|
|
212
|
+
const inputTokens = Number(getAttr(attrs, 'input_tokens') ?? 0);
|
|
213
|
+
const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
|
|
214
|
+
const entry = {
|
|
215
|
+
id: spanId,
|
|
216
|
+
sessionId: getStringAttr(attrs, 'session.id') ?? sessionId,
|
|
217
|
+
dateTime: nanoToIso(span.startTimeUnixNano),
|
|
218
|
+
prompt: getStringAttr(attrs, 'user_prompt') ?? '[Claude Code interaction]',
|
|
219
|
+
tokens: { input: inputTokens, output: outputTokens, cached: Number(getAttr(attrs, 'cache_read_tokens') ?? 0), reasoning: 0, written: outputTokens, total: inputTokens + outputTokens },
|
|
220
|
+
aiCredits: Number(getAttr(attrs, 'cost_usd') ?? 0),
|
|
221
|
+
durationMs: Number(getAttr(attrs, 'interaction.duration_ms')
|
|
222
|
+
?? (nanoToMs(span.endTimeUnixNano) - nanoToMs(span.startTimeUnixNano))),
|
|
223
|
+
toolCalls: [],
|
|
224
|
+
skillCount: 0,
|
|
225
|
+
agentCount: 0,
|
|
226
|
+
mcpCount: 0,
|
|
227
|
+
status: (span.status?.code ?? 0) === 2 ? 'error' : 'done',
|
|
228
|
+
error: span.status?.message,
|
|
229
|
+
};
|
|
230
|
+
ensureSession(entry.sessionId, resolveProjectId(getAttr(attrs, 'vcs.repository.url'), detectWorkingDir(attrs) ?? workingDir, projectId));
|
|
231
|
+
upsertTrace(entry);
|
|
232
|
+
rememberClaudeEntry(claudeInteractionEntries, traceId, entry);
|
|
233
|
+
traceEvents.emit('trace:update', entry);
|
|
234
|
+
traceEvents.emit('trace:done', entry);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (spanName === 'claude_code.llm_request') {
|
|
238
|
+
const inputTokens = Number(getAttr(attrs, 'input_tokens') ?? 0);
|
|
239
|
+
const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
|
|
240
|
+
const cachedTokens = Number(getAttr(attrs, 'cache_read_tokens') ?? 0);
|
|
241
|
+
const entry = claudeInteractionEntries.get(traceId);
|
|
242
|
+
if (entry) {
|
|
243
|
+
entry.tokens = {
|
|
244
|
+
input: entry.tokens.input + inputTokens,
|
|
245
|
+
output: entry.tokens.output + outputTokens,
|
|
246
|
+
cached: entry.tokens.cached + cachedTokens,
|
|
247
|
+
reasoning: 0,
|
|
248
|
+
written: entry.tokens.written + outputTokens,
|
|
249
|
+
total: entry.tokens.total + inputTokens + outputTokens,
|
|
250
|
+
};
|
|
251
|
+
entry.aiCredits += Number(getAttr(attrs, 'cost_usd') ?? 0);
|
|
252
|
+
upsertTrace(entry);
|
|
253
|
+
traceEvents.emit('trace:update', entry);
|
|
254
|
+
}
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
63
257
|
// ── invoke_agent span = top-level agent turn ──────────────────────────
|
|
64
258
|
if (spanName === 'invoke_agent') {
|
|
65
259
|
const startMs = nanoToMs(span.startTimeUnixNano);
|
|
66
260
|
const endMs = nanoToMs(span.endTimeUnixNano);
|
|
67
261
|
const durationMs = endMs - startMs;
|
|
262
|
+
// Extract repo URL for auto project detection
|
|
263
|
+
const repoUrl = getAttr(attrs, 'github.copilot.git.repository');
|
|
264
|
+
const resolvedProjectId = resolveProjectId(repoUrl, workingDir, projectId);
|
|
68
265
|
// Extract prompt from gen_ai.input.messages (real attr name from copilot)
|
|
69
266
|
let promptText = '';
|
|
70
267
|
let responseText = '';
|
|
@@ -213,7 +410,7 @@ function processSpans(spans, sessionId) {
|
|
|
213
410
|
inf.entry.mcpCount = mcps;
|
|
214
411
|
pendingToolCalls.delete(traceId);
|
|
215
412
|
}
|
|
216
|
-
ensureSession(sessionId);
|
|
413
|
+
ensureSession(sessionId, resolvedProjectId);
|
|
217
414
|
upsertTrace(entry);
|
|
218
415
|
traceEvents.emit('trace:update', entry);
|
|
219
416
|
traceEvents.emit('trace:done', entry);
|
|
@@ -276,7 +473,7 @@ function processSpans(spans, sessionId) {
|
|
|
276
473
|
skillCount: 0, agentCount: 0, mcpCount: 0,
|
|
277
474
|
status: 'done',
|
|
278
475
|
};
|
|
279
|
-
ensureSession(sessionId);
|
|
476
|
+
ensureSession(sessionId, resolveProjectId(getAttr(attrs, 'github.copilot.git.repository'), workingDir, projectId));
|
|
280
477
|
upsertTrace(entry);
|
|
281
478
|
const chatKey = `chat:${traceId}`;
|
|
282
479
|
const existing = pendingChatIds.get(chatKey) ?? [];
|
|
@@ -383,7 +580,7 @@ function detectToolType(name) {
|
|
|
383
580
|
return 'builtin';
|
|
384
581
|
}
|
|
385
582
|
// ── Register OTLP HTTP routes on the Express app ──────────────────────────────
|
|
386
|
-
export function registerOtlpRoutes(app, defaultSessionId) {
|
|
583
|
+
export function registerOtlpRoutes(app, defaultSessionId, projectId) {
|
|
387
584
|
// OTLP traces — copilot sends JSON (http/json protocol)
|
|
388
585
|
// body already parsed by express.json() middleware registered before this call
|
|
389
586
|
app.post('/v1/traces', (req, res) => {
|
|
@@ -396,8 +593,9 @@ export function registerOtlpRoutes(app, defaultSessionId) {
|
|
|
396
593
|
?? getAttr(resAttrs, 'session.id')
|
|
397
594
|
?? getAttr(resAttrs, 'github.copilot.conversation_id');
|
|
398
595
|
const sessionId = String(sessionFromOtel ?? defaultSessionId);
|
|
596
|
+
const workingDir = detectWorkingDir(resAttrs);
|
|
399
597
|
for (const ss of rs.scopeSpans ?? []) {
|
|
400
|
-
processSpans(ss.spans ?? [], sessionId);
|
|
598
|
+
processSpans(ss.spans ?? [], sessionId, projectId, workingDir);
|
|
401
599
|
}
|
|
402
600
|
}
|
|
403
601
|
res.status(200).json({ partialSuccess: {} });
|
|
@@ -407,8 +605,17 @@ export function registerOtlpRoutes(app, defaultSessionId) {
|
|
|
407
605
|
res.status(400).json({ error: 'invalid payload' });
|
|
408
606
|
}
|
|
409
607
|
});
|
|
410
|
-
|
|
608
|
+
app.post('/v1/logs', (req, res) => {
|
|
609
|
+
try {
|
|
610
|
+
processClaudeLogs(req.body, defaultSessionId, projectId);
|
|
611
|
+
res.status(200).json({ partialSuccess: {} });
|
|
612
|
+
}
|
|
613
|
+
catch (e) {
|
|
614
|
+
console.error('[OTLP] log parse error:', e);
|
|
615
|
+
res.status(400).json({ error: 'invalid payload' });
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
// Metrics remain accepted for Claude Code dashboards but are not trace entries.
|
|
411
619
|
app.post('/v1/metrics', (_req, res) => res.status(200).json({ partialSuccess: {} }));
|
|
412
|
-
|
|
413
|
-
console.log(' 📡 OTLP receiver ready on /v1/traces');
|
|
620
|
+
console.log(' 📡 OTLP receiver ready on /v1/traces and /v1/logs');
|
|
414
621
|
}
|
package/dist/setup.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* copilot-tracer setup — auto-detect
|
|
2
|
+
* copilot-tracer setup — auto-detect Copilot/VS Code and inject OTLP env config
|
|
3
3
|
*
|
|
4
4
|
* What it does:
|
|
5
5
|
* 1. Detect copilot CLI (which copilot)
|
|
@@ -16,12 +16,62 @@ import os from 'os';
|
|
|
16
16
|
const OTEL_ENDPOINT_KEY = 'OTEL_EXPORTER_OTLP_ENDPOINT';
|
|
17
17
|
const OTEL_CONTENT_KEY = 'OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT';
|
|
18
18
|
const OTEL_ENABLED_KEY = 'COPILOT_OTEL_ENABLED';
|
|
19
|
+
const CLAUDE_TELEMETRY_KEY = 'CLAUDE_CODE_ENABLE_TELEMETRY';
|
|
20
|
+
const CLAUDE_TRACES_KEY = 'CLAUDE_CODE_ENHANCED_TELEMETRY_BETA';
|
|
21
|
+
const OTEL_LOGS_EXPORTER_KEY = 'OTEL_LOGS_EXPORTER';
|
|
22
|
+
const OTEL_TRACES_EXPORTER_KEY = 'OTEL_TRACES_EXPORTER';
|
|
23
|
+
const OTEL_PROTOCOL_KEY = 'OTEL_EXPORTER_OTLP_PROTOCOL';
|
|
24
|
+
const OTEL_LOG_PROMPTS_KEY = 'OTEL_LOG_USER_PROMPTS';
|
|
25
|
+
const OTEL_LOG_RESPONSES_KEY = 'OTEL_LOG_ASSISTANT_RESPONSES';
|
|
19
26
|
function otelEnvBlock(port) {
|
|
20
27
|
return [
|
|
21
28
|
`# >>> copilot-tracer OTLP config (auto-added) >>>`,
|
|
22
29
|
`export ${OTEL_ENDPOINT_KEY}=http://localhost:${port}`,
|
|
23
30
|
`export ${OTEL_CONTENT_KEY}=true`,
|
|
24
31
|
`export ${OTEL_ENABLED_KEY}=true`,
|
|
32
|
+
`export ${CLAUDE_TELEMETRY_KEY}=1`,
|
|
33
|
+
`export ${CLAUDE_TRACES_KEY}=1`,
|
|
34
|
+
`export ${OTEL_LOGS_EXPORTER_KEY}=otlp`,
|
|
35
|
+
`export ${OTEL_TRACES_EXPORTER_KEY}=otlp`,
|
|
36
|
+
`export ${OTEL_PROTOCOL_KEY}=http/json`,
|
|
37
|
+
`export ${OTEL_LOG_PROMPTS_KEY}=1`,
|
|
38
|
+
`export ${OTEL_LOG_RESPONSES_KEY}=1`,
|
|
39
|
+
``,
|
|
40
|
+
`# Tag every copilot prompt with the terminal folder it ran from, so the`,
|
|
41
|
+
`# tracer can attribute it to the right project. OTLP carries no working-dir,`,
|
|
42
|
+
`# so we inject it via OTEL_RESOURCE_ATTRIBUTES (percent-encoded).`,
|
|
43
|
+
`copilot() {`,
|
|
44
|
+
` local _wd`,
|
|
45
|
+
` if command -v python3 >/dev/null 2>&1; then`,
|
|
46
|
+
` _wd="$(pwd | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read().strip(), safe="/"))')"`,
|
|
47
|
+
` else`,
|
|
48
|
+
` _wd="$(pwd)"`,
|
|
49
|
+
` fi`,
|
|
50
|
+
` if [ -n "\${OTEL_RESOURCE_ATTRIBUTES:-}" ]; then`,
|
|
51
|
+
` # Drop any stale working_dir entry, then append the current folder`,
|
|
52
|
+
` OTEL_RESOURCE_ATTRIBUTES="$(printf '%s' "\$OTEL_RESOURCE_ATTRIBUTES" | sed -E 's/(^|,)github\.copilot\.working_dir=[^,]*/\\1/g; s/^,//')"`,
|
|
53
|
+
` [ -z "\$OTEL_RESOURCE_ATTRIBUTES" ] || OTEL_RESOURCE_ATTRIBUTES="\${OTEL_RESOURCE_ATTRIBUTES},"`,
|
|
54
|
+
` fi`,
|
|
55
|
+
` OTEL_RESOURCE_ATTRIBUTES="\${OTEL_RESOURCE_ATTRIBUTES}github.copilot.working_dir=\${_wd}"`,
|
|
56
|
+
` export OTEL_RESOURCE_ATTRIBUTES`,
|
|
57
|
+
` command copilot "\$@"`,
|
|
58
|
+
`}`,
|
|
59
|
+
``,
|
|
60
|
+
`claude() {`,
|
|
61
|
+
` local _wd`,
|
|
62
|
+
` if command -v python3 >/dev/null 2>&1; then`,
|
|
63
|
+
` _wd="$(pwd | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read().strip(), safe="/"))')"`,
|
|
64
|
+
` else`,
|
|
65
|
+
` _wd="$(pwd)"`,
|
|
66
|
+
` fi`,
|
|
67
|
+
` if [ -n "\${OTEL_RESOURCE_ATTRIBUTES:-}" ]; then`,
|
|
68
|
+
` OTEL_RESOURCE_ATTRIBUTES="\${OTEL_RESOURCE_ATTRIBUTES},claude_code.working_dir=\${_wd}"`,
|
|
69
|
+
` else`,
|
|
70
|
+
` OTEL_RESOURCE_ATTRIBUTES="claude_code.working_dir=\${_wd}"`,
|
|
71
|
+
` fi`,
|
|
72
|
+
` export OTEL_RESOURCE_ATTRIBUTES`,
|
|
73
|
+
` command claude "\$@"`,
|
|
74
|
+
`}`,
|
|
25
75
|
`# <<< copilot-tracer <<<`,
|
|
26
76
|
].join('\n');
|
|
27
77
|
}
|
|
@@ -30,6 +80,13 @@ function vscodeEnvBlock(port) {
|
|
|
30
80
|
[OTEL_ENDPOINT_KEY]: `http://localhost:${port}`,
|
|
31
81
|
[OTEL_CONTENT_KEY]: 'true',
|
|
32
82
|
[OTEL_ENABLED_KEY]: 'true',
|
|
83
|
+
[CLAUDE_TELEMETRY_KEY]: '1',
|
|
84
|
+
[CLAUDE_TRACES_KEY]: '1',
|
|
85
|
+
[OTEL_LOGS_EXPORTER_KEY]: 'otlp',
|
|
86
|
+
[OTEL_TRACES_EXPORTER_KEY]: 'otlp',
|
|
87
|
+
[OTEL_PROTOCOL_KEY]: 'http/json',
|
|
88
|
+
[OTEL_LOG_PROMPTS_KEY]: '1',
|
|
89
|
+
[OTEL_LOG_RESPONSES_KEY]: '1',
|
|
33
90
|
};
|
|
34
91
|
}
|
|
35
92
|
// ── Detection helpers ─────────────────────────────────────────────────────────
|
|
@@ -130,7 +187,7 @@ function patchVSCodeSettings(settingsPath, port) {
|
|
|
130
187
|
return { action: wasSet ? 'updated' : 'added' };
|
|
131
188
|
}
|
|
132
189
|
// ── Main setup ────────────────────────────────────────────────────────────────
|
|
133
|
-
export function runSetup(port) {
|
|
190
|
+
export function runSetup(port, silent = false) {
|
|
134
191
|
const CHECK = '✅';
|
|
135
192
|
const WARN = '⚠️ ';
|
|
136
193
|
const INFO = '📍';
|
|
@@ -173,12 +230,13 @@ export function runSetup(port) {
|
|
|
173
230
|
const label = path.basename(profilePath);
|
|
174
231
|
if (result.action === 'added') {
|
|
175
232
|
console.log(`\n${CHECK} Shell profile patched: ${label}`);
|
|
176
|
-
console.log(` ${ARROW} Added
|
|
233
|
+
console.log(` ${ARROW} Added Copilot + Claude Code OTLP env vars`);
|
|
234
|
+
console.log(` ${ARROW} Added copilot() wrapper — tags each prompt with the terminal folder`);
|
|
177
235
|
console.log(` ${ARROW} Run: source ${profilePath}`);
|
|
178
236
|
}
|
|
179
237
|
else if (result.action === 'updated') {
|
|
180
238
|
console.log(`\n${CHECK} Shell profile updated: ${label}`);
|
|
181
|
-
console.log(` ${ARROW} Updated port to ${port}`);
|
|
239
|
+
console.log(` ${ARROW} Updated port to ${port} + Claude Code/Copilot OTLP config`);
|
|
182
240
|
console.log(` ${ARROW} Run: source ${profilePath}`);
|
|
183
241
|
}
|
|
184
242
|
else {
|
|
@@ -210,15 +268,24 @@ export function runSetup(port) {
|
|
|
210
268
|
process.env[OTEL_ENDPOINT_KEY] = `http://localhost:${port}`;
|
|
211
269
|
process.env[OTEL_CONTENT_KEY] = 'true';
|
|
212
270
|
process.env[OTEL_ENABLED_KEY] = 'true';
|
|
271
|
+
process.env[CLAUDE_TELEMETRY_KEY] = '1';
|
|
272
|
+
process.env[CLAUDE_TRACES_KEY] = '1';
|
|
273
|
+
process.env[OTEL_LOGS_EXPORTER_KEY] = 'otlp';
|
|
274
|
+
process.env[OTEL_TRACES_EXPORTER_KEY] = 'otlp';
|
|
275
|
+
process.env[OTEL_PROTOCOL_KEY] = 'http/json';
|
|
276
|
+
process.env[OTEL_LOG_PROMPTS_KEY] = '1';
|
|
277
|
+
process.env[OTEL_LOG_RESPONSES_KEY] = '1';
|
|
213
278
|
// 6. Summary
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
279
|
+
if (!silent) {
|
|
280
|
+
const profileBase = profilePath ? path.basename(profilePath) : '.zshrc';
|
|
281
|
+
console.log('\n────────────────────────────────────────────────');
|
|
282
|
+
console.log(' One manual step required:\n');
|
|
283
|
+
console.log(` source ~/${profileBase}`);
|
|
284
|
+
console.log(` (opens a new terminal already? — env is already active there)`);
|
|
285
|
+
if (vscode.found)
|
|
286
|
+
console.log('\n Restart VS Code once to pick up the new terminal env.');
|
|
287
|
+
console.log('\n ✨ Starting tracer web UI now...');
|
|
288
|
+
console.log(` Open: http://localhost:${port}/`);
|
|
289
|
+
console.log('────────────────────────────────────────────────\n');
|
|
290
|
+
}
|
|
224
291
|
}
|