Fix telemetry shutdown noise and improve token usage diagnostics (#444)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
01adc828b5
commit
995a7f070d
59
tests/unit/app-routes.test.ts
Normal file
59
tests/unit/app-routes.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
const runOpenClawDoctorMock = vi.fn();
|
||||
const runOpenClawDoctorFixMock = vi.fn();
|
||||
const sendJsonMock = vi.fn();
|
||||
const sendNoContentMock = vi.fn();
|
||||
|
||||
vi.mock('@electron/utils/openclaw-doctor', () => ({
|
||||
runOpenClawDoctor: (...args: unknown[]) => runOpenClawDoctorMock(...args),
|
||||
runOpenClawDoctorFix: (...args: unknown[]) => runOpenClawDoctorFixMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/api/route-utils', () => ({
|
||||
setCorsHeaders: vi.fn(),
|
||||
parseJsonBody: vi.fn().mockResolvedValue({}),
|
||||
sendJson: (...args: unknown[]) => sendJsonMock(...args),
|
||||
sendNoContent: (...args: unknown[]) => sendNoContentMock(...args),
|
||||
}));
|
||||
|
||||
describe('handleAppRoutes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('runs openclaw doctor through the host api', async () => {
|
||||
runOpenClawDoctorMock.mockResolvedValueOnce({ success: true, exitCode: 0 });
|
||||
const { handleAppRoutes } = await import('@electron/api/routes/app');
|
||||
|
||||
const handled = await handleAppRoutes(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runOpenClawDoctorMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: true, exitCode: 0 });
|
||||
});
|
||||
|
||||
it('runs openclaw doctor fix when requested', async () => {
|
||||
const { parseJsonBody } = await import('@electron/api/route-utils');
|
||||
vi.mocked(parseJsonBody).mockResolvedValueOnce({ mode: 'fix' });
|
||||
runOpenClawDoctorFixMock.mockResolvedValueOnce({ success: false, exitCode: 1 });
|
||||
const { handleAppRoutes } = await import('@electron/api/routes/app');
|
||||
|
||||
const handled = await handleAppRoutes(
|
||||
{ method: 'POST' } as IncomingMessage,
|
||||
{} as ServerResponse,
|
||||
new URL('http://127.0.0.1:3210/api/app/openclaw-doctor'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(runOpenClawDoctorFixMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendJsonMock).toHaveBeenCalledWith(expect.anything(), 200, { success: false, exitCode: 1 });
|
||||
});
|
||||
});
|
||||
68
tests/unit/models-usage-history.test.ts
Normal file
68
tests/unit/models-usage-history.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterUsageHistoryByWindow,
|
||||
groupUsageHistory,
|
||||
type UsageHistoryEntry,
|
||||
} from '@/pages/Models/usage-history';
|
||||
|
||||
function createEntry(day: number, totalTokens: number): UsageHistoryEntry {
|
||||
return {
|
||||
timestamp: `2026-03-${String(day).padStart(2, '0')}T12:00:00.000Z`,
|
||||
sessionId: `session-${day}`,
|
||||
agentId: 'main',
|
||||
model: 'gpt-5',
|
||||
inputTokens: totalTokens,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalTokens,
|
||||
};
|
||||
}
|
||||
|
||||
describe('models usage history helpers', () => {
|
||||
it('keeps all day buckets instead of truncating to the first eight', () => {
|
||||
const entries = Array.from({ length: 12 }, (_, index) => createEntry(index + 1, index + 1));
|
||||
|
||||
const groups = groupUsageHistory(entries, 'day');
|
||||
|
||||
expect(groups).toHaveLength(12);
|
||||
expect(groups[0]?.totalTokens).toBe(1);
|
||||
expect(groups[11]?.totalTokens).toBe(12);
|
||||
});
|
||||
|
||||
it('limits model buckets to the top eight by total tokens', () => {
|
||||
const entries = Array.from({ length: 10 }, (_, index) => ({
|
||||
...createEntry(index + 1, index + 1),
|
||||
model: `model-${index + 1}`,
|
||||
}));
|
||||
|
||||
const groups = groupUsageHistory(entries, 'model');
|
||||
|
||||
expect(groups).toHaveLength(8);
|
||||
expect(groups[0]?.label).toBe('model-10');
|
||||
expect(groups[7]?.label).toBe('model-3');
|
||||
});
|
||||
|
||||
it('filters the last 30 days relative to now instead of calendar month boundaries', () => {
|
||||
const now = Date.parse('2026-03-12T12:00:00.000Z');
|
||||
const entries = [
|
||||
{
|
||||
...createEntry(12, 12),
|
||||
timestamp: '2026-03-12T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...createEntry(11, 11),
|
||||
timestamp: '2026-02-11T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...createEntry(10, 10),
|
||||
timestamp: '2026-02-10T11:59:59.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = filterUsageHistoryByWindow(entries, '30d', now);
|
||||
|
||||
expect(filtered).toHaveLength(2);
|
||||
expect(filtered.map((entry) => entry.totalTokens)).toEqual([12, 11]);
|
||||
});
|
||||
});
|
||||
92
tests/unit/telemetry.test.ts
Normal file
92
tests/unit/telemetry.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
shutdownMock,
|
||||
captureMock,
|
||||
getSettingMock,
|
||||
setSettingMock,
|
||||
loggerDebugMock,
|
||||
loggerErrorMock,
|
||||
} = vi.hoisted(() => ({
|
||||
shutdownMock: vi.fn(),
|
||||
captureMock: vi.fn(),
|
||||
getSettingMock: vi.fn(),
|
||||
setSettingMock: vi.fn(),
|
||||
loggerDebugMock: vi.fn(),
|
||||
loggerErrorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('posthog-node', () => ({
|
||||
PostHog: vi.fn(function PostHogMock() {
|
||||
return {
|
||||
capture: captureMock,
|
||||
shutdown: shutdownMock,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/store', () => ({
|
||||
getSetting: getSettingMock,
|
||||
setSetting: setSettingMock,
|
||||
}));
|
||||
|
||||
vi.mock('@electron/utils/logger', () => ({
|
||||
logger: {
|
||||
debug: loggerDebugMock,
|
||||
error: loggerErrorMock,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getVersion: () => '0.2.1',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('node-machine-id', () => ({
|
||||
machineIdSync: () => 'machine-id-1',
|
||||
}));
|
||||
|
||||
describe('main telemetry shutdown', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
getSettingMock.mockImplementation(async (key: string) => {
|
||||
switch (key) {
|
||||
case 'telemetryEnabled':
|
||||
return true;
|
||||
case 'machineId':
|
||||
return 'existing-machine-id';
|
||||
case 'hasReportedInstall':
|
||||
return true;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
setSettingMock.mockResolvedValue(undefined);
|
||||
captureMock.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it('ignores PostHog network timeout errors during shutdown', async () => {
|
||||
shutdownMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Network error while fetching PostHog'), {
|
||||
name: 'PostHogFetchNetworkError',
|
||||
cause: Object.assign(new Error('The operation was aborted due to timeout'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const { initTelemetry, shutdownTelemetry } = await import('@electron/utils/telemetry');
|
||||
await initTelemetry();
|
||||
await shutdownTelemetry();
|
||||
|
||||
expect(loggerErrorMock).not.toHaveBeenCalled();
|
||||
expect(loggerDebugMock).toHaveBeenCalledWith(
|
||||
'Ignored telemetry shutdown network error:',
|
||||
expect.objectContaining({ name: 'PostHogFetchNetworkError' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ describe('extractSessionIdFromTranscriptFileName', () => {
|
||||
expect(extractSessionIdFromTranscriptFileName('abc-123.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('parses deleted reset transcript names', () => {
|
||||
expect(extractSessionIdFromTranscriptFileName('abc-123.deleted.jsonl.reset.2026-03-09T03-01-29.968Z')).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('returns undefined for non-transcript files', () => {
|
||||
expect(extractSessionIdFromTranscriptFileName('sessions.json')).toBeUndefined();
|
||||
expect(extractSessionIdFromTranscriptFileName('abc-123.log')).toBeUndefined();
|
||||
|
||||
89
tests/unit/token-usage-scan.test.ts
Normal file
89
tests/unit/token-usage-scan.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { mkdir, rm, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { testHome, testUserData } = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
return {
|
||||
testHome: `/tmp/clawx-token-usage-${suffix}`,
|
||||
testUserData: `/tmp/clawx-token-usage-user-data-${suffix}`,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('os', async () => {
|
||||
const actual = await vi.importActual<typeof import('os')>('os');
|
||||
const mocked = {
|
||||
...actual,
|
||||
homedir: () => testHome,
|
||||
};
|
||||
return {
|
||||
...mocked,
|
||||
default: mocked,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: () => testUserData,
|
||||
getVersion: () => '0.0.0-test',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('token usage session scan', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.restoreAllMocks();
|
||||
await rm(testHome, { recursive: true, force: true });
|
||||
await rm(testUserData, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('includes transcripts from agent directories that exist on disk but are not configured', async () => {
|
||||
const openclawDir = join(testHome, '.openclaw');
|
||||
await mkdir(openclawDir, { recursive: true });
|
||||
await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify({
|
||||
agents: {
|
||||
list: [
|
||||
{ id: 'main', name: 'Main', default: true },
|
||||
],
|
||||
},
|
||||
}, null, 2), 'utf8');
|
||||
|
||||
const diskOnlySessionsDir = join(openclawDir, 'agents', 'custom-custom25', 'sessions');
|
||||
await mkdir(diskOnlySessionsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(diskOnlySessionsDir, 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
timestamp: '2026-03-12T12:19:00.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
model: 'gpt-5.2-2025-12-11',
|
||||
provider: 'openai',
|
||||
usage: {
|
||||
input: 17649,
|
||||
output: 107,
|
||||
total: 17756,
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const { getRecentTokenUsageHistory } = await import('@electron/utils/token-usage');
|
||||
const entries = await getRecentTokenUsageHistory();
|
||||
|
||||
expect(entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
agentId: 'custom-custom25',
|
||||
sessionId: 'f8e66f77-0125-4e2f-b750-9c4de01e8f5a',
|
||||
model: 'gpt-5.2-2025-12-11',
|
||||
totalTokens: 17756,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user