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.
@@ -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.type !== "span") return false;
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 ")) return true;
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 fileOffsets =
5699
+ const priorVersion = Number(copilotState.version) || 1;
5700
+ const fileOffsetsRaw =
5622
5701
  copilotState.fileOffsets && typeof copilotState.fileOffsets === "object"
5623
- ? { ...copilotState.fileOffsets }
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 = { ...copilotState, seenIds: Array.from(seenIds), fileOffsets, updatedAt: new Date().toISOString() };
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 traceId = record?.traceId || "";
5679
- const spanId = record?.spanId || "";
5680
- const dedupKey = traceId && spanId ? `${traceId}:${spanId}` : null;
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
- const cacheWrite = toNonNegativeInt(attrs["gen_ai.usage.cache_write.input_tokens"]);
5688
- const reasoning = toNonNegativeInt(attrs["gen_ai.usage.reasoning.output_tokens"]);
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
- const tsMs = copilotOtelTimeToMs(record.endTime) || copilotOtelTimeToMs(record.startTime);
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 = { ...copilotState, seenIds: cappedSeen, fileOffsets, updatedAt };
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
  }