Skip to content

Commit 0840ff0

Browse files
committed
chore: implement generic CLI flag usage reporting
This change replaces the manual, strictly-typed `FlagUsage` telemetry component with a generic solution that automatically logs all CLI arguments with transformations. Key changes: - `FlagUsage` in `types.ts` is now a generic `Record<string, ...>` to support dynamic flag names. - Added `src/telemetry/flag-utils.ts` to transform CLI arguments into the telemetry payload. - Flag names are automatically converted to snake_case using a new `toSnakeCase` utility. - Boolean flags are logged directly. - Enum flags (strings with `choices`) are logged as uppercase strings to match server-side enums. - Other flags log their 'presence' (e.g., `flag_name_present`). - `_present` is only logged if the flag has no default value, or if the user-provided value differs from the default, indicating explicit user intent. - Updated `main.ts` to use the new `computeFlagUsage` function. - Added tests in `tests/telemetry/flag-utils.test.ts` to ensure 100% coverage of CLI options and detect accidental telemetry payload changes.
1 parent 6181a46 commit 0840ff0

7 files changed

Lines changed: 270 additions & 19 deletions

File tree

src/main.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +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';
1919
import {ClearcutLogger} from './telemetry/clearcut-logger.js';
20+
import {computeFlagUsage} from './telemetry/flag-utils.js';
2021
import {
2122
McpServer,
2223
StdioServerTransport,
@@ -220,11 +221,5 @@ await loadIssueDescriptions();
220221
const transport = new StdioServerTransport();
221222
await server.connect(transport);
222223
logger('Chrome DevTools MCP Server connected');
223-
void clearcutLogger?.logServerStart({
224-
browser_url_present: !!args.browserUrl,
225-
headless: args.headless,
226-
executable_path_present: !!args.executablePath,
227-
isolated: args.isolated,
228-
log_file_present: !!args.logFile,
229-
});
230224
logDisclaimers();
225+
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));

src/telemetry/clearcut-logger.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
*/
66

77
import {ClearcutSender} from './clearcut-sender.js';
8-
import type {
9-
FlagUsage,
10-
} from './types.js';
8+
import type {FlagUsage} from './types.js';
119

1210
export class ClearcutLogger {
1311
#sender: ClearcutSender;

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: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,7 @@ export interface DailyActive {
3232

3333
export type FirstTimeInstallation = Record<string, never>;
3434

35-
export interface FlagUsage {
36-
browser_url_present?: boolean;
37-
headless?: boolean;
38-
executable_path_present?: boolean;
39-
isolated?: boolean;
40-
channel?: ChromeChannel;
41-
log_file_present?: boolean;
42-
}
35+
export type FlagUsage = Record<string, boolean | string | number | undefined>;
4336

4437
// Clearcut API interfaces
4538
export interface LogRequest {

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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
exports[`computeFlagUsage > matches snapshot for all current CLI options 1`] = `
2+
{
3+
"auto_connect_present": true,
4+
"auto_connect": true,
5+
"browser_url_present": true,
6+
"ws_endpoint_present": true,
7+
"ws_headers_present": true,
8+
"headless_present": true,
9+
"headless": true,
10+
"executable_path_present": true,
11+
"isolated_present": true,
12+
"isolated": true,
13+
"user_data_dir_present": true,
14+
"channel_present": true,
15+
"channel": "STABLE",
16+
"log_file_present": true,
17+
"viewport_present": true,
18+
"proxy_server_present": true,
19+
"accept_insecure_certs_present": true,
20+
"accept_insecure_certs": true,
21+
"experimental_devtools_present": true,
22+
"experimental_devtools": true,
23+
"experimental_vision_present": true,
24+
"experimental_vision": true,
25+
"experimental_structured_content_present": true,
26+
"experimental_structured_content": true,
27+
"experimental_include_all_pages_present": true,
28+
"experimental_include_all_pages": true,
29+
"chrome_arg_present": true,
30+
"ignore_default_chrome_arg_present": true,
31+
"category_emulation": true,
32+
"category_performance": true,
33+
"category_network": true,
34+
"usage_statistics_present": true,
35+
"usage_statistics": true
36+
}
37+
`;

tests/telemetry/flag-utils.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert/strict';
8+
import {describe, it} from 'node:test';
9+
10+
import type {cliOptions} from '../../src/cli.js';
11+
import {computeFlagUsage} from '../../src/telemetry/flag-utils.js';
12+
13+
describe('computeFlagUsage', () => {
14+
const mockOptions = {
15+
boolFlag: {
16+
type: 'boolean' as const,
17+
description: 'A boolean flag',
18+
},
19+
stringFlag: {
20+
type: 'string' as const,
21+
description: 'A string flag',
22+
},
23+
enumFlag: {
24+
type: 'string' as const,
25+
description: 'An enum flag',
26+
choices: ['a', 'b'],
27+
},
28+
flagWithDefault: {
29+
type: 'boolean' as const,
30+
description: 'A flag with a default value',
31+
default: false,
32+
},
33+
} as unknown as typeof cliOptions;
34+
35+
it('logs boolean flags directly with snake_case keys', () => {
36+
const args = {boolFlag: true};
37+
const usage = computeFlagUsage(args, mockOptions);
38+
assert.equal(usage.bool_flag, true);
39+
});
40+
41+
it('logs boolean flags as false when false', () => {
42+
const args = {boolFlag: false};
43+
const usage = computeFlagUsage(args, mockOptions);
44+
assert.equal(usage.bool_flag, false);
45+
});
46+
47+
it('logs enum flags as uppercase', () => {
48+
const args = {enumFlag: 'a'};
49+
const usage = computeFlagUsage(args, mockOptions);
50+
assert.equal(usage.enum_flag, 'A');
51+
});
52+
53+
it('logs other flags as present with snake_case keys', () => {
54+
const args = {stringFlag: 'value'};
55+
const usage = computeFlagUsage(args, mockOptions);
56+
assert.equal(usage.string_flag, undefined);
57+
assert.equal(usage.string_flag_present, true);
58+
});
59+
60+
it('handles undefined/null values', () => {
61+
const args = {stringFlag: undefined};
62+
const usage = computeFlagUsage(args, mockOptions);
63+
assert.equal(usage.string_flag_present, false);
64+
});
65+
66+
describe('defaults behavior', () => {
67+
it('logs presence when default exists and user provides different value', () => {
68+
// Case 1: Default exists, and a value is provided by the user.
69+
// default is false, user provides true.
70+
const args = {flagWithDefault: true};
71+
const usage = computeFlagUsage(args, mockOptions);
72+
assert.equal(usage.flag_with_default, true);
73+
assert.equal(usage.flag_with_default_present, true);
74+
});
75+
76+
it('does not log presence when default exists and user provides no value', () => {
77+
// Case 2a: Default exists, and a value is not provided by the user.
78+
// Argument parsing would populate with default.
79+
const args = {flagWithDefault: false};
80+
const usage = computeFlagUsage(args, mockOptions);
81+
assert.equal(usage.flag_with_default, false);
82+
assert.equal(usage.flag_with_default_present, undefined);
83+
});
84+
85+
it('does not log presence when default exists and user explicitly provides the default value', () => {
86+
// Case 2b: User explicitly provides 'false', which matches default.
87+
const args = {flagWithDefault: false};
88+
const usage = computeFlagUsage(args, mockOptions);
89+
assert.equal(usage.flag_with_default, false);
90+
assert.equal(usage.flag_with_default_present, undefined);
91+
});
92+
93+
it('logs presence when no default exists and user provides value', () => {
94+
// Case 3: No default, user provides value.
95+
const args = {stringFlag: 'value'};
96+
const usage = computeFlagUsage(args, mockOptions);
97+
assert.equal(usage.string_flag_present, true);
98+
});
99+
100+
it('logs non-presence when no default exists and user provides no value', () => {
101+
// Case 4: No default, user provides nothing.
102+
const args = {};
103+
const usage = computeFlagUsage(args, mockOptions);
104+
assert.equal(usage.string_flag_present, false);
105+
});
106+
});
107+
108+
it('matches snapshot for all current CLI options', async t => {
109+
// Import the real options to test against the actual CLI definition
110+
const {cliOptions} = await import('../../src/cli.js');
111+
112+
const mockArgs: Record<string, unknown> = {};
113+
for (const [key, config] of Object.entries(cliOptions)) {
114+
if ('choices' in config && config.choices) {
115+
mockArgs[key] = config.choices[0];
116+
} else if (config.type === 'boolean') {
117+
mockArgs[key] = true;
118+
} else if (config.type === 'string') {
119+
mockArgs[key] = '/mock/path';
120+
} else if (config.type === 'array') {
121+
mockArgs[key] = ['--mock-arg'];
122+
} else {
123+
mockArgs[key] = 'mock-value';
124+
}
125+
}
126+
127+
const usage = computeFlagUsage(mockArgs, cliOptions);
128+
t.assert.snapshot(JSON.stringify(usage, null, 2));
129+
});
130+
});

0 commit comments

Comments
 (0)