I recently moved a Scouting WordPress site off of Flywheel and onto a Dokku box I run myself. The migration went fine. That is a different post. The migration left me with a problem Flywheel used to solve for free: when something breaks at 11pm, how do I find out before a member/volunteer does?
I've been using AppSignal for a very long time for all my Rails apps. They shipped a PHP integration recently, and it hit my "to do" list for the migration. The catch, which became the whole adventure: AppSignal for PHP supports Laravel and Symfony. WordPress appears nowhere in their documentation. WordPress appears nowhere in anyone's documentation. It is the eternal special case, a composer-less, framework-less island that also happens to run half the web.
This is the story of bolting the two together anyway. It works, and works well: real per-request traces with database query spans, error tracking, the whole thing. There were two non-obvious failures along the way, and since I expect other people will hit them and go googling, here is the field guide.
The setup
Some context on the deployment, because it shapes the solution. The site runs on Dokku using the official wordpress Docker image. The image is WordPress core plus an entrypoint. wp-content lives in a Dokku storage mount. The database is a linked dokku-mysql service. The Dockerfile, before all this, was four lines on top of the base image.
The nice property of this arrangement: the Dockerfile is the server. There is no SSH-in-and-apt-get drift. If AppSignal needs things installed, they get installed in the image, in git, reviewable and reproducible.
AppSignal's PHP requirements are two:
- PHP >= 8.4. The site was on 8.2, so this meant a version bump.
-
The
opentelemetryPHP extension. AppSignal built their PHP integration on OpenTelemetry rather than a proprietary agent. Good decision, with consequences we will get to.
Checking your own situation is quick. On the server (or in your container):
php -v
php -m | grep -i opentelemetry
If you get 8.2-something and silence, you are where I started.
The easy part: PHP 8.4 and the extension
The official WordPress image publishes PHP-version variants, so the version bump is a tag change. The extension sounds like work. AppSignal's wizard walks you through installing build tools, running pecl install, and hand-editing php.ini. In Docker-land the docker-php-extension-installer project collapses all three steps into one line:
ARG PHP_TAG=php8.4-apache # 8.4+ required by AppSignal PHP
FROM wordpress:${PHP_TAG}
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions opentelemetry
install-php-extensions fetches the build dependencies, compiles the extension, writes the ini file to enable it, and cleans up after itself. Rebuild, and php --ri opentelemetry reports the hooks are enabled.
One non-negotiable note on the version bump: 8.2 to 8.4 is a real jump for a pile of WordPress plugins of varying vintage. I keep a docker-compose.yml that mirrors the production setup (same image, same mount shape) precisely for moments like this. Build it, click through wp-admin, edit a post, submit a form. Then ship. PHP version bumps under WordPress deserve a smoke test, not optimism.
The hard part: WordPress isn't a composer app
Here is where the official instructions stop mapping onto reality. AppSignal's install is:
composer require appsignal/appsignal-php
vendor/bin/appsignal install --push-api-key=...
That assumes your application has a composer.json, a vendor/ directory, and a single front controller that requires the autoloader. WordPress has none of these. There is no composer install in WordPress's life. Plugins ship their own vendored dependencies in zip files like it is 2009 (because for WordPress it is permanently 2009) [insert troll face here]).
So I did what one does: gave AppSignal its own composer project, outside the WordPress docroot, and used PHP's auto_prepend_file to pull it into every request. In the Dockerfile:
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
RUN mkdir -p /opt/appsignal \
&& COMPOSER_ALLOW_SUPERUSER=1 composer require --working-dir=/opt/appsignal \
--no-interaction "appsignal/appsignal-php:^0.5.2" \
&& composer clear-cache
COPY appsignal-prepend.php /opt/appsignal/prepend.php
RUN echo 'auto_prepend_file=/opt/appsignal/prepend.php' > /usr/local/etc/php/conf.d/zz-appsignal.ini
auto_prepend_file is an old, slightly disreputable PHP feature. It runs a file of your choosing before every script, and is best known as a malware persistence mechanism. It is also exactly the right tool for injecting cross-cutting infrastructure into a codebase you do not control, and it is what the OpenTelemetry PHP docs themselves suggest for legacy apps.
Before committing to this, I read the package source rather than trusting the docs, and I would recommend it. The package is small and tells you everything the documentation does not:
-
It self-initializes on autoload. The package registers a
filesautoload entry (_autoload.php) that callsAppsignal::getInstance()->initialize()the moment the composer autoloader loads. No bootstrap call needed in your code. -
It skips the CLI. That same file returns early when
PHP_SAPI == 'cli'(with carve-outs forartisanandbin/console). This matters more than it looks. It meanswp-clicommands and cron jobs do not get instrumented or slowed, for free. -
Environment variables beat the config file.
Config::loadFromEnv()readsAPPSIGNAL_ACTIVE,APPSIGNAL_APP_NAME,APPSIGNAL_APP_ENV,APPSIGNAL_PUSH_API_KEY, andAPPSIGNAL_COLLECTOR_ENDPOINTfrom$_ENV, and theconfig/appsignal.phpfile the wizard wants to scaffold is entirely optional. For a twelve-factor-ish Dokku deployment this is perfect. The API key lives indokku config:set, not in git, not in the image. -
It detects your "framework" by sniffing the directory that contains
vendor/autoload.php. Laravel if there is anartisan, Symfony if there is asymfony.lock, and otherwise:vanilla. Hold that word in your head. It is Chekhov's enum value.
So the design. AppSignal lives in /opt/appsignal, initializes itself via the autoloader on every web request, configures itself from environment variables, and ignores the CLI. Clean.
Then I tested it, and the first gun went off.
Gotcha #1: the package ships without an HTTP client, & the failure mode is fatal
AppSignal exports telemetry over OTLP, which is HTTP POSTs to a collector endpoint. The OpenTelemetry PHP SDK does this through the PSR-18 HTTP client abstraction, and discovers an implementation at runtime via php-http/discovery.
Here is the thing. composer require appsignal/appsignal-php installs the PSR interfaces and the discovery package, but no actual HTTP client. When discovery finds no client, it does not degrade gracefully. It throws during autoload, which in my architecture means during auto_prepend_file, which means a fatal on every page of the site:
PHP Fatal error: Uncaught Http\Discovery\Exception\DiscoveryFailedException:
Could not find resource using any discovery strategy.
...
Next Http\Discovery\Exception\NotFoundException: No PSR-18 clients found.
Make sure to install a package providing "psr/http-client-implementation".
The monitoring tool's failure mode was taking down the thing it monitors. In fairness, in a Laravel app you would likely have Guzzle already and never see this. On a bare install it is a guaranteed fatal, and I am glad it surfaced in a local container instead of in production.
Two fixes, and you want both.
First, install a client. Guzzle is the boring correct answer:
composer require --working-dir=/opt/appsignal guzzlehttp/guzzle
Second, and this is the defensive-driving lesson, never let a prepend file trust anything. Mine is now gated and wrapped:
<?php
// Loads AppSignal (auto-initializes for web requests; self-skips CLI/wp-cli).
// Gated on the env var so unconfigured environments stay silent, and
// wrapped so a monitoring failure can never take the site down.
if (getenv('APPSIGNAL_PUSH_API_KEY')) {
try {
require '/opt/appsignal/vendor/autoload.php';
} catch (\Throwable $e) {
error_log('AppSignal bootstrap failed: ' . $e->getMessage());
}
}
The env-var gate means local dev and CI (anywhere without credentials) skip the whole machinery silently. The try/catch means that if some future composer update reintroduces a fatal, the site serves pages and the error log complains, instead of the other way around. Observability should never be load-bearing.
With Guzzle in place, initialization succeeded in my test container. I set the config on the server, deployed, watched the site come up healthy on PHP 8.4, and went to AppSignal's "waiting for data" wizard screen to collect my reward.
Gotcha #2: "Uh-oh! No data received :("
[Cue the Sad Trombone]
The site was up. The logs were clean: no warnings, no errors, nothing. The config was verifiably present. Requests were flowing. AppSignal received: nothing. The wizard sat there with its sad emoticon, and sad emoticons do not come with stack traces.
Debugging an observability pipeline that fails silently is a special kind of fun, because the tool whose job is to tell you what is wrong is the thing that is, itself, not telling you something is wrong. The first move was to cut the problem in half: is data failing to send, or failing to exist?
The package ships a demo command that answers exactly this. It needs to run from the composer project directory (it resolves paths from the working directory):
dokku run <appname> sh -c "cd /opt/appsignal && php vendor/bin/appsignal demo"
AppSignal demo
Sent a log
Sent a trace
Sent an exception
โ Finished sending data to AppSignal
It all appeared in the UI. API key valid, collector endpoint reachable, exporter functional. The pipe was fine. Web requests were not producing any data to push through it, and that is when the Chekhov's gun from the source-reading expedition fired.
Remember vanilla? When AppSignal detects Laravel or Symfony, it applies framework patches: instrumentation that hooks the request lifecycle and creates spans. The Vanilla environment class, which is what WordPress gets, contains this:
/** @var array<int, class-string|object> */
protected array $patches = [];
An empty list. For a vanilla app, AppSignal initializes the OpenTelemetry SDK, registers providers, sets up exporters, wires the shutdown flush, and then registers zero instrumentation. No hooks means no root span. No root span means no trace ever starts. Nothing is ever created, so nothing is ever exported, so nothing ever errors. The pipeline was a beautifully configured conveyor belt with nobody putting anything on it.
This is the difference between "supports PHP" and "supports your framework," and it is worth understanding if you are bringing AppSignal (or honestly any OTel-based product) to an unsupported platform. The SDK is generic, but something still has to create spans.
Leveraging OpenTelemetry for the Fix
Here is where AppSignal's bet on OpenTelemetry pays off for us. Because the whole stack is standard OTel underneath, anything that creates OTel spans shows up in AppSignal. The OpenTelemetry contrib ecosystem has a WordPress auto-instrumentation package that AppSignal's docs never mention, because why would they? They do not support WordPress (and I can't blame them... without Claude I probably wouldn't either).
composer require --working-dir=/opt/appsignal open-telemetry/opentelemetry-auto-wordpress
It uses the opentelemetry extension's function-hooking to instrument WordPress at the source level. No plugin to install, no WordPress configuration at all. It hooks wp_initial_constants (the earliest hookable function that runs exactly once per request) to open a root SERVER span, then adds child spans for wpdb::query, db_connect, parse_request, template resolution, and friends. Registration happens via composer's autoload files, so requiring the autoloader (which our prepend file already does) activates everything. The hook registration order does not matter, since the tracer resolves lazily through the SDK's globals at span-creation time.
One composer wrinkle: the OTel contrib packages use the tbachert/spi composer plugin, and modern composer refuses to run plugins it has not been told to allow. Since /opt/appsignal is created from scratch in the Dockerfile, I seed the config first. The complete final block:
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
RUN mkdir -p /opt/appsignal \
&& printf '{"config":{"allow-plugins":{"tbachert/spi":true,"php-http/discovery":false}}}' \
> /opt/appsignal/composer.json \
&& COMPOSER_ALLOW_SUPERUSER=1 composer require --working-dir=/opt/appsignal \
--no-interaction "appsignal/appsignal-php:^0.5.2" "guzzlehttp/guzzle:^7.11" \
"open-telemetry/opentelemetry-auto-wordpress:^0.2.0" \
&& composer clear-cache
COPY appsignal-prepend.php /opt/appsignal/prepend.php
RUN echo 'auto_prepend_file=/opt/appsignal/prepend.php' > /usr/local/etc/php/conf.d/zz-appsignal.ini
Verifying without credentials: the fake collector trick
Before redeploying, I wanted proof that spans were actually being created this time, locally, without putting production credentials in a compose file. The trick is to invert the failure: point the exporter at an endpoint that cannot exist, and let the error be the evidence.
In a compose override, set APPSIGNAL_COLLECTOR_ENDPOINT=https://collector.invalid (.invalid is reserved and will never resolve) along with a fake key, bring the stack up, and load a page. If the instrumentation is dead, the logs stay silent, like they did the first time. If it is alive, request shutdown produces:
OpenTelemetry: [error] Export failure [exception] Export retry limit exceeded
[previous] cURL error 6: Could not resolve host: collector.invalid
... for https://collector.invalid/v1/traces
There it was: a serialized protobuf payload of real spans from a real WordPress page load, batched at request shutdown and hurled at a hostname that does not exist. The conveyor belt finally had something on it. A failed export was the most reassuring log line of the whole project.
Swap the env vars for the real ones (on Dokku, that is one config:set command, which is also where the API key permanently lives), deploy, click around the site, and AppSignal lights up. GET / traces with database queries laid out under the request span, slow queries are visibly slow, errors are grouped and stack-traced.
Deploy markers for free
One more refinement is worth the three lines it costs. AppSignal correlates everything to a revision value. When the revision changes, AppSignal records a deploy marker, so a spike in errors or a performance regression points at the deploy that caused it. That is half the value of an APM tool: telling you that something got slow after Tuesday's deploy, rather than just that something is slow.
The package tries to be helpful here. It shells out to git rev-parse HEAD to discover the revision on its own. It runs that in the directory containing vendor/, which in this architecture is /opt/appsignal inside a container, never a git repository. Every deploy reports unknown, and the deploys page stays empty.
The fix came from the platform rather than the package. Dokku (like most git-push-to-deploy platforms, such as Heroku with its dyno metadata equivalent) injects a GIT_REV environment variable into the app containing the SHA of the deployed commit. AppSignal reads APPSIGNAL_REVISION from the environment. The prepend file is already the place where these worlds meet, so it gets three more lines, before the autoloader require:
// Dokku injects GIT_REV with the deployed commit; AppSignal turns
// revision changes into deploy markers. Explicit APPSIGNAL_REVISION wins.
if (!isset($_ENV['APPSIGNAL_REVISION']) && ($rev = getenv('GIT_REV'))) {
$_ENV['APPSIGNAL_REVISION'] = $rev;
}
Now every git push dokku main stamps its SHA onto every span, and AppSignal draws the deploy line on the graphs without being told anything.
A small detail I learned while testing this: PHP's CLI SAPI ignores auto_prepend_file entirely. It is a web-only directive. I had been relying on the AppSignal package's own CLI check to keep wp-cli uninstrumented, but the prepend file never even runs there. Cron jobs and wp commands do not load a single line of any of this. Defense in depth, by accident.
As our campfire fades away...
The complete WordPress-specific implementation for this ended up as: one prepend file (sixteen lines), about ten Dockerfile lines, and five environment variables. The image is reproducible, the credentials are in ENV variables (not git), wp-cli stays uninstrumented, deploys get marked in AppSignal (so we can see the errors from deploy to deploy), and a monitoring failure degrades to a log line instead of an outage.
For the search engines, the two failure modes once more:
-
No PSR-18 clients foundfatal on every request. The AppSignal PHP package ships no HTTP client. Addcomposer require guzzlehttp/guzzlealongside it, and wrap your prepend in a try/catch regardless. -
AppSignal receives no data from web traffic, but
vendor/bin/appsignal demoworks. Your app was detected as "vanilla" and got zero instrumentation. Nothing creates spans. Addopen-telemetry/opentelemetry-auto-wordpress(or the contrib package for whatever unsupported thing you run).
The broader lesson is the one OpenTelemetry promised all along. Because AppSignal speaks standard OTel, "unsupported" turned out to mean "some assembly required" rather than "no." The vendor covers the frameworks they cover. The commons covers WordPress. That division of labor only works if the vendor builds on the standard, and it is worth rewarding the ones that do.
And read the source of your dependencies. Every load-bearing fact in this post (the CLI skip, the env-var precedence, the empty patches array) came from twenty minutes in vendor/, not from documentation. The package was telling me exactly what it would and would not do. I just had to look.
N.B. I have not yet tried this on WPEngine, where I'm not sure about their OTel support. I will be giving that a whirl next!