Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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> {
try {
await fs.mkdir(this.#dataFolder, {recursive: true});
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
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:', error);
Comment thread
ergunsh marked this conversation as resolved.
Outdated
}
}
}
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