Files
sofarr/client/src/utils/clientLogCapture.js
T
gronod 3c6791658c 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.
2026-05-24 11:31:36 +01:00

138 lines
3.7 KiB
JavaScript

// 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);
}
}