watchtower15 / symfony
The WatchTower15 PHP SDK's Symfony bundle.
Package info
github.com/Watchtower15/watchtower15-symfony
Type:symfony-bundle
pkg:composer/watchtower15/symfony
Requires
- php: ^8.3
- composer-runtime-api: ^2.2
- symfony/config: ^6.4 || ^7.4 || ^8.0
- symfony/console: ^6.4 || ^7.4 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.4 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.4 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/http-client: ^6.4 || ^7.4 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.4 || ^8.0
- watchtower15/sdk: v0.1.6
Requires (Dev)
None
Suggests
- ext-apcu: An apcu:// metrics store, flushed from requests
- ext-redis: A redis:// metrics store through phpredis 6.3 or later
- predis/predis: A redis:// metrics store through Predis (^2.2 || ^3.0)
- symfony/monolog-bundle: The watchtower15.monolog_handler service, with Monolog 3 (^3.10 || ^4.0)
- symfony/scheduler: Flushes the metrics every minute with messenger:consume scheduler_watchtower15
Provides
None
Conflicts
None
Replaces
None
README
The WatchTower15 PHP SDK's Symfony bundle. It turns the core SDK (watchtower15/sdk) into Symfony services: the application's
WatchTower15\Client, the metrics aggregator and its flush, a Monolog handler, a profiler-traced HTTP transport, and the
commands watchtower15:test and watchtower15:flush. Symfony ^6.4 || ^7.4 || ^8.0, PHP ^8.3.
Nothing is built, sent or logged when the kernel boots, and a mistyped credential never breaks the kernel or your logging.
This repository is a read-only split of the WatchTower15 PHP SDK: pull requests are not accepted. Documentation: https://docs.watchtower15.com. Report a vulnerability to contact@watchtower15.com with the subject "Security".
Install
composer require watchtower15/symfony
With Flex and the recipe (composer config extra.symfony.allow-contrib true first: Flex asks before it applies a contrib recipe
and skips it when it is not interactive), the bundle is registered, config/packages/watchtower15.yaml is added and
WT15_API_KEY, WT15_METRICS_TOKEN and WT15_LOGS_TOKEN appear in .env, all empty and all optional. Without the recipe, Flex
registers the bundle in config/bundles.php from its Composer type (symfony-bundle), and you create the config file yourself;
without Flex, add WatchTower15\Symfony\WatchTower15Bundle::class => ['all' => true] to config/bundles.php.
Then check the setup:
php bin/console watchtower15:test
Configuration
Every key is optional. The config root is watchtower15.
# config/packages/watchtower15.yaml watchtower15: api_key: '%env(default::WT15_API_KEY)%' metrics: token: '%env(default::WT15_METRICS_TOKEN)%' store: '%env(default::WT15_METRICS_STORE)%' # redis://redis:6379 for a fleet; unset: a file under var/ prefix: shop # required with redis:// and apcu://: one per application allowed: # nothing else is sent orders.shipped: count queue.depth: level logs: token: '%env(default::WT15_LOGS_TOKEN)%'
| Key | Meaning | From %env()%? |
|---|---|---|
api_key |
The API key, for WatchTower15\Client |
yes |
api_url, ingest_url |
Base URLs; unset is the SDK's default | yes |
metrics.token, logs.token |
The ingest tokens | yes |
metrics.enabled |
true by default |
yes |
metrics.store, metrics.prefix |
The metrics store and its prefix (below) | yes |
timeout, connect_timeout, max_retries, app_info |
Passed to the application's client | no |
http_client |
A service id; default the bundle's own watchtower15.http_client; null uses the SDK's cURL transport |
no |
metrics.allowed |
A map of metric key to count or level |
no |
metrics.allow_any |
true sends any metric key (default false) |
no |
metrics.heartbeat |
A metric key the flusher adds once a minute | no |
metrics.flush_after_response |
auto, always or never (below) |
no |
metrics.schedule |
true registers the Scheduler message (below) |
no |
A value from %env()% is read when a service is first built. A key that is not on the right is checked while the container compiles,
so a mistake fails cache:clear naming the key; a value from the environment cannot be checked then and is refused at run time
instead. Use %env(default::NAME)% so that an unset or empty variable is read as "not set"; a plain %env(NAME)% of an empty
variable gives "", which the SDK also reads as not set.
The three clients
| Service | Holds | A bad value |
|---|---|---|
WatchTower15\Client (watchtower15.client) |
The API key, and the other credentials | Throws WatchTower15\ConfigurationException to the code that asks for it, and to nothing else |
| the metrics pipe's client | The metrics token alone | Never throws: the flush stops with a config count and keeps its buckets |
| the logs handler's client | The logs token alone | Counted config by the handler, never thrown |
WatchTower15\Client, WatchTower15\Metrics\Aggregator, Flusher and BucketStore are autowirable.
public function __construct(private readonly \WatchTower15\Client $wt15, private readonly \WatchTower15\Metrics\Aggregator $metrics) {} // … $this->metrics->increment('orders.shipped'); $page = $this->wt15->servers->list(['health' => ['critical']]);
The transport
The bundle sends through its own watchtower15.http_client, a Symfony HTTP client that never follows redirects and is tagged so the
profiler's HTTP panel traces every call. It is not your application's http_client, which may carry a second retry layer or default
headers. Symfony's HTTP client has no portable connect timeout, so the connect is bounded by the total.
⚠️ In a development profile the profiler stores each traced request's headers, Authorization included. Do not share profiles.
The metrics store
metrics.store is a DSN. Symfony's cache contracts have no increment, so the bundle builds the SDK's own stores:
| DSN | Store | Shared by |
|---|---|---|
| unset | a file under var/watchtower15 |
FPM and the CLI on one host |
file:///absolute/folder |
a file store | one host |
redis://[user:password@]host[:port][/db], rediss://… (TLS), redis:///path/to.sock |
Redis, one plain client, connected at its first command | a fleet |
apcu:// |
APCu | one SAPI only |
memory:// |
memory | one process |
- Redis needs
ext-redis6.3 or later, orpredis/predis, and one primary: Redis Cluster,RedisArrayand Sentinel are refused. The driver is checked at run time, so a container compiled where the extension is absent still works where it runs. metrics.prefixis required withredis://andapcu://, one per application: two applications on one Redis database would share buckets, and the first flusher would send the other's keys under its own token.- ⚠️ APCu is per SAPI: a CLI process, the Scheduler's worker and cron cannot see what PHP-FPM wrote to APCu, so an
apcu://store is flushed from requests only. Under Apache'smod_php, where no finish function exists, at most one response a minute per host waits for that flush; use afile://store there. - A Messenger worker keeps the default
file://store, flushed by the Scheduler or cron. memory://is for console commands and tests only: on the web side it is sent at each finished response, and where no finish function exists nothing would ever flush it.- A store the bundle refuses (an unknown scheme, a cluster, a missing prefix) disables the metrics pipe, with its reason, and throws
nothing: nothing is buffered,
watchtower15:testandwatchtower15:flushprint the reason, and a password in a DSN is shown as***.
Flushing
-
After the response.
flush_after_response: autoflushes onkernel.terminatewhere the response is already finished (PHP-FPM, FrankenPHP and LiteSpeed, decided byfastcgi_finish_request()andlitespeed_finish_request(), never by the SAPI's name), for anapcu://store in every runtime, and onconsole.terminateof every command butwatchtower15:flush.alwaysdoes so in every runtime;neveron neither. One flush a minute runs per shared store, whoever claims it first. -
Every minute. With
symfony/scheduler, the bundle registers the schedulewatchtower15; run it as the web server's user:php bin/console messenger:consume scheduler_watchtower15. Several workers on one Redis store flush once a minute between them. It needs the framework'sschedulerto be enabled, and is empty for anapcu://ormemory://store. Without the Scheduler, in the web server's user's crontab on each host:* * * * * cd /app && php bin/console watchtower15:flush --if-dueThe files a file store writes carry the process's umask: a cron job or worker running as another user than PHP-FPM cannot write FPM's files, so run them all as the same user.
Logs
With MonologBundle (and Monolog 3), the service watchtower15.monolog_handler is the SDK's handler, built from closures so a bad logs
token is counted and never thrown while a logger is built. The SDK's own warnings go to the watchtower15 channel. Put the handler
inside a buffer handler, because it sends one push per batch, and exclude the channel watchtower15:
# config/packages/monolog.yaml monolog: handlers: watchtower15: type: buffer handler: watchtower15_send level: warning buffer_size: 100 flush_on_overflow: true channels: ['!watchtower15'] watchtower15_send: type: service id: watchtower15.monolog_handler nested: true
The end-of-request push runs through PHP's shutdown, after the response. A Messenger worker, or a worker-mode runtime such as
FrankenPHP's, resets the buffer handler (kernel.reset) after each unit of work, which sends it. With MonologBundle on Monolog 2 there
is no handler service, and watchtower15:test says so.
Commands
php bin/console watchtower15:testchecks each configured credential on a client of its own: the API key withme(), the metrics token with the source's listing, the logs token by its format (a log push is a write). It then prints the store and its scope, the allow-list, the flush paths and the log handler. Exit 2 when nothing is configured or a credential or URL is malformed (the checks the others allow still run); else 1 when a check failed or the store is refused; else 0.php bin/console watchtower15:flushsends the closed minute buckets now. It prints "disabled" (exit 0) while metrics are off or have no token, refuses a store it cannot read from the CLI or a refused configuration (exit 2, with no store call), and otherwise flushes and prints the report (exit 1 when the flush stopped on a failure).--if-duetakes the same minute claim as the Scheduler andkernel.terminateand prints "Not due" when another process holds it: this is the cron line's form.
Security
API keys and tokens are personal: keep them in the environment or a secrets store, never in code. The bundle never prints a credential or a DSN's password.
MIT licence.