ordinarthur 096f772da8 feat: add health telemetry and centralized log system (Phase 2 & 3)
- Robot-client: TelemetryReporter collects system metrics (CPU, RAM, disk, WiFi)
  and sends them to backend every 60s via WebSocket
- Robot-client: LogForwarder buffers Pino logs and flushes them in batches
  every 5s to the backend via WebSocket
- Backend: HealthReport entity + HealthTelemetryService with alert thresholds
  (CPU >80°C, RAM >90%, disk >90%, load >3.0, heap >85%)
- Backend: DeviceLog entity + LogIngestionService with EventEmitter2 for SSE
- Backend: REST endpoints GET /devices/:id/health/reports and /alerts
- Backend: REST endpoint GET /devices/:id/logs with filtering (level, logger, search)
- Backend: SSE endpoint GET /admin/logs/stream for real-time log streaming
- Migrations for health_reports and device_logs tables with proper indexes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 21:11:53 +02:00

79 lines
2.2 KiB
TypeScript

import pino from 'pino';
import type { LogForwarder, LogEntry } from '../services/log-forwarder.js';
let _logForwarder: LogForwarder | null = null;
/**
* Register a LogForwarder instance.
* Once registered, all loggers created via `createLogger` will
* also forward their output to the cloud backend.
*/
export function setLogForwarder(forwarder: LogForwarder): void {
_logForwarder = forwarder;
}
export function createLogger(name: string, level = 'info') {
const logger = pino({
name,
level,
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});
// Wrap the logger to also forward logs when a forwarder is set.
// We hook into Pino's internal write by using `onChild` isn't available,
// so we use a proxy on the logging methods.
return new Proxy(logger, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
// Intercept logging methods: trace, debug, info, warn, error, fatal
if (
_logForwarder &&
typeof prop === 'string' &&
['trace', 'debug', 'info', 'warn', 'error', 'fatal'].includes(prop) &&
typeof value === 'function'
) {
return (...args: unknown[]) => {
// Call original
(value as (...a: unknown[]) => void).apply(target, args);
// Forward to the cloud
try {
const levelNum = target.levels.values[prop] ?? 30;
let msg = '';
let extra: Record<string, unknown> = {};
if (typeof args[0] === 'string') {
msg = args[0];
} else if (typeof args[0] === 'object' && args[0] !== null) {
extra = args[0] as Record<string, unknown>;
if (typeof args[1] === 'string') {
msg = args[1];
}
}
const entry: LogEntry = {
level: levelNum,
time: Date.now(),
msg,
name,
...extra,
};
_logForwarder!.ingest(entry);
} catch {
// Never let forwarding break the app
}
};
}
return value;
},
});
}
export type Logger = pino.Logger;