@vimoxshah/tokenflow 1.1.2 → 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.
- package/CHANGELOG.md +180 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-menubar-app.sh +3 -1
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- 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
|
+
});
|