diff --git a/src/plugin/piniaActionTrail.js b/src/plugin/piniaActionTrail.js new file mode 100644 index 00000000..6b0bc6fa --- /dev/null +++ b/src/plugin/piniaActionTrail.js @@ -0,0 +1,141 @@ +const STORAGE_KEY = 'vrcx:sentry:piniaActions'; + +function getStorage() { + try { + return localStorage; + } catch { + return null; + } +} + +function safeJsonParse(value) { + if (!value) return null; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function safeJsonStringify(value, fallback = '[]') { + try { + return JSON.stringify(value); + } catch { + return fallback; + } +} + +export function getPiniaActionTrail() { + const storage = getStorage(); + if (!storage) return []; + + const data = safeJsonParse(storage.getItem(STORAGE_KEY)); + return Array.isArray(data) ? data : []; +} + +export function clearPiniaActionTrail() { + const storage = getStorage(); + if (!storage) return; + storage.removeItem(STORAGE_KEY); +} + +export function appendPiniaActionTrail(entry, maxEntries = 200) { + const storage = getStorage(); + if (!storage) return; + + const existing = getPiniaActionTrail(); + existing.push(entry); + + if (existing.length > maxEntries) { + existing.splice(0, existing.length - maxEntries); + } + + try { + storage.setItem(STORAGE_KEY, safeJsonStringify(existing)); + } catch { + // ignore + } +} + +export function createPiniaActionTrailPlugin({ maxEntries = 200 } = {}) { + if (typeof window !== 'undefined') { + // @ts-ignore + if (!window.__VRCX_PINIA_ACTION_TRAIL__) { + // @ts-ignore + window.__VRCX_PINIA_ACTION_TRAIL__ = true; + + window.addEventListener('beforeunload', () => { + clearPiniaActionTrail(); + }); + } + } + + return ({ store }) => { + store.$onAction(({ name }) => { + appendPiniaActionTrail( + { + ts: Date.now(), + storeId: store.$id, + action: name + }, + maxEntries + ); + }); + }; +} + +function readPerformanceMemory() { + // @ts-ignore + const memory = window?.performance?.memory; + if (!memory) return null; + const { usedJSHeapSize, jsHeapSizeLimit } = memory; + if ( + typeof usedJSHeapSize !== 'number' || + typeof jsHeapSizeLimit !== 'number' + ) { + return null; + } + return { usedJSHeapSize, jsHeapSizeLimit }; +} + +export function startRendererMemoryThresholdReport( + Sentry, + { intervalMs = 10_000, thresholdRatio = 0.8, cooldownMs = 5 * 60_000 } = {} +) { + const initial = readPerformanceMemory(); + if (!initial) return null; + + if (!Sentry?.withScope) return null; + + let lastSent = 0; + + return setInterval(() => { + const m = readPerformanceMemory(); + if (!m) return; + + const ratio = m.usedJSHeapSize / m.jsHeapSizeLimit; + if (!Number.isFinite(ratio) || ratio < thresholdRatio) return; + + const now = Date.now(); + if (now - lastSent < cooldownMs) return; + lastSent = now; + + const trail = getPiniaActionTrail(); + Sentry.withScope((scope) => { + scope.setLevel('warning'); + scope.setTag('reason', 'high-js-heap'); + scope.setContext('memory', { + usedMB: m.usedJSHeapSize / 1024 / 1024, + limitMB: m.jsHeapSizeLimit / 1024 / 1024, + ratio + }); + scope.setContext('pinia_actions', { + trail, + count: trail.length + }); + Sentry.captureMessage( + 'Memory usage critical: nearing JS heap limit' + ); + }); + }, intervalMs); +} diff --git a/src/plugin/sentry.js b/src/plugin/sentry.js index b9957955..7205097c 100644 --- a/src/plugin/sentry.js +++ b/src/plugin/sentry.js @@ -1,4 +1,5 @@ import { router } from './router'; +import { startRendererMemoryThresholdReport } from './piniaActionTrail'; import configRepository from '../service/config'; @@ -81,22 +82,7 @@ export async function initSentry(app) { } return event; }, - beforeSendSpan(span) { - span.data = { - ...span.data, - usedJSHeapSize: - // @ts-ignore - window.performance.memory.usedJSHeapSize / 1024 / 1024, - totalJSHeapSize: - // @ts-ignore - window.performance.memory.totalJSHeapSize / 1024 / 1024, - jsHeapSizeLimit: - // @ts-ignore - window.performance.memory.jsHeapSizeLimit / 1024 / 1024, - vrcxId: vrcxId - }; - return span; - }, + integrations: [ Sentry.replayIntegration({ maskAllText: true, @@ -119,6 +105,12 @@ export async function initSentry(app) { ] }); console.log('Sentry initialized'); + + startRendererMemoryThresholdReport(Sentry, { + thresholdRatio: 0.8, + intervalMs: 10_000, + cooldownMs: 5 * 60_000 + }); } catch (e) { console.error('Failed to initialize Sentry:', e); return; diff --git a/src/stores/index.js b/src/stores/index.js index 792873c2..fc0e85e6 100644 --- a/src/stores/index.js +++ b/src/stores/index.js @@ -1,6 +1,7 @@ import { createPinia } from 'pinia'; import { getSentry, isSentryOptedIn } from '../plugin'; +import { createPiniaActionTrailPlugin } from '../plugin/piniaActionTrail'; import { useAdvancedSettingsStore } from './settings/advanced'; import { useAppearanceSettingsStore } from './settings/appearance'; import { useAuthStore } from './auth'; @@ -38,11 +39,17 @@ import { useWristOverlaySettingsStore } from './settings/wristOverlay'; export const pinia = createPinia(); +function registerPiniaActionTrailPlugin() { + if (!NIGHTLY) return; + pinia.use(createPiniaActionTrailPlugin({ maxEntries: 200 })); +} + async function registerSentryPiniaPlugin() { if (!NIGHTLY) return; if (!(await isSentryOptedIn())) return; const Sentry = await getSentry(); + pinia.use( Sentry.createSentryPiniaPlugin({ stateTransformer: (state) => ({ @@ -114,6 +121,7 @@ async function registerSentryPiniaPlugin() { } export async function initPiniaPlugins() { + registerPiniaActionTrailPlugin(); await registerSentryPiniaPlugin(); } diff --git a/src/stores/vrcx.js b/src/stores/vrcx.js index bb89716d..c76d5ef8 100644 --- a/src/stores/vrcx.js +++ b/src/stores/vrcx.js @@ -10,6 +10,7 @@ import { debounce, parseLocation } from '../shared/utils'; import { AppDebug } from '../service/appConfig'; import { database } from '../service/database'; import { failedGetRequests } from '../service/request'; +import { getPiniaActionTrail } from '../plugin/piniaActionTrail'; import { refreshCustomScript } from '../shared/utils/base/ui'; import { useAdvancedSettingsStore } from './settings/advanced'; import { useAvatarProviderStore } from './avatarProvider'; @@ -527,12 +528,18 @@ export const useVrcxStore = defineStore('Vrcx', () => { if (advancedSettingsStore.sentryErrorReporting) { try { import('@sentry/vue').then((Sentry) => { - Sentry.captureMessage( - `crash message: ${crashMessage}`, - { - level: 'fatal' - } - ); + const trail = getPiniaActionTrail(); + Sentry.withScope((scope) => { + scope.setLevel('fatal'); + scope.setTag('reason', 'crash-recovery'); + scope.setContext('pinia_actions', { + trail, + count: trail.length + }); + Sentry.captureMessage( + `crash message: ${crashMessage}` + ); + }); }); } catch (error) { console.error('Error setting up Sentry feedback:', error);