copilot-tracer 1.0.5 → 1.0.7
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 +29 -8
- package/dist/consoleUi.js +2 -2
- package/dist/otlpReceiver.js +246 -4
- package/dist/setup.js +55 -10
- package/package.json +2 -2
- package/web/index.html +8 -8
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# copilot-tracer
|
|
2
2
|
|
|
3
|
-
Real-time tracing and prompt-refinement companion for **GitHub Copilot CLI** and **VS Code Copilot extension**.
|
|
3
|
+
Real-time tracing and prompt-refinement companion for **GitHub Copilot CLI**, **Claude Code**, and **VS Code Copilot extension**.
|
|
4
4
|
|
|
5
5
|
Captures every prompt, response, token usage, AI credits, tool calls, and duration — all in one place. Runs as a background daemon that collects data from all your projects automatically. Includes a web dashboard with project overview and per-project live tracing.
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ Captures every prompt, response, token usage, AI credits, tool calls, and durati
|
|
|
11
11
|
- **Daemon mode** — install once, run forever. Collects traces from all projects automatically
|
|
12
12
|
- **Auto project detection** — detects project from `github.copilot.git.repository` in OTLP spans
|
|
13
13
|
- **Zero-intrusion capture** — uses Copilot's built-in OTel support. Set env vars, done.
|
|
14
|
-
- **Works everywhere** — captures
|
|
14
|
+
- **Works everywhere** — captures GitHub Copilot CLI, Claude Code, and VS Code Copilot Chat
|
|
15
15
|
- **Dashboard** — overview of all projects with token usage, credits, and session counts
|
|
16
16
|
- **Live tracer** — real-time trace table per project with detail panel
|
|
17
17
|
- **Prompt refinement** — rewrites prompts with stronger instructions and less noise
|
|
@@ -31,7 +31,8 @@ This will:
|
|
|
31
31
|
1. Detect your Copilot CLI and VS Code installation
|
|
32
32
|
2. Patch `~/.zshrc` with OTEL env vars
|
|
33
33
|
3. Patch VS Code `settings.json` with terminal env vars
|
|
34
|
-
4.
|
|
34
|
+
4. Enable Claude Code OTLP logs/events and enhanced beta traces
|
|
35
|
+
5. Start the daemon on port 4747
|
|
35
36
|
|
|
36
37
|
Then apply env vars in your current shell:
|
|
37
38
|
|
|
@@ -39,7 +40,9 @@ Then apply env vars in your current shell:
|
|
|
39
40
|
source ~/.zshrc
|
|
40
41
|
```
|
|
41
42
|
|
|
42
|
-
Restart VS Code once. After that, the daemon collects traces from all your Copilot
|
|
43
|
+
Restart VS Code once. After that, the daemon collects traces from all your Copilot
|
|
44
|
+
and Claude Code sessions automatically. Claude Code content flags are enabled by
|
|
45
|
+
setup so prompts and responses can be displayed in the local dashboard.
|
|
43
46
|
|
|
44
47
|
Open **http://localhost:4747** to see the dashboard.
|
|
45
48
|
|
|
@@ -51,20 +54,20 @@ Open **http://localhost:4747** to see the dashboard.
|
|
|
51
54
|
┌─────────────────────────────────────────────────────────┐
|
|
52
55
|
│ copilot-tracer --daemon (runs once, stays running) │
|
|
53
56
|
│ │
|
|
54
|
-
│ OTLP Receiver ← Copilot CLI + VS Code │
|
|
57
|
+
│ OTLP Receiver ← Copilot CLI + Claude Code + VS Code │
|
|
55
58
|
│ (auto-detects project from github.copilot.git.repository)│
|
|
56
59
|
│ │
|
|
57
60
|
│ SQLite DB → Dashboard + Live Tracer (Socket.io) │
|
|
58
61
|
└─────────────────────────────────────────────────────────┘
|
|
59
62
|
|
|
60
|
-
Copilot CLI / VS Code Copilot Chat
|
|
63
|
+
Copilot CLI / Claude Code / VS Code Copilot Chat
|
|
61
64
|
│
|
|
62
65
|
│ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4747
|
|
63
66
|
↓
|
|
64
|
-
POST /v1/traces (OpenTelemetry OTLP JSON)
|
|
67
|
+
POST /v1/traces and /v1/logs (OpenTelemetry OTLP JSON)
|
|
65
68
|
│
|
|
66
69
|
↓
|
|
67
|
-
copilot-tracer parses spans → tokens, credits, tool calls
|
|
70
|
+
copilot-tracer parses spans and events → tokens, credits, tool calls
|
|
68
71
|
│
|
|
69
72
|
↓
|
|
70
73
|
SQLite DB (~/.copilot-tracer/traces.db)
|
|
@@ -121,6 +124,7 @@ Open http://localhost:4747 after starting the daemon.
|
|
|
121
124
|
- **Summary cards** — total projects, sessions, tokens, credits
|
|
122
125
|
- **Project cards** — each project shows path, session count, tokens, credits, last active
|
|
123
126
|
- **Click a project** → opens live tracer filtered to that project
|
|
127
|
+
<img width="737" height="410" alt="image" src="https://github.com/user-attachments/assets/dc20653b-8774-46b3-9a42-6e7bb934aded" />
|
|
124
128
|
|
|
125
129
|
---
|
|
126
130
|
|
|
@@ -138,6 +142,10 @@ Real-time trace table for a specific project.
|
|
|
138
142
|
- Click Skills / Agents / MCPs → filtered call list
|
|
139
143
|
- Real-time updates via Socket.io
|
|
140
144
|
|
|
145
|
+
<img width="1504" height="742" alt="image" src="https://github.com/user-attachments/assets/f334108f-c587-4228-8120-7dac2f85f90b" />
|
|
146
|
+
|
|
147
|
+
<img width="1061" height="696" alt="image" src="https://github.com/user-attachments/assets/1555d5a2-dbca-4f1d-b102-3d2865dcee68" />
|
|
148
|
+
|
|
141
149
|
---
|
|
142
150
|
|
|
143
151
|
## Prompt Refinement
|
|
@@ -173,8 +181,21 @@ Add to `~/.zshrc`:
|
|
|
173
181
|
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4747
|
|
174
182
|
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
|
175
183
|
export COPILOT_OTEL_ENABLED=true
|
|
184
|
+
|
|
185
|
+
# Claude Code telemetry
|
|
186
|
+
export CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
187
|
+
export CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1
|
|
188
|
+
export OTEL_LOGS_EXPORTER=otlp
|
|
189
|
+
export OTEL_TRACES_EXPORTER=otlp
|
|
190
|
+
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
|
|
191
|
+
export OTEL_LOG_USER_PROMPTS=1
|
|
192
|
+
export OTEL_LOG_ASSISTANT_RESPONSES=1
|
|
176
193
|
```
|
|
177
194
|
|
|
195
|
+
Claude Code exports standard OTLP logs/events and optional beta traces. See
|
|
196
|
+
[Anthropic's monitoring documentation](https://code.claude.com/docs/en/monitoring-usage)
|
|
197
|
+
for protocol and content controls.
|
|
198
|
+
|
|
178
199
|
For VS Code, add to `~/Library/Application Support/Code/User/settings.json`:
|
|
179
200
|
|
|
180
201
|
```json
|
package/dist/consoleUi.js
CHANGED
|
@@ -10,7 +10,7 @@ function fmtDuration(ms) {
|
|
|
10
10
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
11
11
|
}
|
|
12
12
|
function fmtCredits(c) {
|
|
13
|
-
return
|
|
13
|
+
return `$${(c * 0.01).toFixed(4)}`;
|
|
14
14
|
}
|
|
15
15
|
function statusColor(status, text) {
|
|
16
16
|
if (status === 'running')
|
|
@@ -25,7 +25,7 @@ export function renderConsoleTable(entries, summary) {
|
|
|
25
25
|
head: [
|
|
26
26
|
chalk.cyan('Date / Time'),
|
|
27
27
|
chalk.cyan('Prompt'),
|
|
28
|
-
chalk.cyan('
|
|
28
|
+
chalk.cyan('Est Cost'),
|
|
29
29
|
chalk.cyan('Duration'),
|
|
30
30
|
chalk.cyan('Tokens\nCached|Written|Reason'),
|
|
31
31
|
chalk.cyan('Skills'),
|
package/dist/otlpReceiver.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* chat <model> span attrs: same as above
|
|
15
15
|
* User message event: github.copilot.user.message
|
|
16
16
|
*/
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
17
18
|
import { upsertTrace, createSession, deleteTrace, ensureProject, ensureProjectByRepo } from './db.js';
|
|
18
19
|
import { traceEvents } from './proxy.js';
|
|
19
20
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -36,7 +37,53 @@ 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
|
|
62
|
+
// Anthropic API pricing per 1K tokens (USD), converted to GitHub "AI credit" units
|
|
63
|
+
// (1 credit = $0.01) so aiCredits stays one unit across Copilot and Claude entries.
|
|
64
|
+
// Rates are Anthropic's current published per-model prices; a model id that doesn't
|
|
65
|
+
// match a specific entry falls back to its tier's (opus/sonnet/haiku) latest rate.
|
|
66
|
+
const ANTHROPIC_USD_PER_1K = {
|
|
67
|
+
'claude-fable-5': { input: 0.010, output: 0.050 },
|
|
68
|
+
'claude-mythos-5': { input: 0.010, output: 0.050 },
|
|
69
|
+
'claude-opus-5': { input: 0.005, output: 0.025 },
|
|
70
|
+
'claude-opus-4-8': { input: 0.005, output: 0.025 },
|
|
71
|
+
'claude-opus-4-7': { input: 0.005, output: 0.025 },
|
|
72
|
+
'claude-opus-4-6': { input: 0.005, output: 0.025 },
|
|
73
|
+
'claude-sonnet-5': { input: 0.002, output: 0.010 },
|
|
74
|
+
'claude-sonnet-4-6': { input: 0.003, output: 0.015 },
|
|
75
|
+
'claude-haiku-4-5': { input: 0.001, output: 0.005 },
|
|
76
|
+
'opus': { input: 0.005, output: 0.025 },
|
|
77
|
+
'sonnet': { input: 0.003, output: 0.015 },
|
|
78
|
+
'haiku': { input: 0.001, output: 0.005 },
|
|
79
|
+
'default': { input: 0.003, output: 0.015 },
|
|
80
|
+
};
|
|
81
|
+
export function calcClaudeCredits(tokens, model) {
|
|
82
|
+
const key = Object.keys(ANTHROPIC_USD_PER_1K).find(k => (model ?? '').toLowerCase().includes(k)) ?? 'default';
|
|
83
|
+
const rate = ANTHROPIC_USD_PER_1K[key];
|
|
84
|
+
const usd = (tokens.input / 1000) * rate.input + (tokens.output / 1000) * rate.output;
|
|
85
|
+
return usd * 100; // USD → credits (1 credit = $0.01)
|
|
86
|
+
}
|
|
40
87
|
// Sessions are created lazily. Always upsert so project_id gets backfilled
|
|
41
88
|
// when the session was created earlier without a project.
|
|
42
89
|
function ensureSession(sessionId, projectId) {
|
|
@@ -53,7 +100,7 @@ function resolveProjectId(repoUrl, workingDir, defaultProjectId) {
|
|
|
53
100
|
return defaultProjectId;
|
|
54
101
|
}
|
|
55
102
|
function detectWorkingDir(attrs) {
|
|
56
|
-
for (const key of ['process.working_directory', 'github.copilot.working_dir']) {
|
|
103
|
+
for (const key of ['process.working_directory', 'github.copilot.working_dir', 'claude_code.working_dir']) {
|
|
57
104
|
const v = getAttr(attrs, key);
|
|
58
105
|
if (v && String(v).trim())
|
|
59
106
|
return String(v).trim();
|
|
@@ -65,6 +112,120 @@ function detectWorkingDir(attrs) {
|
|
|
65
112
|
const pendingChatIds = new Map(); // `chat:${traceId}` → list of entryIds
|
|
66
113
|
// Buffer tool calls that arrive before invoke_agent
|
|
67
114
|
const pendingToolCalls = new Map(); // traceId → tool calls
|
|
115
|
+
const claudePromptEntries = new Map(); // prompt.id → trace entry
|
|
116
|
+
const claudeInteractionEntries = new Map(); // traceId → interaction entry
|
|
117
|
+
// Real Claude Code telemetry sends the child claude_code.llm_request span before its
|
|
118
|
+
// parent claude_code.interaction span within the same batch, so buffer llm_request's
|
|
119
|
+
// token/cost delta here until the interaction entry shows up to receive it.
|
|
120
|
+
const pendingClaudeLlmDeltas = new Map();
|
|
121
|
+
const CLAUDE_STATE_LIMIT = 1000;
|
|
122
|
+
function rememberClaudeEntry(map, key, entry) {
|
|
123
|
+
map.set(key, entry);
|
|
124
|
+
if (map.size > CLAUDE_STATE_LIMIT) {
|
|
125
|
+
const oldest = map.keys().next().value;
|
|
126
|
+
if (oldest)
|
|
127
|
+
map.delete(oldest);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function claudeEventId(record, attrs, eventName) {
|
|
131
|
+
const messageId = getStringAttr(attrs, 'message.uuid', 'tool_use_id');
|
|
132
|
+
const promptId = getStringAttr(attrs, 'prompt.id');
|
|
133
|
+
return `claude:${messageId ?? promptId ?? record.traceId ?? randomUUID()}:${eventName}`;
|
|
134
|
+
}
|
|
135
|
+
function processClaudeLogRecord(record, resourceAttrs, sessionId, projectId, workingDir) {
|
|
136
|
+
const attrs = record.attributes ?? [];
|
|
137
|
+
const eventName = getStringAttr(attrs, 'event.name') ?? getBodyText(record.body);
|
|
138
|
+
if (!eventName || !eventName.startsWith('claude_code.'))
|
|
139
|
+
return;
|
|
140
|
+
const eventTime = getStringAttr(attrs, 'event.timestamp');
|
|
141
|
+
const dateTime = eventTime ?? (record.timeUnixNano ? nanoToIso(record.timeUnixNano) : new Date().toISOString());
|
|
142
|
+
const promptId = getStringAttr(attrs, 'prompt.id');
|
|
143
|
+
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);
|
|
144
|
+
const eventSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
|
|
145
|
+
if (eventName === 'claude_code.user_prompt') {
|
|
146
|
+
const entry = {
|
|
147
|
+
id: claudeEventId(record, attrs, 'prompt'),
|
|
148
|
+
sessionId: eventSessionId,
|
|
149
|
+
dateTime,
|
|
150
|
+
prompt: getStringAttr(attrs, 'prompt') ?? '[Claude Code prompt]',
|
|
151
|
+
tokens: { input: 0, output: 0, cached: 0, reasoning: 0, written: 0, total: 0 },
|
|
152
|
+
aiCredits: 0,
|
|
153
|
+
durationMs: 0,
|
|
154
|
+
toolCalls: [],
|
|
155
|
+
skillCount: 0,
|
|
156
|
+
agentCount: 0,
|
|
157
|
+
mcpCount: 0,
|
|
158
|
+
status: 'running',
|
|
159
|
+
};
|
|
160
|
+
if (promptId)
|
|
161
|
+
rememberClaudeEntry(claudePromptEntries, promptId, entry);
|
|
162
|
+
ensureSession(eventSessionId, resolvedProjectId);
|
|
163
|
+
upsertTrace(entry);
|
|
164
|
+
traceEvents.emit('trace:update', entry);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (eventName === 'claude_code.assistant_response') {
|
|
168
|
+
const existingEntry = promptId ? claudePromptEntries.get(promptId) : undefined;
|
|
169
|
+
const entry = existingEntry ?? {
|
|
170
|
+
id: claudeEventId(record, attrs, 'response'),
|
|
171
|
+
sessionId: eventSessionId,
|
|
172
|
+
dateTime,
|
|
173
|
+
prompt: '[Claude Code response]',
|
|
174
|
+
tokens: { input: 0, output: 0, cached: 0, reasoning: 0, written: 0, total: 0 },
|
|
175
|
+
aiCredits: 0,
|
|
176
|
+
durationMs: 0,
|
|
177
|
+
toolCalls: [],
|
|
178
|
+
skillCount: 0,
|
|
179
|
+
agentCount: 0,
|
|
180
|
+
mcpCount: 0,
|
|
181
|
+
status: 'running',
|
|
182
|
+
};
|
|
183
|
+
entry.response = getStringAttr(attrs, 'response');
|
|
184
|
+
entry.status = 'done';
|
|
185
|
+
entry.durationMs = Number(getStringAttr(attrs, 'duration_ms') ?? 0);
|
|
186
|
+
ensureSession(eventSessionId, resolvedProjectId);
|
|
187
|
+
upsertTrace(entry);
|
|
188
|
+
traceEvents.emit('trace:update', entry);
|
|
189
|
+
traceEvents.emit('trace:done', entry);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (eventName === 'claude_code.tool_result') {
|
|
193
|
+
const entry = promptId ? claudePromptEntries.get(promptId) : undefined;
|
|
194
|
+
if (!entry)
|
|
195
|
+
return;
|
|
196
|
+
const toolName = getStringAttr(attrs, 'tool_name') ?? 'Claude Code tool';
|
|
197
|
+
const toolId = getStringAttr(attrs, 'tool_use_id') ?? randomUUID();
|
|
198
|
+
if (entry.toolCalls.some(call => call.id === toolId))
|
|
199
|
+
return;
|
|
200
|
+
entry.toolCalls.push({
|
|
201
|
+
id: toolId,
|
|
202
|
+
name: toolName,
|
|
203
|
+
type: detectToolType(toolName),
|
|
204
|
+
input: {},
|
|
205
|
+
startedAt: Date.parse(dateTime),
|
|
206
|
+
endedAt: Date.parse(dateTime),
|
|
207
|
+
durationMs: Number(getStringAttr(attrs, 'duration_ms') ?? 0),
|
|
208
|
+
error: getStringAttr(attrs, 'error'),
|
|
209
|
+
});
|
|
210
|
+
upsertTrace(entry);
|
|
211
|
+
traceEvents.emit('trace:update', entry);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function processClaudeLogs(payload, defaultSessionId, projectId) {
|
|
215
|
+
if (!payload || !Array.isArray(payload.resourceLogs)) {
|
|
216
|
+
throw new Error('OTLP logs payload must contain resourceLogs');
|
|
217
|
+
}
|
|
218
|
+
for (const resourceLogs of payload.resourceLogs ?? []) {
|
|
219
|
+
const resourceAttrs = resourceLogs.resource?.attributes ?? [];
|
|
220
|
+
const sessionId = getStringAttr(resourceAttrs, 'session.id') ?? defaultSessionId;
|
|
221
|
+
const workingDir = detectWorkingDir(resourceAttrs);
|
|
222
|
+
for (const scopeLogs of resourceLogs.scopeLogs ?? []) {
|
|
223
|
+
for (const record of scopeLogs.logRecords ?? []) {
|
|
224
|
+
processClaudeLogRecord(record, resourceAttrs, sessionId, projectId, workingDir);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
68
229
|
function processSpans(spans, sessionId, projectId, workingDir) {
|
|
69
230
|
for (const span of spans) {
|
|
70
231
|
// DEBUG — log raw span to stderr when COPILOT_TRACER_DEBUG=1
|
|
@@ -75,6 +236,78 @@ function processSpans(spans, sessionId, projectId, workingDir) {
|
|
|
75
236
|
const spanName = span.name;
|
|
76
237
|
const traceId = span.traceId;
|
|
77
238
|
const spanId = span.spanId;
|
|
239
|
+
if (spanName === 'claude_code.interaction') {
|
|
240
|
+
const attrs = span.attributes ?? [];
|
|
241
|
+
const inputTokens = Number(getAttr(attrs, 'input_tokens') ?? 0);
|
|
242
|
+
const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
|
|
243
|
+
const model = getStringAttr(attrs, 'model', 'gen_ai.request.model');
|
|
244
|
+
const entry = {
|
|
245
|
+
id: spanId,
|
|
246
|
+
sessionId: getStringAttr(attrs, 'session.id') ?? sessionId,
|
|
247
|
+
dateTime: nanoToIso(span.startTimeUnixNano),
|
|
248
|
+
prompt: getStringAttr(attrs, 'user_prompt') ?? '[Claude Code interaction]',
|
|
249
|
+
tokens: { input: inputTokens, output: outputTokens, cached: Number(getAttr(attrs, 'cache_read_tokens') ?? 0), reasoning: 0, written: outputTokens, total: inputTokens + outputTokens },
|
|
250
|
+
aiCredits: calcClaudeCredits({ input: inputTokens, output: outputTokens }, model),
|
|
251
|
+
durationMs: Number(getAttr(attrs, 'interaction.duration_ms')
|
|
252
|
+
?? (nanoToMs(span.endTimeUnixNano) - nanoToMs(span.startTimeUnixNano))),
|
|
253
|
+
toolCalls: [],
|
|
254
|
+
skillCount: 0,
|
|
255
|
+
agentCount: 0,
|
|
256
|
+
mcpCount: 0,
|
|
257
|
+
status: (span.status?.code ?? 0) === 2 ? 'error' : 'done',
|
|
258
|
+
error: span.status?.message,
|
|
259
|
+
};
|
|
260
|
+
// Apply any llm_request delta that arrived before this interaction span.
|
|
261
|
+
const pendingDelta = pendingClaudeLlmDeltas.get(traceId);
|
|
262
|
+
if (pendingDelta) {
|
|
263
|
+
entry.tokens = {
|
|
264
|
+
input: entry.tokens.input + pendingDelta.input,
|
|
265
|
+
output: entry.tokens.output + pendingDelta.output,
|
|
266
|
+
cached: entry.tokens.cached + pendingDelta.cached,
|
|
267
|
+
reasoning: 0,
|
|
268
|
+
written: entry.tokens.written + pendingDelta.output,
|
|
269
|
+
total: entry.tokens.total + pendingDelta.input + pendingDelta.output,
|
|
270
|
+
};
|
|
271
|
+
entry.aiCredits += pendingDelta.credits;
|
|
272
|
+
pendingClaudeLlmDeltas.delete(traceId);
|
|
273
|
+
}
|
|
274
|
+
ensureSession(entry.sessionId, resolveProjectId(getAttr(attrs, 'vcs.repository.url'), detectWorkingDir(attrs) ?? workingDir, projectId));
|
|
275
|
+
upsertTrace(entry);
|
|
276
|
+
rememberClaudeEntry(claudeInteractionEntries, traceId, entry);
|
|
277
|
+
traceEvents.emit('trace:update', entry);
|
|
278
|
+
traceEvents.emit('trace:done', entry);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (spanName === 'claude_code.llm_request') {
|
|
282
|
+
const inputTokens = Number(getAttr(attrs, 'input_tokens') ?? 0);
|
|
283
|
+
const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
|
|
284
|
+
const cachedTokens = Number(getAttr(attrs, 'cache_read_tokens') ?? 0);
|
|
285
|
+
const model = getStringAttr(attrs, 'model', 'gen_ai.request.model');
|
|
286
|
+
const entry = claudeInteractionEntries.get(traceId);
|
|
287
|
+
if (entry) {
|
|
288
|
+
entry.tokens = {
|
|
289
|
+
input: entry.tokens.input + inputTokens,
|
|
290
|
+
output: entry.tokens.output + outputTokens,
|
|
291
|
+
cached: entry.tokens.cached + cachedTokens,
|
|
292
|
+
reasoning: 0,
|
|
293
|
+
written: entry.tokens.written + outputTokens,
|
|
294
|
+
total: entry.tokens.total + inputTokens + outputTokens,
|
|
295
|
+
};
|
|
296
|
+
entry.aiCredits += calcClaudeCredits({ input: inputTokens, output: outputTokens }, model);
|
|
297
|
+
upsertTrace(entry);
|
|
298
|
+
traceEvents.emit('trace:update', entry);
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// Parent claude_code.interaction span hasn't arrived yet — buffer the delta.
|
|
302
|
+
const pending = pendingClaudeLlmDeltas.get(traceId) ?? { input: 0, output: 0, cached: 0, credits: 0 };
|
|
303
|
+
pending.input += inputTokens;
|
|
304
|
+
pending.output += outputTokens;
|
|
305
|
+
pending.cached += cachedTokens;
|
|
306
|
+
pending.credits += calcClaudeCredits({ input: inputTokens, output: outputTokens }, model);
|
|
307
|
+
rememberClaudeEntry(pendingClaudeLlmDeltas, traceId, pending);
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
78
311
|
// ── invoke_agent span = top-level agent turn ──────────────────────────
|
|
79
312
|
if (spanName === 'invoke_agent') {
|
|
80
313
|
const startMs = nanoToMs(span.startTimeUnixNano);
|
|
@@ -426,8 +659,17 @@ export function registerOtlpRoutes(app, defaultSessionId, projectId) {
|
|
|
426
659
|
res.status(400).json({ error: 'invalid payload' });
|
|
427
660
|
}
|
|
428
661
|
});
|
|
429
|
-
|
|
662
|
+
app.post('/v1/logs', (req, res) => {
|
|
663
|
+
try {
|
|
664
|
+
processClaudeLogs(req.body, defaultSessionId, projectId);
|
|
665
|
+
res.status(200).json({ partialSuccess: {} });
|
|
666
|
+
}
|
|
667
|
+
catch (e) {
|
|
668
|
+
console.error('[OTLP] log parse error:', e);
|
|
669
|
+
res.status(400).json({ error: 'invalid payload' });
|
|
670
|
+
}
|
|
671
|
+
});
|
|
672
|
+
// Metrics remain accepted for Claude Code dashboards but are not trace entries.
|
|
430
673
|
app.post('/v1/metrics', (_req, res) => res.status(200).json({ partialSuccess: {} }));
|
|
431
|
-
|
|
432
|
-
console.log(' 📡 OTLP receiver ready on /v1/traces');
|
|
674
|
+
console.log(' 📡 OTLP receiver ready on /v1/traces and /v1/logs');
|
|
433
675
|
}
|
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,26 @@ 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`,
|
|
25
39
|
``,
|
|
26
40
|
`# Tag every copilot prompt with the terminal folder it ran from, so the`,
|
|
27
41
|
`# tracer can attribute it to the right project. OTLP carries no working-dir,`,
|
|
@@ -42,6 +56,22 @@ function otelEnvBlock(port) {
|
|
|
42
56
|
` export OTEL_RESOURCE_ATTRIBUTES`,
|
|
43
57
|
` command copilot "\$@"`,
|
|
44
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
|
+
`}`,
|
|
45
75
|
`# <<< copilot-tracer <<<`,
|
|
46
76
|
].join('\n');
|
|
47
77
|
}
|
|
@@ -50,6 +80,13 @@ function vscodeEnvBlock(port) {
|
|
|
50
80
|
[OTEL_ENDPOINT_KEY]: `http://localhost:${port}`,
|
|
51
81
|
[OTEL_CONTENT_KEY]: 'true',
|
|
52
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',
|
|
53
90
|
};
|
|
54
91
|
}
|
|
55
92
|
// ── Detection helpers ─────────────────────────────────────────────────────────
|
|
@@ -110,11 +147,13 @@ function patchShellProfile(profilePath, port) {
|
|
|
110
147
|
const block = otelEnvBlock(port);
|
|
111
148
|
// Already has our block?
|
|
112
149
|
if (content.includes('copilot-tracer OTLP config')) {
|
|
113
|
-
//
|
|
114
|
-
|
|
150
|
+
// Only skip if the embedded block is byte-identical to what we'd generate now —
|
|
151
|
+
// a marker + matching port isn't enough, since the block's env vars can gain new
|
|
152
|
+
// keys (e.g. Claude Code support) between tracer versions without the port changing.
|
|
153
|
+
if (content.includes(block)) {
|
|
115
154
|
return { action: 'already_set' };
|
|
116
155
|
}
|
|
117
|
-
//
|
|
156
|
+
// Block is stale (port changed, or vars were added/changed) — replace it in place
|
|
118
157
|
const updated = content.replace(/# >>> copilot-tracer OTLP config[\s\S]*?# <<< copilot-tracer <<</, block);
|
|
119
158
|
fs.writeFileSync(profilePath, updated, 'utf8');
|
|
120
159
|
return { action: 'updated' };
|
|
@@ -138,10 +177,9 @@ function patchVSCodeSettings(settingsPath, port) {
|
|
|
138
177
|
const envKey = 'terminal.integrated.env.osx';
|
|
139
178
|
const existing = (settings[envKey] ?? {});
|
|
140
179
|
const newEnv = vscodeEnvBlock(port);
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
existing[OTEL_ENABLED_KEY] === 'true') {
|
|
180
|
+
// Already set correctly only if every current key/value is present — checking a
|
|
181
|
+
// hardcoded subset let newer keys (e.g. Claude Code support) silently go unset.
|
|
182
|
+
if (Object.entries(newEnv).every(([k, v]) => existing[k] === v)) {
|
|
145
183
|
return { action: 'already_set' };
|
|
146
184
|
}
|
|
147
185
|
const wasSet = !!existing[OTEL_ENDPOINT_KEY];
|
|
@@ -193,13 +231,13 @@ export function runSetup(port, silent = false) {
|
|
|
193
231
|
const label = path.basename(profilePath);
|
|
194
232
|
if (result.action === 'added') {
|
|
195
233
|
console.log(`\n${CHECK} Shell profile patched: ${label}`);
|
|
196
|
-
console.log(` ${ARROW} Added
|
|
234
|
+
console.log(` ${ARROW} Added Copilot + Claude Code OTLP env vars`);
|
|
197
235
|
console.log(` ${ARROW} Added copilot() wrapper — tags each prompt with the terminal folder`);
|
|
198
236
|
console.log(` ${ARROW} Run: source ${profilePath}`);
|
|
199
237
|
}
|
|
200
238
|
else if (result.action === 'updated') {
|
|
201
239
|
console.log(`\n${CHECK} Shell profile updated: ${label}`);
|
|
202
|
-
console.log(` ${ARROW} Updated port to ${port} +
|
|
240
|
+
console.log(` ${ARROW} Updated port to ${port} + Claude Code/Copilot OTLP config`);
|
|
203
241
|
console.log(` ${ARROW} Run: source ${profilePath}`);
|
|
204
242
|
}
|
|
205
243
|
else {
|
|
@@ -231,6 +269,13 @@ export function runSetup(port, silent = false) {
|
|
|
231
269
|
process.env[OTEL_ENDPOINT_KEY] = `http://localhost:${port}`;
|
|
232
270
|
process.env[OTEL_CONTENT_KEY] = 'true';
|
|
233
271
|
process.env[OTEL_ENABLED_KEY] = 'true';
|
|
272
|
+
process.env[CLAUDE_TELEMETRY_KEY] = '1';
|
|
273
|
+
process.env[CLAUDE_TRACES_KEY] = '1';
|
|
274
|
+
process.env[OTEL_LOGS_EXPORTER_KEY] = 'otlp';
|
|
275
|
+
process.env[OTEL_TRACES_EXPORTER_KEY] = 'otlp';
|
|
276
|
+
process.env[OTEL_PROTOCOL_KEY] = 'http/json';
|
|
277
|
+
process.env[OTEL_LOG_PROMPTS_KEY] = '1';
|
|
278
|
+
process.env[OTEL_LOG_RESPONSES_KEY] = '1';
|
|
234
279
|
// 6. Summary
|
|
235
280
|
if (!silent) {
|
|
236
281
|
const profileBase = profilePath ? path.basename(profilePath) : '.zshrc';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-tracer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"license": "MIT",
|
|
15
15
|
"repository": {
|
|
16
16
|
"type": "git",
|
|
17
|
-
"url": "https://github.com/
|
|
17
|
+
"url": "https://github.com/chuongxl/copilot-tracer.git"
|
|
18
18
|
},
|
|
19
19
|
"engines": {
|
|
20
20
|
"node": ">=18"
|
package/web/index.html
CHANGED
|
@@ -321,7 +321,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
321
321
|
|
|
322
322
|
<div class="stats-bar">
|
|
323
323
|
<div class="stat"><span class="stat-label">Total Prompts</span><span class="stat-value" id="s-prompts">0</span></div>
|
|
324
|
-
<div class="stat"><span class="stat-label">
|
|
324
|
+
<div class="stat"><span class="stat-label">Est Cost</span><span class="stat-value credits" id="s-credits">$0.0000</span></div>
|
|
325
325
|
<div class="stat"><span class="stat-label">Total Tokens</span><span class="stat-value tokens" id="s-tokens">0</span></div>
|
|
326
326
|
<div class="stat"><span class="stat-label">Cached</span><span class="stat-value tokens" id="s-cached">0</span></div>
|
|
327
327
|
<div class="stat"><span class="stat-label">Reasoning</span><span class="stat-value" style="color:#d2a8ff" id="s-reasoning">0</span></div>
|
|
@@ -338,7 +338,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
338
338
|
<tr>
|
|
339
339
|
<th>Date / Time</th>
|
|
340
340
|
<th>Prompt</th>
|
|
341
|
-
<th>
|
|
341
|
+
<th>Est Cost</th>
|
|
342
342
|
<th>Duration</th>
|
|
343
343
|
<th>Cached Tokens</th>
|
|
344
344
|
<th>Written Tokens</th>
|
|
@@ -488,7 +488,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
488
488
|
<div class="dash-total-value tokens">${data.totals.tokens.toLocaleString()}</div>
|
|
489
489
|
</div>
|
|
490
490
|
<div class="dash-total-card">
|
|
491
|
-
<div class="dash-total-label">Total
|
|
491
|
+
<div class="dash-total-label">Total Est Cost</div>
|
|
492
492
|
<div class="dash-total-value credits">${fmtCredits(data.totals.credits)}</div>
|
|
493
493
|
</div>
|
|
494
494
|
`;
|
|
@@ -525,7 +525,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
525
525
|
<span class="dash-project-stat-value tokens">${p.totalTokens.toLocaleString()}</span>
|
|
526
526
|
</div>
|
|
527
527
|
<div class="dash-project-stat">
|
|
528
|
-
<span class="dash-project-stat-label">
|
|
528
|
+
<span class="dash-project-stat-label">Est Cost</span>
|
|
529
529
|
<span class="dash-project-stat-value credits">${fmtCredits(p.totalCredits)}</span>
|
|
530
530
|
</div>
|
|
531
531
|
</div>
|
|
@@ -559,7 +559,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
559
559
|
return ms < 1000 ? ms + 'ms' : (ms/1000).toFixed(1) + 's';
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
function fmtCredits(c) { return
|
|
562
|
+
function fmtCredits(c) { return '$' + ((c||0)*0.01).toFixed(4); }
|
|
563
563
|
|
|
564
564
|
function fmtDateTime(dt) {
|
|
565
565
|
if (!dt) return '—';
|
|
@@ -650,7 +650,7 @@ Written: ${(t.tokens?.written||0).toLocaleString()}
|
|
|
650
650
|
Reasoning: ${(t.tokens?.reasoning||0).toLocaleString()}
|
|
651
651
|
Output: ${(t.tokens?.output||0).toLocaleString()}
|
|
652
652
|
Total: ${(t.tokens?.total||0).toLocaleString()}
|
|
653
|
-
|
|
653
|
+
Est Cost: ${fmtCredits(t.aiCredits)}
|
|
654
654
|
</div>
|
|
655
655
|
</div>
|
|
656
656
|
${t.toolCalls?.length ? `
|
|
@@ -725,14 +725,14 @@ AI Credits: ${fmtCredits(t.aiCredits)}
|
|
|
725
725
|
function showCreditDetail(id) {
|
|
726
726
|
const t = traces[id];
|
|
727
727
|
if (!t) return;
|
|
728
|
-
document.getElementById('modal-title').textContent = `
|
|
728
|
+
document.getElementById('modal-title').textContent = `Est Cost: ${fmtCredits(t.aiCredits)}`;
|
|
729
729
|
document.getElementById('modal-body').innerHTML = `
|
|
730
730
|
<div class="detail-box" style="line-height:2">
|
|
731
731
|
Input tokens: ${(t.tokens?.input||0).toLocaleString()} × $0.003/1k = $${((t.tokens?.input||0)/1000*0.003).toFixed(5)}
|
|
732
732
|
Written tokens: ${(t.tokens?.written||0).toLocaleString()} × $0.006/1k = $${((t.tokens?.written||0)/1000*0.006).toFixed(5)}
|
|
733
733
|
Reasoning tokens: ${(t.tokens?.reasoning||0).toLocaleString()} × $0.009/1k = $${((t.tokens?.reasoning||0)/1000*0.009).toFixed(5)}
|
|
734
734
|
─────────────────────────────────────────
|
|
735
|
-
Total
|
|
735
|
+
Total Est Cost: ${fmtCredits(t.aiCredits)}
|
|
736
736
|
Duration: ${fmtDuration(t.durationMs)}
|
|
737
737
|
</div>
|
|
738
738
|
`;
|