Zum Hauptinhalt springen

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​

OptionBeschreibung
--dry-runZeigt Ergebnisse und Alert-Inhalte ohne E-Mail-Versand

Checks​

PlattformPrüfungAlert-BedingungQuelle
InstagramCache-Marker + Job-CountNur wenn Queue-Command gar nicht gelaufen ist (0 Jobs UND kein Cache-Marker)collector_jobs + Cache health:instagram_queue_ran:{date}
YouTubedaily_sync_runs Record für heuteexpected_profiles = 0 (kein Sync-Run erstellt)daily_sync_runs Tabelle
YouTube Video SyncCache-Marker (ab 15:00 UTC)Kein Cache-Marker health:youtube_video_sync_ran:{date} nach 15:00 UTCCache (gesetzt von youtube:sync-video-stats, TTL: 36h UTC)
Stuck LeasesExpired Leases > 10 MinMehr als 100 gelockte Jobs mit abgelaufenem Leasecollector_jobs (status=leased, lease_expires_at < now-10min)
Progress StagnationCompletion Rate nach 2hWeniger als 10% Instagram-Jobs nach >2h abgeschlossencollector_jobs (nur wenn Cache-Marker existiert)

Instagram-Check: Cache-Marker vs. Job-Count​

Der Instagram-Check nutzt einen zweistufigen Ansatz:

  • Job-Count: Alle collector_jobs mit source=instagram für heute zählen
  • Cache-Marker: health:instagram_queue_ran:{date} wird von social:queue-daily-instagram gesetzt 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.

MarkerGesetzt vonTTL
health:instagram_queue_ran:{date}social:queue-daily-instagram36h (UTC)
health:youtube_video_sync_ran:{date}youtube:sync-video-stats36h (UTC)
health:daily-scrape-alert:{date}health:check-daily-scrapesbis endOfDay()

Interne Schritte​

  1. Instagram-Jobs fuer heute zaehlen + Cache-Marker pruefen
  2. YouTube-Status laden (DailySyncRun fuer heute)
  3. YouTube Video Sync Cache-Marker pruefen (erst ab 15:00 UTC, da der Command um 14:00 laeuft)
  4. Stuck Leases pruefen (>100 abgelaufene Leases aelter als 10 Minuten)
  5. Progress Stagnation pruefen (<10% abgeschlossen nach >2h, nur wenn Cache-Marker existiert)
  6. Alert nur generieren wenn eine Plattform echtes Problem hat (s.o.)
  7. Daily Cooldown: Pro Tag wird maximal eine Alert-E-Mail gesendet (Cache-Key: health:daily-scrape-alert:{date})
  8. E-Mail an alle config('postbox.admin_emails') senden
  9. E-Mail-Versand wird ueber LogSentMail Listener 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​

OptionBeschreibung
--from=Backfill Start-Zeitpunkt (UTC)
--to=Backfill End-Zeitpunkt (UTC)

Metriken-Serien​

Jeder 15-Minuten-Snapshot enthält pro Plattform:

SerieBeschreibung
totalGesamtzahl aller Jobs
todoNoch nicht begonnene Jobs
queuedIn der Queue wartend
leasedAktuell in Bearbeitung
completedErfolgreich abgeschlossen
errorFehlgeschlagen

Interne Schritte​

  1. Aktuelles 15-Minuten-Bucket bestimmen (UTC)
  2. Collector-Jobs (Instagram): Status-Counts aus collector_jobs aggregieren
  3. Laravel Queue-Jobs (YouTube): Counts aus jobs Tabelle nach Queue-Name mappen
  4. YouTube Daily Scrape: Counts aus social_profile_daily_metrics und Failures aus daily_sync_runs
  5. Totals inkludieren offenen Backlog (Jobs von Vortagen), damit Charts nicht bei UTC-Mitternacht auf 0 fallen
  6. "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​

OptionBeschreibung
--from=Start-Zeitpunkt (Pflicht)
--to=End-Zeitpunkt (Pflicht)
--queue=Queue-Name (z.B. imports-youtube, imports-youtube-video, ai-detection)
--dry-runZeigt was retried würde, ohne Ausführung

Hinweise​

  • Arbeitet mit Jobs aus der failed_jobs Tabelle (Laravel Standard)
  • Für Instagram-Jobs stattdessen collector:retry-failed verwenden

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​

  1. Quota Limits: Abruf über serviceusage.googleapis.com/v1beta1 API
  2. Quota Usage: Abruf über monitoring.googleapis.com/v3 API (timeSeries mit rate/net_usage)
  3. Daten in google_api_quota_limits und google_api_quota_usages Tabellen speichern
  4. 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​

OptionBeschreibung
--host=Bind-Adresse (Default: 127.0.0.1, Production: 0.0.0.0)
--port=Port (Default: 8081, Nginx leitet dahin weiter)
--debugDebug-Logging für Verbindungen/Nachrichten aktivieren

Registrierte Event-Channels​

ChannelEventsBeschreibung
user.{id}Daily Scrape, Sync, Sanitize, ReactivatePer-User Notifications
profile.{id}Related Profiles CalculatedPer-Profile Updates
workspace.{id}Import ProgressWorkspace-weite Events
adminUser RegisteredAdmin-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​

MetrikQuelleBeschreibung
cpu_percentPulse systemCPU-Auslastung in Prozent
ram_percentPulse systemRAM-Auslastung (berechnet aus memory_used / memory_total)
disk_percentPulse systemFestplatten-Auslastung (erstes Storage-Volume)
swap_percentPulse swap_metricsSwap-Füllstand in Prozent. Wird erhoben, alarmiert aber nicht (steht nicht im alerts-Block)
pg_connectionsPulse pg_metricsGesamtzahl der Client-Verbindungen (connections_total), gedacht gegen max_connections
pg_cache_hitPulse pg_metricsFensterwert 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​

  1. Pulse Storage-Werte auslesen (system, swap_metrics, pg_metrics)
  2. JSON-Werte parsen und in Metriken-Array überführen
  3. ServerAlertService::check($metrics) aufrufen — prüft gegen Schwellwerte
  4. Benachrichtigung in der Nachrichtenzentrale und, je nach Einstellung, per E-Mail. Jede Stufe meldet sich je Metrik höchstens einmal pro Cooldown-Fenster (Details: ServerAlertService in 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​

  1. Prüft ob Mail aktuell pausiert ist (MailFloodGuard::isPaused())
  2. Falls nicht pausiert: Info-Meldung, kein Fehler
  3. 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​

BereichMetriken
Flood GuardStatus (Aktiv/Pausiert), Mails/Minute, Mails/Stunde, Limits
Mail-Log (24h)Gesendet gesamt, Gesendet letzte Stunde, Fehlgeschlagen
Top Mail-TypenDie 10 häufigsten Mail-Typen der letzten 24h mit Anzahl

Interne Logik​

  1. MailFloodGuard::getStatus() für Redis-Counter und Pause-Status
  2. MailLog Queries für 24h- und 1h-Statistiken
  3. Gruppierung nach mail_type fü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​

OptionTypDefaultBeschreibung
--date=stringGesternDatum 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​

  1. mail_log Einträge für das Datum gruppiert nach mail_type zählen
  2. Erfolgreiche Mails → Channel email in notification_stats upserten
  3. Fehlgeschlagene Mails → Channel email_failed in notification_stats upserten
  4. 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​

CheckSchwellwertConfigBeschreibung
Inaktivitaet7 Tagepostbox.api_tokens.inactivity_alert_daysToken seit X Tagen nicht benutzt
Ablauf14 Tagepostbox.api_tokens.expiry_warning_daysToken laeuft in weniger als X Tagen ab

Interne Logik​

  1. ApiTokenService::getInactiveTokens() — findet nicht-revozierte Tokens mit last_used_at < now - X days
  2. ApiTokenService::getExpiringTokens() — findet nicht-revozierte Tokens mit expires_at innerhalb X Tagen
  3. Pro Token wird ein Cache-Key gesetzt (24h TTL), um doppelte Alerts zu unterdruecken
  4. Benachrichtigung an alle Admin-User via NotificationService::announceToUser()

Notification-Typen​

TypIconBeschreibung
api_token_inactiveexclamation-triangleToken seit X Tagen unbenutzt
api_token_expiringclockToken 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​

  1. Liest alle Heartbeats aus config('postbox.health.cron_heartbeats')
  2. Prüft Alter gegen max_minutes — überfällig = FAILED
  3. Deploy-Grace-Period: Nach Deployment NOT_REQUIRED statt FAILED
  4. Muted Heartbeats werden übersprungen (MUTED)
  5. Bei Statuswechsel ok → failed: sofortige Admin-Notification + Event-Log
  6. Bei anhaltendem Ausfall: Cooldown verhindert Wiederholungen — Default 60 Minuten, mindestens jedoch die max_minutes des Heartbeats selbst (siehe unten)
  7. Eskalation (E3): nach escalation_after_minutes ohne Recovery, ebenfalls frühestens nach max_minutes
  8. Bei Recovery (failed → ok): Recovery-Notification + Cache-Cleanup
  9. Dependency-Check (E4): Bei Ausfall werden vorgelagerte Abhängigkeiten geprüft
  10. 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:

Heartbeatmax_minuteswirksamer Cooldown
server_alerts (5 min)1560 min (der Standard ist größer)
collector_prune_logs (6 h)480480 min
env_lint (täglich)18001800 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​

TypBeschreibungEvent-Log
cron_heartbeat_failedHeartbeat überfälligfailed Event
cron_heartbeat_recoveredHeartbeat wiederhergestelltrecovered Event
cron_heartbeat_escalationAusfall hält an — frühestens nach max_minutesescalated Event
cron_heartbeat_mute_reminderWö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
OptionBeschreibung
--pruneSnapshots älter als Retention (90 Tage) löschen

Interne Logik:

  1. Liest aktuelle Werte aus Pulse-Recordern (ServerMetrics, PublicStorageMetrics, IsolatedBeat)
  2. Erstellt einen ServerMetric Record mit Timestamp
  3. Bei --prune: löscht Records älter als server-monitoring.server_metrics.retention_days

Schedule:

  • server:snapshot — Alle 5 Minuten
  • server: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
OptionTypDefaultBeschreibung
--datestringgesternBestimmtes Datum (YYYY-MM-DD)
--daysint1Anzahl Tage rückwirkend
--pruneflagfalseRollups ä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:

CheckService-MethodeWas wird geprüftFAILED wenn
DATABASEcheckDatabase()SELECT 1DB nicht erreichbar
CACHEcheckCache()Cache put/get/forgetRedis/Cache nicht erreichbar
REVERBcheckReverb()TCP-Socket auf Reverb-PortWebSocket-Server down
QUEUE_WORKERScheckQueueWorkers()Pending Jobs + Worker-Aktivität— (nur WARNING)
FAILED_JOBScheckFailedJobs()Anzahl failed_jobs— (nur WARNING ab 100)
INSTAGRAM_COLLECTORcheckInstagramDailyScrape()Collector-Jobs für heute0 Jobs nach 01:00 UTC
YOUTUBE_DAILY_SYNCcheckYouTubeDailySync()DailySyncRun für heuteKein Run nach 09:00 UTC
YOUTUBE_VIDEO_SYNCcheckYouTubeVideoSync()Cache-MarkerKein Marker nach 15:00 UTC
COLLECTOR_HEARTBEATcheckCollectorHeartbeat()Letzte Collector-Aktivität— (nur WARNING)
API_TOKENScheckApiTokenHealth()Token-Status via ApiTokenServiceCheck-Exception
GOOGLE_API_QUOTAcheckGoogleApiQuota()YouTube API Quota-Nutzung≥95% verbraucht
EXPLORE_METRICScheckExploreMetricsFreshness()daily_calculated_at Alter>72h stale
PIPELINE_STATUScheckPipelineStatus()PipelineStatusService Progress— (nur WARNING bei failed/overdue)
PULSE_TABLE_SIZEcheckPulseTableSize()pg_total_relation_size()— (nur WARNING ab 2 GB)
CRON_* (dynamisch)checkCronHeartbeats()Cache-Key cron:heartbeat:{key}Heartbeat älter als max_minutes

Status-Logik​

StatusBedeutungUptimeRobot
RUNNINGCheck bestanden—
WARNINGAuffällig, aber nicht kritischKein Alert (zählt als operational)
WAITINGNoch nicht gestartet (Zeitfenster, Deploy-Grace)Kein Alert
FAILEDCheck fehlgeschlagenAlert (SYSTEM_DEGRADED)

Gesamtstatus:

  • ALL_SYSTEMS_OPERATIONAL — kein Check hat FAILED (WARNING/WAITING sind OK)
  • SYSTEM_DEGRADED — mindestens ein Check hat FAILED

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öserWirkung
Deployment (optimize:clear)FLUSHDB auf der Cache-Connection — beide weg
Cache-Gruppe pipeline im Admin leerenbeide weg
Redis-Neustartbeide weg (Store läuft bewusst ohne RDB/AOF)
LRU-Eviction unter SpeicherdruckHeartbeats 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:

KlasseFensterVorlauf
Tageslauf, Standard30 * 6024 h + 6 h
Tageslauf mit gemessener Drift36–48 * 6024 h + gemessen + Reserve
Wochenlauf9 * 24 * 607 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​

DateiBeschreibung
app/Http/Controllers/SystemHealthController.phpController, formatiert Plain-Text-Output
app/Services/Health/SystemHealthService.phpService mit allen Health-Checks
app/Http/Middleware/VerifyHealthToken.phpToken-Authentifizierung
routes/web.phpRoute-Definition (/up_system)
config/postbox.phpSchwellwerte 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​

OptionBeschreibung
--diff-onlyVergleicht mit dem vorherigen Snapshot und loggt nur Checks mit geändertem Status

Funktionsweise​

  1. Ruft SystemHealthService::runAllChecks() auf
  2. Speichert jeden Check als eigene Row in health_check_logs
  3. Bei --diff-only: Vergleicht mit Cache-Key health:last_snapshot_statuses und loggt nur Änderungen
  4. 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​

OptionBeschreibung
--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