Skip to content

Commit 3a47a83

Browse files
committed
[Telemetry] Implement Phase 2: Watchdog Process
1 parent 92b6834 commit 3a47a83

3 files changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import crypto from 'node:crypto';
8+
9+
import {logger} from '../../logger.js';
10+
import type {ChromeDevToolsMcpExtension, OsType} from '../types.js';
11+
12+
const SESSION_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000;
13+
14+
export class ClearcutSender {
15+
#appVersion: string;
16+
#osType: OsType;
17+
#sessionId: string;
18+
#sessionCreated: number;
19+
20+
constructor(appVersion: string, osType: OsType) {
21+
this.#appVersion = appVersion;
22+
this.#osType = osType;
23+
this.#sessionId = crypto.randomUUID();
24+
this.#sessionCreated = Date.now();
25+
}
26+
27+
async send(event: ChromeDevToolsMcpExtension): Promise<void> {
28+
this.#rotateSessionIfNeeded();
29+
const enrichedEvent = this.#enrichEvent(event);
30+
this.transport(enrichedEvent);
31+
}
32+
33+
// Public for stubbing in tests
34+
transport(event: ChromeDevToolsMcpExtension): void {
35+
logger('Telemetry event', JSON.stringify(event, null, 2));
36+
}
37+
38+
async sendShutdownEvent(): Promise<void> {
39+
// CRITICAL: Do not include flag_usage
40+
const shutdownEvent: ChromeDevToolsMcpExtension = {
41+
server_shutdown: {},
42+
};
43+
await this.send(shutdownEvent);
44+
}
45+
46+
#rotateSessionIfNeeded(): void {
47+
if (Date.now() - this.#sessionCreated > SESSION_ROTATION_INTERVAL_MS) {
48+
this.#sessionId = crypto.randomUUID();
49+
this.#sessionCreated = Date.now();
50+
}
51+
}
52+
53+
#enrichEvent(event: ChromeDevToolsMcpExtension): ChromeDevToolsMcpExtension {
54+
return {
55+
...event,
56+
session_id: this.#sessionId,
57+
app_version: this.#appVersion,
58+
os_type: this.#osType,
59+
// client_info.client_type could be added here if we passed it in constructor or knew it
60+
// but strictly following plan, we populate these specific fields.
61+
};
62+
}
63+
}

src/telemetry/watchdog/main.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* See IMPLEMENTATION_PLAN.md for details
9+
*/
10+
11+
import process from 'node:process';
12+
import readline from 'node:readline';
13+
14+
import yargs from 'yargs';
15+
import {hideBin} from 'yargs/helpers';
16+
17+
import {logger, saveLogsToFile} from '../../logger.js';
18+
import {IpcMessageType, OsType} from '../types.js';
19+
20+
import {ClearcutSender} from './clearcut-sender.js';
21+
22+
async function main() {
23+
const argv = await yargs(hideBin(process.argv))
24+
.option('parent-pid', {
25+
type: 'number',
26+
demandOption: true,
27+
describe: 'PID of the main process to monitor',
28+
})
29+
.option('app-version', {
30+
type: 'string',
31+
demandOption: true,
32+
describe: 'Application version string',
33+
})
34+
.option('os-type', {
35+
type: 'number',
36+
demandOption: true,
37+
describe: 'Integer specific to OsType',
38+
})
39+
.option('log-file', {
40+
type: 'string',
41+
describe: 'Optional path to log file',
42+
})
43+
.parse();
44+
45+
if (argv.logFile) {
46+
saveLogsToFile(argv.logFile);
47+
}
48+
49+
logger('Watchdog started', {
50+
parentPid: argv.parentPid,
51+
version: argv.appVersion,
52+
osType: argv.osType,
53+
});
54+
55+
const sender = new ClearcutSender(argv.appVersion, argv.osType as OsType);
56+
57+
// === Death Detection ===
58+
// We rely on Stdin pipe closing to detect parent death.
59+
// This works even with `kill -9` on the parent.
60+
function onParentDeath(reason: string) {
61+
logger(`Parent death detected (${reason}). Sending shutdown event...`);
62+
// We can't await here effectively in all cases, but we try our best.
63+
sender
64+
.sendShutdownEvent()
65+
.then(() => {
66+
logger('Shutdown event sent. Exiting.');
67+
process.exit(0);
68+
})
69+
.catch((err) => {
70+
logger('Failed to send shutdown event', err);
71+
process.exit(1);
72+
});
73+
}
74+
75+
process.stdin.on('end', () => onParentDeath('stdin end'));
76+
process.stdin.on('close', () => onParentDeath('stdin close'));
77+
78+
// Double check disconnection if spawned with IPC channel (not strictly required by plan but good practice)
79+
process.on('disconnect', () => onParentDeath('ipc disconnect'));
80+
81+
// === IPC Handling ===
82+
const rl = readline.createInterface({
83+
input: process.stdin,
84+
terminal: false,
85+
});
86+
87+
rl.on('line', (line) => {
88+
try {
89+
if (!line.trim()) return;
90+
91+
const msg = JSON.parse(line);
92+
if (msg.type === IpcMessageType.DATA && msg.payload) {
93+
sender.send(msg.payload).catch((err) => {
94+
logger('Error sending event', err);
95+
});
96+
}
97+
} catch (err) {
98+
logger('Failed to parse IPC message', err);
99+
}
100+
});
101+
}
102+
103+
main().catch((err) => {
104+
console.error('Watchdog fatal error:', err);
105+
process.exit(1);
106+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import crypto from 'node:crypto';
8+
import sinon from 'sinon';
9+
import {describe, it, afterEach, beforeEach} from 'node:test';
10+
import assert from 'node:assert';
11+
import {OsType} from '../../../src/telemetry/types.js';
12+
import {ClearcutSender} from '../../../src/telemetry/watchdog/clearcut-sender.js';
13+
14+
describe('ClearcutSender', () => {
15+
let clock: sinon.SinonFakeTimers;
16+
let randomUUIDStub: sinon.SinonStub;
17+
18+
beforeEach(() => {
19+
clock = sinon.useFakeTimers();
20+
let uuidCounter = 0;
21+
randomUUIDStub = sinon.stub(crypto, 'randomUUID').callsFake(() => {
22+
return `uuid-${++uuidCounter}` as any;
23+
});
24+
});
25+
26+
afterEach(() => {
27+
clock.restore();
28+
randomUUIDStub.restore();
29+
sinon.restore();
30+
});
31+
32+
it('enriches events with app version, os type, and session id', async () => {
33+
const sender = new ClearcutSender('1.0.0', OsType.OS_TYPE_MACOS);
34+
const transportStub = sinon.stub(sender, 'transport');
35+
36+
await sender.send({mcp_client: undefined});
37+
38+
assert.strictEqual(transportStub.callCount, 1);
39+
const event = transportStub.firstCall.args[0];
40+
41+
assert.strictEqual(event.session_id, 'uuid-1');
42+
assert.strictEqual(event.app_version, '1.0.0');
43+
assert.strictEqual(event.os_type, OsType.OS_TYPE_MACOS);
44+
});
45+
46+
it('rotates session ID after 24 hours', async () => {
47+
const sender = new ClearcutSender('1.0.0', OsType.OS_TYPE_MACOS);
48+
const transportStub = sinon.stub(sender, 'transport');
49+
50+
// First call -> session uuid-1
51+
await sender.send({});
52+
assert.strictEqual(transportStub.lastCall.args[0].session_id, 'uuid-1');
53+
54+
// Advance time by 23 hours -> should NOT rotate
55+
clock.tick(23 * 60 * 60 * 1000);
56+
await sender.send({});
57+
assert.strictEqual(transportStub.lastCall.args[0].session_id, 'uuid-1');
58+
59+
// Advance time by 2 hours (total 25h) -> SHOULD rotate
60+
clock.tick(2 * 60 * 60 * 1000);
61+
await sender.send({});
62+
assert.strictEqual(transportStub.lastCall.args[0].session_id, 'uuid-2');
63+
});
64+
65+
it('sendShutdownEvent sends a server_shutdown event without flag_usage', async () => {
66+
const sender = new ClearcutSender('1.0.0', OsType.OS_TYPE_MACOS);
67+
const transportStub = sinon.stub(sender, 'transport');
68+
69+
await sender.sendShutdownEvent();
70+
71+
const event = transportStub.firstCall.args[0];
72+
assert.ok(event.server_shutdown);
73+
assert.strictEqual(event.server_start, undefined);
74+
});
75+
});

0 commit comments

Comments
 (0)