Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,5 @@ const transport = new StdioServerTransport();
await server.connect(transport);
logger('Chrome DevTools MCP Server connected');
logDisclaimers();
void clearcutLogger?.logDailyActiveIfNeeded();
void clearcutLogger?.logServerStart(computeFlagUsage(args, cliOptions));
56 changes: 54 additions & 2 deletions src/telemetry/clearcut-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {logger} from '../logger.js';
Comment thread
OrKoN marked this conversation as resolved.

import {ClearcutSender} from './clearcut-sender.js';
import type {LocalState, Persistence} from './persistence.js';
import {FilePersistence} from './persistence.js';
import type {FlagUsage} from './types.js';

const MS_PER_DAY = 24 * 60 * 60 * 1000;

export class ClearcutLogger {
#persistence: Persistence;
#sender: ClearcutSender;

constructor(sender?: ClearcutSender) {
this.#sender = sender ?? new ClearcutSender();
constructor(options?: {persistence?: Persistence; sender?: ClearcutSender}) {
this.#persistence = options?.persistence ?? new FilePersistence();
this.#sender = options?.sender ?? new ClearcutSender();
}

async logToolInvocation(args: {
Expand All @@ -35,4 +43,48 @@ export class ClearcutLogger {
},
});
}

async logDailyActiveIfNeeded(): Promise<void> {
try {
const state = await this.#persistence.loadState();

if (this.#shouldLogDailyActive(state)) {
let daysSince = -1;
if (state.lastActive) {
const lastActiveDate = new Date(state.lastActive);
const now = new Date();
const diffTime = Math.abs(now.getTime() - lastActiveDate.getTime());
daysSince = Math.ceil(diffTime / MS_PER_DAY);
}

await this.#sender.send({
daily_active: {
days_since_last_active: daysSince,
},
});

// Update persistence
state.lastActive = new Date().toISOString();
await this.#persistence.saveState(state);
}
} catch (err) {
logger('Error in logDailyActiveIfNeeded:', err);
}
}

#shouldLogDailyActive(state: LocalState): boolean {
if (!state.lastActive) {
return true;
}
const lastActiveDate = new Date(state.lastActive);
const now = new Date();

// Compare UTC dates
const isSameDay =
lastActiveDate.getUTCFullYear() === now.getUTCFullYear() &&
lastActiveDate.getUTCMonth() === now.getUTCMonth() &&
lastActiveDate.getUTCDate() === now.getUTCDate();

return !isSameDay;
}
}
74 changes: 74 additions & 0 deletions src/telemetry/persistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

import {logger} from '../logger.js';

export interface LocalState {
lastActive: string; // ISO 8601 UTC date string
}

const STATE_FILE_NAME = 'telemetry_state.json';
function getDataFolder(): string {
Comment thread
OrKoN marked this conversation as resolved.
const homedir = os.homedir();
const {env} = process;
const name = 'chrome-devtools-mcp';

if (process.platform === 'darwin') {
return path.join(homedir, 'Library', 'Application Support', name);
}

if (process.platform === 'win32') {
const localAppData =
env.LOCALAPPDATA || path.join(homedir, 'AppData', 'Local');
return path.join(localAppData, name, 'Data');
}

return path.join(
env.XDG_DATA_HOME || path.join(homedir, '.local', 'share'),
name,
);
}

export interface Persistence {
loadState(): Promise<LocalState>;
saveState(state: LocalState): Promise<void>;
}

export class FilePersistence implements Persistence {
Comment thread
OrKoN marked this conversation as resolved.
#dataFolder: string;

constructor(dataFolderOverride?: string) {
this.#dataFolder = dataFolderOverride ?? getDataFolder();
}

async loadState(): Promise<LocalState> {
try {
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
const content = await fs.readFile(filePath, 'utf-8');
return JSON.parse(content) as LocalState;
} catch {
return {
lastActive: '',
};
}
}

async saveState(state: LocalState): Promise<void> {
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
try {
await fs.mkdir(this.#dataFolder, {recursive: true});
await fs.writeFile(filePath, JSON.stringify(state, null, 2), 'utf-8');
} catch (error) {
// Ignore errors during state saving to avoid crashing the server
logger(`Failed to save telemetry state to ${filePath}:`, error);
}
}
}
3 changes: 0 additions & 3 deletions src/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export interface ChromeDevToolsMcpExtension {
tool_invocation?: ToolInvocation;
server_start?: ServerStart;
daily_active?: DailyActive;
first_time_installation?: FirstTimeInstallation;
}

export interface ToolInvocation {
Expand All @@ -30,8 +29,6 @@ export interface DailyActive {
days_since_last_active: number;
}

export type FirstTimeInstallation = Record<string, never>;

export type FlagUsage = Record<string, boolean | string | number | undefined>;

// Clearcut API interfaces
Expand Down
152 changes: 127 additions & 25 deletions tests/telemetry/clearcut-logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,145 @@
*/

import assert from 'node:assert';
import {describe, it, mock} from 'node:test';
import {describe, it, afterEach, beforeEach} from 'node:test';

import sinon from 'sinon';

import {ClearcutLogger} from '../../src/telemetry/clearcut-logger.js';
import {ClearcutSender} from '../../src/telemetry/clearcut-sender.js';
import type {Persistence} from '../../src/telemetry/persistence.js';
import {FilePersistence} from '../../src/telemetry/persistence.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,
let mockPersistence: sinon.SinonStubbedInstance<Persistence>;
let mockSender: sinon.SinonStubbedInstance<ClearcutSender>;

beforeEach(() => {
mockPersistence = sinon.createStubInstance(FilePersistence, {
loadState: Promise.resolve({
lastActive: '',
}),
});
mockSender = sinon.createStubInstance(ClearcutSender);
mockSender.send.resolves();
});

afterEach(() => {
sinon.restore();
});

describe('logToolInvocation', () => {
it('sends correct payload', async () => {
const logger = new ClearcutLogger({
persistence: mockPersistence,
sender: mockSender,
});
await logger.logToolInvocation({
toolName: 'test_tool',
success: true,
latencyMs: 123,
});

assert(mockSender.send.calledOnce);
const extension = mockSender.send.firstCall.args[0];
assert.strictEqual(extension.tool_invocation?.tool_name, 'test_tool');
assert.strictEqual(extension.tool_invocation?.success, true);
assert.strictEqual(extension.tool_invocation?.latency_ms, 123);
});
});

describe('logServerStart', () => {
it('logs flag usage', async () => {
const logger = new ClearcutLogger({
persistence: mockPersistence,
sender: mockSender,
});

await logger.logServerStart({headless: true});

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,
// Should have logged server start
const calls = mockSender.send.getCalls();
const serverStartCall = calls.find(call => {
return !!call.args[0].server_start;
});

assert(serverStartCall);
assert.strictEqual(
serverStartCall.args[0].server_start?.flag_usage?.headless,
true,
);
});
});

it('should log server start via sender', async () => {
const sender = new ClearcutSender();
const sendSpy = mock.method(sender, 'send');
const loggerInstance = new ClearcutLogger(sender);
describe('logDailyActiveIfNeeded', () => {
it('logs daily active if needed (lastActive > 24h ago)', async () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);

mockPersistence.loadState.resolves({
lastActive: yesterday.toISOString(),
});

const logger = new ClearcutLogger({
persistence: mockPersistence,
sender: mockSender,
});

await logger.logDailyActiveIfNeeded();

const calls = mockSender.send.getCalls();
const dailyActiveCall = calls.find(call => {
return !!call.args[0].daily_active;
});

assert(dailyActiveCall, 'Should have logged daily active');
assert(mockPersistence.saveState.called);
});

it('does not log daily active if not needed (today)', async () => {
mockPersistence.loadState.resolves({
lastActive: new Date().toISOString(),
});

const logger = new ClearcutLogger({
persistence: mockPersistence,
sender: mockSender,
});

await logger.logDailyActiveIfNeeded();

const calls = mockSender.send.getCalls();
const dailyActiveCall = calls.find(call => {
return !!call.args[0].daily_active;
});

assert(!dailyActiveCall, 'Should NOT have logged daily active');
assert(mockPersistence.saveState.notCalled);
});

it('logs daily active with -1 if lastActive is missing', async () => {
mockPersistence.loadState.resolves({
lastActive: '',
});

const logger = new ClearcutLogger({
persistence: mockPersistence,
sender: mockSender,
});

await logger.logDailyActiveIfNeeded();

await loggerInstance.logServerStart({headless: true});
const calls = mockSender.send.getCalls();
const dailyActiveCall = calls.find(call => {
return !!call.args[0].daily_active;
});

assert.strictEqual(sendSpy.mock.callCount(), 1);
const event = sendSpy.mock.calls[0].arguments[0];
assert.deepStrictEqual(event.server_start, {
flag_usage: {headless: true},
assert(dailyActiveCall, 'Should have logged daily active');
assert.strictEqual(
dailyActiveCall.args[0].daily_active?.days_since_last_active,
-1,
);
assert(mockPersistence.saveState.called);
});
});
});
Loading