copilot-tracer 1.0.7 → 1.0.9
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 +46 -4
- package/dist/claudeHooks.js +145 -0
- package/dist/claudePricing.js +31 -0
- package/dist/claudeSession.js +405 -0
- package/dist/cli.js +36 -1
- package/dist/db.js +18 -4
- package/dist/otlpReceiver.js +77 -27
- package/dist/setup.js +135 -2
- package/dist/webServer.js +13 -2
- package/package.json +1 -1
- package/web/index.html +24 -1
package/README.md
CHANGED
|
@@ -32,7 +32,9 @@ This will:
|
|
|
32
32
|
2. Patch `~/.zshrc` with OTEL env vars
|
|
33
33
|
3. Patch VS Code `settings.json` with terminal env vars
|
|
34
34
|
4. Enable Claude Code OTLP logs/events and enhanced beta traces
|
|
35
|
-
5.
|
|
35
|
+
5. Install Claude Code hooks into `~/.claude/settings.json` (merged with any hooks you
|
|
36
|
+
already have — nothing is overwritten)
|
|
37
|
+
6. Start the daemon on port 4747
|
|
36
38
|
|
|
37
39
|
Then apply env vars in your current shell:
|
|
38
40
|
|
|
@@ -40,9 +42,10 @@ Then apply env vars in your current shell:
|
|
|
40
42
|
source ~/.zshrc
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
Restart VS Code once
|
|
44
|
-
|
|
45
|
-
setup so prompts and responses can be
|
|
45
|
+
Restart VS Code once, and restart Claude Code to pick up the hooks. After that, the
|
|
46
|
+
daemon collects traces from all your Copilot and Claude Code sessions automatically.
|
|
47
|
+
Claude Code content flags are enabled by setup so prompts and responses can be
|
|
48
|
+
displayed in the local dashboard.
|
|
46
49
|
|
|
47
50
|
Open **http://localhost:4747** to see the dashboard.
|
|
48
51
|
|
|
@@ -196,6 +199,45 @@ Claude Code exports standard OTLP logs/events and optional beta traces. See
|
|
|
196
199
|
[Anthropic's monitoring documentation](https://code.claude.com/docs/en/monitoring-usage)
|
|
197
200
|
for protocol and content controls.
|
|
198
201
|
|
|
202
|
+
### Claude Code hooks (required for full session capture)
|
|
203
|
+
|
|
204
|
+
Telemetry alone can't reconstruct a multi-prompt Claude session: its log events are
|
|
205
|
+
correlated by `prompt.id` while its spans are correlated by OTLP `traceId`, and neither
|
|
206
|
+
key is always present. The result is turns with no token usage that never leave the
|
|
207
|
+
`running` state.
|
|
208
|
+
|
|
209
|
+
So the tracer also registers [hooks](https://code.claude.com/docs/en/hooks), which give
|
|
210
|
+
it an ordered, complete lifecycle — every prompt, every tool call, every turn boundary.
|
|
211
|
+
The two halves join on `prompt_id`, which Claude documents as matching the OTLP
|
|
212
|
+
`prompt.id` attribute:
|
|
213
|
+
|
|
214
|
+
- **Hooks** own the turn and tool lifecycle.
|
|
215
|
+
- **OTLP** enriches those turns with tokens, model and cost.
|
|
216
|
+
|
|
217
|
+
`copilot-tracer --setup` writes this for you. To add it by hand, merge the following into
|
|
218
|
+
`~/.claude/settings.json` (keep any hooks you already have — Claude runs all of them):
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"hooks": {
|
|
223
|
+
"UserPromptSubmit": [
|
|
224
|
+
{ "hooks": [{ "type": "http", "url": "http://localhost:4747/claude/hook", "timeout": 5 }] }
|
|
225
|
+
]
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Setup subscribes to `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`,
|
|
231
|
+
`PostToolUseFailure`, `Stop`, `StopFailure` and `SessionEnd` using the same handler.
|
|
232
|
+
|
|
233
|
+
The receiver always answers `204 No Content`, which the hooks spec defines as "no
|
|
234
|
+
decision", so it can never block a tool call, deny a permission, or stop a turn. If the
|
|
235
|
+
daemon isn't running, Claude treats the connection failure as a non-blocking error and
|
|
236
|
+
your session continues normally.
|
|
237
|
+
|
|
238
|
+
If hooks are unavailable (older Claude Code, `disableAllHooks`, remote sessions), the
|
|
239
|
+
tracer falls back to its original OTLP-only handling.
|
|
240
|
+
|
|
199
241
|
For VS Code, add to `~/Library/Application Support/Code/User/settings.json`:
|
|
200
242
|
|
|
201
243
|
```json
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code hook receiver.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code posts each lifecycle event here as a native `type: "http"` hook, giving
|
|
5
|
+
* the tracer an ordered, complete view of a session: every prompt, every tool call, and
|
|
6
|
+
* every turn boundary — the things OTLP alone can't correlate reliably across a
|
|
7
|
+
* multi-prompt session.
|
|
8
|
+
*
|
|
9
|
+
* Safety contract (this runs inside the user's Claude session, so it must never interfere):
|
|
10
|
+
* - Always answer 204 No Content. Per the hooks spec a 2xx with an empty body means
|
|
11
|
+
* "no decision", so the tracer can never block a tool call, deny a permission, or
|
|
12
|
+
* stop a turn — even if the handler hits an unexpected payload.
|
|
13
|
+
* - Never throw. Any error is swallowed and logged; the session continues.
|
|
14
|
+
* - Do no blocking work. Everything here is in-memory plus one SQLite upsert.
|
|
15
|
+
*/
|
|
16
|
+
import { startTurn, finishTurn, startToolCall, finishToolCall, endSession, registerSession, } from './claudeSession.js';
|
|
17
|
+
import { ensureProject } from './db.js';
|
|
18
|
+
function str(value) {
|
|
19
|
+
if (typeof value === 'string' && value.trim())
|
|
20
|
+
return value;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
function errorText(payload) {
|
|
24
|
+
const raw = payload.tool_error ?? payload.error;
|
|
25
|
+
if (raw === undefined || raw === null)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof raw === 'string')
|
|
28
|
+
return raw || undefined;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return String(raw);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Claude reports the working directory it's actually in (worktree-aware), which is the
|
|
38
|
+
* best project signal available from a hook. Falls back to no project — traces still
|
|
39
|
+
* show up on the live page.
|
|
40
|
+
*/
|
|
41
|
+
function resolveProject(cwd) {
|
|
42
|
+
if (!cwd)
|
|
43
|
+
return undefined;
|
|
44
|
+
try {
|
|
45
|
+
return ensureProject(cwd);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function handleClaudeHook(payload) {
|
|
52
|
+
const event = str(payload.hook_event_name);
|
|
53
|
+
const sessionId = str(payload.session_id);
|
|
54
|
+
if (!event || !sessionId)
|
|
55
|
+
return;
|
|
56
|
+
const promptId = str(payload.prompt_id);
|
|
57
|
+
const projectId = resolveProject(str(payload.cwd));
|
|
58
|
+
const toolName = str(payload.tool_name) ?? 'Claude Code tool';
|
|
59
|
+
switch (event) {
|
|
60
|
+
case 'SessionStart':
|
|
61
|
+
registerSession(sessionId, projectId);
|
|
62
|
+
return;
|
|
63
|
+
case 'UserPromptSubmit':
|
|
64
|
+
startTurn({
|
|
65
|
+
sessionId,
|
|
66
|
+
promptId,
|
|
67
|
+
prompt: str(payload.prompt) ?? '[Claude Code prompt]',
|
|
68
|
+
projectId,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
case 'PreToolUse':
|
|
72
|
+
startToolCall({
|
|
73
|
+
sessionId,
|
|
74
|
+
promptId,
|
|
75
|
+
toolUseId: str(payload.tool_use_id),
|
|
76
|
+
name: toolName,
|
|
77
|
+
toolInput: payload.tool_input,
|
|
78
|
+
projectId,
|
|
79
|
+
});
|
|
80
|
+
return;
|
|
81
|
+
case 'PostToolUse':
|
|
82
|
+
case 'PostToolUseFailure':
|
|
83
|
+
finishToolCall({
|
|
84
|
+
sessionId,
|
|
85
|
+
promptId,
|
|
86
|
+
toolUseId: str(payload.tool_use_id),
|
|
87
|
+
name: toolName,
|
|
88
|
+
toolInput: payload.tool_input,
|
|
89
|
+
output: payload.tool_response,
|
|
90
|
+
error: event === 'PostToolUseFailure' ? (errorText(payload) ?? 'tool failed') : errorText(payload),
|
|
91
|
+
projectId,
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
case 'Stop':
|
|
95
|
+
case 'SubagentStop':
|
|
96
|
+
finishTurn({
|
|
97
|
+
sessionId,
|
|
98
|
+
promptId,
|
|
99
|
+
response: str(payload.last_assistant_message),
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
case 'StopFailure':
|
|
103
|
+
finishTurn({
|
|
104
|
+
sessionId,
|
|
105
|
+
promptId,
|
|
106
|
+
error: errorText(payload) ?? str(payload.reason) ?? 'Claude Code turn failed',
|
|
107
|
+
});
|
|
108
|
+
return;
|
|
109
|
+
case 'SessionEnd':
|
|
110
|
+
endSession(sessionId);
|
|
111
|
+
return;
|
|
112
|
+
default:
|
|
113
|
+
// Unknown or unsubscribed event — ignore rather than guess.
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export function registerClaudeHookRoutes(app) {
|
|
118
|
+
// Claude Code posts every subscribed lifecycle event here.
|
|
119
|
+
app.post('/claude/hook', (req, res) => {
|
|
120
|
+
try {
|
|
121
|
+
handleClaudeHook((req.body ?? {}));
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
console.error('[claude-hook] failed to process event:', err);
|
|
125
|
+
}
|
|
126
|
+
// 204 = 2xx with empty body = "no decision". Never influence the session.
|
|
127
|
+
res.status(204).end();
|
|
128
|
+
});
|
|
129
|
+
// Lets `--setup` and users confirm the receiver is reachable.
|
|
130
|
+
app.get('/claude/hook/health', (_req, res) => {
|
|
131
|
+
res.json({ ok: true, receiver: 'claude-hooks' });
|
|
132
|
+
});
|
|
133
|
+
// A malformed or oversized body fails inside the express.json() middleware, before the
|
|
134
|
+
// route above ever runs, and would otherwise surface to Claude as a 4xx — a non-2xx
|
|
135
|
+
// response is a hook error. Convert it back into "no decision" so a bad payload can
|
|
136
|
+
// still never affect the user's session.
|
|
137
|
+
app.use((err, req, res, next) => {
|
|
138
|
+
if (!req.path.startsWith('/claude/hook'))
|
|
139
|
+
return next(err);
|
|
140
|
+
console.error('[claude-hook] rejected malformed request:', err);
|
|
141
|
+
if (res.headersSent)
|
|
142
|
+
return;
|
|
143
|
+
res.status(204).end();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic token pricing, shared by the OTLP receiver and the Claude hook tracker.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so `claudeSession.ts` and `otlpReceiver.ts` can both use it
|
|
5
|
+
* without importing each other (they reference each other in the hybrid hook+OTLP flow).
|
|
6
|
+
*/
|
|
7
|
+
// Anthropic API pricing per 1K tokens (USD), converted to GitHub "AI credit" units
|
|
8
|
+
// (1 credit = $0.01) so aiCredits stays one unit across Copilot and Claude entries.
|
|
9
|
+
// Rates are Anthropic's current published per-model prices; a model id that doesn't
|
|
10
|
+
// match a specific entry falls back to its tier's (opus/sonnet/haiku) latest rate.
|
|
11
|
+
const ANTHROPIC_USD_PER_1K = {
|
|
12
|
+
'claude-fable-5': { input: 0.010, output: 0.050 },
|
|
13
|
+
'claude-mythos-5': { input: 0.010, output: 0.050 },
|
|
14
|
+
'claude-opus-5': { input: 0.005, output: 0.025 },
|
|
15
|
+
'claude-opus-4-8': { input: 0.005, output: 0.025 },
|
|
16
|
+
'claude-opus-4-7': { input: 0.005, output: 0.025 },
|
|
17
|
+
'claude-opus-4-6': { input: 0.005, output: 0.025 },
|
|
18
|
+
'claude-sonnet-5': { input: 0.002, output: 0.010 },
|
|
19
|
+
'claude-sonnet-4-6': { input: 0.003, output: 0.015 },
|
|
20
|
+
'claude-haiku-4-5': { input: 0.001, output: 0.005 },
|
|
21
|
+
'opus': { input: 0.005, output: 0.025 },
|
|
22
|
+
'sonnet': { input: 0.003, output: 0.015 },
|
|
23
|
+
'haiku': { input: 0.001, output: 0.005 },
|
|
24
|
+
'default': { input: 0.003, output: 0.015 },
|
|
25
|
+
};
|
|
26
|
+
export function calcClaudeCredits(tokens, model) {
|
|
27
|
+
const key = Object.keys(ANTHROPIC_USD_PER_1K).find(k => (model ?? '').toLowerCase().includes(k)) ?? 'default';
|
|
28
|
+
const rate = ANTHROPIC_USD_PER_1K[key];
|
|
29
|
+
const usd = (tokens.input / 1000) * rate.input + (tokens.output / 1000) * rate.output;
|
|
30
|
+
return usd * 100; // USD → credits (1 credit = $0.01)
|
|
31
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code session tracker — the "hook" half of the hybrid tracing design.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists
|
|
5
|
+
* ---------------
|
|
6
|
+
* Claude Code's OTLP stream alone can't reliably reconstruct a multi-prompt session.
|
|
7
|
+
* Its log events are correlated by `prompt.id` while its spans are correlated by OTLP
|
|
8
|
+
* `traceId`, and neither key is guaranteed to be present, so turns after the first one
|
|
9
|
+
* would routinely lose their tool calls or land on a duplicate entry.
|
|
10
|
+
*
|
|
11
|
+
* Claude Code hooks give us a deterministic, ordered lifecycle instead:
|
|
12
|
+
* UserPromptSubmit → PreToolUse* / PostToolUse* → Stop
|
|
13
|
+
* and every hook payload carries `session_id` plus `prompt_id`, where `prompt_id` is
|
|
14
|
+
* documented to match the OTLP `prompt.id` attribute. That shared key is what lets the
|
|
15
|
+
* two halves join up.
|
|
16
|
+
*
|
|
17
|
+
* Division of responsibility:
|
|
18
|
+
* - Hooks own the turn/tool *lifecycle* (what happened, in what order, on which turn).
|
|
19
|
+
* - OTLP owns *enrichment* (tokens, model, cost), applied onto the hook-created turn.
|
|
20
|
+
*
|
|
21
|
+
* When hooks aren't configured the OTLP receiver keeps its original standalone behaviour,
|
|
22
|
+
* so this module degrades to a no-op rather than breaking existing users.
|
|
23
|
+
*/
|
|
24
|
+
import { randomUUID } from 'crypto';
|
|
25
|
+
import { upsertTrace, createSession } from './db.js';
|
|
26
|
+
import { traceEvents } from './proxy.js';
|
|
27
|
+
import { calcClaudeCredits } from './claudePricing.js';
|
|
28
|
+
// ── Bounded state ─────────────────────────────────────────────────────────────
|
|
29
|
+
// A long-lived daemon sees unbounded sessions, so every map is FIFO-capped.
|
|
30
|
+
const ACTIVE_LIMIT = 200; // concurrent Claude sessions we track turns for
|
|
31
|
+
const HISTORY_LIMIT = 2000; // finished turns retained for late OTLP enrichment
|
|
32
|
+
const PENDING_LIMIT = 2000; // buffered usage waiting for its turn to appear
|
|
33
|
+
const PENDING_TTL_MS = 10 * 60 * 1000;
|
|
34
|
+
/** sessionId → the turn currently open for that session */
|
|
35
|
+
const activeBySession = new Map();
|
|
36
|
+
/** promptId → turn (open or closed). The join key shared with OTLP's `prompt.id`. */
|
|
37
|
+
const turnsByPromptId = new Map();
|
|
38
|
+
/** sessionId → most recently closed turn, so usage arriving after Stop still lands */
|
|
39
|
+
const lastClosedBySession = new Map();
|
|
40
|
+
/** buffered usage for turns that haven't been created yet, keyed `p:<promptId>`/`s:<sessionId>` */
|
|
41
|
+
const pendingUsage = new Map();
|
|
42
|
+
/**
|
|
43
|
+
* Sessions that have produced at least one hook event — enables hook-authoritative mode.
|
|
44
|
+
* A Map (not a Set) so it can be FIFO-capped: real sessions frequently end without a
|
|
45
|
+
* SessionEnd hook (crash, kill, machine sleep), and this daemon runs for weeks.
|
|
46
|
+
*/
|
|
47
|
+
const hookSessions = new Map();
|
|
48
|
+
function capped(map, limit) {
|
|
49
|
+
while (map.size > limit) {
|
|
50
|
+
const oldest = map.keys().next().value;
|
|
51
|
+
if (oldest === undefined)
|
|
52
|
+
return;
|
|
53
|
+
map.delete(oldest);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function emptyTokens() {
|
|
57
|
+
return { input: 0, output: 0, cached: 0, reasoning: 0, written: 0, total: 0 };
|
|
58
|
+
}
|
|
59
|
+
// ── Tool classification ───────────────────────────────────────────────────────
|
|
60
|
+
// Claude's tool vocabulary differs from Copilot's, so it gets its own classifier
|
|
61
|
+
// rather than reusing the Copilot heuristics (which would mislabel `Task`/`Skill`).
|
|
62
|
+
export function detectClaudeToolType(name) {
|
|
63
|
+
const n = name.toLowerCase();
|
|
64
|
+
if (n.startsWith('mcp__') || n.startsWith('mcp_'))
|
|
65
|
+
return 'mcp';
|
|
66
|
+
if (n === 'task' || n === 'agent' || n.includes('subagent'))
|
|
67
|
+
return 'agent';
|
|
68
|
+
if (n === 'skill' || n.startsWith('skill__') || n.includes('skill'))
|
|
69
|
+
return 'skill';
|
|
70
|
+
return 'builtin';
|
|
71
|
+
}
|
|
72
|
+
function recount(entry) {
|
|
73
|
+
entry.skillCount = entry.toolCalls.filter(c => c.type === 'skill').length;
|
|
74
|
+
entry.agentCount = entry.toolCalls.filter(c => c.type === 'agent').length;
|
|
75
|
+
entry.mcpCount = entry.toolCalls.filter(c => c.type === 'mcp').length;
|
|
76
|
+
}
|
|
77
|
+
function persist(ctx, done = false) {
|
|
78
|
+
recount(ctx.entry);
|
|
79
|
+
upsertTrace(ctx.entry);
|
|
80
|
+
traceEvents.emit('trace:update', ctx.entry);
|
|
81
|
+
if (done)
|
|
82
|
+
traceEvents.emit('trace:done', ctx.entry);
|
|
83
|
+
}
|
|
84
|
+
// ── Pending-usage buffering ───────────────────────────────────────────────────
|
|
85
|
+
function pendingKeys(sessionId, promptId) {
|
|
86
|
+
const keys = [];
|
|
87
|
+
if (promptId)
|
|
88
|
+
keys.push(`p:${promptId}`);
|
|
89
|
+
if (sessionId)
|
|
90
|
+
keys.push(`s:${sessionId}`);
|
|
91
|
+
return keys;
|
|
92
|
+
}
|
|
93
|
+
function bufferUsage(delta, sessionId, promptId) {
|
|
94
|
+
const key = pendingKeys(sessionId, promptId)[0];
|
|
95
|
+
if (!key)
|
|
96
|
+
return;
|
|
97
|
+
const existing = pendingUsage.get(key);
|
|
98
|
+
if (existing) {
|
|
99
|
+
mergeDelta(existing.delta, delta);
|
|
100
|
+
existing.at = Date.now();
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
pendingUsage.set(key, { delta: { ...delta }, at: Date.now() });
|
|
104
|
+
}
|
|
105
|
+
expirePending();
|
|
106
|
+
capped(pendingUsage, PENDING_LIMIT);
|
|
107
|
+
}
|
|
108
|
+
function mergeDelta(target, extra) {
|
|
109
|
+
target.input = (target.input ?? 0) + (extra.input ?? 0);
|
|
110
|
+
target.output = (target.output ?? 0) + (extra.output ?? 0);
|
|
111
|
+
target.cached = (target.cached ?? 0) + (extra.cached ?? 0);
|
|
112
|
+
target.reasoning = (target.reasoning ?? 0) + (extra.reasoning ?? 0);
|
|
113
|
+
target.written = (target.written ?? 0) + (extra.written ?? 0);
|
|
114
|
+
target.credits = (target.credits ?? 0) + (extra.credits ?? 0);
|
|
115
|
+
target.model = extra.model ?? target.model;
|
|
116
|
+
}
|
|
117
|
+
function expirePending() {
|
|
118
|
+
const cutoff = Date.now() - PENDING_TTL_MS;
|
|
119
|
+
for (const [key, value] of pendingUsage) {
|
|
120
|
+
if (value.at < cutoff)
|
|
121
|
+
pendingUsage.delete(key);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function drainPending(ctx) {
|
|
125
|
+
const keys = [`p:${ctx.promptId ?? ''}`];
|
|
126
|
+
// Session-keyed usage is only ever buffered when the session had no turn at all, so
|
|
127
|
+
// it belongs to the session's first turn. Claiming it later would credit one turn's
|
|
128
|
+
// tokens to an unrelated, subsequent turn.
|
|
129
|
+
if (!lastClosedBySession.has(ctx.sessionId))
|
|
130
|
+
keys.push(`s:${ctx.sessionId}`);
|
|
131
|
+
for (const key of keys) {
|
|
132
|
+
if (key === 'p:')
|
|
133
|
+
continue;
|
|
134
|
+
const buffered = pendingUsage.get(key);
|
|
135
|
+
if (!buffered)
|
|
136
|
+
continue;
|
|
137
|
+
pendingUsage.delete(key);
|
|
138
|
+
addUsage(ctx, buffered.delta);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function addUsage(ctx, delta) {
|
|
142
|
+
const t = ctx.entry.tokens;
|
|
143
|
+
t.input += delta.input ?? 0;
|
|
144
|
+
t.output += delta.output ?? 0;
|
|
145
|
+
t.cached += delta.cached ?? 0;
|
|
146
|
+
t.reasoning += delta.reasoning ?? 0;
|
|
147
|
+
t.written += delta.written ?? 0;
|
|
148
|
+
// Matches the rest of the codebase (proxy.ts, the OTLP fallback path): `written` and
|
|
149
|
+
// `cached` are reporting breakdowns of the same traffic, so summing them into `total`
|
|
150
|
+
// would double-count output tokens.
|
|
151
|
+
t.total = t.input + t.output;
|
|
152
|
+
if (delta.model)
|
|
153
|
+
ctx.model = delta.model;
|
|
154
|
+
ctx.entry.aiCredits += delta.credits
|
|
155
|
+
?? calcClaudeCredits({ input: delta.input ?? 0, output: delta.output ?? 0 }, delta.model ?? ctx.model);
|
|
156
|
+
}
|
|
157
|
+
// ── Turn lookup ───────────────────────────────────────────────────────────────
|
|
158
|
+
/**
|
|
159
|
+
* Resolve the turn an OTLP event belongs to. Prefers the exact `prompt.id` join,
|
|
160
|
+
* then the session's open turn, then the session's most recently closed turn
|
|
161
|
+
* (usage spans routinely arrive just after Stop).
|
|
162
|
+
*/
|
|
163
|
+
export function findClaudeTurn(sessionId, promptId) {
|
|
164
|
+
if (promptId) {
|
|
165
|
+
const byPrompt = turnsByPromptId.get(promptId);
|
|
166
|
+
if (byPrompt)
|
|
167
|
+
return byPrompt;
|
|
168
|
+
}
|
|
169
|
+
if (sessionId) {
|
|
170
|
+
return activeBySession.get(sessionId) ?? lastClosedBySession.get(sessionId);
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
/** True when hooks have reported activity for this session, so hooks own its lifecycle. */
|
|
175
|
+
export function isHookTracked(sessionId) {
|
|
176
|
+
return !!sessionId && hookSessions.has(sessionId);
|
|
177
|
+
}
|
|
178
|
+
function markHookSession(sessionId) {
|
|
179
|
+
// Re-insert so the most recently active sessions are evicted last.
|
|
180
|
+
hookSessions.delete(sessionId);
|
|
181
|
+
hookSessions.set(sessionId, Date.now());
|
|
182
|
+
capped(hookSessions, ACTIVE_LIMIT);
|
|
183
|
+
}
|
|
184
|
+
// ── Lifecycle: turns ──────────────────────────────────────────────────────────
|
|
185
|
+
export function startTurn(input) {
|
|
186
|
+
const { sessionId, promptId, projectId } = input;
|
|
187
|
+
markHookSession(sessionId);
|
|
188
|
+
// Claude re-fires UserPromptSubmit for the same prompt_id (retries, resubmits). Reuse
|
|
189
|
+
// that turn. This must be checked BEFORE closing the stale turn below, since the two
|
|
190
|
+
// are frequently the same context — closing it first would force a duplicate trace.
|
|
191
|
+
const existing = promptId ? turnsByPromptId.get(promptId) : undefined;
|
|
192
|
+
if (existing && !existing.closed) {
|
|
193
|
+
existing.entry.prompt = input.prompt || existing.entry.prompt;
|
|
194
|
+
activeBySession.set(sessionId, existing);
|
|
195
|
+
persist(existing);
|
|
196
|
+
return existing;
|
|
197
|
+
}
|
|
198
|
+
// A different turn that never received Stop (crash, /clear, interrupt) would otherwise
|
|
199
|
+
// sit at status 'running' forever and swallow this turn's tool calls.
|
|
200
|
+
const stale = activeBySession.get(sessionId);
|
|
201
|
+
if (stale && stale !== existing && !stale.closed)
|
|
202
|
+
closeTurn(stale, { status: 'done' });
|
|
203
|
+
const startedAtMs = Date.now();
|
|
204
|
+
const entry = {
|
|
205
|
+
id: `claude:turn:${promptId ?? randomUUID()}`,
|
|
206
|
+
sessionId,
|
|
207
|
+
dateTime: input.dateTime ?? new Date(startedAtMs).toISOString(),
|
|
208
|
+
prompt: input.prompt || '[Claude Code prompt]',
|
|
209
|
+
tokens: emptyTokens(),
|
|
210
|
+
aiCredits: 0,
|
|
211
|
+
durationMs: 0,
|
|
212
|
+
toolCalls: [],
|
|
213
|
+
skillCount: 0,
|
|
214
|
+
agentCount: 0,
|
|
215
|
+
mcpCount: 0,
|
|
216
|
+
status: 'running',
|
|
217
|
+
};
|
|
218
|
+
const ctx = {
|
|
219
|
+
entry,
|
|
220
|
+
tools: new Map(),
|
|
221
|
+
sessionId,
|
|
222
|
+
promptId,
|
|
223
|
+
startedAtMs,
|
|
224
|
+
closed: false,
|
|
225
|
+
};
|
|
226
|
+
activeBySession.set(sessionId, ctx);
|
|
227
|
+
capped(activeBySession, ACTIVE_LIMIT);
|
|
228
|
+
if (promptId) {
|
|
229
|
+
turnsByPromptId.set(promptId, ctx);
|
|
230
|
+
capped(turnsByPromptId, HISTORY_LIMIT);
|
|
231
|
+
}
|
|
232
|
+
createSession(sessionId, projectId);
|
|
233
|
+
drainPending(ctx);
|
|
234
|
+
persist(ctx);
|
|
235
|
+
return ctx;
|
|
236
|
+
}
|
|
237
|
+
function closeTurn(ctx, opts) {
|
|
238
|
+
if (!ctx.closed) {
|
|
239
|
+
ctx.entry.durationMs = ctx.entry.durationMs || Date.now() - ctx.startedAtMs;
|
|
240
|
+
ctx.closed = true;
|
|
241
|
+
}
|
|
242
|
+
if (opts.response)
|
|
243
|
+
ctx.entry.response = opts.response;
|
|
244
|
+
if (opts.error)
|
|
245
|
+
ctx.entry.error = opts.error;
|
|
246
|
+
// A turn can be closed twice: the Stop hook fires, then the OTLP assistant_response
|
|
247
|
+
// event arrives to backfill the text. The second call must not downgrade a failure
|
|
248
|
+
// (StopFailure) back to 'done'.
|
|
249
|
+
if (opts.error) {
|
|
250
|
+
ctx.entry.status = 'error';
|
|
251
|
+
}
|
|
252
|
+
else if (ctx.entry.status !== 'error') {
|
|
253
|
+
ctx.entry.status = opts.status ?? 'done';
|
|
254
|
+
}
|
|
255
|
+
// Any tool still open at turn end never got its PostToolUse (denied, interrupted,
|
|
256
|
+
// or the session ended mid-call). Close it out rather than leaving it dangling.
|
|
257
|
+
for (const call of ctx.entry.toolCalls) {
|
|
258
|
+
if (call.endedAt === undefined) {
|
|
259
|
+
call.endedAt = Date.now();
|
|
260
|
+
call.durationMs = call.endedAt - call.startedAt;
|
|
261
|
+
call.error = call.error ?? 'incomplete (no PostToolUse received)';
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (activeBySession.get(ctx.sessionId) === ctx)
|
|
265
|
+
activeBySession.delete(ctx.sessionId);
|
|
266
|
+
lastClosedBySession.set(ctx.sessionId, ctx);
|
|
267
|
+
capped(lastClosedBySession, ACTIVE_LIMIT);
|
|
268
|
+
persist(ctx, true);
|
|
269
|
+
}
|
|
270
|
+
export function finishTurn(input) {
|
|
271
|
+
const ctx = findClaudeTurn(input.sessionId, input.promptId);
|
|
272
|
+
if (!ctx)
|
|
273
|
+
return;
|
|
274
|
+
markHookSession(input.sessionId);
|
|
275
|
+
closeTurn(ctx, { response: input.response, error: input.error });
|
|
276
|
+
}
|
|
277
|
+
export function endSession(sessionId) {
|
|
278
|
+
const ctx = activeBySession.get(sessionId);
|
|
279
|
+
if (ctx && !ctx.closed)
|
|
280
|
+
closeTurn(ctx, { status: 'done' });
|
|
281
|
+
activeBySession.delete(sessionId);
|
|
282
|
+
hookSessions.delete(sessionId);
|
|
283
|
+
}
|
|
284
|
+
export function registerSession(sessionId, projectId) {
|
|
285
|
+
markHookSession(sessionId);
|
|
286
|
+
createSession(sessionId, projectId);
|
|
287
|
+
}
|
|
288
|
+
// ── Lifecycle: tool calls ─────────────────────────────────────────────────────
|
|
289
|
+
/**
|
|
290
|
+
* Pre/Post hook pairs share `tool_use_id` on recent Claude Code versions. Older
|
|
291
|
+
* versions omit it, so fall back to a stable key derived from name + input.
|
|
292
|
+
*/
|
|
293
|
+
function toolKey(toolUseId, name, input) {
|
|
294
|
+
if (toolUseId)
|
|
295
|
+
return toolUseId;
|
|
296
|
+
let serialized = '';
|
|
297
|
+
try {
|
|
298
|
+
serialized = JSON.stringify(input ?? {});
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
serialized = String(input);
|
|
302
|
+
}
|
|
303
|
+
return `${name}:${serialized.slice(0, 512)}`;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Attach a tool call to the session's open turn. If no turn exists (hooks enabled
|
|
307
|
+
* mid-session, or a subagent session whose UserPromptSubmit we never saw) an implicit
|
|
308
|
+
* turn is created so the call is recorded rather than silently dropped.
|
|
309
|
+
*/
|
|
310
|
+
function turnForTool(sessionId, promptId, projectId) {
|
|
311
|
+
// A tool event carrying a prompt_id belongs to that prompt's turn, full stop — even if
|
|
312
|
+
// the turn already closed. Falling back to the session's *current* turn here would file
|
|
313
|
+
// a straggling PostToolUse from the previous prompt onto the next prompt's trace.
|
|
314
|
+
const existing = promptId ? turnsByPromptId.get(promptId) : undefined;
|
|
315
|
+
if (existing)
|
|
316
|
+
return existing;
|
|
317
|
+
const active = activeBySession.get(sessionId);
|
|
318
|
+
if (active && !active.closed)
|
|
319
|
+
return active;
|
|
320
|
+
return startTurn({ sessionId, promptId, prompt: '[Claude Code turn]', projectId });
|
|
321
|
+
}
|
|
322
|
+
export function startToolCall(input) {
|
|
323
|
+
markHookSession(input.sessionId);
|
|
324
|
+
const ctx = turnForTool(input.sessionId, input.promptId, input.projectId);
|
|
325
|
+
const key = toolKey(input.toolUseId, input.name, input.toolInput);
|
|
326
|
+
const previous = ctx.tools.get(key);
|
|
327
|
+
// Without a tool_use_id the key is only name+input, so a repeat of an identical call
|
|
328
|
+
// collides with the earlier one. Skip only while that earlier call is still open (a
|
|
329
|
+
// genuine duplicate PreToolUse); once it has completed, this is a new call.
|
|
330
|
+
if (previous && previous.endedAt === undefined)
|
|
331
|
+
return;
|
|
332
|
+
const call = {
|
|
333
|
+
id: input.toolUseId ?? randomUUID(),
|
|
334
|
+
name: input.name,
|
|
335
|
+
type: detectClaudeToolType(input.name),
|
|
336
|
+
input: input.toolInput ?? {},
|
|
337
|
+
startedAt: Date.now(),
|
|
338
|
+
};
|
|
339
|
+
ctx.tools.set(key, call);
|
|
340
|
+
ctx.entry.toolCalls.push(call);
|
|
341
|
+
persist(ctx);
|
|
342
|
+
}
|
|
343
|
+
export function finishToolCall(input) {
|
|
344
|
+
markHookSession(input.sessionId);
|
|
345
|
+
const ctx = turnForTool(input.sessionId, input.promptId, input.projectId);
|
|
346
|
+
const key = toolKey(input.toolUseId, input.name, input.toolInput);
|
|
347
|
+
let call = ctx.tools.get(key);
|
|
348
|
+
if (!call) {
|
|
349
|
+
// PostToolUse without a matching PreToolUse (hook added mid-call, or matcher
|
|
350
|
+
// mismatch). Record it as a zero-duration call so the tool isn't lost.
|
|
351
|
+
call = {
|
|
352
|
+
id: input.toolUseId ?? randomUUID(),
|
|
353
|
+
name: input.name,
|
|
354
|
+
type: detectClaudeToolType(input.name),
|
|
355
|
+
input: input.toolInput ?? {},
|
|
356
|
+
startedAt: Date.now(),
|
|
357
|
+
};
|
|
358
|
+
ctx.tools.set(key, call);
|
|
359
|
+
ctx.entry.toolCalls.push(call);
|
|
360
|
+
}
|
|
361
|
+
call.endedAt = Date.now();
|
|
362
|
+
call.durationMs = call.endedAt - call.startedAt;
|
|
363
|
+
if (input.output !== undefined)
|
|
364
|
+
call.output = input.output;
|
|
365
|
+
if (input.error)
|
|
366
|
+
call.error = input.error;
|
|
367
|
+
persist(ctx);
|
|
368
|
+
}
|
|
369
|
+
// ── Enrichment from OTLP ──────────────────────────────────────────────────────
|
|
370
|
+
/**
|
|
371
|
+
* Apply token/model/cost usage reported over OTLP onto the matching hook turn.
|
|
372
|
+
* Returns false when no turn matched — the usage is buffered, and the caller may
|
|
373
|
+
* fall back to its own standalone handling.
|
|
374
|
+
*/
|
|
375
|
+
export function applyClaudeUsage(input) {
|
|
376
|
+
const ctx = findClaudeTurn(input.sessionId, input.promptId);
|
|
377
|
+
if (!ctx) {
|
|
378
|
+
bufferUsage(input.delta, input.sessionId, input.promptId);
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
addUsage(ctx, input.delta);
|
|
382
|
+
persist(ctx);
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
/** Fill in prompt/response text discovered over OTLP without clobbering hook data. */
|
|
386
|
+
export function enrichClaudeContent(input) {
|
|
387
|
+
const ctx = findClaudeTurn(input.sessionId, input.promptId);
|
|
388
|
+
if (!ctx)
|
|
389
|
+
return false;
|
|
390
|
+
const placeholder = /^\[Claude Code (prompt|turn)\]$/;
|
|
391
|
+
if (input.prompt && placeholder.test(ctx.entry.prompt))
|
|
392
|
+
ctx.entry.prompt = input.prompt;
|
|
393
|
+
if (input.response && !ctx.entry.response)
|
|
394
|
+
ctx.entry.response = input.response;
|
|
395
|
+
persist(ctx);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
/** Test-only: drop all in-memory state. */
|
|
399
|
+
export function resetClaudeTracker() {
|
|
400
|
+
activeBySession.clear();
|
|
401
|
+
turnsByPromptId.clear();
|
|
402
|
+
lastClosedBySession.clear();
|
|
403
|
+
pendingUsage.clear();
|
|
404
|
+
hookSessions.clear();
|
|
405
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ import { renderConsoleTable } from './consoleUi.js';
|
|
|
11
11
|
import { startWebServer } from './webServer.js';
|
|
12
12
|
import { runSetup } from './setup.js';
|
|
13
13
|
import open from 'open';
|
|
14
|
+
import os from 'os';
|
|
14
15
|
program
|
|
15
16
|
.name('copilot-tracer')
|
|
16
17
|
.description('Real-time monitor and tracer for GitHub Copilot CLI')
|
|
@@ -30,7 +31,41 @@ const port = parseInt(opts.port);
|
|
|
30
31
|
// Handle --setup: patch env files, apply to current process, then fall through to start web UI
|
|
31
32
|
if (opts.setup) {
|
|
32
33
|
runSetup(port, opts.daemon);
|
|
33
|
-
if (
|
|
34
|
+
if (opts.daemon) {
|
|
35
|
+
// Relaunch as a detached background daemon so the terminal doesn't need to stay open.
|
|
36
|
+
const dir = path.join(os.homedir(), '.copilot-tracer');
|
|
37
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
38
|
+
const pidFile = path.join(dir, 'daemon.pid');
|
|
39
|
+
const existingPid = fs.existsSync(pidFile) ? parseInt(fs.readFileSync(pidFile, 'utf8'), 10) : NaN;
|
|
40
|
+
const alreadyRunning = !isNaN(existingPid) && (() => {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(existingPid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
if (alreadyRunning) {
|
|
50
|
+
console.log(`\n 🤖 Daemon already running (pid ${existingPid})`);
|
|
51
|
+
console.log(` 🌐 Dashboard: http://localhost:${port}/\n`);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const logFile = fs.openSync(path.join(dir, 'daemon.log'), 'a');
|
|
55
|
+
const child = spawn(process.execPath, [process.argv[1], '--daemon', '--port', String(port)], {
|
|
56
|
+
detached: true,
|
|
57
|
+
stdio: ['ignore', logFile, logFile],
|
|
58
|
+
});
|
|
59
|
+
child.unref();
|
|
60
|
+
fs.writeFileSync(pidFile, String(child.pid));
|
|
61
|
+
console.log(`\n 🤖 Daemon started in background (pid ${child.pid})`);
|
|
62
|
+
console.log(` 🌐 Dashboard: http://localhost:${port}/`);
|
|
63
|
+
console.log(` 📄 Logs: ${path.join(dir, 'daemon.log')}`);
|
|
64
|
+
console.log(` Stop it with: kill ${child.pid}\n`);
|
|
65
|
+
}
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
34
69
|
opts.proxy = false;
|
|
35
70
|
opts.ui = 'web';
|
|
36
71
|
}
|
package/dist/db.js
CHANGED
|
@@ -2,7 +2,9 @@ import Database from 'better-sqlite3';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
-
|
|
5
|
+
// Overridable so a verification run can point at a throwaway database instead of
|
|
6
|
+
// polluting the user's real trace history.
|
|
7
|
+
const DB_DIR = process.env.COPILOT_TRACER_HOME ?? path.join(os.homedir(), '.copilot-tracer');
|
|
6
8
|
const DB_PATH = path.join(DB_DIR, 'traces.db');
|
|
7
9
|
if (!fs.existsSync(DB_DIR))
|
|
8
10
|
fs.mkdirSync(DB_DIR, { recursive: true });
|
|
@@ -103,7 +105,12 @@ export function createSession(id, projectId) {
|
|
|
103
105
|
END
|
|
104
106
|
`).run(id, new Date().toISOString(), projectId ?? null);
|
|
105
107
|
}
|
|
106
|
-
export function getDashboard() {
|
|
108
|
+
export function getDashboard(page = 1, pageSize = 12) {
|
|
109
|
+
const projectCount = db.prepare('SELECT COUNT(*) as count FROM projects').get();
|
|
110
|
+
const totalProjects = projectCount.count;
|
|
111
|
+
const totalPages = Math.max(1, Math.ceil(totalProjects / pageSize));
|
|
112
|
+
const currentPage = Math.min(page, totalPages);
|
|
113
|
+
const offset = (currentPage - 1) * pageSize;
|
|
107
114
|
const projects = db.prepare(`
|
|
108
115
|
SELECT
|
|
109
116
|
p.id, p.path, p.repo_url, p.local_path,
|
|
@@ -115,8 +122,9 @@ export function getDashboard() {
|
|
|
115
122
|
LEFT JOIN sessions s ON s.project_id = p.id
|
|
116
123
|
LEFT JOIN traces t ON t.session_id = s.id
|
|
117
124
|
GROUP BY p.id
|
|
118
|
-
ORDER BY last_active_at DESC
|
|
119
|
-
|
|
125
|
+
ORDER BY last_active_at DESC, p.id ASC
|
|
126
|
+
LIMIT ? OFFSET ?
|
|
127
|
+
`).all(pageSize, offset);
|
|
120
128
|
const totals = db.prepare(`
|
|
121
129
|
SELECT
|
|
122
130
|
(SELECT COUNT(*) FROM projects) as projects,
|
|
@@ -154,6 +162,12 @@ export function getDashboard() {
|
|
|
154
162
|
tokens: totals.tokens,
|
|
155
163
|
credits: totals.credits,
|
|
156
164
|
},
|
|
165
|
+
pagination: {
|
|
166
|
+
page: currentPage,
|
|
167
|
+
pageSize,
|
|
168
|
+
totalPages,
|
|
169
|
+
totalProjects,
|
|
170
|
+
},
|
|
157
171
|
};
|
|
158
172
|
}
|
|
159
173
|
export function upsertTrace(entry) {
|
package/dist/otlpReceiver.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import { randomUUID } from 'crypto';
|
|
18
18
|
import { upsertTrace, createSession, deleteTrace, ensureProject, ensureProjectByRepo } from './db.js';
|
|
19
19
|
import { traceEvents } from './proxy.js';
|
|
20
|
+
import { calcClaudeCredits } from './claudePricing.js';
|
|
21
|
+
import { applyClaudeUsage, enrichClaudeContent, finishTurn, isHookTracked } from './claudeSession.js';
|
|
20
22
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
21
23
|
function getAttr(attrs, key) {
|
|
22
24
|
const kv = attrs?.find(a => a.key === key);
|
|
@@ -59,31 +61,7 @@ function getBodyText(body) {
|
|
|
59
61
|
return undefined;
|
|
60
62
|
}
|
|
61
63
|
const inFlight = new Map(); // traceId → InFlight
|
|
62
|
-
|
|
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
|
-
}
|
|
64
|
+
export { calcClaudeCredits };
|
|
87
65
|
// Sessions are created lazily. Always upsert so project_id gets backfilled
|
|
88
66
|
// when the session was created earlier without a project.
|
|
89
67
|
function ensureSession(sessionId, projectId) {
|
|
@@ -142,6 +120,27 @@ function processClaudeLogRecord(record, resourceAttrs, sessionId, projectId, wor
|
|
|
142
120
|
const promptId = getStringAttr(attrs, 'prompt.id');
|
|
143
121
|
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
122
|
const eventSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
|
|
123
|
+
// ── Hook-authoritative path ────────────────────────────────────────────────
|
|
124
|
+
// When Claude Code hooks are reporting this session, they already own the turn and
|
|
125
|
+
// tool lifecycle (ordered, complete, and correlated by session_id + prompt_id). OTLP
|
|
126
|
+
// logs then only contribute content that hooks don't carry. Claiming the lifecycle
|
|
127
|
+
// here as well would duplicate every turn and tool call.
|
|
128
|
+
if (isHookTracked(eventSessionId)) {
|
|
129
|
+
if (eventName === 'claude_code.user_prompt') {
|
|
130
|
+
enrichClaudeContent({ sessionId: eventSessionId, promptId, prompt: getStringAttr(attrs, 'prompt') });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (eventName === 'claude_code.assistant_response') {
|
|
134
|
+
// The Stop hook normally closes the turn first; finishTurn is idempotent and
|
|
135
|
+
// backfills the response text either way.
|
|
136
|
+
finishTurn({ sessionId: eventSessionId, promptId, response: getStringAttr(attrs, 'response') });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (eventName === 'claude_code.tool_result') {
|
|
140
|
+
// Pre/PostToolUse already recorded this call with full input and real duration.
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
145
144
|
if (eventName === 'claude_code.user_prompt') {
|
|
146
145
|
const entry = {
|
|
147
146
|
id: claudeEventId(record, attrs, 'prompt'),
|
|
@@ -240,13 +239,43 @@ function processSpans(spans, sessionId, projectId, workingDir) {
|
|
|
240
239
|
const attrs = span.attributes ?? [];
|
|
241
240
|
const inputTokens = Number(getAttr(attrs, 'input_tokens') ?? 0);
|
|
242
241
|
const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
|
|
242
|
+
const cachedTokens = Number(getAttr(attrs, 'cache_read_tokens') ?? 0);
|
|
243
243
|
const model = getStringAttr(attrs, 'model', 'gen_ai.request.model');
|
|
244
|
+
const claudeSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
|
|
245
|
+
const claudePromptId = getStringAttr(attrs, 'prompt.id');
|
|
246
|
+
// ── Hook-authoritative path ──────────────────────────────────────────
|
|
247
|
+
// Hooks already created the turn for this prompt, so the interaction span is
|
|
248
|
+
// pure enrichment: tokens, model and cost land on the existing entry instead
|
|
249
|
+
// of spawning a second, tool-less trace for the same turn.
|
|
250
|
+
if (isHookTracked(claudeSessionId)) {
|
|
251
|
+
const pendingDelta = pendingClaudeLlmDeltas.get(traceId);
|
|
252
|
+
if (pendingDelta)
|
|
253
|
+
pendingClaudeLlmDeltas.delete(traceId);
|
|
254
|
+
applyClaudeUsage({
|
|
255
|
+
sessionId: claudeSessionId,
|
|
256
|
+
promptId: claudePromptId,
|
|
257
|
+
delta: {
|
|
258
|
+
input: inputTokens + (pendingDelta?.input ?? 0),
|
|
259
|
+
output: outputTokens + (pendingDelta?.output ?? 0),
|
|
260
|
+
cached: cachedTokens + (pendingDelta?.cached ?? 0),
|
|
261
|
+
written: outputTokens + (pendingDelta?.output ?? 0),
|
|
262
|
+
credits: calcClaudeCredits({ input: inputTokens, output: outputTokens }, model) + (pendingDelta?.credits ?? 0),
|
|
263
|
+
model,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
enrichClaudeContent({
|
|
267
|
+
sessionId: claudeSessionId,
|
|
268
|
+
promptId: claudePromptId,
|
|
269
|
+
prompt: getStringAttr(attrs, 'user_prompt'),
|
|
270
|
+
});
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
244
273
|
const entry = {
|
|
245
274
|
id: spanId,
|
|
246
|
-
sessionId:
|
|
275
|
+
sessionId: claudeSessionId,
|
|
247
276
|
dateTime: nanoToIso(span.startTimeUnixNano),
|
|
248
277
|
prompt: getStringAttr(attrs, 'user_prompt') ?? '[Claude Code interaction]',
|
|
249
|
-
tokens: { input: inputTokens, output: outputTokens, cached:
|
|
278
|
+
tokens: { input: inputTokens, output: outputTokens, cached: cachedTokens, reasoning: 0, written: outputTokens, total: inputTokens + outputTokens },
|
|
250
279
|
aiCredits: calcClaudeCredits({ input: inputTokens, output: outputTokens }, model),
|
|
251
280
|
durationMs: Number(getAttr(attrs, 'interaction.duration_ms')
|
|
252
281
|
?? (nanoToMs(span.endTimeUnixNano) - nanoToMs(span.startTimeUnixNano))),
|
|
@@ -283,6 +312,27 @@ function processSpans(spans, sessionId, projectId, workingDir) {
|
|
|
283
312
|
const outputTokens = Number(getAttr(attrs, 'output_tokens') ?? 0);
|
|
284
313
|
const cachedTokens = Number(getAttr(attrs, 'cache_read_tokens') ?? 0);
|
|
285
314
|
const model = getStringAttr(attrs, 'model', 'gen_ai.request.model');
|
|
315
|
+
const claudeSessionId = getStringAttr(attrs, 'session.id') ?? sessionId;
|
|
316
|
+
const claudePromptId = getStringAttr(attrs, 'prompt.id');
|
|
317
|
+
// ── Hook-authoritative path ──────────────────────────────────────────
|
|
318
|
+
// Every iteration of Claude's agentic loop emits one of these spans. Keying on
|
|
319
|
+
// session_id + prompt_id (rather than the OTLP traceId) is what makes them
|
|
320
|
+
// accumulate onto the correct turn across a multi-prompt session.
|
|
321
|
+
if (isHookTracked(claudeSessionId)) {
|
|
322
|
+
applyClaudeUsage({
|
|
323
|
+
sessionId: claudeSessionId,
|
|
324
|
+
promptId: claudePromptId,
|
|
325
|
+
delta: {
|
|
326
|
+
input: inputTokens,
|
|
327
|
+
output: outputTokens,
|
|
328
|
+
cached: cachedTokens,
|
|
329
|
+
written: outputTokens,
|
|
330
|
+
credits: calcClaudeCredits({ input: inputTokens, output: outputTokens }, model),
|
|
331
|
+
model,
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
286
336
|
const entry = claudeInteractionEntries.get(traceId);
|
|
287
337
|
if (entry) {
|
|
288
338
|
entry.tokens = {
|
package/dist/setup.js
CHANGED
|
@@ -89,6 +89,119 @@ function vscodeEnvBlock(port) {
|
|
|
89
89
|
[OTEL_LOG_RESPONSES_KEY]: '1',
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
|
+
// ── Claude Code hooks ─────────────────────────────────────────────────────────
|
|
93
|
+
// OTLP alone can't correlate a multi-prompt Claude session (its logs key on prompt.id,
|
|
94
|
+
// its spans key on OTLP traceId, and neither is always present). Hooks give us the
|
|
95
|
+
// ordered turn/tool lifecycle; OTLP still supplies the token/model/cost numbers.
|
|
96
|
+
/** Events the tracer subscribes to, and which of them support a matcher. */
|
|
97
|
+
const CLAUDE_HOOK_EVENTS = [
|
|
98
|
+
{ event: 'SessionStart', matcher: undefined },
|
|
99
|
+
{ event: 'UserPromptSubmit', matcher: undefined },
|
|
100
|
+
{ event: 'PreToolUse', matcher: '.*' },
|
|
101
|
+
{ event: 'PostToolUse', matcher: '.*' },
|
|
102
|
+
{ event: 'PostToolUseFailure', matcher: '.*' },
|
|
103
|
+
{ event: 'Stop', matcher: undefined },
|
|
104
|
+
{ event: 'StopFailure', matcher: undefined },
|
|
105
|
+
{ event: 'SessionEnd', matcher: undefined },
|
|
106
|
+
];
|
|
107
|
+
export function claudeHookUrl(port) {
|
|
108
|
+
return `http://localhost:${port}/claude/hook`;
|
|
109
|
+
}
|
|
110
|
+
function getClaudeSettingsPath() {
|
|
111
|
+
return path.join(os.homedir(), '.claude', 'settings.json');
|
|
112
|
+
}
|
|
113
|
+
/** Our handler is identified by its URL path so we can update the port in place. */
|
|
114
|
+
function isTracerHandler(handler) {
|
|
115
|
+
return handler?.type === 'http' && typeof handler.url === 'string' && handler.url.includes('/claude/hook');
|
|
116
|
+
}
|
|
117
|
+
function tracerHandler(port) {
|
|
118
|
+
return {
|
|
119
|
+
type: 'http',
|
|
120
|
+
url: claudeHookUrl(port),
|
|
121
|
+
// Short timeout: a hook that stalls must never hold up the user's session. A
|
|
122
|
+
// timed-out http hook is cancelled and renders no decision, which is what we want.
|
|
123
|
+
timeout: 5,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Merge the tracer's hooks into Claude's settings.
|
|
128
|
+
*
|
|
129
|
+
* Merging (never replacing) is mandatory — users and plugins routinely register their
|
|
130
|
+
* own handlers on these same events, and clobbering them would silently break unrelated
|
|
131
|
+
* tooling. We only ever add, update, or leave alone our own `/claude/hook` handler.
|
|
132
|
+
*/
|
|
133
|
+
export function patchClaudeSettings(settingsPath, port) {
|
|
134
|
+
let settings = {};
|
|
135
|
+
if (fs.existsSync(settingsPath)) {
|
|
136
|
+
const raw = fs.readFileSync(settingsPath, 'utf8').trim();
|
|
137
|
+
if (raw) {
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(raw);
|
|
140
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
141
|
+
return { action: 'skipped', reason: '~/.claude/settings.json is not a JSON object' };
|
|
142
|
+
}
|
|
143
|
+
settings = parsed;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Never overwrite a file we can't understand — the user would lose their config.
|
|
147
|
+
return { action: 'skipped', reason: 'could not parse ~/.claude/settings.json' };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Everything below treats the user's file as untrusted: it is hand-edited, shared
|
|
152
|
+
// between tools, and losing part of it would silently break their setup. Anything we
|
|
153
|
+
// don't recognise is left exactly as found.
|
|
154
|
+
const rawHooks = settings.hooks;
|
|
155
|
+
if (rawHooks !== undefined && (typeof rawHooks !== 'object' || rawHooks === null || Array.isArray(rawHooks))) {
|
|
156
|
+
return { action: 'skipped', reason: '"hooks" in ~/.claude/settings.json is not an object' };
|
|
157
|
+
}
|
|
158
|
+
const hooks = (rawHooks ?? {});
|
|
159
|
+
let added = false;
|
|
160
|
+
let updated = false;
|
|
161
|
+
for (const { event, matcher } of CLAUDE_HOOK_EVENTS) {
|
|
162
|
+
const rawGroups = hooks[event];
|
|
163
|
+
// An unrecognised shape for this event is left untouched rather than replaced —
|
|
164
|
+
// overwriting it would discard whatever the user or a plugin configured there.
|
|
165
|
+
if (rawGroups !== undefined && !Array.isArray(rawGroups))
|
|
166
|
+
continue;
|
|
167
|
+
const groups = (rawGroups ?? []);
|
|
168
|
+
const desired = tracerHandler(port);
|
|
169
|
+
// Only well-formed groups are candidates for merging into.
|
|
170
|
+
const usable = groups.filter((g) => !!g && typeof g === 'object' && !Array.isArray(g));
|
|
171
|
+
// Find our handler wherever it already lives in this event's groups.
|
|
172
|
+
const ownerGroup = usable.find(g => Array.isArray(g.hooks) && g.hooks.some(h => !!h && isTracerHandler(h)));
|
|
173
|
+
if (ownerGroup) {
|
|
174
|
+
const handlers = ownerGroup.hooks;
|
|
175
|
+
const index = handlers.findIndex(h => !!h && isTracerHandler(h));
|
|
176
|
+
if (handlers[index].url !== desired.url || handlers[index].timeout !== desired.timeout) {
|
|
177
|
+
handlers[index] = { ...handlers[index], ...desired };
|
|
178
|
+
updated = true;
|
|
179
|
+
}
|
|
180
|
+
if (matcher !== undefined && ownerGroup.matcher !== matcher) {
|
|
181
|
+
ownerGroup.matcher = matcher;
|
|
182
|
+
updated = true;
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Reuse an existing group with the same matcher so we don't fragment the config.
|
|
187
|
+
const sameMatcher = usable.find(g => (g.matcher ?? undefined) === matcher);
|
|
188
|
+
if (sameMatcher) {
|
|
189
|
+
sameMatcher.hooks = [...(Array.isArray(sameMatcher.hooks) ? sameMatcher.hooks : []), desired];
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const group = matcher === undefined ? { hooks: [desired] } : { matcher, hooks: [desired] };
|
|
193
|
+
groups.push(group);
|
|
194
|
+
}
|
|
195
|
+
hooks[event] = groups;
|
|
196
|
+
added = true;
|
|
197
|
+
}
|
|
198
|
+
if (!added && !updated)
|
|
199
|
+
return { action: 'already_set' };
|
|
200
|
+
settings.hooks = hooks;
|
|
201
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
202
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
203
|
+
return { action: added ? 'added' : 'updated' };
|
|
204
|
+
}
|
|
92
205
|
// ── Detection helpers ─────────────────────────────────────────────────────────
|
|
93
206
|
function detectCopilotCli() {
|
|
94
207
|
try {
|
|
@@ -265,7 +378,27 @@ export function runSetup(port, silent = false) {
|
|
|
265
378
|
console.log(`\n${WARN} VS Code settings: ${result.reason}`);
|
|
266
379
|
}
|
|
267
380
|
}
|
|
268
|
-
// 5.
|
|
381
|
+
// 5. Patch Claude Code hooks (turn/tool lifecycle — OTLP can't correlate multi-prompt sessions)
|
|
382
|
+
const claudeSettingsPath = getClaudeSettingsPath();
|
|
383
|
+
const claudeResult = patchClaudeSettings(claudeSettingsPath, port);
|
|
384
|
+
if (claudeResult.action === 'added') {
|
|
385
|
+
console.log(`\n${CHECK} Claude Code hooks installed`);
|
|
386
|
+
console.log(` ${ARROW} ~/.claude/settings.json → ${claudeHookUrl(port)}`);
|
|
387
|
+
console.log(` ${ARROW} Captures every prompt, tool call and turn (merged with your existing hooks)`);
|
|
388
|
+
console.log(` ${ARROW} Restart Claude Code to apply`);
|
|
389
|
+
}
|
|
390
|
+
else if (claudeResult.action === 'updated') {
|
|
391
|
+
console.log(`\n${CHECK} Claude Code hooks updated`);
|
|
392
|
+
console.log(` ${ARROW} Endpoint now ${claudeHookUrl(port)}`);
|
|
393
|
+
console.log(` ${ARROW} Restart Claude Code to apply`);
|
|
394
|
+
}
|
|
395
|
+
else if (claudeResult.action === 'already_set') {
|
|
396
|
+
console.log(`\n${CHECK} Claude Code hooks: already configured`);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
console.log(`\n${WARN} Claude Code hooks: ${claudeResult.reason}`);
|
|
400
|
+
}
|
|
401
|
+
// 6. Apply env vars to the current process so the OTLP receiver works immediately
|
|
269
402
|
process.env[OTEL_ENDPOINT_KEY] = `http://localhost:${port}`;
|
|
270
403
|
process.env[OTEL_CONTENT_KEY] = 'true';
|
|
271
404
|
process.env[OTEL_ENABLED_KEY] = 'true';
|
|
@@ -276,7 +409,7 @@ export function runSetup(port, silent = false) {
|
|
|
276
409
|
process.env[OTEL_PROTOCOL_KEY] = 'http/json';
|
|
277
410
|
process.env[OTEL_LOG_PROMPTS_KEY] = '1';
|
|
278
411
|
process.env[OTEL_LOG_RESPONSES_KEY] = '1';
|
|
279
|
-
//
|
|
412
|
+
// 7. Summary
|
|
280
413
|
if (!silent) {
|
|
281
414
|
const profileBase = profilePath ? path.basename(profilePath) : '.zshrc';
|
|
282
415
|
console.log('\n────────────────────────────────────────────────');
|
package/dist/webServer.js
CHANGED
|
@@ -7,6 +7,7 @@ import { execSync } from 'child_process';
|
|
|
7
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
|
+
import { registerClaudeHookRoutes } from './claudeHooks.js';
|
|
10
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
export function startWebServer(port = 4747, sessionId, projectId) {
|
|
12
13
|
const app = express();
|
|
@@ -17,6 +18,8 @@ export function startWebServer(port = 4747, sessionId, projectId) {
|
|
|
17
18
|
// Register OTLP receiver routes
|
|
18
19
|
app.use(express.json({ limit: '10mb' }));
|
|
19
20
|
registerOtlpRoutes(app, sessionId ?? 'default', projectId);
|
|
21
|
+
// Register Claude Code hook receiver (turn/tool lifecycle; OTLP supplies token usage)
|
|
22
|
+
registerClaudeHookRoutes(app);
|
|
20
23
|
// API
|
|
21
24
|
app.get('/api/traces', (req, res) => {
|
|
22
25
|
const sid = req.query.sessionId || undefined; // undefined = all sessions
|
|
@@ -81,8 +84,16 @@ ${prompt.trim()}`;
|
|
|
81
84
|
return res.json(null);
|
|
82
85
|
res.json(getSessionSummary(sid));
|
|
83
86
|
});
|
|
84
|
-
app.get('/api/dashboard', (
|
|
85
|
-
|
|
87
|
+
app.get('/api/dashboard', (req, res) => {
|
|
88
|
+
const pageParam = req.query.page;
|
|
89
|
+
const pageSizeParam = req.query.pageSize;
|
|
90
|
+
const page = pageParam === undefined ? 1 : Number(pageParam);
|
|
91
|
+
const pageSize = pageSizeParam === undefined ? 12 : Number(pageSizeParam);
|
|
92
|
+
if (!Number.isInteger(page) || page < 1 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 100) {
|
|
93
|
+
res.status(400).json({ error: 'page must be a positive integer and pageSize must be an integer between 1 and 100' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
res.json(getDashboard(page, pageSize));
|
|
86
97
|
});
|
|
87
98
|
// Project registration — CLI registers its local path for a project
|
|
88
99
|
app.post('/api/projects', (req, res) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-tracer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
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",
|
package/web/index.html
CHANGED
|
@@ -279,6 +279,10 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
279
279
|
.dash-empty { padding: 60px 24px; text-align: center; color: #8b949e; }
|
|
280
280
|
.dash-empty p { margin-top: 8px; font-size: 12px; }
|
|
281
281
|
.dash-empty code { background: #21262d; padding: 2px 6px; border-radius: 4px; font-size: 11px; color: #79c0ff; }
|
|
282
|
+
.dash-pagination { display: flex; justify-content: center; align-items: center; gap: 12px; padding: 0 24px 24px; color: #8b949e; font-size: 12px; }
|
|
283
|
+
.dash-pagination button { background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; cursor: pointer; padding: 6px 10px; }
|
|
284
|
+
.dash-pagination button:hover:not(:disabled) { border-color: #58a6ff; color: #58a6ff; }
|
|
285
|
+
.dash-pagination button:disabled { cursor: not-allowed; opacity: 0.5; }
|
|
282
286
|
</style>
|
|
283
287
|
</head>
|
|
284
288
|
<body>
|
|
@@ -308,6 +312,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
308
312
|
<div class="dash-totals" id="dash-totals"></div>
|
|
309
313
|
<div class="dash-section-title">Projects</div>
|
|
310
314
|
<div id="dash-projects" class="dash-projects"></div>
|
|
315
|
+
<div id="dash-pagination" class="dash-pagination"></div>
|
|
311
316
|
</div>
|
|
312
317
|
|
|
313
318
|
<!-- ═══════════════ LIVE TRACER PAGE ═══════════════ -->
|
|
@@ -384,6 +389,8 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
384
389
|
let currentProjectId = null;
|
|
385
390
|
let liveSocket = null;
|
|
386
391
|
let dashboardRefreshPending = false;
|
|
392
|
+
let dashboardPage = 1;
|
|
393
|
+
const dashboardPageSize = 12;
|
|
387
394
|
|
|
388
395
|
// Main socket listens for all trace events — refreshes dashboard in real-time
|
|
389
396
|
// (regardless of active page, so it's fresh when you switch back)
|
|
@@ -464,8 +471,10 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
464
471
|
|
|
465
472
|
async function loadDashboard() {
|
|
466
473
|
try {
|
|
467
|
-
const res = await fetch(
|
|
474
|
+
const res = await fetch(`/api/dashboard?page=${dashboardPage}&pageSize=${dashboardPageSize}`);
|
|
475
|
+
if (!res.ok) throw new Error(`Dashboard request failed: ${res.status}`);
|
|
468
476
|
const data = await res.json();
|
|
477
|
+
dashboardPage = data.pagination.page;
|
|
469
478
|
renderDashboard(data);
|
|
470
479
|
} catch (e) {
|
|
471
480
|
console.error('Dashboard load failed:', e);
|
|
@@ -494,6 +503,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
494
503
|
`;
|
|
495
504
|
|
|
496
505
|
const projectsEl = document.getElementById('dash-projects');
|
|
506
|
+
const paginationEl = document.getElementById('dash-pagination');
|
|
497
507
|
if (!data.projects.length) {
|
|
498
508
|
projectsEl.innerHTML = `
|
|
499
509
|
<div class="dash-empty">
|
|
@@ -502,6 +512,7 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
502
512
|
<p>Start the tracer with a project path:</p>
|
|
503
513
|
<p style="margin-top:8px"><code>copilot-tracer --project-path /path/to/repo</code></p>
|
|
504
514
|
</div>`;
|
|
515
|
+
paginationEl.innerHTML = '';
|
|
505
516
|
return;
|
|
506
517
|
}
|
|
507
518
|
|
|
@@ -532,6 +543,18 @@ Reply with ONLY the rewritten prompt. No explanation, no preamble.`;
|
|
|
532
543
|
<div class="dash-project-last-active">Last active: ${lastActive}</div>
|
|
533
544
|
</div>`;
|
|
534
545
|
}).join('');
|
|
546
|
+
|
|
547
|
+
const { page, totalPages, totalProjects } = data.pagination;
|
|
548
|
+
paginationEl.innerHTML = totalPages > 1 ? `
|
|
549
|
+
<button type="button" onclick="changeDashboardPage(-1)" ${page === 1 ? 'disabled' : ''}>Previous</button>
|
|
550
|
+
<span>Page ${page} of ${totalPages} (${totalProjects} projects)</span>
|
|
551
|
+
<button type="button" onclick="changeDashboardPage(1)" ${page === totalPages ? 'disabled' : ''}>Next</button>
|
|
552
|
+
` : `<span>${totalProjects} project${totalProjects === 1 ? '' : 's'}</span>`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function changeDashboardPage(direction) {
|
|
556
|
+
dashboardPage += direction;
|
|
557
|
+
loadDashboard();
|
|
535
558
|
}
|
|
536
559
|
|
|
537
560
|
// ── Live Tracer (Socket.io) ────────────────────
|