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:
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class LogEmitter extends EventEmitter {}
|
||||
const logEmitter = new LogEmitter();
|
||||
|
||||
const logBuffer = [];
|
||||
const clientLogBuffer = [];
|
||||
const MAX_BUFFER_SIZE = 1000;
|
||||
|
||||
// ANSI escape code regular expression for stripping terminal colors
|
||||
const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
|
||||
|
||||
function stripAnsi(str) {
|
||||
return typeof str === 'string' ? str.replace(ansiRegex, '') : str;
|
||||
}
|
||||
|
||||
// Keep track of original stdout/stderr write functions
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
const originalStderrWrite = process.stderr.write;
|
||||
|
||||
// Buffer to accumulate partial lines from stdout and stderr
|
||||
let stdoutLineBuffer = '';
|
||||
let stderrLineBuffer = '';
|
||||
|
||||
function processStreamData(data, encoding, callback, streamName, lineAccumulator) {
|
||||
let str = '';
|
||||
if (Buffer.isBuffer(data)) {
|
||||
str = data.toString(encoding || 'utf8');
|
||||
} else if (typeof data === 'string') {
|
||||
str = data;
|
||||
}
|
||||
|
||||
// Delegate writing to the original stream first
|
||||
callback.call(this, data, encoding);
|
||||
|
||||
// Append new data to the accumulator
|
||||
const accumulated = lineAccumulator.buffer + str;
|
||||
const lines = accumulated.split(/\r?\n/);
|
||||
|
||||
// The last element is either empty (if str ended with \n) or a partial line
|
||||
lineAccumulator.buffer = lines.pop();
|
||||
|
||||
for (const line of lines) {
|
||||
const cleanLine = stripAnsi(line);
|
||||
if (!cleanLine) continue;
|
||||
|
||||
// Prepend timestamp if not present (format: [ISO] Message)
|
||||
const timestampedLine = cleanLine.startsWith('[')
|
||||
? cleanLine
|
||||
: `[${new Date().toISOString()}] [${streamName.toUpperCase()}] ${cleanLine}`;
|
||||
|
||||
logBuffer.push(timestampedLine);
|
||||
if (logBuffer.length > MAX_BUFFER_SIZE) {
|
||||
logBuffer.shift();
|
||||
}
|
||||
|
||||
logEmitter.emit('server-log', timestampedLine);
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulator objects to allow updating string buffers by reference
|
||||
const stdoutAccumulator = { buffer: '' };
|
||||
const stderrAccumulator = { buffer: '' };
|
||||
|
||||
let isHooked = false;
|
||||
|
||||
function init() {
|
||||
if (isHooked) return;
|
||||
|
||||
// Intercept stdout
|
||||
process.stdout.write = function(data, encoding, callback) {
|
||||
processStreamData.call(
|
||||
process.stdout,
|
||||
data,
|
||||
encoding,
|
||||
originalStdoutWrite,
|
||||
'stdout',
|
||||
stdoutAccumulator
|
||||
);
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
// Intercept stderr
|
||||
process.stderr.write = function(data, encoding, callback) {
|
||||
processStreamData.call(
|
||||
process.stderr,
|
||||
data,
|
||||
encoding,
|
||||
originalStderrWrite,
|
||||
'stderr',
|
||||
stderrAccumulator
|
||||
);
|
||||
if (typeof callback === 'function') callback();
|
||||
return true;
|
||||
};
|
||||
|
||||
isHooked = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingests a list of client-side logs into the rolling clientLogBuffer.
|
||||
* Each client log is expected to have structure: { timestamp, level, message }
|
||||
*/
|
||||
function ingestClientLogs(logs) {
|
||||
if (!Array.isArray(logs)) return;
|
||||
|
||||
for (const log of logs) {
|
||||
const timestamp = log.timestamp || new Date().toISOString();
|
||||
const level = (log.level || 'info').toUpperCase();
|
||||
const msg = typeof log.message === 'string' ? log.message : JSON.stringify(log.message);
|
||||
|
||||
const formattedLog = `[${timestamp}] [CLIENT] [${level}] ${stripAnsi(msg)}`;
|
||||
clientLogBuffer.push(formattedLog);
|
||||
|
||||
if (clientLogBuffer.length > MAX_BUFFER_SIZE) {
|
||||
clientLogBuffer.shift();
|
||||
}
|
||||
|
||||
logEmitter.emit('client-log', formattedLog);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
logEmitter,
|
||||
logBuffer,
|
||||
clientLogBuffer,
|
||||
ingestClientLogs
|
||||
};
|
||||
Reference in New Issue
Block a user