diff --git a/src/main.ts b/src/main.ts index a16ba4dc8..ad6507928 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,12 +10,14 @@ import process from 'node:process'; import type {Channel} from './browser.js'; import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js'; -import {parseArguments} from './cli.js'; +import {cliOptions, parseArguments} from './cli.js'; import {loadIssueDescriptions} from './issue-descriptions.js'; import {logger, saveLogsToFile} from './logger.js'; import {McpContext} from './McpContext.js'; import {McpResponse} from './McpResponse.js'; import {Mutex} from './Mutex.js'; +import {ClearcutLogger} from './telemetry/clearcut-logger.js'; +import {computeFlagUsage} from './telemetry/flag-utils.js'; import { McpServer, StdioServerTransport, @@ -34,6 +36,10 @@ const VERSION = '0.12.1'; export const args = parseArguments(VERSION); const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined; +let clearcutLogger: ClearcutLogger | undefined; +if (args.usageStatistics) { + clearcutLogger = new ClearcutLogger(); +} process.on('unhandledRejection', (reason, promise) => { logger('Unhandled promise rejection', promise, reason); @@ -154,6 +160,8 @@ function registerTool(tool: ToolDefinition): void { }, async (params): Promise => { const guard = await toolMutex.acquire(); + const startTime = Date.now(); + let success = false; try { logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`); const context = await getContext(); @@ -176,6 +184,7 @@ function registerTool(tool: ToolDefinition): void { } = { content, }; + success = true; if (args.experimentalStructuredContent) { result.structuredContent = structuredContent as Record< string, @@ -199,6 +208,11 @@ function registerTool(tool: ToolDefinition): void { isError: true, }; } finally { + void clearcutLogger?.logToolInvocation({ + toolName: tool.name, + success, + latencyMs: Date.now() - startTime, + }); guard.dispose(); } }, @@ -214,3 +228,4 @@ const transport = new StdioServerTransport(); await server.connect(transport); logger('Chrome DevTools MCP Server connected'); logDisclaimers(); +void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions)); diff --git a/src/telemetry/clearcut-logger.ts b/src/telemetry/clearcut-logger.ts new file mode 100644 index 000000000..17b53420c --- /dev/null +++ b/src/telemetry/clearcut-logger.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ClearcutSender} from './clearcut-sender.js'; +import type {FlagUsage} from './types.js'; + +export class ClearcutLogger { + #sender: ClearcutSender; + + constructor(sender?: ClearcutSender) { + this.#sender = sender ?? new ClearcutSender(); + } + + async logToolInvocation(args: { + toolName: string; + success: boolean; + latencyMs: number; + }): Promise { + await this.#sender.send({ + 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, + }, + }); + } +} diff --git a/src/telemetry/clearcut-sender.ts b/src/telemetry/clearcut-sender.ts new file mode 100644 index 000000000..7c2fdf336 --- /dev/null +++ b/src/telemetry/clearcut-sender.ts @@ -0,0 +1,15 @@ +/** + * @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/flag-utils.ts b/src/telemetry/flag-utils.ts new file mode 100644 index 000000000..2ff3f761a --- /dev/null +++ b/src/telemetry/flag-utils.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {cliOptions} from '../cli.js'; +import {toSnakeCase} from '../utils/string.js'; + +import type {FlagUsage} from './types.js'; + +type CliOptions = typeof cliOptions; + +/** + * Computes telemetry flag usage from parsed arguments and CLI options. + * + * Iterates over the defined CLI options to construct a payload: + * - Flag names are converted to snake_case (e.g. `browserUrl` -> `browser_url`). + * - A flag is logged as `{flag_name}_present` if: + * - It has no default value, OR + * - The provided value differs from the default value. + * - Boolean flags are logged with their literal value. + * - String flags with defined `choices` (Enums) are logged as their uppercase value. + */ +export function computeFlagUsage( + args: Record, + options: CliOptions, +): FlagUsage { + const usage: FlagUsage = {}; + + for (const [flagName, config] of Object.entries(options)) { + const value = args[flagName]; + const snakeCaseName = toSnakeCase(flagName); + + // If there isn't a default value provided for the flag, + // we're going to log whether it's present on the args user + // provided or not. If there is a default value, we only log presence + // if the value differs from the default, implying explicit user intent. + if (!('default' in config) || value !== config.default) { + usage[`${snakeCaseName}_present`] = value !== undefined && value !== null; + } + + if (config.type === 'boolean' && typeof value === 'boolean') { + // For boolean options, we're going to log the value directly. + usage[snakeCaseName] = value; + } else if ( + config.type === 'string' && + typeof value === 'string' && + 'choices' in config && + config.choices + ) { + // For enums, log the value as uppercase + // We're going to have an enum for such flags with choices represented + // as an `enum` where the keys of the enum will map to the uppercase `choice`. + usage[snakeCaseName] = value.toUpperCase(); + } + } + + return usage; +} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 000000000..c6e1cb190 --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Protobuf message interfaces +export interface ChromeDevToolsMcpExtension { + os_type?: OsType; + mcp_client?: McpClient; + app_version?: string; + session_id?: string; + tool_invocation?: ToolInvocation; + server_start?: ServerStart; + daily_active?: DailyActive; + first_time_installation?: FirstTimeInstallation; +} + +export interface ToolInvocation { + tool_name: string; + success: boolean; + latency_ms: number; +} + +export interface ServerStart { + flag_usage?: FlagUsage; +} + +export interface DailyActive { + days_since_last_active: number; +} + +export type FirstTimeInstallation = Record; + +export type FlagUsage = Record; + +// Clearcut API interfaces +export interface LogRequest { + log_source: number; + request_time_ms: string; + client_info: { + client_type: number; + }; + log_event: Array<{ + event_time_ms: string; + source_extension_json: string; + }>; +} + +// Enums +export enum OsType { + OS_TYPE_UNSPECIFIED = 0, + OS_TYPE_WINDOWS = 1, + OS_TYPE_MACOS = 2, + OS_TYPE_LINUX = 3, +} + +export enum ChromeChannel { + CHROME_CHANNEL_UNSPECIFIED = 0, + CHROME_CHANNEL_CANARY = 1, + CHROME_CHANNEL_DEV = 2, + CHROME_CHANNEL_BETA = 3, + CHROME_CHANNEL_STABLE = 4, +} + +export enum McpClient { + MCP_CLIENT_UNSPECIFIED = 0, + MCP_CLIENT_CLAUDE_CODE = 1, + MCP_CLIENT_GEMINI_CLI = 2, +} diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 000000000..dee2c36d5 --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Converts a given string to snake_case. + * This function handles camelCase, PascalCase, and acronyms, including transitions between letters and numbers. + * It uses Unicode-aware regular expressions (`\p{L}`, `\p{N}`, `\p{Lu}`, `\p{Ll}` with the `u` flag) + * to correctly process letters and numbers from various languages. + * + * @param text The input string to convert to snake_case. + * @returns The snake_case version of the input string. + */ +export function toSnakeCase(text: string): string { + if (!text) { + return ''; + } + // First, handle case-based transformations to insert underscores correctly. + // 1. Add underscore between a letter and a number. + // e.g., "version2" -> "version_2" + // 2. Add underscore between an uppercase letter sequence and a following uppercase+lowercase sequence. + // e.g., "APIFlags" -> "API_Flags" + // 3. Add underscore between a lowercase/number and an uppercase letter. + // e.g., "lastName" -> "last_Name", "version_2Update" -> "version_2_Update" + // 4. Replace sequences of non-alphanumeric with a single underscore + // 5. Remove any leading or trailing underscores. + const result = text + .replace(/(\p{L})(\p{N})/gu, '$1_$2') // 1 + .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, '$1_$2') // 2 + .replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, '$1_$2') // 3 + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, '_') // 4 + .replace(/^_|_$/g, ''); // 5 + + return result; +} diff --git a/tests/telemetry/clearcut-logger.test.ts b/tests/telemetry/clearcut-logger.test.ts new file mode 100644 index 000000000..b41c6da48 --- /dev/null +++ b/tests/telemetry/clearcut-logger.test.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import {describe, it, mock} from 'node:test'; + +import {ClearcutLogger} from '../../src/telemetry/clearcut-logger.js'; +import {ClearcutSender} from '../../src/telemetry/clearcut-sender.js'; + +describe('ClearcutLogger', () => { + it('should log tool invocation via sender', async () => { + const sender = new ClearcutSender(); + const sendSpy = mock.method(sender, 'send'); + const loggerInstance = new ClearcutLogger(sender); + + await loggerInstance.logToolInvocation({ + toolName: 'test-tool', + success: true, + latencyMs: 100, + }); + + assert.strictEqual(sendSpy.mock.callCount(), 1); + const event = sendSpy.mock.calls[0].arguments[0]; + assert.deepStrictEqual(event.tool_invocation, { + tool_name: 'test-tool', + success: true, + latency_ms: 100, + }); + }); + + it('should log server start via sender', async () => { + const sender = new ClearcutSender(); + const sendSpy = mock.method(sender, 'send'); + const loggerInstance = new ClearcutLogger(sender); + + await loggerInstance.logServerStart({headless: true}); + + assert.strictEqual(sendSpy.mock.callCount(), 1); + const event = sendSpy.mock.calls[0].arguments[0]; + assert.deepStrictEqual(event.server_start, { + flag_usage: {headless: true}, + }); + }); +}); diff --git a/tests/telemetry/flag-utils.test.ts b/tests/telemetry/flag-utils.test.ts new file mode 100644 index 000000000..cf1cbfb4e --- /dev/null +++ b/tests/telemetry/flag-utils.test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert/strict'; +import {describe, it} from 'node:test'; + +import type {cliOptions} from '../../src/cli.js'; +import {computeFlagUsage} from '../../src/telemetry/flag-utils.js'; + +describe('computeFlagUsage', () => { + const mockOptions = { + boolFlag: { + type: 'boolean' as const, + description: 'A boolean flag', + }, + stringFlag: { + type: 'string' as const, + description: 'A string flag', + }, + enumFlag: { + type: 'string' as const, + description: 'An enum flag', + choices: ['a', 'b'], + }, + flagWithDefault: { + type: 'boolean' as const, + description: 'A flag with a default value', + default: false, + }, + } as unknown as typeof cliOptions; + + it('logs boolean flags directly with snake_case keys', () => { + const args = {boolFlag: true}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.bool_flag, true); + }); + + it('logs boolean flags as false when false', () => { + const args = {boolFlag: false}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.bool_flag, false); + }); + + it('logs enum flags as uppercase', () => { + const args = {enumFlag: 'a'}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.enum_flag, 'A'); + }); + + it('logs other flags as present with snake_case keys', () => { + const args = {stringFlag: 'value'}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.string_flag, undefined); + assert.equal(usage.string_flag_present, true); + }); + + it('handles undefined/null values', () => { + const args = {stringFlag: undefined}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.string_flag_present, false); + }); + + describe('defaults behavior', () => { + it('logs presence when default exists and user provides different value', () => { + // Case 1: Default exists, and a value is provided by the user. + // default is false, user provides true. + const args = {flagWithDefault: true}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.flag_with_default, true); + assert.equal(usage.flag_with_default_present, true); + }); + + it('does not log presence when default exists and user provides no value', () => { + // Case 2a: Default exists, and a value is not provided by the user. + // Argument parsing would populate with default. + const args = {flagWithDefault: false}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.flag_with_default, false); + assert.equal(usage.flag_with_default_present, undefined); + }); + + it('does not log presence when default exists and user explicitly provides the default value', () => { + // Case 2b: User explicitly provides 'false', which matches default. + const args = {flagWithDefault: false}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.flag_with_default, false); + assert.equal(usage.flag_with_default_present, undefined); + }); + + it('logs presence when no default exists and user provides value', () => { + // Case 3: No default, user provides value. + const args = {stringFlag: 'value'}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.string_flag_present, true); + }); + + it('logs non-presence when no default exists and user provides no value', () => { + // Case 4: No default, user provides nothing. + const args = {}; + const usage = computeFlagUsage(args, mockOptions); + assert.equal(usage.string_flag_present, false); + }); + }); +});