atymic / laravel-spx-lambda
Trigger php-spx request profiles on Bref/Lambda and ship the reports to S3
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/contracts: ^11.0||^12.0||^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- atymic/laravel-aws-xray: ^0.1.0
- larastan/larastan: ^3.0
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^11.0.0||^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
Suggests
- atymic/laravel-aws-xray: Correlates each profile with its distributed trace id.
README
Profile a single HTTP request on a Bref/Lambda stack with php-spx, and ship the report to S3 for offline analysis.
Arm your browser by clicking a bookmarklet, browse normally, and every request is profiled until you disarm. The upload happens in terminable middleware, after the response has already gone out.
Why this package exists
SPX ships its own cookie-based trigger and a web control panel. Neither works on Lambda.
On the CLI SAPI, SPX reads its configuration from getenv() only — spx_config.c explicitly skips the INI, cookie, header and query-string sources when cli is true. Bref's function runtime runs PHP as CLI, so SPX's native trigger is inert there.
This package replaces that trigger in userland: middleware checks a cookie, calls spx_profiler_start()/spx_profiler_stop() itself, and uploads the resulting report.
Requirements
- PHP 8.4+, Laravel 11/12/13
- The
spxextension compiled into your Lambda image (see Building the extension) - A filesystem disk to upload reports to
Installation
composer require atymic/laravel-spx-lambda
Publish the config:
php artisan vendor:publish --tag="laravel-spx-lambda-config"
Register the middleware
Append it globally in bootstrap/app.php. It must be appended (not prepended) so terminate() runs, and the trigger cookie must be excluded from encryption — the bookmarklet writes it in plaintext from JavaScript:
use Atymic\SpxLambda\Http\Middleware\ProfileRequest; ->withMiddleware(function (Middleware $middleware): void { $middleware->append(ProfileRequest::class); $middleware->encryptCookies(except: ['SPX_KEY']); })
Environment
SPX_PROFILING_ENABLED=true SPX_PROFILING_TOKEN=<a long random string> SPX_DISK=spx SPX_STAGE=staging
The extension itself is configured entirely through environment variables — on the CLI SAPI they are the only source it reads, so INI settings are ignored.
Everything that is a fixed property of the image is baked into the Dockerfile (see the example):
ENV SPX_AUTO_START=0 \
SPX_REPORT=full \
SPX_METRICS=wt,ct,zm \
SPX_DATA_DIR=/tmp/spx
SPX_AUTO_START=0is mandatory —spx_profiler_start()refuses to run otherwise, and that call is exactly what this package's trigger makes.SPX_REPORT=fullis mandatory on CLI, where the default isfpto STDERR.ct(CPU time) earns its place: wall minus CPU is idle time, which distinguishes blocking I/O from PHP burning CPU. Avoidio/ior/iow/mor— they read/procon every metric collection, andiorsubtracts SPX's own read noise from asize_t, which can underflow to a garbage value.
That leaves one switch to set per environment, so the same image can deploy with profiling off:
SPX_ENABLED: '1'
SPX_ENABLED is read once per process. Under an Octane worker it is therefore fixed for the whole container and cannot be flipped per request — which is why the userland start()/stop() is what actually scopes a report to one request.
Triggering a profile
Set the cookie to your token. Two bookmarklets, nothing to install:
SPX ▶ ON
javascript:(function(){document.cookie='SPX_KEY=<token>;path=/;secure;samesite=lax';alert('SPX ON');})();
SPX ■ OFF
javascript:(function(){document.cookie='SPX_KEY=;path=/;max-age=0';alert('SPX OFF');})();
Or with curl:
curl --cookie "SPX_KEY=<token>" https://staging.example.com/some/page
Cookies are per-origin, so clicking ON while on the wrong host does nothing.
A cookie is used rather than a query parameter because query strings land in CloudFront logs, ALB logs, browser history and Referer headers. Cookies do not.
Security
This is a staging tool. The trigger cookie is deliberately not httponly — the bookmarklet needs JS write access, which is the trade for not shipping a browser extension.
- Keep
enabledfalse in production. - Put the host behind an access proxy, and validate at the origin too, or the API Gateway URL bypasses the proxy entirely.
- An empty token disables the trigger outright, so a stage that never sets one cannot be profiled by guessing.
Reports
Two files per profile, uploaded to {prefix}/{stage}/{date}/{key}.{txt.gz,json} and then unlinked locally.
Local cleanup is not optional: SPX never deletes its own reports, and Lambda's /tmp is capped at 512 MB. A profiler that fills /tmp starts failing requests in ways that look unrelated to profiling.
A report with no .json beside it is discarded. Metadata is only written during SPX's finalize(), so a container frozen mid-span leaves a truncated .txt.gz that cannot be read.
The spx_lambda metadata block
A report key carries a timestamp, hostname and pid — nothing about which route was profiled or which deploy produced it. So the .json sidecar is augmented with a namespaced block before upload, letting reports be listed and filtered without downloading them:
{
"key": "spx-full-20260803_014557-e47cece870d5-1-1804289383",
"wall_time_ms": 159590,
"spx_lambda": {
"route": "checkout",
"method": "GET",
"sha": "b27e73f8a9c",
"stage": "staging",
"wall_ms": 159.59,
"status": 200
}
}
Every field SPX wrote is left exactly as it was — including wall_time_ms, which is misnamed upstream: it is cum[wt] / 1000 where wt is nanoseconds, so the value is microseconds. wall_ms is the converted value; the original is deliberately not rewritten.
Set SPX_SHA at build time to whatever identifies the deployed commit. Without it, two profiles are two unlabelled numbers and a regression cannot be tied to the change that caused it. It is emitted as "" rather than omitted when unknown.
If the sidecar cannot be parsed, the original bytes are uploaded unchanged — a report with unaugmented metadata is still fully usable, so losing it over a failed augmentation would be the worse trade.
Trace correlation
If atymic/laravel-aws-xray is installed, each report's custom metadata carries the active trace_id, so a profile can be lined up with its distributed trace. The dependency is optional and resolved via class_exists; without it the field is simply omitted.
The route is recorded in custom metadata rather than read back from SPX's own http_request_uri, which is captured at start() and under Octane may hold a previous request's values.
Building the extension
There is no prebuilt SPX layer for Bref — the one in brefphp/extra-php-extensions was never built. Compile it yourself.
A complete, commented example is in examples/Dockerfile.bref.spx. The essentials, noting the tarball (upstream's own Dockerfile opens with git clone, and git is not in the Bref build image):
FROM bref/build-php-84:3 AS spx-builder ARG SPX_VERSION=v0.4.22 RUN LD_LIBRARY_PATH= dnf install -y tar gzip ADD https://github.com/NoiseByNorthwest/php-spx/archive/refs/tags/${SPX_VERSION}.tar.gz /tmp/spx.tar.gz RUN mkdir -p /tmp/php-spx \ && tar -xzf /tmp/spx.tar.gz -C /tmp/php-spx --strip-components=1 \ && cd /tmp/php-spx \ && phpize \ && ./configure --prefix=${INSTALL_DIR} --exec-prefix=${INSTALL_DIR} \ --with-spx-assets-dir=/opt/bref/share/misc/php-spx/assets \ && make -j "$(nproc)" \ && make install \ && cp "$(php-config --extension-dir)/spx.so" /tmp/spx.so \ && strip --strip-all /tmp/spx.so FROM bref/php-84:3 COPY --from=spx-builder /tmp/spx.so /opt/bref/extensions/spx.so RUN printf 'extension=spx.so\nspx.data_dir=/tmp/spx\n' > /opt/bref/etc/php/conf.d/ext-spx.ini
spx.data_dir must live under /tmp, must match data_dir in the config, and its parent must exist — SPX's mkdir is not recursive.
Keep this as a separate Dockerfile from your production one rather than gating a COPY behind an ARG: COPY cannot be made conditional, and a second file leaves the production image built from something the profiling image cannot affect.
The .so is ~94 KB stripped and costs roughly 1 ms to load. It does not link libz; zlib symbols resolve at dlopen against the already-loaded PHP binary, so no dependency-copying step is needed.
Overhead
Enabling the extension is not free even for requests you never profile: SPX resolves and hashes every function name before checking whether a profiler is active, and installs custom Zend MM allocator handlers for the whole request.
While a profile is actively recording, expect roughly 45% instrumentation overhead.
Read call counts and relative cost. Never quote SPX timings as production numbers. With SPX enabled, that stage's absolute timings are no longer comparable to a stage without it.
SPX also only sees inside the PHP request. It covers framework bootstrap but not the Lambda init phase before PHP starts, so it will not by itself explain a cold-start regression.
Testing
composer test
The extension cannot be loaded on a dev machine, so SpxProfiler isolates its four ext calls behind overridable seams that the test suite replaces. Everything around them is the real implementation.
Credits
License
The MIT License (MIT). Please see License File for more information.