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/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
- export function createSession(id) {
42
- db.prepare('INSERT OR REPLACE INTO sessions (id, started_at) VALUES (?, ?)').run(id, new Date().toISOString());
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
+ }
@@ -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 { upsertTrace, createSession, deleteTrace } from './db.js';
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 we've created in DB (avoid duplicate createSession calls)
41
- const knownSessions = new Set();
42
- function ensureSession(sessionId) {
43
- if (!knownSessions.has(sessionId)) {
44
- createSession(sessionId);
45
- knownSessions.add(sessionId);
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
- function processSpans(spans, sessionId) {
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
- // Metrics + logs — accept and ignore
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
- app.post('/v1/logs', (_req, res) => res.status(200).json({ partialSuccess: {} }));
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 copilot CLI + VS Code and inject OTLP env config
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 OTEL env vars (endpoint, content capture)`);
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
- const profileBase = profilePath ? path.basename(profilePath) : '.zshrc';
215
- console.log('\n────────────────────────────────────────────────');
216
- console.log(' One manual step required:\n');
217
- console.log(` source ~/${profileBase}`);
218
- console.log(` (opens a new terminal already? — env is already active there)`);
219
- if (vscode.found)
220
- console.log('\n Restart VS Code once to pick up the new terminal env.');
221
- console.log('\n ✨ Starting tracer web UI now...');
222
- console.log(` Open: http://localhost:${port}/`);
223
- console.log('────────────────────────────────────────────────\n');
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
  }