Health & Monitoring Commands
Commands für Health-Checks, Queue-Metriken, API-Quota-Tracking, Cron-Heartbeat-Monitoring und WebSocket-Server.
health:check-daily-scrapes
Prüft ob die täglichen Scrape-Jobs für Instagram und YouTube erfolgreich erstellt wurden. Sendet Alert-E-Mails an Admins wenn Jobs fehlen.
health:check-daily-scrapes
{--dry-run : Ergebnisse anzeigen ohne E-Mails zu senden}
Beispiele
# Standard Health-Check
php artisan health:check-daily-scrapes
# Vorschau ohne E-Mail-Versand
php artisan health:check-daily-scrapes --dry-run
Optionen
| Option | Beschreibung |
|---|---|
--dry-run | Zeigt Ergebnisse und Alert-Inhalte ohne E-Mail-Versand |
Checks
| Plattform | Prüfung | Alert-Bedingung | Quelle |
|---|---|---|---|
| Cache-Marker + Job-Count | Nur wenn Queue-Command gar nicht gelaufen ist (0 Jobs UND kein Cache-Marker) | collector_jobs + Cache health:instagram_queue_ran:{date} | |
| YouTube | daily_sync_runs Record für heute | expected_profiles = 0 (kein Sync-Run erstellt) | daily_sync_runs Tabelle |
| YouTube Video Sync | Cache-Marker (ab 15:00 UTC) | Kein Cache-Marker health:youtube_video_sync_ran:{date} nach 15:00 UTC | Cache (gesetzt von youtube:sync-video-stats, TTL: 36h UTC) |
| Stuck Leases | Expired Leases > 10 Min | Mehr als 100 gelockte Jobs mit abgelaufenem Lease | collector_jobs (status=leased, lease_expires_at < now-10min) |
| Progress Stagnation | Completion Rate nach 2h | Weniger als 10% Instagram-Jobs nach >2h abgeschlossen | collector_jobs (nur wenn Cache-Marker existiert) |
Instagram-Check: Cache-Marker vs. Job-Count
Der Instagram-Check nutzt einen zweistufigen Ansatz:
- Job-Count: Alle
collector_jobsmitsource=instagramfür heute zählen - Cache-Marker:
health:instagram_queue_ran:{date}wird vonsocial:queue-daily-instagramgesetzt und enthält Statistiken (queued,skipped_rotation,skipped_today,skipped_open)
Wichtig: 0 Jobs ist kein Alert wenn der Cache-Marker existiert. Das ist normal bei Rotation (alle Profile außerhalb des aktuellen Buckets), bereits geschrapten Profilen oder offenen Jobs vom Vortag. Nur wenn weder Jobs noch Cache-Marker existieren, wird ein Alert ausgelöst.
Cache-Marker TTL
Die Health-Marker werden mit now('UTC')->addHours(36) TTL gesetzt (nicht endOfDay()). Grund: Bei App-Timezone Europe/Berlin laeuft endOfDay() bereits um ~22:59 UTC ab, aber der Health-Check laeuft bis 23:30 UTC — das verursachte False-Positive-Alerts.
| Marker | Gesetzt von | TTL |
|---|---|---|
health:instagram_queue_ran:{date} | social:queue-daily-instagram | 36h (UTC) |
health:youtube_video_sync_ran:{date} | youtube:sync-video-stats | 36h (UTC) |
health:daily-scrape-alert:{date} | health:check-daily-scrapes | bis endOfDay() |
Interne Schritte
- Instagram-Jobs fuer heute zaehlen + Cache-Marker pruefen
- YouTube-Status laden (
DailySyncRunfuer heute) - YouTube Video Sync Cache-Marker pruefen (erst ab 15:00 UTC, da der Command um 14:00 laeuft)
- Stuck Leases pruefen (>100 abgelaufene Leases aelter als 10 Minuten)
- Progress Stagnation pruefen (<10% abgeschlossen nach >2h, nur wenn Cache-Marker existiert)
- Alert nur generieren wenn eine Plattform echtes Problem hat (s.o.)
- Daily Cooldown: Pro Tag wird maximal eine Alert-E-Mail gesendet (Cache-Key:
health:daily-scrape-alert:{date}) - E-Mail an alle
config('postbox.admin_emails')senden - E-Mail-Versand wird ueber
LogSentMailListener protokolliert
Alert-E-Mail Inhalt
Die Alert-E-Mail enthält:
- Betroffene Plattform und das Problem
- Den fehlgeschlagenen Artisan Command
- Empfohlene Maßnahmen (Cron-Heartbeats, Schedule-Cache, manueller Lauf, Logs)
Schedule
Alle 2 Stunden bei :30 (ungerade: 01:30, 03:30, 05:30, ...).
Location: app/Console/Commands/CheckDailyScrapeHealth.php
queue:metrics-snapshot
Speichert 15-Minuten Queue-Metriken für das Admin Queue-Chart. Erfasst sowohl Collector-Jobs als auch Laravel Queue-Jobs.
queue:metrics-snapshot
{--from= : Backfill-Start (UTC)}
{--to= : Backfill-Ende (UTC)}
Beispiele
# Aktuelles 15-Minuten-Bucket erfassen
php artisan queue:metrics-snapshot
# Historische Buckets nachfüllen
php artisan queue:metrics-snapshot --from="2026-02-01 00:00:00" --to="2026-02-02 00:00:00"
Optionen
| Option | Beschreibung |
|---|---|
--from= | Backfill Start-Zeitpunkt (UTC) |
--to= | Backfill End-Zeitpunkt (UTC) |
Metriken-Serien
Jeder 15-Minuten-Snapshot enthält pro Plattform:
| Serie | Beschreibung |
|---|---|
total | Gesamtzahl aller Jobs |
todo | Noch nicht begonnene Jobs |
queued | In der Queue wartend |
leased | Aktuell in Bearbeitung |
completed | Erfolgreich abgeschlossen |
error | Fehlgeschlagen |
Interne Schritte
- Aktuelles 15-Minuten-Bucket bestimmen (UTC)
- Collector-Jobs (Instagram): Status-Counts aus
collector_jobsaggregieren - Laravel Queue-Jobs (YouTube): Counts aus
jobsTabelle nach Queue-Name mappen - YouTube Daily Scrape: Counts aus
social_profile_daily_metricsund Failures ausdaily_sync_runs - Totals inkludieren offenen Backlog (Jobs von Vortagen), damit Charts nicht bei UTC-Mitternacht auf 0 fallen
- "All"-Platform Chart enthält extra YouTube-Total-Linie für Vergleich
Schedule
Alle 15 Minuten mit .withoutOverlapping(5).
Location: app/Console/Commands/SnapshotQueueMetrics.php
queue:retry-failed-range
Setzt fehlgeschlagene Laravel Queue-Jobs in einem Zeitraum zurück in die Queue. Optional filterbar nach Queue-Name.
queue:retry-failed-range
{--from= : Start-Zeitpunkt}
{--to= : End-Zeitpunkt}
{--queue= : Queue-Name Filter}
{--dry-run : Vorschau ohne Retry}
Beispiele
# Alle Failed Jobs in einem Zeitraum retrien
php artisan queue:retry-failed-range --from="2026-02-01 00:00:00" --to="2026-02-02 23:59:59"
# Nur bestimmte Queue
php artisan queue:retry-failed-range --from="2026-02-01 00:00:00" --to="2026-02-02 23:59:59" --queue=imports-youtube-video
# Vorschau
php artisan queue:retry-failed-range --from="2026-02-01 00:00:00" --to="2026-02-02 23:59:59" --dry-run
Optionen
| Option | Beschreibung |
|---|---|
--from= | Start-Zeitpunkt (Pflicht) |
--to= | End-Zeitpunkt (Pflicht) |
--queue= | Queue-Name (z.B. imports-youtube, imports-youtube-video, ai-detection) |
--dry-run | Zeigt was retried würde, ohne Ausführung |
Hinweise
- Arbeitet mit Jobs aus der
failed_jobsTabelle (Laravel Standard) - Für Instagram-Jobs stattdessen
collector:retry-failedverwenden
Schedule
Manuell.
Location: app/Console/Commands/RetryFailedQueueJobs.php
google:sync-api-usage
Synchronisiert Google API Quota-Nutzung und Limits für alle konfigurierten Projekte.
Basis für die Quota-Anzeige im Admin-Dashboard und die Quota-Prüfung in ResearchQuotaService.
google:sync-api-usage
Beispiele
# Quota-Nutzung synchronisieren
php artisan google:sync-api-usage
Interne Schritte
- Quota Limits: Abruf über
serviceusage.googleapis.com/v1beta1API - Quota Usage: Abruf über
monitoring.googleapis.com/v3API (timeSeries mitrate/net_usage) - Daten in
google_api_quota_limitsundgoogle_api_quota_usagesTabellen speichern - Sync-Timestamps und Fehler für Admin-Dashboard cachen
⚠️ Die Verbrauchszeile trägt das PAZIFIK-Datum, nicht das UTC-Datum. Die YouTube-Quota wird um Mitternacht Pacific Time zurückgesetzt, und SyncGoogleApiUsage schreibt now('America/Los_Angeles')->startOfDay() in die Spalte date.
Wer diese Tabelle liest, muss dasselbe Datum verwenden. Der Health-Check tat es bis zum 02.09.2026 nicht — er suchte mit dem UTC-Datum und fragte damit zwischen Mitternacht UTC und dem Reset nach einem Tag, den es in der Tabelle noch gar nicht gibt. Er fand nichts und meldete daraufhin grün mit der Begründung „noch kein Sync heute": rund sieben Stunden am Tag blind, und zwar in der beruhigenden Richtung.
Und das Limit steht in der Tabelle, nicht in der Key-Anzahl. google_api_quota_limits führt limit_value und effective_limit_value je Projekt und Metrik. Wer stattdessen „Anzahl konfigurierter Keys × 10.000" rechnet, bildet einen Nenner aus einer anderen Welt als den Zähler: Der Verbrauch wird über alle Projekte summiert, und die Anwendung fährt zusätzlich eigene Key-Pools (video_keys, research_keys), die eigene Kontingente tragen. Die Prozentzahl wird dadurch systematisch zu hoch — genug, um an einem gewöhnlichen Tag über 95 % zu laufen, unabhängig von der tatsächlichen Auslastung.
Eine erschöpfte Quota ist kein Systemfehler. Diese Anwendung fährt ihr Kontingent absichtlich leer: Der Scrape ist danach terminiert, ein Quota-Guard bremst, und youtube:research-retry läuft als eigener Cron am Reset. Der Health-Check meldet den Zustand deshalb als WARNING, nicht als FAILED — sonst dreht er jeden Abend den Gesamtstatus auf SYSTEM_DEGRADED und alarmiert über den Normalfall.
Konfiguration
# Kommaseparierte Google Cloud Projekt-IDs
GOOGLE_API_USAGE_PROJECTS=project-1,project-2
# Service-Account Credentials
GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
Schedule
Stündlich mit .withoutOverlapping(10).
Location: app/Console/Commands/SyncGoogleApiUsage.php
reverb:start
Startet den Laravel Reverb WebSocket-Server. In Production als Forge Daemon konfiguriert. Stellt WebSocket-Verbindungen für Echtzeit-Events bereit (Notifications, Import-Progress, Daily Scrape Status).
reverb:start
{--host= : Bind-Adresse}
{--port= : Port}
{--debug : Debug-Logging aktivieren}
Beispiele
# Lokale Entwicklung
php artisan reverb:start
# Production (alle Interfaces, Port 8081)
php artisan reverb:start --host=0.0.0.0 --port=8081
Optionen
| Option | Beschreibung |
|---|---|
--host= | Bind-Adresse (Default: 127.0.0.1, Production: 0.0.0.0) |
--port= | Port (Default: 8081, Nginx leitet dahin weiter) |
--debug | Debug-Logging für Verbindungen/Nachrichten aktivieren |
Registrierte Event-Channels
| Channel | Events | Beschreibung |
|---|---|---|
user.{id} | Daily Scrape, Sync, Sanitize, Reactivate | Per-User Notifications |
profile.{id} | Related Profiles Calculated | Per-Profile Updates |
workspace.{id} | Import Progress | Workspace-weite Events |
admin | User Registered | Admin-only Events |
Production-Setup (Forge)
# Daemon-Konfiguration in Forge:
php8.4 /home/forge/app.postbox.so/current/artisan reverb:start --host=0.0.0.0 --port=8081
Nginx Proxy-Config leitet WebSocket-Traffic von Port 443 auf den internen Port 8081 weiter.
Schedule
Dauerhaft als Daemon (kein Cron-Job).
Location: Laravel Reverb Package
server:check-alerts
Liest aktuelle Server-Metriken aus Laravel Pulse und prüft sie gegen konfigurierte Alert-Schwellwerte. Sendet Benachrichtigungen wenn Metriken überschritten werden.
server:check-alerts
Beispiele
# Standard-Check (alle Metriken prüfen)
php artisan server:check-alerts
Geprüfte Metriken
| Metrik | Quelle | Beschreibung |
|---|---|---|
cpu_percent | Pulse system | CPU-Auslastung in Prozent |
ram_percent | Pulse system | RAM-Auslastung (berechnet aus memory_used / memory_total) |
disk_percent | Pulse system | Festplatten-Auslastung (erstes Storage-Volume) |
swap_percent | Pulse swap_metrics | Swap-Füllstand in Prozent. Wird erhoben, alarmiert aber nicht (steht nicht im alerts-Block) |
pg_connections | Pulse pg_metrics | Gesamtzahl der Client-Verbindungen (connections_total), gedacht gegen max_connections |
pg_cache_hit | Pulse pg_metrics | Fensterwert der Cache-Trefferquote (cache_hit_ratio_recent): Differenz zweier Beats, rund 15 Sekunden. Fehlt er (erster Beat nach einem Daemon-Neustart), wird die Metrik übersprungen |
Interne Logik
- Pulse Storage-Werte auslesen (
system,swap_metrics,pg_metrics) - JSON-Werte parsen und in Metriken-Array überführen
ServerAlertService::check($metrics)aufrufen — prüft gegen Schwellwerte- Benachrichtigung in der Nachrichtenzentrale und, je nach Einstellung, per E-Mail. Jede Stufe meldet sich je Metrik höchstens einmal pro Cooldown-Fenster (Details:
ServerAlertServicein der Infrastruktur-Doku)
Schedule
Alle 5 Minuten mit .withoutOverlapping().
Location: app/Console/Commands/CheckServerAlerts.php
mail:resume
Hebt die Flood-Guard-Pause für den Mailversand auf. Wird manuell ausgeführt nachdem der automatische Flood-Schutz den Versand pausiert hat.
mail:resume
Beispiele
# Mailversand fortsetzen
php artisan mail:resume
Interne Logik
- Prüft ob Mail aktuell pausiert ist (
MailFloodGuard::isPaused()) - Falls nicht pausiert: Info-Meldung, kein Fehler
- Falls pausiert:
MailFloodGuard::resume()— löscht Redis-Keys für Pause, Counter und Notification-Flag
Hinweise
- Alternativ über die Admin-UI:
/admin/statistics→ "Mailversand fortsetzen" Button - Nach Resume werden pausierte Queue-Jobs automatisch weiterverarbeitet
Schedule
Manuell (bei Flood-Guard-Auslösung).
Location: app/Console/Commands/MailResume.php
mail:status
Zeigt den aktuellen Mail-System-Status auf der Konsole an. Nützlich für Quick-Checks ohne Browser-Zugriff.
mail:status
Beispiele
# Vollständigen Status anzeigen
php artisan mail:status
Angezeigte Informationen
| Bereich | Metriken |
|---|---|
| Flood Guard | Status (Aktiv/Pausiert), Mails/Minute, Mails/Stunde, Limits |
| Mail-Log (24h) | Gesendet gesamt, Gesendet letzte Stunde, Fehlgeschlagen |
| Top Mail-Typen | Die 10 häufigsten Mail-Typen der letzten 24h mit Anzahl |
Interne Logik
MailFloodGuard::getStatus()für Redis-Counter und Pause-StatusMailLogQueries für 24h- und 1h-Statistiken- Gruppierung nach
mail_typefür Top-10 Tabelle
Schedule
Manuell.
Location: app/Console/Commands/MailStatus.php
notifications:rollup-stats
Aggregiert Mail-Log Einträge in tägliche notification_stats für Admin-Charts.
Konsolidiert die einzelnen mail_log Rows zu täglichen Zählern pro Mail-Typ.
notifications:rollup-stats
{--date= : Bestimmtes Datum aggregieren (Y-m-d)}
Optionen
| Option | Typ | Default | Beschreibung |
|---|---|---|---|
--date= | string | Gestern | Datum für die Aggregation (Format: Y-m-d) |
Beispiele
# Standard: gestrige Mails aggregieren
php artisan notifications:rollup-stats
# Bestimmtes Datum nachholend aggregieren
php artisan notifications:rollup-stats --date=2026-02-10
Interne Logik
mail_logEinträge für das Datum gruppiert nachmail_typezählen- Erfolgreiche Mails → Channel
emailinnotification_statsupserten - Fehlgeschlagene Mails → Channel
email_failedinnotification_statsupserten - Upsert-Pattern: Bei existierendem Record wird
countüberschrieben
Schedule
Täglich um 00:30 UTC.
Location: app/Console/Commands/RollupNotificationStats.php
api-tokens:check-alerts
Prueft API-Tokens auf Inaktivitaet und bevorstehenden Ablauf. Benachrichtigt Admins via Nachrichtenzentrale.
api-tokens:check-alerts
Beispiele
# Standard-Check (Inaktivitaet + Ablauf)
php artisan api-tokens:check-alerts
Checks
| Check | Schwellwert | Config | Beschreibung |
|---|---|---|---|
| Inaktivitaet | 7 Tage | postbox.api_tokens.inactivity_alert_days | Token seit X Tagen nicht benutzt |
| Ablauf | 14 Tage | postbox.api_tokens.expiry_warning_days | Token laeuft in weniger als X Tagen ab |
Interne Logik
ApiTokenService::getInactiveTokens()— findet nicht-revozierte Tokens mitlast_used_at < now - X daysApiTokenService::getExpiringTokens()— findet nicht-revozierte Tokens mitexpires_atinnerhalb X Tagen- Pro Token wird ein Cache-Key gesetzt (24h TTL), um doppelte Alerts zu unterdruecken
- Benachrichtigung an alle Admin-User via
NotificationService::announceToUser()
Notification-Typen
| Typ | Icon | Beschreibung |
|---|---|---|
api_token_inactive | exclamation-triangle | Token seit X Tagen unbenutzt |
api_token_expiring | clock | Token laeuft in X Tagen ab |
Schedule
Taeglich um 08:00 UTC mit .withoutOverlapping().
Location: app/Console/Commands/CheckApiTokenAlerts.php
cron:check-heartbeats
Prüft alle konfigurierten Cron-Heartbeats gegen ihre max_minutes-Schwellwerte und sendet Alerts bei Ausfällen.
cron:check-heartbeats
Beispiele
# Standard: Alle Heartbeats prüfen
php artisan cron:check-heartbeats
Output
12/22 heartbeats OK, 0 FAILED, 10 NOT_REQUIRED, 0 MUTED
Bei Ausfällen werden Details angezeigt:
12/22 heartbeats OK, 1 FAILED, 9 NOT_REQUIRED, 0 MUTED
FAILED: Pipeline Run (2h) (180 min ago, max: 120 min)
→ Abhängigkeit: Kein vorgelagerter Fehler erkannt
Funktionsweise
- Liest alle Heartbeats aus
config('postbox.health.cron_heartbeats') - Prüft Alter gegen
max_minutes— überfällig =FAILED - Deploy-Grace-Period: Nach Deployment
NOT_REQUIREDstattFAILED - Muted Heartbeats werden übersprungen (
MUTED) - Bei Statuswechsel
ok→failed: sofortige Admin-Notification + Event-Log - Bei anhaltendem Ausfall: Cooldown verhindert Wiederholungen — Default 60 Minuten,
mindestens jedoch die
max_minutesdes Heartbeats selbst (siehe unten) - Eskalation (E3): nach
escalation_after_minutesohne Recovery, ebenfalls frühestens nachmax_minutes - Bei Recovery (
failed→ok): Recovery-Notification + Cache-Cleanup - Dependency-Check (E4): Bei Ausfall werden vorgelagerte Abhängigkeiten geprüft
- Der vom Lauf hinterlegte Fehlgrund wird an die Meldung angehängt, falls einer
erfasst wurde (
CronStatusService::recordFailureDetail())
Warum eine Wiederholung nie schneller kommt als der Lauf
Ein Wiederholungs-Alarm hat nur dann Inhalt, wenn sich zwischen zwei Meldungen etwas
geändert haben kann. Bei einem Tageslauf ist das nicht der Fall: Scheitert
env:lint um 06:45, steht dieser Zustand bis zum nächsten Lauf am Folgetag fest.
Bis September 2026 galt trotzdem der flache Standard von 60 Minuten für jeden Schlüssel. Das ergab rund 24 identische Mails für einen einzigen Fehlschlag, plus eine Eskalation nach einer Stunde — und beim Empfänger las sich das als Fehlalarm, obwohl der Befund stimmte.
Die Untergrenze ist deshalb max_minutes des jeweiligen Heartbeats:
| Heartbeat | max_minutes | wirksamer Cooldown |
|---|---|---|
server_alerts (5 min) | 15 | 60 min (der Standard ist größer) |
collector_prune_logs (6 h) | 480 | 480 min |
env_lint (täglich) | 1800 | 1800 min |
Ein ausdrücklich am Schlüssel gesetztes alert_cooldown_minutes wird nicht
angehoben — wer es setzt, meint es.
⚠️ Der Cooldown wird schon beim ERSTEN Alarm scharfgestellt. Bis September 2026
geschah das erst beim zweiten: Der Zweig für den neuen Fehlschlag endete direkt nach
dem Versand, der Cooldown-Schlüssel entstand eine Ebene tiefer. Beim nächsten
Wächter-Lauf war er deshalb noch nicht gesetzt, und die zweite Mail ging sofort raus —
cron:check-heartbeats läuft alle fünf Minuten, der eingestellte Cooldown galt für die
erste Wiederholung also nie.
Notification-Typen
| Typ | Beschreibung | Event-Log |
|---|---|---|
cron_heartbeat_failed | Heartbeat überfällig | failed Event |
cron_heartbeat_recovered | Heartbeat wiederhergestellt | recovered Event |
cron_heartbeat_escalation | Ausfall hält an — frühestens nach max_minutes | escalated Event |
cron_heartbeat_mute_reminder | Wöchentliche Erinnerung an stumm geschaltete Heartbeats | — |
Konfiguration
// config/postbox.php → cron_monitoring
'cron_monitoring' => [
'alert_cooldown_minutes' => 60, // Untergrenze; effektiv max(60, max_minutes)
'escalation_after_minutes' => 60, // Untergrenze; effektiv max(60, max_minutes)
'event_retention_days' => 30, // Event-Historie-Retention
'dependencies' => [ // Abhängigkeits-Map
'scores_calculate' => ['pipeline_run'],
'sitemap_generate' => ['public_explorer_refresh'],
// ...
],
],
Schedule
Alle 5 Minuten mit .withoutOverlapping(10).
Zusätzlich:
- Mute-Reminder: Wöchentlich Montag 09:00 UTC — erinnert Admins an stumm geschaltete Heartbeats
- Event-Pruning: Täglich 03:20 UTC — löscht Events älter als
event_retention_days
Location: app/Console/Commands/CheckCronHeartbeats.php, app/Services/CronMonitoring/CronHeartbeatMonitorService.php
Übersicht: Monitoring-Infrastruktur
┌─────────────────────┐
│ cron:check- │──── Heartbeat-Alerting (In-App Cron Monitoring)
│ heartbeats │
├─────────────────────┤
│ health:check- │──── Alert E-Mail bei fehlenden Scrape-/Video-Sync-Jobs
│ daily-scrapes │
├─────────────────────┤
│ queue:metrics- │──── 15-Min Snapshots für Admin Queue Charts
│ snapshot │
├─────────────────────┤
│ google:sync- │──── Stündliche API-Quota Synchronisierung
│ api-usage │
├─────────────────────┤
│ server:check- │──── CPU/RAM/Disk/DB-Alerts aus Pulse-Daten
│ alerts │
├─────────────────────┤
│ api-tokens: │──── Token-Inaktivitaet + Ablauf-Warnungen
│ check-alerts │
├─────────────────────┤
└─────────────────────┘
server:snapshot
Erfasst Server-Metriken (CPU, RAM, Swap, Disk, PG-Connections, PG-Cache-Hit) aus Pulse-Recordern in die eigene server_metrics Tabelle. Ersetzt direkte Pulse-Zeitreihen-Abfragen für langfristiges Monitoring.
# Standard: Snapshot erstellen
php artisan server:snapshot
# Alte Snapshots prunen (> 90 Tage)
php artisan server:snapshot --prune
| Option | Beschreibung |
|---|---|
--prune | Snapshots älter als Retention (90 Tage) löschen |
Interne Logik:
- Liest aktuelle Werte aus Pulse-Recordern (ServerMetrics, PublicStorageMetrics, IsolatedBeat)
- Erstellt einen
ServerMetricRecord mit Timestamp - Bei
--prune: löscht Records älter alsserver-monitoring.server_metrics.retention_days
Schedule:
server:snapshot— Alle 5 Minutenserver:snapshot --prune— Täglich 02:20 UTC
Location: app/Console/Commands/ServerSnapshot.php
server:rollup-hourly
Aggregiert 5-Minuten-Snapshots aus server_metrics zu stündlichen Durchschnittswerten in server_metrics_hourly. Ermöglicht langfristige Trend-Analyse (365 Tage Retention) ohne hohen Speicherverbrauch.
# Standard: gestrige Stunden aggregieren
php artisan server:rollup-hourly
# Bestimmten Tag aggregieren
php artisan server:rollup-hourly --date=2026-02-20
# Mehrere Tage rückwirkend
php artisan server:rollup-hourly --days=7
# Mit Pruning alter Rollups
php artisan server:rollup-hourly --prune
| Option | Typ | Default | Beschreibung |
|---|---|---|---|
--date | string | gestern | Bestimmtes Datum (YYYY-MM-DD) |
--days | int | 1 | Anzahl Tage rückwirkend |
--prune | flag | false | Rollups älter als Retention (365 Tage) löschen |
Schedule: Täglich 03:00 UTC (nach Snapshot-Prune)
Location: app/Console/Commands/ServerRollupHourly.php
pulse:check-size
Prüft wöchentlich die Größe der Pulse-Tabellen (pulse_entries, pulse_aggregates, pulse_values). Sendet Admin-Notification wenn die Gesamtgröße den Schwellwert überschreitet.
php artisan pulse:check-size
Schwellwert: 2 GB (konfigurierbar via server-monitoring.pulse_size_threshold_gb).
Schedule: Wöchentlich Sonntag 03:30 UTC
Location: app/Console/Commands/PulseCheckSize.php
/up_system — System Health Endpoint
Externer Health-Check-Endpoint für UptimeRobot und andere Monitoring-Tools. Gibt immer HTTP 200 zurück — der tatsächliche Status wird über den Response-Body kommuniziert (Keyword-Monitoring).
URL & Authentifizierung
GET /up_system
Header: X-Health-Token: <HEALTH_TOKEN>
Geschützt durch VerifyHealthToken-Middleware. Token wird in .env konfiguriert:
# Token für den /up_system Health-Endpoint
HEALTH_TOKEN=your-secret-token
Response-Format
=== TAGES-PIPELINE (2026-03-09) ===
Datensammlung:
[OK] Instagram Daily Scrape 3.200 -> 3.150 Abgeschl. 14:32
[>>] YouTube Daily Sync 1.800 -> 920 Läuft [##########..........] 51%
...
Verarbeitung:
[OK] Pipeline Run - -> - Abgeschl. 03:12
...
Job-Durchsatz (Collector): 1min=12 5min=58 15min=165
Job-Durchsatz (Queue): 1min=3 5min=14 15min=42
============================================================
[2026-03-09 14:35:00 UTC]
DATABASE RUNNING
Database connection OK
CACHE RUNNING
Cache connection OK
REVERB RUNNING
Reverb listening on 127.0.0.1:8081
QUEUE_WORKERS RUNNING
42 pending, 3 processing
FAILED_JOBS RUNNING
12 failed jobs
INSTAGRAM_COLLECTOR RUNNING
3150/3200 processed, 5 failed, 45 pending | 120 queued total, 340 low-prio profiles
YOUTUBE_DAILY_SYNC RUNNING
920/1800 (51.1%), 3 failed - in progress | 45 queued total, 180 low-prio profiles
YOUTUBE_VIDEO_SYNC RUNNING
85 video sync jobs queued at 14:02
COLLECTOR_HEARTBEAT RUNNING
Last activity: 2 min ago
API_TOKENS RUNNING
3 active tokens, 0 expiring
GOOGLE_API_QUOTA RUNNING
4500/30000 (15.0%) across 3 keys
EXPLORE_METRICS RUNNING
Explore metrics fresh: last calculated 8h ago
PIPELINE_STATUS RUNNING
12/14 steps completed (85%)
PULSE_TABLE_SIZE RUNNING
Pulse tables: 245.3 MB
CRON_INSTAGRAM_SCRAPE RUNNING
Instagram Scrape (hourly) — last run 45 min ago
CRON_YOUTUBE_SCRAPE RUNNING
YouTube Daily Sync (2h, 09-23 UTC) — last run 90 min ago
...
STATUS: ALL_SYSTEMS_OPERATIONAL
Health-Checks
Der Endpoint führt 14 statische Checks + dynamische CRON_*-Checks durch:
| Check | Service-Methode | Was wird geprüft | FAILED wenn |
|---|---|---|---|
| DATABASE | checkDatabase() | SELECT 1 | DB nicht erreichbar |
| CACHE | checkCache() | Cache put/get/forget | Redis/Cache nicht erreichbar |
| REVERB | checkReverb() | TCP-Socket auf Reverb-Port | WebSocket-Server down |
| QUEUE_WORKERS | checkQueueWorkers() | Pending Jobs + Worker-Aktivität | — (nur WARNING) |
| FAILED_JOBS | checkFailedJobs() | Anzahl failed_jobs | — (nur WARNING ab 100) |
| INSTAGRAM_COLLECTOR | checkInstagramDailyScrape() | Collector-Jobs für heute | 0 Jobs nach 01:00 UTC |
| YOUTUBE_DAILY_SYNC | checkYouTubeDailySync() | DailySyncRun für heute | Kein Run nach 09:00 UTC |
| YOUTUBE_VIDEO_SYNC | checkYouTubeVideoSync() | Cache-Marker | Kein Marker nach 15:00 UTC |
| COLLECTOR_HEARTBEAT | checkCollectorHeartbeat() | Letzte Collector-Aktivität | — (nur WARNING) |
| API_TOKENS | checkApiTokenHealth() | Token-Status via ApiTokenService | Check-Exception |
| GOOGLE_API_QUOTA | checkGoogleApiQuota() | YouTube API Quota-Nutzung | ≥95% verbraucht |
| EXPLORE_METRICS | checkExploreMetricsFreshness() | daily_calculated_at Alter | >72h stale |
| PIPELINE_STATUS | checkPipelineStatus() | PipelineStatusService Progress | — (nur WARNING bei failed/overdue) |
| PULSE_TABLE_SIZE | checkPulseTableSize() | pg_total_relation_size() | — (nur WARNING ab 2 GB) |
| CRON_* (dynamisch) | checkCronHeartbeats() | Cache-Key cron:heartbeat:{key} | Heartbeat älter als max_minutes |
Status-Logik
| Status | Bedeutung | UptimeRobot |
|---|---|---|
RUNNING | Check bestanden | — |
WARNING | Auffällig, aber nicht kritisch | Kein Alert (zählt als operational) |
WAITING | Noch nicht gestartet (Zeitfenster, Deploy-Grace) | Kein Alert |
FAILED | Check fehlgeschlagen | Alert (SYSTEM_DEGRADED) |
Gesamtstatus:
ALL_SYSTEMS_OPERATIONAL— kein Check hatFAILED(WARNING/WAITING sind OK)SYSTEM_DEGRADED— mindestens ein Check hatFAILED
UptimeRobot prüft auf das Keyword ALL_SYSTEMS_OPERATIONAL. Fehlt es → Alert.
Deploy-Grace-Period
Nach einem Deployment oder Cache-Clear werden CRON_*-Checks für die Dauer ihres max_minutes-Werts als WAITING statt FAILED gemeldet. Der Deploy-Zeitpunkt wird automatisch im Cache gespeichert (deploy:timestamp, 48 h TTL).
Beide Auswerter teilen sich dieselbe Quelle: CronStatusService::minutesSinceDeploy(). Der Health-Endpoint (/up_system) und der alarmierende Scheduled Command (cron:check-heartbeats) müssen zum selben Ergebnis kommen — und beide legen den Marker selbst an, wenn er fehlt.
Das ist keine Feinheit, sondern die Bedingung dafür, dass die Schonfrist überhaupt wirkt. Der Marker liegt in derselben Cache-Gruppe wie die Heartbeats, die er schützt (pipeline, Präfixe cron:heartbeat: und deploy:), und verschwindet deshalb immer gemeinsam mit ihnen:
| Auslöser | Wirkung |
|---|---|
Deployment (optimize:clear) | FLUSHDB auf der Cache-Connection — beide weg |
Cache-Gruppe pipeline im Admin leeren | beide weg |
| Redis-Neustart | beide weg (Store läuft bewusst ohne RDB/AOF) |
| LRU-Eviction unter Speicherdruck | Heartbeats zuerst — sie sind die kältesten Keys |
Wertet ein Pfad den fehlenden Marker als „keine Schonfrist", meldet er jeden noch nicht wieder geschriebenen Heartbeat als Ausfall. Bei einem Tagesjob dauert dieser Zustand bis zum nächsten Lauf — also bis zu 24 Stunden stündlicher Fehlalarme für ein gesundes System. Genau das trat bis Juli 2026 auf, weil nur der Health-Endpoint den Marker neu anlegte.
Merksatz für die Fehlersuche: Ein fehlender Heartbeat bedeutet nie „Command fehlgeschlagen". Laravel ruft die after-Callbacks unabhängig vom Exit-Code auf — ein roter Command schreibt seinen Heartbeat trotzdem. Fehlt er, wurde der Callback nicht erreicht: das Event wurde übersprungen (Overlap-Schutz), der Scheduler lief nicht, oder der Cache-Eintrag ist verschwunden.
Quiet Hours
Queue-Worker-Check gibt WAITING statt WARNING zurück wenn die aktuelle UTC-Stunde im konfigurierten Quiet-Hours-Fenster liegt (Default: 04:00–06:00 UTC).
// config/postbox.php → health
'queue_quiet_start' => 4, // UTC-Stunde Start
'queue_quiet_end' => 6, // UTC-Stunde Ende
Unterstützt Wrap-Around (z.B. start=23, end=5 für 23:00–04:59 UTC). Deaktiviert wenn start === end.
Konfiguration
// config/postbox.php → health
'health' => [
'failed_jobs_threshold' => 100, // WARNING ab X failed_jobs
'collector_heartbeat_minutes' => 30, // Collector-Inaktivitäts-Schwelle
'queue_pending_threshold' => 5000, // Queue-Backlog WARNING-Schwelle
'queue_activity_window_minutes' => 30, // Zeitfenster für Worker-Aktivität
'queue_quiet_start' => 4, // Quiet Hours Start (UTC)
'queue_quiet_end' => 6, // Quiet Hours Ende (UTC)
'cron_heartbeats' => [ // Dynamische CRON_*-Checks
'key' => ['max_minutes' => 120, 'label' => 'Description'],
// ... alle heartbeat-Einträge
],
],
Wie max_minutes bemessen wird — es misst ABSCHLÜSSE, nicht Starts
Der Heartbeat wird im ->after()-Callback geschrieben, also wenn der Lauf fertig
ist. Der Abstand zwischen zwei Herzschlägen ist deshalb
Intervall + (Laufzeit heute − Laufzeit gestern)
und nicht schlicht das Intervall. Ein Tageslauf mit schwankender Laufzeit erzeugt von sich aus schwankende Abstände, ohne dass irgendetwas kaputt wäre.
Gemessen auf Produktion am 06.09.2026 (alle Zeiten UTC): Der 04:00-Batch endete um
20:22 und 20:23 — 16 h 23 min nach seinem Zeitplan. og-images:cleanup und
pages:warm --force liegen dabei eine Minute auseinander, weil sie sequenziell im
selben Batch laufen; der Batch als Ganzes ist der Verzögerer, nicht ein einzelner
Befehl. Weitere gemessene Drifts: indexnow_submit 11 h 35, websub_manage 5 h 17,
seo_sync_search_console und api_token_alerts je 4 h 43, youtube_stats_tier_review
4 h 33.
Gegen diese Wirklichkeit war der bis dahin übliche Wert von 26 h (24 h + 2 h) zu knapp:
zwei Stunden Vorlauf für einen Lauf, dessen Abschluss um Stunden wandert. Der Alarm
feuerte auf Schwankung statt auf Ausfall — gemeldet wurden 2377 Minuten für
page_warming_full, während die Bahn gesund war.
Das Fenster ist deshalb Intervall + Vorlauf:
| Klasse | Fenster | Vorlauf |
|---|---|---|
| Tageslauf, Standard | 30 * 60 | 24 h + 6 h |
| Tageslauf mit gemessener Drift | 36–48 * 60 | 24 h + gemessen + Reserve |
| Wochenlauf | 9 * 24 * 60 | 7 d + 48 h |
Jeder Wert über dem Standard trägt seine Messung als Kommentar in config/postbox.php
— eine Zahl ohne Begründung wird beim nächsten Aufräumen wieder gekürzt.
⚠️ Das macht die Überwachung nicht stumpfer. Ein Lauf, der startet und
scheitert, wird über den Ergebniszustand erkannt (succeeded) und alarmiert sofort,
unabhängig von diesem Fenster. Gelockert wird allein die Frage „lief er überhaupt", und
nur um den Betrag, um den seine eigene Laufzeit schwankt.
Dateien
| Datei | Beschreibung |
|---|---|
app/Http/Controllers/SystemHealthController.php | Controller, formatiert Plain-Text-Output |
app/Services/Health/SystemHealthService.php | Service mit allen Health-Checks |
app/Http/Middleware/VerifyHealthToken.php | Token-Authentifizierung |
routes/web.php | Route-Definition (/up_system) |
config/postbox.php | Schwellwerte und Heartbeat-Konfiguration |
health:log-snapshot
Loggt alle Health-Check-Ergebnisse in die health_check_logs-Tabelle für Kalibrierungs-Analyse.
health:log-snapshot
{--diff-only : Nur Status-Änderungen loggen}
Beispiele
# Alle Checks loggen
php artisan health:log-snapshot
# Nur geänderte Status loggen (spart Speicher)
php artisan health:log-snapshot --diff-only
Optionen
| Option | Beschreibung |
|---|---|
--diff-only | Vergleicht mit dem vorherigen Snapshot und loggt nur Checks mit geändertem Status |
Funktionsweise
- Ruft
SystemHealthService::runAllChecks()auf - Speichert jeden Check als eigene Row in
health_check_logs - Bei
--diff-only: Vergleicht mit Cache-Keyhealth:last_snapshot_statusesund loggt nur Änderungen - Auto-Prune: Model nutzt
MassPrunable(14 Tage Retention)
Schedule
Alle 5 Minuten mit --diff-only und .withoutOverlapping(10).
Location: app/Console/Commands/LogHealthSnapshot.php
health:analyze-logs
Analysiert Health-Check-Logs für Schwellwert-Kalibrierung. Zeigt Status-Verteilung pro Check, stündliche Degradation-Raten und Percentile-basierte Schwellwert-Empfehlungen.
health:analyze-logs
{--days=7 : Anzahl der Tage für die Analyse}
{--check= : Nur einen bestimmten Check analysieren}
Beispiele
# Letzte 7 Tage analysieren
php artisan health:analyze-logs
# Letzter Tag, nur YouTube Scrape
php artisan health:analyze-logs --days=1 --check=CRON_YOUTUBE_SCRAPE
Optionen
| Option | Beschreibung |
|---|---|
--days= | Analysezeitraum in Tagen (Default: 7) |
--check= | Nur einen bestimmten Check analysieren |
Output
- Per-Check Status-Verteilung: Visueller Balkendiagramm mit Prozentangaben
- Stündliche SYSTEM_DEGRADED Rate: Zeigt zu welchen Uhrzeiten Probleme auftreten
- Schwellwert-Empfehlungen (E3): P50/P90/P95/P99 Percentile + empfohlene
max_minutes(P99 × 1.2) - Quiet-Hours-Empfehlungen: Wenn Failures in zusammenhängenden Stunden auftreten
Hinweise
- Benötigt PostgreSQL (verwendet
EXTRACT(HOUR FROM ...)Syntax) - Mindestens 5 Datenpunkte pro Check für Schwellwert-Empfehlungen
- Empfohlene Schwellwerte basieren auf P99 + 20% Puffer
Location: app/Console/Commands/AnalyzeHealthLogs.php