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
Provides
None
Conflicts
None
Replaces
None
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
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 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:
| Outcome | Retried? |
|---|---|
| Connection refused / DNS failure / timeout | yes |
408 Request Timeout, any 5xx | yes |
429 Too Many Requests | no |
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.