Files
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

144 lines
3.5 KiB
JavaScript

// Copyright (c) 2026 Gordon Bolton. MIT License.
const express = require('express');
const router = express.Router();
const logStreamAuth = require('../middleware/logStreamAuth');
const {
logEmitter,
logBuffer,
clientLogBuffer,
ingestClientLogs
} = require('../utils/logCapture');
// Public status check (no auth, no 403 block, returns standard config state)
router.get('/status', (req, res) => {
res.json({ enabled: process.env.ENABLE_LOG_STREAM === 'true' });
});
// Global toggle check
router.use((req, res, next) => {
if (process.env.ENABLE_LOG_STREAM !== 'true') {
return res.status(403).json({ error: 'Log streaming feature is disabled' });
}
next();
});
// Enforce subnet and authentication validations on all debug routes
router.use(logStreamAuth);
/**
* GET /api/debug/server-logs
* Exposes a real-time SSE stream of intercepted server stdout/stderr logs.
*/
router.get('/server-logs', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
// Send historical server logs buffer first
for (const line of logBuffer) {
res.write(`data: ${line}\n\n`);
}
// Gracefully close for integration testing
if (req.query.testClose === 'true') {
res.end();
return;
}
const sendLog = (line) => {
try {
res.write(`data: ${line}\n\n`);
} catch (err) {
console.error('[debugRoutes] Error sending server log line:', err.message);
}
};
logEmitter.on('server-log', sendLog);
// 25s heartbeat comment to prevent proxy timeouts
const heartbeat = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch {
// Ignore
}
}, 25000);
req.on('close', () => {
clearInterval(heartbeat);
logEmitter.off('server-log', sendLog);
});
});
/**
* GET /api/debug/client-logs
* Exposes a real-time SSE stream of ingested client-side console logs.
*/
router.get('/client-logs', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
// Send historical client logs buffer first
for (const line of clientLogBuffer) {
res.write(`data: ${line}\n\n`);
}
// Gracefully close for integration testing
if (req.query.testClose === 'true') {
res.end();
return;
}
const sendClientLog = (line) => {
try {
res.write(`data: ${line}\n\n`);
} catch (err) {
console.error('[debugRoutes] Error sending client log line:', err.message);
}
};
logEmitter.on('client-log', sendClientLog);
// 25s heartbeat comment to prevent proxy timeouts
const heartbeat = setInterval(() => {
try {
res.write(': heartbeat\n\n');
} catch {
// Ignore
}
}, 25000);
req.on('close', () => {
clearInterval(heartbeat);
logEmitter.off('client-log', sendClientLog);
});
});
/**
* POST /api/debug/client-logs
* Receives batches of frontend console logs to store in buffer and emit.
*/
router.post('/client-logs', (req, res) => {
const logs = req.body;
if (!Array.isArray(logs)) {
return res.status(400).json({ error: 'Body must be a JSON array of log entries' });
}
try {
ingestClientLogs(logs);
return res.status(200).json({ success: true, count: logs.length });
} catch (err) {
console.error('[debugRoutes] Ingestion failed:', err.message);
return res.status(500).json({ error: 'Internal server error during ingestion' });
}
});
module.exports = router;