Zum Hauptinhalt springen
Schneespur
Dokumentation durchblättern

Module entwickeln

Diagnose & Logging

Wie Module über den ModuleLogger nachvollziehbare Log-Einträge schreiben und über Diagnostic-Reporter Fehler an externe Dienste melden — mit Beispielen und Best Practices.

Ein Modul, das im Hintergrund Wetterdaten abruft, PDFs erzeugt oder Aufgaben plant, braucht eine Spur, die sich später nachvollziehen lässt. Schneespur trennt diese Spur in zwei Ebenen: nachvollziehbare, dauerhafte Modul-Logs in der Datenbank und optionale Fehlermeldungen an externe Dienste. Beide Ebenen sind so gebaut, dass ein Modul sie nutzt, ohne den Core zu verändern.

Modul-Logs mit dem ModuleLogger

Der ModuleLogger-Dienst schreibt strukturierte Log-Einträge, die dauerhaft in der Tabelle mod_logs landen. Anders als ein flüchtiger Datei-Log sind diese Einträge in der Administrationsoberfläche sichtbar — wer ein Modul betreibt, sieht dort, was es zuletzt getan hat.

use App\Services\ModuleLogger;

// Über den Service-Container
$logger = app(ModuleLogger::class);

// Oder über die statische Factory
$logger = ModuleLogger::make();

// Log-Level
$logger->info('my-module', 'Settings updated', ['key' => 'api_key']);
$logger->warning('my-module', 'Rate limit approaching', ['remaining' => 5]);
$logger->error('my-module', 'API call failed', ['status' => 500, 'url' => $url]);

// Generischer Aufruf mit eigenem Level
$logger->log('my-module', 'debug', 'Webhook received', ['payload' => $data]);

Der erste Parameter ist immer der Modul-Slug. Dadurch lässt sich jeder Eintrag eindeutig einem Modul zuordnen — auch wenn mehrere Module gleichzeitig aktiv sind. Der dritte Parameter, das Kontext-Array, ist der Grund, warum diese Logs später brauchbar sind: Statt einer reinen Textmeldung speichert er die relevanten IDs, Zähler oder Statuswerte strukturiert ab.

Das ModLog-Modell

Jeder Eintrag wird über das ModLog-Modell abgebildet. Die Felder sind bewusst knapp gehalten:

// Felder
module_slug  // string — welches Modul den Eintrag erzeugt hat
level        // string — 'info', 'warning', 'error', 'debug'
message      // text — menschenlesbare Meldung
context      // JSON|null — strukturierte Kontextdaten
created_at   // datetime

Zum Auswerten stehen vorbereitete Scopes bereit, sodass Sie nicht selbst Abfragen zusammenbauen müssen:

ModLog::forModule('my-module')->get();
ModLog::ofLevel('error')->get();
ModLog::recent(50)->get(); // die letzten 50 Einträge

Logs ansehen

Die Administrationsoberfläche enthält über den AdminModuleLogController einen Log-Betrachter mit Filterung nach Level und Seitennummerierung. Erreichbar ist er von der Modul-Verwaltung aus. So müssen Betreiber für die Fehlersuche nicht auf den Server, sondern sehen die Modul-Spur direkt im Browser.

Diagnostic-Reporter für externe Dienste

Modul-Logs beantworten die Frage „Was ist passiert?” innerhalb der eigenen Installation. Wenn ein unerwarteter Fehler an einen externen Dienst gemeldet werden soll — etwa an Sentry oder Bugsnag —, übernimmt das ein Diagnostic-Reporter. Er ist über ein Interface definiert, damit jeder Dienst auf dieselbe Art angebunden wird:

interface DiagnosticReporterInterface
{
    /**
     * @param string $type  e.g. 'exception', 'cron_failed', 'module_boot_failed'
     * @param array $payload  Sanitized event data
     * @param array $context  Route, user role, version, etc.
     */
    public function report(string $type, array $payload = [], array $context = []): void;

    public function isEnabled(): bool;

    /** @return array{ok: bool, message: string, latency_ms: int} */
    public function testConnection(): array;
}

Die drei Methoden teilen die Aufgaben klar auf: report() meldet ein Ereignis, isEnabled() entscheidet, ob der Reporter überhaupt aktiv ist (etwa nur, wenn ein DSN hinterlegt wurde), und testConnection() erlaubt einen Verbindungstest aus der Oberfläche heraus, der Erfolg und Laufzeit zurückgibt.

Wie die Diagnose intern arbeitet

Hinter dem Interface stehen drei Bausteine, die zusammenspielen:

  1. DiagnosticManager — steuert die Meldung und verteilt sie an alle aktiven Reporter.
  2. DiagnosticPayloadSanitizer — entfernt sensible Daten, bevor etwas das Haus verlässt.
  3. DiagnosticReporterRegistry — hält alle registrierten Reporter.

Der Sanitizer ist hier kein Beiwerk, sondern der Grund, warum diese Meldungen datenschutzfreundlich bleiben: Bevor ein Ereignis an einen Drittdienst geht, werden sensible Felder entfernt. Zusätzlich meldet der ModuleManager automatisch ein module_boot_failed-Ereignis, wenn ein Modul beim Booten abstürzt — ohne dass das Modul dafür selbst etwas tun muss.

Ein Reporter-Modul bauen

Ein Reporter ist eine Klasse, die das Interface umsetzt. Das folgende Beispiel bindet Sentry an und zeigt, wie die drei Methoden zusammenwirken:

namespace Schneespur\Module\Sentry\Diagnostic;

use App\Models\Setting;
use App\Services\Diagnostic\DiagnosticReporterInterface;
use Illuminate\Support\Facades\Http;

class SentryReporter implements DiagnosticReporterInterface
{
    public function report(string $type, array $payload = [], array $context = []): void
    {
        $dsn = Setting::get('sentry.dsn');

        Http::post($dsn, [
            'event_id' => uuid_create(),
            'level' => $this->mapLevel($type),
            'message' => $type,
            'extra' => array_merge($payload, $context),
            'tags' => [
                'schneespur_version' => config('app.version'),
                'type' => $type,
            ],
        ]);
    }

    public function isEnabled(): bool
    {
        return !empty(Setting::get('sentry.dsn'));
    }

    public function testConnection(): array
    {
        $start = microtime(true);
        try {
            $this->report('test', ['message' => 'Connection test']);
            return [
                'ok' => true,
                'message' => 'Test event sent',
                'latency_ms' => (int) ((microtime(true) - $start) * 1000),
            ];
        } catch (\Throwable $e) {
            return [
                'ok' => false,
                'message' => $e->getMessage(),
                'latency_ms' => (int) ((microtime(true) - $start) * 1000),
            ];
        }
    }
}

Registriert wird der Reporter — wie alle Erweiterungen — in der boot()-Phase des ServiceProviders über die zuständige Registry:

app(DiagnosticReporterRegistry::class)->register('sentry', SentryReporter::class);

Die Registrierung läuft über dieselbe Mechanik wie die übrigen Erweiterungspunkte; Details zu den Phasen finden Sie unter ServiceProvider, die Liste aller Registries unter Registries.

Diagnose-Ereignistypen

Der Typ-Parameter von report() benennt, was passiert ist. Drei Typen sind fest vergeben:

TypWird ausgelöst bei
exceptionunbehandelter Ausnahme
cron_failedfehlgeschlagener geplanter Aufgabe
module_boot_failedModul, das beim Booten eine Ausnahme wirft

Best Practices fürs Logging

Welches Werkzeug Sie wählen, hängt davon ab, wer den Eintrag später lesen soll:

  1. ModuleLogger für strukturierte, dauerhafte Logs — nicht Log::info(). Modul-Logs erscheinen in der Administrationsoberfläche.
  2. Laravels Log-Facade nur für reine Debug-Ausgaben — diese landen in storage/logs/laravel.log und sind für Administratoren nicht sichtbar.
  3. Kontext mitgeben — übergeben Sie immer die relevanten IDs, Zähler oder Zustände im Kontext-Array.
  4. Keine sensiblen Daten loggen — niemals API-Schlüssel, Tokens, Passwörter oder personenbezogene Kundendaten.
  5. Das passende Level wählen:
    • info — normaler Betrieb (Einstellung geändert, Abgleich abgeschlossen)
    • warning — behebbare Probleme (Rate-Limit, genutzter Fallback)
    • error — Fehlschläge (API-Fehler, fehlende Konfiguration)
  6. Meldungen kurz und konkret halten — „API call failed: 403 Forbidden” statt „An error occurred while trying to call the external API endpoint.”

Diese Trennung hat einen praktischen Hintergrund: Ein Betreiber, der ein Modul prüft, soll in der Oberfläche genau die Einträge sehen, die ihn betreffen — und nicht in einer Debug-Logdatei nach der einen relevanten Zeile suchen.

Automatische Abschaltung bei Boot-Fehler

Wirft der ServiceProvider eines Moduls während boot() eine Ausnahme, greift ein Schutzmechanismus, damit ein einzelnes fehlerhaftes Modul nicht die ganze Anwendung mitreißt:

  1. Das Modul wird sofort deaktiviert (autoDisable()).
  2. Ein module_boot_failed-Diagnose-Ereignis wird abgesetzt.
  3. Ein Fehler-Eintrag in mod_logs wird erzeugt.
  4. Der Rest der Anwendung läuft normal weiter.

Der Administrator sieht den Grund des Fehlschlags im Modul-Log und kann das Modul nach der Behebung wieder aktivieren. Die Spur bleibt also auch dann erhalten, wenn das Modul selbst nicht mehr lädt.