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 +22 -0
- package/README.md +32 -25
- package/package.json +1 -1
- package/src/cli.js +64 -7
- package/src/ingest.js +110 -10
- package/src/otel.js +88 -0
- package/src/paths.js +3 -0
- package/src/reports.js +44 -5
- package/src/setup.js +9 -1
- package/src/sqlite-store.js +40 -3
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.
|
|
13
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
42
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
73
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
84
|
-
npx copilot-metrics@0.1.
|
|
85
|
-
npx copilot-metrics@0.1.
|
|
86
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
97
|
-
npx copilot-metrics@0.1.
|
|
98
|
-
npx copilot-metrics@0.1.
|
|
99
|
-
npx copilot-metrics@0.1.
|
|
100
|
-
npx copilot-metrics@0.1.
|
|
101
|
-
npx copilot-metrics@0.1.
|
|
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.
|
|
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.
|
|
172
|
-
- Local OTLP collector mode is not included in `0.1.
|
|
173
|
-
- Richer prompt/content capture and redaction controls are not included in `0.1.
|
|
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
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
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:
|
|
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
|
-
[[
|
|
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)}
|
|
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),
|
package/src/sqlite-store.js
CHANGED
|
@@ -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
|
|
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,
|