Skip to content

Commit 53736f5

Browse files
authored
Merge branch 'main' into crux-for-trace
2 parents 009064a + 4d9ac22 commit 53736f5

11 files changed

Lines changed: 536 additions & 2 deletions

File tree

src/main.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import process from 'node:process';
1010

1111
import type {Channel} from './browser.js';
1212
import {ensureBrowserConnected, ensureBrowserLaunched} from './browser.js';
13-
import {parseArguments} from './cli.js';
13+
import {cliOptions, parseArguments} from './cli.js';
1414
import {loadIssueDescriptions} from './issue-descriptions.js';
1515
import {logger, saveLogsToFile} from './logger.js';
1616
import {McpContext} from './McpContext.js';
1717
import {McpResponse} from './McpResponse.js';
1818
import {Mutex} from './Mutex.js';
19+
import {ClearcutLogger} from './telemetry/clearcut-logger.js';
20+
import {computeFlagUsage} from './telemetry/flag-utils.js';
1921
import {
2022
McpServer,
2123
StdioServerTransport,
@@ -34,6 +36,10 @@ const VERSION = '0.12.1';
3436
export const args = parseArguments(VERSION);
3537

3638
const logFile = args.logFile ? saveLogsToFile(args.logFile) : undefined;
39+
let clearcutLogger: ClearcutLogger | undefined;
40+
if (args.usageStatistics) {
41+
clearcutLogger = new ClearcutLogger();
42+
}
3743

3844
process.on('unhandledRejection', (reason, promise) => {
3945
logger('Unhandled promise rejection', promise, reason);
@@ -155,6 +161,8 @@ function registerTool(tool: ToolDefinition): void {
155161
},
156162
async (params): Promise<CallToolResult> => {
157163
const guard = await toolMutex.acquire();
164+
const startTime = Date.now();
165+
let success = false;
158166
try {
159167
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
160168
const context = await getContext();
@@ -177,6 +185,7 @@ function registerTool(tool: ToolDefinition): void {
177185
} = {
178186
content,
179187
};
188+
success = true;
180189
if (args.experimentalStructuredContent) {
181190
result.structuredContent = structuredContent as Record<
182191
string,
@@ -200,6 +209,11 @@ function registerTool(tool: ToolDefinition): void {
200209
isError: true,
201210
};
202211
} finally {
212+
void clearcutLogger?.logToolInvocation({
213+
toolName: tool.name,
214+
success,
215+
latencyMs: Date.now() - startTime,
216+
});
203217
guard.dispose();
204218
}
205219
},
@@ -215,3 +229,4 @@ const transport = new StdioServerTransport();
215229
await server.connect(transport);
216230
logger('Chrome DevTools MCP Server connected');
217231
logDisclaimers();
232+
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));

src/telemetry/clearcut-logger.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {ClearcutSender} from './clearcut-sender.js';
8+
import type {FlagUsage} from './types.js';
9+
10+
export class ClearcutLogger {
11+
#sender: ClearcutSender;
12+
13+
constructor(sender?: ClearcutSender) {
14+
this.#sender = sender ?? new ClearcutSender();
15+
}
16+
17+
async logToolInvocation(args: {
18+
toolName: string;
19+
success: boolean;
20+
latencyMs: number;
21+
}): Promise<void> {
22+
await this.#sender.send({
23+
tool_invocation: {
24+
tool_name: args.toolName,
25+
success: args.success,
26+
latency_ms: args.latencyMs,
27+
},
28+
});
29+
}
30+
31+
async logServerStart(flagUsage: FlagUsage): Promise<void> {
32+
await this.#sender.send({
33+
server_start: {
34+
flag_usage: flagUsage,
35+
},
36+
});
37+
}
38+
}

src/telemetry/clearcut-sender.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {logger} from '../logger.js';
8+
9+
import type {ChromeDevToolsMcpExtension} from './types.js';
10+
11+
export class ClearcutSender {
12+
async send(event: ChromeDevToolsMcpExtension): Promise<void> {
13+
logger('Telemetry event', JSON.stringify(event, null, 2));
14+
}
15+
}

src/telemetry/flag-utils.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {cliOptions} from '../cli.js';
8+
import {toSnakeCase} from '../utils/string.js';
9+
10+
import type {FlagUsage} from './types.js';
11+
12+
type CliOptions = typeof cliOptions;
13+
14+
/**
15+
* Computes telemetry flag usage from parsed arguments and CLI options.
16+
*
17+
* Iterates over the defined CLI options to construct a payload:
18+
* - Flag names are converted to snake_case (e.g. `browserUrl` -> `browser_url`).
19+
* - A flag is logged as `{flag_name}_present` if:
20+
* - It has no default value, OR
21+
* - The provided value differs from the default value.
22+
* - Boolean flags are logged with their literal value.
23+
* - String flags with defined `choices` (Enums) are logged as their uppercase value.
24+
*/
25+
export function computeFlagUsage(
26+
args: Record<string, unknown>,
27+
options: CliOptions,
28+
): FlagUsage {
29+
const usage: FlagUsage = {};
30+
31+
for (const [flagName, config] of Object.entries(options)) {
32+
const value = args[flagName];
33+
const snakeCaseName = toSnakeCase(flagName);
34+
35+
// If there isn't a default value provided for the flag,
36+
// we're going to log whether it's present on the args user
37+
// provided or not. If there is a default value, we only log presence
38+
// if the value differs from the default, implying explicit user intent.
39+
if (!('default' in config) || value !== config.default) {
40+
usage[`${snakeCaseName}_present`] = value !== undefined && value !== null;
41+
}
42+
43+
if (config.type === 'boolean' && typeof value === 'boolean') {
44+
// For boolean options, we're going to log the value directly.
45+
usage[snakeCaseName] = value;
46+
} else if (
47+
config.type === 'string' &&
48+
typeof value === 'string' &&
49+
'choices' in config &&
50+
config.choices
51+
) {
52+
// For enums, log the value as uppercase
53+
// We're going to have an enum for such flags with choices represented
54+
// as an `enum` where the keys of the enum will map to the uppercase `choice`.
55+
usage[snakeCaseName] = value.toUpperCase();
56+
}
57+
}
58+
59+
return usage;
60+
}

src/telemetry/types.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// Protobuf message interfaces
8+
export interface ChromeDevToolsMcpExtension {
9+
os_type?: OsType;
10+
mcp_client?: McpClient;
11+
app_version?: string;
12+
session_id?: string;
13+
tool_invocation?: ToolInvocation;
14+
server_start?: ServerStart;
15+
daily_active?: DailyActive;
16+
first_time_installation?: FirstTimeInstallation;
17+
}
18+
19+
export interface ToolInvocation {
20+
tool_name: string;
21+
success: boolean;
22+
latency_ms: number;
23+
}
24+
25+
export interface ServerStart {
26+
flag_usage?: FlagUsage;
27+
}
28+
29+
export interface DailyActive {
30+
days_since_last_active: number;
31+
}
32+
33+
export type FirstTimeInstallation = Record<string, never>;
34+
35+
export type FlagUsage = Record<string, boolean | string | number | undefined>;
36+
37+
// Clearcut API interfaces
38+
export interface LogRequest {
39+
log_source: number;
40+
request_time_ms: string;
41+
client_info: {
42+
client_type: number;
43+
};
44+
log_event: Array<{
45+
event_time_ms: string;
46+
source_extension_json: string;
47+
}>;
48+
}
49+
50+
// Enums
51+
export enum OsType {
52+
OS_TYPE_UNSPECIFIED = 0,
53+
OS_TYPE_WINDOWS = 1,
54+
OS_TYPE_MACOS = 2,
55+
OS_TYPE_LINUX = 3,
56+
}
57+
58+
export enum ChromeChannel {
59+
CHROME_CHANNEL_UNSPECIFIED = 0,
60+
CHROME_CHANNEL_CANARY = 1,
61+
CHROME_CHANNEL_DEV = 2,
62+
CHROME_CHANNEL_BETA = 3,
63+
CHROME_CHANNEL_STABLE = 4,
64+
}
65+
66+
export enum McpClient {
67+
MCP_CLIENT_UNSPECIFIED = 0,
68+
MCP_CLIENT_CLAUDE_CODE = 1,
69+
MCP_CLIENT_GEMINI_CLI = 2,
70+
}

src/tools/pages.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,22 @@ export const resizePage = defineTool({
211211
handler: async (request, response, context) => {
212212
const page = context.getSelectedPage();
213213

214+
try {
215+
const browser = page.browser();
216+
const windowId = await page.windowId();
217+
218+
const bounds = await browser.getWindowBounds(windowId);
219+
220+
if (bounds.windowState === 'fullscreen') {
221+
// Have to call this twice on Ubuntu when the window is in fullscreen mode.
222+
await browser.setWindowBounds(windowId, {windowState: 'normal'});
223+
await browser.setWindowBounds(windowId, {windowState: 'normal'});
224+
} else if (bounds.windowState !== 'normal') {
225+
await browser.setWindowBounds(windowId, {windowState: 'normal'});
226+
}
227+
} catch {
228+
// Window APIs are not supported on all platforms
229+
}
214230
await page.resize({
215231
contentWidth: request.params.width,
216232
contentHeight: request.params.height,

src/utils/string.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
/**
8+
* Converts a given string to snake_case.
9+
* This function handles camelCase, PascalCase, and acronyms, including transitions between letters and numbers.
10+
* It uses Unicode-aware regular expressions (`\p{L}`, `\p{N}`, `\p{Lu}`, `\p{Ll}` with the `u` flag)
11+
* to correctly process letters and numbers from various languages.
12+
*
13+
* @param text The input string to convert to snake_case.
14+
* @returns The snake_case version of the input string.
15+
*/
16+
export function toSnakeCase(text: string): string {
17+
if (!text) {
18+
return '';
19+
}
20+
// First, handle case-based transformations to insert underscores correctly.
21+
// 1. Add underscore between a letter and a number.
22+
// e.g., "version2" -> "version_2"
23+
// 2. Add underscore between an uppercase letter sequence and a following uppercase+lowercase sequence.
24+
// e.g., "APIFlags" -> "API_Flags"
25+
// 3. Add underscore between a lowercase/number and an uppercase letter.
26+
// e.g., "lastName" -> "last_Name", "version_2Update" -> "version_2_Update"
27+
// 4. Replace sequences of non-alphanumeric with a single underscore
28+
// 5. Remove any leading or trailing underscores.
29+
const result = text
30+
.replace(/(\p{L})(\p{N})/gu, '$1_$2') // 1
31+
.replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, '$1_$2') // 2
32+
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, '$1_$2') // 3
33+
.toLowerCase()
34+
.replace(/[^\p{L}\p{N}]+/gu, '_') // 4
35+
.replace(/^_|_$/g, ''); // 5
36+
37+
return result;
38+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {describe, it, mock} from 'node:test';
9+
10+
import {ClearcutLogger} from '../../src/telemetry/clearcut-logger.js';
11+
import {ClearcutSender} from '../../src/telemetry/clearcut-sender.js';
12+
13+
describe('ClearcutLogger', () => {
14+
it('should log tool invocation via sender', async () => {
15+
const sender = new ClearcutSender();
16+
const sendSpy = mock.method(sender, 'send');
17+
const loggerInstance = new ClearcutLogger(sender);
18+
19+
await loggerInstance.logToolInvocation({
20+
toolName: 'test-tool',
21+
success: true,
22+
latencyMs: 100,
23+
});
24+
25+
assert.strictEqual(sendSpy.mock.callCount(), 1);
26+
const event = sendSpy.mock.calls[0].arguments[0];
27+
assert.deepStrictEqual(event.tool_invocation, {
28+
tool_name: 'test-tool',
29+
success: true,
30+
latency_ms: 100,
31+
});
32+
});
33+
34+
it('should log server start via sender', async () => {
35+
const sender = new ClearcutSender();
36+
const sendSpy = mock.method(sender, 'send');
37+
const loggerInstance = new ClearcutLogger(sender);
38+
39+
await loggerInstance.logServerStart({headless: true});
40+
41+
assert.strictEqual(sendSpy.mock.callCount(), 1);
42+
const event = sendSpy.mock.calls[0].arguments[0];
43+
assert.deepStrictEqual(event.server_start, {
44+
flag_usage: {headless: true},
45+
});
46+
});
47+
});

0 commit comments

Comments
 (0)