atymic/laravel-spx-lambda

Trigger php-spx request profiles on Bref/Lambda and ship the reports to S3

Maintainers

Package info

github.com/atymic/laravel-spx-lambda

pkg:composer/atymic/laravel-spx-lambda

Transparency log

Fund package maintenance!

atymic

Statistics

Installs: 5

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.0 2026-08-03 02:32 UTC

This package is auto-updated.

Last update: 2026-08-03 02:41:59 UTC


README

Latest Version on Packagist Total Downloads

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 spx extension 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=0 is mandatory — spx_profiler_start() refuses to run otherwise, and that call is exactly what this package's trigger makes.
  • SPX_REPORT=full is mandatory on CLI, where the default is fp to STDERR.
  • ct (CPU time) earns its place: wall minus CPU is idle time, which distinguishes blocking I/O from PHP burning CPU. Avoid io/ior/iow/mor — they read /proc on every metric collection, and ior subtracts SPX's own read noise from a size_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 enabled false 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.