diff --git a/src/main.ts b/src/main.ts index ad6507928..1ee2cf266 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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)); diff --git a/src/telemetry/clearcut-logger.ts b/src/telemetry/clearcut-logger.ts index 17b53420c..8b86de212 100644 --- a/src/telemetry/clearcut-logger.ts +++ b/src/telemetry/clearcut-logger.ts @@ -4,14 +4,22 @@ * SPDX-License-Identifier: Apache-2.0 */ +import {logger} from '../logger.js'; + 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: { @@ -35,4 +43,48 @@ export class ClearcutLogger { }, }); } + + async logDailyActiveIfNeeded(): Promise { + 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; + } } diff --git a/src/telemetry/persistence.ts b/src/telemetry/persistence.ts new file mode 100644 index 000000000..5b133649b --- /dev/null +++ b/src/telemetry/persistence.ts @@ -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 { + 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; + saveState(state: LocalState): Promise; +} + +export class FilePersistence implements Persistence { + #dataFolder: string; + + constructor(dataFolderOverride?: string) { + this.#dataFolder = dataFolderOverride ?? getDataFolder(); + } + + async loadState(): Promise { + 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 { + 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); + } + } +} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts index c6e1cb190..7e8234546 100644 --- a/src/telemetry/types.ts +++ b/src/telemetry/types.ts @@ -13,7 +13,6 @@ export interface ChromeDevToolsMcpExtension { tool_invocation?: ToolInvocation; server_start?: ServerStart; daily_active?: DailyActive; - first_time_installation?: FirstTimeInstallation; } export interface ToolInvocation { @@ -30,8 +29,6 @@ export interface DailyActive { days_since_last_active: number; } -export type FirstTimeInstallation = Record; - export type FlagUsage = Record; // Clearcut API interfaces diff --git a/tests/telemetry/clearcut-logger.test.ts b/tests/telemetry/clearcut-logger.test.ts index b41c6da48..c0a11456a 100644 --- a/tests/telemetry/clearcut-logger.test.ts +++ b/tests/telemetry/clearcut-logger.test.ts @@ -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; + let mockSender: sinon.SinonStubbedInstance; + + 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); }); }); }); diff --git a/tests/telemetry/persistence.test.ts b/tests/telemetry/persistence.test.ts new file mode 100644 index 000000000..9bacada9a --- /dev/null +++ b/tests/telemetry/persistence.test.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'node:assert'; +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {describe, it, afterEach, beforeEach} from 'node:test'; + +import * as persistence from '../../src/telemetry/persistence.js'; + +describe('FilePersistence', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = path.join( + await fs.realpath(os.tmpdir()), + `telemetry-test-${crypto.randomUUID()}`, + ); + await fs.mkdir(tmpDir, {recursive: true}); + }); + + afterEach(async () => { + await fs.rm(tmpDir, {recursive: true, force: true}); + }); + + describe('loadState', () => { + it('returns default state if file does not exist', async () => { + const filePersistence = new persistence.FilePersistence(tmpDir); + const state = await filePersistence.loadState(); + assert.deepStrictEqual(state, { + lastActive: '', + }); + }); + + it('returns stored state if file exists', async () => { + const expectedState = { + lastActive: '2023-01-01T00:00:00.000Z', + }; + await fs.writeFile( + path.join(tmpDir, 'telemetry_state.json'), + JSON.stringify(expectedState), + ); + + const filePersistence = new persistence.FilePersistence(tmpDir); + const state = await filePersistence.loadState(); + assert.deepStrictEqual(state, expectedState); + }); + }); + + describe('saveState', () => { + it('saves state to file', async () => { + const state = { + lastActive: '2023-01-01T00:00:00.000Z', + }; + const filePersistence = new persistence.FilePersistence(tmpDir); + await filePersistence.saveState(state); + + const content = await fs.readFile( + path.join(tmpDir, 'telemetry_state.json'), + 'utf-8', + ); + assert.deepStrictEqual(JSON.parse(content), state); + }); + }); +});