React children to wrap with debug context.
Provider component with split contexts for state and actions.
export function DebugProvider({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [isDebugEnabled, setIsDebugEnabled] = useState(false);
const [featureToggles, setFeatureToggles] = useState<DebugFeatureToggles>(
DEFAULT_FEATURE_TOGGLES,
);
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [ipcEvents, setIpcEvents] = useState<IpcLogEntry[]>([]);
const [eventLogEntries, setEventLogEntries] = useState<DebugEventEntry[]>([]);
const [maxIpcEntries, setMaxIpcEntries] = useState<number>(() => {
if (globalThis.window !== undefined && globalThis.electronDebug?.ipc) {
return globalThis.electronDebug.ipc.maxEntries;
}
return 500;
});
const [stateSourceSnapshots, setStateSourceSnapshots] = useState<
StateInspectorSourceSnapshot[]
>([]);
const [debugMenuOpen, setDebugMenuOpen] = useState(false);
const stateSourcesRef = useRef(
new Map<string, StateInspectorSourceInternal>(),
);
const [performanceMetrics, setPerformanceMetrics] =
useState<PerformanceMetrics>(defaultPerformanceMetrics);
const memoryPollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pendingApiSamplesRef = useRef<
Array<{
duration: number;
success: boolean;
correlationId?: string;
provider?: string;
endpoint?: string;
}>
>([]);
const fpsMonitorRef = useRef<FPSMonitor | null>(null);
const [currentFPS, setCurrentFPS] = useState<number>(60);
const isStorageDebuggerEnabled = featureToggles.storageDebugger;
const isLogViewerEnabled = featureToggles.logViewer;
const isLogRedactionEnabled = featureToggles.redactLogs;
const isStateInspectorEnabled = featureToggles.stateInspector;
const isIpcViewerEnabled = featureToggles.ipcViewer;
const isEventLoggerEnabled = featureToggles.eventLogger;
const isConfidenceTestExporterEnabled = featureToggles.confidenceTestExporter;
const isPerformanceMonitorEnabled = featureToggles.performanceMonitor;
useEffect(() => {
// Only install the console interceptor when BOTH debug mode AND the log viewer feature are enabled.
if (!(isDebugEnabled && isLogViewerEnabled)) return;
const detachConsole = installConsoleInterceptor();
return () => {
detachConsole?.();
};
}, [isDebugEnabled, isLogViewerEnabled]);
useEffect(() => {
setCollectorLogRedactionEnabled(isLogRedactionEnabled);
}, [isLogRedactionEnabled]);
useEffect(() => {
if (!isDebugEnabled && !isLogViewerEnabled) {
return;
}
setLogEntries(logCollector.getEntries());
const unsubscribe = logCollector.subscribe((entries) => {
// Use queueMicrotask to defer state update and avoid setState during render
queueMicrotask(() => {
setLogEntries(entries);
});
});
return () => {
unsubscribe();
};
}, [isDebugEnabled, isLogViewerEnabled]);
useEffect(() => {
if (globalThis.window === undefined) return;
const bridge = globalThis.window.electronDebug?.ipc;
if (!bridge) return;
setMaxIpcEntries(bridge.maxEntries);
}, []);
useEffect(() => {
if (globalThis.window === undefined) {
return;
}
const bridge = globalThis.electronDebug?.ipc;
if (!bridge) {
return;
}
// Enable/disable IPC tracking based on debug mode and feature toggle
const shouldTrack = isDebugEnabled && isIpcViewerEnabled;
bridge.setEnabled(shouldTrack);
if (!shouldTrack) {
setIpcEvents([]);
return;
}
setIpcEvents(bridge.getEvents());
const unsubscribe = bridge.subscribe((entries) => {
setIpcEvents(entries);
});
return () => {
unsubscribe();
};
}, [isDebugEnabled, isIpcViewerEnabled]);
useEffect(() => {
if (!isDebugEnabled) {
setEventLogEntries([]);
}
}, [isDebugEnabled]);
useEffect(() => {
if (!isEventLoggerEnabled) {
setEventLogEntries([]);
}
}, [isEventLoggerEnabled]);
// Load debug state from localStorage on initialization
useEffect(() => {
try {
const savedDebugState = localStorage.getItem(DEBUG_STORAGE_KEY);
if (savedDebugState !== null) {
setIsDebugEnabled(JSON.parse(savedDebugState));
}
} catch (error) {
console.error("Failed to load debug state from localStorage:", error);
}
}, []);
// Load feature toggles on initialization
useEffect(() => {
try {
const storedToggles = localStorage.getItem(DEBUG_FEATURE_TOGGLES_KEY);
if (storedToggles) {
const parsed = JSON.parse(
storedToggles,
) as Partial<DebugFeatureToggles>;
setFeatureToggles((prev) => ({
...prev,
...parsed,
}));
}
} catch (error) {
console.error(
"Failed to load debug feature toggles from localStorage:",
error,
);
}
}, []);
/**
* Records a debug event to the event log when debug mode or forcing is enabled.
* @param entry - The debug event to record.
* @param options - Optional force flag to log even when debug mode is disabled.
* @source
*/
const recordEvent = useCallback(
(entry: DebugEventRecord, options?: RecordEventOptions) => {
const shouldRecord =
options?.force === true || (isDebugEnabled && isEventLoggerEnabled);
if (!shouldRecord) {
return;
}
const timestamp = entry.timestamp ?? new Date().toISOString();
let id = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const cryptoApi = globalThis.crypto;
if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
id = cryptoApi.randomUUID();
}
const normalised: DebugEventEntry = {
id,
timestamp,
type: entry.type,
message: entry.message,
level: entry.level,
source: entry.source,
context: entry.context,
metadata: entry.metadata,
tags: entry.tags,
};
setEventLogEntries((previous) => {
const next = [...previous, normalised];
if (next.length > MAX_EVENT_LOG_ENTRIES) {
return next.slice(next.length - MAX_EVENT_LOG_ENTRIES);
}
return next;
});
},
[isEventLoggerEnabled, isDebugEnabled],
);
const clearEventLog = useCallback(() => {
setEventLogEntries([]);
}, []);
// Save debug state to localStorage whenever it changes
const setDebugEnabled = useCallback(
(enabled: boolean) => {
setIsDebugEnabled(enabled);
try {
localStorage.setItem(DEBUG_STORAGE_KEY, JSON.stringify(enabled));
} catch (error) {
console.error("Failed to save debug state to localStorage:", error);
}
recordEvent(
{
type: "debug.mode",
message: enabled ? "Debug mode enabled" : "Debug mode disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
},
{ force: true },
);
},
[recordEvent],
);
const toggleDebug = useCallback(() => {
setDebugEnabled(!isDebugEnabled);
}, [isDebugEnabled, setDebugEnabled]);
const persistFeatureToggles = useCallback(
(updater: (prev: DebugFeatureToggles) => DebugFeatureToggles) => {
setFeatureToggles((prev) => {
const next = updater(prev);
try {
localStorage.setItem(DEBUG_FEATURE_TOGGLES_KEY, JSON.stringify(next));
} catch (error) {
console.error(
"Failed to save debug feature toggles to localStorage:",
error,
);
}
return next;
});
},
[],
);
const setIsStorageDebuggerEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
storageDebugger: enabled,
}));
recordEvent({
type: "debug.storage",
message: enabled
? "Storage debugger enabled"
: "Storage debugger disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
});
},
[persistFeatureToggles, recordEvent],
);
const toggleStorageDebugger = useCallback(() => {
setIsStorageDebuggerEnabled(!isStorageDebuggerEnabled);
}, [setIsStorageDebuggerEnabled, isStorageDebuggerEnabled]);
const setIsLogViewerEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
logViewer: enabled,
}));
recordEvent({
type: "debug.log-viewer",
message: enabled ? "Log viewer enabled" : "Log viewer disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
});
},
[persistFeatureToggles, recordEvent],
);
const toggleLogViewer = useCallback(() => {
setIsLogViewerEnabled(!isLogViewerEnabled);
}, [isLogViewerEnabled, setIsLogViewerEnabled]);
const setIsLogRedactionEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
redactLogs: enabled,
}));
recordEvent({
type: "debug.log-viewer",
message: enabled ? "Log redaction enabled" : "Log redaction disabled",
level: "info",
metadata: { enabled },
});
},
[persistFeatureToggles, recordEvent],
);
const toggleLogRedaction = useCallback(() => {
setIsLogRedactionEnabled(!isLogRedactionEnabled);
}, [isLogRedactionEnabled, setIsLogRedactionEnabled]);
const setIsStateInspectorEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
stateInspector: enabled,
}));
recordEvent({
type: "debug.state-inspector",
message: enabled
? "State inspector enabled"
: "State inspector disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
});
},
[persistFeatureToggles, recordEvent],
);
const toggleStateInspector = useCallback(() => {
setIsStateInspectorEnabled(!isStateInspectorEnabled);
}, [setIsStateInspectorEnabled, isStateInspectorEnabled]);
const setIsIpcViewerEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
ipcViewer: enabled,
}));
recordEvent({
type: "debug.ipc",
message: enabled ? "IPC viewer enabled" : "IPC viewer disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
});
},
[persistFeatureToggles, recordEvent],
);
const toggleIpcViewer = useCallback(() => {
setIsIpcViewerEnabled(!isIpcViewerEnabled);
}, [isIpcViewerEnabled, setIsIpcViewerEnabled]);
const setIsEventLoggerEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
eventLogger: enabled,
}));
recordEvent(
{
type: "debug.event-logger",
message: enabled ? "Event logger enabled" : "Event logger disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
},
{ force: true },
);
},
[persistFeatureToggles, recordEvent],
);
const toggleEventLogger = useCallback(() => {
setIsEventLoggerEnabled(!isEventLoggerEnabled);
}, [isEventLoggerEnabled, setIsEventLoggerEnabled]);
const setIsConfidenceTestExporterEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
confidenceTestExporter: enabled,
}));
recordEvent(
{
type: "debug.confidence-test-exporter",
message: enabled
? "Confidence test exporter enabled"
: "Confidence test exporter disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
},
{ force: true },
);
},
[persistFeatureToggles, recordEvent],
);
const toggleConfidenceTestExporter = useCallback(() => {
setIsConfidenceTestExporterEnabled(!isConfidenceTestExporterEnabled);
}, [isConfidenceTestExporterEnabled, setIsConfidenceTestExporterEnabled]);
const setIsPerformanceMonitorEnabled = useCallback(
(enabled: boolean) => {
persistFeatureToggles((prev) => ({
...prev,
performanceMonitor: enabled,
}));
recordEvent(
{
type: "debug.performance-monitor",
message: enabled
? "Performance monitor enabled"
: "Performance monitor disabled",
level: enabled ? "info" : "warn",
metadata: { enabled },
},
{ force: true },
);
},
[persistFeatureToggles, recordEvent],
);
const togglePerformanceMonitor = useCallback(() => {
setIsPerformanceMonitorEnabled(!isPerformanceMonitorEnabled);
}, [isPerformanceMonitorEnabled, setIsPerformanceMonitorEnabled]);
const flushPendingApiSamples = useCallback(() => {
const samples = pendingApiSamplesRef.current;
if (samples.length === 0) return;
// Collect high-latency events to record after state update
const highLatencyEvents: Array<{
duration: number;
correlationId?: string;
provider?: string;
endpoint?: string;
}> = [];
setPerformanceMetrics((prev) => {
// Validate array before adding
if (!Array.isArray(prev.api.recentLatencies)) {
console.warn("[DebugContext] recentLatencies is not an array");
return prev;
}
const newLatencies = prev.api.recentLatencies.filter(Number.isFinite);
let newSamples: ApiLatencySample[] = [...prev.api.recentSamples];
let totalRequests = prev.api.totalRequests;
let successfulRequests = prev.api.successfulRequests;
let failedRequests = prev.api.failedRequests;
// Process all pending samples
for (const sample of samples) {
totalRequests += 1;
if (sample.success) {
successfulRequests += 1;
} else {
failedRequests += 1;
}
newLatencies.push(sample.duration);
// Use lowercase string for provider to ensure consistency in charts
const providerKey = String(sample.provider ?? "anilist").toLowerCase();
newSamples.push({
duration: sample.duration,
provider: providerKey,
endpoint: sample.endpoint,
});
// Collect high latency events for recording after state update
if (sample.duration > 2000) {
highLatencyEvents.push({
duration: sample.duration,
correlationId: sample.correlationId,
provider: sample.provider,
endpoint: sample.endpoint,
});
}
}
// Keep last 100 samples
const latenciesSliced = newLatencies.slice(-100);
newSamples = newSamples.slice(-100);
// Calculate new average safely
const newAverage =
latenciesSliced.length > 0
? latenciesSliced.reduce((sum, val) => sum + val, 0) /
latenciesSliced.length
: 0;
// Use finite sentinel for min latency to avoid Infinity
const currentMin =
Number.isFinite(prev.api.minLatency) && prev.api.minLatency < Infinity
? prev.api.minLatency
: Number.MAX_SAFE_INTEGER;
return {
...prev,
api: {
totalRequests,
successfulRequests,
failedRequests,
averageLatency: Math.round(newAverage * 100) / 100,
minLatency: Math.min(currentMin, ...latenciesSliced),
maxLatency: Math.max(prev.api.maxLatency, ...latenciesSliced),
recentLatencies: latenciesSliced,
recentSamples: newSamples,
errorRate: (failedRequests / totalRequests) * 100,
},
};
});
// Record high latency events after state update completes
for (const event of highLatencyEvents) {
recordEvent({
type: "performance.api-latency",
message: `High API latency detected: ${event.duration.toFixed(0)}ms`,
level: "warn",
metadata: {
duration: event.duration,
correlationId: event.correlationId,
provider: event.provider,
endpoint: event.endpoint,
},
});
}
// Clear pending samples after flush
pendingApiSamplesRef.current = [];
}, [recordEvent]);
const throttledFlush = useMemo(
() => throttle(flushPendingApiSamples, 250),
[flushPendingApiSamples],
);
const recordApiLatency = useCallback(
(
duration: number,
success: boolean,
correlationId?: string,
provider?: string,
endpoint?: string,
) => {
if (!isPerformanceMonitorEnabled) return;
// Validate duration: ignore non-finite values, clamp at 0
if (!Number.isFinite(duration) || duration < 0) {
console.warn("[DebugContext] Invalid API latency duration:", duration);
return;
}
// Push sample to pending ref
pendingApiSamplesRef.current.push({
duration,
success,
correlationId,
provider,
endpoint,
});
// Schedule throttled flush
throttledFlush();
},
[isPerformanceMonitorEnabled, throttledFlush],
);
const recordCacheAccess = useCallback(
(hit: boolean) => {
if (!isPerformanceMonitorEnabled) return;
setPerformanceMetrics((prev) => {
const newHits = hit ? prev.cache.hits + 1 : prev.cache.hits;
const newMisses = hit ? prev.cache.misses : prev.cache.misses + 1;
const total = newHits + newMisses;
return {
...prev,
cache: {
...prev.cache,
hits: newHits,
misses: newMisses,
hitRate: total > 0 ? (newHits / total) * 100 : 0,
},
};
});
},
[isPerformanceMonitorEnabled],
);
const recordMatchingProgress = useCallback(
(current: number, total: number, elapsedMs: number) => {
if (!isPerformanceMonitorEnabled) return;
setPerformanceMetrics((prev) => {
const speed = elapsedMs > 0 ? (current / elapsedMs) * 60000 : 0; // titles per minute
return {
...prev,
matching: {
totalMatched: current,
averageSpeed: Math.round(speed * 10) / 10,
currentSpeed: Math.round(speed * 10) / 10,
totalDuration: elapsedMs,
lastUpdateTimestamp: Date.now(),
},
};
});
},
[isPerformanceMonitorEnabled],
);
const updateMemoryStats = useCallback(
(stats: MemoryMetrics) => {
if (!isPerformanceMonitorEnabled) return;
setPerformanceMetrics((prev) => {
const newHistory = [...prev.memory.history, stats].slice(-50); // Keep last 50 samples
return {
...prev,
memory: {
current: stats,
history: newHistory,
},
};
});
// Warn if memory usage is high (>500MB private)
if (stats.private > 512000) {
recordEvent({
type: "performance.memory-high",
message: `High memory usage: ${(stats.private / 1024).toFixed(0)}MB`,
level: "warn",
metadata: { private: stats.private, heap: stats.heap },
});
}
},
[isPerformanceMonitorEnabled, recordEvent],
);
const resetPerformanceMetrics = useCallback(() => {
setPerformanceMetrics(defaultPerformanceMetrics);
recordEvent({
type: "debug.performance-monitor",
message: "Performance metrics reset",
level: "info",
});
}, [recordEvent]);
const exportPerformanceReport = useCallback(async () => {
try {
const sessionDuration = Date.now() - performanceMetrics.sessionStartTime;
const payload = {
exportedAt: new Date().toISOString(),
appVersion: import.meta.env.VITE_APP_VERSION || "unknown",
sessionDuration,
metrics: performanceMetrics,
};
await exportToJson(payload, "kenmei-performance-report");
recordEvent({
type: "debug.performance-monitor",
message: "Performance report exported",
level: "info",
metadata: { sessionDuration },
});
} catch (error) {
console.error("Failed to export performance report:", error);
recordEvent(
{
type: "debug.performance-monitor",
message: "Failed to export performance report",
level: "error",
metadata: {
error: error instanceof Error ? error.message : String(error),
},
},
{ force: true },
);
}
}, [performanceMetrics, recordEvent]);
// Debug menu open/close state
const openDebugMenu = useCallback(() => setDebugMenuOpen(true), []);
const closeDebugMenu = useCallback(() => setDebugMenuOpen(false), []);
const toggleDebugMenu = useCallback(() => setDebugMenuOpen((v) => !v), []);
const registerStateInspector = useCallback(
<T,>(config: StateInspectorRegistration<T>): StateInspectorHandle<T> => {
const serialize = config.serialize
? (value: unknown) => config.serialize!(value as T)
: (value: unknown) => value;
const deserialize = config.deserialize
? (value: unknown) => config.deserialize!(value) as unknown
: (value: unknown) => value;
const getSnapshot = () => config.getSnapshot() as unknown;
const setSnapshot = config.setSnapshot
? (value: unknown) => {
(config.setSnapshot as (next: T) => void)(value as T);
}
: undefined;
const initialRaw = getSnapshot();
const initialDisplay = serialize(initialRaw);
const internal: StateInspectorSourceInternal = {
id: config.id,
label: config.label,
description: config.description,
group: config.group ?? "General",
getSnapshot,
setSnapshot,
serialize,
deserialize,
latestRawValue: initialRaw,
latestDisplayValue: initialDisplay,
lastUpdated: Date.now(),
};
const nextSources = new Map(stateSourcesRef.current);
nextSources.set(config.id, internal);
stateSourcesRef.current = nextSources;
setStateSourceSnapshots(toStateInspectorSnapshots(nextSources));
recordEvent({
type: "debug.state-inspector",
message: `Registered state inspector '${config.label}'`,
level: "debug",
metadata: { id: config.id },
});
return {
publish: (value: T) => {
const current = stateSourcesRef.current.get(config.id);
if (!current) return;
const next = {
...current,
latestRawValue: value,
latestDisplayValue: current.serialize(value as unknown),
lastUpdated: Date.now(),
} satisfies StateInspectorSourceInternal;
const map = new Map(stateSourcesRef.current);
map.set(config.id, next);
stateSourcesRef.current = map;
setStateSourceSnapshots(toStateInspectorSnapshots(map));
},
unregister: () => {
const current = new Map(stateSourcesRef.current);
current.delete(config.id);
stateSourcesRef.current = current;
setStateSourceSnapshots(toStateInspectorSnapshots(current));
recordEvent({
type: "debug.state-inspector",
message: `Unregistered state inspector '${config.label}'`,
level: "debug",
metadata: { id: config.id },
});
},
};
},
[recordEvent],
);
const refreshStateInspectorSource = useCallback((id: string) => {
const source = stateSourcesRef.current.get(id);
if (!source) return;
try {
const snapshot = source.getSnapshot();
const map = new Map(stateSourcesRef.current);
map.set(id, {
...source,
latestRawValue: snapshot,
latestDisplayValue: source.serialize(snapshot),
lastUpdated: Date.now(),
});
stateSourcesRef.current = map;
setStateSourceSnapshots(toStateInspectorSnapshots(map));
} catch (error) {
console.error("Failed to refresh state inspector source", {
id,
error,
});
}
}, []);
const applyStateInspectorUpdate = useCallback(
(id: string, value: unknown) => {
const source = stateSourcesRef.current.get(id);
if (!source) {
throw new Error(`Unknown state inspector source: ${id}`);
}
if (!source.setSnapshot) {
throw new Error(`State inspector source '${id}' is read-only`);
}
const nextValue = source.deserialize(value);
try {
source.setSnapshot(nextValue);
const refreshed = source.getSnapshot();
const map = new Map(stateSourcesRef.current);
map.set(id, {
...source,
latestRawValue: refreshed,
latestDisplayValue: source.serialize(refreshed),
lastUpdated: Date.now(),
});
stateSourcesRef.current = map;
setStateSourceSnapshots(toStateInspectorSnapshots(map));
recordEvent({
type: "debug.state-inspector",
message: `State inspector '${source.label ?? id}' updated`,
level: "debug",
metadata: { id },
});
} catch (error) {
console.error("Failed to apply state inspector update", {
id,
error,
});
throw error;
}
},
[recordEvent],
);
useEffect(() => {
const settingsId = "settings-state";
try {
const handle = registerStateInspector<SettingsDebugSnapshot>({
id: settingsId,
label: "Application Settings",
description:
"Persisted sync and matching configuration stored in local preferences.",
group: "Settings",
getSnapshot: () => {
const matchConfig = getMatchConfig();
return {
syncConfig: getSyncConfig(),
matchConfig: matchConfig,
customRules: matchConfig.customRules,
};
},
setSnapshot: (snapshot) => {
if (snapshot.syncConfig) {
saveSyncConfig(snapshot.syncConfig);
}
if (snapshot.matchConfig) {
saveMatchConfig(snapshot.matchConfig);
}
// If customRules are provided separately, merge them back into matchConfig
if (snapshot.customRules && snapshot.matchConfig) {
snapshot.matchConfig.customRules = snapshot.customRules;
saveMatchConfig(snapshot.matchConfig);
}
},
serialize: (snapshot) => ({
syncConfig: snapshot.syncConfig,
matchConfig: snapshot.matchConfig,
customRules: snapshot.customRules,
}),
deserialize: (value) => {
const candidate = value as Partial<SettingsDebugSnapshot> | null;
const matchConfig = candidate?.matchConfig ?? getMatchConfig();
return {
syncConfig: candidate?.syncConfig ?? getSyncConfig(),
matchConfig: matchConfig,
customRules: candidate?.customRules ?? matchConfig.customRules,
} satisfies SettingsDebugSnapshot;
},
});
return () => {
handle.unregister();
};
} catch (error) {
console.error("Failed to register settings state inspector", error);
return undefined;
}
}, [registerStateInspector]);
const clearLogs = useCallback(() => {
logCollector.clear();
recordEvent({
type: "debug.log-viewer",
message: "Console log buffer cleared",
level: "warn",
});
}, [recordEvent]);
const clearIpcEvents = useCallback(() => {
if (globalThis.window === undefined) return;
globalThis.electronDebug?.ipc.clear();
recordEvent({
type: "debug.ipc",
message: "IPC log cleared",
level: "warn",
});
}, [recordEvent]);
const exportLogs = useCallback(async () => {
const entries = logCollector.getEntries();
if (!entries.length) {
console.warn("No debug logs available to export");
return;
}
try {
const payload = {
exportedAt: new Date().toISOString(),
userAgent:
typeof navigator === "undefined" ? undefined : navigator.userAgent,
totalEntries: entries.length,
maxEntries: MAX_LOG_ENTRIES,
logs: serializeLogEntries(entries),
};
await exportToJson(payload, "kenmei-debug-logs");
recordEvent({
type: "debug.log-viewer",
message: "Console logs exported",
level: "info",
metadata: { totalEntries: entries.length },
});
} catch (error) {
console.error("Failed to export debug logs:", error);
recordEvent(
{
type: "debug.log-viewer",
message: "Failed to export debug logs",
level: "error",
metadata: {
error: error instanceof Error ? error.message : String(error),
},
},
{ force: true },
);
}
}, [recordEvent]);
// Poll memory stats when performance monitor is enabled
useEffect(() => {
if (!isDebugEnabled || !isPerformanceMonitorEnabled) {
if (memoryPollingIntervalRef.current) {
clearInterval(memoryPollingIntervalRef.current);
memoryPollingIntervalRef.current = null;
}
return;
}
const pollMemory = async () => {
try {
if (globalThis.electronDebug?.getMemoryStats) {
const stats = await globalThis.electronDebug.getMemoryStats();
updateMemoryStats(stats);
}
} catch (error) {
console.error("Failed to poll memory stats:", error);
}
};
// Poll immediately, then every 2.5 seconds
pollMemory();
memoryPollingIntervalRef.current = setInterval(pollMemory, 2500);
return () => {
if (memoryPollingIntervalRef.current) {
clearInterval(memoryPollingIntervalRef.current);
memoryPollingIntervalRef.current = null;
}
};
}, [isDebugEnabled, isPerformanceMonitorEnabled, updateMemoryStats]);
// Monitor FPS when performance monitor is enabled
useEffect(() => {
if (!isDebugEnabled || !isPerformanceMonitorEnabled) {
// Cancel throttled flush and clear pending samples on disable
throttledFlush.cancel?.();
pendingApiSamplesRef.current = [];
if (fpsMonitorRef.current) {
fpsMonitorRef.current.stop();
fpsMonitorRef.current = null;
}
return;
}
const fpsMonitor = new FPSMonitor({
threshold: 30,
sampleSize: 60,
onLowFPS: (fps) => {
recordEvent({
type: "performance.low-fps",
message: `Low FPS detected: ${fps.toFixed(1)}`,
level: "warn",
metadata: { fps },
});
},
});
fpsMonitorRef.current = fpsMonitor;
fpsMonitor.start();
// Update FPS state every second
const fpsInterval = setInterval(() => {
setCurrentFPS(fpsMonitor.getAverageFPS());
}, 1000);
return () => {
clearInterval(fpsInterval);
// Cancel throttled flush and clear pending samples on unmount
throttledFlush.cancel?.();
pendingApiSamplesRef.current = [];
if (fpsMonitorRef.current) {
fpsMonitorRef.current.stop();
fpsMonitorRef.current = null;
}
};
}, [
isDebugEnabled,
isPerformanceMonitorEnabled,
recordEvent,
throttledFlush,
]);
// Listen for API performance events
useEffect(() => {
if (!isDebugEnabled || !isPerformanceMonitorEnabled) return;
const handleApiPerformance = (event: Event) => {
const customEvent = event as CustomEvent<{
duration: number;
succeeded: boolean;
requestId?: string;
provider?: string;
endpoint?: string;
}>;
const { duration, succeeded, requestId, provider, endpoint } =
customEvent.detail;
recordApiLatency(duration, succeeded, requestId, provider, endpoint);
};
globalThis.addEventListener(
"anilist:request:performance",
handleApiPerformance,
);
globalThis.addEventListener(
"anilist:request:completed",
handleApiPerformance,
);
// Listen for generic API requests (from manga sources, etc.)
const handleGenericApiRequest = (event: Event) => {
const customEvent = event as CustomEvent<{
duration: number;
succeeded: boolean;
provider?: string;
endpoint?: string;
}>;
const { duration, succeeded, provider, endpoint } = customEvent.detail;
recordApiLatency(duration, succeeded, undefined, provider, endpoint);
};
globalThis.addEventListener(
"api:request:completed",
handleGenericApiRequest,
);
return () => {
globalThis.removeEventListener(
"anilist:request:performance",
handleApiPerformance,
);
globalThis.removeEventListener(
"anilist:request:completed",
handleApiPerformance,
);
globalThis.removeEventListener(
"api:request:completed",
handleGenericApiRequest,
);
};
}, [isDebugEnabled, isPerformanceMonitorEnabled, recordApiLatency]);
// Listen for cache hit/miss events
useEffect(() => {
if (!isDebugEnabled || !isPerformanceMonitorEnabled) return;
const handleCacheHit = () => recordCacheAccess(true);
const handleCacheMiss = () => recordCacheAccess(false);
globalThis.addEventListener("matching:cache-hit", handleCacheHit);
globalThis.addEventListener("matching:cache-miss", handleCacheMiss);
return () => {
globalThis.removeEventListener("matching:cache-hit", handleCacheHit);
globalThis.removeEventListener("matching:cache-miss", handleCacheMiss);
};
}, [isDebugEnabled, isPerformanceMonitorEnabled, recordCacheAccess]);
// Listen for matching progress events
useEffect(() => {
if (!isDebugEnabled || !isPerformanceMonitorEnabled) return;
const handleMatchingProgress = (event: Event) => {
const customEvent = event as CustomEvent<{
current: number;
total: number;
elapsedMs: number;
}>;
const { current, total, elapsedMs } = customEvent.detail;
recordMatchingProgress(current, total, elapsedMs);
};
globalThis.addEventListener(
"matching:progress-update",
handleMatchingProgress,
);
return () => {
globalThis.removeEventListener(
"matching:progress-update",
handleMatchingProgress,
);
};
}, [isDebugEnabled, isPerformanceMonitorEnabled, recordMatchingProgress]);
const stateContextValue = React.useMemo<DebugStateContextValue>(
() => ({
isDebugEnabled,
debugMenuOpen,
isStorageDebuggerEnabled,
isLogViewerEnabled,
isLogRedactionEnabled,
isStateInspectorEnabled,
stateInspectorSources: stateSourceSnapshots,
isIpcViewerEnabled,
isEventLoggerEnabled,
isConfidenceTestExporterEnabled,
isPerformanceMonitorEnabled,
performanceMetrics,
currentFPS,
eventLogEntries,
maxEventLogEntries: MAX_EVENT_LOG_ENTRIES,
ipcEvents,
maxIpcEntries,
logEntries,
maxLogEntries: MAX_LOG_ENTRIES,
}),
[
currentFPS,
debugMenuOpen,
eventLogEntries,
isEventLoggerEnabled,
setIsConfidenceTestExporterEnabled,
isPerformanceMonitorEnabled,
performanceMetrics,
ipcEvents,
isIpcViewerEnabled,
isDebugEnabled,
logEntries,
isLogRedactionEnabled,
isLogViewerEnabled,
maxIpcEntries,
isStateInspectorEnabled,
stateSourceSnapshots,
isStorageDebuggerEnabled,
],
);
const actionsContextValue = React.useMemo<DebugActionsContextValue>(
() => ({
toggleDebug,
setDebugEnabled,
openDebugMenu,
closeDebugMenu,
toggleDebugMenu,
setIsStorageDebuggerEnabled,
toggleStorageDebugger,
setIsLogViewerEnabled,
toggleLogViewer,
setIsLogRedactionEnabled,
toggleLogRedaction,
setIsStateInspectorEnabled,
toggleStateInspector,
registerStateInspector,
applyStateInspectorUpdate,
refreshStateInspectorSource,
setIsIpcViewerEnabled,
toggleIpcViewer,
setIsEventLoggerEnabled,
toggleEventLogger,
setIsConfidenceTestExporterEnabled,
toggleConfidenceTestExporter,
setIsPerformanceMonitorEnabled,
togglePerformanceMonitor,
recordApiLatency,
recordCacheAccess,
recordMatchingProgress,
updateMemoryStats,
resetPerformanceMetrics,
exportPerformanceReport,
recordEvent,
clearEventLog,
clearIpcEvents,
clearLogs,
exportLogs,
}),
[
applyStateInspectorUpdate,
clearEventLog,
clearIpcEvents,
clearLogs,
exportLogs,
exportPerformanceReport,
recordApiLatency,
recordCacheAccess,
recordEvent,
recordMatchingProgress,
refreshStateInspectorSource,
registerStateInspector,
resetPerformanceMetrics,
setIsConfidenceTestExporterEnabled,
setDebugEnabled,
setIsEventLoggerEnabled,
setIsIpcViewerEnabled,
setIsLogRedactionEnabled,
setIsLogViewerEnabled,
setIsPerformanceMonitorEnabled,
setIsStateInspectorEnabled,
setIsStorageDebuggerEnabled,
toggleConfidenceTestExporter,
toggleDebug,
toggleEventLogger,
toggleIpcViewer,
toggleLogRedaction,
toggleLogViewer,
togglePerformanceMonitor,
toggleStateInspector,
toggleStorageDebugger,
updateMemoryStats,
],
);
const legacyContextValue = React.useMemo<DebugContextType>(
() => ({
...stateContextValue,
...actionsContextValue,
}),
[actionsContextValue, stateContextValue],
);
return (
<DebugActionsContext.Provider value={actionsContextValue}>
<DebugStateContext.Provider value={stateContextValue}>
<DebugContext.Provider value={legacyContextValue}>
{children}
</DebugContext.Provider>
</DebugStateContext.Provider>
</DebugActionsContext.Provider>
);
}
Provides comprehensive debug context to child components with split context pattern. Manages debug mode, feature toggles, console log interception, IPC monitoring, state inspection, event logging, and performance metrics collection.
Features: