PHP integration

Wire Drumbeats into PHP, including the Laravel scheduler hooks and the fatal errors that bypass your catch block.

php
<?php

use GuzzleHttp\Client;

$api = 'https://api.drumbeats.io/v1/ping/<monitor-id>';
$client = new Client(['timeout' => 3]);

$client->get("$api/start");
try {
    runMyJob();
    $client->get("$api/success");
} catch (\Throwable $e) {
    $client->post("$api/failure", ['json' => ['payload' => (string) $e]]);
    throw $e;
}
<?php

use GuzzleHttp\Client;

$api = 'https://api.drumbeats.io/v1/ping/<monitor-id>';
$client = new Client(['timeout' => 3]);

$client->get("$api/start");
try {
    runMyJob();
    $client->get("$api/success");
} catch (\Throwable $e) {
    $client->post("$api/failure", ['json' => ['payload' => (string) $e]]);
    throw $e;
}

Examples use Guzzle because it gives you timeouts and JSON handling without ceremony. curl_* and file_get_contents work identically, only the transport changes.

Wrap it once#

src/Monitoring/Drumbeats.php
<?php

namespace App\Monitoring;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;

class Drumbeats
{
    private const PING_TIMEOUT = 3;
    private const MAX_PAYLOAD = 20000;

    /** @var array<string, string> */
    public const MONITORS = [
        'daily_backup'    => '11111111-2222-3333-4444-555555555555',
        'hourly_sync'     => '66666666-7777-8888-9999-aaaaaaaaaaaa',
        'newsletter_send' => 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff',
    ];

    private string $baseUrl;
    private Client $client;

    public function __construct(?Client $client = null, ?string $baseUrl = null)
    {
        $this->baseUrl = $baseUrl
            ?? (getenv('DRUMBEATS_BASE_URL') ?: 'https://api.drumbeats.io/v1');
        $this->client = $client ?? new Client(['timeout' => self::PING_TIMEOUT]);
    }

    /**
     * @template T
     * @param callable():T $fn
     * @return T
     */
    public function with(string $key, callable $fn, ?string $runId = null)
    {
        $monitorId = self::MONITORS[$key]
            ?? throw new \InvalidArgumentException("Unknown monitor: $key");
        $runId ??= "$key-" . bin2hex(random_bytes(8));

        $this->ping($key, 'start', $runId);
        try {
            $result = $fn();
            $this->ping($key, 'success', $runId);
            return $result;
        } catch (Throwable $e) {
            $this->ping($key, 'failure', $runId, (string) $e);
            throw $e;
        }
    }

    /** Best effort. Never throws, never blocks longer than PING_TIMEOUT. */
    public function ping(
        string $key,
        string $event,
        ?string $runId = null,
        ?string $payload = null
    ): void {
        $monitorId = self::MONITORS[$key] ?? null;
        if ($monitorId === null) {
            return;
        }

        $url = "{$this->baseUrl}/ping/$monitorId/$event";
        if ($runId !== null) {
            $url .= '?run_id=' . urlencode($runId);
        }

        try {
            if ($payload === null) {
                $this->client->get($url);
            } else {
                $this->client->post($url, [
                    'json' => ['payload' => mb_substr($payload, -self::MAX_PAYLOAD)],
                ]);
            }
        } catch (GuzzleException) {
            // Side channel. Never propagate.
        }
    }
}
<?php

namespace App\Monitoring;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;

class Drumbeats
{
    private const PING_TIMEOUT = 3;
    private const MAX_PAYLOAD = 20000;

    /** @var array<string, string> */
    public const MONITORS = [
        'daily_backup'    => '11111111-2222-3333-4444-555555555555',
        'hourly_sync'     => '66666666-7777-8888-9999-aaaaaaaaaaaa',
        'newsletter_send' => 'bbbbbbbb-cccc-dddd-eeee-ffffffffffff',
    ];

    private string $baseUrl;
    private Client $client;

    public function __construct(?Client $client = null, ?string $baseUrl = null)
    {
        $this->baseUrl = $baseUrl
            ?? (getenv('DRUMBEATS_BASE_URL') ?: 'https://api.drumbeats.io/v1');
        $this->client = $client ?? new Client(['timeout' => self::PING_TIMEOUT]);
    }

    /**
     * @template T
     * @param callable():T $fn
     * @return T
     */
    public function with(string $key, callable $fn, ?string $runId = null)
    {
        $monitorId = self::MONITORS[$key]
            ?? throw new \InvalidArgumentException("Unknown monitor: $key");
        $runId ??= "$key-" . bin2hex(random_bytes(8));

        $this->ping($key, 'start', $runId);
        try {
            $result = $fn();
            $this->ping($key, 'success', $runId);
            return $result;
        } catch (Throwable $e) {
            $this->ping($key, 'failure', $runId, (string) $e);
            throw $e;
        }
    }

    /** Best effort. Never throws, never blocks longer than PING_TIMEOUT. */
    public function ping(
        string $key,
        string $event,
        ?string $runId = null,
        ?string $payload = null
    ): void {
        $monitorId = self::MONITORS[$key] ?? null;
        if ($monitorId === null) {
            return;
        }

        $url = "{$this->baseUrl}/ping/$monitorId/$event";
        if ($runId !== null) {
            $url .= '?run_id=' . urlencode($runId);
        }

        try {
            if ($payload === null) {
                $this->client->get($url);
            } else {
                $this->client->post($url, [
                    'json' => ['payload' => mb_substr($payload, -self::MAX_PAYLOAD)],
                ]);
            }
        } catch (GuzzleException) {
            // Side channel. Never propagate.
        }
    }
}
php
$drumbeats = new \App\Monitoring\Drumbeats();
$drumbeats->with('daily_backup', function () {
    dumpDatabase();
    uploadToS3();
});
$drumbeats = new \App\Monitoring\Drumbeats();
$drumbeats->with('daily_backup', function () {
    dumpDatabase();
    uploadToS3();
});

ping() is public so the Laravel scheduler hooks below can call it directly. The payload keeps the last 20 000 characters, because a PHP exception string ends with the message you want.

Hook the Laravel scheduler#

Laravel gives you the three hooks this needs:

app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('backup:run')
        ->dailyAt('02:00')
        ->before(fn () => app(Drumbeats::class)->ping('daily_backup', 'start'))
        ->onSuccess(fn () => app(Drumbeats::class)->ping('daily_backup', 'success'))
        ->onFailure(fn () => app(Drumbeats::class)->ping('daily_backup', 'failure'));
}
protected function schedule(Schedule $schedule): void
{
    $schedule->command('backup:run')
        ->dailyAt('02:00')
        ->before(fn () => app(Drumbeats::class)->ping('daily_backup', 'start'))
        ->onSuccess(fn () => app(Drumbeats::class)->ping('daily_backup', 'success'))
        ->onFailure(fn () => app(Drumbeats::class)->ping('daily_backup', 'failure'));
}

For queued jobs, wrap the handler:

php
class ProcessOrder implements ShouldQueue
{
    public function handle(Drumbeats $drumbeats): void
    {
        $drumbeats->with(
            'order_processor',
            fn () => $this->process(),
            runId: "order-{$this->order->id}",
        );
    }
}
class ProcessOrder implements ShouldQueue
{
    public function handle(Drumbeats $drumbeats): void
    {
        $drumbeats->with(
            'order_processor',
            fn () => $this->process(),
            runId: "order-{$this->order->id}",
        );
    }
}

What happens when it breaks#

SituationWhat Drumbeats seesWhat to do
The callable throwsfailure with the exception stringNothing. The wrapper handles it
exit() or die() inside the jobNothing. The catch block never runsNever call exit() inside a wrapped job. Throw instead
A fatal error, segfault, or OOMA start with no finishSet max_duration_seconds so the hang is caught server-side
max_execution_time is reachedSame as a fatal errorAs above, and set the monitor's window above PHP's limit
A Laravel job is released back to the queuefailure, then a fresh run on retryExpected. Use a per-attempt run_id if you want them separated

PHP's fatal errors do not run catch blocks or finally. Server-side hang detection is the only thing that catches an OOM, and it needs max_duration_seconds on the monitor plus a start ping.

How you get alerted#

A failure opens a FAILED incident once failure_tolerance is reached and pages every notification group on the monitor. The exception string travels with it.

Symfony Messenger#

Wrap the body of the handler's __invoke, not the message bus. Wrapping the bus produces one monitor for every message type flowing through it, which tells you nothing useful.

Reuse the client#

Keep one Client instance in the service container. In a long-lived worker that reuses the underlying TCP connection, which matters when you are sending three pings per message.

Next#

Production hardening for the language-agnostic patterns. Event-driven pings for queue workers. Monitor types if you have not picked one yet. Alternatives if you are still choosing a vendor.