diff --git a/src/logger.ts b/src/logger.ts index 4c31f1ffb..e86af2efc 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -31,4 +31,17 @@ export function saveLogsToFile(fileName: string): fs.WriteStream { return logFile; } +export function flushLogs( + logFile: fs.WriteStream, + timeoutMs = 2000, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(reject, timeoutMs); + logFile.end(() => { + clearTimeout(timeout); + resolve(); + }); + }); +} + export const logger = debug(mcpDebugNamespace); diff --git a/src/main.ts b/src/main.ts index a4976c0fb..b4972be1f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,7 +38,10 @@ export const args = parseArguments(VERSION); const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; let clearcutLogger: ClearcutLogger | undefined; if (args.usageStatistics) { - clearcutLogger = new ClearcutLogger(); + clearcutLogger = new ClearcutLogger({ + logFile: args.logFile, + appVersion: VERSION, + }); } process.on('unhandledRejection', (reason, promise) => { diff --git a/src/telemetry/clearcut-logger.ts b/src/telemetry/clearcut-logger.ts index 8b86de212..80891ff46 100644 --- a/src/telemetry/clearcut-logger.ts +++ b/src/telemetry/clearcut-logger.ts @@ -4,22 +4,49 @@ * SPDX-License-Identifier: Apache-2.0 */ +import process from 'node:process'; + import {logger} from '../logger.js'; -import {ClearcutSender} from './clearcut-sender.js'; import type {LocalState, Persistence} from './persistence.js'; import {FilePersistence} from './persistence.js'; -import type {FlagUsage} from './types.js'; +import {type FlagUsage, WatchdogMessageType, OsType} from './types.js'; +import {WatchdogClient} from './watchdog-client.js'; const MS_PER_DAY = 24 * 60 * 60 * 1000; +function detectOsType(): OsType { + switch (process.platform) { + case 'win32': + return OsType.OS_TYPE_WINDOWS; + case 'darwin': + return OsType.OS_TYPE_MACOS; + case 'linux': + return OsType.OS_TYPE_LINUX; + default: + return OsType.OS_TYPE_UNSPECIFIED; + } +} + export class ClearcutLogger { #persistence: Persistence; - #sender: ClearcutSender; + #watchdog: WatchdogClient; - constructor(options?: {persistence?: Persistence; sender?: ClearcutSender}) { - this.#persistence = options?.persistence ?? new FilePersistence(); - this.#sender = options?.sender ?? new ClearcutSender(); + constructor(options: { + appVersion: string; + logFile?: string; + persistence?: Persistence; + watchdogClient?: WatchdogClient; + }) { + this.#persistence = options.persistence ?? new FilePersistence(); + this.#watchdog = + options.watchdogClient ?? + new WatchdogClient({ + parentPid: process.pid, + appVersion: options.appVersion, + osType: detectOsType(), + logFile: options.logFile, + }); } async logToolInvocation(args: { @@ -27,19 +54,25 @@ export class ClearcutLogger { success: boolean; latencyMs: number; }): Promise { - await this.#sender.send({ - tool_invocation: { - tool_name: args.toolName, - success: args.success, - latency_ms: args.latencyMs, + this.#watchdog.send({ + type: WatchdogMessageType.LOG_EVENT, + payload: { + tool_invocation: { + tool_name: args.toolName, + success: args.success, + latency_ms: args.latencyMs, + }, }, }); } async logServerStart(flagUsage: FlagUsage): Promise { - await this.#sender.send({ - server_start: { - flag_usage: flagUsage, + this.#watchdog.send({ + type: WatchdogMessageType.LOG_EVENT, + payload: { + server_start: { + flag_usage: flagUsage, + }, }, }); } @@ -57,13 +90,15 @@ export class ClearcutLogger { daysSince = Math.ceil(diffTime / MS_PER_DAY); } - await this.#sender.send({ - daily_active: { - days_since_last_active: daysSince, + this.#watchdog.send({ + type: WatchdogMessageType.LOG_EVENT, + payload: { + daily_active: { + days_since_last_active: daysSince, + }, }, }); - // Update persistence state.lastActive = new Date().toISOString(); await this.#persistence.saveState(state); } diff --git a/src/telemetry/clearcut-sender.ts b/src/telemetry/clearcut-sender.ts deleted file mode 100644 index 7c2fdf336..000000000 --- a/src/telemetry/clearcut-sender.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {logger} from '../logger.js'; - -import type {ChromeDevToolsMcpExtension} from './types.js'; - -export class ClearcutSender { - async send(event: ChromeDevToolsMcpExtension): Promise { - logger('Telemetry event', JSON.stringify(event, null, 2)); - } -} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index 7e8234546..2bb20192e 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -13,8 +13,11 @@ export interface ChromeDevToolsMcpExtension { tool_invocation?: ToolInvocation; server_start?: ServerStart; daily_active?: DailyActive; + server_shutdown?: ServerShutdown; } +export type ServerShutdown = Record; + export interface ToolInvocation { tool_name: string; success: boolean; @@ -65,3 +68,14 @@ export enum McpClient { MCP_CLIENT_CLAUDE_CODE = 1, MCP_CLIENT_GEMINI_CLI = 2, } + +// IPC types for messages between the main process and the +// telemetry watchdog process. +export enum WatchdogMessageType { + LOG_EVENT = 'log-event', +} + +export interface WatchdogMessage { + type: WatchdogMessageType.LOG_EVENT; + payload: ChromeDevToolsMcpExtension; +} diff --git a/src/telemetry/watchdog-client.ts b/src/telemetry/watchdog-client.ts new file mode 100644 index 000000000..49f21b8fb --- /dev/null +++ b/src/telemetry/watchdog-client.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawn, type ChildProcess} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; + +import {logger} from '../logger.js'; + +import type {WatchdogMessage, OsType} from './types.js'; + +export class WatchdogClient { + #childProcess: ChildProcess; + + constructor( + config: { + parentPid: number; + appVersion: string; + osType: OsType; + logFile?: string; + }, + options?: {spawn?: typeof spawn}, + ) { + const watchdogPath = fileURLToPath( + new URL('./watchdog/main.js', import.meta.url), + ); + + const args = [ + watchdogPath, + `--parent-pid=${config.parentPid}`, + `--app-version=${config.appVersion}`, + `--os-type=${config.osType}`, + ]; + + if (config.logFile) { + args.push(`--log-file=${config.logFile}`); + } + + const spawner = options?.spawn ?? spawn; + this.#childProcess = spawner(process.execPath, args, { + stdio: ['pipe', 'ignore', 'ignore'], + detached: true, + }); + this.#childProcess.unref(); + this.#childProcess.on('error', err => { + logger('Watchdog process error:', err); + }); + this.#childProcess.on('exit', (code, signal) => { + logger(`Watchdog exited with code ${code} and signal ${signal}`); + }); + } + + send(message: WatchdogMessage): void { + if ( + this.#childProcess.stdin && + !this.#childProcess.stdin.destroyed && + this.#childProcess.pid + ) { + try { + const line = JSON.stringify(message) + '\n'; + this.#childProcess.stdin.write(line); + } catch (err) { + logger('Failed to write to watchdog stdin', err); + } + } else { + logger('Watchdog stdin not available, dropping message'); + } + } +} diff --git a/src/telemetry/watchdog/clearcut-sender.ts b/src/telemetry/watchdog/clearcut-sender.ts new file mode 100644 index 000000000..ebd3b3380 --- /dev/null +++ b/src/telemetry/watchdog/clearcut-sender.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; + +import {logger} from '../../logger.js'; +import type {ChromeDevToolsMcpExtension, OsType} from '../types.js'; + +const SESSION_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export class ClearcutSender { + #appVersion: string; + #osType: OsType; + #sessionId: string; + #sessionCreated: number; + + constructor(appVersion: string, osType: OsType) { + this.#appVersion = appVersion; + this.#osType = osType; + this.#sessionId = crypto.randomUUID(); + this.#sessionCreated = Date.now(); + } + + async send(event: ChromeDevToolsMcpExtension): Promise { + this.#rotateSessionIfNeeded(); + const enrichedEvent = this.#enrichEvent(event); + this.transport(enrichedEvent); + } + + transport(event: ChromeDevToolsMcpExtension): void { + logger('Telemetry event', JSON.stringify(event, null, 2)); + } + + async sendShutdownEvent(): Promise { + const shutdownEvent: ChromeDevToolsMcpExtension = { + server_shutdown: {}, + }; + await this.send(shutdownEvent); + } + + #rotateSessionIfNeeded(): void { + if (Date.now() - this.#sessionCreated > SESSION_ROTATION_INTERVAL_MS) { + this.#sessionId = crypto.randomUUID(); + this.#sessionCreated = Date.now(); + } + } + + #enrichEvent(event: ChromeDevToolsMcpExtension): ChromeDevToolsMcpExtension { + return { + ...event, + session_id: this.#sessionId, + app_version: this.#appVersion, + os_type: this.#osType, + }; + } +} diff --git a/src/telemetry/watchdog/main.ts b/src/telemetry/watchdog/main.ts new file mode 100644 index 000000000..2750d0312 --- /dev/null +++ b/src/telemetry/watchdog/main.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {WriteStream} from 'node:fs'; +import process from 'node:process'; +import readline from 'node:readline'; +import {parseArgs} from 'node:util'; + +import {logger, flushLogs, saveLogsToFile} from '../../logger.js'; +import type {OsType} from '../types.js'; +import {WatchdogMessageType} from '../types.js'; + +import {ClearcutSender} from './clearcut-sender.js'; + +function main() { + const {values} = parseArgs({ + options: { + 'parent-pid': {type: 'string'}, + 'app-version': {type: 'string'}, + 'os-type': {type: 'string'}, + 'log-file': {type: 'string'}, + }, + strict: true, + }); + + const parentPid = parseInt(values['parent-pid'] ?? '', 10); + const appVersion = values['app-version']; + const osType = parseInt(values['os-type'] ?? '', 10); + const logFile = values['log-file']; + let logStream: WriteStream | undefined; + if (logFile) { + logStream = saveLogsToFile(logFile); + } + + const exit = (code: number) => { + if (!logStream) { + process.exit(code); + } + + void flushLogs(logStream).finally(() => { + process.exit(code); + }); + }; + + if (isNaN(parentPid) || !appVersion || isNaN(osType)) { + logger( + 'Invalid arguments provided for watchdog process: ', + JSON.stringify({parentPid, appVersion, osType}), + ); + exit(1); + return; + } + + logger( + 'Watchdog started', + JSON.stringify( + { + pid: process.pid, + parentPid, + version: appVersion, + osType, + }, + null, + 2, + ), + ); + + const sender = new ClearcutSender(appVersion, osType as OsType); + + let isShuttingDown = false; + function onParentDeath(reason: string) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + logger(`Parent death detected (${reason}). Sending shutdown event...`); + sender + .sendShutdownEvent() + .then(() => { + logger('Shutdown event sent. Exiting.'); + exit(0); + }) + .catch(err => { + logger('Failed to send shutdown event', err); + exit(1); + }); + } + + process.stdin.on('end', () => onParentDeath('stdin end')); + process.stdin.on('close', () => onParentDeath('stdin close')); + process.on('disconnect', () => onParentDeath('ipc disconnect')); + + const rl = readline.createInterface({ + input: process.stdin, + terminal: false, + }); + + rl.on('line', line => { + try { + if (!line.trim()) { + return; + } + + const msg = JSON.parse(line); + if (msg.type === WatchdogMessageType.LOG_EVENT && msg.payload) { + sender.send(msg.payload).catch(err => { + logger('Error sending event', err); + }); + } + } catch (err) { + logger('Failed to parse IPC message', err); + } + }); +} + +try { + main(); +} catch (err) { + console.error('Watchdog fatal error:', err); + process.exit(1); +} diff --git a/tests/e2e/telemetry.test.ts b/tests/e2e/telemetry.test.ts new file mode 100644 index 000000000..0156d8f7b --- /dev/null +++ b/tests/e2e/telemetry.test.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {spawn, type ChildProcess, type SpawnOptions} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {describe, it} from 'node:test'; + +const SERVER_PATH = path.resolve('build/src/main.js'); +const WATCHDOG_START_PATTERN = /Watchdog started[\s\S]*?"pid":\s*(\d+)/; +const SHUTDOWN_PATTERN = /server_shutdown/; +const PARENT_DEATH_PATTERN = /Parent death detected/; + +interface TestContext { + logFile: string; + process?: ChildProcess; + watchdogPid?: number; +} + +async function waitForLogPattern( + logFile: string, + pattern: RegExp, + timeoutMs = 10000, +): Promise { + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + if (fs.existsSync(logFile)) { + const content = fs.readFileSync(logFile, 'utf8'); + const match = content.match(pattern); + if (match) { + return match; + } + } + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error(`Timeout waiting for pattern: ${pattern}`); +} + +async function waitForProcessExit( + pid: number, + timeoutMs = 10000, +): Promise { + const startTime = Date.now(); + return new Promise((resolve, reject) => { + const checkInterval = setInterval(() => { + try { + process.kill(pid, 0); + if (Date.now() - startTime > timeoutMs) { + clearInterval(checkInterval); + try { + process.kill(pid, 'SIGKILL'); + } catch { + // ignore + } + reject(new Error(`Timeout waiting for process ${pid} to exit`)); + } + } catch { + clearInterval(checkInterval); + resolve(); + } + }, 50); + }); +} + +function createLogFilePath(testName: string): string { + return path.join( + os.tmpdir(), + `test-mcp-telemetry-${testName}-${Date.now()}-${Math.random().toString(36).slice(2)}.log`, + ); +} + +function cleanupTest(ctx: TestContext): void { + if (ctx.process && ctx.process.exitCode === null) { + try { + ctx.process.kill('SIGKILL'); + } catch { + // ignore + } + } + if (ctx.watchdogPid) { + try { + process.kill(ctx.watchdogPid, 'SIGKILL'); + } catch { + // ignore + } + } + if (ctx.logFile && fs.existsSync(ctx.logFile)) { + try { + fs.unlinkSync(ctx.logFile); + } catch { + // ignore + } + } +} + +describe('Telemetry E2E', () => { + async function runTelemetryTest( + killFn: (ctx: TestContext) => void, + testName: string, + spawnOptions?: SpawnOptions, + ): Promise { + const ctx: TestContext = { + logFile: createLogFilePath(testName), + }; + + try { + ctx.process = spawn( + process.execPath, + [ + SERVER_PATH, + `--log-file=${ctx.logFile}`, + '--usage-statistics', + '--headless', + ], + { + stdio: ['pipe', 'pipe', 'pipe'], + ...spawnOptions, + }, + ); + + const match = await waitForLogPattern( + ctx.logFile, + WATCHDOG_START_PATTERN, + ); + assert.ok(match, 'Watchdog start log not found'); + ctx.watchdogPid = parseInt(match[1], 10); + assert.ok(ctx.watchdogPid > 0, 'Invalid watchdog PID'); + + killFn(ctx); + await waitForProcessExit(ctx.watchdogPid); + + const shutdownMatch = await waitForLogPattern( + ctx.logFile, + SHUTDOWN_PATTERN, + 2000, + ); + assert.ok(shutdownMatch, 'server_shutdown not logged'); + + const deathMatch = await waitForLogPattern( + ctx.logFile, + PARENT_DEATH_PATTERN, + 2000, + ); + assert.ok(deathMatch, 'Parent death not detected'); + } finally { + cleanupTest(ctx); + } + } + + it('handles SIGKILL', () => + runTelemetryTest(ctx => { + ctx.process!.kill('SIGKILL'); + }, 'SIGKILL')); + + it('handles SIGTERM', () => + runTelemetryTest(ctx => { + ctx.process!.kill('SIGTERM'); + }, 'SIGTERM')); + + it( + 'handles POSIX process group SIGTERM', + {skip: process.platform === 'win32'}, + () => + runTelemetryTest( + ctx => { + process.kill(-ctx.process!.pid!, 'SIGTERM'); + }, + 'sigterm-group', + {detached: true}, + ), + ); +}); diff --git a/tests/telemetry/clearcut-logger.test.ts b/tests/telemetry/clearcut-logger.test.ts index c0a11456a..2c4912ba4 100644 --- a/tests/telemetry/clearcut-logger.test.ts +++ b/tests/telemetry/clearcut-logger.test.ts @@ -10,13 +10,14 @@ import {describe, it, afterEach, beforeEach} from 'node:test'; import sinon from 'sinon'; import {ClearcutLogger} from '../../src/telemetry/clearcut-logger.js'; -import {ClearcutSender} from '../../src/telemetry/clearcut-sender.js'; import type {Persistence} from '../../src/telemetry/persistence.js'; import {FilePersistence} from '../../src/telemetry/persistence.js'; +import {WatchdogMessageType} from '../../src/telemetry/types.js'; +import {WatchdogClient} from '../../src/telemetry/watchdog-client.js'; describe('ClearcutLogger', () => { let mockPersistence: sinon.SinonStubbedInstance; - let mockSender: sinon.SinonStubbedInstance; + let mockWatchdogClient: sinon.SinonStubbedInstance; beforeEach(() => { mockPersistence = sinon.createStubInstance(FilePersistence, { @@ -24,8 +25,7 @@ describe('ClearcutLogger', () => { lastActive: '', }), }); - mockSender = sinon.createStubInstance(ClearcutSender); - mockSender.send.resolves(); + mockWatchdogClient = sinon.createStubInstance(WatchdogClient); }); afterEach(() => { @@ -36,7 +36,8 @@ describe('ClearcutLogger', () => { it('sends correct payload', async () => { const logger = new ClearcutLogger({ persistence: mockPersistence, - sender: mockSender, + appVersion: '1.0.0', + watchdogClient: mockWatchdogClient, }); await logger.logToolInvocation({ toolName: 'test_tool', @@ -44,11 +45,12 @@ describe('ClearcutLogger', () => { latencyMs: 123, }); - assert(mockSender.send.calledOnce); - const extension = mockSender.send.firstCall.args[0]; - assert.strictEqual(extension.tool_invocation?.tool_name, 'test_tool'); - assert.strictEqual(extension.tool_invocation?.success, true); - assert.strictEqual(extension.tool_invocation?.latency_ms, 123); + assert(mockWatchdogClient.send.calledOnce); + const msg = mockWatchdogClient.send.firstCall.args[0]; + assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); + assert.strictEqual(msg.payload.tool_invocation?.tool_name, 'test_tool'); + assert.strictEqual(msg.payload.tool_invocation?.success, true); + assert.strictEqual(msg.payload.tool_invocation?.latency_ms, 123); }); }); @@ -56,22 +58,16 @@ describe('ClearcutLogger', () => { it('logs flag usage', async () => { const logger = new ClearcutLogger({ persistence: mockPersistence, - sender: mockSender, + appVersion: '1.0.0', + watchdogClient: mockWatchdogClient, }); await logger.logServerStart({headless: true}); - // Should have logged server start - const calls = mockSender.send.getCalls(); - const serverStartCall = calls.find(call => { - return !!call.args[0].server_start; - }); - - assert(serverStartCall); - assert.strictEqual( - serverStartCall.args[0].server_start?.flag_usage?.headless, - true, - ); + assert(mockWatchdogClient.send.calledOnce); + const msg = mockWatchdogClient.send.firstCall.args[0]; + assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); + assert.strictEqual(msg.payload.server_start?.flag_usage?.headless, true); }); }); @@ -86,17 +82,17 @@ describe('ClearcutLogger', () => { const logger = new ClearcutLogger({ persistence: mockPersistence, - sender: mockSender, + appVersion: '1.0.0', + watchdogClient: mockWatchdogClient, }); await logger.logDailyActiveIfNeeded(); - const calls = mockSender.send.getCalls(); - const dailyActiveCall = calls.find(call => { - return !!call.args[0].daily_active; - }); + assert(mockWatchdogClient.send.calledOnce); + const msg = mockWatchdogClient.send.firstCall.args[0]; + assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); + assert.ok(msg.payload.daily_active); - assert(dailyActiveCall, 'Should have logged daily active'); assert(mockPersistence.saveState.called); }); @@ -107,17 +103,13 @@ describe('ClearcutLogger', () => { const logger = new ClearcutLogger({ persistence: mockPersistence, - sender: mockSender, + appVersion: '1.0.0', + watchdogClient: mockWatchdogClient, }); await logger.logDailyActiveIfNeeded(); - const calls = mockSender.send.getCalls(); - const dailyActiveCall = calls.find(call => { - return !!call.args[0].daily_active; - }); - - assert(!dailyActiveCall, 'Should NOT have logged daily active'); + assert(mockWatchdogClient.send.notCalled); assert(mockPersistence.saveState.notCalled); }); @@ -128,21 +120,16 @@ describe('ClearcutLogger', () => { const logger = new ClearcutLogger({ persistence: mockPersistence, - sender: mockSender, + appVersion: '1.0.0', + watchdogClient: mockWatchdogClient, }); await logger.logDailyActiveIfNeeded(); - const calls = mockSender.send.getCalls(); - const dailyActiveCall = calls.find(call => { - return !!call.args[0].daily_active; - }); - - assert(dailyActiveCall, 'Should have logged daily active'); - assert.strictEqual( - dailyActiveCall.args[0].daily_active?.days_since_last_active, - -1, - ); + assert(mockWatchdogClient.send.calledOnce); + const msg = mockWatchdogClient.send.firstCall.args[0]; + assert.strictEqual(msg.type, WatchdogMessageType.LOG_EVENT); + assert.strictEqual(msg.payload.daily_active?.days_since_last_active, -1); assert(mockPersistence.saveState.called); }); }); diff --git a/tests/telemetry/watchdog-client.test.ts b/tests/telemetry/watchdog-client.test.ts new file mode 100644 index 000000000..258d3cac6 --- /dev/null +++ b/tests/telemetry/watchdog-client.test.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {ChildProcess} from 'node:child_process'; +import {Writable} from 'node:stream'; +import {describe, it, afterEach, beforeEach} from 'node:test'; + +import sinon from 'sinon'; + +import {OsType, WatchdogMessageType} from '../../src/telemetry/types.js'; +import {WatchdogClient} from '../../src/telemetry/watchdog-client.js'; + +describe('WatchdogClient', () => { + let spawnStub: sinon.SinonStub; + let stdinStub: sinon.SinonStubbedInstance; + let mockChildProcess: sinon.SinonStubbedInstance; + + beforeEach(() => { + stdinStub = sinon.createStubInstance(Writable); + mockChildProcess = sinon.createStubInstance(ChildProcess); + spawnStub = sinon.stub().returns(mockChildProcess); + + Object.defineProperty(mockChildProcess, 'stdin', { + value: stdinStub, + writable: true, + }); + Object.defineProperty(mockChildProcess, 'pid', { + value: 12345, + writable: true, + }); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('spawns watchdog process with correct arguments', () => { + new WatchdogClient( + { + parentPid: 100, + appVersion: '1.2.3', + osType: OsType.OS_TYPE_MACOS, + }, + {spawn: spawnStub}, + ); + + assert.ok(spawnStub.calledOnce, 'Expected `spawn` to be called'); + const args = spawnStub.firstCall.args; + const cmdArgs = args[1]; + + assert.match( + cmdArgs[0], + /watchdog[/\\]main\.js$/, + 'First argument should be path to watchdog/main.js', + ); + assert.ok( + cmdArgs.includes('--parent-pid=100'), + 'Arguments should include parent PID', + ); + assert.ok( + cmdArgs.includes('--app-version=1.2.3'), + 'Arguments should include app version', + ); + assert.ok( + cmdArgs.includes('--os-type=2'), + 'Arguments should include OS type', + ); + assert.strictEqual( + spawnStub.firstCall.args[2].detached, + true, + 'Process should be spawned as detached', + ); + }); + + it('passes log-file argument if provided', () => { + new WatchdogClient( + { + parentPid: 100, + appVersion: '1.0.0', + osType: OsType.OS_TYPE_LINUX, + logFile: '/tmp/test.log', + }, + {spawn: spawnStub}, + ); + + const cmdArgs = spawnStub.firstCall.args[1]; + assert.ok( + cmdArgs.includes('--log-file=/tmp/test.log'), + 'Arguments should include log file path', + ); + }); + + it('sends IPC messages via stdin', () => { + const client = new WatchdogClient( + { + parentPid: 100, + appVersion: '1.0.0', + osType: OsType.OS_TYPE_LINUX, + }, + {spawn: spawnStub}, + ); + + const msg = {type: WatchdogMessageType.LOG_EVENT, payload: {}}; + client.send(msg); + + assert.ok( + stdinStub.write.calledOnce, + 'Expected `stdin.write` to be called', + ); + + const writtenData = stdinStub.write.firstCall.args[0]; + assert.strictEqual( + writtenData.trim(), + JSON.stringify(msg), + 'Written data should match expected JSON message', + ); + }); + + it('handles write errors gracefully', () => { + const client = new WatchdogClient( + { + parentPid: 100, + appVersion: '1.0.0', + osType: OsType.OS_TYPE_LINUX, + }, + {spawn: spawnStub}, + ); + + stdinStub.write.throws(new Error('EPIPE')); + + assert.doesNotThrow(() => { + client.send({type: WatchdogMessageType.LOG_EVENT, payload: {}}); + }, 'Client should catch and ignore write errors'); + }); +}); diff --git a/tests/telemetry/watchdog/clearcut-sender.test.ts b/tests/telemetry/watchdog/clearcut-sender.test.ts new file mode 100644 index 000000000..870ea9473 --- /dev/null +++ b/tests/telemetry/watchdog/clearcut-sender.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import crypto from 'node:crypto'; +import {describe, it, afterEach, beforeEach} from 'node:test'; + +import sinon from 'sinon'; + +import {OsType} from '../../../src/telemetry/types.js'; +import {ClearcutSender} from '../../../src/telemetry/watchdog/clearcut-sender.js'; + +describe('ClearcutSender', () => { + let clock: sinon.SinonFakeTimers; + let randomUUIDStub: sinon.SinonStub; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + let uuidCounter = 0; + randomUUIDStub = sinon.stub(crypto, 'randomUUID').callsFake(() => { + return `uuid-${++uuidCounter}` as ReturnType; + }); + }); + + afterEach(() => { + clock.restore(); + randomUUIDStub.restore(); + sinon.restore(); + }); + + it('enriches events with app version, os type, and session id', async () => { + const sender = new ClearcutSender('1.0.0', OsType.OS_TYPE_MACOS); + const transportStub = sinon.stub(sender, 'transport'); + + await sender.send({mcp_client: undefined}); + + assert.strictEqual(transportStub.callCount, 1); + const event = transportStub.firstCall.args[0]; + + assert.strictEqual(event.session_id, 'uuid-1'); + assert.strictEqual(event.app_version, '1.0.0'); + assert.strictEqual(event.os_type, OsType.OS_TYPE_MACOS); + }); + + it('rotates session ID after 24 hours', async () => { + const sender = new ClearcutSender('1.0.0', OsType.OS_TYPE_MACOS); + const transportStub = sinon.stub(sender, 'transport'); + + await sender.send({}); + assert.strictEqual(transportStub.lastCall.args[0].session_id, 'uuid-1'); + + clock.tick(23 * 60 * 60 * 1000); + await sender.send({}); + assert.strictEqual(transportStub.lastCall.args[0].session_id, 'uuid-1'); + + clock.tick(2 * 60 * 60 * 1000); + await sender.send({}); + assert.strictEqual(transportStub.lastCall.args[0].session_id, 'uuid-2'); + }); + + it('sendShutdownEvent sends a server_shutdown event', async () => { + const sender = new ClearcutSender('1.0.0', OsType.OS_TYPE_MACOS); + const transportStub = sinon.stub(sender, 'transport'); + + await sender.sendShutdownEvent(); + + const event = transportStub.firstCall.args[0]; + assert.ok(event.server_shutdown); + assert.strictEqual(event.server_start, undefined); + }); +});