copilot-metrics 0.1.1 → 0.1.3
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 +14 -0
- package/README.md +31 -26
- package/package.json +1 -1
- package/src/cli.js +58 -7
- package/src/ingest.js +33 -11
- package/src/otel.js +88 -0
- package/src/paths.js +3 -0
- package/src/setup.js +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3 - 2026-05-31
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Hook install now avoids embedding transient `npx` cache shims, which can break when their shebang points at a removed Node install. Generated hooks use a stable `npx -y copilot-metrics@<version>` package invocation for those cache paths.
|
|
8
|
+
|
|
9
|
+
## 0.1.2 - 2026-05-31
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- Copilot session-state imports persist only shutdown usage records, while using prompt-bearing session events in memory for label extraction and context.
|
|
15
|
+
- Hook-only report diagnostics now stay quiet when token-bearing Copilot session-state usage is available.
|
|
16
|
+
|
|
3
17
|
## 0.1.1 - 2026-05-30
|
|
4
18
|
|
|
5
19
|
### Added
|
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.3 --help
|
|
13
|
+
npx copilot-metrics@0.1.3 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.3 init
|
|
42
|
+
npx copilot-metrics@0.1.3 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.3 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.3 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,51 +67,52 @@ 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.3 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.3 hooks install --scope local --surface both
|
|
77
|
+
npx copilot-metrics@0.1.3 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.3 store init
|
|
88
|
+
npx copilot-metrics@0.1.3 import --source vscode --file ~/.local/share/copilot-metrics/telemetry/vscode-copilot-otel.jsonl
|
|
89
|
+
npx copilot-metrics@0.1.3 import --source copilot-cli --file ~/.local/share/copilot-metrics/telemetry/copilot-cli-otel.jsonl
|
|
90
|
+
npx copilot-metrics@0.1.3 import --source copilot-session --file ~/.copilot/session-state/<session-id>/events.jsonl
|
|
91
|
+
npx copilot-metrics@0.1.3 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. Re-importing the same JSONL rows is idempotent.
|
|
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.3 report labels
|
|
102
|
+
npx copilot-metrics@0.1.3 report label DEMO-12345
|
|
103
|
+
npx copilot-metrics@0.1.3 report label DEMO-12345 --detail
|
|
104
|
+
npx copilot-metrics@0.1.3 report models
|
|
105
|
+
npx copilot-metrics@0.1.3 report repos
|
|
106
|
+
npx copilot-metrics@0.1.3 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.3 report labels --json
|
|
108
113
|
```
|
|
109
114
|
|
|
110
|
-
Report commands automatically import newly appended configured VS Code, Copilot CLI, 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.
|
|
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.
|
|
111
116
|
|
|
112
117
|
## Attribution Model
|
|
113
118
|
|
|
@@ -170,7 +175,7 @@ The manual prompt performs one harmless tool call so Copilot CLI hook execution
|
|
|
170
175
|
## Current Limits
|
|
171
176
|
|
|
172
177
|
- Costs are estimates, not official billing records.
|
|
173
|
-
- Official GitHub usage report reconciliation is not included in `0.1.
|
|
174
|
-
- Local OTLP collector mode is not included in `0.1.
|
|
175
|
-
- 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.3`.
|
|
179
|
+
- Local OTLP collector mode is not included in `0.1.3`.
|
|
180
|
+
- Richer prompt/content capture and redaction controls are not included in `0.1.3`.
|
|
176
181
|
- Dashboard views are deferred until the CLI/query model proves useful.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -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);
|
|
@@ -237,14 +286,15 @@ async function main(args, io) {
|
|
|
237
286
|
|
|
238
287
|
if (command === 'report') {
|
|
239
288
|
ensureDataDirs(paths);
|
|
240
|
-
await autoImportConfiguredSources(paths, {
|
|
289
|
+
const imports = await autoImportConfiguredSources(paths, {
|
|
241
290
|
cwd: io.cwd,
|
|
242
291
|
extractors: loadConfiguredExtractors(paths.configJson, io.cwd),
|
|
243
292
|
});
|
|
293
|
+
const diagnostics = telemetryDiagnostics(imports);
|
|
244
294
|
|
|
245
295
|
if (subcommand === 'labels') {
|
|
246
296
|
const rows = await labelOverview(paths.usageDb);
|
|
247
|
-
writeOutput(io.stdout, json ? { labels: rows } : formatLabels(rows), json);
|
|
297
|
+
writeOutput(io.stdout, json ? { labels: rows, diagnostics } : appendDiagnostics(formatLabels(rows), diagnostics), json);
|
|
248
298
|
return;
|
|
249
299
|
}
|
|
250
300
|
if (subcommand === 'label') {
|
|
@@ -253,10 +303,10 @@ async function main(args, io) {
|
|
|
253
303
|
const summary = await labelSummary(paths.usageDb, label);
|
|
254
304
|
if (flags.detail === true) {
|
|
255
305
|
const details = await labelDetails(paths.usageDb, label);
|
|
256
|
-
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);
|
|
257
307
|
return;
|
|
258
308
|
}
|
|
259
|
-
writeOutput(io.stdout, json ? { label: summary } : formatLabelSummary(summary), json);
|
|
309
|
+
writeOutput(io.stdout, json ? { label: summary, diagnostics } : appendDiagnostics(formatLabelSummary(summary), diagnostics), json);
|
|
260
310
|
return;
|
|
261
311
|
}
|
|
262
312
|
if (subcommand === 'models') {
|
|
@@ -291,4 +341,5 @@ module.exports = {
|
|
|
291
341
|
main,
|
|
292
342
|
parseFlags,
|
|
293
343
|
helpText,
|
|
344
|
+
telemetryDiagnostics,
|
|
294
345
|
};
|
package/src/ingest.js
CHANGED
|
@@ -4,7 +4,7 @@ const crypto = require('node:crypto');
|
|
|
4
4
|
const fs = require('node:fs');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const { readJsonl } = require('./jsonl');
|
|
7
|
-
const { normalizePayload, normalizeHookEvent } = require('./otel');
|
|
7
|
+
const { normalizePayload, normalizeHookEvent, normalizeCopilotSessionEvents } = require('./otel');
|
|
8
8
|
const { estimateCost, PRICING_VERSION } = require('./pricing');
|
|
9
9
|
const { existingRawFingerprints, insertImport } = require('./sqlite-store');
|
|
10
10
|
const { attachUsageLabelEvidence, attachHookLabelEvidence } = require('./labels');
|
|
@@ -38,6 +38,10 @@ function rawFingerprint(source, file, record) {
|
|
|
38
38
|
.digest('hex');
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function isCopilotSessionUsageRecord(record) {
|
|
42
|
+
return record.value && record.value.type === 'session.shutdown';
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
async function ingestFile(options) {
|
|
42
46
|
const { dbPath, file, source } = options;
|
|
43
47
|
const parsed = readJsonl(file);
|
|
@@ -47,23 +51,30 @@ async function ingestFile(options) {
|
|
|
47
51
|
...record,
|
|
48
52
|
raw_fingerprint: rawFingerprint(source, sourceFile, record),
|
|
49
53
|
}));
|
|
54
|
+
const importableRecords = source === 'copilot-session'
|
|
55
|
+
? parsedRecords.filter(isCopilotSessionUsageRecord)
|
|
56
|
+
: parsedRecords;
|
|
50
57
|
const existing = await existingRawFingerprints(
|
|
51
58
|
dbPath,
|
|
52
59
|
source,
|
|
53
60
|
sourceFile,
|
|
54
|
-
|
|
61
|
+
importableRecords.map((record) => record.raw_fingerprint),
|
|
55
62
|
);
|
|
56
|
-
const newRecords =
|
|
63
|
+
const newRecords = importableRecords.filter((record) => !existing.has(record.raw_fingerprint));
|
|
57
64
|
const usageRecords = [];
|
|
58
65
|
const hookEvents = [];
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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));
|
|
65
77
|
}
|
|
66
|
-
usageRecords.push(...normalizePayload(record.value, source, record.line));
|
|
67
78
|
}
|
|
68
79
|
|
|
69
80
|
const extractorOptions = { extractors: options.extractors || [] };
|
|
@@ -85,9 +96,9 @@ async function ingestFile(options) {
|
|
|
85
96
|
source,
|
|
86
97
|
file,
|
|
87
98
|
dbPath,
|
|
88
|
-
raw_records:
|
|
99
|
+
raw_records: importableRecords.length,
|
|
89
100
|
new_raw_records: newRecords.length,
|
|
90
|
-
skipped_existing_records:
|
|
101
|
+
skipped_existing_records: importableRecords.length - newRecords.length,
|
|
91
102
|
usage_records: enrichedUsage.length,
|
|
92
103
|
hook_events: enrichedHooks.length,
|
|
93
104
|
label_evidence: enrichedUsage.reduce((sum, usage) => sum + (usage.label_evidence || []).length, 0)
|
|
@@ -105,6 +116,7 @@ function configuredSourceFiles(paths, config = {}) {
|
|
|
105
116
|
{ source: 'hooks', file: sourceConfig.vscode?.hooks || paths.hookEventsJsonl },
|
|
106
117
|
{ source: 'copilot-cli', file: sourceConfig.copilotCli?.telemetry || telemetryConfig.copilotCli || paths.copilotCliOtelJsonl },
|
|
107
118
|
{ source: 'hooks', file: sourceConfig.copilotCli?.hooks || paths.hookEventsJsonl },
|
|
119
|
+
...discoverCopilotSessionFiles(sourceConfig.copilotCli?.sessions || paths.copilotSessionStateDir),
|
|
108
120
|
];
|
|
109
121
|
const seen = new Set();
|
|
110
122
|
return files
|
|
@@ -118,6 +130,15 @@ function configuredSourceFiles(paths, config = {}) {
|
|
|
118
130
|
});
|
|
119
131
|
}
|
|
120
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
|
+
|
|
121
142
|
function readConfig(configJson) {
|
|
122
143
|
if (!fs.existsSync(configJson)) return {};
|
|
123
144
|
return JSON.parse(fs.readFileSync(configJson, 'utf8'));
|
|
@@ -145,5 +166,6 @@ async function autoImportConfiguredSources(paths, options = {}) {
|
|
|
145
166
|
module.exports = {
|
|
146
167
|
autoImportConfiguredSources,
|
|
147
168
|
configuredSourceFiles,
|
|
169
|
+
discoverCopilotSessionFiles,
|
|
148
170
|
ingestFile,
|
|
149
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/setup.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { resolvePaths } = require('./paths');
|
|
6
|
+
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
6
7
|
|
|
7
8
|
const HOOK_SURFACES = ['both', 'copilot-cli', 'vscode'];
|
|
8
9
|
|
|
@@ -59,6 +60,7 @@ function ensureDataDirs(paths) {
|
|
|
59
60
|
copilotCli: {
|
|
60
61
|
telemetry: paths.copilotCliOtelJsonl,
|
|
61
62
|
hooks: paths.hookEventsJsonl,
|
|
63
|
+
sessions: paths.copilotSessionStateDir,
|
|
62
64
|
},
|
|
63
65
|
},
|
|
64
66
|
labelExtractors: [],
|
|
@@ -95,10 +97,18 @@ function packageBinCommand(cwd) {
|
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
function commandInvocation(command) {
|
|
100
|
+
if (isEphemeralPackageShim(command)) {
|
|
101
|
+
return `npx -y copilot-metrics@${PACKAGE_VERSION}`;
|
|
102
|
+
}
|
|
98
103
|
const quoted = shellQuote(command);
|
|
99
104
|
return command.endsWith('.js') ? `node ${quoted}` : quoted;
|
|
100
105
|
}
|
|
101
106
|
|
|
107
|
+
function isEphemeralPackageShim(command) {
|
|
108
|
+
const normalized = String(command || '').replace(/\\/g, '/');
|
|
109
|
+
return normalized.includes('/.npm/_npx/') || normalized.endsWith('/node_modules/.bin/copilot-metrics');
|
|
110
|
+
}
|
|
111
|
+
|
|
102
112
|
function hookEventsForSurface(surface) {
|
|
103
113
|
if (surface === 'copilot-cli' || surface === 'both') return COPILOT_CLI_HOOK_EVENTS;
|
|
104
114
|
if (surface === 'vscode') return VSCODE_HOOK_EVENTS;
|