Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,17 @@ export function saveLogsToFile(fileName: string): fs.WriteStream {
return logFile;
}

export function flushLogs(
logFile: fs.WriteStream,
timeoutMs = 2000,
): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(reject, timeoutMs);
logFile.end(() => {
clearTimeout(timeout);
resolve();
});
});
}

export const logger = debug(mcpDebugNamespace);
5 changes: 4 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
71 changes: 53 additions & 18 deletions src/telemetry/clearcut-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,75 @@
* 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: {
toolName: string;
success: boolean;
latencyMs: number;
}): Promise<void> {
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<void> {
await this.#sender.send({
server_start: {
flag_usage: flagUsage,
this.#watchdog.send({
type: WatchdogMessageType.LOG_EVENT,
payload: {
server_start: {
flag_usage: flagUsage,
},
},
});
}
Expand All @@ -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);
}
Expand Down
15 changes: 0 additions & 15 deletions src/telemetry/clearcut-sender.ts

This file was deleted.

14 changes: 14 additions & 0 deletions src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,11 @@ export interface ChromeDevToolsMcpExtension {
tool_invocation?: ToolInvocation;
server_start?: ServerStart;
daily_active?: DailyActive;
server_shutdown?: ServerShutdown;
}

export type ServerShutdown = Record<string, never>;

export interface ToolInvocation {
tool_name: string;
success: boolean;
Expand Down Expand Up @@ -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;
}
71 changes: 71 additions & 0 deletions src/telemetry/watchdog-client.ts
Original file line number Diff line number Diff line change
@@ -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');
}
}
}
59 changes: 59 additions & 0 deletions src/telemetry/watchdog/clearcut-sender.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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,
};
}
}
Loading