From c1f1b6566c79a462d0d93fc98e4917f97eb88572 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:46:11 -0400 Subject: [PATCH] logging --- src/main/index.ts | 118 +++++++++++++++++++++++- src/preload/index.ts | 7 +- src/renderer/components/BottomBar.tsx | 32 +++++++ src/renderer/env.d.ts | 5 +- src/renderer/stores/performanceStore.ts | 73 ++++++++++++++- src/types/performance.ts | 12 +++ 6 files changed, 242 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 314360d..fea68fe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, Menu, screen, session, shell } from 'electron' import type { BrowserWindowConstructorOptions, MenuItemConstructorOptions, OpenDialogOptions, WebContents } from 'electron' +import { appendFile, mkdir, writeFile } from 'node:fs/promises' import { extname, join, resolve } from 'path' import type { AstraControlCommand, @@ -22,7 +23,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import { RESIZE_DIRECTIONS, type ResizeDirection } from '../types/windowResize' -import type { PerformanceMemorySnapshot } from '../types/performance' +import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' import { normalizeProfile } from '../shared/profileState' import { calculateResizedWindowBounds } from '../shared/windowResize' import { FileBackedProfileLibrary } from './profileLibrary' @@ -70,6 +71,12 @@ const POPOUT_DEFAULTS = { minWidth: 220, minHeight: 160, } +const MEMORY_LOG_DIRECTORY_NAME = 'Prism Logs' +const MEMORY_LOG_FILE_NAME = 'memory-trace-latest.csv' +const memoryLogSessionStartedAt = Date.now() +let memoryLogInitialized = false +let memoryLogInitialization: Promise | null = null +let memoryLogWriteQueue: Promise = Promise.resolve() function kilobytesToMegabytes(value: number | undefined): number { return Math.round((((value ?? 0) / 1024) * 10)) / 10 @@ -90,6 +97,101 @@ function sumWorkingSet(metrics: Electron.ProcessMetric[], predicate: (metric: El return kilobytesToMegabytes(totalKilobytes) } +function getMemoryLogDirectory(): string { + return join(app.getPath('documents'), MEMORY_LOG_DIRECTORY_NAME) +} + +function getMemoryLogPath(): string { + return join(getMemoryLogDirectory(), MEMORY_LOG_FILE_NAME) +} + +function escapeCsvField(value: string | number | boolean | null): string { + if (value === null) { + return '' + } + + const text = String(value) + if (!/[",\n]/.test(text)) { + return text + } + + return `"${text.replace(/"/g, '""')}"` +} + +async function ensureMemoryLogFile(): Promise { + if (memoryLogInitialized) { + return + } + + if (!memoryLogInitialization) { + memoryLogInitialization = (async () => { + await mkdir(getMemoryLogDirectory(), { recursive: true }) + const header = [ + 'session_started_at_iso', + 'captured_at_iso', + 'elapsed_s', + 'app_mb', + 'main_mb', + 'renderer_mb', + 'renderer_private_mb', + 'gpu_mb', + 'utility_mb', + 'js_heap_used_mb', + 'js_heap_limit_mb', + 'renderer_delta_mb', + 'app_delta_mb', + 'frame_target', + 'docked_render_fps', + 'is_capturing', + 'capture_status', + 'visible_scopes', + 'popped_out_scopes', + ].join(',') + await writeFile(getMemoryLogPath(), `${header}\n`, 'utf8') + memoryLogInitialized = true + })().finally(() => { + memoryLogInitialization = null + }) + } + + await memoryLogInitialization +} + +function queueMemoryLogWrite(record: PerformanceMemoryLogRecord): void { + const line = [ + new Date(memoryLogSessionStartedAt).toISOString(), + new Date(record.capturedAt).toISOString(), + record.elapsedSeconds.toFixed(1), + record.appMb.toFixed(1), + record.mainMb.toFixed(1), + record.rendererMb.toFixed(1), + record.rendererPrivateMb === null ? '' : record.rendererPrivateMb.toFixed(1), + record.gpuMb.toFixed(1), + record.utilityMb.toFixed(1), + record.jsHeapUsedMb === null ? '' : record.jsHeapUsedMb.toFixed(1), + record.jsHeapLimitMb === null ? '' : record.jsHeapLimitMb.toFixed(1), + record.rendererDeltaMb.toFixed(1), + record.appDeltaMb.toFixed(1), + record.frameTarget, + Math.round(record.dockedRenderFps), + record.isCapturing, + record.captureStatus, + record.visibleScopes.join('|'), + record.poppedOutScopes.join('|'), + ] + .map((field) => escapeCsvField(field)) + .join(',') + + memoryLogWriteQueue = memoryLogWriteQueue + .then(async () => { + await ensureMemoryLogFile() + await appendFile(getMemoryLogPath(), `${line}\n`, 'utf8') + }) + .catch((error: unknown) => { + console.error('Failed to write memory trace log:', error) + }) +} + function getProfileLibrary(): FileBackedProfileLibrary { if (!profileLibrary) { profileLibrary = new FileBackedProfileLibrary( @@ -923,6 +1025,16 @@ function setupIPC(): void { return getWindowFromSender(event.sender)?.isAlwaysOnTop() ?? true }) + ipcMain.handle('performance:get-memory-log-path', async () => { + await ensureMemoryLogFile() + return getMemoryLogPath() + }) + + ipcMain.handle('performance:reveal-memory-log', async () => { + await ensureMemoryLogFile() + shell.showItemInFolder(getMemoryLogPath()) + }) + ipcMain.handle('performance:get-memory-snapshot', async (event): Promise => { const metrics = app.getAppMetrics() const senderPid = event.sender.getOSProcessId() @@ -942,6 +1054,10 @@ function setupIPC(): void { } }) + ipcMain.handle('performance:append-memory-log', async (_event, record: PerformanceMemoryLogRecord) => { + queueMemoryLogWrite(record) + }) + ipcMain.handle('audio:get-desktop-sources', async () => { const sources = await desktopCapturer.getSources({ types: ['screen'] }) return sources.map((s) => ({ id: s.id, name: s.name })) diff --git a/src/preload/index.ts b/src/preload/index.ts index 64b7be6..766bc2a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,7 +28,7 @@ import type { } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' import type { VisualizerDSP } from '../renderer/audio/native/visualizer-dsp' -import type { PerformanceMemorySnapshot } from '../types/performance' +import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' type NativeAddonModule = VisualizerDSP & NativeCaptureAPI @@ -50,6 +50,11 @@ contextBridge.exposeInMainWorld('electronAPI', { repositionWindow: (position: 'top' | 'bottom') => ipcRenderer.send('window:reposition', position), toggleAlwaysOnTop: () => ipcRenderer.send('window:toggle-always-on-top'), isAlwaysOnTop: () => ipcRenderer.invoke('window:is-always-on-top'), + getPerformanceMemoryLogPath: () => ipcRenderer.invoke('performance:get-memory-log-path') as Promise, + revealPerformanceMemoryLog: () => ipcRenderer.invoke('performance:reveal-memory-log') as Promise, + appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => { + return ipcRenderer.invoke('performance:append-memory-log', record) as Promise + }, getPerformanceMemorySnapshot: async () => { const snapshot = await ipcRenderer.invoke('performance:get-memory-snapshot') as PerformanceMemorySnapshot try { diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 27d7561..98c16c6 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -40,6 +40,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): const rootRef = useRef(null) const [astraBaseUrlInput, setAstraBaseUrlInput] = useState('') const [astraTokenInput, setAstraTokenInput] = useState('') + const [memoryLogPath, setMemoryLogPath] = useState('') const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const scopeOrder = useSettingsStore((s) => s.scopeOrder) @@ -95,6 +96,26 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): setAstraTokenInput(astraState.config.token) }, [astraState.config.baseUrl, astraState.config.token]) + useEffect(() => { + let disposed = false + void (async () => { + try { + const path = await window.electronAPI.getPerformanceMemoryLogPath() + if (!disposed) { + setMemoryLogPath(path) + } + } catch { + if (!disposed) { + setMemoryLogPath('') + } + } + })() + + return () => { + disposed = true + } + }, []) + useLayoutEffect(() => { if (!onHeightChange || !rootRef.current) return @@ -440,6 +461,17 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
{roundedDockedRenderFps} FPS
+ + {memorySample ? ( diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index fbcebaa..90246e1 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -29,7 +29,7 @@ import type { ThemeLibrarySnapshot, } from '../types/theme' import type { ResizeDirection } from '../types/windowResize' -import type { PerformanceMemorySnapshot } from '../types/performance' +import type { PerformanceMemoryLogRecord, PerformanceMemorySnapshot } from '../types/performance' declare global { interface Window { @@ -48,6 +48,9 @@ declare global { repositionWindow: (position: 'top' | 'bottom') => void toggleAlwaysOnTop: () => void isAlwaysOnTop: () => Promise + getPerformanceMemoryLogPath: () => Promise + revealPerformanceMemoryLog: () => Promise + appendPerformanceMemoryLog: (record: PerformanceMemoryLogRecord) => Promise getPerformanceMemorySnapshot: () => Promise getDesktopSources: () => Promise<{ id: string; name: string }[]> getCaptureBackendSupport: () => Promise diff --git a/src/renderer/stores/performanceStore.ts b/src/renderer/stores/performanceStore.ts index ee27ce7..79ed706 100644 --- a/src/renderer/stores/performanceStore.ts +++ b/src/renderer/stores/performanceStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand' import { isVisualizerFrameTarget, + type PerformanceMemoryLogRecord, type PerformanceMemorySnapshot, type VisualizerFrameTarget, } from '../../types/performance' @@ -88,6 +89,17 @@ interface ChromiumPerformanceMemory { let memoryMonitorTimer: ReturnType | null = null let memoryMonitorRefCount = 0 let memoryMonitorInFlight = false +let contextStoreAccessorsPromise: Promise<{ + getAudioState: () => { + isCapturing: boolean + captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + } + getSettingsState: () => { + scopeOrder: string[] + hiddenScopes: Set + scopePopouts: Record + } +}> | null = null function isMainWindowContext(): boolean { if (typeof window === 'undefined') { @@ -117,6 +129,34 @@ async function collectMemorySnapshot(): Promise { + isCapturing: boolean + captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + } + getSettingsState: () => { + scopeOrder: string[] + hiddenScopes: Set + scopePopouts: Record + } +} | null> { + if (typeof window === 'undefined') { + return null + } + + if (!contextStoreAccessorsPromise) { + contextStoreAccessorsPromise = Promise.all([ + import('./audioStore'), + import('./settingsStore'), + ]).then(([audioStoreModule, settingsStoreModule]) => ({ + getAudioState: () => audioStoreModule.useAudioStore.getState(), + getSettingsState: () => settingsStoreModule.useSettingsStore.getState(), + })) + } + + return contextStoreAccessorsPromise +} + async function sampleMemory(): Promise { if (memoryMonitorInFlight) { return @@ -129,6 +169,8 @@ async function sampleMemory(): Promise { return } + const contextStoreAccessors = await getContextStoreAccessors() + let logRecord: PerformanceMemoryLogRecord | null = null usePerformanceStore.setState((state) => { const baseline = state.memoryHistory[0] ?? snapshot const nextHistory = [...state.memoryHistory, snapshot] @@ -138,14 +180,41 @@ async function sampleMemory(): Promise { const baselineRendererMb = baseline.rendererPrivateMb ?? baseline.rendererMb const currentRendererMb = snapshot.rendererPrivateMb ?? snapshot.rendererMb + const rendererDeltaMb = Math.round((currentRendererMb - baselineRendererMb) * 10) / 10 + const appDeltaMb = Math.round((snapshot.appMb - baseline.appMb) * 10) / 10 + const settingsState = contextStoreAccessors?.getSettingsState() + const audioState = contextStoreAccessors?.getAudioState() + const visibleScopes = settingsState + ? settingsState.scopeOrder.filter((kind) => !settingsState.hiddenScopes.has(kind)) + : [] + const poppedOutScopes = settingsState + ? settingsState.scopeOrder.filter((kind) => settingsState.scopePopouts[kind]?.poppedOut) + : [] + + logRecord = { + ...snapshot, + elapsedSeconds: Math.round(((snapshot.capturedAt - baseline.capturedAt) / 100)) / 10, + rendererDeltaMb, + appDeltaMb, + frameTarget: state.frameTarget, + dockedRenderFps: state.dockedRenderFps, + isCapturing: audioState?.isCapturing ?? false, + captureStatus: audioState?.captureStatus ?? 'idle', + visibleScopes, + poppedOutScopes, + } return { memorySample: snapshot, memoryHistory: nextHistory, - rendererMemoryDeltaMb: Math.round((currentRendererMb - baselineRendererMb) * 10) / 10, - appMemoryDeltaMb: Math.round((snapshot.appMb - baseline.appMb) * 10) / 10, + rendererMemoryDeltaMb: rendererDeltaMb, + appMemoryDeltaMb: appDeltaMb, } }) + + if (logRecord && typeof window !== 'undefined' && typeof window.electronAPI?.appendPerformanceMemoryLog === 'function') { + void window.electronAPI.appendPerformanceMemoryLog(logRecord) + } } finally { memoryMonitorInFlight = false } diff --git a/src/types/performance.ts b/src/types/performance.ts index 9001fd7..b03f8f7 100644 --- a/src/types/performance.ts +++ b/src/types/performance.ts @@ -14,6 +14,18 @@ export interface PerformanceMemorySnapshot { jsHeapLimitMb: number | null } +export interface PerformanceMemoryLogRecord extends PerformanceMemorySnapshot { + elapsedSeconds: number + rendererDeltaMb: number + appDeltaMb: number + frameTarget: VisualizerFrameTarget + dockedRenderFps: number + isCapturing: boolean + captureStatus: 'idle' | 'connecting' | 'capturing' | 'error' + visibleScopes: string[] + poppedOutScopes: string[] +} + export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget { return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget) }