Search by

First-party PHP client for the didnt.run ping API: correct, non-blocking instrumentation by default.

v1.1.1 2026-08-28 17:01 UTC

This package is not auto-updated.

Last update: 2026-09-11 16:18:03 UTC


README

Instrument a scheduled job against didnt.run with one line. The SDK's contract: your job is never slowed beyond a hard time budget and never failed by the watchdog — every transport problem is swallowed and logged, by default through PHP's error_log(), or through a PSR-3 logger you supply.

Install

composer require didntrun/sdk

Use

use DidntRun\Sdk\Watchdog;

// One env var wires everything: https://{ping-token}@it.didnt.run[?budget_ms=2000&connect_timeout_ms=1000]
$watchdog = Watchdog::fromDsn($_SERVER['DIDNT_RUN_DSN']);

Your DSN is shown once, ready to copy, when you issue a ping token in the didnt.run admin UI.

// Recommended: bracket the work. ONE completion ping — measured duration on success,
// exception context on failure (the exception is rethrown; your job's semantics don't change).
$report = $watchdog->run(DailyReportJob::class, fn () => $this->generate());

// Or fire the bare "I ran" ping yourself:
$watchdog->ping('app.daily-report');                        // any string id
$watchdog->ping(DailyReportJob::class, durationMs: 1234);   // FQCNs work — see "Job identity"

// Or send explicit lifecycle pings — manual bracketing when start and finish
// live in different processes or code paths (see "Lifecycle bracketing"):
$watchdog->start('app.daily-report');
$watchdog->success('app.daily-report', durationMs: 1234);
$watchdog->fail('app.daily-report');

ping() returns bool (delivered vs. swallowed) if you want to observe delivery; you never have to.

Job identity

Ids pass through an identity-defining normalizer: leading \ stripped, \. — so App\Jobs\SyncOrders becomes the job App.Jobs.SyncOrders. Renaming a class therefore registers a NEW job (the old one goes stale and will alert): after a rename, retire or rename the old job in the admin UI.

Failure semantics

  • Misconfiguration (bad DSN, empty token, or a token containing non-printable-ASCII characters) throws InvalidArgumentException at construction — fail fast at wiring time. After that, nothing throws.
  • Each ping gets a wall-clock budget (default 2 s, connect 1 s) with one retry inside it — see "Delivery and retries" below for exactly which failures qualify. 401 is logged as error (your token is wrong).
  • Oversized meta (> 64 KiB body) is dropped; the ping still goes out.

Wiring

Symfony (config/services.yaml):

services:
    DidntRun\Sdk\Watchdog:
        factory: ['DidntRun\Sdk\Watchdog', 'fromDsn']
        arguments: ['%env(DIDNT_RUN_DSN)%', null, '@logger']

Nette (config/services.neon):

services:
    - DidntRun\Sdk\Watchdog::fromDsn(::getenv('DIDNT_RUN_DSN'))

Plain script: $watchdog = Watchdog::fromDsn($_SERVER['DIDNT_RUN_DSN']);

Bring your own HTTP client (optional): pass a Psr18Transport wrapping your PSR-18 client (requires psr/http-client + psr/http-factory). Note the time budget is then only as good as your client's own timeout config.

Lifecycle bracketing

run() sends one completion ping by default. Opt into bracketing and it also emits a start ping before the work runs: the start anchor is duration-immune, so it tightens the inferred cadence for jobs whose duration is non-trivial or variable (a terminal-only anchor drifts with run length).

Enable it globally (constructor arg, or the lifecycle DSN param) with a per-call override:

// Global default via DSN: https://{token}@it.didnt.run?lifecycle=true
$watchdog = Watchdog::fromDsn($_SERVER['DIDNT_RUN_DSN']);
$watchdog->run(DailyReportJob::class, fn () => $this->generate());   // start + completion

// Per-call override wins (null = inherit the instance default):
$watchdog->run(DailyReportJob::class, fn () => $this->generate(), lifecycle: true);

A bracketed run has two budget windows — one ping before the work, one after — so its worst-case added wall-clock is ~2× the per-ping budget, straddling the work. For work a single closure can't wrap (it spans processes, or start and finish live in different code), call start() / success() / fail() directly instead.

Server requirement: bracketing needs a ping-api that understands lifecycle kinds. Against an older, kind-blind self-hosted server, leave lifecycle off — a separate start ping would double-count runs in schedule inference.

Logging

Every failure is swallowed and logged. By default the SDK logs through PHP's error_log(), which means it goes wherever you have already pointed PHP's errors: under CLI with no error_log ini setting — the usual cron case — that is stderr, so your cron runner captures or mails it. The SDK deliberately does not pick a file of its own.

Hand it any PSR-3 logger to redirect (see the Symfony wiring example above, which passes '@logger'):

$watchdog = Watchdog::fromDsn($_SERVER['DIDNT_RUN_DSN'], null, $myLogger);

To silence it completely, pass a NullLogger:

$watchdog = Watchdog::fromDsn($_SERVER['DIDNT_RUN_DSN'], null, new NullLogger());

A ping that is never delivered is the one failure you cannot see from the server side — it looks identical to a job that died before pinging. Leaving logging on is what tells the two apart.

Each failure is written as exactly one line, so a log is safe to grep and count. Control characters in a value you passed — a newline or a tab inside an external_id, say — appear as ? rather than breaking the line in two. That holds for the logger you hand in as much as for the default one: the SDK sanitises each value before handing the record over, so a forged line is not something a redirected log inherits. Your ping token never appears in a log line either, including inside an error message authored by a transport.

One caveat for the stderr case: when error_log is unset and output goes to stderr, PHP adds no timestamp (it only prefixes one when error_log points at a file). Counting failures over a week works either way, but correlating a specific lost ping with a specific alert needs a timestamp — so have your cron wrapper stamp the stream, or point error_log at a file.

Delivery and retries

A ping is retried once when the failure could plausibly succeed on a second attempt:

OutcomeRetried?
Connection refused / DNS failure / timeoutyes
408 Request Timeout, any 5xxyes
429 Too Many Requestsno
Any other 4xx (400, 401, 404, …)no

One retry in total, never one per category. The retry has to fit inside the same time budget as the first attempt (2 s by default), so against a slow server there may be no room for it — the SDK will never exceed the budget to get a ping through.

Requirements

PHP ≥ 8.3, ext-curl. Only Composer dependency: psr/log.

License

MIT — see LICENSE.

Development happens in the didnt.run monorepo; this repository is a read-only subtree split. Please report issues against the SDK there rather than opening merge requests here.