copilot-metrics 0.1.0 → 0.1.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 - 2026-05-31
4
+
5
+ ### Fixed
6
+
7
+ - Reports now import Copilot CLI session-state `events.jsonl` files by default, so token statistics are collected after `init` and hooks install without requiring users to export telemetry environment variables.
8
+ - Copilot session-state imports persist only shutdown usage records, while using prompt-bearing session events in memory for label extraction and context.
9
+ - Hook-only report diagnostics now stay quiet when token-bearing Copilot session-state usage is available.
10
+
11
+ ## 0.1.1 - 2026-05-30
12
+
13
+ ### Added
14
+
15
+ - Setup-once report flow: report commands automatically import configured VS Code, Copilot CLI, and hook JSONL sources before querying.
16
+ - Idempotent import fingerprints so repeated reports or imports do not double-count previously ingested JSONL rows.
17
+ - Complete label token reporting for input, output, cache read, cache creation, and reasoning tokens in human and JSON output.
18
+ - Hook-only label status so attribution evidence from hooks remains visible without implying token-bearing usage.
19
+
20
+ ### Changed
21
+
22
+ - `setup all` now persists the central data directory config, matching `init` for setup-once usage.
23
+ - Hook commands support installed executable shims as well as checkout-local JavaScript entrypoints.
24
+
3
25
  ## 0.1.0 - 2026-05-30
4
26
 
5
27
  First local release candidate for `copilot-metrics`.
package/README.md CHANGED
@@ -9,8 +9,8 @@ Costs are estimates, not official billing records. GitHub billing remains the so
9
9
  From npm:
10
10
 
11
11
  ```bash
12
- npx copilot-metrics@0.1.0 --help
13
- npx copilot-metrics@0.1.0 init
12
+ npx copilot-metrics@0.1.2 --help
13
+ npx copilot-metrics@0.1.2 init
14
14
  ```
15
15
 
16
16
  From this checkout:
@@ -38,24 +38,28 @@ export COPILOT_METRICS_HOME=/path/to/copilot-metrics-data
38
38
  Useful commands:
39
39
 
40
40
  ```bash
41
- npx copilot-metrics@0.1.0 init
42
- npx copilot-metrics@0.1.0 paths --json
41
+ npx copilot-metrics@0.1.2 init
42
+ npx copilot-metrics@0.1.2 paths --json
43
43
  ```
44
44
 
45
45
  ## Configure Telemetry
46
46
 
47
+ For Copilot CLI, `init` plus hooks are enough for local token reporting. Reports import Copilot's local session-state `events.jsonl` files and extract shutdown usage totals without requiring telemetry environment variables.
48
+
47
49
  Print VS Code Insiders Copilot Chat OpenTelemetry settings:
48
50
 
49
51
  ```bash
50
- npx copilot-metrics@0.1.0 setup vscode
52
+ npx copilot-metrics@0.1.2 setup vscode
51
53
  ```
52
54
 
53
55
  Print Copilot CLI OpenTelemetry environment exports:
54
56
 
55
57
  ```bash
56
- npx copilot-metrics@0.1.0 setup copilot-cli
58
+ npx copilot-metrics@0.1.2 setup copilot-cli
57
59
  ```
58
60
 
61
+ This is optional. Use it only when you also want Copilot CLI OTel JSONL output.
62
+
59
63
  Content capture is disabled by default. Do not enable richer prompt capture unless you explicitly accept the privacy tradeoff.
60
64
 
61
65
  ## Configure Hooks
@@ -63,50 +67,53 @@ Content capture is disabled by default. Do not enable richer prompt capture unle
63
67
  Preview repo-local hook config. The default `--surface both` emits the Copilot CLI lower camel case hook format:
64
68
 
65
69
  ```bash
66
- npx copilot-metrics@0.1.0 hooks preview --scope local --surface both
70
+ npx copilot-metrics@0.1.2 hooks preview --scope local --surface both
67
71
  ```
68
72
 
69
73
  Install repo-local or user-global hook config:
70
74
 
71
75
  ```bash
72
- npx copilot-metrics@0.1.0 hooks install --scope local --surface both
73
- npx copilot-metrics@0.1.0 hooks install --scope global --surface both
76
+ npx copilot-metrics@0.1.2 hooks install --scope local --surface both
77
+ npx copilot-metrics@0.1.2 hooks install --scope global --surface both
74
78
  ```
75
79
 
76
80
  Local install writes `.github/hooks/copilot-metrics.json`. Global install updates `~/.copilot/settings.json` idempotently, replacing prior `copilot-metrics` hook entries while preserving other settings and hooks. Use `--surface vscode` for VS Code-only PascalCase events or `--surface copilot-cli` for CLI-native lower camel case events. The hook logger writes redacted JSONL metadata to the central data directory. It extracts Jira-style labels such as `DEMO-12345` from safe metadata and does not store full prompt text by default.
77
81
 
78
82
  ## Import Telemetry
79
83
 
80
- Initialize the local SQLite store and import JSONL files:
84
+ Initialize the local SQLite store and import JSONL files manually:
81
85
 
82
86
  ```bash
83
- npx copilot-metrics@0.1.0 store init
84
- npx copilot-metrics@0.1.0 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
85
- npx copilot-metrics@0.1.0 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
86
- npx copilot-metrics@0.1.0 import --source hooks --file ~/.local/share/copilot-metrics/hooks/copilot-hooks.jsonl
87
+ npx copilot-metrics@0.1.2 store init
88
+ npx copilot-metrics@0.1.2 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
89
+ npx copilot-metrics@0.1.2 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
90
+ npx copilot-metrics@0.1.2 import --source copilot-session --file ~/.copilot/session-state/<session-id>/events.jsonl
91
+ npx copilot-metrics@0.1.2 import --source hooks --file ~/.local/share/copilot-metrics/hooks/copilot-hooks.jsonl
87
92
  ```
88
93
 
89
- Imports persist raw records, normalized LLM usage records, hook events, label evidence, and import warnings.
94
+ Imports persist raw records, normalized LLM usage records, hook events, label evidence, and import warnings. Re-importing the same JSONL rows is idempotent. For Copilot session-state files, only shutdown usage rows are persisted; prompt-bearing session events are used in memory for label extraction and context and are not stored as raw records.
90
95
 
91
96
  ## Reports
92
97
 
93
98
  Run local reports from the SQLite store:
94
99
 
95
100
  ```bash
96
- npx copilot-metrics@0.1.0 report labels
97
- npx copilot-metrics@0.1.0 report label DEMO-12345
98
- npx copilot-metrics@0.1.0 report label DEMO-12345 --detail
99
- npx copilot-metrics@0.1.0 report models
100
- npx copilot-metrics@0.1.0 report repos
101
- npx copilot-metrics@0.1.0 report unattributed
101
+ npx copilot-metrics@0.1.2 report labels
102
+ npx copilot-metrics@0.1.2 report label DEMO-12345
103
+ npx copilot-metrics@0.1.2 report label DEMO-12345 --detail
104
+ npx copilot-metrics@0.1.2 report models
105
+ npx copilot-metrics@0.1.2 report repos
106
+ npx copilot-metrics@0.1.2 report unattributed
102
107
  ```
103
108
 
104
109
  Every report supports `--json`:
105
110
 
106
111
  ```bash
107
- npx copilot-metrics@0.1.0 report labels --json
112
+ npx copilot-metrics@0.1.2 report labels --json
108
113
  ```
109
114
 
115
+ Report commands automatically import newly appended configured VS Code OTel, optional Copilot CLI OTel, Copilot CLI session-state, and hook JSONL files before querying. Label reports include input, output, cache read, cache creation, and reasoning token totals. Labels seen only in hooks remain visible as hook-only evidence with zero usage records, so attribution hints do not imply token-bearing usage.
116
+
110
117
  ## Attribution Model
111
118
 
112
119
  The default extractor finds Jira-style labels such as `DEMO-12345` from safe metadata including hook labels, branch names, cwd/path values, repo metadata, and task hints.
@@ -168,7 +175,7 @@ The manual prompt performs one harmless tool call so Copilot CLI hook execution
168
175
  ## Current Limits
169
176
 
170
177
  - Costs are estimates, not official billing records.
171
- - Official GitHub usage report reconciliation is not included in `0.1.0`.
172
- - Local OTLP collector mode is not included in `0.1.0`.
173
- - Richer prompt/content capture and redaction controls are not included in `0.1.0`.
178
+ - Official GitHub usage report reconciliation is not included in `0.1.2`.
179
+ - Local OTLP collector mode is not included in `0.1.2`.
180
+ - Richer prompt/content capture and redaction controls are not included in `0.1.2`.
174
181
  - Dashboard views are deferred until the CLI/query model proves useful.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-metrics",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Local-first Copilot usage telemetry setup and reporting tools.",
5
5
  "type": "commonjs",
6
6
  "homepage": "https://github.com/nnexai/copilot-metrics#readme",
package/src/cli.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
  } = require('./setup');
13
13
  const { appendHookEvent, readJsonFromStream } = require('./hook-logger');
14
14
  const { initStore } = require('./sqlite-store');
15
- const { ingestFile } = require('./ingest');
15
+ const { autoImportConfiguredSources, ingestFile } = require('./ingest');
16
16
  const { MODEL_PRICES, PRICING_VERSION } = require('./pricing');
17
17
  const {
18
18
  labelOverview,
@@ -34,6 +34,10 @@ function parseFlags(args) {
34
34
  const rest = [];
35
35
  for (let i = 0; i < args.length; i += 1) {
36
36
  const arg = args[i];
37
+ if (arg === '--') {
38
+ rest.push(...args.slice(i + 1));
39
+ break;
40
+ }
37
41
  if (!arg.startsWith('--')) {
38
42
  rest.push(arg);
39
43
  continue;
@@ -72,7 +76,7 @@ Usage:
72
76
  copilot-metrics hooks install [--scope local|global] [--surface both|vscode|copilot-cli] [--json]
73
77
  copilot-metrics hook-log --event <name>
74
78
  copilot-metrics store init [--json]
75
- copilot-metrics import --source vscode|copilot-cli|hooks --file <path> [--json]
79
+ copilot-metrics import --source vscode|copilot-cli|copilot-session|hooks --file <path> [--json]
76
80
  copilot-metrics report labels [--json]
77
81
  copilot-metrics report label <id> [--detail] [--json]
78
82
  copilot-metrics report models [--json]
@@ -112,10 +116,55 @@ function formatCopilotCli(env) {
112
116
  'Export these variables before running Copilot CLI:',
113
117
  shellExports(env),
114
118
  '',
119
+ 'This is optional. Copilot CLI session-state usage is imported by reports without these exports.',
120
+ '',
115
121
  'Content capture is disabled by default.',
116
122
  ].join('\n');
117
123
  }
118
124
 
125
+ function telemetryDiagnostics(importResults) {
126
+ const hookResult = importResults.find((result) => result.source === 'hooks' && result.raw_records > 0);
127
+ if (!hookResult) return [];
128
+ const sessionUsage = importResults.find((result) => result.source === 'copilot-session' && result.raw_records > 0);
129
+ if (sessionUsage) return [];
130
+
131
+ const cliTelemetry = importResults.find((result) => result.source === 'copilot-cli');
132
+ if (!cliTelemetry) return [];
133
+
134
+ if (cliTelemetry.skipped && cliTelemetry.reason === 'missing_file') {
135
+ return [{
136
+ code: 'missing_copilot_cli_otel',
137
+ message: `Hook evidence was found, but no token-bearing Copilot session-state or OTel usage was imported. Check that ${cliTelemetry.file} exists for optional OTel data, or that Copilot session-state files are available under the configured COPILOT_HOME.`,
138
+ }];
139
+ }
140
+
141
+ if (cliTelemetry.raw_records === 0) {
142
+ return [{
143
+ code: 'empty_copilot_cli_otel',
144
+ message: `Hook evidence was found, but Copilot CLI token telemetry is empty at ${cliTelemetry.file}. Run another Copilot session with OTel enabled.`,
145
+ }];
146
+ }
147
+
148
+ if (cliTelemetry.usage_records === 0) {
149
+ return [{
150
+ code: 'no_copilot_cli_usage_records',
151
+ message: `Copilot CLI telemetry was found at ${cliTelemetry.file}, but no token-bearing LLM spans were normalized from it.`,
152
+ }];
153
+ }
154
+
155
+ return [];
156
+ }
157
+
158
+ function appendDiagnostics(output, diagnostics) {
159
+ if (diagnostics.length === 0) return output;
160
+ return [
161
+ output,
162
+ '',
163
+ 'Diagnostics:',
164
+ ...diagnostics.map((diagnostic) => `- ${diagnostic.message}`),
165
+ ].join('\n');
166
+ }
167
+
119
168
  async function main(args, io) {
120
169
  const { flags, rest } = parseFlags(args);
121
170
  const json = flags.json === true;
@@ -213,8 +262,8 @@ async function main(args, io) {
213
262
  : source === 'hooks'
214
263
  ? paths.hookEventsJsonl
215
264
  : null);
216
- if (!['vscode', 'copilot-cli', 'hooks'].includes(source)) {
217
- throw new Error('import requires --source vscode|copilot-cli|hooks');
265
+ if (!['vscode', 'copilot-cli', 'copilot-session', 'hooks'].includes(source)) {
266
+ throw new Error('import requires --source vscode|copilot-cli|copilot-session|hooks');
218
267
  }
219
268
  if (!file) throw new Error('import requires --file <path>');
220
269
  ensureDataDirs(paths);
@@ -236,9 +285,16 @@ async function main(args, io) {
236
285
  }
237
286
 
238
287
  if (command === 'report') {
288
+ ensureDataDirs(paths);
289
+ const imports = await autoImportConfiguredSources(paths, {
290
+ cwd: io.cwd,
291
+ extractors: loadConfiguredExtractors(paths.configJson, io.cwd),
292
+ });
293
+ const diagnostics = telemetryDiagnostics(imports);
294
+
239
295
  if (subcommand === 'labels') {
240
296
  const rows = await labelOverview(paths.usageDb);
241
- writeOutput(io.stdout, json ? { labels: rows } : formatLabels(rows), json);
297
+ writeOutput(io.stdout, json ? { labels: rows, diagnostics } : appendDiagnostics(formatLabels(rows), diagnostics), json);
242
298
  return;
243
299
  }
244
300
  if (subcommand === 'label') {
@@ -247,10 +303,10 @@ async function main(args, io) {
247
303
  const summary = await labelSummary(paths.usageDb, label);
248
304
  if (flags.detail === true) {
249
305
  const details = await labelDetails(paths.usageDb, label);
250
- writeOutput(io.stdout, json ? { label: summary, details } : formatLabelSummary(summary, details), json);
306
+ writeOutput(io.stdout, json ? { label: summary, details, diagnostics } : appendDiagnostics(formatLabelSummary(summary, details), diagnostics), json);
251
307
  return;
252
308
  }
253
- writeOutput(io.stdout, json ? { label: summary } : formatLabelSummary(summary), json);
309
+ writeOutput(io.stdout, json ? { label: summary, diagnostics } : appendDiagnostics(formatLabelSummary(summary), diagnostics), json);
254
310
  return;
255
311
  }
256
312
  if (subcommand === 'models') {
@@ -285,4 +341,5 @@ module.exports = {
285
341
  main,
286
342
  parseFlags,
287
343
  helpText,
344
+ telemetryDiagnostics,
288
345
  };
package/src/ingest.js CHANGED
@@ -1,10 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
3
6
  const { readJsonl } = require('./jsonl');
4
- const { normalizePayload, normalizeHookEvent } = require('./otel');
7
+ const { normalizePayload, normalizeHookEvent, normalizeCopilotSessionEvents } = require('./otel');
5
8
  const { estimateCost, PRICING_VERSION } = require('./pricing');
6
- const { insertImport } = require('./sqlite-store');
9
+ const { existingRawFingerprints, insertImport } = require('./sqlite-store');
7
10
  const { attachUsageLabelEvidence, attachHookLabelEvidence } = require('./labels');
11
+ const { loadConfiguredExtractors } = require('./label-extractors');
8
12
 
9
13
  function enrichCosts(records) {
10
14
  return records.map((record) => {
@@ -21,20 +25,56 @@ function enrichCosts(records) {
21
25
  });
22
26
  }
23
27
 
28
+ function rawFingerprint(source, file, record) {
29
+ return crypto
30
+ .createHash('sha256')
31
+ .update(source)
32
+ .update('\0')
33
+ .update(path.resolve(file))
34
+ .update('\0')
35
+ .update(String(record.line))
36
+ .update('\0')
37
+ .update(JSON.stringify(record.value))
38
+ .digest('hex');
39
+ }
40
+
41
+ function isCopilotSessionUsageRecord(record) {
42
+ return record.value && record.value.type === 'session.shutdown';
43
+ }
44
+
24
45
  async function ingestFile(options) {
25
46
  const { dbPath, file, source } = options;
26
47
  const parsed = readJsonl(file);
27
48
  const warnings = [...parsed.warnings];
49
+ const sourceFile = path.resolve(file);
50
+ const parsedRecords = parsed.records.map((record) => ({
51
+ ...record,
52
+ raw_fingerprint: rawFingerprint(source, sourceFile, record),
53
+ }));
54
+ const importableRecords = source === 'copilot-session'
55
+ ? parsedRecords.filter(isCopilotSessionUsageRecord)
56
+ : parsedRecords;
57
+ const existing = await existingRawFingerprints(
58
+ dbPath,
59
+ source,
60
+ sourceFile,
61
+ importableRecords.map((record) => record.raw_fingerprint),
62
+ );
63
+ const newRecords = importableRecords.filter((record) => !existing.has(record.raw_fingerprint));
28
64
  const usageRecords = [];
29
65
  const hookEvents = [];
30
66
 
31
- for (const record of parsed.records) {
32
- if (source === 'hooks') {
33
- const event = normalizeHookEvent(record.value, source, record.line);
34
- if (event) hookEvents.push(event);
35
- continue;
67
+ if (source === 'copilot-session') {
68
+ usageRecords.push(...normalizeCopilotSessionEvents(newRecords, parsedRecords));
69
+ } else {
70
+ for (const record of newRecords) {
71
+ if (source === 'hooks') {
72
+ const event = normalizeHookEvent(record.value, source, record.line);
73
+ if (event) hookEvents.push(event);
74
+ continue;
75
+ }
76
+ usageRecords.push(...normalizePayload(record.value, source, record.line));
36
77
  }
37
- usageRecords.push(...normalizePayload(record.value, source, record.line));
38
78
  }
39
79
 
40
80
  const extractorOptions = { extractors: options.extractors || [] };
@@ -50,13 +90,15 @@ async function ingestFile(options) {
50
90
  }
51
91
  }
52
92
 
53
- await insertImport(dbPath, source, parsed.records, enrichedUsage, enrichedHooks, warnings);
93
+ await insertImport(dbPath, source, sourceFile, newRecords, enrichedUsage, enrichedHooks, warnings);
54
94
 
55
95
  return {
56
96
  source,
57
97
  file,
58
98
  dbPath,
59
- raw_records: parsed.records.length,
99
+ raw_records: importableRecords.length,
100
+ new_raw_records: newRecords.length,
101
+ skipped_existing_records: importableRecords.length - newRecords.length,
60
102
  usage_records: enrichedUsage.length,
61
103
  hook_events: enrichedHooks.length,
62
104
  label_evidence: enrichedUsage.reduce((sum, usage) => sum + (usage.label_evidence || []).length, 0)
@@ -66,6 +108,64 @@ async function ingestFile(options) {
66
108
  };
67
109
  }
68
110
 
111
+ function configuredSourceFiles(paths, config = {}) {
112
+ const sourceConfig = config.sources || {};
113
+ const telemetryConfig = config.telemetry || {};
114
+ const files = [
115
+ { source: 'vscode', file: sourceConfig.vscode?.telemetry || telemetryConfig.vscode || paths.vscodeOtelJsonl },
116
+ { source: 'hooks', file: sourceConfig.vscode?.hooks || paths.hookEventsJsonl },
117
+ { source: 'copilot-cli', file: sourceConfig.copilotCli?.telemetry || telemetryConfig.copilotCli || paths.copilotCliOtelJsonl },
118
+ { source: 'hooks', file: sourceConfig.copilotCli?.hooks || paths.hookEventsJsonl },
119
+ ...discoverCopilotSessionFiles(sourceConfig.copilotCli?.sessions || paths.copilotSessionStateDir),
120
+ ];
121
+ const seen = new Set();
122
+ return files
123
+ .filter((entry) => entry.file)
124
+ .map((entry) => ({ source: entry.source, file: path.resolve(entry.file) }))
125
+ .filter((entry) => {
126
+ const key = `${entry.source}\0${entry.file}`;
127
+ if (seen.has(key)) return false;
128
+ seen.add(key);
129
+ return true;
130
+ });
131
+ }
132
+
133
+ function discoverCopilotSessionFiles(sessionStateDir) {
134
+ if (!sessionStateDir || !fs.existsSync(sessionStateDir)) return [];
135
+ return fs.readdirSync(sessionStateDir, { withFileTypes: true })
136
+ .filter((entry) => entry.isDirectory())
137
+ .map((entry) => path.join(sessionStateDir, entry.name, 'events.jsonl'))
138
+ .filter((file) => fs.existsSync(file))
139
+ .map((file) => ({ source: 'copilot-session', file }));
140
+ }
141
+
142
+ function readConfig(configJson) {
143
+ if (!fs.existsSync(configJson)) return {};
144
+ return JSON.parse(fs.readFileSync(configJson, 'utf8'));
145
+ }
146
+
147
+ async function autoImportConfiguredSources(paths, options = {}) {
148
+ const config = readConfig(paths.configJson);
149
+ const extractors = options.extractors || loadConfiguredExtractors(paths.configJson, options.cwd || process.cwd());
150
+ const results = [];
151
+ for (const entry of configuredSourceFiles(paths, config)) {
152
+ if (!fs.existsSync(entry.file)) {
153
+ results.push({ ...entry, skipped: true, reason: 'missing_file' });
154
+ continue;
155
+ }
156
+ results.push(await ingestFile({
157
+ dbPath: paths.usageDb,
158
+ file: entry.file,
159
+ source: entry.source,
160
+ extractors,
161
+ }));
162
+ }
163
+ return results;
164
+ }
165
+
69
166
  module.exports = {
167
+ autoImportConfiguredSources,
168
+ configuredSourceFiles,
169
+ discoverCopilotSessionFiles,
70
170
  ingestFile,
71
171
  };
package/src/otel.js CHANGED
@@ -29,6 +29,23 @@ function number(attrs, keys) {
29
29
  return Number.isFinite(parsed) ? parsed : 0;
30
30
  }
31
31
 
32
+ const LABEL_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
33
+
34
+ function collectLabels(value, labels = new Set()) {
35
+ if (typeof value === 'string') {
36
+ for (const match of value.matchAll(LABEL_RE)) labels.add(match[0]);
37
+ return labels;
38
+ }
39
+ if (Array.isArray(value)) {
40
+ for (const item of value) collectLabels(item, labels);
41
+ return labels;
42
+ }
43
+ if (value && typeof value === 'object') {
44
+ for (const item of Object.values(value)) collectLabels(item, labels);
45
+ }
46
+ return labels;
47
+ }
48
+
32
49
  function flattenSpans(payload) {
33
50
  if (!payload || typeof payload !== 'object') return [];
34
51
  if (payload.name || payload.attributes || payload.spanId) return [payload];
@@ -102,6 +119,76 @@ function normalizePayload(payload, source, rawLine) {
102
119
  .filter(Boolean);
103
120
  }
104
121
 
122
+ function normalizeCopilotSessionEvents(newRecords, allRecords) {
123
+ const session = {
124
+ labels: new Set(),
125
+ id: null,
126
+ cwd: null,
127
+ repo: null,
128
+ branch: null,
129
+ commit: null,
130
+ conversationId: null,
131
+ };
132
+
133
+ for (const record of allRecords) {
134
+ const event = record.value;
135
+ if (!event || typeof event !== 'object') continue;
136
+ if (event.type === 'session.start') {
137
+ const data = event.data || {};
138
+ const context = data.context || {};
139
+ session.id = data.sessionId || session.id;
140
+ session.cwd = context.cwd || session.cwd;
141
+ session.repo = context.gitRoot || context.repository || session.repo;
142
+ session.branch = context.branch || session.branch;
143
+ session.commit = context.headCommit || session.commit;
144
+ collectLabels(context, session.labels);
145
+ }
146
+ if (event.type === 'hook.start') {
147
+ collectLabels(event.data && event.data.input, session.labels);
148
+ }
149
+ if (event.type === 'assistant.message') {
150
+ const data = event.data || {};
151
+ session.conversationId = data.interactionId || session.conversationId;
152
+ collectLabels(data.content, session.labels);
153
+ }
154
+ }
155
+
156
+ const records = [];
157
+ for (const record of newRecords) {
158
+ const event = record.value;
159
+ if (!event || event.type !== 'session.shutdown') continue;
160
+ const data = event.data || {};
161
+ const modelMetrics = data.modelMetrics || {};
162
+ for (const [model, metrics] of Object.entries(modelMetrics)) {
163
+ const usage = metrics.usage || {};
164
+ records.push({
165
+ raw_line: record.line,
166
+ span_id: event.id || null,
167
+ trace_id: session.id || null,
168
+ parent_span_id: event.parentId || null,
169
+ timestamp: event.timestamp || null,
170
+ surface: 'copilot-cli-session',
171
+ conversation_id: session.conversationId,
172
+ session_id: session.id,
173
+ requested_model: model,
174
+ resolved_model: model,
175
+ repo: session.repo,
176
+ branch: session.branch,
177
+ cwd: session.cwd,
178
+ commit_sha: session.commit,
179
+ labels: Array.from(session.labels).sort(),
180
+ input_tokens: Number(usage.inputTokens || 0),
181
+ output_tokens: Number(usage.outputTokens || 0),
182
+ cache_read_tokens: Number(usage.cacheReadTokens || 0),
183
+ cache_creation_tokens: Number(usage.cacheWriteTokens || 0),
184
+ reasoning_tokens: Number(usage.reasoningTokens || 0),
185
+ warnings: [],
186
+ });
187
+ }
188
+ }
189
+ return records;
190
+ }
191
+
105
192
  function normalizeHookEvent(payload, source, rawLine) {
106
193
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
107
194
  return {
@@ -122,5 +209,6 @@ module.exports = {
122
209
  flattenSpans,
123
210
  classifySpan,
124
211
  normalizePayload,
212
+ normalizeCopilotSessionEvents,
125
213
  normalizeHookEvent,
126
214
  };
package/src/paths.js CHANGED
@@ -28,6 +28,7 @@ function resolvePaths(options = {}) {
28
28
  const storeDir = path.join(home, 'store');
29
29
  const skillsDir = path.join(home, 'skills');
30
30
  const copilotHome = env.COPILOT_HOME || path.join(os.homedir(), '.copilot');
31
+ const copilotSessionStateDir = path.join(copilotHome, 'session-state');
31
32
 
32
33
  return {
33
34
  home,
@@ -40,6 +41,8 @@ function resolvePaths(options = {}) {
40
41
  hookEventsJsonl: path.join(hooksDir, 'copilot-hooks.jsonl'),
41
42
  usageDb: path.join(storeDir, 'copilot-metrics.sqlite'),
42
43
  configJson: path.join(home, 'config.json'),
44
+ copilotHome,
45
+ copilotSessionStateDir,
43
46
  localHookConfig: path.join(cwd, '.github', 'hooks', 'copilot-metrics.json'),
44
47
  globalHookConfig: path.join(copilotHome, 'settings.json'),
45
48
  };
package/src/reports.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { queryRows } = require('./sqlite-store');
3
+ const { initStore, queryRows } = require('./sqlite-store');
4
4
  const { canonicalLabel } = require('./label-extractors');
5
5
 
6
6
  function n(value) {
@@ -31,6 +31,7 @@ function table(headers, rows) {
31
31
  }
32
32
 
33
33
  async function labelOverview(dbPath) {
34
+ await initStore(dbPath);
34
35
  return queryRows(dbPath, `
35
36
  SELECT
36
37
  labels.label,
@@ -43,6 +44,10 @@ SELECT
43
44
  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
45
  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
46
  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,
47
+ CASE
48
+ WHEN (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) = 0 THEN 'hook-only'
49
+ ELSE 'token-bearing'
50
+ END AS token_status,
46
51
  (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
52
  (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
53
  (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
@@ -51,6 +56,7 @@ ORDER BY estimated_ai_credits DESC, labels.label`);
51
56
  }
52
57
 
53
58
  async function labelSummary(dbPath, label) {
59
+ await initStore(dbPath);
54
60
  const rows = await queryRows(dbPath, `
55
61
  SELECT
56
62
  labels.label,
@@ -63,6 +69,10 @@ SELECT
63
69
  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
70
  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
71
  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,
72
+ CASE
73
+ WHEN (SELECT COUNT(DISTINCT usage_record_id) FROM label_evidence WHERE label = labels.label AND usage_record_id IS NOT NULL) = 0 THEN 'hook-only'
74
+ ELSE 'token-bearing'
75
+ END AS token_status,
66
76
  (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
77
  (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
78
  (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
@@ -71,6 +81,7 @@ FROM (SELECT DISTINCT label FROM label_evidence WHERE label = ?) labels`, [canon
71
81
  }
72
82
 
73
83
  async function labelDetails(dbPath, label) {
84
+ await initStore(dbPath);
74
85
  return queryRows(dbPath, `
75
86
  SELECT
76
87
  le.label,
@@ -87,6 +98,9 @@ SELECT
87
98
  ur.resolved_model,
88
99
  ur.input_tokens,
89
100
  ur.output_tokens,
101
+ ur.cache_read_tokens,
102
+ ur.cache_creation_tokens,
103
+ ur.reasoning_tokens,
90
104
  ur.estimated_ai_credits,
91
105
  ur.estimate_label,
92
106
  COALESCE(ur.timestamp, le.timestamp, le.imported_at) AS timestamp
@@ -97,6 +111,7 @@ ORDER BY timestamp, le.source_type, le.source_field`, [canonicalLabel(label)]);
97
111
  }
98
112
 
99
113
  async function modelReport(dbPath) {
114
+ await initStore(dbPath);
100
115
  return queryRows(dbPath, `
101
116
  SELECT
102
117
  COALESCE(resolved_model, requested_model, 'unknown') AS model,
@@ -114,6 +129,7 @@ ORDER BY estimated_ai_credits DESC, model`);
114
129
  }
115
130
 
116
131
  async function repoReport(dbPath) {
132
+ await initStore(dbPath);
117
133
  return queryRows(dbPath, `
118
134
  SELECT
119
135
  COALESCE(repo, 'unknown') AS repo,
@@ -130,6 +146,7 @@ ORDER BY estimated_ai_credits DESC, repo, cwd`);
130
146
  }
131
147
 
132
148
  async function unattributedReport(dbPath) {
149
+ await initStore(dbPath);
133
150
  return queryRows(dbPath, `
134
151
  SELECT
135
152
  ur.id,
@@ -156,13 +173,18 @@ ORDER BY ur.timestamp, ur.id`);
156
173
  function formatLabels(rows) {
157
174
  return [
158
175
  table(
159
- ['Label', 'Sessions', 'Input', 'Output', 'Credits', 'Evidence', 'Last seen'],
176
+ ['Label', 'Sessions', 'Usage', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'Credits', 'Status', 'Evidence', 'Last seen'],
160
177
  rows.map((row) => [
161
178
  row.label,
162
179
  row.sessions,
180
+ row.usage_records,
163
181
  formatNumber(row.input_tokens),
164
182
  formatNumber(row.output_tokens),
183
+ formatNumber(row.cache_read_tokens),
184
+ formatNumber(row.cache_creation_tokens),
185
+ formatNumber(row.reasoning_tokens),
165
186
  formatCredits(row.estimated_ai_credits),
187
+ row.token_status,
166
188
  row.evidence_count,
167
189
  row.last_seen || '',
168
190
  ]),
@@ -176,18 +198,35 @@ function formatLabelSummary(summary, details = null) {
176
198
  if (!summary) return 'No usage found for label.';
177
199
  const lines = [
178
200
  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]],
201
+ ['Label', 'Sessions', 'Usage', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'Credits', 'Status', 'Evidence'],
202
+ [[
203
+ summary.label,
204
+ summary.sessions,
205
+ summary.usage_records,
206
+ formatNumber(summary.input_tokens),
207
+ formatNumber(summary.output_tokens),
208
+ formatNumber(summary.cache_read_tokens),
209
+ formatNumber(summary.cache_creation_tokens),
210
+ formatNumber(summary.reasoning_tokens),
211
+ formatCredits(summary.estimated_ai_credits),
212
+ summary.token_status,
213
+ summary.evidence_count,
214
+ ]],
181
215
  ),
182
216
  ];
183
217
  if (details) {
184
218
  lines.push('', table(
185
- ['Source', 'Field', 'Session', 'Model', 'Credits', 'Value'],
219
+ ['Source', 'Field', 'Session', 'Model', 'Input', 'Output', 'Cache read', 'Cache create', 'Reasoning', 'Credits', 'Value'],
186
220
  details.map((row) => [
187
221
  row.source_type,
188
222
  row.source_field,
189
223
  row.session_id || '',
190
224
  row.resolved_model || '',
225
+ formatNumber(row.input_tokens),
226
+ formatNumber(row.output_tokens),
227
+ formatNumber(row.cache_read_tokens),
228
+ formatNumber(row.cache_creation_tokens),
229
+ formatNumber(row.reasoning_tokens),
191
230
  formatCredits(row.estimated_ai_credits),
192
231
  row.source_value || '',
193
232
  ]),
package/src/setup.js CHANGED
@@ -45,6 +45,7 @@ function ensureDataDirs(paths) {
45
45
  if (!fs.existsSync(paths.configJson)) {
46
46
  writePrivateFile(paths.configJson, `${JSON.stringify({
47
47
  version: 1,
48
+ dataHome: paths.home,
48
49
  contentCapture: false,
49
50
  telemetry: {
50
51
  vscode: paths.vscodeOtelJsonl,
@@ -58,6 +59,7 @@ function ensureDataDirs(paths) {
58
59
  copilotCli: {
59
60
  telemetry: paths.copilotCliOtelJsonl,
60
61
  hooks: paths.hookEventsJsonl,
62
+ sessions: paths.copilotSessionStateDir,
61
63
  },
62
64
  },
63
65
  labelExtractors: [],
@@ -93,6 +95,11 @@ function packageBinCommand(cwd) {
93
95
  return path.join(cwd, 'bin', 'copilot-metrics.js');
94
96
  }
95
97
 
98
+ function commandInvocation(command) {
99
+ const quoted = shellQuote(command);
100
+ return command.endsWith('.js') ? `node ${quoted}` : quoted;
101
+ }
102
+
96
103
  function hookEventsForSurface(surface) {
97
104
  if (surface === 'copilot-cli' || surface === 'both') return COPILOT_CLI_HOOK_EVENTS;
98
105
  if (surface === 'vscode') return VSCODE_HOOK_EVENTS;
@@ -100,7 +107,7 @@ function hookEventsForSurface(surface) {
100
107
  }
101
108
 
102
109
  function hookCommand(command, event, metricsHome) {
103
- return `COPILOT_METRICS_HOME=${shellQuote(metricsHome)} node ${shellQuote(command)} hook-log --event ${shellQuote(event)} --quiet`;
110
+ return `COPILOT_METRICS_HOME=${shellQuote(metricsHome)} ${commandInvocation(command)} hook-log --event ${shellQuote(event)} --quiet`;
104
111
  }
105
112
 
106
113
  function hookConfig(paths, options = {}) {
@@ -171,6 +178,7 @@ function installHook(paths, options = {}) {
171
178
 
172
179
  function setupSnapshot(options = {}) {
173
180
  const paths = resolvePaths(options);
181
+ ensureDataDirs(paths);
174
182
  return {
175
183
  paths,
176
184
  vscode: vscodeSettings(paths),
@@ -29,6 +29,19 @@ function persistDatabase(dbPath, db) {
29
29
  }
30
30
  }
31
31
 
32
+ function hasColumn(db, table, column) {
33
+ const result = db.exec(`PRAGMA table_info(${table})`);
34
+ if (!result.length) return false;
35
+ const nameIndex = result[0].columns.indexOf('name');
36
+ return result[0].values.some((row) => row[nameIndex] === column);
37
+ }
38
+
39
+ function addColumnIfMissing(db, table, column, definition) {
40
+ if (!hasColumn(db, table, column)) {
41
+ db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
42
+ }
43
+ }
44
+
32
45
  async function initStore(dbPath) {
33
46
  const db = await openDatabase(dbPath);
34
47
  db.run(`
@@ -36,7 +49,9 @@ CREATE TABLE IF NOT EXISTS raw_records (
36
49
  id INTEGER PRIMARY KEY AUTOINCREMENT,
37
50
  imported_at TEXT NOT NULL,
38
51
  source TEXT NOT NULL,
52
+ source_file TEXT,
39
53
  line INTEGER NOT NULL,
54
+ raw_fingerprint TEXT,
40
55
  payload_json TEXT NOT NULL
41
56
  );
42
57
  CREATE TABLE IF NOT EXISTS usage_records (
@@ -105,6 +120,9 @@ CREATE TABLE IF NOT EXISTS import_warnings (
105
120
  message TEXT NOT NULL
106
121
  );
107
122
  `);
123
+ addColumnIfMissing(db, 'raw_records', 'source_file', 'TEXT');
124
+ addColumnIfMissing(db, 'raw_records', 'raw_fingerprint', 'TEXT');
125
+ db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_records_fingerprint ON raw_records (source, source_file, raw_fingerprint)');
108
126
  persistDatabase(dbPath, db);
109
127
  }
110
128
 
@@ -147,7 +165,25 @@ function insertLabelEvidence(db, importedAt, evidenceRows) {
147
165
  );
148
166
  }
149
167
 
150
- async function insertImport(dbPath, source, rawRecords, usageRecords, hookEvents, warnings) {
168
+ async function existingRawFingerprints(dbPath, source, sourceFile, fingerprints) {
169
+ await initStore(dbPath);
170
+ if (!fingerprints.length) return new Set();
171
+ const db = await openDatabase(dbPath);
172
+ const existing = new Set();
173
+ const statement = db.prepare('SELECT 1 FROM raw_records WHERE source = ? AND source_file = ? AND raw_fingerprint = ? LIMIT 1');
174
+ try {
175
+ for (const fingerprint of fingerprints) {
176
+ statement.bind([source, sourceFile, fingerprint]);
177
+ if (statement.step()) existing.add(fingerprint);
178
+ statement.reset();
179
+ }
180
+ } finally {
181
+ statement.free();
182
+ }
183
+ return existing;
184
+ }
185
+
186
+ async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords, hookEvents, warnings) {
151
187
  await initStore(dbPath);
152
188
  const db = await openDatabase(dbPath);
153
189
  const importedAt = new Date().toISOString();
@@ -156,8 +192,8 @@ async function insertImport(dbPath, source, rawRecords, usageRecords, hookEvents
156
192
  try {
157
193
  runPrepared(
158
194
  db,
159
- 'INSERT INTO raw_records (imported_at, source, line, payload_json) VALUES (?, ?, ?, ?)',
160
- rawRecords.map((record) => [importedAt, source, record.line, JSON.stringify(record.value)]),
195
+ 'INSERT OR IGNORE INTO raw_records (imported_at, source, source_file, line, raw_fingerprint, payload_json) VALUES (?, ?, ?, ?, ?, ?)',
196
+ rawRecords.map((record) => [importedAt, source, sourceFile, record.line, record.raw_fingerprint || null, JSON.stringify(record.value)]),
161
197
  );
162
198
 
163
199
  const labelEvidence = [];
@@ -283,6 +319,7 @@ async function queryRows(dbPath, sql, params = []) {
283
319
  }
284
320
 
285
321
  module.exports = {
322
+ existingRawFingerprints,
286
323
  initStore,
287
324
  insertImport,
288
325
  queryOne,