Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {Mutex} from './Mutex.js';
import {SlimMcpResponse} from './SlimMcpResponse.js';
import {ClearcutLogger} from './telemetry/ClearcutLogger.js';
import {bucketizeLatency} from './telemetry/metricUtils.js';
import {FilePersistence} from './telemetry/persistence.js';
import {
McpServer,
type CallToolResult,
Expand Down Expand Up @@ -136,6 +137,7 @@ export async function createMcpServer(
) {
if (serverArgs.usageStatistics) {
ClearcutLogger.initialize({
persistence: new FilePersistence(),
logFile: serverArgs.logFile,
appVersion: VERSION,
clearcutEndpoint: serverArgs.clearcutEndpoint,
Expand Down
5 changes: 2 additions & 3 deletions src/telemetry/ClearcutLogger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {zod, ShapeOutput} from '../third_party/index.js';

import type {ErrorCode} from './errors.js';
import type {LocalState, Persistence} from './persistence.js';
import {FilePersistence} from './persistence.js';
import {
McpClient,
type FlagUsage,
Expand Down Expand Up @@ -174,8 +173,8 @@ function detectOsType(): OsType {

export interface ClearcutLoggerOptions {
appVersion: string;
persistence: Persistence;
logFile?: string;
persistence?: Persistence;
watchdogClient?: WatchdogClient;
clearcutEndpoint?: string;
clearcutForceFlushIntervalMs?: number;
Expand Down Expand Up @@ -207,7 +206,7 @@ export class ClearcutLogger {
}

private constructor(options: ClearcutLoggerOptions) {
this.#persistence = options.persistence ?? new FilePersistence();
this.#persistence = options.persistence;
this.#watchdog =
options.watchdogClient ??
new WatchdogClient({
Expand Down
4 changes: 4 additions & 0 deletions src/telemetry/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
* IMPORTANT:
* 1. this module must only contain ErrorCode.
* 2. do not refactor ErrorCode to elsewhere.
* 3. prefix new enum values with "ERROR_CODE_". This makes it easier to
* programmtically parse this file.
*/

export enum ErrorCode {
ERROR_CODE_UNSPECIFIED = 0,
ERROR_CODE_PERSISTENCE_FILE_READ_FAILED = 1,
ERROR_CODE_PERSISTENCE_FILE_SAVE_FAILED = 3,
}
23 changes: 21 additions & 2 deletions src/telemetry/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import process from 'node:process';

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

import {ClearcutLogger} from './ClearcutLogger.js';
import {ErrorCode} from './errors.js';

export interface LocalState {
lastActive: string; // ISO 8601 UTC date string
}
Expand Down Expand Up @@ -50,11 +53,24 @@ export class FilePersistence implements Persistence {
}

async loadState(): Promise<LocalState> {
const filePath = path.join(this.#dataFolder, STATE_FILE_NAME);
try {
await fs.access(filePath);
} catch {
// File doesn't exist. Not an error because new users do not have the state file.
return {
lastActive: '',
};
}

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 {
} catch (error) {
logger(`Failed to read telemetry state from ${filePath}:`, error);
void ClearcutLogger.get()?.logServerError({
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_READ_FAILED,
});
return {
lastActive: '',
};
Expand All @@ -69,6 +85,9 @@ export class FilePersistence implements Persistence {
} catch (error) {
// Ignore errors during state saving to avoid crashing the server
logger(`Failed to save telemetry state to ${filePath}:`, error);
void ClearcutLogger.get()?.logServerError({
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_SAVE_FAILED,
});
}
}
}
76 changes: 75 additions & 1 deletion tests/telemetry/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,86 @@ import os from 'node:os';
import path from 'node:path';
import {describe, it, afterEach, beforeEach} from 'node:test';

import sinon from 'sinon';

import {ClearcutLogger} from '../../src/telemetry/ClearcutLogger.js';
import {ErrorCode} from '../../src/telemetry/errors.js';
import * as persistence from '../../src/telemetry/persistence.js';
import {WatchdogClient} from '../../src/telemetry/WatchdogClient.js';

describe('FilePersistence', () => {
let tmpDir: string;
let logServerErrorStub: sinon.SinonStub;

beforeEach(async () => {
tmpDir = path.join(
await fs.realpath(os.tmpdir()),
`telemetry-test-${crypto.randomUUID()}`,
);
await fs.mkdir(tmpDir, {recursive: true});

ClearcutLogger.resetForTesting();
const mockWatchdog = sinon.createStubInstance(WatchdogClient);
const logger = ClearcutLogger.initialize({
appVersion: '1.0.0',
persistence: new persistence.FilePersistence(tmpDir),
watchdogClient: mockWatchdog,
});
logServerErrorStub = sinon.stub(logger, 'logServerError');
});

afterEach(async () => {
sinon.restore();
ClearcutLogger.resetForTesting();
await fs.rm(tmpDir, {recursive: true, force: true});
});

describe('loadState', () => {
it('returns default state if file does not exist', async () => {
it('returns default state and does NOT log telemetry if file does not exist (ENOENT)', async () => {
const filePersistence = new persistence.FilePersistence(tmpDir);
const state = await filePersistence.loadState();
assert.deepStrictEqual(state, {
lastActive: '',
});
assert(logServerErrorStub.notCalled);
});

it('returns default state and LOGS telemetry if load fails due to corruption', async () => {
const filePath = path.join(tmpDir, 'telemetry_state.json');
await fs.writeFile(filePath, 'not-valid-json', 'utf-8');

const filePersistence = new persistence.FilePersistence(tmpDir);
const state = await filePersistence.loadState();

assert.deepStrictEqual(state, {
lastActive: '',
});
assert(logServerErrorStub.calledOnce);
assert.deepStrictEqual(logServerErrorStub.firstCall.args[0], {
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_READ_FAILED,
});
});

it('returns default state and LOGS telemetry if load fails during read stage', async () => {
const filePath = path.join(tmpDir, 'telemetry_state.json');
await fs.writeFile(filePath, '{"valid": "json"}', 'utf-8');

const readFileStub = sinon
.stub(fs, 'readFile')
.rejects(new Error('Synthetic read error'));

const filePersistence = new persistence.FilePersistence(tmpDir);
const state = await filePersistence.loadState();

assert.deepStrictEqual(state, {
lastActive: '',
});
assert(logServerErrorStub.calledOnce);
assert.deepStrictEqual(logServerErrorStub.firstCall.args[0], {
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_READ_FAILED,
});

readFileStub.restore();
});

it('returns stored state if file exists', async () => {
Expand Down Expand Up @@ -65,6 +121,24 @@ describe('FilePersistence', () => {
'utf-8',
);
assert.deepStrictEqual(JSON.parse(content), state);
assert(logServerErrorStub.notCalled);
});

it('logs telemetry when failing to save to file', async () => {
// Force error by replacing directory with a file, causing mkdir to fail.
const dirPath = path.join(tmpDir, 'blocked_dir');
await fs.writeFile(dirPath, 'i-am-a-file');
const filePersistence = new persistence.FilePersistence(dirPath);

const state = {
lastActive: '2023-01-01T00:00:00.000Z',
};
await filePersistence.saveState(state);

assert(logServerErrorStub.calledOnce);
assert.deepStrictEqual(logServerErrorStub.firstCall.args[0], {
errorCode: ErrorCode.ERROR_CODE_PERSISTENCE_FILE_SAVE_FAILED,
});
});
});
});
Loading