@indigoai-us/hq-cloud 6.14.30 → 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 +4 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +168 -57
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +73 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +1 -1
- package/src/telemetry.test.ts +92 -0
- package/src/telemetry.ts +200 -67
package/src/telemetry.ts
CHANGED
|
@@ -77,6 +77,10 @@ export interface CollectTelemetryOptions {
|
|
|
77
77
|
claudeProjectsRoot?: string;
|
|
78
78
|
/** Override `~/.codex` for tests. Both live and archived rollouts are scanned. */
|
|
79
79
|
codexRoot?: string;
|
|
80
|
+
/** Override the per-runtime scan budget for deterministic tests. */
|
|
81
|
+
maxScanBytesPerSource?: number;
|
|
82
|
+
/** Override the upload batch cap for deterministic tests. */
|
|
83
|
+
maxBatchesPerRun?: number;
|
|
80
84
|
/** Override `~/.hq/telemetry-cursor.json` for tests. */
|
|
81
85
|
cursorPath?: string;
|
|
82
86
|
/** Override `~/.hq/menubar.json` (the offline opt-in fallback) for tests. */
|
|
@@ -107,6 +111,7 @@ interface CodexUsageContext {
|
|
|
107
111
|
session_model?: string;
|
|
108
112
|
collaboration_model?: string;
|
|
109
113
|
turn_model?: string;
|
|
114
|
+
discarding_partial_line?: boolean;
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
interface CursorEntry {
|
|
@@ -545,13 +550,32 @@ function codexUsageRow(
|
|
|
545
550
|
return normalized;
|
|
546
551
|
}
|
|
547
552
|
|
|
553
|
+
async function sortFilesFreshestFirst(files: string[]): Promise<string[]> {
|
|
554
|
+
const ranked = await Promise.all(
|
|
555
|
+
files.map(async (filePath) => {
|
|
556
|
+
try {
|
|
557
|
+
return { filePath, mtime: (await fs.stat(filePath)).mtimeMs };
|
|
558
|
+
} catch {
|
|
559
|
+
return { filePath, mtime: 0 };
|
|
560
|
+
}
|
|
561
|
+
}),
|
|
562
|
+
);
|
|
563
|
+
ranked.sort(
|
|
564
|
+
(left, right) =>
|
|
565
|
+
right.mtime - left.mtime || left.filePath.localeCompare(right.filePath),
|
|
566
|
+
);
|
|
567
|
+
return ranked.map(({ filePath }) => filePath);
|
|
568
|
+
}
|
|
569
|
+
|
|
548
570
|
async function listCodexRolloutFiles(codexRoot: string): Promise<string[]> {
|
|
549
571
|
const [live, archived] = await Promise.all([
|
|
550
572
|
listJsonlFiles(path.join(codexRoot, "sessions")),
|
|
551
573
|
listJsonlFiles(path.join(codexRoot, "archived_sessions")),
|
|
552
574
|
]);
|
|
553
|
-
return
|
|
554
|
-
|
|
575
|
+
return sortFilesFreshestFirst(
|
|
576
|
+
[...live, ...archived].filter((file) =>
|
|
577
|
+
path.basename(file).startsWith("rollout-"),
|
|
578
|
+
),
|
|
555
579
|
);
|
|
556
580
|
}
|
|
557
581
|
|
|
@@ -559,6 +583,9 @@ async function listCodexRolloutFiles(codexRoot: string): Promise<string[]> {
|
|
|
559
583
|
|
|
560
584
|
const MAX_BATCH_EVENTS = 100;
|
|
561
585
|
const MAX_BATCH_BYTES = 240 * 1024;
|
|
586
|
+
const MAX_SCAN_BYTES_PER_SOURCE = 4 * 1024 * 1024;
|
|
587
|
+
const MAX_BATCHES_PER_RUN = 4;
|
|
588
|
+
const MAX_PARTIAL_LINE_BYTES = 64 * 1024;
|
|
562
589
|
const ROW_TRUNCATION_SUFFIX = "...[truncated]";
|
|
563
590
|
|
|
564
591
|
interface RowSource {
|
|
@@ -650,6 +677,9 @@ export async function collectAndSendTelemetry(
|
|
|
650
677
|
const cursorPath = opts.cursorPath ?? path.join(home, ".hq", "telemetry-cursor.json");
|
|
651
678
|
const menubarPath = opts.menubarPath ?? path.join(home, ".hq", "menubar.json");
|
|
652
679
|
const log = opts.log ?? (() => {});
|
|
680
|
+
const maxScanBytesPerSource =
|
|
681
|
+
opts.maxScanBytesPerSource ?? MAX_SCAN_BYTES_PER_SOURCE;
|
|
682
|
+
const maxBatchesPerRun = opts.maxBatchesPerRun ?? MAX_BATCHES_PER_RUN;
|
|
653
683
|
|
|
654
684
|
// Company attribution (US-002): parse the manifest ONCE per run and reuse the
|
|
655
685
|
// repo-path→companyUid map for every event below. No per-event manifest read.
|
|
@@ -745,13 +775,16 @@ export async function collectAndSendTelemetry(
|
|
|
745
775
|
const rotationResets: Record<string, CursorEntry> = {};
|
|
746
776
|
const newlyCommitted: Record<string, CursorEntry> = {};
|
|
747
777
|
|
|
748
|
-
const [
|
|
778
|
+
const [claudeFilesUnsorted, codexFiles] = await Promise.all([
|
|
749
779
|
listJsonlFiles(claudeProjectsRoot),
|
|
750
780
|
listCodexRolloutFiles(codexRoot),
|
|
751
781
|
]);
|
|
782
|
+
const claudeFiles = await sortFilesFreshestFirst(claudeFilesUnsorted);
|
|
783
|
+
// Codex goes first so a fresh Linux install cannot spend its entire bounded
|
|
784
|
+
// pass replaying an older Claude backlog before reporting current Codex use.
|
|
752
785
|
const files: Array<{ filePath: string; kind: "claude" | "codex" }> = [
|
|
753
|
-
...claudeFiles.map((filePath) => ({ filePath, kind: "claude" as const })),
|
|
754
786
|
...codexFiles.map((filePath) => ({ filePath, kind: "codex" as const })),
|
|
787
|
+
...claudeFiles.map((filePath) => ({ filePath, kind: "claude" as const })),
|
|
755
788
|
];
|
|
756
789
|
|
|
757
790
|
// 3. Walk each file, sanitize new rows, batch, flush at the server contract.
|
|
@@ -768,15 +801,45 @@ export async function collectAndSendTelemetry(
|
|
|
768
801
|
let batchBytes = ENVELOPE_BYTES;
|
|
769
802
|
let eventsSent = 0;
|
|
770
803
|
let batchesSent = 0;
|
|
804
|
+
let uploadFailed = false;
|
|
805
|
+
let batchLimitReached = false;
|
|
806
|
+
const scannedBytes = { claude: 0, codex: 0 };
|
|
807
|
+
|
|
808
|
+
const commitSources = (sources: RowSource[]): void => {
|
|
809
|
+
const maxPerFile = new Map<
|
|
810
|
+
string,
|
|
811
|
+
{ mtime: number; offset: number; context?: CodexUsageContext }
|
|
812
|
+
>();
|
|
813
|
+
for (const source of sources) {
|
|
814
|
+
const current = maxPerFile.get(source.filePath);
|
|
815
|
+
if (!current || source.endOffset > current.offset) {
|
|
816
|
+
maxPerFile.set(source.filePath, {
|
|
817
|
+
mtime: source.mtime,
|
|
818
|
+
offset: source.endOffset,
|
|
819
|
+
...(source.context
|
|
820
|
+
? { context: cloneCodexContext(source.context) }
|
|
821
|
+
: {}),
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
for (const [filePath, entry] of maxPerFile) {
|
|
826
|
+
const current = newlyCommitted[filePath];
|
|
827
|
+
if (!current || entry.offset > current.offset) {
|
|
828
|
+
newlyCommitted[filePath] = entry;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
};
|
|
771
832
|
|
|
772
|
-
const flush = async (): Promise<
|
|
773
|
-
if (batchEvents.length === 0) return;
|
|
833
|
+
const flush = async (): Promise<boolean> => {
|
|
774
834
|
const events = batchEvents;
|
|
775
835
|
const sources = batchSources;
|
|
776
836
|
batchEvents = [];
|
|
777
837
|
batchSources = [];
|
|
778
838
|
batchBytes = ENVELOPE_BYTES;
|
|
779
|
-
|
|
839
|
+
if (events.length === 0) {
|
|
840
|
+
commitSources(sources);
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
780
843
|
try {
|
|
781
844
|
await opts.client.postUsage({
|
|
782
845
|
machineId: opts.machineId,
|
|
@@ -785,35 +848,26 @@ export async function collectAndSendTelemetry(
|
|
|
785
848
|
});
|
|
786
849
|
batchesSent++;
|
|
787
850
|
eventsSent += events.length;
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
string,
|
|
791
|
-
{ mtime: number; offset: number; context?: CodexUsageContext }
|
|
792
|
-
>();
|
|
793
|
-
for (const src of sources) {
|
|
794
|
-
const cur = maxPerFile.get(src.filePath);
|
|
795
|
-
if (!cur || src.endOffset > cur.offset) {
|
|
796
|
-
maxPerFile.set(src.filePath, {
|
|
797
|
-
mtime: src.mtime,
|
|
798
|
-
offset: src.endOffset,
|
|
799
|
-
...(src.context ? { context: cloneCodexContext(src.context) } : {}),
|
|
800
|
-
});
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
for (const [fp, entry] of maxPerFile) {
|
|
804
|
-
newlyCommitted[fp] = {
|
|
805
|
-
offset: entry.offset,
|
|
806
|
-
mtime: entry.mtime,
|
|
807
|
-
...(entry.context ? { context: entry.context } : {}),
|
|
808
|
-
};
|
|
809
|
-
}
|
|
851
|
+
commitSources(sources);
|
|
852
|
+
return true;
|
|
810
853
|
} catch (err) {
|
|
811
|
-
log(
|
|
812
|
-
|
|
854
|
+
log(
|
|
855
|
+
"[telemetry] postUsage failed (" +
|
|
856
|
+
((err as Error).message ?? err) +
|
|
857
|
+
") — cursor not advanced for " +
|
|
858
|
+
sources.length +
|
|
859
|
+
" rows",
|
|
860
|
+
);
|
|
861
|
+
return false;
|
|
813
862
|
}
|
|
814
863
|
};
|
|
815
864
|
|
|
816
|
-
for (const { filePath, kind } of files) {
|
|
865
|
+
filesLoop: for (const { filePath, kind } of files) {
|
|
866
|
+
if (uploadFailed || batchLimitReached) break;
|
|
867
|
+
const sourceBudgetRemaining =
|
|
868
|
+
maxScanBytesPerSource - scannedBytes[kind];
|
|
869
|
+
if (sourceBudgetRemaining <= 0) continue;
|
|
870
|
+
|
|
817
871
|
let stat;
|
|
818
872
|
try {
|
|
819
873
|
stat = await fs.stat(filePath);
|
|
@@ -822,12 +876,10 @@ export async function collectAndSendTelemetry(
|
|
|
822
876
|
}
|
|
823
877
|
const currentSize = stat.size;
|
|
824
878
|
const currentMtime = Math.floor(stat.mtimeMs / 1000);
|
|
825
|
-
|
|
826
879
|
const stored = cursor.files[filePath] ?? { offset: 0, mtime: 0 };
|
|
827
880
|
let offset = stored.offset;
|
|
828
881
|
let codexContext: CodexUsageContext | undefined =
|
|
829
882
|
kind === "codex" ? cloneCodexContext(stored.context ?? {}) : undefined;
|
|
830
|
-
|
|
831
883
|
const rotated =
|
|
832
884
|
currentSize < offset || (stored.mtime > 0 && currentMtime < stored.mtime);
|
|
833
885
|
if (rotated) {
|
|
@@ -835,44 +887,117 @@ export async function collectAndSendTelemetry(
|
|
|
835
887
|
if (kind === "codex") codexContext = {};
|
|
836
888
|
rotationResets[filePath] = { offset: 0, mtime: currentMtime };
|
|
837
889
|
}
|
|
838
|
-
|
|
839
890
|
if (offset >= currentSize && !rotated) continue;
|
|
840
891
|
|
|
841
892
|
let content: string;
|
|
893
|
+
let bytesRead = 0;
|
|
894
|
+
let readBuffer = Buffer.alloc(0);
|
|
842
895
|
try {
|
|
843
|
-
const
|
|
896
|
+
const fileHandle = await fs.open(filePath, "r");
|
|
844
897
|
try {
|
|
845
|
-
const length = Math.max(
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
898
|
+
const length = Math.max(
|
|
899
|
+
0,
|
|
900
|
+
Math.min(currentSize - offset, sourceBudgetRemaining),
|
|
901
|
+
);
|
|
902
|
+
readBuffer = Buffer.alloc(length);
|
|
903
|
+
({ bytesRead } = await fileHandle.read(
|
|
904
|
+
readBuffer,
|
|
905
|
+
0,
|
|
906
|
+
length,
|
|
907
|
+
offset,
|
|
908
|
+
));
|
|
909
|
+
content = readBuffer.toString("utf-8", 0, bytesRead);
|
|
849
910
|
} finally {
|
|
850
|
-
await
|
|
911
|
+
await fileHandle.close();
|
|
851
912
|
}
|
|
852
913
|
} catch {
|
|
853
914
|
continue;
|
|
854
915
|
}
|
|
855
|
-
if (
|
|
916
|
+
if (bytesRead === 0) continue;
|
|
917
|
+
|
|
918
|
+
// Resume a previously discarded oversized Codex row using raw byte
|
|
919
|
+
// positions. Decoding from the middle of a multibyte UTF-8 codepoint would
|
|
920
|
+
// otherwise skew byte offsets and could skip or replay later rows.
|
|
921
|
+
if (codexContext?.discarding_partial_line) {
|
|
922
|
+
const newlineIndex = readBuffer.subarray(0, bytesRead).indexOf(0x0a);
|
|
923
|
+
const discardedBytes = newlineIndex >= 0 ? newlineIndex + 1 : bytesRead;
|
|
924
|
+
codexContext.discarding_partial_line = newlineIndex < 0;
|
|
925
|
+
scannedBytes[kind] += discardedBytes;
|
|
926
|
+
batchSources.push({
|
|
927
|
+
filePath,
|
|
928
|
+
endOffset: offset + discardedBytes,
|
|
929
|
+
mtime: currentMtime,
|
|
930
|
+
context: cloneCodexContext(codexContext),
|
|
931
|
+
});
|
|
932
|
+
if (newlineIndex < 0) continue;
|
|
933
|
+
offset += discardedBytes;
|
|
934
|
+
bytesRead -= discardedBytes;
|
|
935
|
+
readBuffer = readBuffer.subarray(discardedBytes);
|
|
936
|
+
content = readBuffer.toString("utf-8", 0, bytesRead);
|
|
937
|
+
if (bytesRead === 0) continue;
|
|
938
|
+
}
|
|
856
939
|
|
|
940
|
+
const reachedPhysicalEof = offset + bytesRead >= currentSize;
|
|
857
941
|
const segments = content.split("\n");
|
|
858
942
|
const lineEndOffsets: number[] = [];
|
|
859
943
|
let cumulative = 0;
|
|
860
944
|
for (let i = 0; i < segments.length; i++) {
|
|
861
945
|
cumulative += Buffer.byteLength(segments[i], "utf-8");
|
|
862
|
-
if (i < segments.length - 1) cumulative += 1;
|
|
946
|
+
if (i < segments.length - 1) cumulative += 1;
|
|
863
947
|
lineEndOffsets.push(offset + cumulative);
|
|
864
948
|
}
|
|
865
949
|
|
|
866
950
|
for (let i = 0; i < segments.length; i++) {
|
|
951
|
+
const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
|
|
952
|
+
const hasNewline = i < segments.length - 1;
|
|
953
|
+
const endOffset =
|
|
954
|
+
!hasNewline && !reachedPhysicalEof
|
|
955
|
+
? offset + bytesRead
|
|
956
|
+
: lineEndOffsets[i];
|
|
957
|
+
const lineBytes = endOffset - startOffset;
|
|
958
|
+
if (!hasNewline && !reachedPhysicalEof) {
|
|
959
|
+
const discarding = codexContext?.discarding_partial_line === true;
|
|
960
|
+
if (discarding || lineBytes >= MAX_PARTIAL_LINE_BYTES) {
|
|
961
|
+
if (codexContext) codexContext.discarding_partial_line = true;
|
|
962
|
+
scannedBytes[kind] += lineBytes;
|
|
963
|
+
batchSources.push({
|
|
964
|
+
filePath,
|
|
965
|
+
endOffset,
|
|
966
|
+
mtime: currentMtime,
|
|
967
|
+
...(codexContext
|
|
968
|
+
? { context: cloneCodexContext(codexContext) }
|
|
969
|
+
: {}),
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
scannedBytes[kind] += lineBytes;
|
|
976
|
+
const source = (): RowSource => ({
|
|
977
|
+
filePath,
|
|
978
|
+
endOffset,
|
|
979
|
+
mtime: currentMtime,
|
|
980
|
+
...(codexContext
|
|
981
|
+
? { context: cloneCodexContext(codexContext) }
|
|
982
|
+
: {}),
|
|
983
|
+
});
|
|
984
|
+
if (codexContext?.discarding_partial_line) {
|
|
985
|
+
codexContext.discarding_partial_line = false;
|
|
986
|
+
batchSources.push(source());
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
867
989
|
const trimmed = segments[i].trim();
|
|
868
|
-
if (trimmed.length === 0)
|
|
990
|
+
if (trimmed.length === 0) {
|
|
991
|
+
batchSources.push(source());
|
|
992
|
+
continue;
|
|
993
|
+
}
|
|
869
994
|
let parsed: unknown;
|
|
870
995
|
try {
|
|
871
996
|
parsed = JSON.parse(trimmed);
|
|
872
997
|
} catch {
|
|
998
|
+
batchSources.push(source());
|
|
873
999
|
continue;
|
|
874
1000
|
}
|
|
875
|
-
const startOffset = i === 0 ? offset : lineEndOffsets[i - 1];
|
|
876
1001
|
const sourceRow =
|
|
877
1002
|
kind === "codex"
|
|
878
1003
|
? codexUsageRow(
|
|
@@ -880,11 +1005,9 @@ export async function collectAndSendTelemetry(
|
|
|
880
1005
|
codexContext!,
|
|
881
1006
|
filePath,
|
|
882
1007
|
startOffset,
|
|
883
|
-
|
|
1008
|
+
endOffset,
|
|
884
1009
|
)
|
|
885
1010
|
: parsed;
|
|
886
|
-
// Resolve this row's cwd → owning company (cmp_* uid) before sanitizing,
|
|
887
|
-
// using the per-run map. Unresolved → undefined → companyUid omitted.
|
|
888
1011
|
const rowCwd =
|
|
889
1012
|
sourceRow && typeof sourceRow === "object" && !Array.isArray(sourceRow)
|
|
890
1013
|
? (sourceRow as Record<string, unknown>).cwd
|
|
@@ -894,52 +1017,62 @@ export async function collectAndSendTelemetry(
|
|
|
894
1017
|
repoCompanyMap,
|
|
895
1018
|
);
|
|
896
1019
|
const sanitized = sanitizeRow(sourceRow, companyUid);
|
|
897
|
-
if (!sanitized)
|
|
1020
|
+
if (!sanitized) {
|
|
1021
|
+
batchSources.push(source());
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
898
1025
|
const maxRowBytes = MAX_BATCH_BYTES - ENVELOPE_BYTES;
|
|
899
1026
|
const wasOversized = jsonBytes(sanitized) > maxRowBytes;
|
|
900
1027
|
const bounded = boundRowForPost(sanitized, maxRowBytes);
|
|
901
1028
|
if (!bounded) {
|
|
902
1029
|
log(
|
|
903
|
-
|
|
1030
|
+
"[telemetry] oversized row dropped before send (" +
|
|
1031
|
+
filePath +
|
|
1032
|
+
":" +
|
|
1033
|
+
(i + 1) +
|
|
1034
|
+
")",
|
|
904
1035
|
);
|
|
1036
|
+
batchSources.push(source());
|
|
905
1037
|
continue;
|
|
906
1038
|
}
|
|
907
1039
|
if (wasOversized) {
|
|
908
1040
|
log(
|
|
909
|
-
|
|
1041
|
+
"[telemetry] oversized row truncated before send (" +
|
|
1042
|
+
filePath +
|
|
1043
|
+
":" +
|
|
1044
|
+
(i + 1) +
|
|
1045
|
+
")",
|
|
910
1046
|
);
|
|
911
1047
|
}
|
|
912
1048
|
|
|
913
|
-
|
|
914
|
-
// length plus 1 byte for the leading comma when there's already at
|
|
915
|
-
// least one row. (No comma when the batch is empty — the row sits
|
|
916
|
-
// alone inside the events array.)
|
|
917
|
-
const rowJsonBytes = Buffer.byteLength(JSON.stringify(bounded), "utf-8");
|
|
1049
|
+
const rowJsonBytes = jsonBytes(bounded);
|
|
918
1050
|
const addCost = rowJsonBytes + (batchEvents.length > 0 ? 1 : 0);
|
|
919
|
-
|
|
920
1051
|
if (
|
|
921
1052
|
batchEvents.length > 0 &&
|
|
922
1053
|
(batchEvents.length >= MAX_BATCH_EVENTS ||
|
|
923
1054
|
batchBytes + addCost > MAX_BATCH_BYTES)
|
|
924
1055
|
) {
|
|
925
|
-
await flush()
|
|
926
|
-
|
|
1056
|
+
if (!(await flush())) {
|
|
1057
|
+
uploadFailed = true;
|
|
1058
|
+
break filesLoop;
|
|
1059
|
+
}
|
|
1060
|
+
if (batchesSent >= maxBatchesPerRun) {
|
|
1061
|
+
batchLimitReached = true;
|
|
1062
|
+
break filesLoop;
|
|
1063
|
+
}
|
|
927
1064
|
batchBytes = ENVELOPE_BYTES + rowJsonBytes;
|
|
928
1065
|
} else {
|
|
929
1066
|
batchBytes += addCost;
|
|
930
1067
|
}
|
|
931
|
-
|
|
932
1068
|
batchEvents.push(bounded);
|
|
933
|
-
batchSources.push(
|
|
934
|
-
filePath,
|
|
935
|
-
endOffset: lineEndOffsets[i],
|
|
936
|
-
mtime: currentMtime,
|
|
937
|
-
...(codexContext ? { context: cloneCodexContext(codexContext) } : {}),
|
|
938
|
-
});
|
|
1069
|
+
batchSources.push(source());
|
|
939
1070
|
}
|
|
940
1071
|
}
|
|
941
1072
|
|
|
942
|
-
|
|
1073
|
+
if (!uploadFailed && !batchLimitReached) {
|
|
1074
|
+
uploadFailed = !(await flush());
|
|
1075
|
+
}
|
|
943
1076
|
|
|
944
1077
|
// 4. Persist cursor: loaded < rotation_resets < newly_committed.
|
|
945
1078
|
const finalFiles: Record<string, CursorEntry> = { ...loadedFiles };
|