copilot-metrics 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5 - 2026-05-31
4
+
5
+ ### Fixed
6
+
7
+ - VS Code Copilot token usage is now attributed to Jira labels by matching OTel `gen_ai.response.id` values to VS Code chat session `responseId` values.
8
+ - Existing local stores with older VS Code usage rows are repaired by backfilling missing response IDs from already imported raw OTel records.
9
+ - VS Code chat session files are parsed only in memory for label extraction; full chat content is not persisted in the metrics store.
10
+ - Versioned model IDs such as dated Copilot telemetry model names now use the canonical per-token pricing row when one is available, so estimates show what the token usage would cost even during included or `0x` periods.
11
+
3
12
  ## 0.1.4 - 2026-05-31
4
13
 
5
14
  ### Changed
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.4 --help
13
- npx copilot-metrics@0.1.4 init
12
+ npx copilot-metrics@0.1.5 --help
13
+ npx copilot-metrics@0.1.5 init
14
14
  ```
15
15
 
16
16
  From this checkout:
@@ -38,8 +38,8 @@ export COPILOT_METRICS_HOME=/path/to/copilot-metrics-data
38
38
  Useful commands:
39
39
 
40
40
  ```bash
41
- npx copilot-metrics@0.1.4 init
42
- npx copilot-metrics@0.1.4 paths --json
41
+ npx copilot-metrics@0.1.5 init
42
+ npx copilot-metrics@0.1.5 paths --json
43
43
  ```
44
44
 
45
45
  `init` only creates the central data directory and local config. It does not modify editor or hook settings. `setup` performs integration setup for the current machine/workspace.
@@ -51,19 +51,19 @@ For Copilot CLI, `init` plus hooks are enough for local token reporting. Reports
51
51
  Install VS Code Copilot Chat OpenTelemetry settings:
52
52
 
53
53
  ```bash
54
- npx copilot-metrics@0.1.4 setup vscode
54
+ npx copilot-metrics@0.1.5 setup vscode
55
55
  ```
56
56
 
57
57
  Install Copilot CLI hooks for the current workspace:
58
58
 
59
59
  ```bash
60
- npx copilot-metrics@0.1.4 setup copilot-cli
60
+ npx copilot-metrics@0.1.5 setup copilot-cli
61
61
  ```
62
62
 
63
63
  Or set up both VS Code settings and workspace hooks in one command:
64
64
 
65
65
  ```bash
66
- npx copilot-metrics@0.1.4 setup
66
+ npx copilot-metrics@0.1.5 setup
67
67
  ```
68
68
 
69
69
  Use `setup vscode --print` or `setup copilot-cli --print` to print the settings/optional environment exports without writing files. Copilot CLI OTel exports are optional because CLI token usage is read from local session-state files.
@@ -75,14 +75,14 @@ Content capture is disabled by default. Do not enable richer prompt capture unle
75
75
  Preview repo-local hook config. The default `--surface both` emits the Copilot CLI lower camel case hook format:
76
76
 
77
77
  ```bash
78
- npx copilot-metrics@0.1.4 hooks preview --scope local --surface both
78
+ npx copilot-metrics@0.1.5 hooks preview --scope local --surface both
79
79
  ```
80
80
 
81
81
  Install repo-local or user-global hook config:
82
82
 
83
83
  ```bash
84
- npx copilot-metrics@0.1.4 hooks install --scope local --surface both
85
- npx copilot-metrics@0.1.4 hooks install --scope global --surface both
84
+ npx copilot-metrics@0.1.5 hooks install --scope local --surface both
85
+ npx copilot-metrics@0.1.5 hooks install --scope global --surface both
86
86
  ```
87
87
 
88
88
  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.
@@ -92,39 +92,40 @@ Local install writes `.github/hooks/copilot-metrics.json`. Global install update
92
92
  Initialize the local SQLite store and import JSONL files manually:
93
93
 
94
94
  ```bash
95
- npx copilot-metrics@0.1.4 store init
96
- npx copilot-metrics@0.1.4 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
97
- npx copilot-metrics@0.1.4 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
98
- npx copilot-metrics@0.1.4 import --source copilot-session --file ~/.copilot/session-state/<session-id>/events.jsonl
99
- npx copilot-metrics@0.1.4 import --source hooks --file ~/.local/share/copilot-metrics/hooks/copilot-hooks.jsonl
95
+ npx copilot-metrics@0.1.5 store init
96
+ npx copilot-metrics@0.1.5 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
97
+ npx copilot-metrics@0.1.5 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
98
+ npx copilot-metrics@0.1.5 import --source copilot-session --file ~/.copilot/session-state/<session-id>/events.jsonl
99
+ npx copilot-metrics@0.1.5 import --source vscode-chat --file ~/.config/Code\ -\ Insiders/User/workspaceStorage/<workspace-id>/chatSessions/<session-id>.jsonl
100
+ npx copilot-metrics@0.1.5 import --source hooks --file ~/.local/share/copilot-metrics/hooks/copilot-hooks.jsonl
100
101
  ```
101
102
 
102
- 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.
103
+ 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. VS Code chat session files are also parsed only in memory, then reduced to label evidence linked to VS Code OTel usage by exact response ID.
103
104
 
104
105
  ## Reports
105
106
 
106
107
  Run local reports from the SQLite store:
107
108
 
108
109
  ```bash
109
- npx copilot-metrics@0.1.4 report labels
110
- npx copilot-metrics@0.1.4 report label DEMO-12345
111
- npx copilot-metrics@0.1.4 report label DEMO-12345 --detail
112
- npx copilot-metrics@0.1.4 report models
113
- npx copilot-metrics@0.1.4 report repos
114
- npx copilot-metrics@0.1.4 report unattributed
110
+ npx copilot-metrics@0.1.5 report labels
111
+ npx copilot-metrics@0.1.5 report label DEMO-12345
112
+ npx copilot-metrics@0.1.5 report label DEMO-12345 --detail
113
+ npx copilot-metrics@0.1.5 report models
114
+ npx copilot-metrics@0.1.5 report repos
115
+ npx copilot-metrics@0.1.5 report unattributed
115
116
  ```
116
117
 
117
118
  Every report supports `--json`:
118
119
 
119
120
  ```bash
120
- npx copilot-metrics@0.1.4 report labels --json
121
+ npx copilot-metrics@0.1.5 report labels --json
121
122
  ```
122
123
 
123
- Report commands automatically import newly appended configured VS Code OTel, optional Copilot CLI OTel, Copilot CLI session-state, and hook JSONL files before querying. Repeated reports skip already imported session-state files and already imported JSONL lines.
124
+ Report commands automatically import newly appended configured VS Code OTel, VS Code chat session metadata, optional Copilot CLI OTel, Copilot CLI session-state, and hook JSONL files before querying. Repeated reports skip already imported session-state files and already imported JSONL lines.
124
125
 
125
126
  `report labels` shows accumulated totals per label. `report label <id>` shows the selected label summary plus a per-model breakdown by default. Label reports include input, output, cache read, cache creation, and reasoning token totals. Labels seen only in hooks remain visible as `evidence-only` with zero usage records, so attribution hints do not imply token-bearing usage.
126
127
 
127
- `AI Credits est.` is a local estimate derived from the pricing table. The project treats 1 AI Credit as $0.01 for estimates; GitHub billing remains the source of truth.
128
+ `AI Credits est.` is a local what-would-this-cost estimate derived from the token pricing table, not a claim that the interaction was billed today. Some included or request-based models can appear as `0x` in Copilot while still having published per-token prices. The project treats 1 AI Credit as $0.01 for estimates; GitHub billing remains the source of truth.
128
129
 
129
130
  ## Attribution Model
130
131
 
@@ -132,6 +133,8 @@ The default extractor finds Jira-style labels such as `DEMO-12345` from safe met
132
133
 
133
134
  Attribution is stored as evidence with source, field, session, repo, branch, cwd, confidence, and related usage or hook record IDs. This makes the data useful for later analysis, such as deciding whether a label was the main task or a sidetrack.
134
135
 
136
+ For VS Code Copilot Chat, token records from OTel are linked to chat labels by exact response ID. The OTel `gen_ai.response.id` value must match the VS Code chat session `responseId`; timestamp-only attribution is not used.
137
+
135
138
  Full prompt content is not stored by default. Prompt-like fields are only used to extract labels and the stored source value is reduced to the matched label.
136
139
 
137
140
  ## Custom Label Extractors
@@ -187,7 +190,7 @@ The manual prompt performs one harmless tool call so Copilot CLI hook execution
187
190
  ## Current Limits
188
191
 
189
192
  - Costs are estimates, not official billing records.
190
- - Official GitHub usage report reconciliation is not included in `0.1.4`.
191
- - Local OTLP collector mode is not included in `0.1.4`.
192
- - Richer prompt/content capture and redaction controls are not included in `0.1.4`.
193
+ - Official GitHub usage report reconciliation is not included in `0.1.5`.
194
+ - Local OTLP collector mode is not included in `0.1.5`.
195
+ - Richer prompt/content capture and redaction controls are not included in `0.1.5`.
193
196
  - 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.4",
3
+ "version": "0.1.5",
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
@@ -79,7 +79,7 @@ Usage:
79
79
  copilot-metrics hooks install [--scope local|global] [--surface both|vscode|copilot-cli] [--json]
80
80
  copilot-metrics hook-log --event <name>
81
81
  copilot-metrics store init [--json]
82
- copilot-metrics import --source vscode|copilot-cli|copilot-session|hooks --file <path> [--json]
82
+ copilot-metrics import --source vscode|vscode-chat|copilot-cli|copilot-session|hooks --file <path> [--json]
83
83
  copilot-metrics report labels [--json]
84
84
  copilot-metrics report label <id> [--detail] [--json]
85
85
  copilot-metrics report models [--json]
@@ -305,8 +305,8 @@ async function main(args, io) {
305
305
  : source === 'hooks'
306
306
  ? paths.hookEventsJsonl
307
307
  : null);
308
- if (!['vscode', 'copilot-cli', 'copilot-session', 'hooks'].includes(source)) {
309
- throw new Error('import requires --source vscode|copilot-cli|copilot-session|hooks');
308
+ if (!['vscode', 'vscode-chat', 'copilot-cli', 'copilot-session', 'hooks'].includes(source)) {
309
+ throw new Error('import requires --source vscode|vscode-chat|copilot-cli|copilot-session|hooks');
310
310
  }
311
311
  if (!file) throw new Error('import requires --file <path>');
312
312
  ensureDataDirs(paths);
package/src/ingest.js CHANGED
@@ -2,13 +2,23 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
+ const os = require('node:os');
5
6
  const path = require('node:path');
6
7
  const { readJsonl } = require('./jsonl');
7
8
  const { normalizePayload, normalizeHookEvent, normalizeCopilotSessionEvents } = require('./otel');
8
9
  const { estimateCost, PRICING_VERSION } = require('./pricing');
9
- const { existingRawFingerprints, importedLineHighWater, insertImport } = require('./sqlite-store');
10
+ const {
11
+ attachVscodeChatLabelEvidence,
12
+ existingRawFingerprints,
13
+ importedLineHighWater,
14
+ insertImport,
15
+ queryRows,
16
+ updateUsageCostEstimates,
17
+ updateVscodeUsageResponseIds,
18
+ vscodeRawRecordsNeedingResponseBackfill,
19
+ } = require('./sqlite-store');
10
20
  const { attachUsageLabelEvidence, attachHookLabelEvidence } = require('./labels');
11
- const { loadConfiguredExtractors } = require('./label-extractors');
21
+ const { loadConfiguredExtractors, runLabelExtractors } = require('./label-extractors');
12
22
 
13
23
  function enrichCosts(records) {
14
24
  return records.map((record) => {
@@ -42,9 +52,221 @@ function isCopilotSessionUsageRecord(record) {
42
52
  return record.value && record.value.type === 'session.shutdown';
43
53
  }
44
54
 
55
+ function pushText(values, value) {
56
+ if (typeof value === 'string' && value.trim()) values.push(value);
57
+ }
58
+
59
+ function pushPromptCandidates(values, value) {
60
+ if (!value || typeof value !== 'object') return;
61
+ if (Array.isArray(value)) {
62
+ for (const item of value) pushPromptCandidates(values, item);
63
+ return;
64
+ }
65
+ pushText(values, value.text);
66
+ pushText(values, value.value);
67
+ pushText(values, value.message);
68
+ pushText(values, value.prompt);
69
+ pushText(values, value.promptText);
70
+ pushText(values, value.renderedUserMessage);
71
+ pushText(values, value.userMessage);
72
+ if (value.renderedUserMessage && typeof value.renderedUserMessage === 'object') {
73
+ pushPromptCandidates(values, value.renderedUserMessage);
74
+ }
75
+ if (value.message && typeof value.message === 'object') pushPromptCandidates(values, value.message);
76
+ if (value.result && typeof value.result === 'object') pushPromptCandidates(values, value.result);
77
+ if (value.metadata && typeof value.metadata === 'object') pushPromptCandidates(values, value.metadata);
78
+ }
79
+
80
+ function responseId(value) {
81
+ if (!value || typeof value !== 'object') return null;
82
+ return value.responseId
83
+ || value.metadata?.responseId
84
+ || value.result?.responseId
85
+ || value.result?.metadata?.responseId
86
+ || value.modelMessageId
87
+ || value.metadata?.modelMessageId
88
+ || null;
89
+ }
90
+
91
+ function chatSessionId(value) {
92
+ if (!value || typeof value !== 'object') return null;
93
+ return value.sessionId
94
+ || value.sessionID
95
+ || value.metadata?.sessionId
96
+ || value.result?.sessionId
97
+ || value.result?.metadata?.sessionId
98
+ || null;
99
+ }
100
+
101
+ function chatRequestIndex(record) {
102
+ const key = Array.isArray(record.k) ? record.k : Array.isArray(record.key) ? record.key : [];
103
+ if (key[0] !== 'requests') return null;
104
+ const index = Number(key[1]);
105
+ return Number.isInteger(index) ? index : null;
106
+ }
107
+
108
+ function normalizeVscodeChatSession(records, extractors = []) {
109
+ const requests = new Map();
110
+ let defaultSessionId = null;
111
+
112
+ function entry(index) {
113
+ const key = String(index);
114
+ if (!requests.has(key)) requests.set(key, { texts: [] });
115
+ return requests.get(key);
116
+ }
117
+
118
+ function mergeRequest(index, request, sessionId) {
119
+ if (!request || typeof request !== 'object') return;
120
+ const current = entry(index);
121
+ current.sessionId = chatSessionId(request) || sessionId || current.sessionId;
122
+ current.responseId = responseId(request) || current.responseId;
123
+ pushPromptCandidates(current.texts, request);
124
+ }
125
+
126
+ for (const record of records) {
127
+ const value = record.value;
128
+ if (!value || typeof value !== 'object') continue;
129
+ const root = value.v && typeof value.v === 'object' ? value.v : value;
130
+ defaultSessionId = root.sessionId || root.sessionID || defaultSessionId;
131
+
132
+ if (Array.isArray(root.requests)) {
133
+ root.requests.forEach((request, index) => mergeRequest(index, request, defaultSessionId));
134
+ }
135
+
136
+ const key = Array.isArray(value.k) ? value.k : Array.isArray(value.key) ? value.key : [];
137
+ if (key.length === 1 && key[0] === 'requests' && Array.isArray(value.v)) {
138
+ const startIndex = requests.size;
139
+ value.v.forEach((request, offset) => mergeRequest(startIndex + offset, request, defaultSessionId));
140
+ }
141
+
142
+ const index = chatRequestIndex(value);
143
+ if (index !== null) {
144
+ const current = entry(index);
145
+ const patch = value.v;
146
+ if (patch && typeof patch === 'object') {
147
+ current.sessionId = chatSessionId(patch) || defaultSessionId || current.sessionId;
148
+ current.responseId = responseId(patch) || current.responseId;
149
+ pushPromptCandidates(current.texts, patch);
150
+ } else {
151
+ pushText(current.texts, patch);
152
+ }
153
+ }
154
+ }
155
+
156
+ return Array.from(requests.values())
157
+ .filter((request) => request.responseId)
158
+ .map((request) => {
159
+ const labelEvidence = runLabelExtractors('usage', { prompt: request.texts }, extractors)
160
+ .map((evidence) => ({
161
+ ...evidence,
162
+ source_type: 'usage',
163
+ source_field: 'vscode_chat_response',
164
+ source_value: request.responseId,
165
+ confidence: Math.max(Number(evidence.confidence || 0), 0.95),
166
+ }));
167
+ return {
168
+ responseId: request.responseId,
169
+ sessionId: request.sessionId || defaultSessionId || null,
170
+ label_evidence: labelEvidence,
171
+ };
172
+ })
173
+ .filter((request) => request.label_evidence.length > 0);
174
+ }
175
+
176
+ async function ingestVscodeChatSessionFile(options) {
177
+ const { dbPath, file } = options;
178
+ const sourceFile = path.resolve(file);
179
+ const parsed = readJsonl(sourceFile);
180
+ const mappings = normalizeVscodeChatSession(parsed.records, options.extractors || []);
181
+ const attached = await attachVscodeChatLabelEvidence(dbPath, mappings);
182
+ return {
183
+ source: 'vscode-chat',
184
+ file,
185
+ dbPath,
186
+ raw_records: 0,
187
+ new_raw_records: 0,
188
+ skipped_existing_records: 0,
189
+ usage_records: attached.matched_usage_records,
190
+ hook_events: 0,
191
+ label_evidence: attached.label_evidence,
192
+ warnings: parsed.warnings,
193
+ estimate_label: `estimate:${PRICING_VERSION}`,
194
+ };
195
+ }
196
+
197
+ async function backfillVscodeUsageResponseIds(dbPath, sourceFile) {
198
+ const rows = await vscodeRawRecordsNeedingResponseBackfill(dbPath, sourceFile);
199
+ const updates = [];
200
+ for (const row of rows) {
201
+ let payload;
202
+ try {
203
+ payload = JSON.parse(row.payload_json);
204
+ } catch {
205
+ continue;
206
+ }
207
+ for (const usage of normalizePayload(payload, 'vscode', row.line)) {
208
+ if (!usage.span_id) continue;
209
+ updates.push({
210
+ raw_line: usage.raw_line,
211
+ span_id: usage.span_id,
212
+ session_id: usage.session_id,
213
+ timestamp: usage.timestamp,
214
+ requested_model: usage.requested_model,
215
+ resolved_model: usage.resolved_model,
216
+ input_tokens: usage.input_tokens,
217
+ output_tokens: usage.output_tokens,
218
+ cache_read_tokens: usage.cache_read_tokens,
219
+ cache_creation_tokens: usage.cache_creation_tokens,
220
+ reasoning_tokens: usage.reasoning_tokens,
221
+ });
222
+ }
223
+ }
224
+ return updateVscodeUsageResponseIds(dbPath, updates);
225
+ }
226
+
227
+ function parseWarningsJson(value) {
228
+ try {
229
+ const parsed = JSON.parse(value || '[]');
230
+ return Array.isArray(parsed) ? parsed : [];
231
+ } catch {
232
+ return [];
233
+ }
234
+ }
235
+
236
+ async function repairUsageCostEstimates(dbPath) {
237
+ const rows = await queryRows(dbPath, `
238
+ SELECT id, requested_model, resolved_model, input_tokens, output_tokens,
239
+ cache_read_tokens, cache_creation_tokens, reasoning_tokens, warnings_json
240
+ FROM usage_records
241
+ WHERE estimated_ai_credits IS NULL
242
+ OR estimated_ai_credits = 0
243
+ OR warnings_json LIKE '%unknown_model:%'
244
+ OR warnings_json LIKE '%missing_model%'
245
+ `);
246
+ const updates = [];
247
+ for (const row of rows) {
248
+ const estimate = estimateCost(row);
249
+ if (estimate.warning) continue;
250
+ const warnings = parseWarningsJson(row.warnings_json)
251
+ .filter((warning) => !String(warning).startsWith('unknown_model:') && warning !== 'missing_model');
252
+ updates.push({
253
+ id: row.id,
254
+ estimated_usd: estimate.estimated_usd,
255
+ estimated_ai_credits: estimate.estimated_ai_credits,
256
+ warnings,
257
+ });
258
+ }
259
+ return updateUsageCostEstimates(dbPath, updates);
260
+ }
261
+
45
262
  async function ingestFile(options) {
46
263
  const { dbPath, file, source } = options;
264
+ if (source === 'vscode-chat') return ingestVscodeChatSessionFile(options);
265
+
47
266
  const sourceFile = path.resolve(file);
267
+ const backfilledUsageRecords = source === 'vscode'
268
+ ? await backfillVscodeUsageResponseIds(dbPath, sourceFile)
269
+ : 0;
48
270
  const highWaterLine = await importedLineHighWater(dbPath, source, sourceFile);
49
271
  if (source === 'copilot-session' && highWaterLine > 0) {
50
272
  return {
@@ -57,6 +279,7 @@ async function ingestFile(options) {
57
279
  usage_records: 0,
58
280
  hook_events: 0,
59
281
  label_evidence: 0,
282
+ backfilled_usage_records: backfilledUsageRecords,
60
283
  warnings: [],
61
284
  estimate_label: `estimate:${PRICING_VERSION}`,
62
285
  };
@@ -108,6 +331,7 @@ async function ingestFile(options) {
108
331
  }
109
332
 
110
333
  await insertImport(dbPath, source, sourceFile, newRecords, enrichedUsage, enrichedHooks, warnings);
334
+ const repairedCostRecords = await repairUsageCostEstimates(dbPath);
111
335
 
112
336
  return {
113
337
  source,
@@ -118,6 +342,8 @@ async function ingestFile(options) {
118
342
  skipped_existing_records: highWaterLine,
119
343
  usage_records: enrichedUsage.length,
120
344
  hook_events: enrichedHooks.length,
345
+ backfilled_usage_records: backfilledUsageRecords,
346
+ repaired_cost_records: repairedCostRecords,
121
347
  label_evidence: enrichedUsage.reduce((sum, usage) => sum + (usage.label_evidence || []).length, 0)
122
348
  + enrichedHooks.reduce((sum, event) => sum + (event.label_evidence || []).length, 0),
123
349
  warnings,
@@ -130,6 +356,7 @@ function configuredSourceFiles(paths, config = {}) {
130
356
  const telemetryConfig = config.telemetry || {};
131
357
  const files = [
132
358
  { source: 'vscode', file: sourceConfig.vscode?.telemetry || telemetryConfig.vscode || paths.vscodeOtelJsonl },
359
+ ...discoverVscodeChatSessionFiles(sourceConfig.vscode?.chatSessions),
133
360
  { source: 'hooks', file: sourceConfig.vscode?.hooks || paths.hookEventsJsonl },
134
361
  { source: 'copilot-cli', file: sourceConfig.copilotCli?.telemetry || telemetryConfig.copilotCli || paths.copilotCliOtelJsonl },
135
362
  { source: 'hooks', file: sourceConfig.copilotCli?.hooks || paths.hookEventsJsonl },
@@ -147,6 +374,40 @@ function configuredSourceFiles(paths, config = {}) {
147
374
  });
148
375
  }
149
376
 
377
+ function listJsonlFiles(dir) {
378
+ if (!dir || !fs.existsSync(dir)) return [];
379
+ return fs.readdirSync(dir, { withFileTypes: true })
380
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
381
+ .map((entry) => path.join(dir, entry.name));
382
+ }
383
+
384
+ function discoverWorkspaceChatSessions(workspaceStorageDir) {
385
+ if (!workspaceStorageDir || !fs.existsSync(workspaceStorageDir)) return [];
386
+ return fs.readdirSync(workspaceStorageDir, { withFileTypes: true })
387
+ .filter((entry) => entry.isDirectory())
388
+ .flatMap((entry) => listJsonlFiles(path.join(workspaceStorageDir, entry.name, 'chatSessions')));
389
+ }
390
+
391
+ function discoverVscodeChatSessionFiles(configured) {
392
+ const configuredEntries = Array.isArray(configured) ? configured : configured ? [configured] : [];
393
+ const files = configuredEntries.length > 0
394
+ ? configuredEntries.flatMap((entry) => {
395
+ const resolved = path.resolve(entry);
396
+ if (!fs.existsSync(resolved)) return [];
397
+ const stat = fs.statSync(resolved);
398
+ if (stat.isFile()) return [resolved];
399
+ return listJsonlFiles(resolved).concat(discoverWorkspaceChatSessions(resolved));
400
+ })
401
+ : [
402
+ path.join(os.homedir(), '.config', 'Code', 'User', 'workspaceStorage'),
403
+ path.join(os.homedir(), '.config', 'Code - Insiders', 'User', 'workspaceStorage'),
404
+ ].flatMap(discoverWorkspaceChatSessions);
405
+
406
+ return files
407
+ .sort()
408
+ .map((file) => ({ source: 'vscode-chat', file }));
409
+ }
410
+
150
411
  function discoverCopilotSessionFiles(sessionStateDir) {
151
412
  if (!sessionStateDir || !fs.existsSync(sessionStateDir)) return [];
152
413
  return fs.readdirSync(sessionStateDir, { withFileTypes: true })
@@ -184,5 +445,9 @@ module.exports = {
184
445
  autoImportConfiguredSources,
185
446
  configuredSourceFiles,
186
447
  discoverCopilotSessionFiles,
448
+ discoverVscodeChatSessionFiles,
449
+ backfillVscodeUsageResponseIds,
187
450
  ingestFile,
451
+ normalizeVscodeChatSession,
452
+ repairUsageCostEstimates,
188
453
  };
package/src/otel.js CHANGED
@@ -2,9 +2,16 @@
2
2
 
3
3
  function attrsToObject(attrs) {
4
4
  if (!attrs) return {};
5
+ if (attrs && typeof attrs === 'object' && Array.isArray(attrs._rawAttributes)) {
6
+ return Object.fromEntries(attrs._rawAttributes);
7
+ }
5
8
  if (!Array.isArray(attrs)) return attrs;
6
9
  const out = {};
7
10
  for (const attr of attrs) {
11
+ if (Array.isArray(attr) && attr.length >= 2) {
12
+ out[attr[0]] = attr[1];
13
+ continue;
14
+ }
8
15
  const value = attr.value;
9
16
  if (value && typeof value === 'object') {
10
17
  out[attr.key] = value.stringValue ?? value.intValue ?? value.doubleValue ?? value.boolValue ?? value.arrayValue;
@@ -61,8 +68,28 @@ function flattenSpans(payload) {
61
68
  return spans;
62
69
  }
63
70
 
71
+ function timestampValue(value) {
72
+ if (!value) return null;
73
+ if (Array.isArray(value) && value.length >= 2) {
74
+ const millis = (Number(value[0]) * 1000) + (Number(value[1]) / 1e6);
75
+ return Number.isFinite(millis) ? new Date(millis).toISOString() : null;
76
+ }
77
+ if (typeof value === 'string' && /^\d+$/.test(value)) {
78
+ const numeric = Number(value);
79
+ if (!Number.isFinite(numeric)) return null;
80
+ const millis = numeric > 1e15 ? numeric / 1e6 : numeric;
81
+ return new Date(millis).toISOString();
82
+ }
83
+ if (typeof value === 'number') {
84
+ const millis = value > 1e15 ? value / 1e6 : value;
85
+ return new Date(millis).toISOString();
86
+ }
87
+ return value;
88
+ }
89
+
64
90
  function classifySpan(span) {
65
91
  const attrs = attrsToObject(span.attributes);
92
+ const eventName = String(pick(attrs, ['event.name']) || '').toLowerCase();
66
93
  const operation = String(pick(attrs, ['gen_ai.operation.name', 'llm.operation']) || '').toLowerCase();
67
94
  const name = String(span.name || '').toLowerCase();
68
95
  const hasTokens = number(attrs, [
@@ -72,7 +99,14 @@ function classifySpan(span) {
72
99
  'llm.usage.completion_tokens',
73
100
  ]) > 0;
74
101
 
75
- if (operation.includes('agent') || operation.includes('tool') || name.includes('agent') || name.includes('tool')) {
102
+ if (
103
+ eventName.includes('agent')
104
+ || eventName.includes('tool')
105
+ || operation.includes('agent')
106
+ || operation.includes('tool')
107
+ || name.includes('agent')
108
+ || name.includes('tool')
109
+ ) {
76
110
  return 'non_billable';
77
111
  }
78
112
  if (hasTokens || operation.includes('chat') || operation.includes('completion') || operation.includes('generate')) {
@@ -83,19 +117,19 @@ function classifySpan(span) {
83
117
 
84
118
  function normalizeSpan(span, source, rawLine) {
85
119
  const attrs = attrsToObject(span.attributes);
86
- const resourceAttrs = attrsToObject(span.resourceAttributes);
120
+ const resourceAttrs = attrsToObject(span.resourceAttributes || span.resource);
87
121
  const type = classifySpan(span);
88
122
  if (type !== 'llm') return null;
89
123
 
90
124
  return {
91
125
  raw_line: rawLine,
92
- span_id: span.spanId || span.span_id || null,
126
+ span_id: span.spanId || span.span_id || pick(attrs, ['gen_ai.response.id']) || null,
93
127
  trace_id: span.traceId || span.trace_id || null,
94
128
  parent_span_id: span.parentSpanId || span.parent_span_id || null,
95
- timestamp: span.startTimeUnixNano || span.start_time || attrs['timestamp'] || null,
129
+ timestamp: timestampValue(span.startTimeUnixNano || span.start_time || span.hrTime || attrs.timestamp),
96
130
  surface: source,
97
131
  conversation_id: pick(attrs, ['gen_ai.conversation.id', 'conversation.id', 'copilot.conversation.id']),
98
- session_id: pick(attrs, ['session.id', 'copilot.session.id']),
132
+ session_id: pick(attrs, ['session.id', 'copilot.session.id']) || pick(resourceAttrs, ['session.id', 'copilot.session.id']),
99
133
  requested_model: pick(attrs, ['gen_ai.request.model', 'llm.request.model', 'llm.model_name']),
100
134
  resolved_model: pick(attrs, ['gen_ai.response.model', 'llm.response.model', 'model']),
101
135
  repo: pick(attrs, ['vcs.repository.name', 'git.repository', 'repo']) || pick(resourceAttrs, ['vcs.repository.name', 'service.name']),
@@ -194,6 +228,7 @@ function normalizeHookEvent(payload, source, rawLine) {
194
228
  return {
195
229
  raw_line: rawLine,
196
230
  event: payload.event || null,
231
+ timestamp: payload.captured_at || payload.timestamp || null,
197
232
  session_id: payload.session_id || payload.sessionId || null,
198
233
  cwd: payload.cwd || null,
199
234
  repo: payload.repo || payload.repository || null,
package/src/pricing.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const PRICING_VERSION = 'github-copilot-2026-06-01';
4
4
 
5
- // USD per 1M tokens. Source: GitHub Copilot models and pricing docs, checked 2026-05-30.
5
+ // USD per 1M tokens. Source: GitHub Copilot models and pricing docs, checked 2026-05-31.
6
6
  const MODEL_PRICES = {
7
7
  'gpt-4.1': { input: 2.00, cacheRead: 0.50, cacheWrite: 0, output: 8.00 },
8
8
  'gpt-5 mini': { input: 0.25, cacheRead: 0.025, cacheWrite: 0, output: 2.00 },
@@ -32,11 +32,19 @@ const MODEL_PRICES = {
32
32
  };
33
33
 
34
34
  function normalizeModelName(model) {
35
- return String(model || '').trim().toLowerCase();
35
+ return String(model || '').trim().toLowerCase().replace(/^copilot\//, '');
36
+ }
37
+
38
+ function modelPriceKey(model) {
39
+ const normalized = normalizeModelName(model);
40
+ if (MODEL_PRICES[normalized]) return normalized;
41
+ const withoutDate = normalized.replace(/-\d{4}-\d{2}-\d{2}$/, '');
42
+ if (MODEL_PRICES[withoutDate]) return withoutDate;
43
+ return normalized;
36
44
  }
37
45
 
38
46
  function estimateCost(record) {
39
- const model = normalizeModelName(record.resolved_model || record.requested_model);
47
+ const model = modelPriceKey(record.resolved_model || record.requested_model);
40
48
  const price = MODEL_PRICES[model];
41
49
  if (!model) {
42
50
  return { estimated_usd: null, estimated_ai_credits: null, warning: 'missing_model' };
@@ -63,4 +71,5 @@ module.exports = {
63
71
  PRICING_VERSION,
64
72
  MODEL_PRICES,
65
73
  estimateCost,
74
+ modelPriceKey,
66
75
  };
@@ -141,6 +141,7 @@ function lastInsertId(db) {
141
141
  }
142
142
 
143
143
  function insertLabelEvidence(db, importedAt, evidenceRows) {
144
+ if (!evidenceRows.length) return;
144
145
  runPrepared(
145
146
  db,
146
147
  `INSERT INTO label_evidence (
@@ -283,6 +284,7 @@ async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords
283
284
  repo: event.repo,
284
285
  branch: event.branch,
285
286
  cwd: event.cwd,
287
+ timestamp: event.timestamp,
286
288
  });
287
289
  }
288
290
  }
@@ -307,6 +309,188 @@ async function insertImport(dbPath, source, sourceFile, rawRecords, usageRecords
307
309
  persistDatabase(dbPath, db);
308
310
  }
309
311
 
312
+ async function attachVscodeChatLabelEvidence(dbPath, mappings) {
313
+ await initStore(dbPath);
314
+ if (!mappings.length) {
315
+ return { matched_usage_records: 0, label_evidence: 0 };
316
+ }
317
+
318
+ const db = await openDatabase(dbPath);
319
+ const importedAt = new Date().toISOString();
320
+ let matchedUsageRecords = 0;
321
+ let labelEvidence = 0;
322
+
323
+ const usageStatement = db.prepare(`
324
+ SELECT id, session_id, repo, branch, cwd, timestamp
325
+ FROM usage_records
326
+ WHERE source = 'vscode' AND span_id = ?
327
+ `);
328
+ const existingStatement = db.prepare(`
329
+ SELECT 1
330
+ FROM label_evidence
331
+ WHERE label = ?
332
+ AND source_type = 'usage'
333
+ AND source_field = 'vscode_chat_response'
334
+ AND source_value = ?
335
+ AND usage_record_id = ?
336
+ LIMIT 1
337
+ `);
338
+ const insertStatement = db.prepare(`
339
+ INSERT INTO label_evidence (
340
+ imported_at, label, source_type, source_field, source_value, confidence,
341
+ usage_record_id, hook_event_id, session_id, repo, branch, cwd, timestamp
342
+ ) VALUES (?, ?, 'usage', 'vscode_chat_response', ?, ?, ?, NULL, ?, ?, ?, ?, ?)
343
+ `);
344
+
345
+ db.run('BEGIN');
346
+ try {
347
+ for (const mapping of mappings) {
348
+ usageStatement.bind([mapping.responseId]);
349
+ const usageRows = [];
350
+ while (usageStatement.step()) usageRows.push(usageStatement.getAsObject());
351
+ usageStatement.reset();
352
+ matchedUsageRecords += usageRows.length;
353
+
354
+ for (const usage of usageRows) {
355
+ for (const evidence of mapping.label_evidence || []) {
356
+ existingStatement.bind([evidence.label, mapping.responseId, usage.id]);
357
+ const exists = existingStatement.step();
358
+ existingStatement.reset();
359
+ if (exists) continue;
360
+
361
+ insertStatement.run([
362
+ importedAt,
363
+ evidence.label,
364
+ mapping.responseId,
365
+ evidence.confidence || 0.95,
366
+ usage.id,
367
+ mapping.sessionId || usage.session_id || null,
368
+ usage.repo || null,
369
+ usage.branch || null,
370
+ usage.cwd || null,
371
+ usage.timestamp || null,
372
+ ]);
373
+ labelEvidence += 1;
374
+ }
375
+ }
376
+ }
377
+ db.run('COMMIT');
378
+ } catch (error) {
379
+ db.run('ROLLBACK');
380
+ throw error;
381
+ } finally {
382
+ usageStatement.free();
383
+ existingStatement.free();
384
+ insertStatement.free();
385
+ }
386
+
387
+ persistDatabase(dbPath, db);
388
+ return { matched_usage_records: matchedUsageRecords, label_evidence: labelEvidence };
389
+ }
390
+
391
+ async function vscodeRawRecordsNeedingResponseBackfill(dbPath, sourceFile) {
392
+ await initStore(dbPath);
393
+ return queryRows(dbPath, `
394
+ SELECT rr.line, rr.payload_json
395
+ FROM raw_records rr
396
+ WHERE rr.source = 'vscode'
397
+ AND rr.source_file = ?
398
+ AND EXISTS (
399
+ SELECT 1
400
+ FROM usage_records ur
401
+ WHERE ur.source = 'vscode'
402
+ AND ur.raw_line = rr.line
403
+ AND ur.span_id IS NULL
404
+ )
405
+ `, [sourceFile]);
406
+ }
407
+
408
+ async function updateVscodeUsageResponseIds(dbPath, updates) {
409
+ await initStore(dbPath);
410
+ if (!updates.length) return 0;
411
+
412
+ const db = await openDatabase(dbPath);
413
+ const statement = db.prepare(`
414
+ UPDATE usage_records
415
+ SET span_id = ?,
416
+ session_id = COALESCE(session_id, ?),
417
+ timestamp = COALESCE(timestamp, ?)
418
+ WHERE source = 'vscode'
419
+ AND raw_line = ?
420
+ AND span_id IS NULL
421
+ AND input_tokens = ?
422
+ AND output_tokens = ?
423
+ AND cache_read_tokens = ?
424
+ AND cache_creation_tokens = ?
425
+ AND reasoning_tokens = ?
426
+ AND COALESCE(resolved_model, requested_model, '') = COALESCE(?, '')
427
+ `);
428
+ let updated = 0;
429
+ db.run('BEGIN');
430
+ try {
431
+ for (const update of updates) {
432
+ statement.run([
433
+ update.span_id,
434
+ update.session_id || null,
435
+ update.timestamp || null,
436
+ update.raw_line,
437
+ update.input_tokens,
438
+ update.output_tokens,
439
+ update.cache_read_tokens,
440
+ update.cache_creation_tokens,
441
+ update.reasoning_tokens,
442
+ update.resolved_model || update.requested_model || '',
443
+ ]);
444
+ updated += typeof db.getRowsModified === 'function' ? db.getRowsModified() : 0;
445
+ }
446
+ db.run('COMMIT');
447
+ } catch (error) {
448
+ db.run('ROLLBACK');
449
+ throw error;
450
+ } finally {
451
+ statement.free();
452
+ }
453
+
454
+ persistDatabase(dbPath, db);
455
+ return updated;
456
+ }
457
+
458
+ async function updateUsageCostEstimates(dbPath, updates) {
459
+ await initStore(dbPath);
460
+ if (!updates.length) return 0;
461
+
462
+ const db = await openDatabase(dbPath);
463
+ const statement = db.prepare(`
464
+ UPDATE usage_records
465
+ SET estimated_usd = ?,
466
+ estimated_ai_credits = ?,
467
+ warnings_json = ?
468
+ WHERE id = ?
469
+ `);
470
+ let updated = 0;
471
+ db.run('BEGIN');
472
+ try {
473
+ for (const update of updates) {
474
+ statement.run([
475
+ update.estimated_usd,
476
+ update.estimated_ai_credits,
477
+ JSON.stringify(update.warnings || []),
478
+ update.id,
479
+ ]);
480
+ updated += typeof db.getRowsModified === 'function' ? db.getRowsModified() : 0;
481
+ }
482
+ db.run('COMMIT');
483
+ } catch (error) {
484
+ db.run('ROLLBACK');
485
+ throw error;
486
+ } finally {
487
+ statement.free();
488
+ }
489
+
490
+ persistDatabase(dbPath, db);
491
+ return updated;
492
+ }
493
+
310
494
  async function queryOne(dbPath, sql) {
311
495
  const db = await openDatabase(dbPath);
312
496
  const result = db.exec(sql);
@@ -329,10 +513,14 @@ async function queryRows(dbPath, sql, params = []) {
329
513
  }
330
514
 
331
515
  module.exports = {
516
+ attachVscodeChatLabelEvidence,
332
517
  existingRawFingerprints,
333
518
  importedLineHighWater,
334
519
  initStore,
335
520
  insertImport,
336
521
  queryOne,
337
522
  queryRows,
523
+ updateUsageCostEstimates,
524
+ updateVscodeUsageResponseIds,
525
+ vscodeRawRecordsNeedingResponseBackfill,
338
526
  };