chromedevtools--chrome-devtools-mcp
ee35f207eb
This PR implements the watchdog process architecture for the telemetry system. It moves the `ClearcutSender` execution to a dedicated child process, ensuring that events—especially shutdown events—are reliably transmitted even if the main server process terminates abruptly. Added an e2e test that runs the server, checks the log file and confirms the telemetry logs exist and that the watchdog process is correctly killed after sending the shutdown event once the main process is killed. **Implementation Roadmap:** This is the fourth in a series of PRs designed to implement the telemetry system: 1. **CLI & Opt-out Mechanism ([Merged](https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/757)):** * Added `--usage-statistics` flag and transparency logging. 2. **Logger Scaffolding & Integration ([Merged](https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/758)):** * **`ClearcutLogger`**: Implemented the main logging entry point. * **One-way Data Flow**: Integrated `logToolInvocation` and `logServerStart` hooks into `main.ts` to capture events. * **`ClearcutSender`**: Introduced a transport abstraction. * **Type Definitions**: Added TypeScript definitions for the telemetry Protocol Buffer messages. 3. **Persistence Layer ([Merged](https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/766)):** * **`FilePersistence`**: Implemented a local file-based state manager to persist the `lastActive` timestamp. * **Daily Active Logic**: Integrated persistence into `ClearcutLogger` to automatically detect and log `daily_active` events (with `days_since_last_active` calculation) via `logDailyActiveIfNeeded`. 4. **Watchdog Process Architecture (This PR):** * **`WatchdogClient`**: Added a client-side wrapper to spawn and communicate with the watchdog process via `stdin`. * **`watchdog/main.ts`**: Created the entry point for the watchdog process. It listens for IPC messages and uses `ClearcutSender` to transmit events. * **Reliable Shutdown**: The watchdog monitors the parent process and guarantees a `shutdown` event is sent when the parent exits or crashes (detecting `stdin` closure). * **Refactoring**: Moved `ClearcutSender` to the `watchdog` directory and updated `ClearcutLogger` to delegate event sending to the `WatchdogClient`. 5. **Transport, Batching & Retries (Next):** * Finalize `ClearcutSender` with actual HTTP transport logic, including event batching and exponential backoff retries.
178 行
4.2 KiB
TypeScript
178 行
4.2 KiB
TypeScript
/**
|
|
* @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<RegExpMatchArray | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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},
|
|
),
|
|
);
|
|
});
|