Files
sencho/backend/src/utils/log-parsing.ts
T
Anso a74a516850 fix(logs): harden global logs with shared parsing, SSE fixes, and level filter (#539)
- Extract log parsing utilities (normalizeContainerName, parseLogTimestamp,
  detectLogLevel, stripControlChars, demuxDockerLog) to shared module,
  eliminating duplication between polling and SSE endpoints
- Fix timestamp regex to accept timezone offsets (+HH:MM/-HH:MM), not just Z
- Replace any[] with typed GlobalLogEntry interface
- Add SSE heartbeat (30s) to prevent reverse proxy timeouts
- Add X-Accel-Buffering: no header for nginx SSE compatibility
- Replace swallowed catch blocks with console.warn diagnostics
- Fix SSE not reconnecting on node switch (missing dep in effect array)
- Add settings change event so devMode/pollRate updates apply without remount
- Add log level filter (ALL/ERROR/WARN/INFO) to toolbar
- Replace overflow-auto div with ScrollArea for design system compliance
- Add strokeWidth={1.5} to all toolbar icons
- Include stack name in download format
- Surface fetch errors with inline banner
- Add 39 unit tests for all log parsing utilities
- Update docs with level filter documentation and refreshed screenshot
2026-04-12 21:19:08 -04:00

141 lines
5.3 KiB
TypeScript

/**
* Shared utilities for global log parsing.
*
* Both the polling snapshot (`GET /api/logs/global`) and the SSE stream
* (`GET /api/logs/global/stream`) need identical timestamp extraction,
* log-level classification, and container-name normalization. Extracting
* them here eliminates duplication and provides a single place to test.
*/
export interface GlobalLogEntry {
stackName: string;
containerName: string;
source: 'STDOUT' | 'STDERR';
level: 'INFO' | 'WARN' | 'ERROR';
message: string;
timestampMs: number;
}
// Matches ISO 8601 timestamps with Z or +/-HH:MM offset.
// Docker's `timestamps: true` typically emits Z, but some logging drivers
// and Docker configurations produce offset notation instead.
const TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))\s+(.*)/;
// Non-printable control characters that appear in TTY container logs.
// Stripping them prevents garbled output in the UI and JSON responses.
const CONTROL_CHARS_RE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
// --- Level detection regexes (compiled once) ---
// Tier 1: Explicit INFO/DEBUG/TRACE indicators override the STDERR default.
const INFO_STRUCTURED_RE = /level=["']?(info|debug|trace)["']?/i;
const INFO_BRACKET_RE = /\[\s*(info|inf|debug|dbg|trace)\s*\]/i;
const INFO_KEYWORD_RE = /(?:\s|^)(info|inf|debug|trace)(?:\s|:|\(|\[|$)/i;
// Tier 2: WARN indicators.
const WARN_STRUCTURED_RE = /level=["']?(warn|warning)["']?/i;
const WARN_BRACKET_RE = /\[\s*(warn|warning)\s*\]/i;
const WARN_KEYWORD_RE = /(?:\s|^)(warn|warning)(?:\s|:|\(|\[|$)/i;
// Tier 3: ERROR indicators.
const ERROR_STRUCTURED_RE = /level=["']?(error|err|fatal|crit|critical|panic)["']?/i;
const ERROR_BRACKET_RE = /\[\s*(error|err|fatal|crit|critical|panic)\s*\]/i;
const ERROR_KEYWORD_RE = /(?:\s|^)(error|err|fatal|crit|critical|panic)(?:\s|:|\(|\[|$)/i;
const EXCEPTION_RE = /Exception:/i;
/**
* Strip the Docker Compose stack prefix and trailing replica suffix from a
* container name so the UI shows the clean service name.
*
* Examples:
* normalizeContainerName('mystack-redis-1', 'mystack') -> 'redis'
* normalizeContainerName('mystack_redis_1', 'mystack') -> 'redis'
* normalizeContainerName('standalone', 'system') -> 'standalone'
*/
export function normalizeContainerName(rawName: string, stackName: string): string {
if (rawName.startsWith(`${stackName}-`)) {
return rawName.slice(stackName.length + 1).replace(/-1$/, '');
}
if (rawName.startsWith(`${stackName}_`)) {
return rawName.slice(stackName.length + 1).replace(/_1$/, '');
}
return rawName;
}
/**
* Extract and parse an ISO timestamp from the beginning of a Docker log line.
* Returns the millisecond epoch value and the remainder of the line (the actual
* log message). Falls back to `Date.now()` when no timestamp is found.
*/
export function parseLogTimestamp(line: string): { timestampMs: number; cleanMessage: string } {
const match = line.match(TIMESTAMP_RE);
if (match) {
return {
timestampMs: new Date(match[1]).getTime(),
cleanMessage: match[2],
};
}
return { timestampMs: Date.now(), cleanMessage: line };
}
/**
* Three-tier regex classification to detect the log level from a message.
*
* Priority order:
* 1. Explicit INFO/DEBUG/TRACE keywords (overrides the STDERR default)
* 2. WARN keywords
* 3. ERROR/FATAL/CRIT/PANIC keywords or `Exception:` pattern
* 4. Fallback: STDERR -> ERROR, STDOUT -> INFO
*/
export function detectLogLevel(message: string, source: 'STDOUT' | 'STDERR'): 'INFO' | 'WARN' | 'ERROR' {
// Tier 1: INFO/DEBUG/TRACE (overrides STDERR default)
if (INFO_STRUCTURED_RE.test(message) || INFO_BRACKET_RE.test(message) || INFO_KEYWORD_RE.test(message)) {
return 'INFO';
}
// Tier 2: WARN
if (WARN_STRUCTURED_RE.test(message) || WARN_BRACKET_RE.test(message) || WARN_KEYWORD_RE.test(message)) {
return 'WARN';
}
// Tier 3: ERROR
if (ERROR_STRUCTURED_RE.test(message) || ERROR_BRACKET_RE.test(message) || ERROR_KEYWORD_RE.test(message) || EXCEPTION_RE.test(message)) {
return 'ERROR';
}
// Tier 4: Fallback based on stream source
return source === 'STDERR' ? 'ERROR' : 'INFO';
}
/** Strip non-printable control characters from TTY container log output. */
export function stripControlChars(text: string): string {
return text.replace(CONTROL_CHARS_RE, '');
}
/**
* Parse Docker's multiplexed log stream format and call `onLine` for each
* line. TTY containers produce raw text (no headers); non-TTY containers
* prepend an 8-byte header per frame:
* [streamType(1), reserved(3), payloadLength(4 BE)]
*/
export function demuxDockerLog(
buf: Buffer,
isTty: boolean,
onLine: (line: string, source: 'STDOUT' | 'STDERR') => void,
): void {
if (isTty) {
stripControlChars(buf.toString('utf-8'))
.split('\n')
.forEach(line => onLine(line, 'STDOUT'));
return;
}
let offset = 0;
while (offset < buf.length) {
if (offset + 8 > buf.length) break;
const streamType = buf[offset];
const length = buf.readUInt32BE(offset + 4);
offset += 8;
if (offset + length > buf.length) break;
const payload = buf.slice(offset, offset + length).toString('utf-8');
offset += length;
payload.split('\n').forEach(line => onLine(line, streamType === 2 ? 'STDERR' : 'STDOUT'));
}
}