claude-code-session-manager 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/assets/{AgentLibrary-pwlkAFb3.js → AgentLibrary-B-OM-4gk.js} +1 -1
  2. package/dist/assets/{DataModel-BZK9PXFD.js → DataModel-BC6ppcBr.js} +1 -1
  3. package/dist/assets/{History-BVRjxJjS.js → History-D3Qw2BWn.js} +1 -1
  4. package/dist/assets/{Hooks-CNuwHeGx.js → Hooks-OxBQ0nIc.js} +1 -1
  5. package/dist/assets/{HostBilko-cjwNodhV.js → HostBilko-BNFC6bNZ.js} +1 -1
  6. package/dist/assets/{Library-YPNm9W92.js → Library-DAMyrUtY.js} +1 -1
  7. package/dist/assets/{ListDetail-CY4GM1Om.js → ListDetail-BwywrpvE.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BF4y2Jiz.js → MarkdownEditor-HEwjN1bp.js} +1 -1
  9. package/dist/assets/{McpServers-CmBdWtX_.js → McpServers-D1VZF7Ix.js} +1 -1
  10. package/dist/assets/{Memory-CvkIXNl1.js → Memory-CgYJ_QqD.js} +1 -1
  11. package/dist/assets/{Panel-D93o-sxe.js → Panel-dTNWC59Q.js} +1 -1
  12. package/dist/assets/{Permissions-DQipg16I.js → Permissions-qI_NKi1b.js} +1 -1
  13. package/dist/assets/{Plugins-B6NwzPfK.js → Plugins-DZ0_CHiF.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-DACVJhrB.js → ProvenanceBadge-B2i_uxCu.js} +1 -1
  15. package/dist/assets/{SaveBar-Cg4lbChb.js → SaveBar-4tNMCjUd.js} +1 -1
  16. package/dist/assets/{Scheduler-Dr5ZcBLe.js → Scheduler-CUen18Gc.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-C-locvy0.js → ScopeSwitcher-Co3tkLTT.js} +1 -1
  18. package/dist/assets/{Settings-BVrAle90.js → Settings-DXVgKLUx.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DDzuYgSK.js → SkillReferenceGraph-BKcHtMPc.js} +1 -1
  20. package/dist/assets/{Skills-DFvhiOAQ.js → Skills-DHb4INtF.js} +1 -1
  21. package/dist/assets/{SystemPrompt-8PiTyUyL.js → SystemPrompt-BjqFOHSk.js} +1 -1
  22. package/dist/assets/{TagLibrary-DruUYaAc.js → TagLibrary-xLP5dJvh.js} +1 -1
  23. package/dist/assets/{TiptapBody-Dr4a--42.js → TiptapBody-T-ULTjK4.js} +1 -1
  24. package/dist/assets/{Toggle-B_EH2TFb.js → Toggle-B-5M2hGe.js} +1 -1
  25. package/dist/assets/{index-DApB4DHS.js → index-CzAxC432.js} +3 -3
  26. package/dist/assets/{settingsSchema-BKa-xk8g.js → settingsSchema-C_nsemcF.js} +1 -1
  27. package/dist/index.html +1 -1
  28. package/package.json +1 -1
  29. package/src/main/__tests__/telemetryClient.test.cjs +744 -0
  30. package/src/main/lib/telemetryClient.cjs +622 -0
@@ -0,0 +1,622 @@
1
+ /**
2
+ * telemetryClient — the single egress module that ships telemetry to
3
+ * bilko.run's beacon endpoints (POST /api/telemetry/event, /log, /error).
4
+ *
5
+ * Records accumulate durably in ~/.config/session-manager/telemetry-queue.jsonl
6
+ * the instant they're accepted, and are only ever sent by flush(reason) — on
7
+ * a deliberate cadence (boot / daily / version-change / quit / manual), never
8
+ * on a short interval. Every record is idempotent by recordId and stamped at
9
+ * creation with the appVersion/platform/arch/machineDigest of the install
10
+ * that produced it, so a record survives an upgrade with its original
11
+ * attribution intact.
12
+ *
13
+ * Fail-inert like ../otel.cjs: every ingress function is wrapped so it can
14
+ * never throw and never blocks the caller on real I/O completion (the
15
+ * returned promise resolves once accepted/dropped, but callers are free to
16
+ * not await it). Telemetry failing must stay invisible to the user.
17
+ *
18
+ * Single-writer precedent (../lib/instanceLock.cjs): this module assumes the
19
+ * app's main process is the only writer of the queue/sent-set files for a
20
+ * given machine. It does not itself arbitrate multiple OS processes.
21
+ *
22
+ * Wiring (who calls track/logLine/reportError, when flush('boot'/'quit') is
23
+ * invoked) is deliberately out of scope here — this module only implements
24
+ * the egress contract and its own internal daily cadence timer.
25
+ */
26
+ 'use strict';
27
+
28
+ const fsp = require('node:fs/promises');
29
+ const os = require('node:os');
30
+ const path = require('node:path');
31
+ const crypto = require('node:crypto');
32
+
33
+ const config = require('../config.cjs');
34
+ const telemetrySettings = require('./telemetrySettings.cjs');
35
+ const machineProfile = require('./machineProfile.cjs');
36
+
37
+ const MAX_BATCH = 50;
38
+ const QUEUE_CAP_COUNT = 5000;
39
+ const QUEUE_CAP_BYTES = 5 * 1024 * 1024;
40
+ const SENT_CAP = 5000;
41
+ const META_MAX_BYTES = 4000;
42
+ const MSG_MAX_CHARS = 500;
43
+ const STACK_MAX_CHARS = 16000;
44
+ const HOUR_MS = 60 * 60 * 1000;
45
+ const BACKOFF_BASE_MS = 60 * 1000;
46
+ const BACKOFF_MAX_MS = 6 * 60 * 60 * 1000;
47
+ const ERR_DEDUP_WINDOW_MS = 60 * 1000;
48
+ const ERR_DEDUP_MAX_PER_WINDOW = 3;
49
+ const VALID_REASONS = new Set(['boot', 'daily', 'version-change', 'quit', 'manual']);
50
+ const ALLOWED_LEVELS = new Set(['info', 'warn', 'error']);
51
+
52
+ // Anti-noise tag only — ships inside a public npm package, never a secret.
53
+ const FALLBACK_BEACON_KEY = 'sm-beacon-a1c93f0e';
54
+
55
+ // Same redaction policy as opsErrorLog.cjs's REDACT_KEY (kept independent —
56
+ // no shared require — so this module has no dependency on ops-folder code).
57
+ const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message|token|secret|password|authorization|cookie|api[_-]?key|access[_-]?token|refresh[_-]?token)$/i;
58
+ // Unix absolute-path segments, not preceded by ':' (so "https://host/path"
59
+ // URLs in stack traces aren't mistaken for filesystem paths) and not
60
+ // preceded by '~' (so a path already reduced to homedir-relative by the
61
+ // os.homedir() substitution above isn't re-reduced to just its basename).
62
+ const ABS_PATH_RE = /(?<![:\w~])\/(?:[\w.\-]+\/)*[\w.\-]+/g;
63
+
64
+ /** Module-singleton state. Reset by deleting this module from require.cache. */
65
+ const S = {
66
+ initPromise: null,
67
+ settings: null,
68
+ profile: null,
69
+ profileBuildCount: 0,
70
+ profileBuilder: machineProfile.buildMachineProfile,
71
+ fetchImpl: null,
72
+ sessionId: null,
73
+ queue: [],
74
+ queueIds: new Set(),
75
+ sentIds: new Set(),
76
+ sentOrder: [],
77
+ consecutiveFailures: 0,
78
+ backoffUntil: 0,
79
+ disabledForProcess: false,
80
+ dedupedAppends: 0,
81
+ evictedCount: 0,
82
+ recent: [],
83
+ errSeen: new Map(),
84
+ dailyTimer: null,
85
+ flushChain: Promise.resolve(),
86
+ };
87
+
88
+ function queuePath() {
89
+ return path.join(os.homedir(), '.config', 'session-manager', 'telemetry-queue.jsonl');
90
+ }
91
+
92
+ function sentPath() {
93
+ return path.join(os.homedir(), '.config', 'session-manager', 'telemetry-sent.json');
94
+ }
95
+
96
+ function getBeaconToken() {
97
+ return process.env.SM_BEACON_KEY || FALLBACK_BEACON_KEY;
98
+ }
99
+
100
+ // ─── safe coercion helpers (never throw, regardless of input shape) ───────
101
+
102
+ function safeObj(v) {
103
+ return (v && typeof v === 'object' && !Array.isArray(v)) ? v : {};
104
+ }
105
+
106
+ function safeStr(v, maxLen) {
107
+ if (v == null) return '';
108
+ if (typeof v === 'string') return v.length > maxLen ? v.slice(0, maxLen) : v;
109
+ try {
110
+ const s = String(v);
111
+ return s.length > maxLen ? s.slice(0, maxLen) : s;
112
+ } catch {
113
+ return '';
114
+ }
115
+ }
116
+
117
+ function clampStr(s, n) {
118
+ return typeof s === 'string' ? s.slice(0, n) : safeStr(s, n);
119
+ }
120
+
121
+ function redactString(s) {
122
+ if (typeof s !== 'string' || !s) return s;
123
+ let out = s.split(os.homedir()).join('~');
124
+ out = out.replace(ABS_PATH_RE, (m) => path.basename(m));
125
+ return out;
126
+ }
127
+
128
+ function hashCwd(cwd) {
129
+ return crypto.createHash('sha256').update(String(cwd)).digest('hex').slice(0, 12);
130
+ }
131
+
132
+ /**
133
+ * Recursively redacts + bounds an arbitrary user-supplied object: REDACT_KEY
134
+ * keys -> '[redacted]', a literal `cwd` key -> `projectHash` (sha256(cwd)
135
+ * truncated to 12 hex chars, raw cwd dropped entirely), string leaves get
136
+ * homedir/path redaction + a per-leaf clamp, circular refs are cut via a
137
+ * WeakSet, and object size is bounded by a max depth + max key/array count
138
+ * so a 2 MB string leaf or a deeply nested object can't blow up downstream
139
+ * JSON.stringify. O(n) in the number of visited nodes, each bounded — no
140
+ * unbounded recursion even on a circular or adversarial input.
141
+ */
142
+ function redactDeep(obj, seen, depth) {
143
+ seen = seen || new WeakSet();
144
+ depth = depth || 0;
145
+ if (obj == null) return obj;
146
+ if (typeof obj === 'string') return clampStr(redactString(obj), META_MAX_BYTES);
147
+ if (typeof obj !== 'object') return obj;
148
+ if (depth > 6) return '[MaxDepth]';
149
+ if (seen.has(obj)) return '[Circular]';
150
+ seen.add(obj);
151
+ if (Array.isArray(obj)) {
152
+ return obj.slice(0, 50).map((v) => redactDeep(v, seen, depth + 1));
153
+ }
154
+ const out = {};
155
+ let count = 0;
156
+ let keys;
157
+ try {
158
+ keys = Object.keys(obj);
159
+ } catch {
160
+ return {};
161
+ }
162
+ for (const k of keys) {
163
+ if (count++ >= 50) break;
164
+ let v;
165
+ try {
166
+ v = obj[k];
167
+ } catch {
168
+ continue;
169
+ }
170
+ if (k === 'cwd' && typeof v === 'string' && v) {
171
+ out.projectHash = hashCwd(v);
172
+ continue;
173
+ }
174
+ if (REDACT_KEY.test(k)) {
175
+ out[k] = '[redacted]';
176
+ continue;
177
+ }
178
+ out[k] = redactDeep(v, seen, depth + 1);
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Merges redacted user content with the frozen attribution fields + recordId.
185
+ * Attribution is merged AFTER redaction/clamping so it is never touched by
186
+ * either, and if the combined payload still exceeds the server's meta byte
187
+ * budget, user content is dropped entirely rather than attribution — the
188
+ * four attribution fields + recordId must always survive intact.
189
+ */
190
+ function buildMeta(userObjRedacted, attribution, recordId) {
191
+ let candidate = { ...userObjRedacted, recordId, ...attribution };
192
+ let json;
193
+ try {
194
+ json = JSON.stringify(candidate);
195
+ } catch {
196
+ candidate = { recordId, ...attribution };
197
+ json = JSON.stringify(candidate);
198
+ }
199
+ if (Buffer.byteLength(json, 'utf8') > META_MAX_BYTES) {
200
+ candidate = { recordId, ...attribution, _truncated: true };
201
+ }
202
+ return candidate;
203
+ }
204
+
205
+ function takeRecordId(obj) {
206
+ if (obj && typeof obj.recordId === 'string' && obj.recordId) {
207
+ const rest = { ...obj };
208
+ delete rest.recordId;
209
+ return { recordId: obj.recordId, rest };
210
+ }
211
+ return { recordId: crypto.randomUUID(), rest: obj };
212
+ }
213
+
214
+ function errorSignature(name, stack) {
215
+ const firstFrame = (stack || '').split('\n').find((l) => l.includes('at ')) || '';
216
+ return `${name}|${firstFrame.slice(0, 160)}`;
217
+ }
218
+
219
+ function errorDedupAllows(sig) {
220
+ const now = Date.now();
221
+ const entry = S.errSeen.get(sig);
222
+ if (entry && (now - entry.windowStart) < ERR_DEDUP_WINDOW_MS) {
223
+ if (entry.count >= ERR_DEDUP_MAX_PER_WINDOW) return false;
224
+ entry.count += 1;
225
+ return true;
226
+ }
227
+ S.errSeen.set(sig, { count: 1, windowStart: now });
228
+ return true;
229
+ }
230
+
231
+ // ─── init ───────────────────────────────────────────────────────────────
232
+
233
+ function ensureInit() {
234
+ if (S.initPromise) return S.initPromise;
235
+ S.initPromise = (async () => {
236
+ S.settings = await telemetrySettings.load();
237
+ S.profile = await S.profileBuilder();
238
+ S.profileBuildCount += 1;
239
+ S.sessionId = crypto.randomUUID();
240
+ await loadQueueFromDisk();
241
+ await loadSentFromDisk();
242
+ armDailyTimer();
243
+ })();
244
+ return S.initPromise;
245
+ }
246
+
247
+ async function loadQueueFromDisk() {
248
+ try {
249
+ const raw = await fsp.readFile(queuePath(), 'utf8');
250
+ for (const line of raw.split('\n')) {
251
+ if (!line.trim()) continue;
252
+ try {
253
+ const rec = JSON.parse(line);
254
+ if (rec && typeof rec.recordId === 'string' && !S.queueIds.has(rec.recordId)) {
255
+ S.queue.push(rec);
256
+ S.queueIds.add(rec.recordId);
257
+ }
258
+ } catch { /* skip malformed line */ }
259
+ }
260
+ } catch (e) {
261
+ if (e.code !== 'ENOENT') { /* best-effort read; queue starts empty */ }
262
+ }
263
+ }
264
+
265
+ async function loadSentFromDisk() {
266
+ try {
267
+ const raw = await fsp.readFile(sentPath(), 'utf8');
268
+ const data = JSON.parse(raw);
269
+ if (data && Array.isArray(data.ids)) {
270
+ for (const id of data.ids) {
271
+ if (typeof id === 'string' && !S.sentIds.has(id)) {
272
+ S.sentIds.add(id);
273
+ S.sentOrder.push(id);
274
+ }
275
+ }
276
+ }
277
+ } catch (e) {
278
+ if (e.code !== 'ENOENT') { /* best-effort read; sent set starts empty */ }
279
+ }
280
+ }
281
+
282
+ function armDailyTimer() {
283
+ if (S.dailyTimer) return;
284
+ S.dailyTimer = setInterval(() => {
285
+ tickDaily().catch(() => {});
286
+ }, HOUR_MS);
287
+ if (S.dailyTimer.unref) S.dailyTimer.unref();
288
+ }
289
+
290
+ async function tickDaily() {
291
+ await ensureInit();
292
+ if (telemetrySettings.isDailyFlushDue(S.settings, Date.now())) {
293
+ await flush('daily');
294
+ }
295
+ }
296
+
297
+ // ─── queue persistence ─────────────────────────────────────────────────
298
+
299
+ async function persistAppendLine(rec) {
300
+ const p = queuePath();
301
+ await fsp.mkdir(path.dirname(p), { recursive: true });
302
+ await fsp.appendFile(p, JSON.stringify(rec) + '\n', { mode: 0o600 });
303
+ }
304
+
305
+ /** Full atomic rewrite — only used after mark-done and cap eviction. */
306
+ async function persistQueueFull() {
307
+ const body = S.queue.map((r) => JSON.stringify(r)).join('\n');
308
+ const text = S.queue.length ? body + '\n' : '';
309
+ await config.writeTextAtomic(queuePath(), text, { mode: 0o600 });
310
+ }
311
+
312
+ async function persistSent() {
313
+ await config.writeJson(sentPath(), { ids: S.sentOrder, updatedAt: new Date().toISOString() }, { mode: 0o600 });
314
+ }
315
+
316
+ function recordBytes(r) {
317
+ try {
318
+ return Buffer.byteLength(JSON.stringify(r), 'utf8');
319
+ } catch {
320
+ return 0;
321
+ }
322
+ }
323
+
324
+ /** Oldest-first eviction, bounded by count and total bytes. O(n) per call, n <= QUEUE_CAP_COUNT. */
325
+ async function enforceCap() {
326
+ let changed = false;
327
+ while (S.queue.length > QUEUE_CAP_COUNT) {
328
+ const ev = S.queue.shift();
329
+ S.queueIds.delete(ev.recordId);
330
+ S.evictedCount += 1;
331
+ changed = true;
332
+ }
333
+ let total = S.queue.reduce((a, r) => a + recordBytes(r), 0);
334
+ while (total > QUEUE_CAP_BYTES && S.queue.length > 0) {
335
+ const ev = S.queue.shift();
336
+ S.queueIds.delete(ev.recordId);
337
+ S.evictedCount += 1;
338
+ total -= recordBytes(ev);
339
+ changed = true;
340
+ }
341
+ if (changed) await persistQueueFull();
342
+ }
343
+
344
+ function addToSent(id) {
345
+ if (S.sentIds.has(id)) return;
346
+ S.sentIds.add(id);
347
+ S.sentOrder.push(id);
348
+ while (S.sentOrder.length > SENT_CAP) {
349
+ const ev = S.sentOrder.shift();
350
+ S.sentIds.delete(ev);
351
+ }
352
+ }
353
+
354
+ function removeFromQueue(id) {
355
+ const idx = S.queue.findIndex((r) => r.recordId === id);
356
+ if (idx >= 0) {
357
+ S.queue.splice(idx, 1);
358
+ S.queueIds.delete(id);
359
+ }
360
+ }
361
+
362
+ function pushRecent(rec) {
363
+ S.recent.push({ recordId: rec.recordId, channel: rec.channel, wire: rec.wire });
364
+ if (S.recent.length > 20) S.recent.shift();
365
+ }
366
+
367
+ async function appendRecord(channel, wire, recordId) {
368
+ if (S.queueIds.has(recordId) || S.sentIds.has(recordId)) {
369
+ S.dedupedAppends += 1;
370
+ return { accepted: false, reason: 'duplicate', recordId };
371
+ }
372
+ const rec = { recordId, channel, wire };
373
+ S.queue.push(rec);
374
+ S.queueIds.add(recordId);
375
+ try {
376
+ await persistAppendLine(rec);
377
+ } catch { /* best-effort; still tracked in-memory for this process */ }
378
+ await enforceCap();
379
+ pushRecent(rec);
380
+ return { accepted: true, recordId };
381
+ }
382
+
383
+ // ─── ingress ────────────────────────────────────────────────────────────
384
+
385
+ async function track(name, props) {
386
+ try {
387
+ await ensureInit();
388
+ if (!telemetrySettings.isEnabled(S.settings)) return { accepted: false, reason: 'disabled' };
389
+ const { recordId, rest } = takeRecordId(safeObj(props));
390
+ const redacted = redactDeep(rest);
391
+ const meta = buildMeta(redacted, {
392
+ appVersion: S.profile.appVersion,
393
+ platform: S.profile.platform,
394
+ arch: S.profile.arch,
395
+ machineDigest: S.profile.machineDigest,
396
+ }, recordId);
397
+ const wire = {
398
+ app: 'session-manager',
399
+ name: safeStr(name, 200),
400
+ props: meta,
401
+ session_id: S.sessionId,
402
+ visitor_id: S.settings.installId,
403
+ };
404
+ return await appendRecord('event', wire, recordId);
405
+ } catch {
406
+ return { accepted: false, reason: 'error' };
407
+ }
408
+ }
409
+
410
+ async function logLine(opts) {
411
+ try {
412
+ await ensureInit();
413
+ if (!telemetrySettings.isEnabled(S.settings)) return { accepted: false, reason: 'disabled' };
414
+ const { recordId, rest } = takeRecordId(safeObj(opts));
415
+ const level = ALLOWED_LEVELS.has(rest.level) ? rest.level : 'info';
416
+ const msg = clampStr(redactString(safeStr(rest.msg, 20000)), MSG_MAX_CHARS);
417
+ const fieldsRedacted = redactDeep(safeObj(rest.fields));
418
+ const meta = buildMeta(fieldsRedacted, {
419
+ platform: S.profile.platform,
420
+ arch: S.profile.arch,
421
+ machineDigest: S.profile.machineDigest,
422
+ }, recordId);
423
+ const wire = {
424
+ app: 'session-manager',
425
+ version: S.profile.appVersion,
426
+ level,
427
+ msg,
428
+ visitor_id: S.settings.installId,
429
+ session_id: S.sessionId,
430
+ fields: meta,
431
+ ts: Date.now(),
432
+ };
433
+ return await appendRecord('log', wire, recordId);
434
+ } catch {
435
+ return { accepted: false, reason: 'error' };
436
+ }
437
+ }
438
+
439
+ async function reportError(opts) {
440
+ try {
441
+ await ensureInit();
442
+ if (!telemetrySettings.isEnabled(S.settings)) return { accepted: false, reason: 'disabled' };
443
+ const { recordId, rest } = takeRecordId(safeObj(opts));
444
+ const name = safeStr(rest.name, 200) || 'Error';
445
+ const rawStack = safeStr(rest.stack, 32000);
446
+ const sig = errorSignature(name, rawStack);
447
+ if (!errorDedupAllows(sig)) return { accepted: false, reason: 'error-dedup' };
448
+ const msg = clampStr(redactString(safeStr(rest.msg, 20000)), MSG_MAX_CHARS);
449
+ const stack = clampStr(redactString(rawStack), STACK_MAX_CHARS);
450
+ const contextRedacted = redactDeep(safeObj(rest.context));
451
+ const meta = buildMeta(contextRedacted, {
452
+ platform: S.profile.platform,
453
+ arch: S.profile.arch,
454
+ machineDigest: S.profile.machineDigest,
455
+ }, recordId);
456
+ const wire = {
457
+ app: 'session-manager',
458
+ version: S.profile.appVersion,
459
+ name,
460
+ msg,
461
+ stack,
462
+ url: '',
463
+ ua: '',
464
+ visitor_id: S.settings.installId,
465
+ session_id: S.sessionId,
466
+ context: meta,
467
+ ts: Date.now(),
468
+ };
469
+ return await appendRecord('error', wire, recordId);
470
+ } catch {
471
+ return { accepted: false, reason: 'error' };
472
+ }
473
+ }
474
+
475
+ // ─── egress ────────────────────────────────────────────────────────────
476
+
477
+ async function sendBatch(channel, wireBatch) {
478
+ const fetchFn = S.fetchImpl || (typeof fetch === 'function' ? fetch : null);
479
+ if (typeof fetchFn !== 'function') return { ok: false, status: 0 };
480
+ const base = telemetrySettings.resolveEndpoint(S.settings);
481
+ const url = `${base}/api/telemetry/${channel}`;
482
+ const headers = {
483
+ 'Content-Type': 'application/json',
484
+ 'X-SM-Beacon': `session-manager/${S.profile.appVersion}`,
485
+ 'X-SM-Beacon-Key': getBeaconToken(),
486
+ };
487
+ try {
488
+ const res = await fetchFn(url, { method: 'POST', headers, body: JSON.stringify({ batch: wireBatch }) });
489
+ if (res && res.ok) return { ok: true };
490
+ return { ok: false, status: res ? res.status : 0 };
491
+ } catch {
492
+ return { ok: false, status: 0 };
493
+ }
494
+ }
495
+
496
+ function applyFailureBackoff(status) {
497
+ if (status === 429 || status === 0 || status >= 500) {
498
+ S.consecutiveFailures += 1;
499
+ const ms = Math.min(BACKOFF_BASE_MS * (2 ** (S.consecutiveFailures - 1)), BACKOFF_MAX_MS);
500
+ S.backoffUntil = Date.now() + ms;
501
+ return;
502
+ }
503
+ if (status >= 400 && status < 500) {
504
+ // Malformed-contract circuit breaker (includes 401) — not retried.
505
+ S.disabledForProcess = true;
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Serializes flush() calls through a chain (rather than a boolean lock) so
511
+ * an automatic daily tick and an explicit caller-initiated flush (or two
512
+ * overlapping ticks) can never interleave their queue-file rewrites —
513
+ * each waits for the previous flush to fully settle before starting.
514
+ */
515
+ function flush(reason) {
516
+ const run = () => flushImpl(reason);
517
+ const next = S.flushChain.then(run, run);
518
+ S.flushChain = next.catch(() => {});
519
+ return next;
520
+ }
521
+
522
+ async function flushImpl(reason) {
523
+ const safeReason = VALID_REASONS.has(reason) ? reason : 'manual';
524
+ const result = { sent: [], failed: [], reason: safeReason };
525
+ try {
526
+ await ensureInit();
527
+ if (!telemetrySettings.isEnabled(S.settings)) return result;
528
+ if (S.disabledForProcess) return result;
529
+ if (Date.now() < S.backoffUntil) return result;
530
+
531
+ const byChannel = { event: [], log: [], error: [] };
532
+ for (const r of S.queue) {
533
+ if (byChannel[r.channel]) byChannel[r.channel].push(r);
534
+ }
535
+
536
+ let sawFailure = false;
537
+ for (const channel of ['event', 'log', 'error']) {
538
+ if (sawFailure) break;
539
+ const records = byChannel[channel];
540
+ for (let i = 0; i < records.length; i += MAX_BATCH) {
541
+ const batch = records.slice(i, i + MAX_BATCH);
542
+ const res = await sendBatch(channel, batch.map((r) => r.wire));
543
+ if (res.ok) {
544
+ S.consecutiveFailures = 0;
545
+ S.backoffUntil = 0;
546
+ for (const r of batch) {
547
+ removeFromQueue(r.recordId);
548
+ addToSent(r.recordId);
549
+ result.sent.push(r.recordId);
550
+ }
551
+ } else {
552
+ for (const r of batch) result.failed.push(r.recordId);
553
+ applyFailureBackoff(res.status);
554
+ sawFailure = true;
555
+ break;
556
+ }
557
+ }
558
+ }
559
+
560
+ await persistQueueFull();
561
+ await persistSent();
562
+
563
+ if (safeReason === 'daily' && result.failed.length === 0) {
564
+ S.settings = await telemetrySettings.save({ ...S.settings, lastDailyFlushAt: new Date().toISOString() });
565
+ }
566
+ } catch { /* fail-inert */ }
567
+ return result;
568
+ }
569
+
570
+ function shutdown() {
571
+ if (S.dailyTimer) {
572
+ clearInterval(S.dailyTimer);
573
+ S.dailyTimer = null;
574
+ }
575
+ }
576
+
577
+ function status() {
578
+ return {
579
+ pendingCount: S.queue.length,
580
+ sentCount: S.sentIds.size,
581
+ dedupedAppends: S.dedupedAppends,
582
+ evictedCount: S.evictedCount,
583
+ disabledForProcess: S.disabledForProcess,
584
+ consecutiveFailures: S.consecutiveFailures,
585
+ backoffUntil: S.backoffUntil,
586
+ profileBuildCount: S.profileBuildCount,
587
+ };
588
+ }
589
+
590
+ function recentRecords() {
591
+ return S.recent.slice();
592
+ }
593
+
594
+ async function isPending(recordId) {
595
+ await ensureInit();
596
+ return S.queueIds.has(recordId);
597
+ }
598
+
599
+ // ─── test hooks ─────────────────────────────────────────────────────────
600
+
601
+ function _setFetchImpl(fn) {
602
+ S.fetchImpl = typeof fn === 'function' ? fn : null;
603
+ }
604
+
605
+ function _setMachineProfileBuilder(fn) {
606
+ S.profileBuilder = typeof fn === 'function' ? fn : machineProfile.buildMachineProfile;
607
+ }
608
+
609
+ module.exports = {
610
+ track,
611
+ logLine,
612
+ reportError,
613
+ flush,
614
+ shutdown,
615
+ status,
616
+ recentRecords,
617
+ isPending,
618
+ queuePath,
619
+ sentPath,
620
+ _setFetchImpl,
621
+ _setMachineProfileBuilder,
622
+ };