import { createErrorResponse, ToolResult } from '@/common/tool-handler';
import { BaseBrowserToolExecutor } from '../base-browser';
import { TOOL_NAMES } from 'chrome-mcp-shared';
import { cdpSessionManager } from '@/utils/cdp-session-manager';
type OwnerTag = 'performance';
interface StartTraceParams {
reload?: boolean; // whether to reload the page after starting trace
autoStop?: boolean; // whether to auto stop after a short duration
durationMs?: number; // custom duration when autoStop is true (default 5000)
}
interface StopTraceParams {
saveToDownloads?: boolean; // save trace to Downloads as JSON (default true)
filenamePrefix?: string; // filename prefix (default 'performance_trace')
}
interface AnalyzeInsightParams {
insightName?: string; // placeholder for future deep insights
}
type DebuggeeEvent = (source: chrome.debugger.Debuggee, method: string, params?: any) => void;
interface TraceSessionState {
recording: boolean;
events: any[];
startedAt: number;
pageUrl?: string;
listener: DebuggeeEvent;
stopResolver?: (value: { completed: boolean }) => void;
stopPromise?: Promise<{ completed: boolean }>;
}
const sessions = new Map<number, TraceSessionState>();
const LAST_RESULTS = new Map<
number,
{
events: any[];
startedAt: number;
endedAt: number;
tabUrl: string;
saved?: { downloadId?: number; filename?: string; fullPath?: string };
metrics?: Record<string, number>;
}
>();
function tracingCategories(): string[] {
// Keep broadly consistent with other project
return [
'-*',
'blink.console',
'blink.user_timing',
'devtools.timeline',
'disabled-by-default-devtools.screenshot',
'disabled-by-default-devtools.timeline',
'disabled-by-default-devtools.timeline.invalidationTracking',
'disabled-by-default-devtools.timeline.frame',
'disabled-by-default-devtools.timeline.stack',
'disabled-by-default-v8.cpu_profiler',
'disabled-by-default-v8.cpu_profiler.hires',
'latencyInfo',
'loading',
'disabled-by-default-lighthouse',
'v8.execute',
'v8',
];
}
async function enablePerformanceMetrics(tabId: number): Promise<Record<string, number>> {
try {
await cdpSessionManager.sendCommand(tabId, 'Performance.enable');
const result = (await cdpSessionManager.sendCommand(tabId, 'Performance.getMetrics')) as {
metrics: Array<{ name: string; value: number }>;
};
await cdpSessionManager.sendCommand(tabId, 'Performance.disable');
const map: Record<string, number> = {};
for (const m of result.metrics || []) map[m.name] = m.value;
return map;
} catch (e) {
return {};
}
}
async function saveTraceToDownloads(
json: string,
filenamePrefix = 'performance_trace',
): Promise<{ downloadId?: number; filename?: string; fullPath?: string }> {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${filenamePrefix}_${timestamp}.json`;
const dataUrl = `data:application/json;base64,${btoa(unescape(encodeURIComponent(json)))}`;
const downloadId = await chrome.downloads.download({ url: dataUrl, filename, saveAs: false });
// Attempt to resolve full path
try {
await new Promise((r) => setTimeout(r, 120));
const [item] = await chrome.downloads.search({ id: downloadId });
return { downloadId, filename, fullPath: item?.filename };
} catch {
return { downloadId, filename };
}
} catch {
return {};
}
}
async function saveTraceToNativeTemp(
json: string,
filenamePrefix = 'performance_trace',
): Promise<{ filename?: string; fullPath?: string } | undefined> {
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${filenamePrefix}_${timestamp}.json`;
const base64 = btoa(unescape(encodeURIComponent(json)));
const requestId = `trace-temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const timeoutMs = 30000;
const resp = await new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => {
chrome.runtime.onMessage.removeListener(listener);
reject(new Error('Native temp save timed out'));
}, timeoutMs);
const listener = (message: any) => {
if (
message &&
message.type === 'file_operation_response' &&
message.responseToRequestId === requestId
) {
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(listener);
resolve(message.payload);
}
};
chrome.runtime.onMessage.addListener(listener);
chrome.runtime
.sendMessage({
type: 'forward_to_native',
message: {
type: 'file_operation',
requestId,
payload: {
action: 'prepareFile',
base64Data: base64,
fileName: filename,
},
},
})
.catch((err) => {
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(listener);
reject(err);
});
});
if (resp && resp.success && resp.filePath) {
return { filename, fullPath: resp.filePath };
}
} catch {
// ignore, fallback will apply
}
return undefined;
}
async function cleanupNativeTempFile(filePath: string): Promise<void> {
if (!filePath) return;
try {
const requestId = `trace-clean-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const timeoutMs = 10000;
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
chrome.runtime.onMessage.removeListener(listener);
resolve(); // best-effort
}, timeoutMs);
const listener = (message: any) => {
if (
message &&
message.type === 'file_operation_response' &&
message.responseToRequestId === requestId
) {
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(listener);
resolve();
}
};
chrome.runtime.onMessage.addListener(listener);
chrome.runtime
.sendMessage({
type: 'forward_to_native',
message: {
type: 'file_operation',
requestId,
payload: {
action: 'cleanupFile',
filePath,
},
},
})
.catch(() => {
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(listener);
resolve();
});
});
} catch {
// ignore
}
}
function getOrCreateStopPromise(session: TraceSessionState): Promise<{ completed: boolean }> {
if (session.stopPromise) return session.stopPromise;
session.stopPromise = new Promise((resolve) => {
session.stopResolver = resolve;
});
return session.stopPromise;
}
/**
* Start performance trace
*/
class PerformanceStartTraceTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.PERFORMANCE_START_TRACE;
async execute(args: StartTraceParams): Promise<ToolResult> {
const { reload = false, autoStop = false, durationMs = 5000 } = args || {};
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) {
return createErrorResponse('No active tab found');
}
const tabId = activeTab.id;
const existed = sessions.get(tabId);
if (existed?.recording) {
return {
content: [{ type: 'text', text: 'Error: a performance trace is already running.' }],
isError: false,
};
}
await cdpSessionManager.attach(tabId, 'performance');
const state: TraceSessionState = {
recording: true,
events: [],
startedAt: Date.now(),
pageUrl: activeTab.url || '',
listener: (source, method, params) => {
if (source.tabId !== tabId) return;
if (method === 'Tracing.dataCollected' && params?.value) {
try {
state.events.push(...(params.value as any[]));
} catch {
// ignore
}
} else if (method === 'Tracing.tracingComplete') {
state.recording = false;
state.stopResolver?.({ completed: true });
}
},
};
chrome.debugger.onEvent.addListener(state.listener);
sessions.set(tabId, state);
// Start tracing with categories
const cats = tracingCategories().join(',');
await cdpSessionManager.sendCommand(tabId, 'Tracing.start', {
categories: cats,
options: 'record-as-much-as-possible',
transferMode: 'ReportEvents',
});
if (reload) {
try {
await cdpSessionManager.sendCommand(tabId, 'Page.reload', { ignoreCache: true });
} catch {
// best effort; ignore if fails
}
}
if (autoStop) {
setTimeout(
async () => {
try {
await cdpSessionManager.sendCommand(tabId, 'Tracing.end');
} catch {
// ignore
}
},
Math.max(1000, Math.min(durationMs, 60000)),
);
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'Performance trace is recording. Use performance_stop_trace to stop it.',
reload,
autoStop,
}),
},
],
isError: false,
};
} catch (e: any) {
return createErrorResponse(`Failed to start performance trace: ${e?.message || e}`);
}
}
}
/**
* Stop performance trace
*/
class PerformanceStopTraceTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.PERFORMANCE_STOP_TRACE;
async execute(args: StopTraceParams): Promise<ToolResult> {
const { saveToDownloads = true, filenamePrefix } = args || {};
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) return createErrorResponse('No active tab found');
const tabId = activeTab.id;
const session = sessions.get(tabId);
if (!session) {
return {
content: [
{ type: 'text', text: 'No performance trace session found for the current tab.' },
],
isError: false,
};
}
let stopResult: { completed: boolean } = { completed: false };
if (session.recording) {
// End tracing and wait for completion signal
await cdpSessionManager.sendCommand(tabId, 'Tracing.end');
await getOrCreateStopPromise(session);
stopResult = await session.stopPromise!;
} else {
// Already auto-stopped; proceed to finalize without waiting
stopResult = { completed: true };
}
// Fetch metrics before detach
const metrics = await enablePerformanceMetrics(tabId);
// Cleanup event listener and detach
try {
chrome.debugger.onEvent.removeListener(session.listener);
} catch {
// ignore
}
try {
await cdpSessionManager.detach(tabId, 'performance');
} catch {
// ignore
}
const endedAt = Date.now();
const trace = { traceEvents: session.events };
const json = JSON.stringify(trace);
let saved: { downloadId?: number; filename?: string; fullPath?: string } | undefined;
if (saveToDownloads) {
saved = await saveTraceToDownloads(json, filenamePrefix || 'performance_trace');
} else {
// Persist to native temp directory so that analysis can run without Downloads permission
const tempSaved = await saveTraceToNativeTemp(json, filenamePrefix || 'performance_trace');
if (tempSaved) {
saved = { ...tempSaved } as any;
}
}
LAST_RESULTS.set(tabId, {
events: session.events,
startedAt: session.startedAt,
endedAt,
tabUrl: session.pageUrl || '',
saved,
metrics,
});
sessions.delete(tabId);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
message: 'The performance trace has been stopped.',
eventCount: session.events.length,
saved,
metrics,
startedAt: session.startedAt,
endedAt,
durationMs: endedAt - session.startedAt,
url: session.pageUrl || '',
tracingCompleted: stopResult?.completed === true,
}),
},
],
isError: false,
};
} catch (e: any) {
return createErrorResponse(`Failed to stop performance trace: ${e?.message || e}`);
}
}
}
/**
* Analyze last trace (lightweight)
* Note: Deep insights require DevTools front-end trace engine on the native side; this is a
* pragmatic first step returning basic metrics and a quick event histogram.
*/
class PerformanceAnalyzeInsightTool extends BaseBrowserToolExecutor {
name = TOOL_NAMES.BROWSER.PERFORMANCE_ANALYZE_INSIGHT;
async execute(args: AnalyzeInsightParams & { timeoutMs?: number }): Promise<ToolResult> {
const { insightName } = args || {};
try {
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!activeTab?.id) return createErrorResponse('No active tab found');
const tabId = activeTab.id;
const result = LAST_RESULTS.get(tabId);
if (!result) {
return {
content: [
{
type: 'text',
text: 'No recorded traces found. Start and stop a performance trace first.',
},
],
isError: false,
};
}
// Prefer native-side deep analysis when we have a saved file path
const fullPath = (result.saved && (result.saved as any).fullPath) || undefined;
if (fullPath) {
try {
const requestId = `trace-analyze-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const timeoutMs = Math.max(10000, Math.min((args as any)?.timeoutMs ?? 60000, 300000));
const resp = await new Promise<any>((resolve, reject) => {
const timer = setTimeout(() => {
chrome.runtime.onMessage.removeListener(listener);
reject(new Error('Native trace analysis timed out'));
}, timeoutMs);
const listener = (message: any) => {
if (
message &&
message.type === 'file_operation_response' &&
message.responseToRequestId === requestId
) {
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(listener);
resolve(message.payload);
}
};
chrome.runtime.onMessage.addListener(listener);
chrome.runtime
.sendMessage({
type: 'forward_to_native',
message: {
type: 'file_operation',
requestId,
payload: { action: 'analyzeTrace', traceFilePath: fullPath, insightName },
},
})
.catch((err) => {
clearTimeout(timer);
chrome.runtime.onMessage.removeListener(listener);
reject(err);
});
});
if (resp && resp.success) {
// Best-effort cleanup for temp files (Downloads paths are ignored by native cleaner)
await cleanupNativeTempFile(fullPath);
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
url: result.tabUrl,
startedAt: result.startedAt,
endedAt: result.endedAt,
durationMs: result.endedAt - result.startedAt,
metrics: result.metrics || {},
saved: result.saved,
summary: resp.summary,
insight: resp.insight,
}),
},
],
isError: false,
};
}
// If native returned error, fall through to lightweight analysis
} catch (e) {
// Fallback to lightweight analysis below
}
}
// Lightweight fallback (when no saved file path)
const counts = new Map<string, number>();
for (const ev of result.events.slice(0, 100000)) {
const n = typeof (ev as any)?.name === 'string' ? (ev as any).name : 'unknown';
counts.set(n, (counts.get(n) || 0) + 1);
}
const top = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 20)
.map(([name, count]) => ({ name, count }));
return {
content: [
{
type: 'text',
text: JSON.stringify({
success: true,
info: 'Lightweight analysis (no saved file path). Native-side deep analysis unavailable.',
requestedInsight: insightName || null,
url: result.tabUrl,
startedAt: result.startedAt,
endedAt: result.endedAt,
durationMs: result.endedAt - result.startedAt,
metrics: result.metrics || {},
topEventNames: top,
saved: result.saved,
}),
},
],
isError: false,
};
} catch (e: any) {
return createErrorResponse(`Failed to analyze trace: ${e?.message || e}`);
}
}
}
export const performanceStartTraceTool = new PerformanceStartTraceTool();
export const performanceStopTraceTool = new PerformanceStopTraceTool();
export const performanceAnalyzeInsightTool = new PerformanceAnalyzeInsightTool();