豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 61f37d6

Browse files
authored
chore: implement ClearcutLogger and ClearcutSender dummy (#758)
This PR implements the core logging infrastructure for the telemetry system. It introduces the `ClearcutLogger` class, integrates it into the server lifecycle events, and establishes the `ClearcutSender` abstraction. **Implementation Roadmap:** This is the second in a series of PRs designed to implement a robust, privacy-conscious telemetry system: 1. **CLI & Opt-out Mechanism ([Merged](#757 * Added `--usage-statistics` flag and transparency logging. 2. **Logger Scaffolding & Integration (This PR):** * **`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 (currently a dummy implementation) that will later handle HTTP requests, batching, and retries. * **Type Definitions**: Added TypeScript definitions for the telemetry Protocol Buffer messages. 3. **Persistence Layer (Next):** * Implement local state management to reliably track "First Time Installation" and "Daily Active" metrics. 4. **Watchdog Process Architecture (Next):** * Move `ClearcutSender` execution to a dedicated watchdog process to ensure reliable event transmission even during abrupt server shutdowns. 5. **Transport, Batching & Retries (Next):** * Finalize `ClearcutSender` with actual HTTP transport logic, including event batching and exponential backoff retries.
1 parent f81cd2d commit 61f37d6

File tree

8 files changed

+391
-1
lines changed

8 files changed

+391
-1
lines changed

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);
@@ -154,6 +160,8 @@ function registerTool(tool: ToolDefinition): void {
154160
},
155161
async (params): Promise<CallToolResult> => {
156162
const guard = await toolMutex.acquire();
163+
const startTime = Date.now();
164+
let success = false;
157165
try {
158166
logger(`${tool.name} request: ${JSON.stringify(params, null, ' ')}`);
159167
const context = await getContext();
@@ -176,6 +184,7 @@ function registerTool(tool: ToolDefinition): void {
176184
} = {
177185
content,
178186
};
187+
success = true;
179188
if (args.experimentalStructuredContent) {
180189
result.structuredContent = structuredContent as Record<
181190
string,
@@ -199,6 +208,11 @@ function registerTool(tool: ToolDefinition): void {
199208
isError: true,
200209
};
201210
} finally {
211+
void clearcutLogger?.logToolInvocation({
212+
toolName: tool.name,
213+
success,
214+
latencyMs: Date.now() - startTime,
215+
});
202216
guard.dispose();
203217
}
204218
},
@@ -214,3 +228,4 @@ const transport = new StdioServerTransport();
214228
await server.connect(transport);
215229
logger('Chrome DevTools MCP Server connected');
216230
logDisclaimers();
231+
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/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)