@vimoxshah/tokenflow 1.1.1 → 1.2.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-dmg.sh +11 -2
  25. package/scripts/build-menubar-app.sh +58 -7
  26. package/scripts/design-build.js +475 -0
  27. package/src/analytics/anatomy.js +467 -0
  28. package/src/analytics/branch-compare.js +159 -0
  29. package/src/analytics/cache-health.js +141 -0
  30. package/src/analytics/live-view.js +266 -0
  31. package/src/analytics/receipt-schema.js +214 -0
  32. package/src/analytics/receipt.js +709 -0
  33. package/src/analytics/rhythm.js +184 -0
  34. package/src/analytics/whatif.js +263 -0
  35. package/src/commands/budget-scopes.js +133 -0
  36. package/src/commands/doctor-checks.js +400 -0
  37. package/src/commands/guard.js +531 -0
  38. package/src/commands/hooks.js +238 -0
  39. package/src/commands/pricing-diff.js +316 -0
  40. package/src/commands/receipt.js +226 -0
  41. package/src/commands/team-serve.js +407 -0
  42. package/src/commands/week.js +86 -0
  43. package/src/core/annotations.js +97 -0
  44. package/src/core/budget.js +33 -0
  45. package/src/core/bundle.js +45 -2
  46. package/src/core/ingest.js +33 -0
  47. package/src/core/live-status.js +227 -2
  48. package/src/core/policy.js +103 -0
  49. package/src/core/receipt-note.js +123 -0
  50. package/src/core/repo.js +64 -0
  51. package/src/core/sync.js +163 -26
  52. package/src/core/team.js +0 -0
  53. package/src/export/html-snapshot.js +28 -1
  54. package/src/export/menubar.js +21 -0
  55. package/src/export/receipt-card.js +210 -0
  56. package/src/export/week-card.js +185 -0
  57. package/src/providers/mock/index.js +383 -52
  58. package/src/providers/openai/index.js +31 -1
  59. package/src/providers/otel/index.js +656 -0
  60. package/src/server/routes/annotations.js +42 -0
  61. package/src/server/routes/cache-health.js +95 -0
  62. package/src/server/routes/index.js +54 -0
  63. package/src/server/routes/session.js +157 -0
  64. package/src/server/server.js +47 -1
  65. package/src/ui/app.js +541 -308
  66. package/src/ui/charts.js +95 -0
  67. package/src/ui/first-run.js +144 -0
  68. package/src/ui/index.html +4 -1
  69. package/src/ui/palette.js +335 -0
  70. package/src/ui/styles/anatomy.css +117 -0
  71. package/src/ui/styles/annotations.css +40 -0
  72. package/src/ui/styles/branches.css +99 -0
  73. package/src/ui/styles/cache.css +6 -0
  74. package/src/ui/styles/first-run.css +31 -0
  75. package/src/ui/styles/live.css +100 -0
  76. package/src/ui/styles/palette.css +85 -0
  77. package/src/ui/styles/rhythm.css +8 -0
  78. package/src/ui/styles/whatif.css +55 -0
  79. package/src/ui/styles.css +303 -196
  80. package/src/ui/views/anatomy.js +567 -0
  81. package/src/ui/views/annotations.js +121 -0
  82. package/src/ui/views/branches.js +304 -0
  83. package/src/ui/views/cache.js +232 -0
  84. package/src/ui/views/index.js +85 -0
  85. package/src/ui/views/live.js +683 -0
  86. package/src/ui/views/rhythm.js +206 -0
  87. package/src/ui/views/whatif.js +196 -0
@@ -0,0 +1,656 @@
1
+ /**
2
+ * OpenTelemetry (GenAI) — any tool that writes an OTLP-shaped export, or
3
+ * Gemini CLI's own file telemetry, to a local file.
4
+ *
5
+ * A machine sweep found no tool that writes per-request token counts to disk
6
+ * in a proprietary format for several agent CLIs (Gemini CLI among them), but
7
+ * several of them CAN write OpenTelemetry data to a local file. This adapter
8
+ * is the capture path for that: point a tool's OTLP file exporter, or Gemini
9
+ * CLI's `telemetry.outfile`, at a file this adapter reads, and it becomes a
10
+ * TokenFlow source with zero vendor-specific code on the tool's side.
11
+ *
12
+ * ## Verified primary sources (fetched 2026-09-05)
13
+ *
14
+ * - OTel GenAI spans: raw.githubusercontent.com/open-telemetry/semantic-conventions-genai/main/docs/gen-ai/gen-ai-spans.md
15
+ * (the copy that used to live in open-telemetry/semantic-conventions has moved here)
16
+ * - OTel GenAI events: .../semantic-conventions-genai/main/docs/gen-ai/gen-ai-events.md
17
+ * - OTLP JSON wire example (resourceSpans/attributes-as-array): raw.githubusercontent.com/open-telemetry/opentelemetry-proto/main/examples/trace.json
18
+ * - Gemini CLI telemetry settings: raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/cli/telemetry.md
19
+ * - Gemini CLI's own file writer (ground truth for what actually lands on disk):
20
+ * raw.githubusercontent.com/google-gemini/gemini-cli/main/packages/core/src/telemetry/file-exporters.ts,
21
+ * .../packages/core/src/telemetry/types.ts (`ApiResponseEvent`, EVENT_API_RESPONSE,
22
+ * EVENT_GEN_AI_OPERATION_DETAILS), .../loggers.ts (logApiResponse emits both records),
23
+ * .../telemetryAttributes.ts (getCommonAttributes: `session.id`, `installation.id`),
24
+ * .../metrics.ts (GenAiProviderName.GCP_GEN_AI = 'gcp.gen_ai' — confirms Gemini's OWN
25
+ * gen_ai.provider.name is not the literal "google"), and the gemini-cli `docs/` tree
26
+ * listing (github api repos/google-gemini/gemini-cli/contents/docs) used to locate
27
+ * docs/cli/telemetry.md in the first place.
28
+ * - Gemini `usage.promptTokenCount` cache-inclusion and the `total = prompt + thoughts +
29
+ * candidates` formula: ai.google.dev/api/generate-content ("promptTokenCount ...
30
+ * includes the number of tokens in the cached content"; "totalTokenCount ... prompt +
31
+ * thoughts + response candidates").
32
+ * - OTel JS SDK internals used ONLY to establish what `safeJsonStringify` on a raw
33
+ * ReadableLogRecord/ReadableSpan actually serializes (own vs. getter-backed fields):
34
+ * opentelemetry-js experimental/packages/sdk-logs/src/LogRecordImpl.ts (`attributes` is
35
+ * an own field; `hrTime`/`body`/`severityText` are getters, dropped by plain
36
+ * JSON.stringify) and packages/opentelemetry-resources/src/ResourceImpl.ts (`attributes`
37
+ * is a getter over a private `_rawAttributes`, so a raw dump's `resource.attributes` is
38
+ * unreliable — used best-effort only, never for a required field).
39
+ *
40
+ * ## Two on-disk shapes, not one — this is the load-bearing finding
41
+ *
42
+ * The task that produced this adapter assumed every source here writes
43
+ * standard OTLP wire JSON (`resourceSpans`/`resourceLogs` with attributes as
44
+ * `[{key, value:{stringValue|intValue|...}}]`). That is true for a compliant
45
+ * OTLP file exporter (e.g. the OTel Collector's `file` exporter), and this
46
+ * adapter reads it (shapes A/B below). It is NOT true for Gemini CLI: its
47
+ * `FileLogExporter`/`FileSpanExporter` (file-exporters.ts) call
48
+ * `safeJsonStringify(data, 2) + '\n'` on the OTel JS SDK's own internal
49
+ * `ReadableLogRecord`/`ReadableSpan` objects — a pretty-printed (indent 2,
50
+ * so individual records span MANY lines), vendor-internal dump, not the wire
51
+ * protocol. Its `attributes` field is a flat plain object (own property on
52
+ * `LogRecordImpl`, confirmed against opentelemetry-js's
53
+ * `experimental/packages/sdk-logs/src/LogRecordImpl.ts`), not an array of
54
+ * `{key,value}`. Its `hrTime`/`body`/`severityText` are GETTERS over private
55
+ * `_`-prefixed fields and a plain `JSON.stringify` (no `toJSON()` on that
56
+ * class) silently drops them — so for Gemini's own dump the only reliable
57
+ * timestamp is the plain string the CLI puts INSIDE `attributes` itself:
58
+ * `attributes['event.timestamp']`. This adapter therefore parses three
59
+ * concrete shapes, not one file format:
60
+ *
61
+ * A. OTLP JSON `resourceSpans[].scopeSpans[].spans[]`, attributes as
62
+ * `[{key, value:{stringValue|intValue|doubleValue|boolValue}}]`.
63
+ * B. OTLP JSON `resourceLogs[].scopeLogs[].logRecords[]` (or a span's
64
+ * `events[]`), same wire attribute shape.
65
+ * C. A bare, single JSON object per record — either Gemini's raw SDK dump
66
+ * (`{attributes:{...}, resource:{...}, ...}`, attributes already flat)
67
+ * or a hand-rolled line that IS the flat attributes map directly.
68
+ *
69
+ * `normalize(doc)` dispatches on which shape a top-level JSON value is; the
70
+ * same attribute-flattening code (`flattenAttrs`) accepts either the wire
71
+ * array or an already-flat object, so B and C share one code path.
72
+ *
73
+ * ## The file is not JSON Lines — it is concatenated JSON values
74
+ *
75
+ * Because Gemini pretty-prints, a "line" of Gemini's outfile is not one JSON
76
+ * value; one JSON value spans many text lines. `ingestFile` does NOT use the
77
+ * codebase's line-oriented `readLines` — it scans the byte buffer for
78
+ * balanced top-level `{...}`/`[...]` values (comments on `scanJsonDocuments`
79
+ * explain the brace-depth/string-aware walk and its EOF/resync rules), which
80
+ * transparently handles compact JSONL, pretty-printed concatenation, and a
81
+ * file that is a single JSON document, with exact byte offsets for resume.
82
+ *
83
+ * **Known limitation.** `readTail` reads the unread delta (`ref.start` ..
84
+ * EOF) into memory in one call, like the generic importer's `json` format
85
+ * does. That delta is normally small (incremental resume), but Gemini's
86
+ * `telemetry.logPrompts` defaults to `true`, so a first ingest of a large,
87
+ * long-lived outfile that has never been read before can be a large read —
88
+ * full prompt/response text is still on disk in that file even though this
89
+ * adapter never stores it.
90
+ *
91
+ * ## Avoiding a 2–3x double count from Gemini CLI's own file
92
+ *
93
+ * `logApiResponse()` (loggers.ts) emits TWO log records per API call: the
94
+ * `gemini_cli.api_response` event (all the token fields) AND a semantic twin,
95
+ * `gen_ai.client.inference.operation.details` (types.ts `toSemanticLogRecord`),
96
+ * which repeats `gen_ai.usage.input_tokens`/`output_tokens` under the
97
+ * standards-based attribute names. If `telemetry.traces` is also on
98
+ * (default `false`), an `llm_call` SPAN with its own `gen_ai.usage.*` — and
99
+ * no `event.name` at all — can land in the same file too. Reading all of
100
+ * "shape A", "shape B" and "the Gemini event" naively would count one real
101
+ * API call two or three times. The rule applied here, in `recordFromAttrs`:
102
+ * a record whose `attributes['event.name']` starts with `gemini_cli.` or
103
+ * equals `gen_ai.client.inference.operation.details` is recognised as
104
+ * Gemini-native, and ONLY `gemini_cli.api_response` yields a usage record;
105
+ * a record with NO `event.name` is still recognised as Gemini-native (and
106
+ * skipped) when it carries `gen_ai.agent.name: 'gemini-cli'` (quoted
107
+ * verbatim in telemetry.md's span attribute list) or `installation.id`
108
+ * (only ever set by Gemini's own `getCommonAttributes()`). Every Gemini-
109
+ * native record other than `gemini_cli.api_response` is silently skipped,
110
+ * the same as any other record with no usage to report.
111
+ *
112
+ * ## Token semantics
113
+ *
114
+ * **Standards-based path (gen_ai.usage.*).** Per gen-ai-spans.md notes
115
+ * [23]/[24]/[28]: `gen_ai.usage.input_tokens` "SHOULD include" BOTH
116
+ * `cache_read.input_tokens` and `cache_write.input_tokens` — the same
117
+ * inclusive convention as OpenAI/Codex. `input_tokens` (schema) = usage
118
+ * input minus cache_read minus cache_write, clamped to >= 0.
119
+ * `gen_ai.usage.reasoning.output_tokens` note [30] says it is ALREADY
120
+ * included in `output_tokens`, so it is a direct, unmodified sub-field — no
121
+ * arithmetic needed (unlike Gemini's own event, next).
122
+ *
123
+ * **Gemini's own event (`gemini_cli.api_response`).** Verified against
124
+ * ai.google.dev/api/generate-content: `promptTokenCount` ("this is still the
125
+ * total effective prompt size ... includes the number of tokens in the
126
+ * cached content") is INCLUSIVE of `cachedContentTokenCount`, so
127
+ * `input_tokens` = `input_token_count` - `cached_content_token_count`
128
+ * (clamped >= 0); `cache_read_tokens` = `cached_content_token_count` (Gemini
129
+ * always reports this field — even as a measured 0 — so it is never
130
+ * "unreported" for this source). There is no cache-write count in this
131
+ * event (creating a `CachedContent` is a separate call), so
132
+ * `cache_write_tokens` stays `null`. `totalTokenCount` is documented as
133
+ * "prompt + thoughts + response candidates" — three SEPARATE, additive
134
+ * terms, which means `candidatesTokenCount` (-> output_token_count) does
135
+ * NOT already include `thoughtsTokenCount`. The schema requires
136
+ * `reasoning_tokens` to be a SUBSET of `output_tokens`, so:
137
+ * `output_tokens` = `output_token_count` + `thoughts_token_count`,
138
+ * `reasoning_tokens` = `thoughts_token_count`.
139
+ *
140
+ * **Unverified — kept out of the totals on purpose.** Whether
141
+ * `toolUsePromptTokenCount` is folded into `promptTokenCount` already, or is
142
+ * additive and simply omitted from the documented `totalTokenCount` formula,
143
+ * is not stated anywhere fetched for this adapter. `tool_token_count` is
144
+ * therefore recorded only in `metadata` (for an audit cross-check against
145
+ * `total_token_count`), never added into `input_tokens` or `output_tokens`.
146
+ *
147
+ * ## Privacy
148
+ *
149
+ * OTel GenAI attributes and Gemini CLI's own events can carry full prompt/
150
+ * response text (`gen_ai.input.messages`, `gen_ai.output.messages`,
151
+ * `response_text`, a log `body` describing the call). None of those keys are
152
+ * ever read by the mapping functions below, and `metadata` is built field by
153
+ * field from an explicit allow-list (never `{...attrs}`), so nothing "extra"
154
+ * can leak through even if a future attribute is added upstream. Per the
155
+ * product privacy rule, `metadata` string fields are limited to `model`
156
+ * (top-level schema field, not metadata), `provider`/`operation`/`status`,
157
+ * and `session_id`/`conversation_id` (also top-level) — `auth_type` and
158
+ * `finish_reasons` are deliberately NOT retained even though they are on the
159
+ * source records, because they are not on that allow-list.
160
+ *
161
+ * ## Discovery
162
+ *
163
+ * `sources.otel.paths` (array), if configured, is used verbatim (files or
164
+ * directories). Otherwise: `~/.gemini/telemetry.log` (the path from Gemini
165
+ * CLI's own worked example in docs/cli/telemetry.md — the setting has no
166
+ * fixed default there, `outfile` is `-` unless set, and a relative value
167
+ * resolves against the CLI's working directory, so this home-level guess is
168
+ * best-effort, not a documented default) and `~/.tokenflow/otel/` — a plain
169
+ * drop folder for any tool's OTLP file exporter, matching `*.jsonl`,
170
+ * `*.ndjson`, `*.json`, `*.log`. An explicit `outfile`/collector `file`
171
+ * exporter path pointed at that folder is the reliable setup; see
172
+ * docs/providers-otel.md.
173
+ */
174
+ import fs from 'node:fs';
175
+ import path from 'node:path';
176
+ import os from 'node:os';
177
+ import { createProvider } from '../../core/registry.js';
178
+ import { walk } from '../../core/ingest.js';
179
+ import { MEASUREMENT } from '../../core/schema.js';
180
+
181
+ const EXTS = ['.jsonl', '.ndjson', '.json', '.log'];
182
+
183
+ const GEMINI_API_RESPONSE = 'gemini_cli.api_response';
184
+ const GEMINI_SEMANTIC_TWIN = 'gen_ai.client.inference.operation.details';
185
+ const GEMINI_EVENT_PREFIX = 'gemini_cli.';
186
+
187
+ // ---------------------------------------------------------------- helpers --
188
+
189
+ function str(v) {
190
+ return v === undefined || v === null || v === '' ? null : String(v);
191
+ }
192
+ function numOrNull(v) {
193
+ if (v === undefined || v === null || v === '') return null;
194
+ const n = Number(v);
195
+ return Number.isFinite(n) ? n : null;
196
+ }
197
+ function expand(p, home) {
198
+ return p.startsWith('~') ? path.join(home, p.slice(1)) : p;
199
+ }
200
+
201
+ /** 19-digit nanosecond epoch string -> ISO. BigInt division avoids the
202
+ * precision loss `Number(nanoString)` would silently introduce. */
203
+ export function fromUnixNano(v) {
204
+ if (v === undefined || v === null) return null;
205
+ try {
206
+ const ns = typeof v === 'bigint' ? v : BigInt(String(v));
207
+ if (ns === 0n) return null;
208
+ const d = new Date(Number(ns / 1000000n));
209
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /** Millisecond duration between two nanosecond epoch strings, or null. */
216
+ export function nanoDiffMs(startV, endV) {
217
+ try {
218
+ const s = BigInt(String(startV));
219
+ const e = BigInt(String(endV));
220
+ if (e <= s) return null;
221
+ return Number((e - s) / 1000000n);
222
+ } catch {
223
+ return null;
224
+ }
225
+ }
226
+
227
+ /** OTLP wire `AnyValue` -> a plain JS scalar/array/object. */
228
+ function scalarFromOtlpValue(v) {
229
+ if (v === undefined || v === null || typeof v !== 'object') return v ?? null;
230
+ if ('stringValue' in v) return v.stringValue;
231
+ if ('intValue' in v) return Number(v.intValue);
232
+ if ('doubleValue' in v) return v.doubleValue;
233
+ if ('boolValue' in v) return v.boolValue;
234
+ if ('arrayValue' in v) return (v.arrayValue?.values || []).map(scalarFromOtlpValue);
235
+ if ('kvlistValue' in v) return flattenAttrs(v.kvlistValue?.values);
236
+ return null;
237
+ }
238
+
239
+ /**
240
+ * Accepts EITHER the OTLP wire shape (`[{key, value:{stringValue}}, ...]`)
241
+ * or an already-flat object (Gemini's own dump, or a hand-rolled line) and
242
+ * returns a flat `{key: scalar}` map either way.
243
+ */
244
+ export function flattenAttrs(x) {
245
+ if (!x) return {};
246
+ if (Array.isArray(x)) {
247
+ const out = {};
248
+ for (const kv of x) {
249
+ if (kv && typeof kv === 'object' && 'key' in kv) out[kv.key] = scalarFromOtlpValue(kv.value);
250
+ }
251
+ return out;
252
+ }
253
+ if (typeof x === 'object') return x;
254
+ return {};
255
+ }
256
+
257
+ function hasUsage(attrs) {
258
+ return (
259
+ attrs['gen_ai.usage.input_tokens'] !== undefined
260
+ || attrs['gen_ai.usage.output_tokens'] !== undefined
261
+ || attrs.input_token_count !== undefined
262
+ || attrs.output_token_count !== undefined
263
+ || attrs.cached_content_token_count !== undefined
264
+ || attrs.thoughts_token_count !== undefined
265
+ );
266
+ }
267
+
268
+ // --------------------------------------------------------- field mappers --
269
+
270
+ /**
271
+ * Gemini CLI's own `gemini_cli.api_response` event. See the module header
272
+ * for the verified token-inclusion rules this implements.
273
+ */
274
+ function fromGeminiApiResponse(attrs, { timestamp, durationMs }) {
275
+ const input = numOrNull(attrs.input_token_count);
276
+ const output = numOrNull(attrs.output_token_count);
277
+ const cached = numOrNull(attrs.cached_content_token_count);
278
+ const thoughts = numOrNull(attrs.thoughts_token_count);
279
+ const tool = numOrNull(attrs.tool_token_count);
280
+ const total = numOrNull(attrs.total_token_count);
281
+ if (input === null && output === null && cached === null && thoughts === null) return null;
282
+
283
+ const freshInput = input === null ? null : Math.max(0, input - (cached ?? 0));
284
+ const combinedOutput = output === null && thoughts === null ? null : (output ?? 0) + (thoughts ?? 0);
285
+
286
+ return {
287
+ timestamp: str(attrs['event.timestamp']) ?? timestamp ?? null,
288
+ model: str(attrs.model),
289
+ providerHint: 'google',
290
+ input_tokens: freshInput,
291
+ cache_read_tokens: cached,
292
+ cache_write_tokens: null,
293
+ output_tokens: combinedOutput,
294
+ reasoning_tokens: thoughts,
295
+ cache_refresh_tokens: null,
296
+ session_id: str(attrs['session.id']),
297
+ conversation_id: str(attrs['gen_ai.conversation.id']),
298
+ request_id: str(attrs.prompt_id),
299
+ duration_ms: numOrNull(attrs.duration_ms) ?? durationMs,
300
+ client: 'gemini-cli',
301
+ application: 'Gemini CLI',
302
+ interfaceSignals: [],
303
+ metadata: {
304
+ transport: 'otel',
305
+ operation: GEMINI_API_RESPONSE,
306
+ status: attrs.status_code ?? null,
307
+ tool_token_count: tool,
308
+ total_token_count_reported: total,
309
+ },
310
+ };
311
+ }
312
+
313
+ /** Standards-based `gen_ai.usage.*` — a span, a log record, or a span event. */
314
+ function fromGenAiUsage(attrs, { timestamp, requestId, resourceAttrs = {}, durationMs = null }) {
315
+ const inputRaw = numOrNull(attrs['gen_ai.usage.input_tokens']);
316
+ const output = numOrNull(attrs['gen_ai.usage.output_tokens']);
317
+ const cacheRead = numOrNull(attrs['gen_ai.usage.cache_read.input_tokens']);
318
+ const cacheWrite = numOrNull(attrs['gen_ai.usage.cache_write.input_tokens']);
319
+ const reasoning = numOrNull(attrs['gen_ai.usage.reasoning.output_tokens']);
320
+ if (inputRaw === null && output === null) return null;
321
+
322
+ const freshInput = inputRaw === null ? null : Math.max(0, inputRaw - (cacheRead ?? 0) - (cacheWrite ?? 0));
323
+ const model = str(attrs['gen_ai.response.model']) ?? str(attrs['gen_ai.request.model']);
324
+ // gen_ai.system is the pre-1.x attribute name; kept as a legacy fallback —
325
+ // it has zero references left in the current semantic-conventions-genai spec.
326
+ const providerHint = str(attrs['gen_ai.provider.name']) ?? str(attrs['gen_ai.system']);
327
+ const serviceName = str(resourceAttrs['service.name']);
328
+
329
+ return {
330
+ timestamp: timestamp ?? str(attrs['event.timestamp']),
331
+ model,
332
+ providerHint,
333
+ input_tokens: freshInput,
334
+ cache_read_tokens: cacheRead,
335
+ cache_write_tokens: cacheWrite,
336
+ output_tokens: output,
337
+ reasoning_tokens: reasoning,
338
+ cache_refresh_tokens: null,
339
+ session_id: str(attrs['gen_ai.conversation.id']) ?? str(attrs['session.id']) ?? str(resourceAttrs['session.id']),
340
+ conversation_id: str(attrs['gen_ai.conversation.id']),
341
+ request_id: requestId ?? str(attrs['gen_ai.response.id']),
342
+ duration_ms: durationMs,
343
+ client: serviceName,
344
+ application: serviceName,
345
+ interfaceSignals: [serviceName],
346
+ metadata: {
347
+ transport: 'otel',
348
+ operation: str(attrs['gen_ai.operation.name']),
349
+ status: str(attrs['error.type']),
350
+ },
351
+ };
352
+ }
353
+
354
+ /** Dispatch: Gemini-native dedup rule, then the standards-based mapping. */
355
+ function recordFromAttrs(attrs, opts) {
356
+ if (!attrs || typeof attrs !== 'object') return null;
357
+ const eventName = str(attrs['event.name']);
358
+ if (eventName === GEMINI_API_RESPONSE) return fromGeminiApiResponse(attrs, opts);
359
+ if (eventName && (eventName.startsWith(GEMINI_EVENT_PREFIX) || eventName === GEMINI_SEMANTIC_TWIN)) {
360
+ // A Gemini-native event that is not the api_response event, or the
361
+ // semantic twin of one we already counted — skip, not malformed.
362
+ return null;
363
+ }
364
+ // `telemetry.traces` (default false) also emits an `llm_call` SPAN with its
365
+ // own gen_ai.usage.* — same call, third copy. `gen_ai.agent.name: gemini-cli`
366
+ // is documented verbatim in telemetry.md's span attribute list, and
367
+ // `installation.id` is only ever set by Gemini's own getCommonAttributes(),
368
+ // so either one identifies a Gemini-native span with no event.name at all.
369
+ if (attrs['gen_ai.agent.name'] === 'gemini-cli' || attrs['installation.id'] !== undefined) return null;
370
+ if (!hasUsage(attrs)) return null;
371
+ return fromGenAiUsage(attrs, opts);
372
+ }
373
+
374
+ // -------------------------------------------------------- shape dispatch --
375
+
376
+ function collectResourceSpans(rs, out) {
377
+ const resourceAttrs = flattenAttrs(rs?.resource?.attributes);
378
+ for (const scope of rs?.scopeSpans || []) {
379
+ for (const sp of scope?.spans || []) {
380
+ const spanAttrs = flattenAttrs(sp.attributes);
381
+ const ts = fromUnixNano(sp.endTimeUnixNano) ?? fromUnixNano(sp.startTimeUnixNano);
382
+ const durationMs = nanoDiffMs(sp.startTimeUnixNano, sp.endTimeUnixNano);
383
+ const requestId = str(sp.spanId);
384
+ if (hasUsage(spanAttrs) || spanAttrs['event.name']) {
385
+ const rec = recordFromAttrs(spanAttrs, { timestamp: ts, requestId, resourceAttrs, durationMs });
386
+ if (rec) { out.push(rec); continue; }
387
+ }
388
+ // Usage sometimes lives on a span EVENT instead of the span's own
389
+ // attributes (e.g. a streamed call). Only look here when the span
390
+ // itself had nothing, so one call is never counted from both places.
391
+ for (const ev of sp.events || []) {
392
+ const evAttrs = { ...spanAttrs, ...flattenAttrs(ev.attributes) };
393
+ const rec = recordFromAttrs(evAttrs, {
394
+ timestamp: fromUnixNano(ev.timeUnixNano) ?? ts,
395
+ requestId,
396
+ resourceAttrs,
397
+ durationMs: null,
398
+ });
399
+ if (rec) out.push(rec);
400
+ }
401
+ }
402
+ }
403
+ }
404
+
405
+ function collectResourceLogs(rl, out) {
406
+ const resourceAttrs = flattenAttrs(rl?.resource?.attributes);
407
+ for (const scope of rl?.scopeLogs || []) {
408
+ for (const lr of scope?.logRecords || []) {
409
+ const attrs = flattenAttrs(lr.attributes);
410
+ const ts = fromUnixNano(lr.timeUnixNano) ?? fromUnixNano(lr.observedTimeUnixNano);
411
+ const rec = recordFromAttrs(attrs, {
412
+ timestamp: ts,
413
+ requestId: str(lr.spanId),
414
+ resourceAttrs,
415
+ durationMs: null,
416
+ });
417
+ if (rec) out.push(rec);
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * A bare, single-record JSON document: Gemini's raw `FileLogExporter` dump
424
+ * (`{attributes:{...}, resource:{...}, ...}`), or a hand-rolled line that IS
425
+ * the flat attributes map already.
426
+ */
427
+ function collectBare(doc, out) {
428
+ let attrs = flattenAttrs(doc.attributes);
429
+ if (!Object.keys(attrs).length && (doc['event.name'] !== undefined || hasUsage(doc))) attrs = doc;
430
+ if (!Object.keys(attrs).length) return;
431
+ // Best-effort only: a raw ReadableLogRecord/ReadableSpan's `resource` is an
432
+ // OTel-JS `Resource` instance whose `.attributes` is a getter over a
433
+ // private field, so `JSON.stringify` typically drops it — this succeeds
434
+ // only when the shape happens to already be a plain object.
435
+ const resourceAttrs = flattenAttrs(doc.resource?.attributes);
436
+ const ts = fromUnixNano(doc.timeUnixNano)
437
+ ?? fromUnixNano(doc.observedTimeUnixNano)
438
+ ?? fromUnixNano(doc.endTimeUnixNano)
439
+ ?? fromUnixNano(doc.startTimeUnixNano);
440
+ const durationMs = nanoDiffMs(doc.startTimeUnixNano, doc.endTimeUnixNano);
441
+ const rec = recordFromAttrs(attrs, {
442
+ timestamp: ts,
443
+ requestId: str(doc.spanId),
444
+ resourceAttrs,
445
+ durationMs,
446
+ });
447
+ if (rec) out.push(rec);
448
+ }
449
+
450
+ /**
451
+ * Turn one parsed top-level JSON value into zero or more partial usage
452
+ * records. Exposed for tests and dry-run importers, per the provider
453
+ * contract's optional `normalize()` hook.
454
+ * @param {unknown} doc
455
+ * @returns {object[]}
456
+ */
457
+ export function normalize(doc) {
458
+ const out = [];
459
+ if (Array.isArray(doc)) {
460
+ for (const d of doc) out.push(...normalize(d));
461
+ return out;
462
+ }
463
+ if (!doc || typeof doc !== 'object') return out;
464
+ const d = /** @type {{resourceSpans?:unknown[], resourceLogs?:unknown[]}} */ (doc);
465
+ if (Array.isArray(d.resourceSpans)) {
466
+ for (const rs of d.resourceSpans) collectResourceSpans(rs, out);
467
+ return out;
468
+ }
469
+ if (Array.isArray(d.resourceLogs)) {
470
+ for (const rl of d.resourceLogs) collectResourceLogs(rl, out);
471
+ return out;
472
+ }
473
+ collectBare(doc, out);
474
+ return out;
475
+ }
476
+
477
+ // ------------------------------------------------------------- scanning --
478
+
479
+ const OPEN = new Set([0x7b, 0x5b]); // '{' '['
480
+ const CLOSE = new Set([0x7d, 0x5d]); // '}' ']'
481
+
482
+ /**
483
+ * Scan a buffer for complete, balanced top-level JSON values. Handles
484
+ * compact JSON Lines, pretty-printed multi-line JSON concatenated with no
485
+ * separator, and a buffer that is a single JSON document, uniformly — a
486
+ * value only "completes" when its brace/bracket depth returns to 0, so a
487
+ * pretty-printed record spanning many lines is not mistaken for many
488
+ * records.
489
+ *
490
+ * Recovery: pretty-printed JSON (`JSON.stringify(x, null, N)`) and JSON
491
+ * Lines both share one property — a NESTED value is always indented, so a
492
+ * `{`/`[` at the very start of a line is always the start of a fresh
493
+ * top-level value. If the previously-open value never balances before that
494
+ * happens, it is abandoned (counted in `resynced`) rather than swallowing
495
+ * every byte after it for the rest of the file.
496
+ * @param {Buffer} buf
497
+ * @returns {{docs: {start:number,end:number,text:string}[], consumedEnd:number, resynced:number}}
498
+ */
499
+ export function scanJsonDocuments(buf) {
500
+ const docs = [];
501
+ let resynced = 0;
502
+ const n = buf.length;
503
+ let i = 0;
504
+ let depth = 0;
505
+ let inString = false;
506
+ let escape = false;
507
+ let docStart = -1;
508
+ let atLineStart = true;
509
+
510
+ while (i < n) {
511
+ const c = buf[i];
512
+
513
+ if (docStart === -1) {
514
+ if (c === 0x20 || c === 0x09 || c === 0x0d) { i++; atLineStart = false; continue; }
515
+ if (c === 0x0a) { i++; atLineStart = true; continue; }
516
+ if (OPEN.has(c)) {
517
+ docStart = i;
518
+ depth = 0; inString = false; escape = false;
519
+ // fall through: process this byte below in the same pass
520
+ } else {
521
+ i++; atLineStart = false; continue; // stray byte with nothing open: ignore
522
+ }
523
+ } else if (!inString && OPEN.has(c) && atLineStart) {
524
+ // The document that was open never balanced — abandon it.
525
+ resynced++;
526
+ docStart = i;
527
+ depth = 0; inString = false; escape = false;
528
+ }
529
+
530
+ if (inString) {
531
+ if (escape) escape = false;
532
+ else if (c === 0x5c) escape = true;
533
+ else if (c === 0x22) inString = false;
534
+ i++; atLineStart = false;
535
+ continue;
536
+ }
537
+ if (c === 0x22) { inString = true; i++; atLineStart = false; continue; }
538
+ if (OPEN.has(c)) { depth++; i++; atLineStart = false; continue; }
539
+ if (CLOSE.has(c)) {
540
+ depth--; i++; atLineStart = false;
541
+ if (depth === 0) {
542
+ docs.push({ start: docStart, end: i, text: buf.subarray(docStart, i).toString('utf8') });
543
+ docStart = -1;
544
+ }
545
+ continue;
546
+ }
547
+ if (c === 0x0a) { atLineStart = true; i++; continue; }
548
+ atLineStart = false;
549
+ i++;
550
+ }
551
+
552
+ const consumedEnd = docs.length ? docs[docs.length - 1].end : 0;
553
+ return { docs, consumedEnd, resynced };
554
+ }
555
+
556
+ // -------------------------------------------------------------- discover --
557
+
558
+ function candidatePaths(ctx) {
559
+ const home = ctx?.home || os.homedir();
560
+ const configured = ctx?.config?.sources?.otel?.paths;
561
+ if (Array.isArray(configured) && configured.length) return configured.map((p) => expand(p, home));
562
+ return [
563
+ path.join(home, '.gemini', 'telemetry.log'),
564
+ path.join(home, '.tokenflow', 'otel'),
565
+ ];
566
+ }
567
+
568
+ function statSafe(p) {
569
+ try {
570
+ return fs.statSync(p);
571
+ } catch {
572
+ return null;
573
+ }
574
+ }
575
+
576
+ // ------------------------------------------------------------------ ingest --
577
+
578
+ /** Read only the new bytes (`start` .. EOF) of a file into a Buffer. */
579
+ function readTail(file, start) {
580
+ const fd = fs.openSync(file, 'r');
581
+ try {
582
+ const stat = fs.fstatSync(fd);
583
+ const from = Math.min(start, stat.size);
584
+ const len = stat.size - from;
585
+ if (len <= 0) return { buf: Buffer.alloc(0), from };
586
+ const buf = Buffer.allocUnsafe(len);
587
+ fs.readSync(fd, buf, 0, len, from);
588
+ return { buf, from };
589
+ } finally {
590
+ fs.closeSync(fd);
591
+ }
592
+ }
593
+
594
+ export default createProvider({
595
+ id: 'otel',
596
+ name: 'OpenTelemetry (GenAI)',
597
+ description: 'Per-request usage from an OTLP file export, or from Gemini CLI\'s own file telemetry.',
598
+ measurement: MEASUREMENT.PRIMARY,
599
+ requires: ['~/.gemini/telemetry.log (Gemini CLI, telemetry.outfile) or an OTLP file export under ~/.tokenflow/otel/'],
600
+
601
+ async detect(ctx) {
602
+ const found = candidatePaths(ctx).filter((p) => {
603
+ const st = statSafe(p);
604
+ return st && (st.isFile() || st.isDirectory());
605
+ });
606
+ if (!found.length) {
607
+ return { available: false, detail: `no OTel GenAI export found (looked in ${candidatePaths(ctx).join(', ')})` };
608
+ }
609
+ return { available: true, detail: found.join(', '), paths: found };
610
+ },
611
+
612
+ async discover(ctx) {
613
+ const out = [];
614
+ for (const p of candidatePaths(ctx)) {
615
+ const st = statSafe(p);
616
+ if (!st) continue;
617
+ if (st.isFile()) {
618
+ if (st.size) out.push({ key: p, path: p, stat: st });
619
+ continue;
620
+ }
621
+ if (st.isDirectory()) {
622
+ for (const f of walk(p, (name) => EXTS.some((e) => name.endsWith(e)))) {
623
+ const fstat = statSafe(f);
624
+ if (!fstat || !fstat.size) continue;
625
+ out.push({ key: path.relative(p, f), path: f, stat: fstat });
626
+ }
627
+ }
628
+ }
629
+ return out;
630
+ },
631
+
632
+ async ingestFile(ref, ctx, emit) {
633
+ const { buf, from } = readTail(ref.path, ref.start || 0);
634
+ if (!buf.length) return { offset: from, records: 0, malformed: 0 };
635
+
636
+ const { docs, consumedEnd, resynced } = scanJsonDocuments(buf);
637
+ let records = 0;
638
+ let malformed = resynced;
639
+
640
+ for (const doc of docs) {
641
+ let parsed;
642
+ try {
643
+ parsed = JSON.parse(doc.text);
644
+ } catch {
645
+ malformed++;
646
+ continue;
647
+ }
648
+ for (const partial of normalize(parsed)) {
649
+ emit(partial);
650
+ records++;
651
+ }
652
+ }
653
+
654
+ return { offset: from + consumedEnd, records, malformed };
655
+ },
656
+ });