claude-code-session-manager 0.84.0 → 0.85.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/dist/assets/{AgentLibrary-CCVIpoSz.js → AgentLibrary-Bkv-HcP1.js} +1 -1
- package/dist/assets/{DataModel-BltREYde.js → DataModel-DRH-Ty20.js} +1 -1
- package/dist/assets/{History-BZxFkOp6.js → History-CfRhT1Im.js} +1 -1
- package/dist/assets/{Hooks-Bznlfaaa.js → Hooks-wEmh_U6c.js} +1 -1
- package/dist/assets/{HostBilko-DUG5YHA_.js → HostBilko-D_t7Rbi7.js} +1 -1
- package/dist/assets/{Library-Bi2Fn3w9.js → Library-CpArQ-OJ.js} +1 -1
- package/dist/assets/{ListDetail-4VBvXKrz.js → ListDetail-pjaKYs84.js} +1 -1
- package/dist/assets/{MarkdownEditor-D2ft_v5j.js → MarkdownEditor-Xc141kjj.js} +1 -1
- package/dist/assets/{McpServers-FDekKkyE.js → McpServers-ftqaV3kn.js} +1 -1
- package/dist/assets/{Memory-Dkl80uj_.js → Memory-ChMWkNd0.js} +1 -1
- package/dist/assets/{Panel-LurG5VfD.js → Panel-D9Kr40Ai.js} +1 -1
- package/dist/assets/{Permissions-D2wHBCFA.js → Permissions-DKoNVgzj.js} +1 -1
- package/dist/assets/{Plugins-CTw_wwbI.js → Plugins-BtChISho.js} +2 -2
- package/dist/assets/{ProvenanceBadge-CW6HNUv6.js → ProvenanceBadge-DBA5EcYy.js} +1 -1
- package/dist/assets/{SaveBar-alDHG6cP.js → SaveBar-I0_dWNTX.js} +1 -1
- package/dist/assets/{Scheduler-DxiPcaiW.js → Scheduler-CbES7MC8.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DzFXUKLZ.js → ScopeSwitcher-5GTEveb2.js} +1 -1
- package/dist/assets/{Settings-B6H3v2am.js → Settings-BX3FElXk.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-B8EZalpN.js → SkillReferenceGraph-DNBFGrYE.js} +1 -1
- package/dist/assets/{Skills-DwuHuA08.js → Skills-DJB6-bBM.js} +1 -1
- package/dist/assets/{SystemPrompt-CMMqpGYn.js → SystemPrompt-BiDDrJUA.js} +1 -1
- package/dist/assets/{TagLibrary-C2N5C1n9.js → TagLibrary-_Wrevtop.js} +1 -1
- package/dist/assets/{TiptapBody-btlID-dQ.js → TiptapBody-OWWXdLRy.js} +1 -1
- package/dist/assets/{Toggle-BGOn3DZj.js → Toggle-B122N0HL.js} +1 -1
- package/dist/assets/{index-mnjNDpb1.css → index-CDo9xBR9.css} +1 -1
- package/dist/assets/{index-QLRf0epp.js → index-DhvuQL4C.js} +3 -3
- package/dist/assets/{settingsSchema-DA3N2Up3.js → settingsSchema-sGoCTd7J.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +6 -0
- package/src/main/__tests__/telemetryClient.test.cjs +176 -1
- package/src/main/__tests__/telemetryContract.test.cjs +74 -27
- package/src/main/index.cjs +2 -2
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +6 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +6 -0
- package/src/main/lib/__tests__/telemetryBoot.test.cjs +11 -13
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +6 -0
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +6 -0
- package/src/main/lib/queueStore.cjs +8 -2
- package/src/main/lib/telemetryBoot.cjs +9 -4
- package/src/main/lib/telemetryClient.cjs +119 -5
|
@@ -78,8 +78,9 @@ async function createStubServer() {
|
|
|
78
78
|
events: [], // { event, tool, metadata, session_id, visitor_id, path, version }
|
|
79
79
|
logs: [], // { app, version, level, msg, visitor_id, session_id, fields_json, created_at }
|
|
80
80
|
errors: [], // { app, version, name, msg, stack, url, ua, visitor_id, session_id, context_json, created_at }
|
|
81
|
+
installsById: new Map(), // install_id -> materialised app_installs row (upsert, mirrors the deployed route)
|
|
81
82
|
allRequests: [], // { path, headers, rawBody, parsedBody, channel }
|
|
82
|
-
overrides: { event: [], log: [], error: [] },
|
|
83
|
+
overrides: { event: [], log: [], error: [], install: [] },
|
|
83
84
|
};
|
|
84
85
|
|
|
85
86
|
function nextOverride(channel) {
|
|
@@ -95,6 +96,7 @@ async function createStubServer() {
|
|
|
95
96
|
const channel = urlPath.endsWith('/event') ? 'event'
|
|
96
97
|
: urlPath.endsWith('/log') ? 'log'
|
|
97
98
|
: urlPath.endsWith('/error') ? 'error'
|
|
99
|
+
: urlPath.endsWith('/install') ? 'install'
|
|
98
100
|
: null;
|
|
99
101
|
state.allRequests.push({ path: urlPath, headers: req.headers, rawBody, parsedBody, channel });
|
|
100
102
|
|
|
@@ -111,6 +113,41 @@ async function createStubServer() {
|
|
|
111
113
|
return;
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
if (channel === 'install') {
|
|
117
|
+
// Flat body (never {batch:[...]}) — mirrors the real route's upsert
|
|
118
|
+
// keyed on install_id: first_seen_at preserved, everything else overwritten.
|
|
119
|
+
const b = parsedBody;
|
|
120
|
+
const installId = clamp(b.install_id, 80);
|
|
121
|
+
if (!installId) {
|
|
122
|
+
res.writeHead(400, { 'content-type': 'application/json' });
|
|
123
|
+
res.end(JSON.stringify({ error: 'install_id_required' }));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const now = Math.floor(Date.now() / 1000);
|
|
127
|
+
const existing = state.installsById.get(installId);
|
|
128
|
+
state.installsById.set(installId, {
|
|
129
|
+
install_id: installId,
|
|
130
|
+
app: clamp(b.app, 60),
|
|
131
|
+
app_version: clamp(b.app_version, 20),
|
|
132
|
+
platform: clamp(b.platform, 20),
|
|
133
|
+
os_release: clamp(b.os_release, 80),
|
|
134
|
+
arch: clamp(b.arch, 20),
|
|
135
|
+
cpu_count: Number(b.cpu_count) || 0,
|
|
136
|
+
total_mem_mb: Number(b.total_mem_mb) || 0,
|
|
137
|
+
node_version: clamp(b.node_version, 20),
|
|
138
|
+
electron_version: clamp(b.electron_version, 20),
|
|
139
|
+
install_channel: clamp(b.install_channel, 20),
|
|
140
|
+
locale: clamp(b.locale, 20),
|
|
141
|
+
timezone: clamp(b.timezone, 20),
|
|
142
|
+
first_seen_at: existing ? existing.first_seen_at : now,
|
|
143
|
+
last_seen_at: now,
|
|
144
|
+
seen_count: existing ? existing.seen_count + 1 : 1,
|
|
145
|
+
});
|
|
146
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
147
|
+
res.end(JSON.stringify({ ok: true }));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
114
151
|
const batchRaw = Array.isArray(parsedBody.batch) ? parsedBody.batch : [];
|
|
115
152
|
const batch = batchRaw.slice(0, MAX_BATCH); // MAX_BATCH truncation, exactly as the deployed route does
|
|
116
153
|
|
|
@@ -228,6 +265,7 @@ let originalHome;
|
|
|
228
265
|
let originalSmTelemetry;
|
|
229
266
|
let originalSmTelemetryEndpoint;
|
|
230
267
|
let originalSmBeaconKey;
|
|
268
|
+
let originalSmTelemetrySpool;
|
|
231
269
|
let stubServer;
|
|
232
270
|
|
|
233
271
|
beforeAll(async () => {
|
|
@@ -247,6 +285,7 @@ beforeEach(() => {
|
|
|
247
285
|
originalSmTelemetry = process.env.SM_TELEMETRY;
|
|
248
286
|
originalSmTelemetryEndpoint = process.env.SM_TELEMETRY_ENDPOINT;
|
|
249
287
|
originalSmBeaconKey = process.env.SM_BEACON_KEY;
|
|
288
|
+
originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
|
|
250
289
|
process.env.SM_TELEMETRY_ENDPOINT = stubServer.url;
|
|
251
290
|
delete process.env.SM_TELEMETRY;
|
|
252
291
|
});
|
|
@@ -256,6 +295,7 @@ afterEach(async () => {
|
|
|
256
295
|
if (originalSmTelemetry === undefined) delete process.env.SM_TELEMETRY; else process.env.SM_TELEMETRY = originalSmTelemetry;
|
|
257
296
|
if (originalSmTelemetryEndpoint === undefined) delete process.env.SM_TELEMETRY_ENDPOINT; else process.env.SM_TELEMETRY_ENDPOINT = originalSmTelemetryEndpoint;
|
|
258
297
|
if (originalSmBeaconKey === undefined) delete process.env.SM_BEACON_KEY; else process.env.SM_BEACON_KEY = originalSmBeaconKey;
|
|
298
|
+
if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
|
|
259
299
|
vi.useRealTimers();
|
|
260
300
|
while (tmpDirs.length) {
|
|
261
301
|
const d = tmpDirs.pop();
|
|
@@ -306,6 +346,9 @@ const HOME_DEPENDENT_MODULES = ['../lib/telemetryClient.cjs', '../config.cjs', '
|
|
|
306
346
|
/** A fresh telemetryClient bound to `home`, simulating a real relaunch: only on-disk state carries over. */
|
|
307
347
|
function freshClient(home, profileOverrides) {
|
|
308
348
|
process.env.HOME = home;
|
|
349
|
+
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
350
|
+
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
351
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
|
|
309
352
|
for (const p of HOME_DEPENDENT_MODULES) {
|
|
310
353
|
const resolved = require.resolve(p);
|
|
311
354
|
delete require.cache[resolved];
|
|
@@ -490,30 +533,30 @@ test('an emitted event, log, and error each map to non-NULL values for every col
|
|
|
490
533
|
// AC: machine-profile record survives redaction + clamping intact
|
|
491
534
|
// ═══════════════════════════════════════════════════════════════════════
|
|
492
535
|
|
|
493
|
-
test('the
|
|
536
|
+
test('the install record arrives at app_installs with every mapped field present, snake_case, cpu_count/total_mem_mb numeric', async () => {
|
|
494
537
|
const home = await mkHome();
|
|
495
538
|
const client = freshClient(home);
|
|
496
539
|
const profile = fakeProfile();
|
|
497
|
-
const before = stubServer.state.events.length;
|
|
498
540
|
|
|
499
|
-
await client.
|
|
541
|
+
await client.reportInstall(profile);
|
|
500
542
|
await client.flush('manual');
|
|
501
543
|
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
expect(
|
|
544
|
+
const settings = require('../lib/telemetrySettings.cjs');
|
|
545
|
+
const persisted = await settings.load();
|
|
546
|
+
const row = stubServer.state.installsById.get(persisted.installId);
|
|
547
|
+
expect(row).toBeDefined();
|
|
548
|
+
expect(row.app).toBe('session-manager');
|
|
549
|
+
expect(row.app_version).toBe(profile.appVersion);
|
|
550
|
+
expect(row.platform).toBe(profile.platform);
|
|
551
|
+
expect(row.os_release).toBe(profile.osRelease);
|
|
552
|
+
expect(row.arch).toBe(profile.arch);
|
|
553
|
+
expect(row.cpu_count).toBe(profile.cpuCount);
|
|
554
|
+
expect(row.total_mem_mb).toBe(profile.totalMemMb);
|
|
555
|
+
expect(row.node_version).toBe(profile.nodeVersion);
|
|
556
|
+
expect(row.electron_version).toBe(profile.electronVersion);
|
|
557
|
+
expect(row.install_channel).toBe(profile.installChannel);
|
|
558
|
+
expect(row.locale).toBe(profile.locale);
|
|
559
|
+
expect(row.timezone).toBe(String(profile.timezoneOffsetMinutes));
|
|
517
560
|
|
|
518
561
|
client.shutdown();
|
|
519
562
|
});
|
|
@@ -522,23 +565,27 @@ test('the machine-profile record arrives with every buildMachineProfile() field
|
|
|
522
565
|
// AC: machine profile sent once per version + once per 30-day heartbeat
|
|
523
566
|
// ═══════════════════════════════════════════════════════════════════════
|
|
524
567
|
|
|
525
|
-
test('install
|
|
568
|
+
test('the install upsert fires once per app version and once per 30-day heartbeat, not once per launch', async () => {
|
|
526
569
|
const home = await mkHome();
|
|
527
|
-
|
|
570
|
+
let installId = null;
|
|
528
571
|
|
|
529
|
-
function
|
|
530
|
-
|
|
572
|
+
function seenCount() {
|
|
573
|
+
if (!installId) return 0;
|
|
574
|
+
const row = stubServer.state.installsById.get(installId);
|
|
575
|
+
return row ? row.seen_count : 0;
|
|
531
576
|
}
|
|
532
577
|
|
|
533
578
|
async function simulatedLaunch({ now, appVersion }) {
|
|
534
579
|
const client = freshClient(home, { appVersion });
|
|
535
580
|
const boot = freshBoot();
|
|
581
|
+
const telemetrySettings = require('../lib/telemetrySettings.cjs');
|
|
582
|
+
if (!installId) installId = (await telemetrySettings.load()).installId;
|
|
536
583
|
await boot.bootSequence({
|
|
537
584
|
now,
|
|
538
585
|
appVersion,
|
|
539
586
|
installChannel: 'dev',
|
|
540
587
|
deps: {
|
|
541
|
-
telemetrySettings
|
|
588
|
+
telemetrySettings,
|
|
542
589
|
telemetryClient: client,
|
|
543
590
|
buildMachineProfile: async () => fakeProfile({ appVersion }),
|
|
544
591
|
telemetryCounters: { trackAppLaunch: () => {} },
|
|
@@ -557,15 +604,15 @@ test('install.machine fires once per app version and once per 30-day heartbeat,
|
|
|
557
604
|
await simulatedLaunch({ now: t0 + 2 * ONE_DAY, appVersion: '1.0.0' });
|
|
558
605
|
await simulatedLaunch({ now: t0 + 3 * ONE_DAY, appVersion: '1.0.0' });
|
|
559
606
|
await simulatedLaunch({ now: t0 + 4 * ONE_DAY, appVersion: '1.0.0' });
|
|
560
|
-
expect(
|
|
607
|
+
expect(seenCount()).toBe(1);
|
|
561
608
|
|
|
562
609
|
// Launch 6: version bump.
|
|
563
610
|
await simulatedLaunch({ now: t0 + 5 * ONE_DAY, appVersion: '1.1.0' });
|
|
564
|
-
expect(
|
|
611
|
+
expect(seenCount()).toBe(2);
|
|
565
612
|
|
|
566
613
|
// Launch 7: 31 days after the version-bump report, same version -> heartbeat due.
|
|
567
614
|
await simulatedLaunch({ now: t0 + 5 * ONE_DAY + 31 * ONE_DAY, appVersion: '1.1.0' });
|
|
568
|
-
expect(
|
|
615
|
+
expect(seenCount()).toBe(3);
|
|
569
616
|
});
|
|
570
617
|
|
|
571
618
|
// ═══════════════════════════════════════════════════════════════════════
|
package/src/main/index.cjs
CHANGED
|
@@ -1070,8 +1070,8 @@ app.whenReady().then(async () => {
|
|
|
1070
1070
|
});
|
|
1071
1071
|
|
|
1072
1072
|
// Telemetry boot sequence: flush('boot') always, flush('version-change')
|
|
1073
|
-
// when detected, and the once-per-machine install
|
|
1074
|
-
// (version bump or 30-day liveness heartbeat). See telemetryBoot.cjs.
|
|
1073
|
+
// when detected, and the once-per-machine install upsert (app_installs) when
|
|
1074
|
+
// due (version bump or 30-day liveness heartbeat). See telemetryBoot.cjs.
|
|
1075
1075
|
require('./lib/telemetryBoot.cjs')
|
|
1076
1076
|
.bootSequence({
|
|
1077
1077
|
appVersion: app.getVersion(),
|
|
@@ -16,9 +16,11 @@ const { reportCrash } = require('../crashTelemetry.cjs');
|
|
|
16
16
|
|
|
17
17
|
const tmpDirs = [];
|
|
18
18
|
let originalHome;
|
|
19
|
+
let originalSmTelemetrySpool;
|
|
19
20
|
|
|
20
21
|
afterEach(async () => {
|
|
21
22
|
if (originalHome !== undefined) process.env.HOME = originalHome;
|
|
23
|
+
if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
|
|
22
24
|
while (tmpDirs.length) {
|
|
23
25
|
const d = tmpDirs.pop();
|
|
24
26
|
await fsp.rm(d, { recursive: true, force: true });
|
|
@@ -27,9 +29,13 @@ afterEach(async () => {
|
|
|
27
29
|
|
|
28
30
|
async function mkHome() {
|
|
29
31
|
originalHome = process.env.HOME;
|
|
32
|
+
originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
|
|
30
33
|
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-crash-telemetry-home-'));
|
|
31
34
|
tmpDirs.push(dir);
|
|
32
35
|
process.env.HOME = dir;
|
|
36
|
+
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
37
|
+
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
38
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
|
|
33
39
|
return dir;
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -18,13 +18,16 @@ const { resolveProjectContext } = require('../projectRootResolve.cjs');
|
|
|
18
18
|
|
|
19
19
|
const tmpDirs = [];
|
|
20
20
|
let originalHome;
|
|
21
|
+
let originalSmTelemetrySpool;
|
|
21
22
|
|
|
22
23
|
beforeEach(() => {
|
|
23
24
|
originalHome = process.env.HOME;
|
|
25
|
+
originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
afterEach(async () => {
|
|
27
29
|
if (originalHome !== undefined) process.env.HOME = originalHome;
|
|
30
|
+
if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
|
|
28
31
|
vi.unstubAllGlobals();
|
|
29
32
|
while (tmpDirs.length) {
|
|
30
33
|
const d = tmpDirs.pop();
|
|
@@ -51,6 +54,9 @@ function fakeProfile(overrides = {}) {
|
|
|
51
54
|
/** Reloads the HOME-dependent modules (config.cjs computes allowedRoots from os.homedir() at require time). */
|
|
52
55
|
function freshTelemetry(home) {
|
|
53
56
|
process.env.HOME = home;
|
|
57
|
+
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
58
|
+
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
59
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
|
|
54
60
|
for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
|
|
55
61
|
const resolved = require.resolve(p);
|
|
56
62
|
delete require.cache[resolved];
|
|
@@ -27,7 +27,7 @@ function fakeDeps({ settings } = {}) {
|
|
|
27
27
|
},
|
|
28
28
|
telemetryClient: {
|
|
29
29
|
flush: async (reason) => { flushCalls.push(reason); return { sent: [], failed: [], reason }; },
|
|
30
|
-
|
|
30
|
+
reportInstall: async (profile) => ({ accepted: true, profile }),
|
|
31
31
|
},
|
|
32
32
|
buildMachineProfile: async () => ({ appVersion: '1.0.0', platform: 'linux', arch: 'x64', machineDigest: 'abc123' }),
|
|
33
33
|
telemetryCounters: { trackAppLaunch: () => {} },
|
|
@@ -42,11 +42,11 @@ test('a second boot at the same version within 30 days sends nothing', async ()
|
|
|
42
42
|
settings: { lastMachineReportAt: priorReportAt, lastMachineReportVersion: '1.0.0' },
|
|
43
43
|
});
|
|
44
44
|
const seen = [];
|
|
45
|
-
deps.telemetryClient.
|
|
45
|
+
deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
|
|
46
46
|
|
|
47
47
|
await bootSequence({ now, appVersion: '1.0.0', deps });
|
|
48
48
|
|
|
49
|
-
expect(seen
|
|
49
|
+
expect(seen).toHaveLength(0);
|
|
50
50
|
expect(state().lastMachineReportAt).toBe(priorReportAt);
|
|
51
51
|
});
|
|
52
52
|
|
|
@@ -56,12 +56,11 @@ test('a version bump sends the machine profile exactly once', async () => {
|
|
|
56
56
|
settings: { lastMachineReportAt: new Date(now - DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
|
|
57
57
|
});
|
|
58
58
|
const seen = [];
|
|
59
|
-
deps.telemetryClient.
|
|
59
|
+
deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
|
|
60
60
|
|
|
61
61
|
await bootSequence({ now, appVersion: '2.0.0', deps });
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
expect(installEvents.length).toBe(1);
|
|
63
|
+
expect(seen.length).toBe(1);
|
|
65
64
|
expect(state().lastMachineReportVersion).toBe('2.0.0');
|
|
66
65
|
});
|
|
67
66
|
|
|
@@ -71,27 +70,26 @@ test('31 days on an unchanged version sends the machine profile exactly once (li
|
|
|
71
70
|
settings: { lastMachineReportAt: new Date(now - 31 * DAY_MS).toISOString(), lastMachineReportVersion: '1.0.0' },
|
|
72
71
|
});
|
|
73
72
|
const seen = [];
|
|
74
|
-
deps.telemetryClient.
|
|
73
|
+
deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
|
|
75
74
|
|
|
76
75
|
await bootSequence({ now, appVersion: '1.0.0', deps });
|
|
77
76
|
|
|
78
|
-
|
|
79
|
-
expect(installEvents.length).toBe(1);
|
|
77
|
+
expect(seen.length).toBe(1);
|
|
80
78
|
expect(state().lastMachineReportAt).toBe(new Date(now).toISOString());
|
|
81
79
|
});
|
|
82
80
|
|
|
83
|
-
test('the install
|
|
81
|
+
test('the install report appVersion equals the appVersion stamped on a sibling error record', async () => {
|
|
84
82
|
const now = Date.now();
|
|
85
83
|
const { deps } = fakeDeps({ settings: { lastMachineReportVersion: '', lastMachineReportAt: null } });
|
|
86
84
|
const seen = [];
|
|
87
|
-
deps.telemetryClient.
|
|
85
|
+
deps.telemetryClient.reportInstall = async (profile) => { seen.push(profile); return { accepted: true }; };
|
|
88
86
|
// Sibling error record stamped by the SAME process's telemetryClient with the same appVersion.
|
|
89
87
|
const siblingErrorStampAppVersion = '1.0.0';
|
|
90
88
|
|
|
91
89
|
await bootSequence({ now, appVersion: '1.0.0', deps });
|
|
92
90
|
|
|
93
|
-
const [install] = seen
|
|
94
|
-
expect(install.
|
|
91
|
+
const [install] = seen;
|
|
92
|
+
expect(install.appVersion).toBe(siblingErrorStampAppVersion);
|
|
95
93
|
});
|
|
96
94
|
|
|
97
95
|
test('on app ready, flush("boot") always runs', async () => {
|
|
@@ -13,13 +13,16 @@ const path = require('node:path');
|
|
|
13
13
|
|
|
14
14
|
const tmpDirs = [];
|
|
15
15
|
let originalHome;
|
|
16
|
+
let originalSmTelemetrySpool;
|
|
16
17
|
|
|
17
18
|
beforeEach(() => {
|
|
18
19
|
originalHome = process.env.HOME;
|
|
20
|
+
originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
afterEach(async () => {
|
|
22
24
|
if (originalHome !== undefined) process.env.HOME = originalHome; else delete process.env.HOME;
|
|
25
|
+
if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
|
|
23
26
|
vi.unstubAllGlobals();
|
|
24
27
|
while (tmpDirs.length) {
|
|
25
28
|
const d = tmpDirs.pop();
|
|
@@ -35,6 +38,9 @@ async function mkHome() {
|
|
|
35
38
|
|
|
36
39
|
function freshModules(home) {
|
|
37
40
|
process.env.HOME = home;
|
|
41
|
+
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
42
|
+
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
43
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.config', 'session-manager');
|
|
38
44
|
for (const p of ['../telemetryConsent.cjs', '../telemetryClient.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs', '../../config.cjs']) {
|
|
39
45
|
const resolved = require.resolve(p);
|
|
40
46
|
delete require.cache[resolved];
|
|
@@ -18,9 +18,11 @@ const counters = require('../telemetryCounters.cjs');
|
|
|
18
18
|
|
|
19
19
|
const tmpDirs = [];
|
|
20
20
|
let originalHome;
|
|
21
|
+
let originalSmTelemetrySpool;
|
|
21
22
|
|
|
22
23
|
afterEach(async () => {
|
|
23
24
|
if (originalHome !== undefined) process.env.HOME = originalHome;
|
|
25
|
+
if (originalSmTelemetrySpool === undefined) delete process.env.SM_TELEMETRY_SPOOL; else process.env.SM_TELEMETRY_SPOOL = originalSmTelemetrySpool;
|
|
24
26
|
while (tmpDirs.length) {
|
|
25
27
|
const d = tmpDirs.pop();
|
|
26
28
|
await fsp.rm(d, { recursive: true, force: true });
|
|
@@ -29,9 +31,13 @@ afterEach(async () => {
|
|
|
29
31
|
|
|
30
32
|
async function mkHome() {
|
|
31
33
|
originalHome = process.env.HOME;
|
|
34
|
+
originalSmTelemetrySpool = process.env.SM_TELEMETRY_SPOOL;
|
|
32
35
|
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-telemetry-counters-home-'));
|
|
33
36
|
tmpDirs.push(dir);
|
|
34
37
|
process.env.HOME = dir;
|
|
38
|
+
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
39
|
+
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
40
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.config', 'session-manager');
|
|
35
41
|
return dir;
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -220,6 +220,11 @@ function shapeMachine(data) {
|
|
|
220
220
|
config: data.config || {},
|
|
221
221
|
scheduledFor: data.scheduledFor ?? null,
|
|
222
222
|
lastRunAt: data.lastRunAt ?? null,
|
|
223
|
+
// Distinct from lastRunAt (stamped only when tickQueue actually launches a
|
|
224
|
+
// job): this is stamped every time tickQueue gets far enough to evaluate
|
|
225
|
+
// the queue at all, whether or not that evaluation ends in a launch. See
|
|
226
|
+
// classifyQueueStarvation's header for why the two must never merge.
|
|
227
|
+
lastDispatchAttemptAt: data.lastDispatchAttemptAt ?? null,
|
|
223
228
|
paused: data.paused ?? null,
|
|
224
229
|
// Launch circuit breaker (lib/launchFailure.cjs): per-persona blocks and
|
|
225
230
|
// the degraded-mode env a persona is currently launching with. Machine
|
|
@@ -440,7 +445,7 @@ function shapeJobs(raw, file) {
|
|
|
440
445
|
* consulted so writeSplit can persist "this project now has zero jobs".
|
|
441
446
|
*/
|
|
442
447
|
function readMergedSync(opts) {
|
|
443
|
-
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
448
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
444
449
|
const sourceCwds = [];
|
|
445
450
|
const machine = loadMachineStateSync();
|
|
446
451
|
if (machine.shaped) {
|
|
@@ -473,7 +478,7 @@ function readMergedSync(opts) {
|
|
|
473
478
|
|
|
474
479
|
/** Async twin of readMergedSync for IPC hot paths. */
|
|
475
480
|
async function readMerged(opts) {
|
|
476
|
-
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
481
|
+
const out = { config: {}, jobs: [], scheduledFor: null, lastRunAt: null, lastDispatchAttemptAt: null, paused: null, launchBlocks: {}, launchMitigations: {}, invalidJobs: [] };
|
|
477
482
|
const sourceCwds = [];
|
|
478
483
|
const machine = await loadMachineState();
|
|
479
484
|
if (machine.shaped) {
|
|
@@ -525,6 +530,7 @@ async function writeSplit(state, defaultCwd) {
|
|
|
525
530
|
config: state.config,
|
|
526
531
|
scheduledFor: state.scheduledFor ?? null,
|
|
527
532
|
lastRunAt: state.lastRunAt ?? null,
|
|
533
|
+
lastDispatchAttemptAt: state.lastDispatchAttemptAt ?? null,
|
|
528
534
|
paused: state.paused ?? null,
|
|
529
535
|
launchBlocks: state.launchBlocks ?? {},
|
|
530
536
|
launchMitigations: state.launchMitigations ?? {},
|
|
@@ -36,10 +36,15 @@ function resolveDeps(deps = {}) {
|
|
|
36
36
|
* 2. flush('version-change') — only when this install's persisted
|
|
37
37
|
* lastMachineReportVersion differs from the running appVersion (the same
|
|
38
38
|
* signal that also gates the machine-profile heartbeat below).
|
|
39
|
-
* 3. install
|
|
40
|
-
* telemetrySettings.isMachineReportDue() is true (a version bump, OR
|
|
41
|
-
* 30-day liveness heartbeat), then lastMachineReportAt/Version are
|
|
39
|
+
* 3. install upsert — sent once via telemetryClient.reportInstall(profile)
|
|
40
|
+
* when telemetrySettings.isMachineReportDue() is true (a version bump, OR
|
|
41
|
+
* the 30-day liveness heartbeat), then lastMachineReportAt/Version are
|
|
42
42
|
* persisted so a same-version reboot within the window sends nothing.
|
|
43
|
+
* This lands in bilko.run's app_installs table (the sole source for
|
|
44
|
+
* every install-shaped number on the site). It replaces the former
|
|
45
|
+
* track('install.machine', profile) call, which duplicated the same
|
|
46
|
+
* facts into funnel_events for no reader — app_installs is now the only
|
|
47
|
+
* destination for a machine profile.
|
|
43
48
|
* 4. app.launch — one counter event per boot, via telemetryCounters so the
|
|
44
49
|
* shape lives in exactly one place across every counter this PRD adds.
|
|
45
50
|
*/
|
|
@@ -57,7 +62,7 @@ async function bootSequence({ now = Date.now(), appVersion, installChannel, deps
|
|
|
57
62
|
|
|
58
63
|
if (telemetrySettings.isMachineReportDue(settings, { now, appVersion })) {
|
|
59
64
|
const profile = await buildMachineProfile();
|
|
60
|
-
await telemetryClient.
|
|
65
|
+
await telemetryClient.reportInstall(profile);
|
|
61
66
|
await telemetrySettings.save({
|
|
62
67
|
...settings,
|
|
63
68
|
lastMachineReportAt: new Date(now).toISOString(),
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* telemetryClient — the single egress module that ships telemetry to
|
|
3
|
-
* bilko.run's beacon endpoints (POST /api/telemetry/event, /log, /error
|
|
3
|
+
* bilko.run's beacon endpoints (POST /api/telemetry/event, /log, /error,
|
|
4
|
+
* /install). The `install` channel is a durable upsert keyed on install_id
|
|
5
|
+
* (app_installs), not an append like the other three — its wire body is a
|
|
6
|
+
* single flat snake_case object, never `{batch:[...]}` — so it is routed
|
|
7
|
+
* through the same queue/dedup/backoff machinery but sent one record at a
|
|
8
|
+
* time via sendSingle() rather than sendBatch().
|
|
4
9
|
*
|
|
5
10
|
* Records accumulate durably in ~/.config/session-manager/telemetry-queue.jsonl
|
|
6
11
|
* the instant they're accepted, and are only ever sent by flush(reason) — on
|
|
@@ -88,12 +93,37 @@ const S = {
|
|
|
88
93
|
lastFlushReason: null,
|
|
89
94
|
};
|
|
90
95
|
|
|
96
|
+
/**
|
|
97
|
+
* SM_TELEMETRY_SPOOL, when set, overrides the spool directory in place of
|
|
98
|
+
* `~/.config/session-manager` — the explicit opt-in a test that genuinely
|
|
99
|
+
* needs to exercise real queue/sent-file I/O uses to prove it isn't about to
|
|
100
|
+
* write into a real user's spool (see isTestEnvironment() below).
|
|
101
|
+
*/
|
|
102
|
+
function spoolDir() {
|
|
103
|
+
return process.env.SM_TELEMETRY_SPOOL || path.join(os.homedir(), '.config', 'session-manager');
|
|
104
|
+
}
|
|
105
|
+
|
|
91
106
|
function queuePath() {
|
|
92
|
-
return path.join(
|
|
107
|
+
return path.join(spoolDir(), 'telemetry-queue.jsonl');
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
function sentPath() {
|
|
96
|
-
return path.join(
|
|
111
|
+
return path.join(spoolDir(), 'telemetry-sent.json');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* True when running under a test runner (VITEST, NODE_ENV=test) with no
|
|
116
|
+
* explicit SM_TELEMETRY_SPOOL override. Gates appendRecord() below so a test
|
|
117
|
+
* that merely exercises epicMint/scheduler/etc. code paths — without itself
|
|
118
|
+
* setting up an isolated spool — can never accumulate fake records into a
|
|
119
|
+
* real user's telemetry-queue.jsonl. A test that deliberately wants to
|
|
120
|
+
* exercise real persistence (telemetryClient's own suite) sets
|
|
121
|
+
* SM_TELEMETRY_SPOOL to an isolated directory, which counts as an explicit
|
|
122
|
+
* opt-in and disables this guard.
|
|
123
|
+
*/
|
|
124
|
+
function isTestEnvironment() {
|
|
125
|
+
if (process.env.SM_TELEMETRY_SPOOL) return false;
|
|
126
|
+
return !!process.env.VITEST || process.env.NODE_ENV === 'test';
|
|
97
127
|
}
|
|
98
128
|
|
|
99
129
|
function getBeaconToken() {
|
|
@@ -368,6 +398,9 @@ function pushRecent(rec) {
|
|
|
368
398
|
}
|
|
369
399
|
|
|
370
400
|
async function appendRecord(channel, wire, recordId) {
|
|
401
|
+
if (isTestEnvironment()) {
|
|
402
|
+
return { accepted: false, reason: 'test-environment', recordId };
|
|
403
|
+
}
|
|
371
404
|
if (S.queueIds.has(recordId) || S.sentIds.has(recordId)) {
|
|
372
405
|
S.dedupedAppends += 1;
|
|
373
406
|
return { accepted: false, reason: 'duplicate', recordId };
|
|
@@ -475,6 +508,47 @@ async function reportError(opts) {
|
|
|
475
508
|
}
|
|
476
509
|
}
|
|
477
510
|
|
|
511
|
+
function buildInstallWire(profile, installId) {
|
|
512
|
+
const p = safeObj(profile);
|
|
513
|
+
return {
|
|
514
|
+
install_id: safeStr(installId, 80),
|
|
515
|
+
app: 'session-manager',
|
|
516
|
+
app_version: safeStr(p.appVersion, 20),
|
|
517
|
+
platform: safeStr(p.platform, 20),
|
|
518
|
+
os_release: safeStr(p.osRelease, 80),
|
|
519
|
+
arch: safeStr(p.arch, 20),
|
|
520
|
+
cpu_count: typeof p.cpuCount === 'number' ? p.cpuCount : 0,
|
|
521
|
+
total_mem_mb: typeof p.totalMemMb === 'number' ? p.totalMemMb : 0,
|
|
522
|
+
node_version: safeStr(p.nodeVersion, 20),
|
|
523
|
+
electron_version: safeStr(p.electronVersion, 20),
|
|
524
|
+
install_channel: safeStr(p.installChannel, 20),
|
|
525
|
+
locale: safeStr(p.locale, 20),
|
|
526
|
+
// machineProfile deliberately never carries an IANA timezone name — only
|
|
527
|
+
// the numeric UTC offset (see machineProfile.cjs) — so that is what maps
|
|
528
|
+
// onto the server's `timezone` column.
|
|
529
|
+
timezone: safeStr(p.timezoneOffsetMinutes, 20),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Reports the once-per-cadence install/liveness record. Unlike track/logLine/
|
|
535
|
+
* reportError this isn't arbitrary user content, so it skips redactDeep() —
|
|
536
|
+
* every field already comes from machineProfile's own anonymity contract —
|
|
537
|
+
* and it never merges an attribution block onto the body, since the server's
|
|
538
|
+
* app_installs upsert doesn't read a recordId.
|
|
539
|
+
*/
|
|
540
|
+
async function reportInstall(profile) {
|
|
541
|
+
try {
|
|
542
|
+
await ensureInit();
|
|
543
|
+
if (!telemetrySettings.isEnabled(S.settings)) return { accepted: false, reason: 'disabled' };
|
|
544
|
+
const recordId = crypto.randomUUID();
|
|
545
|
+
const wire = buildInstallWire(profile, S.settings.installId);
|
|
546
|
+
return await appendRecord('install', wire, recordId);
|
|
547
|
+
} catch {
|
|
548
|
+
return { accepted: false, reason: 'error' };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
478
552
|
// ─── egress ────────────────────────────────────────────────────────────
|
|
479
553
|
|
|
480
554
|
async function sendBatch(channel, wireBatch) {
|
|
@@ -496,6 +570,26 @@ async function sendBatch(channel, wireBatch) {
|
|
|
496
570
|
}
|
|
497
571
|
}
|
|
498
572
|
|
|
573
|
+
/** Like sendBatch(), but posts `wire` as a flat body — the install route takes one upsert, never a `{batch:[...]}` envelope. */
|
|
574
|
+
async function sendSingle(channel, wire) {
|
|
575
|
+
const fetchFn = S.fetchImpl || (typeof fetch === 'function' ? fetch : null);
|
|
576
|
+
if (typeof fetchFn !== 'function') return { ok: false, status: 0 };
|
|
577
|
+
const base = telemetrySettings.resolveEndpoint(S.settings);
|
|
578
|
+
const url = `${base}/api/telemetry/${channel}`;
|
|
579
|
+
const headers = {
|
|
580
|
+
'Content-Type': 'application/json',
|
|
581
|
+
'X-SM-Beacon': `session-manager/${S.profile.appVersion}`,
|
|
582
|
+
'X-SM-Beacon-Key': getBeaconToken(),
|
|
583
|
+
};
|
|
584
|
+
try {
|
|
585
|
+
const res = await fetchFn(url, { method: 'POST', headers, body: JSON.stringify(wire) });
|
|
586
|
+
if (res && res.ok) return { ok: true };
|
|
587
|
+
return { ok: false, status: res ? res.status : 0 };
|
|
588
|
+
} catch {
|
|
589
|
+
return { ok: false, status: 0 };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
499
593
|
function describeFailureStatus(status) {
|
|
500
594
|
return status === 0 ? 'network error' : `HTTP ${status}`;
|
|
501
595
|
}
|
|
@@ -537,15 +631,34 @@ async function flushImpl(reason) {
|
|
|
537
631
|
if (S.disabledForProcess) return result;
|
|
538
632
|
if (Date.now() < S.backoffUntil) return result;
|
|
539
633
|
|
|
540
|
-
const byChannel = { event: [], log: [], error: [] };
|
|
634
|
+
const byChannel = { event: [], log: [], error: [], install: [] };
|
|
541
635
|
for (const r of S.queue) {
|
|
542
636
|
if (byChannel[r.channel]) byChannel[r.channel].push(r);
|
|
543
637
|
}
|
|
544
638
|
|
|
545
639
|
let sawFailure = false;
|
|
546
|
-
for (const channel of ['event', 'log', 'error']) {
|
|
640
|
+
for (const channel of ['event', 'log', 'error', 'install']) {
|
|
547
641
|
if (sawFailure) break;
|
|
548
642
|
const records = byChannel[channel];
|
|
643
|
+
if (channel === 'install') {
|
|
644
|
+
for (const r of records) {
|
|
645
|
+
const res = await sendSingle('install', r.wire);
|
|
646
|
+
if (res.ok) {
|
|
647
|
+
S.consecutiveFailures = 0;
|
|
648
|
+
S.backoffUntil = 0;
|
|
649
|
+
S.lastError = null;
|
|
650
|
+
removeFromQueue(r.recordId);
|
|
651
|
+
addToSent(r.recordId);
|
|
652
|
+
result.sent.push(r.recordId);
|
|
653
|
+
} else {
|
|
654
|
+
result.failed.push(r.recordId);
|
|
655
|
+
applyFailureBackoff(res.status);
|
|
656
|
+
sawFailure = true;
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
549
662
|
for (let i = 0; i < records.length; i += MAX_BATCH) {
|
|
550
663
|
const batch = records.slice(i, i + MAX_BATCH);
|
|
551
664
|
const res = await sendBatch(channel, batch.map((r) => r.wire));
|
|
@@ -640,6 +753,7 @@ module.exports = {
|
|
|
640
753
|
track,
|
|
641
754
|
logLine,
|
|
642
755
|
reportError,
|
|
756
|
+
reportInstall,
|
|
643
757
|
flush,
|
|
644
758
|
shutdown,
|
|
645
759
|
status,
|