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:
2026-05-24 11:31:36 +01:00
parent afc940aba7
commit 3c6791658c
12 changed files with 1127 additions and 0 deletions
+131
View File
@@ -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
};