From 744702ccf63f8f1febe3990c5ba4fd337bbbea07 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot <259247574+Kpa-clawbot@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:49:10 -0700 Subject: [PATCH] feat(perf): show Go Runtime stats instead of Event Loop on Go backend When engine=go, the perf page now renders Go-specific runtime stats (goroutines, GC collections, GC pause times, heap breakdown, CPUs) instead of the misleading Node.js Event Loop metrics. Falls back to the existing Node UI when engine is not 'go' or goRuntime data is missing. Includes color-coded GC pause thresholds. fixes #153 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/index.html | 54 ++++----- public/perf.js | 42 +++++-- test-perf-go-runtime.js | 253 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+), 39 deletions(-) create mode 100644 test-perf-go-runtime.js diff --git a/public/index.html b/public/index.html index 2f5a6d7a..c7efb652 100644 --- a/public/index.html +++ b/public/index.html @@ -22,9 +22,9 @@ - - - + + + @@ -81,29 +81,29 @@
- - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/perf.js b/public/perf.js index 852c89f9..5f1dbc4b 100644 --- a/public/perf.js +++ b/public/perf.js @@ -31,19 +31,37 @@
${server.slowQueries.length}
Slow (>100ms)
`; - // System health (memory, event loop, WS) + // System health (memory, event loop / go runtime, WS) if (health) { - const m = health.memory, el = health.eventLoop; - const elColor = el.p95Ms > 500 ? 'var(--status-red)' : el.p95Ms > 100 ? 'var(--status-yellow)' : 'var(--status-green)'; - const memColor = m.heapUsed > m.heapTotal * 0.85 ? 'var(--status-red)' : m.heapUsed > m.heapTotal * 0.7 ? 'var(--status-yellow)' : 'var(--status-green)'; - html += `

System Health

-
${m.heapUsed}MB
Heap Used / ${m.heapTotal}MB
-
${m.rss}MB
RSS
-
${el.p95Ms}ms
Event Loop p95
-
${el.maxLagMs}ms
EL Max Lag
-
${el.currentLagMs}ms
EL Current
-
${health.websocket.clients}
WS Clients
-
`; + const isGo = health.engine === 'go'; + if (isGo && server.goRuntime) { + const gr = server.goRuntime; + const gcColor = gr.lastPauseMs > 5 ? 'var(--status-red)' : gr.lastPauseMs > 1 ? 'var(--status-yellow)' : 'var(--status-green)'; + html += `

๐Ÿ”ง Go Runtime

+
${gr.goroutines}
Goroutines
+
${gr.numGC}
GC Collections
+
${gr.pauseTotalMs}ms
GC Pause Total
+
${gr.lastPauseMs}ms
Last GC Pause
+
${gr.heapAllocMB}MB
Heap Alloc
+
${gr.heapSysMB}MB
Heap Sys
+
${gr.heapInuseMB}MB
Heap Inuse
+
${gr.heapIdleMB}MB
Heap Idle
+
${gr.numCPU}
CPUs
+
${health.websocket.clients}
WS Clients
+
`; + } else { + const m = health.memory, el = health.eventLoop; + const elColor = el.p95Ms > 500 ? 'var(--status-red)' : el.p95Ms > 100 ? 'var(--status-yellow)' : 'var(--status-green)'; + const memColor = m.heapUsed > m.heapTotal * 0.85 ? 'var(--status-red)' : m.heapUsed > m.heapTotal * 0.7 ? 'var(--status-yellow)' : 'var(--status-green)'; + html += `

System Health

+
${m.heapUsed}MB
Heap Used / ${m.heapTotal}MB
+
${m.rss}MB
RSS
+
${el.p95Ms}ms
Event Loop p95
+
${el.maxLagMs}ms
EL Max Lag
+
${el.currentLagMs}ms
EL Current
+
${health.websocket.clients}
WS Clients
+
`; + } } // Cache stats diff --git a/test-perf-go-runtime.js b/test-perf-go-runtime.js new file mode 100644 index 00000000..f271072f --- /dev/null +++ b/test-perf-go-runtime.js @@ -0,0 +1,253 @@ +/* Tests for perf.js Go runtime vs Node event loop rendering (fixes #153) */ +'use strict'; +const vm = require('vm'); +const fs = require('fs'); +const assert = require('assert'); + +let passed = 0, failed = 0; +function test(name, fn) { + try { fn(); passed++; console.log(` โœ… ${name}`); } + catch (e) { failed++; console.log(` โŒ ${name}: ${e.message}`); } +} + +// Minimal sandbox to run perf.js in a browser-like context +function makeSandbox() { + let capturedHtml = ''; + const pages = {}; + const ctx = { + window: { addEventListener: () => {}, apiPerf: null }, + document: { + getElementById: (id) => { + if (id === 'perfContent') return { set innerHTML(v) { capturedHtml = v; } }; + return null; + }, + addEventListener: () => {}, + }, + console, + Date, Math, Array, Object, String, Number, JSON, RegExp, Error, TypeError, + parseInt, parseFloat, isNaN, isFinite, + setTimeout: () => {}, clearTimeout: () => {}, + setInterval: () => 0, clearInterval: () => {}, + performance: { now: () => Date.now() }, + Map, Set, Promise, + registerPage: (name, handler) => { pages[name] = handler; }, + _apiCache: null, + fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }), + }; + ctx.window.document = ctx.document; + ctx.globalThis = ctx; + return { ctx, pages, getHtml: () => capturedHtml }; +} + +// Load perf.js into sandbox +function loadPerf() { + const sb = makeSandbox(); + const code = fs.readFileSync('public/perf.js', 'utf8'); + vm.runInNewContext(code, sb.ctx); + return sb; +} + +// Stub fetch to return controlled data +function stubFetch(sb, perfData, healthData) { + sb.ctx.fetch = (url) => { + if (url === '/api/perf') return Promise.resolve({ json: () => Promise.resolve(perfData) }); + if (url === '/api/health') return Promise.resolve({ json: () => Promise.resolve(healthData) }); + return Promise.resolve({ json: () => Promise.resolve({}) }); + }; +} + +const basePerf = { + totalRequests: 100, avgMs: 5, uptime: 3600, + slowQueries: [], endpoints: {}, cache: null, packetStore: null, sqlite: null +}; + +const nodeHealth = { + engine: 'node', + uptimeHuman: '1h', + memory: { heapUsed: 100, heapTotal: 200, rss: 250 }, + eventLoop: { p95Ms: 10, maxLagMs: 20, currentLagMs: 1 }, + websocket: { clients: 3 } +}; + +const goRuntime = { + goroutines: 17, numGC: 31, pauseTotalMs: 2.1, lastPauseMs: 0.03, + heapAllocMB: 473, heapSysMB: 1035, heapInuseMB: 663, heapIdleMB: 371, numCPU: 2 +}; + +const goHealth = { + engine: 'go', + uptimeHuman: '2h', + websocket: { clients: 5 } +}; + +console.log('\n๐Ÿงช perf.js โ€” Go Runtime vs Node Event Loop\n'); + +// --- Node engine tests --- + +test('Node engine shows Event Loop labels', async () => { + const sb = loadPerf(); + stubFetch(sb, basePerf, nodeHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + // Wait for async refresh + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('Event Loop p95'), 'should show Event Loop p95'); + assert.ok(html.includes('EL Max Lag'), 'should show EL Max Lag'); + assert.ok(html.includes('EL Current'), 'should show EL Current'); + assert.ok(html.includes('System Health'), 'should show System Health heading'); +}); + +test('Node engine does NOT show Go Runtime heading', async () => { + const sb = loadPerf(); + stubFetch(sb, basePerf, nodeHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(!html.includes('Go Runtime'), 'should not show Go Runtime'); + assert.ok(!html.includes('Goroutines'), 'should not show Goroutines'); +}); + +test('Node engine shows memory stats', async () => { + const sb = loadPerf(); + stubFetch(sb, basePerf, nodeHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('Heap Used'), 'should show Heap Used'); + assert.ok(html.includes('RSS'), 'should show RSS'); +}); + +// --- Go engine tests --- + +test('Go engine shows Go Runtime heading', async () => { + const sb = loadPerf(); + stubFetch(sb, { ...basePerf, goRuntime }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('Go Runtime'), 'should show Go Runtime heading'); +}); + +test('Go engine shows all goRuntime fields', async () => { + const sb = loadPerf(); + stubFetch(sb, { ...basePerf, goRuntime }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('Goroutines'), 'should show Goroutines'); + assert.ok(html.includes('GC Collections'), 'should show GC Collections'); + assert.ok(html.includes('GC Pause Total'), 'should show GC Pause Total'); + assert.ok(html.includes('Last GC Pause'), 'should show Last GC Pause'); + assert.ok(html.includes('Heap Alloc'), 'should show Heap Alloc'); + assert.ok(html.includes('Heap Sys'), 'should show Heap Sys'); + assert.ok(html.includes('Heap Inuse'), 'should show Heap Inuse'); + assert.ok(html.includes('Heap Idle'), 'should show Heap Idle'); + assert.ok(html.includes('CPUs'), 'should show CPUs'); +}); + +test('Go engine shows goRuntime values', async () => { + const sb = loadPerf(); + stubFetch(sb, { ...basePerf, goRuntime }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('17'), 'goroutines value'); + assert.ok(html.includes('31'), 'numGC value'); + assert.ok(html.includes('2.1ms'), 'pauseTotalMs value'); + assert.ok(html.includes('0.03ms'), 'lastPauseMs value'); + assert.ok(html.includes('473MB'), 'heapAllocMB value'); + assert.ok(html.includes('1035MB'), 'heapSysMB value'); + assert.ok(html.includes('663MB'), 'heapInuseMB value'); + assert.ok(html.includes('371MB'), 'heapIdleMB value'); +}); + +test('Go engine does NOT show Event Loop labels', async () => { + const sb = loadPerf(); + stubFetch(sb, { ...basePerf, goRuntime }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(!html.includes('Event Loop'), 'should not show Event Loop'); + assert.ok(!html.includes('EL Max Lag'), 'should not show EL Max Lag'); + assert.ok(!html.includes('EL Current'), 'should not show EL Current'); +}); + +test('Go engine still shows WS Clients', async () => { + const sb = loadPerf(); + stubFetch(sb, { ...basePerf, goRuntime }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('WS Clients'), 'should show WS Clients'); + assert.ok(html.includes('>5<'), 'should show 5 WS clients'); +}); + +// --- GC color threshold tests --- + +test('Go GC pause green when lastPauseMs <= 1', async () => { + const sb = loadPerf(); + const gr = { ...goRuntime, lastPauseMs: 0.5 }; + stubFetch(sb, { ...basePerf, goRuntime: gr }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('var(--status-green)'), 'should use green for low GC pause'); +}); + +test('Go GC pause yellow when lastPauseMs > 1 and <= 5', async () => { + const sb = loadPerf(); + const gr = { ...goRuntime, lastPauseMs: 3 }; + stubFetch(sb, { ...basePerf, goRuntime: gr }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('var(--status-yellow)'), 'should use yellow for moderate GC pause'); +}); + +test('Go GC pause red when lastPauseMs > 5', async () => { + const sb = loadPerf(); + const gr = { ...goRuntime, lastPauseMs: 10 }; + stubFetch(sb, { ...basePerf, goRuntime: gr }, goHealth); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('var(--status-red)'), 'should use red for high GC pause'); +}); + +// --- Fallback: engine=go but no goRuntime falls back to Node UI --- + +test('engine=go but missing goRuntime falls back to Node UI', async () => { + const sb = loadPerf(); + const goHealthWithMemory = { + ...goHealth, + memory: { heapUsed: 50, heapTotal: 100, rss: 80 }, + eventLoop: { p95Ms: 5, maxLagMs: 10, currentLagMs: 1 } + }; + stubFetch(sb, basePerf, goHealthWithMemory); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('Event Loop p95'), 'should fall back to Event Loop'); + assert.ok(!html.includes('Go Runtime'), 'should not show Go Runtime'); +}); + +// --- Missing engine field --- + +test('Missing engine field shows Node UI', async () => { + const sb = loadPerf(); + const healthNoEngine = { + uptimeHuman: '1h', + memory: { heapUsed: 100, heapTotal: 200, rss: 250 }, + eventLoop: { p95Ms: 10, maxLagMs: 20, currentLagMs: 1 }, + websocket: { clients: 2 } + }; + stubFetch(sb, basePerf, healthNoEngine); + await sb.pages.perf.init({ set innerHTML(v) {} }); + await new Promise(r => setTimeout(r, 50)); + const html = sb.getHtml(); + assert.ok(html.includes('Event Loop p95'), 'should show Event Loop'); + assert.ok(!html.includes('Go Runtime'), 'should not show Go Runtime'); +}); + +console.log(`\n${passed} passed, ${failed} failed\n`); +process.exit(failed ? 1 : 0);