diff --git a/server/broadcast.js b/server/broadcast.js index 87ccf43..bb649c8 100755 --- a/server/broadcast.js +++ b/server/broadcast.js @@ -2,6 +2,7 @@ import crypto from 'crypto'; import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import axios from 'axios'; +import { getServerStartStats } from './server-lifecycle.js'; const TIMEOUT_SECONDS = 1800; // 30 Minuten const MAX_ACTIVE_VIDEO_CONNECTIONS = 3; @@ -609,27 +610,6 @@ function logClientLogin(client, __dirname) { } } -function checkAndLogStart(__dirname) { - try { - const logsDir = join(__dirname, '../logs'); - // Erstelle logs-Verzeichnis falls es nicht existiert - if (!existsSync(logsDir)) { - try { - mkdirSync(logsDir, { recursive: true }); - console.log(`[Log] Logs-Verzeichnis erstellt: ${logsDir}`); - } catch (mkdirError) { - console.error(`[Log] Fehler beim Erstellen des Logs-Verzeichnisses: ${mkdirError.message}`); - return; // Beende Funktion, wenn Verzeichnis nicht erstellt werden kann - } - } - const logPath = join(logsDir, 'starts.log'); - const logEntry = `${new Date().toISOString()}\n`; - appendFileSync(logPath, logEntry, 'utf-8'); - } catch (error) { - console.error('Fehler beim Loggen des Starts:', error.message); - } -} - export function setupBroadcast(io, __dirname) { // Länderliste beim Start laden let countriesMap = {}; @@ -728,23 +708,8 @@ export function setupBroadcast(io, __dirname) { .slice(0, limit); } - function readStartsLogStats() { - const path = join(ensureLogsDir(__dirname), 'starts.log'); - if (!existsSync(path)) { - return { count: 0, first: null, last: null }; - } - const lines = readFileSync(path, 'utf-8') - .split('\n') - .map((l) => l.trim()) - .filter(Boolean); - if (lines.length === 0) { - return { count: 0, first: null, last: null }; - } - return { count: lines.length, first: lines[0], last: lines[lines.length - 1] }; - } - /** - * Sammelt alle verfügbaren Kennzahlen: Live-Server, starts.log, vollständige Auswertung von logins.log + * Sammelt alle verfügbaren Kennzahlen: Live-Server, echte Serverstarts und vollständige Login-Auswertung. * (entspricht inhaltlich den /stat-Unterbefehlen in einer Tabelle). */ function buildFullAllStatsRows(records) { @@ -763,7 +728,7 @@ export function setupBroadcast(io, __dirname) { totalMessages += Array.isArray(msgs) ? msgs.length : 0; } - const starts = readStartsLogStats(); + const starts = getServerStartStats(__dirname); push('— Live (Server) —', ''); push('Nutzer mit Chat-Login und aktiver Verbindung', liveOnline); @@ -771,13 +736,17 @@ export function setupBroadcast(io, __dirname) { push('Client-Sitzungen im Speicher', clients.size); push('Unterhaltungen im Speicher (Paare)', conversations.size); push('Nachrichten-Einträge gesamt (RAM)', totalMessages); - push('Server-Starts protokolliert (starts.log)', starts.count); + push('Server-Starts protokolliert (server-lifecycle.jsonl)', starts.count); if (starts.first) { - push('Erster Start (Timestamp)', starts.first); + push('Erster Server-Start (Timestamp)', starts.first.timestamp); } if (starts.last) { - push('Letzter Start (Timestamp)', starts.last); + push('Letzter Server-Start (Timestamp)', starts.last.timestamp); + push('Anlass des letzten Server-Starts', starts.last.reason); } + starts.latest.forEach((start, index) => { + push(`Server-Start ${index + 1} (UTC)`, `${start.timestamp} — ${start.reason}`); + }); push('— Login-Protokoll (logins.log, UTC) —', ''); if (records.length === 0) { @@ -887,7 +856,7 @@ export function setupBroadcast(io, __dirname) { ['/stat ages', 'Jüngster und ältester Nutzer'], ['/stat names', 'Häufigkeit der verwendeten Namen'], ['/stat countries', 'Häufigkeit der Länder'], - ['/all-stats', 'Alle Kennzahlen: Live-Server + starts.log + komplette logins.log-Auswertung'] + ['/all-stats', 'Alle Kennzahlen: Live-Server + echte Serverstarts + komplette Login-Auswertung'] ]); return; } @@ -1148,7 +1117,7 @@ export function setupBroadcast(io, __dirname) { ['/logout-admin', 'Admin-/Command-Login beenden'], ['/whoami-rights', 'Aktuelle Admin-Rechte anzeigen'], ['/stat help', 'Hilfe zu Statistikbefehlen anzeigen'], - ['/all-stats', 'Alle Kennzahlen: Live-Server + starts.log + komplette logins.log-Auswertung'], + ['/all-stats', 'Alle Kennzahlen: Live-Server + echte Serverstarts + komplette Login-Auswertung'], ['/kick ', 'Benutzer aus dem Chat werfen'], ['/help oder /?', 'Diese Hilfe anzeigen'] ]); @@ -1670,7 +1639,6 @@ export function setupBroadcast(io, __dirname) { }); logClientLogin(client, __dirname); - checkAndLogStart(__dirname); // Benutzerliste an alle senden broadcastUserList(); diff --git a/server/index.js b/server/index.js index 579bd4e..f3abccd 100755 --- a/server/index.js +++ b/server/index.js @@ -9,12 +9,14 @@ import { dirname, join } from 'path'; import { setupBroadcast } from './broadcast.js'; import { setupRoutes } from './routes.js'; import { setupSEORoutes } from './routes-seo.js'; +import { installLifecycleHandlers, logServerStart } from './server-lifecycle.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const app = express(); const server = createServer(app); +installLifecycleHandlers(__dirname, server); // Umgebungsvariablen const NODE_ENV = process.env.NODE_ENV || 'development'; @@ -233,6 +235,7 @@ if (IS_PRODUCTION) { const HOST = process.env.HOST || '127.0.0.1'; server.listen(PORT, HOST, () => { + logServerStart(__dirname); console.log(`Server läuft auf http://${HOST}:${PORT}`); console.log(`Umgebung: ${NODE_ENV}`); console.log(`CORS erlaubt für: ${allowedOrigins.join(', ')}`); diff --git a/server/server-lifecycle.js b/server/server-lifecycle.js new file mode 100644 index 0000000..399f79c --- /dev/null +++ b/server/server-lifecycle.js @@ -0,0 +1,111 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'fs'; +import { join } from 'path'; + +const LIFECYCLE_LOG_FILE = 'server-lifecycle.jsonl'; + +function getLogPath(__dirname) { + const logsDir = join(__dirname, '../logs'); + if (!existsSync(logsDir)) { + mkdirSync(logsDir, { recursive: true }); + } + return join(logsDir, LIFECYCLE_LOG_FILE); +} + +function appendEvent(__dirname, event) { + const entry = { timestamp: new Date().toISOString(), ...event }; + try { + appendFileSync(getLogPath(__dirname), `${JSON.stringify(entry)}\n`, 'utf-8'); + } catch (error) { + console.error('[Lifecycle] Ereignis konnte nicht protokolliert werden:', error.message); + } + return entry; +} + +function readEvents(__dirname) { + try { + const logPath = getLogPath(__dirname); + if (!existsSync(logPath)) return []; + return readFileSync(logPath, 'utf-8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter((event) => event && typeof event.timestamp === 'string' && typeof event.type === 'string'); + } catch (error) { + console.error('[Lifecycle] Protokoll konnte nicht gelesen werden:', error.message); + return []; + } +} + +export function logServerStart(__dirname) { + const events = readEvents(__dirname); + const previous = events.at(-1); + let reason = 'Erststart oder vorheriger Shutdown ist bereits protokolliert'; + + // SIGKILL, OOM und Stromausfälle geben dem Node-Prozess keine Gelegenheit, + // einen Shutdown zu schreiben. Beim nächsten Start machen wir das transparent. + if (previous?.type === 'start') { + appendEvent(__dirname, { + type: 'stop', + reason: 'Abrupte Beendigung: kein sauberer Shutdown protokolliert', + previousPid: previous.pid + }); + reason = 'Neustart nach abrupter Beendigung der vorherigen Instanz'; + } else if (previous?.type === 'stop' && previous.reason?.startsWith('Kontrollierter Shutdown')) { + reason = `Kontrollierter Neustart nach: ${previous.reason}`; + } + + return appendEvent(__dirname, { type: 'start', pid: process.pid, reason }); +} + +export function logServerStop(__dirname, reason, details = {}) { + return appendEvent(__dirname, { type: 'stop', pid: process.pid, reason, ...details }); +} + +export function getServerStartStats(__dirname, limit = 5) { + const starts = readEvents(__dirname).filter((event) => event.type === 'start'); + return { + count: starts.length, + first: starts[0] || null, + last: starts.at(-1) || null, + latest: starts.slice(-limit).reverse() + }; +} + +export function installLifecycleHandlers(__dirname, httpServer) { + let shuttingDown = false; + + const gracefulShutdown = (signal) => { + if (shuttingDown) return; + shuttingDown = true; + logServerStop( + __dirname, + `Kontrollierter Shutdown per ${signal} (manuell, Deployment oder System-Neustart)` + ); + httpServer.close(() => process.exit(0)); + setTimeout(() => process.exit(0), 10_000).unref(); + }; + + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); + process.on('uncaughtException', (error) => { + logServerStop(__dirname, 'Absturz durch unbehandelte Ausnahme', { + error: String(error?.stack || error?.message || error) + }); + console.error('[Lifecycle] Unbehandelte Ausnahme:', error); + process.exit(1); + }); + process.on('unhandledRejection', (reason) => { + logServerStop(__dirname, 'Absturz durch unbehandelte Promise-Ablehnung', { + error: String(reason?.stack || reason?.message || reason) + }); + console.error('[Lifecycle] Unbehandelte Promise-Ablehnung:', reason); + process.exit(1); + }); +}