copilot-tracer 1.0.4 → 1.0.5

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,7 @@
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 { upsertTrace, createSession, deleteTrace, ensureProject, ensureProjectByRepo } from './db.js';
18
18
  import { traceEvents } from './proxy.js';
19
19
  // ── Helpers ───────────────────────────────────────────────────────────────────
20
20
  function getAttr(attrs, key) {
@@ -37,20 +37,35 @@ function nanoToIso(nano) {
37
37
  return new Date(nanoToMs(nano)).toISOString();
38
38
  }
39
39
  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);
40
+ // Sessions are created lazily. Always upsert so project_id gets backfilled
41
+ // when the session was created earlier without a project.
42
+ function ensureSession(sessionId, projectId) {
43
+ createSession(sessionId, projectId);
44
+ }
45
+ // ── Project resolution ────────────────────────────────────────────────────────
46
+ // Precedence: repo URL > working dir > default (CLI) project.
47
+ // If none match, the session keeps no project (traces still appear on live page).
48
+ function resolveProjectId(repoUrl, workingDir, defaultProjectId) {
49
+ if (repoUrl)
50
+ return ensureProjectByRepo(String(repoUrl));
51
+ if (workingDir)
52
+ return ensureProject(workingDir);
53
+ return defaultProjectId;
54
+ }
55
+ function detectWorkingDir(attrs) {
56
+ for (const key of ['process.working_directory', 'github.copilot.working_dir']) {
57
+ const v = getAttr(attrs, key);
58
+ if (v && String(v).trim())
59
+ return String(v).trim();
46
60
  }
61
+ return undefined;
47
62
  }
48
63
  // ── Process one batch of spans ────────────────────────────────────────────────
49
64
  // Track standalone chat entries so invoke_agent can replace them (multiple chat spans per traceId)
50
65
  const pendingChatIds = new Map(); // `chat:${traceId}` → list of entryIds
51
66
  // Buffer tool calls that arrive before invoke_agent
52
67
  const pendingToolCalls = new Map(); // traceId → tool calls
53
- function processSpans(spans, sessionId) {
68
+ function processSpans(spans, sessionId, projectId, workingDir) {
54
69
  for (const span of spans) {
55
70
  // DEBUG — log raw span to stderr when COPILOT_TRACER_DEBUG=1
56
71
  if (process.env.COPILOT_TRACER_DEBUG === '1') {
@@ -65,6 +80,9 @@ function processSpans(spans, sessionId) {
65
80
  const startMs = nanoToMs(span.startTimeUnixNano);
66
81
  const endMs = nanoToMs(span.endTimeUnixNano);
67
82
  const durationMs = endMs - startMs;
83
+ // Extract repo URL for auto project detection
84
+ const repoUrl = getAttr(attrs, 'github.copilot.git.repository');
85
+ const resolvedProjectId = resolveProjectId(repoUrl, workingDir, projectId);
68
86
  // Extract prompt from gen_ai.input.messages (real attr name from copilot)
69
87
  let promptText = '';
70
88
  let responseText = '';
@@ -213,7 +231,7 @@ function processSpans(spans, sessionId) {
213
231
  inf.entry.mcpCount = mcps;
214
232
  pendingToolCalls.delete(traceId);
215
233
  }
216
- ensureSession(sessionId);
234
+ ensureSession(sessionId, resolvedProjectId);
217
235
  upsertTrace(entry);
218
236
  traceEvents.emit('trace:update', entry);
219
237
  traceEvents.emit('trace:done', entry);
@@ -276,7 +294,7 @@ function processSpans(spans, sessionId) {
276
294
  skillCount: 0, agentCount: 0, mcpCount: 0,
277
295
  status: 'done',
278
296
  };
279
- ensureSession(sessionId);
297
+ ensureSession(sessionId, resolveProjectId(getAttr(attrs, 'github.copilot.git.repository'), workingDir, projectId));
280
298
  upsertTrace(entry);
281
299
  const chatKey = `chat:${traceId}`;
282
300
  const existing = pendingChatIds.get(chatKey) ?? [];
@@ -383,7 +401,7 @@ function detectToolType(name) {
383
401
  return 'builtin';
384
402
  }
385
403
  // ── Register OTLP HTTP routes on the Express app ──────────────────────────────
386
- export function registerOtlpRoutes(app, defaultSessionId) {
404
+ export function registerOtlpRoutes(app, defaultSessionId, projectId) {
387
405
  // OTLP traces — copilot sends JSON (http/json protocol)
388
406
  // body already parsed by express.json() middleware registered before this call
389
407
  app.post('/v1/traces', (req, res) => {
@@ -396,8 +414,9 @@ export function registerOtlpRoutes(app, defaultSessionId) {
396
414
  ?? getAttr(resAttrs, 'session.id')
397
415
  ?? getAttr(resAttrs, 'github.copilot.conversation_id');
398
416
  const sessionId = String(sessionFromOtel ?? defaultSessionId);
417
+ const workingDir = detectWorkingDir(resAttrs);
399
418
  for (const ss of rs.scopeSpans ?? []) {
400
- processSpans(ss.spans ?? [], sessionId);
419
+ processSpans(ss.spans ?? [], sessionId, projectId, workingDir);
401
420
  }
402
421
  }
403
422
  res.status(200).json({ partialSuccess: {} });
package/dist/setup.js CHANGED
@@ -22,6 +22,26 @@ function otelEnvBlock(port) {
22
22
  `export ${OTEL_ENDPOINT_KEY}=http://localhost:${port}`,
23
23
  `export ${OTEL_CONTENT_KEY}=true`,
24
24
  `export ${OTEL_ENABLED_KEY}=true`,
25
+ ``,
26
+ `# Tag every copilot prompt with the terminal folder it ran from, so the`,
27
+ `# tracer can attribute it to the right project. OTLP carries no working-dir,`,
28
+ `# so we inject it via OTEL_RESOURCE_ATTRIBUTES (percent-encoded).`,
29
+ `copilot() {`,
30
+ ` local _wd`,
31
+ ` if command -v python3 >/dev/null 2>&1; then`,
32
+ ` _wd="$(pwd | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read().strip(), safe="/"))')"`,
33
+ ` else`,
34
+ ` _wd="$(pwd)"`,
35
+ ` fi`,
36
+ ` if [ -n "\${OTEL_RESOURCE_ATTRIBUTES:-}" ]; then`,
37
+ ` # Drop any stale working_dir entry, then append the current folder`,
38
+ ` OTEL_RESOURCE_ATTRIBUTES="$(printf '%s' "\$OTEL_RESOURCE_ATTRIBUTES" | sed -E 's/(^|,)github\.copilot\.working_dir=[^,]*/\\1/g; s/^,//')"`,
39
+ ` [ -z "\$OTEL_RESOURCE_ATTRIBUTES" ] || OTEL_RESOURCE_ATTRIBUTES="\${OTEL_RESOURCE_ATTRIBUTES},"`,
40
+ ` fi`,
41
+ ` OTEL_RESOURCE_ATTRIBUTES="\${OTEL_RESOURCE_ATTRIBUTES}github.copilot.working_dir=\${_wd}"`,
42
+ ` export OTEL_RESOURCE_ATTRIBUTES`,
43
+ ` command copilot "\$@"`,
44
+ `}`,
25
45
  `# <<< copilot-tracer <<<`,
26
46
  ].join('\n');
27
47
  }
@@ -130,7 +150,7 @@ function patchVSCodeSettings(settingsPath, port) {
130
150
  return { action: wasSet ? 'updated' : 'added' };
131
151
  }
132
152
  // ── Main setup ────────────────────────────────────────────────────────────────
133
- export function runSetup(port) {
153
+ export function runSetup(port, silent = false) {
134
154
  const CHECK = '✅';
135
155
  const WARN = '⚠️ ';
136
156
  const INFO = '📍';
@@ -174,11 +194,12 @@ export function runSetup(port) {
174
194
  if (result.action === 'added') {
175
195
  console.log(`\n${CHECK} Shell profile patched: ${label}`);
176
196
  console.log(` ${ARROW} Added OTEL env vars (endpoint, content capture)`);
197
+ console.log(` ${ARROW} Added copilot() wrapper — tags each prompt with the terminal folder`);
177
198
  console.log(` ${ARROW} Run: source ${profilePath}`);
178
199
  }
179
200
  else if (result.action === 'updated') {
180
201
  console.log(`\n${CHECK} Shell profile updated: ${label}`);
181
- console.log(` ${ARROW} Updated port to ${port}`);
202
+ console.log(` ${ARROW} Updated port to ${port} + copilot() wrapper`);
182
203
  console.log(` ${ARROW} Run: source ${profilePath}`);
183
204
  }
184
205
  else {
@@ -211,14 +232,16 @@ export function runSetup(port) {
211
232
  process.env[OTEL_CONTENT_KEY] = 'true';
212
233
  process.env[OTEL_ENABLED_KEY] = 'true';
213
234
  // 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');
235
+ if (!silent) {
236
+ const profileBase = profilePath ? path.basename(profilePath) : '.zshrc';
237
+ console.log('\n────────────────────────────────────────────────');
238
+ console.log(' One manual step required:\n');
239
+ console.log(` source ~/${profileBase}`);
240
+ console.log(` (opens a new terminal already? — env is already active there)`);
241
+ if (vscode.found)
242
+ console.log('\n Restart VS Code once to pick up the new terminal env.');
243
+ console.log('\n ✨ Starting tracer web UI now...');
244
+ console.log(` Open: http://localhost:${port}/`);
245
+ console.log('────────────────────────────────────────────────\n');
246
+ }
224
247
  }
package/dist/webServer.js CHANGED
@@ -4,11 +4,11 @@ import { Server } from 'socket.io';
4
4
  import path from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { execSync } from 'child_process';
7
- import { getTraces, getTrace, getSessionSummary } from './db.js';
7
+ import { getTraces, getTrace, getSessionSummary, getDashboard, updateProjectLocalPath, getProjectTraces, getProjectSessionSummary } from './db.js';
8
8
  import { traceEvents } from './proxy.js';
9
9
  import { registerOtlpRoutes } from './otlpReceiver.js';
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- export function startWebServer(port = 4747, sessionId) {
11
+ export function startWebServer(port = 4747, sessionId, projectId) {
12
12
  const app = express();
13
13
  const httpServer = createServer(app);
14
14
  const io = new Server(httpServer, { cors: { origin: '*' } });
@@ -16,7 +16,7 @@ export function startWebServer(port = 4747, sessionId) {
16
16
  app.use(express.static(path.join(__dirname, '../web')));
17
17
  // Register OTLP receiver routes
18
18
  app.use(express.json({ limit: '10mb' }));
19
- registerOtlpRoutes(app, sessionId ?? 'default');
19
+ registerOtlpRoutes(app, sessionId ?? 'default', projectId);
20
20
  // API
21
21
  app.get('/api/traces', (req, res) => {
22
22
  const sid = req.query.sessionId || undefined; // undefined = all sessions
@@ -81,14 +81,45 @@ ${prompt.trim()}`;
81
81
  return res.json(null);
82
82
  res.json(getSessionSummary(sid));
83
83
  });
84
+ app.get('/api/dashboard', (_req, res) => {
85
+ res.json(getDashboard());
86
+ });
87
+ // Project registration — CLI registers its local path for a project
88
+ app.post('/api/projects', (req, res) => {
89
+ const { projectId, localPath } = req.body;
90
+ if (!projectId || !localPath) {
91
+ res.status(400).json({ error: 'projectId and localPath required' });
92
+ return;
93
+ }
94
+ updateProjectLocalPath(projectId, localPath);
95
+ res.json({ ok: true });
96
+ });
97
+ // Project-scoped traces
98
+ app.get('/api/projects/:id/traces', (req, res) => {
99
+ const traces = getProjectTraces(req.params.id, 200);
100
+ res.json(traces);
101
+ });
102
+ // Project-scoped summary
103
+ app.get('/api/projects/:id/summary', (req, res) => {
104
+ res.json(getProjectSessionSummary(req.params.id));
105
+ });
84
106
  // Socket.io — push real-time updates
85
107
  io.on('connection', (socket) => {
108
+ const projectId = socket.handshake.query.projectId;
86
109
  // Send current state on connect
87
- const sid = sessionId;
88
- socket.emit('init', {
89
- traces: getTraces(sid, 200),
90
- summary: sid ? getSessionSummary(sid) : null,
91
- });
110
+ if (projectId) {
111
+ socket.emit('init', {
112
+ traces: getProjectTraces(projectId, 200),
113
+ summary: getProjectSessionSummary(projectId),
114
+ });
115
+ }
116
+ else {
117
+ const sid = sessionId;
118
+ socket.emit('init', {
119
+ traces: getTraces(sid, 200),
120
+ summary: sid ? getSessionSummary(sid) : null,
121
+ });
122
+ }
92
123
  const onUpdate = (entry) => socket.emit('trace:update', entry);
93
124
  const onDone = (entry) => socket.emit('trace:done', entry);
94
125
  traceEvents.on('trace:update', onUpdate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-tracer",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Real-time tracing and prompt-refinement companion for GitHub Copilot CLI and VS Code Copilot — tracks tokens, AI credits, tool calls, and refined prompts with console and web UI",
5
5
  "keywords": [
6
6
  "copilot",