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

Commit a3de5e4

Browse files
authored
chore: implement telemetry persistence and daily active logging (#769)
This PR implements the persistence layer for the telemetry system. It introduces `FilePersistence` for local state management and integrates it with `ClearcutLogger` to support "Daily Active" metric. I have decided not to send `first_time_installation` events since we can deduce them from `daily active` events where the `days_since_last_active` will be `-1` for that case. **Implementation Roadmap:** This is the third in a series of PRs designed to implement the telemetry system: 1. **CLI & Opt-out Mechanism ([Merged](#757 * Added `--usage-statistics` flag and transparency logging. 2. **Logger Scaffolding & Integration ([Merged](#758 * **`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. * **Type Definitions**: Added TypeScript definitions for the telemetry Protocol Buffer messages. 3. **Persistence Layer (This PR):** * **`FilePersistence`**: Implemented a local file-based state manager to persist the `lastActive` timestamp. * **Daily Active Logic**: Integrated persistence into `ClearcutLogger` to automatically detect and log `daily_active` events (with `days_since_last_active` calculation) via `logDailyActiveIfNeeded`. 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 4090da2 commit a3de5e4

File tree

6 files changed

+326
-30
lines changed

6 files changed

+326
-30
lines changed

src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,5 @@ const transport = new StdioServerTransport();
228228
await server.connect(transport);
229229
logger('Chrome DevTools MCP Server connected');
230230
logDisclaimers();
231+
void clearcutLogger?.logDailyActiveIfNeeded();
231232
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));

src/telemetry/clearcut-logger.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44
* SPDX-License-Identifier: Apache-2.0
55
*/
66

7+
import {logger} from '../logger.js';
8+
79
import {ClearcutSender} from './clearcut-sender.js';
10+
import type {LocalState, Persistence} from './persistence.js';
11+
import {FilePersistence} from './persistence.js';
812
import type {FlagUsage} from './types.js';
913

14+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
15+
1016
export class ClearcutLogger {
17+
#persistence: Persistence;
1118
#sender: ClearcutSender;
1219

13-
constructor(sender?: ClearcutSender) {
14-
this.#sender = sender ?? new ClearcutSender();
20+
constructor(options?: {persistence?: Persistence; sender?: ClearcutSender}) {
21+
this.#persistence = options?.persistence ?? new FilePersistence();
22+
this.#sender = options?.sender ?? new ClearcutSender();
1523
}
1624

1725
async logToolInvocation(args: {
@@ -35,4 +43,48 @@ export class ClearcutLogger {
3543
},
3644
});
3745
}
46+
47+
async logDailyActiveIfNeeded(): Promise<void> {
48+
try {
49+
const state = await this.#persistence.loadState();
50+
51+
if (this.#shouldLogDailyActive(state)) {
52+
let daysSince = -1;
53+
if (state.lastActive) {
54+
const lastActiveDate = new Date(state.lastActive);
55+
const now = new Date();
56+
const diffTime = Math.abs(now.getTime() - lastActiveDate.getTime());
57+
daysSince = Math.ceil(diffTime / MS_PER_DAY);
58+
}
59+
60+
await this.#sender.send({
61+
daily_active: {
62+
days_since_last_active: daysSince,
63+
},
64+
});
65+
66+
// Update persistence
67+
state.lastActive = new Date().toISOString();
68+
await this.#persistence.saveState(state);
69+
}
70+
} catch (err) {
71+
logger('Error in logDailyActiveIfNeeded:', err);
72+
}
73+
}
74+
75+
#shouldLogDailyActive(state: LocalState): boolean {
76+
if (!state.lastActive) {
77+
return true;
78+
}
79+
const lastActiveDate = new Date(state.lastActive);
80+
const now = new Date();
81+
82+
// Compare UTC dates
83+
const isSameDay =
84+
lastActiveDate.getUTCFullYear() === now.getUTCFullYear() &&
85+
lastActiveDate.getUTCMonth() === now.getUTCMonth() &&
86+
lastActiveDate.getUTCDate() === now.getUTCDate();
87+
88+
return !isSameDay;
89+
}
3890
}

src/telemetry/persistence.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import fs from 'node:fs/promises';
8+
import os from 'node:os';
9+
import path from 'node:path';
10+
import process from 'node:process';
11+
12+
import {logger} from '../logger.js';
13+
14+
export interface LocalState {
15+
lastActive: string; // ISO 8601 UTC date string
16+
}
17+
18+
const STATE_FILE_NAME = 'telemetry_state.json';
19+
function getDataFolder(): string {
20+
const homedir = os.homedir();
21+
const {env} = process;
22+
const name = 'chrome-devtools-mcp';
23+
24+
if (process.platform === 'darwin') {
25+
return path.join(homedir, 'Library', 'Application Support', name);
26+
}
27+
28+
if (process.platform === 'win32') {
29+
const localAppData =
30+
env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
31+
return path.join(localAppData, name, 'Data');
32+
}
33+
34+
return path.join(
35+
env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'),
36+
name,
37+
);
38+
}
39+
40+
export interface Persistence {
41+
loadState(): Promise<LocalState>;
42+
saveState(state: LocalState): Promise<void>;
43+
}
44+
45+
export class FilePersistence implements Persistence {
46+
#dataFolder: string;
47+
48+
constructor(dataFolderOverride?: string) {
49+
this.#dataFolder = dataFolderOverride ?? getDataFolder();
50+
}
51+
52+
async loadState(): Promise<LocalState> {
53+
try {
54+
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
55+
const content = await fs.readFile(filePath, 'utf-8');
56+
return JSON.parse(content) as LocalState;
57+
} catch {
58+
return {
59+
lastActive: '',
60+
};
61+
}
62+
}
63+
64+
async saveState(state: LocalState): Promise<void> {
65+
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
66+
try {
67+
await fs.mkdir(this.#dataFolder, {recursive: true});
68+
await fs.writeFile(filePath, JSON.stringify(state, null, 2), 'utf-8');
69+
} catch (error) {
70+
// Ignore errors during state saving to avoid crashing the server
71+
logger(`Failed to save telemetry state to ${filePath}:`, error);
72+
}
73+
}
74+
}

src/telemetry/types.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export interface ChromeDevToolsMcpExtension {
1313
tool_invocation?: ToolInvocation;
1414
server_start?: ServerStart;
1515
daily_active?: DailyActive;
16-
first_time_installation?: FirstTimeInstallation;
1716
}
1817

1918
export interface ToolInvocation {
@@ -30,8 +29,6 @@ export interface DailyActive {
3029
days_since_last_active: number;
3130
}
3231

33-
export type FirstTimeInstallation = Record<string, never>;
34-
3532
export type FlagUsage = Record<string, boolean | string | number | undefined>;
3633

3734
// Clearcut API interfaces

tests/telemetry/clearcut-logger.test.ts

Lines changed: 127 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,145 @@
55
*/
66

77
import assert from 'node:assert';
8-
import {describe, it, mock} from 'node:test';
8+
import {describe, it, afterEach, beforeEach} from 'node:test';
9+
10+
import sinon from 'sinon';
911

1012
import {ClearcutLogger} from '../../src/telemetry/clearcut-logger.js';
1113
import {ClearcutSender} from '../../src/telemetry/clearcut-sender.js';
14+
import type {Persistence} from '../../src/telemetry/persistence.js';
15+
import {FilePersistence} from '../../src/telemetry/persistence.js';
1216

1317
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,
18+
let mockPersistence: sinon.SinonStubbedInstance<Persistence>;
19+
let mockSender: sinon.SinonStubbedInstance<ClearcutSender>;
20+
21+
beforeEach(() => {
22+
mockPersistence = sinon.createStubInstance(FilePersistence, {
23+
loadState: Promise.resolve({
24+
lastActive: '',
25+
}),
26+
});
27+
mockSender = sinon.createStubInstance(ClearcutSender);
28+
mockSender.send.resolves();
29+
});
30+
31+
afterEach(() => {
32+
sinon.restore();
33+
});
34+
35+
describe('logToolInvocation', () => {
36+
it('sends correct payload', async () => {
37+
const logger = new ClearcutLogger({
38+
persistence: mockPersistence,
39+
sender: mockSender,
40+
});
41+
await logger.logToolInvocation({
42+
toolName: 'test_tool',
43+
success: true,
44+
latencyMs: 123,
45+
});
46+
47+
assert(mockSender.send.calledOnce);
48+
const extension = mockSender.send.firstCall.args[0];
49+
assert.strictEqual(extension.tool_invocation?.tool_name, 'test_tool');
50+
assert.strictEqual(extension.tool_invocation?.success, true);
51+
assert.strictEqual(extension.tool_invocation?.latency_ms, 123);
2352
});
53+
});
54+
55+
describe('logServerStart', () => {
56+
it('logs flag usage', async () => {
57+
const logger = new ClearcutLogger({
58+
persistence: mockPersistence,
59+
sender: mockSender,
60+
});
61+
62+
await logger.logServerStart({headless: true});
2463

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,
64+
// Should have logged server start
65+
const calls = mockSender.send.getCalls();
66+
const serverStartCall = calls.find(call => {
67+
return !!call.args[0].server_start;
68+
});
69+
70+
assert(serverStartCall);
71+
assert.strictEqual(
72+
serverStartCall.args[0].server_start?.flag_usage?.headless,
73+
true,
74+
);
3175
});
3276
});
3377

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);
78+
describe('logDailyActiveIfNeeded', () => {
79+
it('logs daily active if needed (lastActive > 24h ago)', async () => {
80+
const yesterday = new Date();
81+
yesterday.setDate(yesterday.getDate() - 1);
82+
83+
mockPersistence.loadState.resolves({
84+
lastActive: yesterday.toISOString(),
85+
});
86+
87+
const logger = new ClearcutLogger({
88+
persistence: mockPersistence,
89+
sender: mockSender,
90+
});
91+
92+
await logger.logDailyActiveIfNeeded();
93+
94+
const calls = mockSender.send.getCalls();
95+
const dailyActiveCall = calls.find(call => {
96+
return !!call.args[0].daily_active;
97+
});
98+
99+
assert(dailyActiveCall, 'Should have logged daily active');
100+
assert(mockPersistence.saveState.called);
101+
});
102+
103+
it('does not log daily active if not needed (today)', async () => {
104+
mockPersistence.loadState.resolves({
105+
lastActive: new Date().toISOString(),
106+
});
107+
108+
const logger = new ClearcutLogger({
109+
persistence: mockPersistence,
110+
sender: mockSender,
111+
});
112+
113+
await logger.logDailyActiveIfNeeded();
114+
115+
const calls = mockSender.send.getCalls();
116+
const dailyActiveCall = calls.find(call => {
117+
return !!call.args[0].daily_active;
118+
});
119+
120+
assert(!dailyActiveCall, 'Should NOT have logged daily active');
121+
assert(mockPersistence.saveState.notCalled);
122+
});
123+
124+
it('logs daily active with -1 if lastActive is missing', async () => {
125+
mockPersistence.loadState.resolves({
126+
lastActive: '',
127+
});
128+
129+
const logger = new ClearcutLogger({
130+
persistence: mockPersistence,
131+
sender: mockSender,
132+
});
133+
134+
await logger.logDailyActiveIfNeeded();
38135

39-
await loggerInstance.logServerStart({headless: true});
136+
const calls = mockSender.send.getCalls();
137+
const dailyActiveCall = calls.find(call => {
138+
return !!call.args[0].daily_active;
139+
});
40140

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},
141+
assert(dailyActiveCall, 'Should have logged daily active');
142+
assert.strictEqual(
143+
dailyActiveCall.args[0].daily_active?.days_since_last_active,
144+
-1,
145+
);
146+
assert(mockPersistence.saveState.called);
45147
});
46148
});
47149
});

0 commit comments

Comments
 (0)