项目文件夹

文件
Ergün Erdoğmuş 210bacdb04 chore: Implement ClearcutSender HTTP transport for telemetry disabled by default (#805)
This PR completes the telemetry system by implementing the transport
layer for `ClearcutSender`. It enables actual HTTP communication with
the Clearcut backend, handling event batching, rate limiting, and
reliable delivery, including robust shutdown handling.

**Key Changes:**
* **HTTP Transport**: Implemented `fetch`-based transport sending `POST`
requests to the Clearcut HTTP server.
* **Event Batching**: Events are now buffered and flushed periodically
(default: 15 minutes) or on shutdown.
*   **Reliability & Rate Limiting**:
* **Server-Side Backoff**: Respects `next_request_wait_millis` from
server responses to handle rate limiting dynamically.
* **Transient Error Retries**: Failed requests (5xx, 429) result in
events being requeued for the next flush.
* **Request Timeouts**: Enforced 30s timeout on requests to prevent
hanging processes.
* **Session Rotation**: Automatically rotates session IDs every 24
hours.
*   **Safety & Stability**:
* **Buffer Overflow Protection**: Caps the buffer at 1000 events to
prevent memory leaks, dropping oldest events if necessary.
* **Optimistic Removal**: Prevents race conditions and duplicate events
during shutdown by optimistically removing events from the buffer before
sending.
*   **Testing Improvements**:
* **E2E Robustness**: Updated E2E tests to use a mock web server instead
of relying on the logger to log specific lines.

**Implementation Roadmap:**
These changes finalize the planned telemetry architecture:
1. **CLI & Opt-out Mechanism
([Merged](https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/757))**
2. **Logger Scaffolding & Integration
([Merged](https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/758))**
3. **Persistence Layer
([Merged](https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/766))**
4. **Watchdog Process Architecture
([Merged](https://github.com/ChromeDevTools/chrome-devtools-mcp/pull/769))**
5.  **Transport, Batching & Retries (This PR):**
* Finalized `ClearcutSender` with HTTP transport, batching, and
server-directed backoff strategies.

---------

Co-authored-by: Alex Rudenko <alexrudenko@chromium.org>
2026-01-27 14:18:33 +00:00

256 行
6.5 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 http from 'node:http';
import type {AddressInfo} from 'node:net';
import path from 'node:path';
import {describe, it} from 'node:test';
import type {ChromeDevToolsMcpExtension} from '../../src/telemetry/types';
const SERVER_PATH = path.resolve('build/src/main.js');
interface MockServerContext {
server: http.Server;
port: number;
events: ChromeDevToolsMcpExtension[];
watchdogPid?: number;
waitForEvent: (
predicate: (event: ChromeDevToolsMcpExtension) => boolean,
) => Promise<ChromeDevToolsMcpExtension>;
}
async function startMockServer(): Promise<MockServerContext> {
const events: ChromeDevToolsMcpExtension[] = [];
let waitingResolvers: Array<{
predicate: (event: ChromeDevToolsMcpExtension) => boolean;
resolve: (event: ChromeDevToolsMcpExtension) => void;
}> = [];
let watchdogPid: number | undefined;
const server = http.createServer((req, res) => {
if (req.method === 'POST') {
const pidHeader = req.headers['x-watchdog-pid'];
if (pidHeader && !Array.isArray(pidHeader)) {
watchdogPid = parseInt(pidHeader, 10);
}
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', () => {
try {
const parsed = JSON.parse(body);
// Extract internal log events
if (parsed.log_event) {
for (const logEvent of parsed.log_event) {
if (logEvent.source_extension_json) {
const ext = JSON.parse(
logEvent.source_extension_json,
) as ChromeDevToolsMcpExtension;
events.push(ext);
// Check if any waiters are satisfied
waitingResolvers = waitingResolvers.filter(
({predicate, resolve}) => {
if (predicate(ext)) {
resolve(ext);
return false;
}
return true;
},
);
}
}
}
} catch (err) {
console.error('Failed to parse mock server request', err);
}
res.writeHead(200, {'Content-Type': 'application/json'});
res.end(JSON.stringify({next_request_wait_millis: 100}));
});
} else {
res.writeHead(404);
res.end();
}
});
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address() as AddressInfo;
return {
server,
port: address.port,
events,
get watchdogPid() {
return watchdogPid;
},
waitForEvent: predicate => {
const existing = events.find(predicate);
if (existing) {
return Promise.resolve(existing);
}
return new Promise(resolve => {
waitingResolvers.push({predicate, resolve});
});
},
};
}
interface TestContext {
process?: ChildProcess;
mockServer?: MockServerContext;
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function waitForProcessExit(
pid: number,
timeoutMs = 5000,
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (!isProcessAlive(pid)) {
return;
}
await new Promise(resolve => setTimeout(resolve, 50));
}
throw new Error(`Timeout waiting for process ${pid} to exit`);
}
function cleanupTest(ctx: TestContext): void {
// Kill Main Process
if (ctx.process && ctx.process.exitCode === null) {
try {
ctx.process.kill('SIGKILL');
} catch {
// ignore
}
}
// Kill Watchdog Process
if (ctx.mockServer?.watchdogPid) {
try {
process.kill(ctx.mockServer.watchdogPid, 'SIGKILL');
} catch {
// ignore
}
}
// Stop Mock Server
if (ctx.mockServer) {
ctx.mockServer.server.close();
}
}
describe('Telemetry E2E', () => {
async function runTelemetryTest(
killFn: (ctx: TestContext) => void,
spawnOptions?: SpawnOptions,
): Promise<void> {
const mockContext = await startMockServer();
const ctx: TestContext = {
mockServer: mockContext,
};
try {
ctx.process = spawn(
process.execPath,
[
SERVER_PATH,
'--usage-statistics',
'--headless',
`--clearcutEndpoint=http://127.0.0.1:${mockContext.port}`,
'--clearcutForceFlushIntervalMs=10',
'--clearcutIncludePidHeader',
],
{
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
},
...spawnOptions,
},
);
const startEvent = await Promise.race([
mockContext.waitForEvent(e => e.server_start !== undefined),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for server_start')),
10000,
),
),
]);
assert.ok(startEvent, 'server_start event not received');
// Now that we received an event, we should have the Watchdog PID
const watchdogPid = mockContext.watchdogPid;
assert.ok(watchdogPid, 'Watchdog PID not captured from headers');
// Assert Watchdog is actually running
assert.strictEqual(
isProcessAlive(watchdogPid),
true,
'Watchdog process should be running',
);
// Trigger shutdown
killFn(ctx);
// Verify shutdown event
const shutdownEvent = await Promise.race([
mockContext.waitForEvent(e => e.server_shutdown !== undefined),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout waiting for server_shutdown')),
10000,
),
),
]);
assert.ok(shutdownEvent, 'server_shutdown event not received');
// Wait for Watchdog to exit naturally
await waitForProcessExit(watchdogPid);
} finally {
cleanupTest(ctx);
}
}
it('handles SIGKILL', () =>
runTelemetryTest(ctx => {
ctx.process!.kill('SIGKILL');
}));
it('handles SIGTERM', () =>
runTelemetryTest(ctx => {
ctx.process!.kill('SIGTERM');
}));
it(
'handles POSIX process group SIGTERM',
{skip: process.platform === 'win32'},
() =>
runTelemetryTest(
ctx => {
process.kill(-ctx.process!.pid!, 'SIGTERM');
},
{detached: true},
),
);
});