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