@indigoai-us/hq-cloud 5.19.1 → 5.21.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/.github/workflows/ci.yml +8 -4
- package/.github/workflows/publish.yml +9 -3
- package/dist/bin/sync-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +58 -0
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/entity-resolver.d.ts +53 -0
- package/dist/entity-resolver.d.ts.map +1 -0
- package/dist/entity-resolver.js +127 -0
- package/dist/entity-resolver.js.map +1 -0
- package/dist/entity-resolver.test.d.ts +10 -0
- package/dist/entity-resolver.test.d.ts.map +1 -0
- package/dist/entity-resolver.test.js +244 -0
- package/dist/entity-resolver.test.js.map +1 -0
- package/dist/index.d.ts +17 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/signal-types.d.ts +16 -0
- package/dist/schemas/signal-types.d.ts.map +1 -0
- package/dist/schemas/signal-types.js +30 -0
- package/dist/schemas/signal-types.js.map +1 -0
- package/dist/schemas/signal-types.test.d.ts +2 -0
- package/dist/schemas/signal-types.test.d.ts.map +1 -0
- package/dist/schemas/signal-types.test.js +65 -0
- package/dist/schemas/signal-types.test.js.map +1 -0
- package/dist/schemas/source-channels.d.ts +15 -0
- package/dist/schemas/source-channels.d.ts.map +1 -0
- package/dist/schemas/source-channels.js +28 -0
- package/dist/schemas/source-channels.js.map +1 -0
- package/dist/schemas/source-channels.test.d.ts +2 -0
- package/dist/schemas/source-channels.test.d.ts.map +1 -0
- package/dist/schemas/source-channels.test.js +65 -0
- package/dist/schemas/source-channels.test.js.map +1 -0
- package/dist/signals/get.d.ts +13 -0
- package/dist/signals/get.d.ts.map +1 -0
- package/dist/signals/get.js +74 -0
- package/dist/signals/get.js.map +1 -0
- package/dist/signals/get.test.d.ts +5 -0
- package/dist/signals/get.test.d.ts.map +1 -0
- package/dist/signals/get.test.js +170 -0
- package/dist/signals/get.test.js.map +1 -0
- package/dist/signals/internals.d.ts +16 -0
- package/dist/signals/internals.d.ts.map +1 -0
- package/dist/signals/internals.js +39 -0
- package/dist/signals/internals.js.map +1 -0
- package/dist/signals/list.d.ts +10 -0
- package/dist/signals/list.d.ts.map +1 -0
- package/dist/signals/list.js +76 -0
- package/dist/signals/list.js.map +1 -0
- package/dist/signals/list.test.d.ts +9 -0
- package/dist/signals/list.test.d.ts.map +1 -0
- package/dist/signals/list.test.js +227 -0
- package/dist/signals/list.test.js.map +1 -0
- package/dist/signals/parse.d.ts +8 -0
- package/dist/signals/parse.d.ts.map +1 -0
- package/dist/signals/parse.js +8 -0
- package/dist/signals/parse.js.map +1 -0
- package/dist/signals/types.d.ts +69 -0
- package/dist/signals/types.d.ts.map +1 -0
- package/dist/signals/types.js +10 -0
- package/dist/signals/types.js.map +1 -0
- package/dist/sources/get.d.ts +11 -0
- package/dist/sources/get.d.ts.map +1 -0
- package/dist/sources/get.js +67 -0
- package/dist/sources/get.js.map +1 -0
- package/dist/sources/get.test.d.ts +5 -0
- package/dist/sources/get.test.d.ts.map +1 -0
- package/dist/sources/get.test.js +132 -0
- package/dist/sources/get.test.js.map +1 -0
- package/dist/sources/internals.d.ts +16 -0
- package/dist/sources/internals.d.ts.map +1 -0
- package/dist/sources/internals.js +39 -0
- package/dist/sources/internals.js.map +1 -0
- package/dist/sources/list.d.ts +10 -0
- package/dist/sources/list.d.ts.map +1 -0
- package/dist/sources/list.js +76 -0
- package/dist/sources/list.js.map +1 -0
- package/dist/sources/list.test.d.ts +8 -0
- package/dist/sources/list.test.d.ts.map +1 -0
- package/dist/sources/list.test.js +198 -0
- package/dist/sources/list.test.js.map +1 -0
- package/dist/sources/parse.d.ts +18 -0
- package/dist/sources/parse.d.ts.map +1 -0
- package/dist/sources/parse.js +35 -0
- package/dist/sources/parse.js.map +1 -0
- package/dist/sources/types.d.ts +62 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +8 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/telemetry.d.ts +87 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +349 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/telemetry.test.d.ts +11 -0
- package/dist/telemetry.test.d.ts.map +1 -0
- package/dist/telemetry.test.js +309 -0
- package/dist/telemetry.test.js.map +1 -0
- package/dist/vault-client.d.ts +43 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +28 -0
- package/dist/vault-client.js.map +1 -1
- package/package.json +5 -3
- package/src/bin/sync-runner.ts +73 -0
- package/src/entity-resolver.test.ts +315 -0
- package/src/entity-resolver.ts +180 -0
- package/src/index.ts +76 -0
- package/src/schemas/signal-types.test.ts +82 -0
- package/src/schemas/signal-types.ts +38 -0
- package/src/schemas/source-channels.test.ts +82 -0
- package/src/schemas/source-channels.ts +36 -0
- package/src/signals/get.test.ts +204 -0
- package/src/signals/get.ts +79 -0
- package/src/signals/internals.ts +46 -0
- package/src/signals/list.test.ts +283 -0
- package/src/signals/list.ts +92 -0
- package/src/signals/parse.ts +8 -0
- package/src/signals/types.ts +74 -0
- package/src/sources/get.test.ts +166 -0
- package/src/sources/get.ts +75 -0
- package/src/sources/internals.ts +46 -0
- package/src/sources/list.test.ts +247 -0
- package/src/sources/list.ts +95 -0
- package/src/sources/parse.ts +43 -0
- package/src/sources/types.ts +67 -0
- package/src/telemetry.test.ts +394 -0
- package/src/telemetry.ts +436 -0
- package/src/vault-client.ts +60 -0
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage telemetry collector — TypeScript port of the Tauri Rust collector that
|
|
3
|
+
* used to live at `hq-workspace/apps/hq-sync/src-tauri/src/commands/telemetry.rs`.
|
|
4
|
+
*
|
|
5
|
+
* Why it moved: the Rust copy only ran inside the macOS menubar app. By moving
|
|
6
|
+
* the logic into `@indigoai-us/hq-cloud`, every consumer of the package
|
|
7
|
+
* (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
|
|
8
|
+
*
|
|
9
|
+
* What it does: after each successful sync (`all-complete` arm of
|
|
10
|
+
* `bin/sync-runner.ts`), walks `~/.claude/projects/**\/*.jsonl`, diffs each
|
|
11
|
+
* file against a persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`,
|
|
12
|
+
* sanitizes new rows through a tight allowlist that matches the server's
|
|
13
|
+
* KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
|
|
14
|
+
* batches into ≤1 MiB POST bodies, and ships them to `/v1/usage`.
|
|
15
|
+
*
|
|
16
|
+
* Trust model: the caller's `personUid` is resolved on the server from the
|
|
17
|
+
* Cognito JWT — never from the body. `sanitizeRow` strips prompt bodies,
|
|
18
|
+
* thinking content, tool inputs/outputs, and any nested `message` object so
|
|
19
|
+
* the wire payload contains only token-accounting fields.
|
|
20
|
+
*
|
|
21
|
+
* Errors are swallowed by design — telemetry must never abort or delay a
|
|
22
|
+
* sync. The cursor is only advanced for batches the server 2xx'd, so a
|
|
23
|
+
* transient outage retries automatically on the next sync.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { promises as fs } from "node:fs";
|
|
27
|
+
import * as os from "node:os";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
|
|
30
|
+
import type {
|
|
31
|
+
TelemetryOptInResponse,
|
|
32
|
+
UsageBatch,
|
|
33
|
+
UsageIngestResult,
|
|
34
|
+
} from "./vault-client.js";
|
|
35
|
+
|
|
36
|
+
// ── Public surface ────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Minimal subset of `VaultClient` the collector needs. Declared as an
|
|
40
|
+
* interface so tests can inject a stub without spinning up a fetch mock.
|
|
41
|
+
* The real `VaultClient` from `./vault-client.js` satisfies this structurally.
|
|
42
|
+
*/
|
|
43
|
+
export interface TelemetryClientSurface {
|
|
44
|
+
getTelemetryOptIn(): Promise<TelemetryOptInResponse>;
|
|
45
|
+
postUsage(batch: UsageBatch): Promise<UsageIngestResult>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CollectTelemetryOptions {
|
|
49
|
+
client: TelemetryClientSurface;
|
|
50
|
+
/** Stable per-machine id. The Tauri menubar reads this from `~/.hq/menubar.json`; the runner can pass it through or generate one once and cache. */
|
|
51
|
+
machineId: string;
|
|
52
|
+
/** Version of the wrapping caller (menubar app, CLI, etc.). Reaches CloudWatch metrics as the `installerVersion` dimension. */
|
|
53
|
+
installerVersion: string;
|
|
54
|
+
/** Override `~/.claude/projects` for tests. */
|
|
55
|
+
claudeProjectsRoot?: string;
|
|
56
|
+
/** Override `~/.hq/telemetry-cursor.json` for tests. */
|
|
57
|
+
cursorPath?: string;
|
|
58
|
+
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
59
|
+
menubarPath?: string;
|
|
60
|
+
/** Diagnostic sink. No-op by default. */
|
|
61
|
+
log?: (msg: string) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CollectTelemetryResult {
|
|
65
|
+
/** Whether the opt-in check resolved to true (either server-side or via the menubar fallback). When false, nothing else ran. */
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
/** Source for the `enabled` decision — useful for diagnosing missing-events reports. */
|
|
68
|
+
optInSource: "server" | "menubar-fallback" | "skipped";
|
|
69
|
+
/** How many `.jsonl` files we considered (before the cursor diff). */
|
|
70
|
+
filesScanned: number;
|
|
71
|
+
/** Total events successfully POSTed across all batches. */
|
|
72
|
+
eventsSent: number;
|
|
73
|
+
/** Number of `POST /v1/usage` requests made. */
|
|
74
|
+
batchesSent: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Cursor schema ─────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
interface CursorEntry {
|
|
80
|
+
offset: number;
|
|
81
|
+
mtime: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface TelemetryCursor {
|
|
85
|
+
version: string;
|
|
86
|
+
files: Record<string, CursorEntry>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function emptyCursor(): TelemetryCursor {
|
|
90
|
+
return { version: "1", files: {} };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function loadCursor(cursorPath: string): Promise<TelemetryCursor> {
|
|
94
|
+
try {
|
|
95
|
+
const raw = await fs.readFile(cursorPath, "utf-8");
|
|
96
|
+
const parsed = JSON.parse(raw) as Partial<TelemetryCursor>;
|
|
97
|
+
if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
|
|
98
|
+
return { version: parsed.version ?? "1", files: parsed.files as Record<string, CursorEntry> };
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Missing / unparseable — start fresh.
|
|
102
|
+
}
|
|
103
|
+
return emptyCursor();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function saveCursor(cursorPath: string, cursor: TelemetryCursor): Promise<void> {
|
|
107
|
+
// Atomic write: tmp + rename. The Rust impl uses the same .tmp suffix; we
|
|
108
|
+
// keep it for cross-implementation grep-ability.
|
|
109
|
+
await fs.mkdir(path.dirname(cursorPath), { recursive: true });
|
|
110
|
+
const tmp = `${cursorPath}.tmp`;
|
|
111
|
+
await fs.writeFile(tmp, JSON.stringify(cursor, null, 2), "utf-8");
|
|
112
|
+
await fs.rename(tmp, cursorPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Local opt-in fallback ─────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
async function readLocalTelemetryEnabled(menubarPath: string): Promise<boolean> {
|
|
118
|
+
try {
|
|
119
|
+
const raw = await fs.readFile(menubarPath, "utf-8");
|
|
120
|
+
const parsed = JSON.parse(raw) as { telemetryEnabled?: unknown };
|
|
121
|
+
return parsed.telemetryEnabled === true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Sanitizer ─────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** Top-level fields the server accepts. Keep aligned with `KEEP_FIELDS` in
|
|
130
|
+
* `apps/hq-pro/src/vault-service/handlers/usage.ts` — any drift will surface
|
|
131
|
+
* as an `unexpected-event-field` rejection in `UsageIngestResult.skipped`. */
|
|
132
|
+
const KEEP_TOP_LEVEL = [
|
|
133
|
+
"sessionId",
|
|
134
|
+
"timestamp",
|
|
135
|
+
"uuid",
|
|
136
|
+
"cwd",
|
|
137
|
+
"gitBranch",
|
|
138
|
+
"userType",
|
|
139
|
+
] as const;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build an outgoing event row matching the server's KEEP allowlist.
|
|
143
|
+
*
|
|
144
|
+
* Two transforms:
|
|
145
|
+
* 1. Top-level fields are copied straight through (string identity).
|
|
146
|
+
* 2. `message.model` and `message.usage.{input_tokens, output_tokens,
|
|
147
|
+
* cache_creation_input_tokens, cache_read_input_tokens}` are promoted to
|
|
148
|
+
* camelCase top-level fields. The original `message` object — which
|
|
149
|
+
* carries prompt/response text, thinking, and tool data — is dropped.
|
|
150
|
+
*
|
|
151
|
+
* Returns `null` when the input isn't an object. Empty results (e.g. a row
|
|
152
|
+
* with no recognised fields) are still returned as `{}` and emitted; the
|
|
153
|
+
* server accepts empty rows and they're useful as a "Claude Code was run at
|
|
154
|
+
* this time" heartbeat.
|
|
155
|
+
*/
|
|
156
|
+
export function sanitizeRow(row: unknown): Record<string, unknown> | null {
|
|
157
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) return null;
|
|
158
|
+
const obj = row as Record<string, unknown>;
|
|
159
|
+
const out: Record<string, unknown> = {};
|
|
160
|
+
|
|
161
|
+
for (const key of KEEP_TOP_LEVEL) {
|
|
162
|
+
if (key in obj) {
|
|
163
|
+
out[key] = obj[key];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const message = obj.message;
|
|
168
|
+
if (message && typeof message === "object" && !Array.isArray(message)) {
|
|
169
|
+
const m = message as Record<string, unknown>;
|
|
170
|
+
if ("model" in m) out.model = m.model;
|
|
171
|
+
const usage = m.usage;
|
|
172
|
+
if (usage && typeof usage === "object" && !Array.isArray(usage)) {
|
|
173
|
+
const u = usage as Record<string, unknown>;
|
|
174
|
+
if ("input_tokens" in u) out.inputTokens = u.input_tokens;
|
|
175
|
+
if ("output_tokens" in u) out.outputTokens = u.output_tokens;
|
|
176
|
+
if ("cache_creation_input_tokens" in u) {
|
|
177
|
+
out.cacheCreationInputTokens = u.cache_creation_input_tokens;
|
|
178
|
+
}
|
|
179
|
+
if ("cache_read_input_tokens" in u) {
|
|
180
|
+
out.cacheReadInputTokens = u.cache_read_input_tokens;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── File walker ───────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
/** Recursively collect every `.jsonl` file under `root`. Skips errors silently
|
|
191
|
+
* (missing dir, EACCES on a stray subdir) — anything we can't enter is
|
|
192
|
+
* treated as absent rather than fatal, matching the Rust glob behavior. */
|
|
193
|
+
async function listJsonlFiles(root: string): Promise<string[]> {
|
|
194
|
+
const out: string[] = [];
|
|
195
|
+
async function walk(dir: string): Promise<void> {
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const ent of entries) {
|
|
203
|
+
const full = path.join(dir, ent.name);
|
|
204
|
+
if (ent.isDirectory()) {
|
|
205
|
+
await walk(full);
|
|
206
|
+
} else if (ent.isFile() && ent.name.endsWith(".jsonl")) {
|
|
207
|
+
out.push(full);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
await walk(root);
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Batching primitives ───────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const MAX_BATCH_BYTES = 1_000_000;
|
|
218
|
+
|
|
219
|
+
interface RowSource {
|
|
220
|
+
filePath: string;
|
|
221
|
+
endOffset: number;
|
|
222
|
+
mtime: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Byte cost of the fixed wire-payload skeleton:
|
|
227
|
+
* {"machineId":"…","installerVersion":"…","events":[]}
|
|
228
|
+
*
|
|
229
|
+
* The Rust implementation re-serializes the entire growing batch on every
|
|
230
|
+
* row to check size — O(n²) bytes of JSON.stringify work per batch, which
|
|
231
|
+
* for 60K events takes ~4 minutes wall-clock in V8. This computes the same
|
|
232
|
+
* payload size incrementally instead: skeleton + Σ(per-row JSON length) +
|
|
233
|
+
* commas between rows. Same threshold semantics, O(n) total cost.
|
|
234
|
+
*/
|
|
235
|
+
function envelopeBytes(machineId: string, installerVersion: string): number {
|
|
236
|
+
// Serialize the empty-events envelope once and measure it. Captures the
|
|
237
|
+
// exact JSON whitespace / escaping V8 produces so we match what would
|
|
238
|
+
// actually go over the wire.
|
|
239
|
+
return Buffer.byteLength(
|
|
240
|
+
JSON.stringify({ machineId, installerVersion, events: [] }),
|
|
241
|
+
"utf-8",
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Scan, sanitize, and POST any new Claude Code session rows.
|
|
249
|
+
*
|
|
250
|
+
* Fire-and-forget from the caller's perspective: errors are caught internally
|
|
251
|
+
* and surfaced only via `log`. The returned summary lets observers (e.g.
|
|
252
|
+
* sync-runner) decide whether to record a "telemetry attempted" breadcrumb,
|
|
253
|
+
* but no consumer is expected to react to it.
|
|
254
|
+
*/
|
|
255
|
+
export async function collectAndSendTelemetry(
|
|
256
|
+
opts: CollectTelemetryOptions,
|
|
257
|
+
): Promise<CollectTelemetryResult> {
|
|
258
|
+
const home = os.homedir();
|
|
259
|
+
const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
|
|
260
|
+
const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
|
|
261
|
+
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
262
|
+
const log = opts.log ?? (() => {});
|
|
263
|
+
|
|
264
|
+
// 1. Opt-in check (server-authoritative, with local fallback).
|
|
265
|
+
let enabled: boolean;
|
|
266
|
+
let optInSource: CollectTelemetryResult["optInSource"];
|
|
267
|
+
try {
|
|
268
|
+
const resp = await opts.client.getTelemetryOptIn();
|
|
269
|
+
enabled = resp.enabled === true;
|
|
270
|
+
optInSource = "server";
|
|
271
|
+
} catch (err) {
|
|
272
|
+
log(`[telemetry] opt-in check failed (${(err as Error).message ?? err}) — falling back to local menubar.json`);
|
|
273
|
+
enabled = await readLocalTelemetryEnabled(menubarPath);
|
|
274
|
+
optInSource = "menubar-fallback";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!enabled) {
|
|
278
|
+
return { enabled: false, optInSource, filesScanned: 0, eventsSent: 0, batchesSent: 0 };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 2. Cursor + file enumeration.
|
|
282
|
+
const cursor = await loadCursor(cursorPath);
|
|
283
|
+
const loadedFiles = { ...cursor.files };
|
|
284
|
+
const rotationResets: Record<string, CursorEntry> = {};
|
|
285
|
+
const newlyCommitted: Record<string, CursorEntry> = {};
|
|
286
|
+
|
|
287
|
+
const files = await listJsonlFiles(claudeProjectsRoot);
|
|
288
|
+
|
|
289
|
+
// 3. Walk each file, sanitize new rows, batch, flush at 1 MiB.
|
|
290
|
+
//
|
|
291
|
+
// Byte accounting is incremental: we track `batchBytes` as the projected
|
|
292
|
+
// serialized size of the current batch (envelope + per-row JSON + commas).
|
|
293
|
+
// Each row contributes its own JSON.stringify length once; we never
|
|
294
|
+
// re-serialize the growing batch. This is the O(n) replacement for the
|
|
295
|
+
// O(n²) projected-payload check the Rust impl uses (which spent ~4 min
|
|
296
|
+
// on a 60K-event first-run in the E2E smoke against hq-prod).
|
|
297
|
+
const ENVELOPE_BYTES = envelopeBytes(opts.machineId, opts.installerVersion);
|
|
298
|
+
let batchEvents: Array<Record<string, unknown>> = [];
|
|
299
|
+
let batchSources: RowSource[] = [];
|
|
300
|
+
let batchBytes = ENVELOPE_BYTES;
|
|
301
|
+
let eventsSent = 0;
|
|
302
|
+
let batchesSent = 0;
|
|
303
|
+
|
|
304
|
+
const flush = async (): Promise<void> => {
|
|
305
|
+
if (batchEvents.length === 0) return;
|
|
306
|
+
const events = batchEvents;
|
|
307
|
+
const sources = batchSources;
|
|
308
|
+
batchEvents = [];
|
|
309
|
+
batchSources = [];
|
|
310
|
+
batchBytes = ENVELOPE_BYTES;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
await opts.client.postUsage({
|
|
314
|
+
machineId: opts.machineId,
|
|
315
|
+
installerVersion: opts.installerVersion,
|
|
316
|
+
events,
|
|
317
|
+
});
|
|
318
|
+
batchesSent++;
|
|
319
|
+
eventsSent += events.length;
|
|
320
|
+
// Advance cursor to max(endOffset) per file in this batch.
|
|
321
|
+
const maxPerFile = new Map<string, { mtime: number; offset: number }>();
|
|
322
|
+
for (const src of sources) {
|
|
323
|
+
const cur = maxPerFile.get(src.filePath);
|
|
324
|
+
if (!cur || src.endOffset > cur.offset) {
|
|
325
|
+
maxPerFile.set(src.filePath, { mtime: src.mtime, offset: src.endOffset });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
for (const [fp, entry] of maxPerFile) {
|
|
329
|
+
newlyCommitted[fp] = { offset: entry.offset, mtime: entry.mtime };
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
log(`[telemetry] postUsage failed (${(err as Error).message ?? err}) — cursor not advanced for ${sources.length} rows`);
|
|
333
|
+
// Cursor intentionally left un-advanced — next sync retries.
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
for (const filePath of files) {
|
|
338
|
+
let stat;
|
|
339
|
+
try {
|
|
340
|
+
stat = await fs.stat(filePath);
|
|
341
|
+
} catch {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const currentSize = stat.size;
|
|
345
|
+
const currentMtime = Math.floor(stat.mtimeMs / 1000);
|
|
346
|
+
|
|
347
|
+
const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
|
|
348
|
+
let offset = stored.offset;
|
|
349
|
+
|
|
350
|
+
const rotated =
|
|
351
|
+
currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
|
|
352
|
+
if (rotated) {
|
|
353
|
+
offset = 0;
|
|
354
|
+
rotationResets[filePath] = { offset: 0, mtime: currentMtime };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (offset >= currentSize && !rotated) continue;
|
|
358
|
+
|
|
359
|
+
let content: string;
|
|
360
|
+
try {
|
|
361
|
+
const fh = await fs.open(filePath, "r");
|
|
362
|
+
try {
|
|
363
|
+
const length = Math.max(0, currentSize - offset);
|
|
364
|
+
const buf = Buffer.alloc(length);
|
|
365
|
+
await fh.read(buf, 0, length, offset);
|
|
366
|
+
content = buf.toString("utf-8");
|
|
367
|
+
} finally {
|
|
368
|
+
await fh.close();
|
|
369
|
+
}
|
|
370
|
+
} catch {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (content.length === 0) continue;
|
|
374
|
+
|
|
375
|
+
const segments = content.split("\n");
|
|
376
|
+
const lineEndOffsets: number[] = [];
|
|
377
|
+
let cumulative = 0;
|
|
378
|
+
for (let i = 0; i < segments.length; i++) {
|
|
379
|
+
cumulative += Buffer.byteLength(segments[i], "utf-8");
|
|
380
|
+
if (i < segments.length - 1) cumulative += 1; // newline byte
|
|
381
|
+
lineEndOffsets.push(offset + cumulative);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (let i = 0; i < segments.length; i++) {
|
|
385
|
+
const trimmed = segments[i].trim();
|
|
386
|
+
if (trimmed.length === 0) continue;
|
|
387
|
+
let parsed: unknown;
|
|
388
|
+
try {
|
|
389
|
+
parsed = JSON.parse(trimmed);
|
|
390
|
+
} catch {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const sanitized = sanitizeRow(parsed);
|
|
394
|
+
if (!sanitized) continue;
|
|
395
|
+
|
|
396
|
+
// Cost of appending this row to the current batch: the row's JSON
|
|
397
|
+
// length plus 1 byte for the leading comma when there's already at
|
|
398
|
+
// least one row. (No comma when the batch is empty — the row sits
|
|
399
|
+
// alone inside the events array.)
|
|
400
|
+
const rowJsonBytes = Buffer.byteLength(JSON.stringify(sanitized), "utf-8");
|
|
401
|
+
const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
|
|
402
|
+
|
|
403
|
+
if (batchEvents.length > 0 && batchBytes + addCost > MAX_BATCH_BYTES) {
|
|
404
|
+
await flush();
|
|
405
|
+
// After flush, batchEvents is empty → no comma needed for the first row.
|
|
406
|
+
batchBytes = ENVELOPE_BYTES + rowJsonBytes;
|
|
407
|
+
} else {
|
|
408
|
+
batchBytes += addCost;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
batchEvents.push(sanitized);
|
|
412
|
+
batchSources.push({
|
|
413
|
+
filePath,
|
|
414
|
+
endOffset: lineEndOffsets[i],
|
|
415
|
+
mtime: currentMtime,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
await flush();
|
|
421
|
+
|
|
422
|
+
// 4. Persist cursor: loaded < rotation_resets < newly_committed.
|
|
423
|
+
const finalFiles: Record<string, CursorEntry> = { ...loadedFiles };
|
|
424
|
+
for (const [fp, entry] of Object.entries(rotationResets)) finalFiles[fp] = entry;
|
|
425
|
+
for (const [fp, entry] of Object.entries(newlyCommitted)) finalFiles[fp] = entry;
|
|
426
|
+
|
|
427
|
+
await saveCursor(cursorPath, { version: "1", files: finalFiles });
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
enabled: true,
|
|
431
|
+
optInSource,
|
|
432
|
+
filesScanned: files.length,
|
|
433
|
+
eventsSent,
|
|
434
|
+
batchesSent,
|
|
435
|
+
};
|
|
436
|
+
}
|
package/src/vault-client.ts
CHANGED
|
@@ -192,6 +192,35 @@ export interface VendChildResult {
|
|
|
192
192
|
expiresAt: string;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Usage telemetry (hq-pro `/v1/usage` + `/v1/usage/opt-in`)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
export interface TelemetryOptInResponse {
|
|
200
|
+
enabled: boolean;
|
|
201
|
+
updatedAt: string | null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface UsageBatch {
|
|
205
|
+
machineId: string;
|
|
206
|
+
installerVersion: string;
|
|
207
|
+
/**
|
|
208
|
+
* Sanitized event rows. Each row is a plain object containing only the
|
|
209
|
+
* fields in the server's KEEP allowlist (sessionId, timestamp, uuid, cwd,
|
|
210
|
+
* gitBranch, userType, model, inputTokens, outputTokens,
|
|
211
|
+
* cacheCreationInputTokens, cacheReadInputTokens). Any extra field is
|
|
212
|
+
* rejected by hq-pro with `unexpected-event-field`, so the sanitizer in
|
|
213
|
+
* `./telemetry.ts` is the only thing allowed to produce these.
|
|
214
|
+
*/
|
|
215
|
+
events: Array<Record<string, unknown>>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface UsageIngestResult {
|
|
219
|
+
ok: boolean;
|
|
220
|
+
written: number;
|
|
221
|
+
skipped: Array<{ index: number; code: string; error: string }>;
|
|
222
|
+
}
|
|
223
|
+
|
|
195
224
|
// ---------------------------------------------------------------------------
|
|
196
225
|
// Retry config
|
|
197
226
|
// ---------------------------------------------------------------------------
|
|
@@ -466,6 +495,37 @@ export class VaultClient {
|
|
|
466
495
|
},
|
|
467
496
|
};
|
|
468
497
|
|
|
498
|
+
// -- Usage telemetry ------------------------------------------------------
|
|
499
|
+
//
|
|
500
|
+
// The server resolves `personUid` from the JWT (`extractCallerSub` →
|
|
501
|
+
// `resolveCallerPersonUid` in hq-pro `src/vault-service/handlers/_shared.ts`)
|
|
502
|
+
// and explicitly rejects any request body that carries a top-level
|
|
503
|
+
// `personUid`. So clients only send `{ machineId, installerVersion, events }`
|
|
504
|
+
// — tenant isolation is preserved no matter how this client is wrapped.
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* `GET /v1/usage/opt-in` — read whether the authenticated caller has opted
|
|
508
|
+
* in to per-event usage telemetry. Defaults to `false` server-side when the
|
|
509
|
+
* person row carries no `telemetryOptIn` field. Callers should treat any
|
|
510
|
+
* thrown error as "unknown — fall back to the local gate" rather than
|
|
511
|
+
* either yes or no; see `./telemetry.ts::collectAndSendTelemetry`.
|
|
512
|
+
*/
|
|
513
|
+
async getTelemetryOptIn(): Promise<TelemetryOptInResponse> {
|
|
514
|
+
return this.get<TelemetryOptInResponse>("/v1/usage/opt-in");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* `POST /v1/usage` — upload a batch of sanitized telemetry events.
|
|
519
|
+
*
|
|
520
|
+
* `personUid` MUST NOT appear in the batch — server-side resolution from
|
|
521
|
+
* the JWT is the only path. The server caps the body at 256 KiB and the
|
|
522
|
+
* event list at 100 rows; the collector in `./telemetry.ts` enforces a
|
|
523
|
+
* 1 MiB pre-flush cap which is the binding limit in practice.
|
|
524
|
+
*/
|
|
525
|
+
async postUsage(batch: UsageBatch): Promise<UsageIngestResult> {
|
|
526
|
+
return this.post<UsageIngestResult>("/v1/usage", batch);
|
|
527
|
+
}
|
|
528
|
+
|
|
469
529
|
// -- HTTP primitives with retry -------------------------------------------
|
|
470
530
|
|
|
471
531
|
private async get<T>(path: string): Promise<T> {
|