didntrun / sdk
First-party PHP client for the didnt.run ping API: correct, non-blocking instrumentation by default.
Requires
- php: ^8.3
- ext-curl: *
- psr/log: ^3.0
Requires (Dev)
- nyholm/psr7: ^1.8
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^2.0
- roave/security-advisories: dev-latest
- slevomat/coding-standard: ^8.15
- squizlabs/php_codesniffer: ^3.13
- symplify/easy-coding-standard: ^12.0
Suggests
- psr/http-client: Route pings through your own PSR-18 client via Psr18Transport
- psr/http-factory: Required alongside psr/http-client for Psr18Transport
- psr/http-message: Required alongside psr/http-client for Psr18Transport
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 if you hand it a PSR-3 logger).
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']);
// 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
InvalidArgumentExceptionat 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 quick reconnect retry
inside it. HTTP errors are never retried; 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.
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.