PHP integration
Wire Drumbeats into PHP, including the Laravel scheduler hooks and the fatal errors that bypass your catch block.
<?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#
<?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.
}
}
}$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:
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:
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#
| Situation | What Drumbeats sees | What to do |
|---|---|---|
| The callable throws | failure with the exception string | Nothing. The wrapper handles it |
exit() or die() inside the job | Nothing. The catch block never runs | Never call exit() inside a wrapped job. Throw instead |
| A fatal error, segfault, or OOM | A start with no finish | Set max_duration_seconds so the hang is caught server-side |
max_execution_time is reached | Same as a fatal error | As above, and set the monitor's window above PHP's limit |
| A Laravel job is released back to the queue | failure, then a fresh run on retry | Expected. 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.