tokentracker-cli 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +146 -15
package/src/lib/rollout.js
CHANGED
|
@@ -5572,7 +5572,7 @@ async function parseCraftIncremental({
|
|
|
5572
5572
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
5573
5573
|
|
|
5574
5574
|
function resolveCopilotOtelPaths(env = process.env) {
|
|
5575
|
-
const home = require("node:os").homedir();
|
|
5575
|
+
const home = env.HOME || require("node:os").homedir();
|
|
5576
5576
|
const paths = new Set();
|
|
5577
5577
|
const defaultDir = path.join(home, ".copilot", "otel");
|
|
5578
5578
|
if (fssync.existsSync(defaultDir)) {
|
|
@@ -5590,10 +5590,17 @@ function resolveCopilotOtelPaths(env = process.env) {
|
|
|
5590
5590
|
}
|
|
5591
5591
|
|
|
5592
5592
|
function isCopilotChatSpan(record) {
|
|
5593
|
-
if (!record || record
|
|
5593
|
+
if (!record || typeof record !== "object") return false;
|
|
5594
|
+
// Skip metric records (resource + scopeMetrics) which have no chat usage data
|
|
5595
|
+
if (record.scopeMetrics) return false;
|
|
5594
5596
|
const opName = record?.attributes?.["gen_ai.operation.name"];
|
|
5597
|
+
// Both Copilot CLI (Span shape with type:"span") and Copilot Chat extension
|
|
5598
|
+
// (OTEL JS SDK LogRecord shape with event.name:"gen_ai.client.inference.operation.details")
|
|
5599
|
+
// mark chat completions with gen_ai.operation.name === "chat".
|
|
5595
5600
|
if (opName === "chat") return true;
|
|
5596
|
-
if (typeof record.name === "string" && record.name.startsWith("chat "))
|
|
5601
|
+
if (record.type === "span" && typeof record.name === "string" && record.name.startsWith("chat ")) {
|
|
5602
|
+
return true;
|
|
5603
|
+
}
|
|
5597
5604
|
return false;
|
|
5598
5605
|
}
|
|
5599
5606
|
|
|
@@ -5614,20 +5621,116 @@ function pickCopilotModel(attrs) {
|
|
|
5614
5621
|
return null;
|
|
5615
5622
|
}
|
|
5616
5623
|
|
|
5624
|
+
const COPILOT_PARSER_VERSION = 2;
|
|
5625
|
+
|
|
5626
|
+
function isCopilotV1ChatSpan(record) {
|
|
5627
|
+
if (!record || record.type !== "span") return false;
|
|
5628
|
+
const opName = record?.attributes?.["gen_ai.operation.name"];
|
|
5629
|
+
if (opName === "chat") return true;
|
|
5630
|
+
return typeof record.name === "string" && record.name.startsWith("chat ");
|
|
5631
|
+
}
|
|
5632
|
+
|
|
5633
|
+
function copilotLineHash(line) {
|
|
5634
|
+
return crypto.createHash("sha256").update(line).digest("hex");
|
|
5635
|
+
}
|
|
5636
|
+
|
|
5637
|
+
function incrementMapCount(map, key, amount = 1) {
|
|
5638
|
+
map.set(key, (map.get(key) || 0) + amount);
|
|
5639
|
+
}
|
|
5640
|
+
|
|
5641
|
+
function getCopilotDedupKey(record, attrs = record?.attributes || {}) {
|
|
5642
|
+
const traceId = record?.traceId || record?.spanContext?.traceId || "";
|
|
5643
|
+
const spanId = record?.spanId || record?.spanContext?.spanId || "";
|
|
5644
|
+
const responseId =
|
|
5645
|
+
typeof attrs["gen_ai.response.id"] === "string" ? attrs["gen_ai.response.id"] : "";
|
|
5646
|
+
return traceId && spanId ? `${traceId}:${spanId}` : responseId ? `resp:${responseId}` : null;
|
|
5647
|
+
}
|
|
5648
|
+
|
|
5649
|
+
// Migration helper: stream the bytes v1 already saw (0 -> prevSize), classify
|
|
5650
|
+
// whether the file contains old CLI spans v1 processed, and whether it also
|
|
5651
|
+
// contains v2-only chat records v1 skipped. Mixed files must be replayed, but
|
|
5652
|
+
// their old CLI lines are skipped by hash so history does not double-count.
|
|
5653
|
+
async function scanCopilotV1MigrationFile(filePath, maxBytes) {
|
|
5654
|
+
const result = {
|
|
5655
|
+
v1Processed: false,
|
|
5656
|
+
v2OnlyChat: false,
|
|
5657
|
+
v1LineHashes: new Map(),
|
|
5658
|
+
};
|
|
5659
|
+
if (!maxBytes || maxBytes <= 0) return result;
|
|
5660
|
+
try {
|
|
5661
|
+
const stream = fssync.createReadStream(filePath, {
|
|
5662
|
+
encoding: "utf8",
|
|
5663
|
+
start: 0,
|
|
5664
|
+
end: maxBytes - 1,
|
|
5665
|
+
});
|
|
5666
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
5667
|
+
try {
|
|
5668
|
+
for await (const line of rl) {
|
|
5669
|
+
if (!line || !line.trim()) continue;
|
|
5670
|
+
let record;
|
|
5671
|
+
try {
|
|
5672
|
+
record = JSON.parse(line);
|
|
5673
|
+
} catch (_e) {
|
|
5674
|
+
continue;
|
|
5675
|
+
}
|
|
5676
|
+
// Must mirror v1's isCopilotChatSpan exactly: BOTH the
|
|
5677
|
+
// gen_ai.operation.name path AND the legacy name-prefix fallback.
|
|
5678
|
+
// Missing the second path lets metric-free files of name-only CLI spans
|
|
5679
|
+
// look like "v1 skipped" -> offset reset -> re-read -> double-count.
|
|
5680
|
+
if (isCopilotV1ChatSpan(record)) {
|
|
5681
|
+
result.v1Processed = true;
|
|
5682
|
+
incrementMapCount(result.v1LineHashes, copilotLineHash(line));
|
|
5683
|
+
} else if (isCopilotChatSpan(record)) {
|
|
5684
|
+
result.v2OnlyChat = true;
|
|
5685
|
+
}
|
|
5686
|
+
}
|
|
5687
|
+
} finally {
|
|
5688
|
+
rl.close();
|
|
5689
|
+
stream.destroy();
|
|
5690
|
+
}
|
|
5691
|
+
} catch (_e) {}
|
|
5692
|
+
return result;
|
|
5693
|
+
}
|
|
5694
|
+
|
|
5617
5695
|
async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgress, env } = {}) {
|
|
5618
5696
|
await ensureDir(path.dirname(queuePath));
|
|
5619
5697
|
const copilotState = cursors.copilot && typeof cursors.copilot === "object" ? cursors.copilot : {};
|
|
5620
5698
|
const seenIds = new Set(Array.isArray(copilotState.seenIds) ? copilotState.seenIds : []);
|
|
5621
|
-
const
|
|
5699
|
+
const priorVersion = Number(copilotState.version) || 1;
|
|
5700
|
+
const fileOffsetsRaw =
|
|
5622
5701
|
copilotState.fileOffsets && typeof copilotState.fileOffsets === "object"
|
|
5623
|
-
?
|
|
5702
|
+
? copilotState.fileOffsets
|
|
5624
5703
|
: {};
|
|
5704
|
+
const fileOffsets = { ...fileOffsetsRaw };
|
|
5705
|
+
const migrationSkipLineHashes = new Map();
|
|
5706
|
+
// One-shot v1->v2 migration:
|
|
5707
|
+
// - pure v2-only files: clear offset and re-read all skipped Chat records
|
|
5708
|
+
// - pure v1 CLI files: preserve offset to avoid replaying history beyond seenIds
|
|
5709
|
+
// - mixed files: clear offset, but skip old v1 CLI lines by hash during replay
|
|
5710
|
+
if (priorVersion < COPILOT_PARSER_VERSION) {
|
|
5711
|
+
for (const filePath of Object.keys(fileOffsets)) {
|
|
5712
|
+
const prevSize = Number(fileOffsets[filePath]?.size) || 0;
|
|
5713
|
+
const scan = await scanCopilotV1MigrationFile(filePath, prevSize);
|
|
5714
|
+
if (!scan.v1Processed) {
|
|
5715
|
+
delete fileOffsets[filePath];
|
|
5716
|
+
} else if (scan.v2OnlyChat) {
|
|
5717
|
+
delete fileOffsets[filePath];
|
|
5718
|
+
migrationSkipLineHashes.set(filePath, scan.v1LineHashes);
|
|
5719
|
+
}
|
|
5720
|
+
}
|
|
5721
|
+
}
|
|
5625
5722
|
|
|
5626
5723
|
const files = Array.isArray(otelPaths) && otelPaths.length > 0
|
|
5627
5724
|
? otelPaths
|
|
5628
5725
|
: resolveCopilotOtelPaths(env || process.env);
|
|
5629
5726
|
if (files.length === 0) {
|
|
5630
|
-
cursors.copilot = {
|
|
5727
|
+
cursors.copilot = {
|
|
5728
|
+
...copilotState,
|
|
5729
|
+
version: COPILOT_PARSER_VERSION,
|
|
5730
|
+
seenIds: Array.from(seenIds),
|
|
5731
|
+
fileOffsets,
|
|
5732
|
+
updatedAt: new Date().toISOString(),
|
|
5733
|
+
};
|
|
5631
5734
|
return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
|
|
5632
5735
|
}
|
|
5633
5736
|
|
|
@@ -5666,6 +5769,16 @@ async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgre
|
|
|
5666
5769
|
|
|
5667
5770
|
for await (const line of rl) {
|
|
5668
5771
|
if (!line || !line.trim()) continue;
|
|
5772
|
+
const skipLineHashes = migrationSkipLineHashes.get(filePath);
|
|
5773
|
+
if (skipLineHashes && skipLineHashes.size > 0) {
|
|
5774
|
+
const lineHash = copilotLineHash(line);
|
|
5775
|
+
const skipCount = skipLineHashes.get(lineHash) || 0;
|
|
5776
|
+
if (skipCount > 0) {
|
|
5777
|
+
if (skipCount === 1) skipLineHashes.delete(lineHash);
|
|
5778
|
+
else skipLineHashes.set(lineHash, skipCount - 1);
|
|
5779
|
+
continue;
|
|
5780
|
+
}
|
|
5781
|
+
}
|
|
5669
5782
|
let record;
|
|
5670
5783
|
try {
|
|
5671
5784
|
record = JSON.parse(line);
|
|
@@ -5675,25 +5788,37 @@ async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgre
|
|
|
5675
5788
|
recordsProcessed++;
|
|
5676
5789
|
if (!isCopilotChatSpan(record)) continue;
|
|
5677
5790
|
|
|
5678
|
-
const
|
|
5679
|
-
|
|
5680
|
-
|
|
5791
|
+
const attrs = record.attributes || {};
|
|
5792
|
+
// Dedup: CLI puts traceId/spanId at the top level; the Chat extension
|
|
5793
|
+
// file exporter writes LogRecord-shaped entries without either, but
|
|
5794
|
+
// gen_ai.response.id is per-LLM-call unique.
|
|
5795
|
+
const dedupKey = getCopilotDedupKey(record, attrs);
|
|
5681
5796
|
if (dedupKey && seenIds.has(dedupKey)) continue;
|
|
5682
5797
|
|
|
5683
|
-
const attrs = record.attributes || {};
|
|
5684
5798
|
const inputRaw = toNonNegativeInt(attrs["gen_ai.usage.input_tokens"]);
|
|
5685
5799
|
const output = toNonNegativeInt(attrs["gen_ai.usage.output_tokens"]);
|
|
5686
5800
|
const cacheRead = toNonNegativeInt(attrs["gen_ai.usage.cache_read.input_tokens"]);
|
|
5687
|
-
|
|
5688
|
-
const
|
|
5801
|
+
// Copilot CLI: cache_write.input_tokens; Copilot Chat extension: cache_creation.input_tokens
|
|
5802
|
+
const cacheWrite = toNonNegativeInt(
|
|
5803
|
+
attrs["gen_ai.usage.cache_write.input_tokens"] ??
|
|
5804
|
+
attrs["gen_ai.usage.cache_creation.input_tokens"],
|
|
5805
|
+
);
|
|
5806
|
+
// Copilot CLI: reasoning.output_tokens; Copilot Chat extension: reasoning_tokens
|
|
5807
|
+
const reasoning = toNonNegativeInt(
|
|
5808
|
+
attrs["gen_ai.usage.reasoning.output_tokens"] ?? attrs["gen_ai.usage.reasoning_tokens"],
|
|
5809
|
+
);
|
|
5689
5810
|
// OTEL input_tokens INCLUDES cache_read — subtract per project convention
|
|
5690
5811
|
const cacheReadClamped = Math.min(cacheRead, inputRaw);
|
|
5691
5812
|
const input = Math.max(0, inputRaw - cacheReadClamped);
|
|
5692
5813
|
const totalInteresting = input + output + cacheReadClamped + cacheWrite + reasoning;
|
|
5693
|
-
// Drop empty rows unless cache-only
|
|
5694
5814
|
if (totalInteresting === 0) continue;
|
|
5695
5815
|
|
|
5696
|
-
|
|
5816
|
+
// CLI Span uses endTime/startTime; Chat extension LogRecord uses hrTime/hrTimeObserved.
|
|
5817
|
+
const tsMs =
|
|
5818
|
+
copilotOtelTimeToMs(record.endTime) ||
|
|
5819
|
+
copilotOtelTimeToMs(record.startTime) ||
|
|
5820
|
+
copilotOtelTimeToMs(record.hrTime) ||
|
|
5821
|
+
copilotOtelTimeToMs(record.hrTimeObserved);
|
|
5697
5822
|
if (!tsMs) continue;
|
|
5698
5823
|
const tsIso = new Date(tsMs).toISOString();
|
|
5699
5824
|
const bucketStart = toUtcHalfHourStart(tsIso);
|
|
@@ -5747,7 +5872,13 @@ async function parseCopilotIncremental({ otelPaths, cursors, queuePath, onProgre
|
|
|
5747
5872
|
const updatedAt = new Date().toISOString();
|
|
5748
5873
|
hourlyState.updatedAt = updatedAt;
|
|
5749
5874
|
cursors.hourly = hourlyState;
|
|
5750
|
-
cursors.copilot = {
|
|
5875
|
+
cursors.copilot = {
|
|
5876
|
+
...copilotState,
|
|
5877
|
+
version: COPILOT_PARSER_VERSION,
|
|
5878
|
+
seenIds: cappedSeen,
|
|
5879
|
+
fileOffsets,
|
|
5880
|
+
updatedAt,
|
|
5881
|
+
};
|
|
5751
5882
|
|
|
5752
5883
|
return { recordsProcessed, eventsAggregated, bucketsQueued };
|
|
5753
5884
|
}
|