feat: implement togglable debug log streaming for server stdout/stderr and client console logs
- Created server/utils/logCapture.js to intercept and buffer server output, stripping ANSI escape codes. - Created server/middleware/logStreamAuth.js enforcing subnet IP filtering (LOG_ALLOW_SUBNETS), Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass. - Created server/routes/debug.js with SSE streams /api/debug/server-logs, /api/debug/client-logs and batched POST /api/debug/client-logs. Exposes public configuration status at /api/debug/status. - Integrated log capture and mounted debug routes in server/app.js and server/index.js. - Implemented client/src/utils/clientLogCapture.js in the frontend SPA to hook console log/warn/error and flush batched console events. - Documented all endpoints in OpenAPI server/openapi.yaml, ARCHITECTURE.md, and README.md. - Wrote route integration tests and frontend console capture tests, with full validation in swagger-coverage.
This commit is contained in:
@@ -11,8 +11,12 @@ import { initThemeSwitcher } from './ui/theme.js';
|
||||
import { initTabs, goHome } from './ui/tabs.js';
|
||||
import { handleShowAllToggle } from './sse.js';
|
||||
import { loadAppVersion } from './api.js';
|
||||
import { initClientLogCapture } from './utils/clientLogCapture.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Initialize client console log capturing early
|
||||
initClientLogCapture();
|
||||
|
||||
// Login form
|
||||
const loginForm = document.getElementById('login-form');
|
||||
if (loginForm) {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
|
||||
const logQueue = [];
|
||||
const MAX_QUEUE_SIZE = 20;
|
||||
const FLUSH_INTERVAL_MS = 2000;
|
||||
|
||||
// Original console functions
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const originalError = console.error;
|
||||
|
||||
let isSending = false;
|
||||
let isInitialized = false;
|
||||
let flushInterval = null;
|
||||
|
||||
function formatArgs(args) {
|
||||
return args.map(arg => {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (arg instanceof Error) {
|
||||
return `${arg.name}: ${arg.message}\n${arg.stack || ''}`;
|
||||
}
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function enqueue(level, args) {
|
||||
const formattedMsg = formatArgs(args);
|
||||
|
||||
// Still write to the developer console!
|
||||
if (level === 'info') originalLog.apply(console, args);
|
||||
else if (level === 'warn') originalWarn.apply(console, args);
|
||||
else if (level === 'error') originalError.apply(console, args);
|
||||
|
||||
// Guard against infinite loop during logs dispatching
|
||||
if (isSending) return;
|
||||
|
||||
logQueue.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message: formattedMsg
|
||||
});
|
||||
|
||||
// Flush immediately if queue is full
|
||||
if (logQueue.length >= MAX_QUEUE_SIZE) {
|
||||
flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function flushQueue() {
|
||||
if (logQueue.length === 0 || isSending) return;
|
||||
|
||||
isSending = true;
|
||||
const batch = [...logQueue];
|
||||
logQueue.length = 0;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/debug/client-logs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(batch),
|
||||
// keepalive allows request to survive page unload
|
||||
keepalive: true
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
originalError.call(console, '[clientLogCapture] Ingestion server returned error status:', response.status);
|
||||
}
|
||||
} catch (err) {
|
||||
originalError.call(console, '[clientLogCapture] Ingestion post request failed:', err.message);
|
||||
} finally {
|
||||
isSending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a fast/unblocked payload flush using sendBeacon on page unload
|
||||
function flushOnUnload() {
|
||||
if (logQueue.length === 0) return;
|
||||
|
||||
const batch = [...logQueue];
|
||||
logQueue.length = 0;
|
||||
|
||||
try {
|
||||
const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
|
||||
navigator.sendBeacon('/api/debug/client-logs', blob);
|
||||
} catch (err) {
|
||||
// sendBeacon failure, fallback to synchronous fetch with keepalive if available
|
||||
try {
|
||||
fetch('/api/debug/client-logs', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(batch),
|
||||
keepalive: true
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function initClientLogCapture() {
|
||||
if (isInitialized) return;
|
||||
|
||||
try {
|
||||
// 1. Check if the server toggle for logging is active
|
||||
const response = await fetch('/api/debug/status');
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
if (data && data.enabled === true) {
|
||||
// 2. Override global console methods
|
||||
console.log = (...args) => enqueue('info', args);
|
||||
console.warn = (...args) => enqueue('warn', args);
|
||||
console.error = (...args) => enqueue('error', args);
|
||||
|
||||
// 3. Set interval for batch updates
|
||||
flushInterval = setInterval(flushQueue, FLUSH_INTERVAL_MS);
|
||||
|
||||
// 4. Setup beforeunload listener for clean flushing
|
||||
window.addEventListener('beforeunload', flushOnUnload);
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[clientLogCapture] Browser console logging interceptor initialized successfully.');
|
||||
}
|
||||
} catch (err) {
|
||||
originalError.call(console, '[clientLogCapture] Check failed to start interceptor:', err.message);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user