Skip to main content
Wintertrace
Browse the documentation

Building modules

Diagnostics & logging

Register diagnostic reporters and emit logs from a module so operators can see its health on the diagnostics page.

A module that fetches weather data in the background, generates PDFs, or runs scheduled tasks needs a trace that can be reviewed later. The application separates this trace into two layers: structured, persistent module logs in the database and optional error reports to external services. Both are built so a module can use them without modifying the core.

Module logs with ModuleLogger

The ModuleLogger service writes structured log entries that are persisted to the mod_logs table. Unlike a transient file log, these entries are visible in the admin UI — an operator running a module can see directly in the browser what it last did.

use App\Services\ModuleLogger;

// Via app container
$logger = app(ModuleLogger::class);

// Or via static factory
$logger = ModuleLogger::make();

// Log levels
$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]);

// Generic log with custom level
$logger->log('my-module', 'debug', 'Webhook received', ['payload' => $data]);

The first parameter is always the module slug. This makes every entry unambiguously attributable to a module, even when multiple modules are active simultaneously. The third parameter — the context array — is what makes these logs useful later: instead of a plain text message, it stores the relevant IDs, counters, or status values in a structured form.

The ModLog model

Each entry is represented by the ModLog model. The fields are deliberately minimal:

// Fields
module_slug  // string — which module created this log
level        // string — 'info', 'warning', 'error', 'debug'
message      // text — human-readable message
context      // JSON|null — structured context data
created_at   // datetime

// Scopes
ModLog::forModule('my-module')->get();
ModLog::ofLevel('error')->get();
ModLog::recent(50)->get(); // last 50 entries

Viewing logs

The admin UI has a log viewer at AdminModuleLogController, with level filtering and pagination. It is accessible from the module management page. Operators do not need to access the server to investigate an issue — the module’s trace is visible directly in the browser.

Diagnostic reporters for external services

Module logs answer “what happened?” within the local installation. When an unexpected error should be reported to an external service — such as Sentry or Bugsnag — that is the job of a diagnostic reporter. It is defined through an interface so that every service is connected in the same way:

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;
}

The three methods divide responsibilities clearly: report() sends an event, isEnabled() decides whether the reporter is active at all (for instance only when a DSN has been configured), and testConnection() allows a connection test from the UI that returns success status and latency.

How diagnostics work internally

Three components work together behind the interface:

  1. DiagnosticManager — orchestrates reporting and dispatches to all active reporters.
  2. DiagnosticPayloadSanitizer — strips sensitive data before anything leaves the system.
  3. DiagnosticReporterRegistry — holds all registered reporters.

The sanitiser is not an afterthought — it is the reason these reports remain privacy-conscious: before an event reaches a third-party service, sensitive fields are removed. Additionally, the ModuleManager automatically reports a module_boot_failed event when a module crashes during boot, without the module needing to do anything itself.

Building a diagnostic reporter module

A reporter is a class that implements the interface. The following example connects Sentry and shows how the three methods work together:

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),
            ];
        }
    }
}

Register the reporter — like all extensions — in the boot() phase of the ServiceProvider, through the relevant registry:

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

Registration follows the same mechanics as all other extension points; details on the phases are under ServiceProvider, and the full list of registries is under Registries.

Diagnostic event types

The type parameter of report() names what happened. Three types are reserved:

TypeTriggered when
exceptionUnhandled exception
cron_failedScheduled task fails
module_boot_failedModule throws during boot

Logging best practices

Which tool to use depends on who will read the entry later:

  1. Use ModuleLogger for structured, persistent logs — not Log::info(). Module logs appear in the admin UI.
  2. Use Laravel’s Log facade for debug-only output — this goes to storage/logs/laravel.log and is not visible to administrators.
  3. Include context — always pass the relevant IDs, counts, or state in the context array.
  4. Do not log sensitive data — never log API keys, tokens, passwords, or customer personally identifiable information.
  5. Log at the appropriate level:
    • info — normal operations (settings changed, sync completed)
    • warning — recoverable issues (rate limit reached, fallback used)
    • error — failures (API error, missing configuration)
  6. Keep messages concise and actionable — “API call failed: 403 Forbidden” rather than “An error occurred while trying to call the external API endpoint.”

There is a practical reason for this separation: an operator checking on a module should see exactly the entries that concern them in the UI — not have to search through a debug log file for the one relevant line.

Auto-disable on boot failure

If a module’s ServiceProvider throws during boot(), a protection mechanism kicks in to prevent a single faulty module from bringing down the entire application:

  1. The module is immediately disabled (autoDisable()).
  2. A module_boot_failed diagnostic event is filed.
  3. An error entry in mod_logs is created.
  4. The rest of the application continues normally.

The administrator can see the failure reason in the module log and re-enable the module after fixing the issue. The trace is preserved even when the module itself no longer loads.