copilot-metrics 0.1.0

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/src/otel.js ADDED
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ function attrsToObject(attrs) {
4
+ if (!attrs) return {};
5
+ if (!Array.isArray(attrs)) return attrs;
6
+ const out = {};
7
+ for (const attr of attrs) {
8
+ const value = attr.value;
9
+ if (value && typeof value === 'object') {
10
+ out[attr.key] = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue ?? value.arrayValue;
11
+ } else {
12
+ out[attr.key] = value;
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+
18
+ function pick(attrs, keys) {
19
+ for (const key of keys) {
20
+ const value = attrs[key];
21
+ if (value !== undefined && value !== null && value !== '') return value;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function number(attrs, keys) {
27
+ const value = pick(attrs, keys);
28
+ const parsed = Number(value);
29
+ return Number.isFinite(parsed) ? parsed : 0;
30
+ }
31
+
32
+ function flattenSpans(payload) {
33
+ if (!payload || typeof payload !== 'object') return [];
34
+ if (payload.name || payload.attributes || payload.spanId) return [payload];
35
+ const spans = [];
36
+ for (const resourceSpan of payload.resourceSpans || []) {
37
+ const resourceAttrs = attrsToObject(resourceSpan.resource && resourceSpan.resource.attributes);
38
+ for (const scopeSpan of resourceSpan.scopeSpans || []) {
39
+ for (const span of scopeSpan.spans || []) {
40
+ spans.push({ ...span, resourceAttributes: resourceAttrs });
41
+ }
42
+ }
43
+ }
44
+ return spans;
45
+ }
46
+
47
+ function classifySpan(span) {
48
+ const attrs = attrsToObject(span.attributes);
49
+ const operation = String(pick(attrs, ['gen_ai.operation.name', 'llm.operation']) || '').toLowerCase();
50
+ const name = String(span.name || '').toLowerCase();
51
+ const hasTokens = number(attrs, [
52
+ 'gen_ai.usage.input_tokens',
53
+ 'llm.usage.prompt_tokens',
54
+ 'gen_ai.usage.output_tokens',
55
+ 'llm.usage.completion_tokens',
56
+ ]) > 0;
57
+
58
+ if (operation.includes('agent') || operation.includes('tool') || name.includes('agent') || name.includes('tool')) {
59
+ return 'non_billable';
60
+ }
61
+ if (hasTokens || operation.includes('chat') || operation.includes('completion') || operation.includes('generate')) {
62
+ return 'llm';
63
+ }
64
+ return 'other';
65
+ }
66
+
67
+ function normalizeSpan(span, source, rawLine) {
68
+ const attrs = attrsToObject(span.attributes);
69
+ const resourceAttrs = attrsToObject(span.resourceAttributes);
70
+ const type = classifySpan(span);
71
+ if (type !== 'llm') return null;
72
+
73
+ return {
74
+ raw_line: rawLine,
75
+ span_id: span.spanId || span.span_id || null,
76
+ trace_id: span.traceId || span.trace_id || null,
77
+ parent_span_id: span.parentSpanId || span.parent_span_id || null,
78
+ timestamp: span.startTimeUnixNano || span.start_time || attrs['timestamp'] || null,
79
+ surface: source,
80
+ conversation_id: pick(attrs, ['gen_ai.conversation.id', 'conversation.id', 'copilot.conversation.id']),
81
+ session_id: pick(attrs, ['session.id', 'copilot.session.id']),
82
+ requested_model: pick(attrs, ['gen_ai.request.model', 'llm.request.model', 'llm.model_name']),
83
+ resolved_model: pick(attrs, ['gen_ai.response.model', 'llm.response.model', 'model']),
84
+ repo: pick(attrs, ['vcs.repository.name', 'git.repository', 'repo']) || pick(resourceAttrs, ['vcs.repository.name', 'service.name']),
85
+ branch: pick(attrs, ['vcs.branch.name', 'git.branch', 'branch']),
86
+ cwd: pick(attrs, ['process.command_line.cwd', 'cwd', 'working_directory']),
87
+ commit_sha: pick(attrs, ['vcs.revision', 'git.commit', 'commit']),
88
+ task_hint: pick(attrs, ['task_hint', 'task.hint', 'copilot.task.hint', 'title']),
89
+ prompt_preview: pick(attrs, ['prompt_preview', 'prompt.preview']),
90
+ input_tokens: number(attrs, ['gen_ai.usage.input_tokens', 'llm.usage.prompt_tokens', 'input_tokens', 'prompt_tokens']),
91
+ output_tokens: number(attrs, ['gen_ai.usage.output_tokens', 'llm.usage.completion_tokens', 'output_tokens', 'completion_tokens']),
92
+ cache_read_tokens: number(attrs, ['gen_ai.usage.cache_read_input_tokens', 'gen_ai.usage.cached_input_tokens', 'cache_read_tokens']),
93
+ cache_creation_tokens: number(attrs, ['gen_ai.usage.cache_creation_input_tokens', 'gen_ai.usage.cache_write_input_tokens', 'cache_creation_tokens']),
94
+ reasoning_tokens: number(attrs, ['gen_ai.usage.reasoning_tokens', 'reasoning_tokens']),
95
+ warnings: [],
96
+ };
97
+ }
98
+
99
+ function normalizePayload(payload, source, rawLine) {
100
+ return flattenSpans(payload)
101
+ .map((span) => normalizeSpan(span, source, rawLine))
102
+ .filter(Boolean);
103
+ }
104
+
105
+ function normalizeHookEvent(payload, source, rawLine) {
106
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
107
+ return {
108
+ raw_line: rawLine,
109
+ event: payload.event || null,
110
+ session_id: payload.session_id || payload.sessionId || null,
111
+ cwd: payload.cwd || null,
112
+ repo: payload.repo || payload.repository || null,
113
+ branch: payload.branch || payload.gitBranch || null,
114
+ task_hint: payload.task_hint || payload.taskHint || null,
115
+ labels: Array.isArray(payload.labels) ? payload.labels : [],
116
+ payload,
117
+ };
118
+ }
119
+
120
+ module.exports = {
121
+ attrsToObject,
122
+ flattenSpans,
123
+ classifySpan,
124
+ normalizePayload,
125
+ normalizeHookEvent,
126
+ };
package/src/paths.js ADDED
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+
6
+ function defaultDataHome(env = process.env, platform = process.platform) {
7
+ if (env.COPILOT_METRICS_HOME) return path.resolve(env.COPILOT_METRICS_HOME);
8
+
9
+ if (platform === 'win32') {
10
+ const base = env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
11
+ return path.join(base, 'copilot-metrics');
12
+ }
13
+
14
+ if (platform === 'darwin') {
15
+ return path.join(os.homedir(), 'Library', 'Application Support', 'copilot-metrics');
16
+ }
17
+
18
+ const xdg = env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
19
+ return path.join(xdg, 'copilot-metrics');
20
+ }
21
+
22
+ function resolvePaths(options = {}) {
23
+ const env = options.env || process.env;
24
+ const cwd = path.resolve(options.cwd || process.cwd());
25
+ const home = path.resolve(options.home || defaultDataHome(env, options.platform || process.platform));
26
+ const telemetryDir = path.join(home, 'telemetry');
27
+ const hooksDir = path.join(home, 'hooks');
28
+ const storeDir = path.join(home, 'store');
29
+ const skillsDir = path.join(home, 'skills');
30
+ const copilotHome = env.COPILOT_HOME || path.join(os.homedir(), '.copilot');
31
+
32
+ return {
33
+ home,
34
+ telemetryDir,
35
+ hooksDir,
36
+ storeDir,
37
+ skillsDir,
38
+ vscodeOtelJsonl: path.join(telemetryDir, 'vscode-copilot-otel.jsonl'),
39
+ copilotCliOtelJsonl: path.join(telemetryDir, 'copilot-cli-otel.jsonl'),
40
+ hookEventsJsonl: path.join(hooksDir, 'copilot-hooks.jsonl'),
41
+ usageDb: path.join(storeDir, 'copilot-metrics.sqlite'),
42
+ configJson: path.join(home, 'config.json'),
43
+ localHookConfig: path.join(cwd, '.github', 'hooks', 'copilot-metrics.json'),
44
+ globalHookConfig: path.join(copilotHome, 'settings.json'),
45
+ };
46
+ }
47
+
48
+ module.exports = {
49
+ defaultDataHome,
50
+ resolvePaths,
51
+ };
package/src/pricing.js ADDED
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ const PRICING_VERSION = 'github-copilot-2026-06-01';
4
+
5
+ // USD per 1M tokens. Source: GitHub Copilot models and pricing docs, checked 2026-05-30.
6
+ const MODEL_PRICES = {
7
+ 'gpt-4.1': { input: 2.00, cacheRead: 0.50, cacheWrite: 0, output: 8.00 },
8
+ 'gpt-5 mini': { input: 0.25, cacheRead: 0.025, cacheWrite: 0, output: 2.00 },
9
+ 'gpt-5-mini': { input: 0.25, cacheRead: 0.025, cacheWrite: 0, output: 2.00 },
10
+ 'gpt-5.2': { input: 1.75, cacheRead: 0.175, cacheWrite: 0, output: 14.00 },
11
+ 'gpt-5.2-codex': { input: 1.75, cacheRead: 0.175, cacheWrite: 0, output: 14.00 },
12
+ 'gpt-5.3-codex': { input: 1.75, cacheRead: 0.175, cacheWrite: 0, output: 14.00 },
13
+ 'gpt-5.4': { input: 2.50, cacheRead: 0.25, cacheWrite: 0, output: 15.00 },
14
+ 'gpt-5.4 mini': { input: 0.75, cacheRead: 0.075, cacheWrite: 0, output: 4.50 },
15
+ 'gpt-5.4-mini': { input: 0.75, cacheRead: 0.075, cacheWrite: 0, output: 4.50 },
16
+ 'gpt-5.4 nano': { input: 0.20, cacheRead: 0.02, cacheWrite: 0, output: 1.25 },
17
+ 'gpt-5.4-nano': { input: 0.20, cacheRead: 0.02, cacheWrite: 0, output: 1.25 },
18
+ 'gpt-5.5': { input: 5.00, cacheRead: 0.50, cacheWrite: 0, output: 30.00 },
19
+ 'claude haiku 4.5': { input: 1.00, cacheRead: 0.10, cacheWrite: 1.25, output: 5.00 },
20
+ 'claude sonnet 4': { input: 3.00, cacheRead: 0.30, cacheWrite: 3.75, output: 15.00 },
21
+ 'claude sonnet 4.5': { input: 3.00, cacheRead: 0.30, cacheWrite: 3.75, output: 15.00 },
22
+ 'claude sonnet 4.6': { input: 3.00, cacheRead: 0.30, cacheWrite: 3.75, output: 15.00 },
23
+ 'claude opus 4.5': { input: 5.00, cacheRead: 0.50, cacheWrite: 6.25, output: 25.00 },
24
+ 'claude opus 4.6': { input: 5.00, cacheRead: 0.50, cacheWrite: 6.25, output: 25.00 },
25
+ 'claude opus 4.7': { input: 5.00, cacheRead: 0.50, cacheWrite: 6.25, output: 25.00 },
26
+ 'claude opus 4.8': { input: 5.00, cacheRead: 0.50, cacheWrite: 6.25, output: 25.00 },
27
+ 'gemini 2.5 pro': { input: 1.25, cacheRead: 0.125, cacheWrite: 0, output: 10.00 },
28
+ 'gemini 3 flash': { input: 0.50, cacheRead: 0.05, cacheWrite: 0, output: 3.00 },
29
+ 'gemini 3.1 pro': { input: 2.00, cacheRead: 0.20, cacheWrite: 0, output: 12.00 },
30
+ 'gemini 3.5 flash': { input: 1.50, cacheRead: 0.15, cacheWrite: 0, output: 9.00 },
31
+ 'raptor mini': { input: 0.25, cacheRead: 0.025, cacheWrite: 0, output: 2.00 },
32
+ };
33
+
34
+ function normalizeModelName(model) {
35
+ return String(model || '').trim().toLowerCase();
36
+ }
37
+
38
+ function estimateCost(record) {
39
+ const model = normalizeModelName(record.resolved_model || record.requested_model);
40
+ const price = MODEL_PRICES[model];
41
+ if (!model) {
42
+ return { estimated_usd: null, estimated_ai_credits: null, warning: 'missing_model' };
43
+ }
44
+ if (!price) {
45
+ return { estimated_usd: null, estimated_ai_credits: null, warning: `unknown_model:${model}` };
46
+ }
47
+
48
+ const usd = (
49
+ (record.input_tokens / 1_000_000) * price.input
50
+ + (record.output_tokens / 1_000_000) * price.output
51
+ + (record.cache_read_tokens / 1_000_000) * price.cacheRead
52
+ + (record.cache_creation_tokens / 1_000_000) * price.cacheWrite
53
+ );
54
+
55
+ return {
56
+ estimated_usd: Number(usd.toFixed(8)),
57
+ estimated_ai_credits: Number((usd / 0.01).toFixed(6)),
58
+ warning: null,
59
+ };
60
+ }
61
+
62
+ module.exports = {
63
+ PRICING_VERSION,
64
+ MODEL_PRICES,
65
+ estimateCost,
66
+ };
package/src/reports.js ADDED
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+
3
+ const { queryRows } = require('./sqlite-store');
4
+ const { canonicalLabel } = require('./label-extractors');
5
+
6
+ function n(value) {
7
+ return Number(value || 0);
8
+ }
9
+
10
+ function estimateLabel(rows) {
11
+ return rows.find((row) => row.estimate_label)?.estimate_label || 'estimate:unknown';
12
+ }
13
+
14
+ function formatNumber(value) {
15
+ return n(value).toLocaleString('en-US');
16
+ }
17
+
18
+ function formatCredits(value) {
19
+ return n(value).toFixed(6);
20
+ }
21
+
22
+ function table(headers, rows) {
23
+ const widths = headers.map((header, index) => Math.max(
24
+ header.length,
25
+ ...rows.map((row) => String(row[index] ?? '').length),
26
+ ));
27
+ const line = headers.map((header, index) => header.padEnd(widths[index])).join(' ');
28
+ const sep = widths.map((width) => '-'.repeat(width)).join(' ');
29
+ const body = rows.map((row) => row.map((cell, index) => String(cell ?? '').padEnd(widths[index])).join(' '));
30
+ return [line, sep, ...body].join('\n');
31
+ }
32
+
33
+ async function labelOverview(dbPath) {
34
+ return queryRows(dbPath, `
35
+ SELECT
36
+ labels.label,
37
+ (SELECT COUNT(DISTINCT session_id) FROM label_evidence WHERE label = labels.label) AS sessions,
38
+ (SELECT COUNT(*) FROM label_evidence WHERE label = labels.label) AS evidence_count,
39
+ (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) AS usage_records,
40
+ COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS input_tokens,
41
+ COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS output_tokens,
42
+ COALESCE((SELECT SUM(cache_read_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS cache_read_tokens,
43
+ COALESCE((SELECT SUM(cache_creation_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS cache_creation_tokens,
44
+ COALESCE((SELECT SUM(reasoning_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS reasoning_tokens,
45
+ COALESCE((SELECT SUM(COALESCE(estimated_ai_credits, 0)) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS estimated_ai_credits,
46
+ (SELECT MIN(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS first_seen,
47
+ (SELECT MAX(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS last_seen,
48
+ (SELECT MAX(estimate_label) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)) AS estimate_label
49
+ FROM (SELECT DISTINCT label FROM label_evidence) labels
50
+ ORDER BY estimated_ai_credits DESC, labels.label`);
51
+ }
52
+
53
+ async function labelSummary(dbPath, label) {
54
+ const rows = await queryRows(dbPath, `
55
+ SELECT
56
+ labels.label,
57
+ (SELECT COUNT(DISTINCT session_id) FROM label_evidence WHERE label = labels.label) AS sessions,
58
+ (SELECT COUNT(*) FROM label_evidence WHERE label = labels.label) AS evidence_count,
59
+ (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) AS usage_records,
60
+ COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS input_tokens,
61
+ COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS output_tokens,
62
+ COALESCE((SELECT SUM(cache_read_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS cache_read_tokens,
63
+ COALESCE((SELECT SUM(cache_creation_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS cache_creation_tokens,
64
+ COALESCE((SELECT SUM(reasoning_tokens) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS reasoning_tokens,
65
+ COALESCE((SELECT SUM(COALESCE(estimated_ai_credits, 0)) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)), 0) AS estimated_ai_credits,
66
+ (SELECT MIN(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS first_seen,
67
+ (SELECT MAX(COALESCE(ur.timestamp, le.timestamp, le.imported_at)) FROM label_evidence le LEFT JOIN usage_records ur ON ur.id = le.usage_record_id WHERE le.label = labels.label) AS last_seen,
68
+ (SELECT MAX(estimate_label) FROM usage_records WHERE id IN (SELECT DISTINCT usage_record_id FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL)) AS estimate_label
69
+ FROM (SELECT DISTINCT label FROM label_evidence WHERE label = ?) labels`, [canonicalLabel(label)]);
70
+ return rows[0] || null;
71
+ }
72
+
73
+ async function labelDetails(dbPath, label) {
74
+ return queryRows(dbPath, `
75
+ SELECT
76
+ le.label,
77
+ le.source_type,
78
+ le.source_field,
79
+ le.source_value,
80
+ le.confidence,
81
+ le.session_id,
82
+ le.repo,
83
+ le.branch,
84
+ le.cwd,
85
+ ur.id AS usage_record_id,
86
+ ur.surface,
87
+ ur.resolved_model,
88
+ ur.input_tokens,
89
+ ur.output_tokens,
90
+ ur.estimated_ai_credits,
91
+ ur.estimate_label,
92
+ COALESCE(ur.timestamp, le.timestamp, le.imported_at) AS timestamp
93
+ FROM label_evidence le
94
+ LEFT JOIN usage_records ur ON ur.id = le.usage_record_id
95
+ WHERE le.label = ?
96
+ ORDER BY timestamp, le.source_type, le.source_field`, [canonicalLabel(label)]);
97
+ }
98
+
99
+ async function modelReport(dbPath) {
100
+ return queryRows(dbPath, `
101
+ SELECT
102
+ COALESCE(resolved_model, requested_model, 'unknown') AS model,
103
+ COUNT(*) AS usage_records,
104
+ SUM(input_tokens) AS input_tokens,
105
+ SUM(output_tokens) AS output_tokens,
106
+ SUM(cache_read_tokens) AS cache_read_tokens,
107
+ SUM(cache_creation_tokens) AS cache_creation_tokens,
108
+ SUM(reasoning_tokens) AS reasoning_tokens,
109
+ SUM(COALESCE(estimated_ai_credits, 0)) AS estimated_ai_credits,
110
+ MAX(estimate_label) AS estimate_label
111
+ FROM usage_records
112
+ GROUP BY COALESCE(resolved_model, requested_model, 'unknown')
113
+ ORDER BY estimated_ai_credits DESC, model`);
114
+ }
115
+
116
+ async function repoReport(dbPath) {
117
+ return queryRows(dbPath, `
118
+ SELECT
119
+ COALESCE(repo, 'unknown') AS repo,
120
+ COALESCE(cwd, '') AS cwd,
121
+ COUNT(*) AS usage_records,
122
+ COUNT(DISTINCT session_id) AS sessions,
123
+ SUM(input_tokens) AS input_tokens,
124
+ SUM(output_tokens) AS output_tokens,
125
+ SUM(COALESCE(estimated_ai_credits, 0)) AS estimated_ai_credits,
126
+ MAX(estimate_label) AS estimate_label
127
+ FROM usage_records
128
+ GROUP BY COALESCE(repo, 'unknown'), COALESCE(cwd, '')
129
+ ORDER BY estimated_ai_credits DESC, repo, cwd`);
130
+ }
131
+
132
+ async function unattributedReport(dbPath) {
133
+ return queryRows(dbPath, `
134
+ SELECT
135
+ ur.id,
136
+ ur.source,
137
+ ur.surface,
138
+ ur.session_id,
139
+ ur.conversation_id,
140
+ ur.repo,
141
+ ur.branch,
142
+ ur.cwd,
143
+ ur.resolved_model,
144
+ ur.input_tokens,
145
+ ur.output_tokens,
146
+ ur.estimated_ai_credits,
147
+ ur.estimate_label,
148
+ ur.timestamp
149
+ FROM usage_records ur
150
+ WHERE NOT EXISTS (
151
+ SELECT 1 FROM label_evidence le WHERE le.usage_record_id = ur.id
152
+ )
153
+ ORDER BY ur.timestamp, ur.id`);
154
+ }
155
+
156
+ function formatLabels(rows) {
157
+ return [
158
+ table(
159
+ ['Label', 'Sessions', 'Input', 'Output', 'Credits', 'Evidence', 'Last seen'],
160
+ rows.map((row) => [
161
+ row.label,
162
+ row.sessions,
163
+ formatNumber(row.input_tokens),
164
+ formatNumber(row.output_tokens),
165
+ formatCredits(row.estimated_ai_credits),
166
+ row.evidence_count,
167
+ row.last_seen || '',
168
+ ]),
169
+ ),
170
+ '',
171
+ `Costs are estimates (${estimateLabel(rows)}).`,
172
+ ].join('\n');
173
+ }
174
+
175
+ function formatLabelSummary(summary, details = null) {
176
+ if (!summary) return 'No usage found for label.';
177
+ const lines = [
178
+ table(
179
+ ['Label', 'Sessions', 'Input', 'Output', 'Credits', 'Evidence'],
180
+ [[summary.label, summary.sessions, formatNumber(summary.input_tokens), formatNumber(summary.output_tokens), formatCredits(summary.estimated_ai_credits), summary.evidence_count]],
181
+ ),
182
+ ];
183
+ if (details) {
184
+ lines.push('', table(
185
+ ['Source', 'Field', 'Session', 'Model', 'Credits', 'Value'],
186
+ details.map((row) => [
187
+ row.source_type,
188
+ row.source_field,
189
+ row.session_id || '',
190
+ row.resolved_model || '',
191
+ formatCredits(row.estimated_ai_credits),
192
+ row.source_value || '',
193
+ ]),
194
+ ));
195
+ }
196
+ lines.push('', `Costs are estimates (${summary.estimate_label || 'estimate:unknown'}).`);
197
+ return lines.join('\n');
198
+ }
199
+
200
+ function formatModels(rows) {
201
+ return [
202
+ table(
203
+ ['Model', 'Records', 'Input', 'Output', 'Credits'],
204
+ rows.map((row) => [row.model, row.usage_records, formatNumber(row.input_tokens), formatNumber(row.output_tokens), formatCredits(row.estimated_ai_credits)]),
205
+ ),
206
+ '',
207
+ `Costs are estimates (${estimateLabel(rows)}).`,
208
+ ].join('\n');
209
+ }
210
+
211
+ function formatRepos(rows) {
212
+ return [
213
+ table(
214
+ ['Repo', 'CWD', 'Sessions', 'Input', 'Output', 'Credits'],
215
+ rows.map((row) => [row.repo, row.cwd, row.sessions, formatNumber(row.input_tokens), formatNumber(row.output_tokens), formatCredits(row.estimated_ai_credits)]),
216
+ ),
217
+ '',
218
+ `Costs are estimates (${estimateLabel(rows)}).`,
219
+ ].join('\n');
220
+ }
221
+
222
+ function formatUnattributed(rows) {
223
+ return [
224
+ table(
225
+ ['ID', 'Source', 'Session', 'Repo', 'Branch', 'CWD', 'Model', 'Credits'],
226
+ rows.map((row) => [row.id, row.source, row.session_id || '', row.repo || '', row.branch || '', row.cwd || '', row.resolved_model || '', formatCredits(row.estimated_ai_credits)]),
227
+ ),
228
+ '',
229
+ `Costs are estimates (${estimateLabel(rows)}).`,
230
+ ].join('\n');
231
+ }
232
+
233
+ module.exports = {
234
+ labelOverview,
235
+ labelSummary,
236
+ labelDetails,
237
+ modelReport,
238
+ repoReport,
239
+ unattributedReport,
240
+ formatLabels,
241
+ formatLabelSummary,
242
+ formatModels,
243
+ formatRepos,
244
+ formatUnattributed,
245
+ };
package/src/setup.js ADDED
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { resolvePaths } = require('./paths');
6
+
7
+ const HOOK_SURFACES = ['both', 'copilot-cli', 'vscode'];
8
+
9
+ const COPILOT_CLI_HOOK_EVENTS = [
10
+ 'sessionStart',
11
+ 'sessionEnd',
12
+ 'userPromptSubmitted',
13
+ 'preToolUse',
14
+ 'postToolUse',
15
+ 'agentStop',
16
+ 'subagentStop',
17
+ 'errorOccurred',
18
+ ];
19
+
20
+ const VSCODE_HOOK_EVENTS = [
21
+ 'SessionStart',
22
+ 'UserPromptSubmit',
23
+ 'PreToolUse',
24
+ 'PostToolUse',
25
+ 'PreCompact',
26
+ 'SubagentStart',
27
+ 'SubagentStop',
28
+ 'Stop',
29
+ ];
30
+
31
+ function writePrivateFile(file, data) {
32
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
33
+ fs.writeFileSync(file, data, { mode: 0o600 });
34
+ }
35
+
36
+ function shellQuote(value) {
37
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
38
+ }
39
+
40
+ function ensureDataDirs(paths) {
41
+ for (const dir of [paths.home, paths.telemetryDir, paths.hooksDir, paths.storeDir, paths.skillsDir]) {
42
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ }
44
+
45
+ if (!fs.existsSync(paths.configJson)) {
46
+ writePrivateFile(paths.configJson, `${JSON.stringify({
47
+ version: 1,
48
+ contentCapture: false,
49
+ telemetry: {
50
+ vscode: paths.vscodeOtelJsonl,
51
+ copilotCli: paths.copilotCliOtelJsonl,
52
+ },
53
+ sources: {
54
+ vscode: {
55
+ telemetry: paths.vscodeOtelJsonl,
56
+ hooks: paths.hookEventsJsonl,
57
+ },
58
+ copilotCli: {
59
+ telemetry: paths.copilotCliOtelJsonl,
60
+ hooks: paths.hookEventsJsonl,
61
+ },
62
+ },
63
+ labelExtractors: [],
64
+ }, null, 2)}\n`);
65
+ }
66
+ }
67
+
68
+ function vscodeSettings(paths) {
69
+ return {
70
+ 'github.copilot.chat.otel.enabled': true,
71
+ 'github.copilot.chat.otel.exporterType': 'file',
72
+ 'github.copilot.chat.otel.outfile': paths.vscodeOtelJsonl,
73
+ 'github.copilot.chat.otel.captureContent': false,
74
+ };
75
+ }
76
+
77
+ function copilotCliEnvironment(paths) {
78
+ return {
79
+ COPILOT_OTEL_ENABLED: 'true',
80
+ COPILOT_OTEL_EXPORTER_TYPE: 'file',
81
+ COPILOT_OTEL_FILE_EXPORTER_PATH: paths.copilotCliOtelJsonl,
82
+ OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: 'false',
83
+ };
84
+ }
85
+
86
+ function shellExports(env) {
87
+ return Object.entries(env)
88
+ .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`)
89
+ .join('\n');
90
+ }
91
+
92
+ function packageBinCommand(cwd) {
93
+ return path.join(cwd, 'bin', 'copilot-metrics.js');
94
+ }
95
+
96
+ function hookEventsForSurface(surface) {
97
+ if (surface === 'copilot-cli' || surface === 'both') return COPILOT_CLI_HOOK_EVENTS;
98
+ if (surface === 'vscode') return VSCODE_HOOK_EVENTS;
99
+ throw new Error(`Unknown hook surface "${surface}". Use "both", "copilot-cli", or "vscode".`);
100
+ }
101
+
102
+ function hookCommand(command, event, metricsHome) {
103
+ return `COPILOT_METRICS_HOME=${shellQuote(metricsHome)} node ${shellQuote(command)} hook-log --event ${shellQuote(event)} --quiet`;
104
+ }
105
+
106
+ function hookConfig(paths, options = {}) {
107
+ const surface = options.surface || 'both';
108
+ const events = hookEventsForSurface(surface);
109
+ const command = options.command || packageBinCommand(options.cwd || process.cwd());
110
+ const commandHook = (event) => ({
111
+ type: 'command',
112
+ bash: hookCommand(command, event, paths.home),
113
+ command: hookCommand(command, event, paths.home),
114
+ env: {
115
+ COPILOT_METRICS_HOME: paths.home,
116
+ },
117
+ timeout: 10,
118
+ timeoutSec: 10,
119
+ });
120
+ return {
121
+ version: 1,
122
+ hooks: Object.fromEntries(events.map((event) => [
123
+ event,
124
+ [commandHook(event)],
125
+ ])),
126
+ };
127
+ }
128
+
129
+ function hookTarget(paths, scope) {
130
+ if (scope === 'global') return paths.globalHookConfig;
131
+ if (scope === 'local') return paths.localHookConfig;
132
+ throw new Error(`Unknown hook scope "${scope}". Use "local" or "global".`);
133
+ }
134
+
135
+ function mergeGlobalSettingsHooks(settings, hooks) {
136
+ const next = { ...settings };
137
+ const existingHooks = next.hooks || {};
138
+ const mergedHooks = {};
139
+ const isMetricsHook = (hook) => {
140
+ const command = `${hook.bash || ''}\n${hook.powershell || ''}\n${hook.command || ''}`;
141
+ return command.includes('copilot-metrics') && command.includes('hook-log');
142
+ };
143
+
144
+ for (const event of new Set([...Object.keys(existingHooks), ...Object.keys(hooks)])) {
145
+ const existing = Array.isArray(existingHooks[event]) ? existingHooks[event].filter((hook) => !isMetricsHook(hook)) : [];
146
+ const additions = Array.isArray(hooks[event]) ? hooks[event] : [];
147
+ if (existing.length > 0 || additions.length > 0) {
148
+ mergedHooks[event] = [...existing, ...additions];
149
+ }
150
+ }
151
+
152
+ next.hooks = mergedHooks;
153
+ return next;
154
+ }
155
+
156
+ function installHook(paths, options = {}) {
157
+ const scope = options.scope || 'local';
158
+ const target = hookTarget(paths, scope);
159
+ const config = hookConfig(paths, { ...options, scope });
160
+ if (scope === 'global') {
161
+ let settings = {};
162
+ if (fs.existsSync(target)) {
163
+ settings = JSON.parse(fs.readFileSync(target, 'utf8'));
164
+ }
165
+ writePrivateFile(target, `${JSON.stringify(mergeGlobalSettingsHooks(settings, config.hooks), null, 2)}\n`);
166
+ return { target, config };
167
+ }
168
+ writePrivateFile(target, `${JSON.stringify(config, null, 2)}\n`);
169
+ return { target, config };
170
+ }
171
+
172
+ function setupSnapshot(options = {}) {
173
+ const paths = resolvePaths(options);
174
+ return {
175
+ paths,
176
+ vscode: vscodeSettings(paths),
177
+ copilotCli: copilotCliEnvironment(paths),
178
+ hooks: hookConfig(paths, options),
179
+ };
180
+ }
181
+
182
+ module.exports = {
183
+ HOOK_SURFACES,
184
+ COPILOT_CLI_HOOK_EVENTS,
185
+ VSCODE_HOOK_EVENTS,
186
+ ensureDataDirs,
187
+ vscodeSettings,
188
+ copilotCliEnvironment,
189
+ shellExports,
190
+ shellQuote,
191
+ hookConfig,
192
+ hookTarget,
193
+ installHook,
194
+ mergeGlobalSettingsHooks,
195
+ setupSnapshot,
196
+ };