cyberwizard / watchtower-php
Framework-agnostic PHP SDK that reports requests, exceptions, logs, queries, outgoing HTTP calls and CLI commands to a self-hosted Watch Tower (Laravel Nightwatch compatible) backend.
Requires
- php: ^8.1
Requires (Dev)
- guzzlehttp/guzzle: ^7.8
- monolog/monolog: ^3.5
- phpunit/phpunit: ^10.5|^11.0
- psr/log: ^3.0
Suggests
- ext-curl: Faster ingest transport and the WatchTower::curlExec() wrapper.
- ext-zlib: Gzip-compressed ingest batches.
- guzzlehttp/guzzle: Use WatchTower::guzzleMiddleware() to record outgoing requests.
- monolog/monolog: Use WatchTower\Log\MonologHandler to forward Monolog records.
- psr/log: WatchTower::logger() then implements Psr\Log\LoggerInterface.
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-11-11 07:06:41 UTC
README
cyberwizard/watchtower-php lets any PHP application report to a self-hosted
Watch Tower server, with no
framework required. It covers plain PHP, WordPress, CodeIgniter, Slim, legacy apps and
cron scripts.
It sends records in the Laravel Nightwatch v1 wire format that Watch Tower already ingests, so requests, exceptions, logs, queries, outgoing HTTP calls, commands and users show up on the same pages as your Laravel apps.
- Zero required dependencies, PHP 8.1+
- Sends after the response (
fastcgi_finish_request()), with a 1 s connect and 2 s total timeout - Falls back to an on-disk spool that a cron job drains
- Redacts secrets before anything leaves the server
- Never throws into your application
Contents
- Install
- Quick start: plain PHP, CodeIgniter-style front controllers, CLI scripts, WordPress
- Shield (bot and spam protection)
- Client IP behind proxies
- What is captured
- Options
- API
- Privacy and redaction
- Performance
- Transport contract
- Development
Install
With Composer
composer require cyberwizard/watchtower-php
Single file (shared hosting, no Composer)
Download dist/watchtower.php from a release (or build it with composer build) and
upload it next to your code:
require __DIR__.'/watchtower.php'; WatchTower::init(['token' => 'your-environment-token', 'base_url' => 'https://watchtower.example.com']);
If the app also has a Composer autoloader, require it before watchtower.php so the
PSR-3 logger and the Monolog handler can see psr/log and Monolog. Do not load both the
Composer package and the single file in one app.
Quick start
Create an environment in Watch Tower and copy its token. Then call init() as early as
possible, ideally on the first line of your front controller:
Plain PHP
<?php // public/index.php require __DIR__.'/../vendor/autoload.php'; WatchTower::init([ 'token' => getenv('WATCHTOWER_TOKEN'), 'base_url' => 'https://watchtower.example.com', 'service' => 'shop', 'deploy' => trim((string) @file_get_contents(__DIR__.'/../REVISION')), // version or git sha ]); // Optional: give the request a route pattern so similar URLs group together. WatchTower::route('/products/{id}', 'products.show'); // Optional: attribute the request to a user. WatchTower::user($user->id, $user->name, $user->email); // ... your application ...
token and base_url can be left out when the WATCHTOWER_TOKEN and WATCHTOWER_URL
environment variables are set.
Without WatchTower::route(), the route is derived from the path with ids replaced:
/users/42/orders/9b1d... becomes /users/{id}/orders/{uuid}.
CodeIgniter and other front-controller frameworks
Put init() in index.php (or a pre_system hook) and set the route where the router
resolves it, for example in a base controller:
// CodeIgniter 4: app/Controllers/BaseController.php public function initController(...$args) { parent::initController(...$args); $router = service('router'); WatchTower::route('/'.$router->getMatchedRoute()[0], $router->getMatchedRouteOptions()['as'] ?? null, $router->controllerName().'::'.$router->methodName()); }
Send framework logs to Watch Tower with the PSR-3 logger (WatchTower::logger()) or,
if the framework uses Monolog, with new \WatchTower\Log\MonologHandler().
Database queries (PDO)
Swap PDO for TracedPdo. It is a subclass, so type hints keep working:
use WatchTower\Pdo\TracedPdo; $pdo = new TracedPdo('mysql:host=db;dbname=shop', 'shop', $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], 'mysql');
Every successful prepare()/execute(), query() and exec() becomes a query record. Each
record has the SQL with literals stripped, the duration, the connection name (last argument,
default: the driver name) and the file and line of your code that ran it.
Outgoing HTTP
// cURL: pass your headers so a W3C traceparent can be added to them. $ch = curl_init('https://api.stripe.com/v1/charges'); curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body]); $response = WatchTower::curlExec($ch, ['Content-Type: application/x-www-form-urlencoded', 'Authorization: Bearer '.$key]); // Guzzle $stack = GuzzleHttp\HandlerStack::create(); $stack->push(WatchTower::guzzleMiddleware(), 'watchtower'); $client = new GuzzleHttp\Client(['handler' => $stack]);
When the called service also reports to Watch Tower, the traceparent header links the two
executions.
CLI scripts and cron jobs
When init() runs under the CLI, the script is recorded as a command. Its name is the
script file name and its duration and exception count are included:
#!/usr/bin/env php <?php require __DIR__.'/vendor/autoload.php'; WatchTower::init(); $failed = run_backup(); WatchTower::exitCode($failed ? 1 : 0); // PHP cannot read the exit code at shutdown, so tell the SDK exit($failed ? 1 : 0);
Uncaught exceptions and fatal errors are reported with exit code 255 automatically.
To report several tasks from one script, or to capture their output, wrap them. Each wrapped task becomes its own command, and the script itself is then not reported separately:
WatchTower::command('backup:database', function () { echo "Dumping...\n"; // still printed; also sent as wt-output when capture is on return 0; // int = exit code, false = 1, anything else = 0 }, captureOutput: true);
Records are sent when each wrapped task ends. A long-running worker should call
WatchTower::flush() from time to time, because one execution keeps at most 500 records.
WordPress
- Copy
wordpress/watchtower.phptowp-content/mu-plugins/watchtower.php. - Copy
dist/watchtower.phptowp-content/mu-plugins/watchtower-sdk/watchtower.php(or install the Composer package and load its autoloader, or setWATCHTOWER_SDK_PATH). - Add to
wp-config.php:
define('WATCHTOWER_TOKEN', 'your-environment-token'); define('WATCHTOWER_URL', 'https://watchtower.example.com'); // optional define('WATCHTOWER_DEPLOY', 'theme-2.3.0'); define('WATCHTOWER_OPTIONS', ['sample_rate' => 0.25, 'ignore_paths' => ['/health', '/wp-cron.php']]); // behind a proxy or CDN, see "Client IP behind proxies" define('WATCHTOWER_CLIENT_IP_PRESET', 'cloudflare'); define('WATCHTOWER_TRUSTED_PROXIES', 'private'); // '*', 'private' or ['10.0.0.0/8', ...] define('WATCHTOWER_CLIENT_IP_HEADER', 'X-Forwarded-For'); // when no preset fits
WATCHTOWER_OPTIONS['client_ip'] wins over these constants, key by key.
The plugin:
- names each request after the main query's template type (
wp.single.poston/post/{slug},wp.page,wp.category,wp.404, ...) and the theme template used (theme/single.php). REST requests use the registered route pattern (/wp-json/wp/v2/posts/{id}), wp-admin usesadmin.{screen}, and AJAX usesajax.{action}. - attributes the request to the logged-in user. It sends the user ID, display name and login name, never the email address.
- records
$wpdbqueries with literals stripped. - reports PHP errors, uncaught exceptions and fatal errors (WordPress's own fatal error handler keeps working).
- sends
framework: "wordpress"inwt-hello. - with Shield enabled (
define('WATCHTOWER_OPTIONS', ['shield' => ['enabled' => true]]);), adds the honeypot field to the comment, login, registration and lost password forms (comment_form,login_form,register_form,lostpassword_form). Shield runs when the mu-plugin loads, before WordPress handles the request.
Query timing limits:
- Without
SAVEQUERIES(recommended in production), a query starts at WordPress'squeryfilter and ends at the next hook that fires after it. That is an upper bound: it includes any PHP work between the query and that hook, usually microseconds. File and line point at the code that called$wpdb. - With
SAVEQUERIES, the exact durations WordPress measures are used. WordPress only records the calling functions, sofileholds the innermost caller (e.g.WP_Query->get_posts) andlineis 0. - Queries that bypass
$wpdb(a plugin's own PDO/mysqli connection) are not seen.
Shield (bot and spam protection)
Off by default. Shield applies rules you manage in Watch Tower inside WatchTower::init(),
before any application code runs:
WatchTower::init([ 'token' => '...', 'base_url' => 'https://watchtower.example.com', 'shield' => [ 'enabled' => true, // env WATCHTOWER_SHIELD 'mode_override' => null, // 'monitor' or 'enforce' instead of the mode Watch Tower sends 'cache_dir' => null, // default: spool_dir 'turnstile_secret' => null, // env TURNSTILE_SECRET (never sent by Watch Tower) 'counters' => 'auto', // 'auto' (APCu when enabled, else files), 'apcu' or 'file' 'pass_minutes' => 30, // lifetime of the wt_shield_pass cookie 'honeypot_field' => 'wt_hp_website', // until Watch Tower sends one 'throttle_seconds' => 10, // one wt-shield record per (action, rule, IP) per window, env WATCHTOWER_SHIELD_THROTTLE_SECONDS; 0 = every one ], ]);
- Rules come from
GET {base_url}/api/shield/v1/ruleswith the environment token (1 s connect, 2 s total,If-None-Matchso unchanged rules are a 304) and are cached in a file (written atomically) for theirttl_seconds. A stale copy is refreshed in the shutdown handler, after the response is sent and at most once per 15 seconds across workers, so requests never wait for Watch Tower. The last good copy is kept when Watch Tower is unreachable; the very first request without any copy is allowed (fail open). - Order: allow list (wins over everything), blocked IPs (IPv4/IPv6 CIDR; IPv4-mapped IPv6
addresses match IPv4 ranges; expired entries ignored), user agent expressions (PCRE without
delimiters; invalid expressions are skipped, matching is capped at 1 KB with a low
backtracking limit), path globs (
*crosses/, case-insensitive, decoded path with duplicate slashes collapsed), rate limits, honeypot, challenge. The client IP is the one the request record uses (see Client IP behind proxies), so a forgedX-Forwarded-Forcan't dodge an IP block, reset a rate limit or reuse a pass cookie. - Rate limits count in a fixed window from the first hit, per
ip,ip_pathoruser(the SDK runs before your app knows the user, sousercounts by IP), in APCu or inflock()ed files undercache_dir/shield-counters. Over the limit: 429 withRetry-After, forblock_secondswhen set. - Responses: 403
Forbidden.or 429Too Many Requests.(JSON{"message": ...}when theAcceptheader asks for JSON), thenexit. Bodies never name the rule. The request is still reported, with itswt-shieldrecord in the same batch. - Challenge: a rate limit with
action: challengeshows a browser GET (Acceptwithtext/html, not XHR) a Turnstile interstitial (429). It posts back to the same URL with the reserved_wt_shield_verifyfield; the SDK checks the token with Cloudflare's siteverify usingturnstile_secret, sets the signedwt_shield_passcookie (HMAC of expiry and client IP keyed with a hash of the token, HttpOnly, SameSite=Lax) and redirects (303) to the same URL. Other clients, or a missing secret, get the 429. 20 verifications per IP per minute. - Honeypot:
echo WatchTower::honeypotField();inside your forms renders an off-screen,aria-hidden,tabindex="-1",autocomplete="off"input. A POST that fills it is blocked (action: block) or only reported (flag). - Monitor mode blocks nothing and reports
would_block. - Sampling: a decision is a security signal, so it keeps its execution even when
sample_ratedropped it: thewt-shieldrecord and the request it was made for are delivered. Repeats of the same (action, rule, IP) are recorded at most once perthrottle_seconds(10), counted in the same APCu or file counters as the rate limits so all workers share the window; a throttled decision is not recorded and its request followssample_rate. Records carry no count, so during an attack Watch Tower sees one decision per window and its rule hit counters count windows, not requests.
Client IP behind proxies
By default the client IP is REMOTE_ADDR, the address that opened the TCP connection. Behind
a load balancer, CDN or reverse proxy that is the proxy, and the visitor's address is in a
header the proxy sets. Headers come from the client too, so the SDK only reads one when the
connection itself comes from a proxy you trust:
'client_ip' => [ 'preset' => null, // 'cloudflare', 'digitalocean', 'aws_alb', 'heroku', 'fly', 'vercel', 'nginx', 'traefik' 'header' => null, // 'X-Forwarded-For', 'Forwarded', 'X-Real-IP', 'CF-Connecting-IP', 'do-connecting-ip', 'Fly-Client-IP', 'True-Client-IP', ... 'trusted_proxies' => null, // list of CIDRs, '*' or 'private' (default 'private' when a header is set) ],
- The header is ignored unless
REMOTE_ADDRis insidetrusted_proxies. A request that reaches the app directly gets itsREMOTE_ADDR, whatever it sends. trusted_proxiesis a list of IPv4/IPv6 CIDRs or addresses, which may include the keywords'private'(10/8, 172.16/12, 192.168/16, 100.64/10, 127/8, 169.254/16, ::1, fc00::/7, fe80::/10) and'cloudflare'(Cloudflare's edge ranges). A comma-separated string works too.'*'trusts any peer. Only use it when nothing can reach the app except through the proxy, e.g. DigitalOcean App Platform, Heroku, Render or Fly. On a server with a public IP it lets anyone pick their IP.X-Forwarded-ForandForwarded(RFC 7239for=) are lists every proxy appends to, so only the right end is trustworthy. The SDK walks them right to left, skips addresses intrusted_proxies, and uses the first one that is not (the left-most when every hop is trusted). With'*'only the peer is trusted, so the right-most entry wins. Ports, brackets and quotes ([2001:db8::1]:443,"203.0.113.7:8080") are removed;unknown, obfuscated identifiers and garbage are skipped.- Single-value headers (
CF-Connecting-IP,do-connecting-ip,X-Real-IP,Fly-Client-IP,True-Client-IP, anything else) are used as they are when valid. - An invalid or missing header falls back to
REMOTE_ADDR. - A preset sets
headerandtrusted_proxies; either key set explicitly wins. Unknown presets are ignored (REMOTE_ADDR). - The same address is used everywhere: the request record's
ip, Shield (IP allow and block lists, rate-limit keys, the IP-bound pass cookie, Turnstileremoteip) andwt-shieldrecords.
| Hosting | Config | Why |
|---|---|---|
| Cloudflare (orange cloud) | ['preset' => 'cloudflare'] |
Cloudflare overwrites CF-Connecting-IP; only its published ranges (embedded, as of 2026-11-10) are trusted, so direct-to-origin requests can't spoof it. Override with 'trusted_proxies' => ['cloudflare', ...] if the list changes or another proxy sits between. |
| DigitalOcean App Platform | ['preset' => 'digitalocean'] |
App Platform sets do-connecting-ip and only exposes apps through its edge, so the peer is always the platform and '*' is safe. Not for Droplets. |
| AWS ALB / ELB | ['preset' => 'aws_alb'] |
Header X-Forwarded-For, trusted 'private': the load balancer appends the client and connects to targets from private VPC addresses. Lock the instance's security group to the load balancer. |
| nginx / Apache reverse proxy (same host or LAN) | ['preset' => 'nginx'] |
Header X-Forwarded-For, trusted 'private'. nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;. Apache mod_proxy adds it. A proxy on a public address: 'trusted_proxies' => ['198.51.100.10']. Using X-Real-IP $remote_addr instead: ['header' => 'X-Real-IP', 'trusted_proxies' => 'private']. |
| Traefik / Docker / Kubernetes ingress | ['preset' => 'traefik'] |
Header X-Forwarded-For, trusted 'private' (container networks are private ranges). Add your ingress CIDR if it is not. |
| Heroku | ['preset' => 'heroku'] |
The router appends the client to X-Forwarded-For and dynos are only reachable through it: '*', right-most entry. |
| Render | ['header' => 'X-Forwarded-For', 'trusted_proxies' => '*'] |
Services are only reachable through Render's proxy. Check a request's X-Forwarded-For once to confirm the right-most entry is the client. |
| Fly.io | ['preset' => 'fly'] |
fly-proxy sets Fly-Client-IP; public traffic only reaches machines through it. |
| Vercel (PHP runtime) | ['preset' => 'vercel'] |
Vercel overwrites X-Forwarded-For with the client address. |
| Cloudflare in front of another proxy (e.g. Cloudflare + nginx) | ['header' => 'CF-Connecting-IP', 'trusted_proxies' => ['private']] |
nginx is the peer; make sure nginx only accepts Cloudflare's ranges. |
| No proxy | nothing (default) | REMOTE_ADDR is the client. Don't set a header: anyone could send it. |
The deprecated trusted_proxy_header option still works as client_ip.header, but since
0.2.0 it is only honoured when the peer is a private address (or one in
client_ip.trusted_proxies), and X-Forwarded-For is read right to left instead of taking the
first entry.
What is captured
| Watch Tower record | Source |
|---|---|
request |
Every web request: method, URL (redacted), route, status, duration (bootstrap plus action), peak memory, request and response size, IP, user agent and sanitised headers, counters |
command |
The CLI script, or each WatchTower::command() task, with exit code |
exception |
Uncaught exceptions (unhandled), captureException() (handled by default), PHP warnings and notices (as handled ErrorException), fatal errors at shutdown (WatchTower\FatalError). Includes stack frames with code snippets of your own files |
log |
WatchTower::logger() (PSR-3), MonologHandler |
query |
TracedPdo, WordPress $wpdb |
outgoing-request |
WatchTower::curlExec(), Guzzle middleware |
user |
WatchTower::user() |
wt-trace-link |
Incoming W3C traceparent header |
wt-output |
Output of wrapped commands (opt-in) |
wt-shield |
Each Shield decision that is not "allow" (blocked, rate_limited, challenged, challenge_passed, challenge_failed, honeypot, would_block) |
wt-hello |
Once a day per server: sdk: "php", versions, framework |
Every in-execution record carries the execution header (trace_id, execution_source,
execution_id, execution_preview, execution_stage, user), so Watch Tower shows it
inside its request or command.
Options
WatchTower::init([ // Connection 'token' => null, // env WATCHTOWER_TOKEN 'base_url' => null, // env WATCHTOWER_URL 'enabled' => true, // env WATCHTOWER_ENABLED // Identification 'service' => null, // app name (sent in wt-hello) 'environment' => null, // informational (sent in wt-hello), env WATCHTOWER_ENV 'deploy' => '', // version or git sha, env WATCHTOWER_DEPLOY 'server' => gethostname(), // env WATCHTOWER_SERVER 'framework' => null, // e.g. 'codeigniter' (sent in wt-hello) // What to capture (all shown with their defaults) 'capture' => [ 'requests' => true, 'commands' => true, // automatic CLI script record 'exceptions' => true, // installs an exception handler 'errors' => true, // installs an error handler (warnings, notices) 'deprecations' => false, // E_DEPRECATED / E_USER_DEPRECATED as exceptions 'fatal_errors' => true, 'logs' => true, 'queries' => true, 'outgoing_requests' => true, 'headers' => true, // sanitised request headers 'response_size' => true, // counts output bytes with a pass-through buffer 'payload' => false, // redacted $_POST of 500 responses (Nightwatch semantics) 'code_snippets' => true, // source lines around frames in your own files 'command_output' => false, // default for WatchTower::command(captureOutput:) 'trace_context' => true, // traceparent in and out 'hello' => true, // daily wt-hello ], // Sampling 'sample_rate' => 1.0, // share of requests/commands kept, env WATCHTOWER_SAMPLE_RATE 'exception_sample_rate' => 1.0, // an unhandled exception keeps an unsampled execution 'slow_request_ms' => null, // slower executions are always kept 'slow_query_ms' => null, // an execution with a slower query is always kept // Requests 'ignore_paths' => ['/health'], // exact paths or fnmatch() patterns ('/assets/*') 'client_ip' => [ // see "Client IP behind proxies"; default REMOTE_ADDR only 'preset' => null, // env WATCHTOWER_CLIENT_IP_PRESET 'header' => null, // e.g. 'X-Forwarded-For', 'CF-Connecting-IP' 'trusted_proxies' => null, // '*', 'private' or a list; env WATCHTOWER_TRUSTED_PROXIES ], 'trusted_proxy_header' => null, // deprecated alias for client_ip.header (trusts private peers) // Privacy 'redact_keys' => Config::DEFAULT_REDACT_KEYS, // regex, same as Watch Tower's server-side pattern 'redact_headers' => [], // added to the built-in list 'strip_numeric_literals' => true, // also replace bare numbers in SQL with ? // Errors and logs 'error_reporting' => null, // extra mask on top of error_reporting() 'log_level' => 'debug', // minimum PSR-3 level sent // Delivery 'mode' => 'auto', // 'auto' = send after the response, spool on failure; 'spool' = always spool (cron sends) 'connect_timeout' => 1.0, // seconds 'timeout' => 2.0, // seconds, total budget per execution (including spool draining) 'finish_request' => true, // call fastcgi_finish_request()/litespeed_finish_request() before sending 'max_records' => 500, // per execution and per HTTP request; extras are dropped and summarised in one log 'spool_dir' => sys_get_temp_dir().'/watchtower', // env WATCHTOWER_SPOOL_DIR 'spool_max_bytes' => 52428800, // 50 MB, oldest batches dropped first 'spool_drain_batches' => 5, // spooled batches sent after each successful delivery // Misc 'base_path' => null, // project root for relative file names (default: Composer root, else DOCUMENT_ROOT) 'location_skip' => [], // path fragments treated as library code when finding the caller 'output_max_bytes' => 65536, // wt-output keeps the last N bytes 'install_handlers' => true, // set false to install no global handlers 'http_client' => null, // a WatchTower\Transport\HttpClient (tests, proxies) 'shield' => ['enabled' => false], // see Shield ]);
finish_request hands the response to the client before records are sent. Turn it off if
your app streams the response or runs other shutdown work that must stay attached to the
connection.
API
| Method | Purpose |
|---|---|
WatchTower::init(array $options) |
Configure and start observing (idempotent) |
WatchTower::route(string $path, ?string $name, ?string $action, ?array $methods) |
Route pattern of the request, e.g. /users/{id} |
WatchTower::user($id, ?string $name, ?string $email) |
User of the execution (username in Watch Tower) |
WatchTower::captureException(Throwable $e, bool $handled = true) |
Report a caught exception |
WatchTower::logger() |
PSR-3 logger |
new WatchTower\Log\MonologHandler($level = 'debug') |
Monolog 2/3 handler |
new WatchTower\Pdo\TracedPdo(...) |
Traced PDO |
WatchTower::curlExec(CurlHandle $ch, ?array $headers) |
Traced curl_exec() |
WatchTower::guzzleMiddleware() |
Traced Guzzle |
WatchTower::traceparent() |
traceparent value for any other HTTP client |
WatchTower::command(string $name, callable $task, ?bool $captureOutput) |
Report a CLI task |
WatchTower::exitCode(int $code) |
Exit code of the auto-recorded script |
WatchTower::ignore() |
Do not report this request or script |
WatchTower::flush() |
Send what is buffered now |
WatchTower::honeypotField() |
The hidden Shield honeypot input (empty when Shield is off) |
Every method is a no-op before init() and never throws.
Privacy and redaction
Redaction runs in your app before anything is sent. Watch Tower then applies its own server-side pass.
- Keys: values whose key matches
redact_keysare replaced by[redacted]in query strings, log context and extra, and request payloads. The default matches password/secret/token/api key/private key/credential/authorization/cookie/session/card number/CVV/SSN/IBAN, the same pattern Watch Tower uses. - Headers:
authorization,proxy-authorization,cookie,set-cookie, CSRF/XSRF tokens,x-api-key,x-auth-tokenandx-amz-security-tokenare masked, as is any header whose name matchesredact_keys.php-auth-*headers are dropped. - Free text (exception and log messages, command lines, output):
Bearer/Basiccredentials, JWTs andkey=valuepairs with sensitive keys are masked. - SQL: string literals become
'?'(exactly like Watch Tower) and, by default, bare numbers become?. Bound parameters are never read. - URLs: user info (
user:pass@) is removed. - Users: only what you pass to
WatchTower::user()is sent. The WordPress plugin sends the login name, not the email. - Request bodies are not sent unless
capture.payloadis on. Even then only form fields ($_POST) of 500 responses are sent, redacted.
Performance
-
After the response. On PHP-FPM and LiteSpeed, the SDK calls
fastcgi_finish_request()in the last shutdown function, then sends. Visitors do not wait. Under Apache mod_php or the CLI, sending happens at shutdown, bounded bytimeout. -
Bounded. Connecting gets at most 1 s and the whole delivery (including draining spooled batches) at most 2 s per execution. Use
mode => 'spool'to keep network I/O out of web workers completely. -
Spool and cron. Failed deliveries (and everything in
spoolmode) go tospool_diras JSON-lines files, one batch per file, capped at 50 MB. Drain them from cron:* * * * * php /path/to/vendor/bin/watchtower-flush --config=/path/to/watchtower-config.php
--configpoints to a PHP file that returns yourinit()options.--token,--urland--spool-dir(or the env variables) also work.--statusshows the queue size. Without Composer, copybin/watchtower-flushnext towatchtower.php. -
401 back-off. If Watch Tower rejects the token, the SDK stops sending for 10 minutes (a marker file in the spool directory) and spools meanwhile.
-
Overhead. Records are plain arrays buffered in memory (at most 500 per execution). The response-size counter is a pass-through output buffer that never holds output back. Disable it with
capture.response_size => falseif your app manages output buffers itself.
Transport contract
POST {base_url}/api/ingest/v1
Authorization: Bearer {environment token}
Content-Type: application/json
Content-Encoding: gzip (omitted only when ext-zlib is missing)
User-Agent: watchtower-php/{version}
gzip({"records":[ ...Nightwatch v1 records... ]})
| Response | SDK behaviour |
|---|---|
| 2xx | Accepted; then up to spool_drain_batches spooled batches are sent |
| 401 | Stop sending for 10 minutes; the batch is spooled |
| 413 | Batch split in half and retried; a single oversized record is dropped |
| anything else / no response | Batch spooled |
Batches hold at most max_records records. Records are flat JSON objects. Timestamps are
float UNIX seconds, durations integer microseconds, memory bytes, and headers,
context, extra, trace and payload are JSON strings, exactly like
laravel/nightwatch.
Development
composer install composer test # PHPUnit: unit + integration (uses `php -S` and subprocesses) composer build # regenerate dist/watchtower.php
The record-shape tests compare every record type against field and type maps of
Watch Tower's tests/Support/NightwatchRecords.php builders. When the Watch Tower
repository is checked out next to this one, or WATCHTOWER_BUILDERS points at that file,
a test also checks the maps against the builders themselves.
License
MIT