@indigoai-us/hq-cloud 6.14.29 → 6.14.31
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/dist/telemetry.d.ts +9 -4
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +338 -53
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +230 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +1 -1
- package/src/telemetry.test.ts +286 -0
- package/src/telemetry.ts +411 -59
package/dist/telemetry.d.ts
CHANGED
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
* (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
|
|
8
8
|
*
|
|
9
9
|
* What it does: after each successful sync (`all-complete` arm of
|
|
10
|
-
* `bin/sync-runner.ts`), walks
|
|
11
|
-
* file against
|
|
12
|
-
* sanitizes new rows through a tight allowlist that matches the server's
|
|
10
|
+
* `bin/sync-runner.ts`), walks Claude session logs plus live and archived Codex
|
|
11
|
+
* rollouts, then diffs each file against the persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`, sanitizes new rows through a tight allowlist that matches the server's
|
|
13
12
|
* KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
|
|
14
13
|
* batches into server-sized POST bodies, and ships them to `/v1/usage`.
|
|
15
14
|
*
|
|
@@ -58,6 +57,12 @@ export interface CollectTelemetryOptions {
|
|
|
58
57
|
hqRoot?: string;
|
|
59
58
|
/** Override `~/.claude/projects` for tests. */
|
|
60
59
|
claudeProjectsRoot?: string;
|
|
60
|
+
/** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
|
|
61
|
+
codexRoot?: string;
|
|
62
|
+
/** Override the per-runtime scan budget for deterministic tests. */
|
|
63
|
+
maxScanBytesPerSource?: number;
|
|
64
|
+
/** Override the upload batch cap for deterministic tests. */
|
|
65
|
+
maxBatchesPerRun?: number;
|
|
61
66
|
/** Override `~/.hq/telemetry-cursor.json` for tests. */
|
|
62
67
|
cursorPath?: string;
|
|
63
68
|
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
@@ -124,7 +129,7 @@ export declare function extractServices(message: unknown): Record<string, number
|
|
|
124
129
|
*/
|
|
125
130
|
export declare function sanitizeRow(row: unknown, companyUid?: string): Record<string, unknown> | null;
|
|
126
131
|
/**
|
|
127
|
-
* Scan, sanitize, and POST
|
|
132
|
+
* Scan, sanitize, and POST new Claude Code and Codex usage rows.
|
|
128
133
|
*
|
|
129
134
|
* Fire-and-forget from the caller's perspective: errors are caught internally
|
|
130
135
|
* and surfaced only via `log`. The returned summary lets observers (e.g.
|
package/dist/telemetry.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../src/telemetry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAYH,OAAO,KAAK,EACV,sBAAsB,EACtB,UAAU,EACV,iBAAiB,EAClB,MAAM,mBAAmB,CAAC;AAI3B;;;;GAIG;AACH,MAAM,WAAW,sBAAsB;IACrC,iBAAiB,IAAI,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACrD,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IACzD;;;OAGG;IACH,iBAAiB,CAAC,CAChB,OAAO,EAAE,OAAO,EAChB,IAAI,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/B,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,sBAAsB,CAAC;IAC/B,oJAAoJ;IACpJ,SAAS,EAAE,MAAM,CAAC;IAClB,+HAA+H;IAC/H,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oEAAoE;IACpE,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,yCAAyC;IACzC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,sBAAsB;IACrC,gIAAgI;IAChI,OAAO,EAAE,OAAO,CAAC;IACjB,wFAAwF;IACxF,WAAW,EAAE,QAAQ,GAAG,kBAAkB,GAAG,oBAAoB,GAAG,SAAS,CAAC;IAC9E,sEAAsE;IACtE,YAAY,EAAE,MAAM,CAAC;IACrB,2DAA2D;IAC3D,UAAU,EAAE,MAAM,CAAC;IACnB,gDAAgD;IAChD,WAAW,EAAE,MAAM,CAAC;CACrB;AA0JD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,OAAO,GACf,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAyBpC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,OAAO,EACZ,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CA4ChC;AA8RD;;;;;;;GAOG;AACH,wBAAsB,uBAAuB,CAC3C,IAAI,EAAE,uBAAuB,GAC5B,OAAO,CAAC,sBAAsB,CAAC,CAwajC"}
|
package/dist/telemetry.js
CHANGED
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
* (`hq-sync-runner`, `hq-cli`, mobile wrappers) emits telemetry uniformly.
|
|
8
8
|
*
|
|
9
9
|
* What it does: after each successful sync (`all-complete` arm of
|
|
10
|
-
* `bin/sync-runner.ts`), walks
|
|
11
|
-
* file against
|
|
12
|
-
* sanitizes new rows through a tight allowlist that matches the server's
|
|
10
|
+
* `bin/sync-runner.ts`), walks Claude session logs plus live and archived Codex
|
|
11
|
+
* rollouts, then diffs each file against the persisted byte-offset cursor at `~/.hq/telemetry-cursor.json`, sanitizes new rows through a tight allowlist that matches the server's
|
|
13
12
|
* KEEP_FIELDS set in `apps/hq-pro/src/vault-service/handlers/usage.ts`,
|
|
14
13
|
* batches into server-sized POST bodies, and ships them to `/v1/usage`.
|
|
15
14
|
*
|
|
@@ -22,6 +21,7 @@
|
|
|
22
21
|
* sync. The cursor is only advanced for batches the server 2xx'd, so a
|
|
23
22
|
* transient outage retries automatically on the next sync.
|
|
24
23
|
*/
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
25
|
import { promises as fs } from "node:fs";
|
|
26
26
|
import * as os from "node:os";
|
|
27
27
|
import * as path from "node:path";
|
|
@@ -34,7 +34,13 @@ async function loadCursor(cursorPath) {
|
|
|
34
34
|
const raw = await fs.readFile(cursorPath, "utf-8");
|
|
35
35
|
const parsed = JSON.parse(raw);
|
|
36
36
|
if (parsed && typeof parsed === "object" && parsed.files && typeof parsed.files === "object") {
|
|
37
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
version: parsed.version ?? "1",
|
|
39
|
+
files: parsed.files,
|
|
40
|
+
...(typeof parsed.codex_next_rollout === "string"
|
|
41
|
+
? { codex_next_rollout: parsed.codex_next_rollout }
|
|
42
|
+
: {}),
|
|
43
|
+
};
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
catch {
|
|
@@ -274,9 +280,162 @@ async function listJsonlFiles(root) {
|
|
|
274
280
|
await walk(root);
|
|
275
281
|
return out;
|
|
276
282
|
}
|
|
283
|
+
// ── Codex rollout adapter ────────────────────────────────────────────────────
|
|
284
|
+
const MAX_ID_BYTES = 256;
|
|
285
|
+
const MAX_TIMESTAMP_BYTES = 128;
|
|
286
|
+
const MAX_PATH_BYTES = 4 * 1024;
|
|
287
|
+
const MAX_MODEL_BYTES = 256;
|
|
288
|
+
function boundedString(value, maxBytes) {
|
|
289
|
+
return typeof value === "string" &&
|
|
290
|
+
value.length > 0 &&
|
|
291
|
+
Buffer.byteLength(value, "utf-8") <= maxBytes
|
|
292
|
+
? value
|
|
293
|
+
: undefined;
|
|
294
|
+
}
|
|
295
|
+
function tokenCount(value) {
|
|
296
|
+
return typeof value === "number" &&
|
|
297
|
+
Number.isSafeInteger(value) &&
|
|
298
|
+
value >= 0
|
|
299
|
+
? value
|
|
300
|
+
: 0;
|
|
301
|
+
}
|
|
302
|
+
function cloneCodexContext(context) {
|
|
303
|
+
return { ...context };
|
|
304
|
+
}
|
|
305
|
+
function codexModel(context) {
|
|
306
|
+
return context.turn_model ?? context.collaboration_model ?? context.session_model;
|
|
307
|
+
}
|
|
308
|
+
function codexGitBranch(payload) {
|
|
309
|
+
const direct = boundedString(payload.gitBranch, MAX_PATH_BYTES) ??
|
|
310
|
+
boundedString(payload.git_branch, MAX_PATH_BYTES);
|
|
311
|
+
if (direct)
|
|
312
|
+
return direct;
|
|
313
|
+
const git = payload.git;
|
|
314
|
+
return git && typeof git === "object" && !Array.isArray(git)
|
|
315
|
+
? boundedString(git.branch, MAX_PATH_BYTES)
|
|
316
|
+
: undefined;
|
|
317
|
+
}
|
|
318
|
+
function collaborationModel(payload) {
|
|
319
|
+
const settings = payload.settings;
|
|
320
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
return boundedString(settings.model, MAX_MODEL_BYTES);
|
|
324
|
+
}
|
|
325
|
+
function stableCodexEventId(rolloutIdentity, startOffset, endOffset) {
|
|
326
|
+
const offsets = Buffer.alloc(16);
|
|
327
|
+
offsets.writeBigUInt64BE(BigInt(startOffset), 0);
|
|
328
|
+
offsets.writeBigUInt64BE(BigInt(endOffset), 8);
|
|
329
|
+
return "codex-" + createHash("sha256")
|
|
330
|
+
.update(rolloutIdentity)
|
|
331
|
+
.update(Buffer.from([0]))
|
|
332
|
+
.update(offsets)
|
|
333
|
+
.digest("hex");
|
|
334
|
+
}
|
|
335
|
+
/** Adapt a Codex rollout record to the Claude-shaped sanitizer input. */
|
|
336
|
+
function codexUsageRow(row, context, rolloutIdentity, startOffset, endOffset) {
|
|
337
|
+
if (!row || typeof row !== "object" || Array.isArray(row))
|
|
338
|
+
return null;
|
|
339
|
+
const obj = row;
|
|
340
|
+
const kind = obj.type;
|
|
341
|
+
const rawPayload = obj.payload;
|
|
342
|
+
const payload = rawPayload && typeof rawPayload === "object" && !Array.isArray(rawPayload)
|
|
343
|
+
? rawPayload
|
|
344
|
+
: undefined;
|
|
345
|
+
if (kind === "session_meta") {
|
|
346
|
+
if (!payload)
|
|
347
|
+
return null;
|
|
348
|
+
context.session_id = boundedString(payload.id, MAX_ID_BYTES) ?? context.session_id;
|
|
349
|
+
context.cwd = boundedString(payload.cwd, MAX_PATH_BYTES);
|
|
350
|
+
context.git_branch = codexGitBranch(payload);
|
|
351
|
+
context.session_model = boundedString(payload.model, MAX_MODEL_BYTES);
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
if (kind === "turn_context") {
|
|
355
|
+
context.turn_model = payload
|
|
356
|
+
? boundedString(payload.model, MAX_MODEL_BYTES)
|
|
357
|
+
: undefined;
|
|
358
|
+
const mode = payload?.collaboration_mode;
|
|
359
|
+
if (mode && typeof mode === "object" && !Array.isArray(mode)) {
|
|
360
|
+
const model = collaborationModel(mode);
|
|
361
|
+
if (model)
|
|
362
|
+
context.collaboration_model = model;
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
if (kind === "collaboration_mode") {
|
|
367
|
+
context.collaboration_model = payload
|
|
368
|
+
? collaborationModel(payload)
|
|
369
|
+
: undefined;
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
if (kind !== "event_msg" || !payload)
|
|
373
|
+
return null;
|
|
374
|
+
if (payload.type === "collaboration_mode") {
|
|
375
|
+
context.collaboration_model = collaborationModel(payload);
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
if (payload.type !== "token_count")
|
|
379
|
+
return null;
|
|
380
|
+
const info = payload.info;
|
|
381
|
+
if (!info || typeof info !== "object" || Array.isArray(info))
|
|
382
|
+
return null;
|
|
383
|
+
const last = info.last_token_usage;
|
|
384
|
+
if (!last || typeof last !== "object" || Array.isArray(last))
|
|
385
|
+
return null;
|
|
386
|
+
const usage = last;
|
|
387
|
+
const outputTokens = tokenCount(usage.output_tokens) + tokenCount(usage.reasoning_output_tokens);
|
|
388
|
+
const model = codexModel(context);
|
|
389
|
+
const normalized = {
|
|
390
|
+
uuid: boundedString(obj.uuid, MAX_ID_BYTES) ??
|
|
391
|
+
stableCodexEventId(rolloutIdentity, startOffset, endOffset),
|
|
392
|
+
message: {
|
|
393
|
+
usage: {
|
|
394
|
+
input_tokens: tokenCount(usage.input_tokens),
|
|
395
|
+
output_tokens: outputTokens,
|
|
396
|
+
...(typeof usage.cached_input_tokens === "number"
|
|
397
|
+
? { cache_read_input_tokens: tokenCount(usage.cached_input_tokens) }
|
|
398
|
+
: {}),
|
|
399
|
+
},
|
|
400
|
+
...(model ? { model } : {}),
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
if (context.session_id)
|
|
404
|
+
normalized.sessionId = context.session_id;
|
|
405
|
+
const timestamp = boundedString(obj.timestamp, MAX_TIMESTAMP_BYTES);
|
|
406
|
+
if (timestamp)
|
|
407
|
+
normalized.timestamp = timestamp;
|
|
408
|
+
if (context.cwd)
|
|
409
|
+
normalized.cwd = context.cwd;
|
|
410
|
+
if (context.git_branch)
|
|
411
|
+
normalized.gitBranch = context.git_branch;
|
|
412
|
+
return normalized;
|
|
413
|
+
}
|
|
414
|
+
async function sortFilesFreshestFirst(files) {
|
|
415
|
+
const ranked = await Promise.all(files.map(async (filePath) => {
|
|
416
|
+
try {
|
|
417
|
+
return { filePath, mtime: (await fs.stat(filePath)).mtimeMs };
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return { filePath, mtime: 0 };
|
|
421
|
+
}
|
|
422
|
+
}));
|
|
423
|
+
ranked.sort((left, right) => right.mtime - left.mtime || left.filePath.localeCompare(right.filePath));
|
|
424
|
+
return ranked.map(({ filePath }) => filePath);
|
|
425
|
+
}
|
|
426
|
+
async function listCodexRolloutFiles(codexRoot) {
|
|
427
|
+
const [live, archived] = await Promise.all([
|
|
428
|
+
listJsonlFiles(path.join(codexRoot, "sessions")),
|
|
429
|
+
listJsonlFiles(path.join(codexRoot, "archived_sessions")),
|
|
430
|
+
]);
|
|
431
|
+
return sortFilesFreshestFirst([...live, ...archived].filter((file) => path.basename(file).startsWith("rollout-")));
|
|
432
|
+
}
|
|
277
433
|
// ── Batching primitives ───────────────────────────────────────────────────────
|
|
278
434
|
const MAX_BATCH_EVENTS = 100;
|
|
279
435
|
const MAX_BATCH_BYTES = 240 * 1024;
|
|
436
|
+
const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
|
|
437
|
+
const MAX_BATCHES_PER_RUN = 4;
|
|
438
|
+
const MAX_PARTIAL_LINE_BYTES = 64 * 1024;
|
|
280
439
|
const ROW_TRUNCATION_SUFFIX = "...[truncated]";
|
|
281
440
|
/**
|
|
282
441
|
* Byte cost of the fixed wire-payload skeleton:
|
|
@@ -335,7 +494,7 @@ function boundRowForPost(row, maxRowBytes) {
|
|
|
335
494
|
}
|
|
336
495
|
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
337
496
|
/**
|
|
338
|
-
* Scan, sanitize, and POST
|
|
497
|
+
* Scan, sanitize, and POST new Claude Code and Codex usage rows.
|
|
339
498
|
*
|
|
340
499
|
* Fire-and-forget from the caller's perspective: errors are caught internally
|
|
341
500
|
* and surfaced only via `log`. The returned summary lets observers (e.g.
|
|
@@ -345,9 +504,12 @@ function boundRowForPost(row, maxRowBytes) {
|
|
|
345
504
|
export async function collectAndSendTelemetry(opts) {
|
|
346
505
|
const home = os.homedir();
|
|
347
506
|
const claudeProjectsRoot = opts.claudeProjectsRoot ?? path.join(home, ".claude", "projects");
|
|
507
|
+
const codexRoot = opts.codexRoot ?? path.join(home, ".codex");
|
|
348
508
|
const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
|
|
349
509
|
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
350
510
|
const log = opts.log ?? (() => { });
|
|
511
|
+
const maxScanBytesPerSource = opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE;
|
|
512
|
+
const maxBatchesPerRun = opts.maxBatchesPerRun ?? MAX_BATCHES_PER_RUN;
|
|
351
513
|
// Company attribution (US-002): parse the manifest ONCE per run and reuse the
|
|
352
514
|
// repo-path→companyUid map for every event below. No per-event manifest read.
|
|
353
515
|
// When `hqRoot` is omitted the map is empty → every event stays unattributed.
|
|
@@ -431,7 +593,17 @@ export async function collectAndSendTelemetry(opts) {
|
|
|
431
593
|
const loadedFiles = { ...cursor.files };
|
|
432
594
|
const rotationResets = {};
|
|
433
595
|
const newlyCommitted = {};
|
|
434
|
-
const
|
|
596
|
+
const [claudeFilesUnsorted, codexFiles] = await Promise.all([
|
|
597
|
+
listJsonlFiles(claudeProjectsRoot),
|
|
598
|
+
listCodexRolloutFiles(codexRoot),
|
|
599
|
+
]);
|
|
600
|
+
const claudeFiles = await sortFilesFreshestFirst(claudeFilesUnsorted);
|
|
601
|
+
// Codex goes first so a fresh Linux install cannot spend its entire bounded
|
|
602
|
+
// pass replaying an older Claude backlog before reporting current Codex use.
|
|
603
|
+
const files = [
|
|
604
|
+
...codexFiles.map((filePath) => ({ filePath, kind: "codex" })),
|
|
605
|
+
...claudeFiles.map((filePath) => ({ filePath, kind: "claude" })),
|
|
606
|
+
];
|
|
435
607
|
// 3. Walk each file, sanitize new rows, batch, flush at the server contract.
|
|
436
608
|
//
|
|
437
609
|
// Byte accounting is incremental: we track `batchBytes` as the projected
|
|
@@ -446,14 +618,40 @@ export async function collectAndSendTelemetry(opts) {
|
|
|
446
618
|
let batchBytes = ENVELOPE_BYTES;
|
|
447
619
|
let eventsSent = 0;
|
|
448
620
|
let batchesSent = 0;
|
|
621
|
+
let uploadFailed = false;
|
|
622
|
+
let batchLimitReached = false;
|
|
623
|
+
const scannedBytes = { claude: 0, codex: 0 };
|
|
624
|
+
const commitSources = (sources) => {
|
|
625
|
+
const maxPerFile = new Map();
|
|
626
|
+
for (const source of sources) {
|
|
627
|
+
const current = maxPerFile.get(source.filePath);
|
|
628
|
+
if (!current || source.endOffset > current.offset) {
|
|
629
|
+
maxPerFile.set(source.filePath, {
|
|
630
|
+
mtime: source.mtime,
|
|
631
|
+
offset: source.endOffset,
|
|
632
|
+
...(source.context
|
|
633
|
+
? { context: cloneCodexContext(source.context) }
|
|
634
|
+
: {}),
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
for (const [filePath, entry] of maxPerFile) {
|
|
639
|
+
const current = newlyCommitted[filePath];
|
|
640
|
+
if (!current || entry.offset > current.offset) {
|
|
641
|
+
newlyCommitted[filePath] = entry;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
};
|
|
449
645
|
const flush = async () => {
|
|
450
|
-
if (batchEvents.length === 0)
|
|
451
|
-
return;
|
|
452
646
|
const events = batchEvents;
|
|
453
647
|
const sources = batchSources;
|
|
454
648
|
batchEvents = [];
|
|
455
649
|
batchSources = [];
|
|
456
650
|
batchBytes = ENVELOPE_BYTES;
|
|
651
|
+
if (events.length === 0) {
|
|
652
|
+
commitSources(sources);
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
457
655
|
try {
|
|
458
656
|
await opts.client.postUsage({
|
|
459
657
|
machineId: opts.machineId,
|
|
@@ -462,24 +660,24 @@ export async function collectAndSendTelemetry(opts) {
|
|
|
462
660
|
});
|
|
463
661
|
batchesSent++;
|
|
464
662
|
eventsSent += events.length;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
for (const src of sources) {
|
|
468
|
-
const cur = maxPerFile.get(src.filePath);
|
|
469
|
-
if (!cur || src.endOffset > cur.offset) {
|
|
470
|
-
maxPerFile.set(src.filePath, { mtime: src.mtime, offset: src.endOffset });
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
for (const [fp, entry] of maxPerFile) {
|
|
474
|
-
newlyCommitted[fp] = { offset: entry.offset, mtime: entry.mtime };
|
|
475
|
-
}
|
|
663
|
+
commitSources(sources);
|
|
664
|
+
return true;
|
|
476
665
|
}
|
|
477
666
|
catch (err) {
|
|
478
|
-
log(
|
|
479
|
-
|
|
667
|
+
log("[telemetry] postUsage failed (" +
|
|
668
|
+
(err.message ?? err) +
|
|
669
|
+
") — cursor not advanced for " +
|
|
670
|
+
sources.length +
|
|
671
|
+
" rows");
|
|
672
|
+
return false;
|
|
480
673
|
}
|
|
481
674
|
};
|
|
482
|
-
for (const filePath of files) {
|
|
675
|
+
filesLoop: for (const { filePath, kind } of files) {
|
|
676
|
+
if (uploadFailed || batchLimitReached)
|
|
677
|
+
break;
|
|
678
|
+
const sourceBudgetRemaining = maxScanBytesPerSource - scannedBytes[kind];
|
|
679
|
+
if (sourceBudgetRemaining <= 0)
|
|
680
|
+
continue;
|
|
483
681
|
let stat;
|
|
484
682
|
try {
|
|
485
683
|
stat = await fs.stat(filePath);
|
|
@@ -491,102 +689,189 @@ export async function collectAndSendTelemetry(opts) {
|
|
|
491
689
|
const currentMtime = Math.floor(stat.mtimeMs / 1000);
|
|
492
690
|
const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
|
|
493
691
|
let offset = stored.offset;
|
|
692
|
+
let codexContext = kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
|
|
494
693
|
const rotated = currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
|
|
495
694
|
if (rotated) {
|
|
496
695
|
offset = 0;
|
|
696
|
+
if (kind === "codex")
|
|
697
|
+
codexContext = {};
|
|
497
698
|
rotationResets[filePath] = { offset: 0, mtime: currentMtime };
|
|
498
699
|
}
|
|
499
700
|
if (offset >= currentSize && !rotated)
|
|
500
701
|
continue;
|
|
501
702
|
let content;
|
|
703
|
+
let bytesRead = 0;
|
|
704
|
+
let readBuffer = Buffer.alloc(0);
|
|
502
705
|
try {
|
|
503
|
-
const
|
|
706
|
+
const fileHandle = await fs.open(filePath, "r");
|
|
504
707
|
try {
|
|
505
|
-
const length = Math.max(0, currentSize - offset);
|
|
506
|
-
|
|
507
|
-
await
|
|
508
|
-
content =
|
|
708
|
+
const length = Math.max(0, Math.min(currentSize - offset, sourceBudgetRemaining));
|
|
709
|
+
readBuffer = Buffer.alloc(length);
|
|
710
|
+
({ bytesRead } = await fileHandle.read(readBuffer, 0, length, offset));
|
|
711
|
+
content = readBuffer.toString("utf-8", 0, bytesRead);
|
|
509
712
|
}
|
|
510
713
|
finally {
|
|
511
|
-
await
|
|
714
|
+
await fileHandle.close();
|
|
512
715
|
}
|
|
513
716
|
}
|
|
514
717
|
catch {
|
|
515
718
|
continue;
|
|
516
719
|
}
|
|
517
|
-
if (
|
|
720
|
+
if (bytesRead === 0)
|
|
518
721
|
continue;
|
|
722
|
+
// Resume a previously discarded oversized Codex row using raw byte
|
|
723
|
+
// positions. Decoding from the middle of a multibyte UTF-8 codepoint would
|
|
724
|
+
// otherwise skew byte offsets and could skip or replay later rows.
|
|
725
|
+
if (codexContext?.discarding_partial_line) {
|
|
726
|
+
const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
|
|
727
|
+
const discardedBytes = newlineIndex >= 0 ? newlineIndex + 1 : bytesRead;
|
|
728
|
+
codexContext.discarding_partial_line = newlineIndex < 0;
|
|
729
|
+
scannedBytes[kind] += discardedBytes;
|
|
730
|
+
batchSources.push({
|
|
731
|
+
filePath,
|
|
732
|
+
endOffset: offset + discardedBytes,
|
|
733
|
+
mtime: currentMtime,
|
|
734
|
+
context: cloneCodexContext(codexContext),
|
|
735
|
+
});
|
|
736
|
+
if (newlineIndex < 0)
|
|
737
|
+
continue;
|
|
738
|
+
offset += discardedBytes;
|
|
739
|
+
bytesRead -= discardedBytes;
|
|
740
|
+
readBuffer = readBuffer.subarray(discardedBytes);
|
|
741
|
+
content = readBuffer.toString("utf-8", 0, bytesRead);
|
|
742
|
+
if (bytesRead === 0)
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const reachedPhysicalEof = offset + bytesRead >= currentSize;
|
|
519
746
|
const segments = content.split("\n");
|
|
520
747
|
const lineEndOffsets = [];
|
|
521
748
|
let cumulative = 0;
|
|
522
749
|
for (let i = 0; i < segments.length; i++) {
|
|
523
750
|
cumulative += Buffer.byteLength(segments[i], "utf-8");
|
|
524
751
|
if (i < segments.length - 1)
|
|
525
|
-
cumulative += 1;
|
|
752
|
+
cumulative += 1;
|
|
526
753
|
lineEndOffsets.push(offset + cumulative);
|
|
527
754
|
}
|
|
528
755
|
for (let i = 0; i < segments.length; i++) {
|
|
756
|
+
const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
|
|
757
|
+
const hasNewline = i < segments.length - 1;
|
|
758
|
+
const endOffset = !hasNewline && !reachedPhysicalEof
|
|
759
|
+
? offset + bytesRead
|
|
760
|
+
: lineEndOffsets[i];
|
|
761
|
+
const lineBytes = endOffset - startOffset;
|
|
762
|
+
if (!hasNewline && !reachedPhysicalEof) {
|
|
763
|
+
const discarding = codexContext?.discarding_partial_line === true;
|
|
764
|
+
if (discarding || lineBytes >= MAX_PARTIAL_LINE_BYTES) {
|
|
765
|
+
if (codexContext)
|
|
766
|
+
codexContext.discarding_partial_line = true;
|
|
767
|
+
scannedBytes[kind] += lineBytes;
|
|
768
|
+
batchSources.push({
|
|
769
|
+
filePath,
|
|
770
|
+
endOffset,
|
|
771
|
+
mtime: currentMtime,
|
|
772
|
+
...(codexContext
|
|
773
|
+
? { context: cloneCodexContext(codexContext) }
|
|
774
|
+
: {}),
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
scannedBytes[kind] += lineBytes;
|
|
780
|
+
const source = () => ({
|
|
781
|
+
filePath,
|
|
782
|
+
endOffset,
|
|
783
|
+
mtime: currentMtime,
|
|
784
|
+
...(codexContext
|
|
785
|
+
? { context: cloneCodexContext(codexContext) }
|
|
786
|
+
: {}),
|
|
787
|
+
});
|
|
788
|
+
if (codexContext?.discarding_partial_line) {
|
|
789
|
+
codexContext.discarding_partial_line = false;
|
|
790
|
+
batchSources.push(source());
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
529
793
|
const trimmed = segments[i].trim();
|
|
530
|
-
if (trimmed.length === 0)
|
|
794
|
+
if (trimmed.length === 0) {
|
|
795
|
+
batchSources.push(source());
|
|
531
796
|
continue;
|
|
797
|
+
}
|
|
532
798
|
let parsed;
|
|
533
799
|
try {
|
|
534
800
|
parsed = JSON.parse(trimmed);
|
|
535
801
|
}
|
|
536
802
|
catch {
|
|
803
|
+
batchSources.push(source());
|
|
537
804
|
continue;
|
|
538
805
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
806
|
+
const sourceRow = kind === "codex"
|
|
807
|
+
? codexUsageRow(parsed, codexContext, filePath, startOffset, endOffset)
|
|
808
|
+
: parsed;
|
|
809
|
+
const rowCwd = sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
|
|
810
|
+
? sourceRow.cwd
|
|
543
811
|
: undefined;
|
|
544
812
|
const companyUid = resolveCompanyForCwd(typeof rowCwd === "string" ? rowCwd : undefined, repoCompanyMap);
|
|
545
|
-
const sanitized = sanitizeRow(
|
|
546
|
-
if (!sanitized)
|
|
813
|
+
const sanitized = sanitizeRow(sourceRow, companyUid);
|
|
814
|
+
if (!sanitized) {
|
|
815
|
+
batchSources.push(source());
|
|
547
816
|
continue;
|
|
817
|
+
}
|
|
548
818
|
const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
|
|
549
819
|
const wasOversized = jsonBytes(sanitized) > maxRowBytes;
|
|
550
820
|
const bounded = boundRowForPost(sanitized, maxRowBytes);
|
|
551
821
|
if (!bounded) {
|
|
552
|
-
log(
|
|
822
|
+
log("[telemetry] oversized row dropped before send (" +
|
|
823
|
+
filePath +
|
|
824
|
+
":" +
|
|
825
|
+
(i + 1) +
|
|
826
|
+
")");
|
|
827
|
+
batchSources.push(source());
|
|
553
828
|
continue;
|
|
554
829
|
}
|
|
555
830
|
if (wasOversized) {
|
|
556
|
-
log(
|
|
831
|
+
log("[telemetry] oversized row truncated before send (" +
|
|
832
|
+
filePath +
|
|
833
|
+
":" +
|
|
834
|
+
(i + 1) +
|
|
835
|
+
")");
|
|
557
836
|
}
|
|
558
|
-
|
|
559
|
-
// length plus 1 byte for the leading comma when there's already at
|
|
560
|
-
// least one row. (No comma when the batch is empty — the row sits
|
|
561
|
-
// alone inside the events array.)
|
|
562
|
-
const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
|
|
837
|
+
const rowJsonBytes = jsonBytes(bounded);
|
|
563
838
|
const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
|
|
564
839
|
if (batchEvents.length > 0 &&
|
|
565
840
|
(batchEvents.length >= MAX_BATCH_EVENTS ||
|
|
566
841
|
batchBytes + addCost > MAX_BATCH_BYTES)) {
|
|
567
|
-
await flush()
|
|
568
|
-
|
|
842
|
+
if (!(await flush())) {
|
|
843
|
+
uploadFailed = true;
|
|
844
|
+
break filesLoop;
|
|
845
|
+
}
|
|
846
|
+
if (batchesSent >= maxBatchesPerRun) {
|
|
847
|
+
batchLimitReached = true;
|
|
848
|
+
break filesLoop;
|
|
849
|
+
}
|
|
569
850
|
batchBytes = ENVELOPE_BYTES + rowJsonBytes;
|
|
570
851
|
}
|
|
571
852
|
else {
|
|
572
853
|
batchBytes += addCost;
|
|
573
854
|
}
|
|
574
855
|
batchEvents.push(bounded);
|
|
575
|
-
batchSources.push(
|
|
576
|
-
filePath,
|
|
577
|
-
endOffset: lineEndOffsets[i],
|
|
578
|
-
mtime: currentMtime,
|
|
579
|
-
});
|
|
856
|
+
batchSources.push(source());
|
|
580
857
|
}
|
|
581
858
|
}
|
|
582
|
-
|
|
859
|
+
if (!uploadFailed && !batchLimitReached) {
|
|
860
|
+
uploadFailed = !(await flush());
|
|
861
|
+
}
|
|
583
862
|
// 4. Persist cursor: loaded < rotation_resets < newly_committed.
|
|
584
863
|
const finalFiles = { ...loadedFiles };
|
|
585
864
|
for (const [fp, entry] of Object.entries(rotationResets))
|
|
586
865
|
finalFiles[fp] = entry;
|
|
587
866
|
for (const [fp, entry] of Object.entries(newlyCommitted))
|
|
588
867
|
finalFiles[fp] = entry;
|
|
589
|
-
await saveCursor(cursorPath, {
|
|
868
|
+
await saveCursor(cursorPath, {
|
|
869
|
+
version: "1",
|
|
870
|
+
files: finalFiles,
|
|
871
|
+
...(cursor.codex_next_rollout
|
|
872
|
+
? { codex_next_rollout: cursor.codex_next_rollout }
|
|
873
|
+
: {}),
|
|
874
|
+
});
|
|
590
875
|
return {
|
|
591
876
|
enabled: true,
|
|
592
877
|
optInSource,
|