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