Profiling PHP code
OxPHP ships a built-in, per-request profiler. Unlike xdebug or standalone
extensions, it runs inside the server itself, requires no PHP restart, and adds
no meaningful overhead when disabled (the mode=Off branch bails out before it
even touches the filter cache).
This guide covers a first run with zero config, hunting slow production endpoints, reading flamegraphs, and comparing before/after optimization runs.
Quick start: 60 seconds to first profile
services:
app:
image: ghcr.io/oxphp/oxphp:0.13.0
environment:
INTERNAL_ADDR: 0.0.0.0:9090
PROFILER_ENABLED: "true"
PROFILER_AUTH_TOKEN: "dev-secret"
PROFILER_OUTPUT_FORMATS: "xhprof,speedscope,collapsed"
volumes:
- ./www:/var/www/html
- profiles:/tmp/oxphp-profiles
ports:
- "80:80"
- "9090:9090"
volumes:
profiles:# 1. Request a page with the profiling trigger.
curl -H "X-OxPHP-Profile: dev-secret" http://localhost/slow-endpoint
# 2. List captured runs.
curl -H "Authorization: Bearer dev-secret" http://localhost:9090/__profiler/runs \
| jq '.runs[0]'
# 3. Open the profile in speedscope (in-browser flamegraph).
open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"The rest of this guide explains what happens at each step and how to use the profiler in production.
What the profiler does
- Captures every PHP function call via the Zend Observer API, without bytecode patching or changes to application code.
- Builds a span tree with wall-time, CPU-time, memory at entry/exit, attributes, and events.
- Exports four formats simultaneously:
xhprof.json,speedscope.json,pprof(protobuf + gzip),collapsed(forflamegraph.pl). - Persists runs to an in-memory LRU cache and to disk files, with an optional HTTP push (xhgui or any custom collector).
- Exposes 8 internal HTTP routes on
INTERNAL_ADDRto browse and download profiles. - Emits Prometheus metrics for runs per source, spans collected, bytes written, drops, and push failures.
- Activates per request on a trigger, so it needs no restart.
How it works
graph TD R["Request"] R --> S1["Tokio thread: ProfilerRequestHandler inspects the trigger<br/>(header / cookie / query / sample_rate), constant-time compare"] S1 --> S2["Decision written into PluginRequestActions,<br/>forwarded to the worker via the SAPI channel"] S2 --> S3["Before RINIT the worker sets ProfilingMode = ProfileAll<br/>and registers Observer handlers on begin/end of every function"] S3 --> S4["Each PHP function call → C hook → bridge buffer → Rust SpanTree<br/>(span_id = monotonic BE counter; names interned in a<br/>thread-local interner, no extra allocations)"] S4 --> S5["After the response: ProfilerCompleteHandler receives Arc<SpanTree>,<br/>runs 4 exporters, puts it in the LRU cache, spawns disk-write<br/>and HTTP-push tasks (semaphores bound the fan-out)"]
Three per-request modes
| Mode | When | What is captured |
|---|---|---|
Off |
Default. No plugin asked for profiling. | Nothing. Zero overhead. |
ApmOnly |
plugin-apm is enabled but no profiler trigger matched. |
Only APM's explicit hooks: #[Trace], PDO/cURL emitters, oxphp_apm_*(). |
ProfileAll |
A profiler trigger matched (or OxPHP\Profile\start() was called while the profiler is enabled). |
Every PHP function call via the Observer API plus everything APM collects. |
ProfileAll supersedes ApmOnly: when both plugins are enabled and a trigger
matches, a single shared Arc<SpanTree> is used, so nothing is collected twice.
Installation and build
The plugin-profiler plugin is part of the default cargo features. A stock
docker compose build already includes it.
To disable it:
ARG OXPHP_WITH_PROFILER=0
# or
ARG CARGO_FEATURES="plugin-apm,plugin-otel" # no plugin-profilerTo verify the plugin is compiled in, check the startup log. With
LOG_LEVEL=info (the default), every compiled-in plugin logs a
Plugin initialized line that carries its name:
docker compose logs app | grep '"Plugin initialized"' | grep '"name":"profiler"'
# expect one JSON line with "message":"Plugin initialized" and "name":"profiler"Activation triggers
Triggers are checked in this priority order: header → cookie → query →
sample_rate. Any match activates ProfileAll. Tokens are compared in
constant time (subtle::ConstantTimeEq).
For development and scripts.
curl -H "X-OxPHP-Profile: dev-secret" https://app.local/checkoutIdeal for CI benchmarks, Postman collections, curl scripts.
For browser debugging. Set the cookie in the browser via DevTools or an extension:
OXPROF=dev-secret; Domain=app.local; Path=/As long as the cookie is alive, every request is profiled. Delete it to stop. Useful for walking a user scenario (open product → add to cart → checkout) and collecting a series of profiles.
The cookie is not stripped from the request: the token stays visible to the
code being profiled and to anything that logs or forwards cookies. It appears in
$_COOKIE and $_SERVER['HTTP_COOKIE'] like any other cookie. This is one more
reason to keep a production token off this path (see
Best practices).
For sharing links.
https://app.local/admin/report?__oxprof=dev-secretThe bluntest method, but handy when you want to send a coworker a link like "open
this, it reproduces the bug". Careful: the parameter ends up in access logs and
in Referer, so don't use it with a production token.
For production.
PROFILER_SAMPLE_RATE=0.001 # ≈ 1 in 1000 requestsRuns without a token. Turn it on in production to accumulate statistics
against real traffic. A good starting range is 0.0005..0.002; higher values
produce noticeable overhead, especially with PROFILER_INTERNAL=true.
Excluding paths from sampling
PROFILER_SAMPLE_RATE samples a random fraction of all requests, including
framework self-traffic that pollutes the data. Symfony's web debug toolbar polls
/_wdt/{token} and links to /_profiler/{token}; Laravel Debugbar and Telescope
behave similarly. Keep these out of sampling with PROFILER_EXCLUDE_PATHS:
PROFILER_EXCLUDE_PATHS=/_profiler,/_profiler/**,/_wdt/**Comma-separated glob patterns, same syntax as PHP_DENY_PATHS: * does not
cross /, ** does, and a leading / is optional. A pattern matches a bare path
or its subtree only if you list both: /_profiler/** covers /_profiler/x
but not the bare /_profiler, hence the two-pattern recipe above. Patterns match
the request path as received, with no percent-decoding or .. normalization, so
list the literal path your framework uses.
A request carrying an explicit trigger (the x-oxphp-profile header, the
OXPROF cookie, or the __oxprof query parameter) is always profiled, even
on an excluded path. This lets you deliberately profile /_profiler itself while
keeping it out of background sampling.
Configuration reference
| Variable | Default | Description |
|---|---|---|
PROFILER_ENABLED |
false |
Master switch. true → plugin is loaded. |
PROFILER_AUTH_TOKEN |
(unset) | Secret for triggers and bearer token for /__profiler/* routes. Empty string = "no token required" (any non-empty trigger value passes). Never commit the token to the repo. |
PROFILER_SAMPLE_RATE |
0.0 |
[0.0; 1.0]. Random sampling rate. |
PROFILER_EXCLUDE_PATHS |
(unset) | CSV glob patterns (PHP_DENY_PATHS syntax) excluded from PROFILER_SAMPLE_RATE. Explicit triggers still profile them. Example: /_profiler,/_profiler/**,/_wdt/**. |
PROFILER_INTERNAL |
false |
Observe internal C functions (strlen, json_encode, …). Full coverage, but 2× to 5× overhead. Use it only for targeted runs. |
PROFILER_MAX_SPANS |
50000 |
Hard cap on the calls observed per request. Once it is reached no further spans are recorded, and the run is flagged truncated. With no depth limit this is also the size of the tree; under PROFILER_MAX_DEPTH it is not, because a call the depth limit discards is counted here first, so a truncated run then reaches disk with only a fraction of this many spans. |
PROFILER_MAX_DEPTH |
0 (no cap) |
Maximum nesting depth of recorded calls; the effective maximum is 32. See Depth limit below. |
PROFILER_OUTPUT_DIR |
/tmp/oxphp-profiles |
Absolute path. Must be writable by www-data. |
PROFILER_OUTPUT_FORMATS |
xhprof,speedscope |
CSV subset of xhprof, speedscope, pprof, collapsed. |
PROFILER_RETENTION_COUNT |
100 |
How many runs to keep (both on disk and in LRU). Background trim every 5 seconds. |
PROFILER_DISK_MAX_PER_SEC |
10 |
Token bucket protecting the disk. Overflow is dropped and increments oxphp_profiler_disk_drops_total. |
PROFILER_EXPORT_URL |
(unset) | POST URL for each captured run (xhgui, custom collector). |
PROFILER_EXPORT_FORMAT |
xhprof |
One of the four formats for HTTP push. |
PROFILER_EXPORT_AUTH_TOKEN |
(unset) | Bearer token for the push target. |
PROFILER_EXPORT_XHGUI |
auto |
Force xhgui envelope mode. Auto: URL path ends with /run/import (the canonical xhgui endpoint; host/query hints are not matched, so set this to true for a non-standard path). |
PROFILER_EXPORT_BUGGREGATOR |
auto |
Force the Buggregator envelope. Auto: URL path ends with /api/profiler/store. The envelope always emits xhprof, so PROFILER_EXPORT_FORMAT is ignored for it (a non-xhprof value is warned, not fatal). Mutually exclusive with PROFILER_EXPORT_XHGUI (enabling both is a startup error). |
PROFILER_EXPORT_APP_NAME |
(unset) | Buggregator app_name: the project a profile is grouped under. |
PROFILER_EXPORT_TAGS |
(unset) | Buggregator tags, a key=value,key2=value2 list for filtering. A malformed token (not key=value), an empty key, or a duplicate key is a startup error. |
Depth limit
A call nested deeper than PROFILER_MAX_DEPTH is not recorded, and normally neither is anything it calls: that subtree is absent from the profile rather than flattened into it. Its time is still counted: the call at the limit spans everything it did, and with nothing recorded underneath, that duration reads as its own exclusive time. A frame at the boundary therefore looks hot in itself rather than hot in what it called.
- The effective maximum is
32, the depth to which the engine observer tracks open calls. A larger value is accepted and clamped, and withPROFILER_ENABLED=trueit is reported as clamped at startup. - Deep self-recursion is the exception. Past 32 frames of a function recursing into itself, the observer loses track of which call a return belongs to and attributes it to a shallower frame of the same function. A call deeper than the limit can then still be recorded, and the durations around it should not be trusted.
- It bounds the profile, not the cost of producing it. The engine hook still runs on the deeper frames, and the calls it drops still count against
PROFILER_MAX_SPANS. - Spans an application pushes through the APM helpers are not subject to it.
Example production config
environment:
PROFILER_ENABLED: "true"
PROFILER_AUTH_TOKEN: "${PROFILER_TOKEN_FROM_VAULT}"
PROFILER_SAMPLE_RATE: "0.001" # ~0.1% of traffic
PROFILER_INTERNAL: "false"
PROFILER_OUTPUT_DIR: /var/lib/oxphp/profiles
PROFILER_OUTPUT_FORMATS: "xhprof,collapsed"
PROFILER_RETENTION_COUNT: "500"
PROFILER_DISK_MAX_PER_SEC: "20"
PROFILER_EXPORT_URL: "http://xhgui.monitoring.svc.cluster.local/run/import"
PROFILER_EXPORT_FORMAT: "xhprof"PHP SDK
All functions live in the OxPHP\Profile namespace. They are always safe to call:
if profiling isn't active for the current request, mutators are safe no-ops and
is_active() returns false. With the profiler disabled (PROFILER_ENABLED),
start() does nothing either.
Explicit capture around a region
use function OxPHP\Profile\{start, stop, is_active};
function heavy_report(): array
{
start(); // activate ProfileAll inside the request
$result = build_report(); // this lands in the tree
stop(); // stop capture
return $result;
}start() is idempotent, and so is stop(): calling it twice in a row is safe.
Calling start() mid-request resets the current tree (see
PROFILING_CONTEXT.reset() in php_sdk.rs). This matches the spec invariant:
mode is set once per request, either by the trigger at RINIT or by the first
start() call.
Pause and resume
use function OxPHP\Profile\{pause, resume};
pause();
noisy_helper_we_dont_care_about(); // will not land in the tree
resume();Unlike stop(), pause/resume is a documentary signal for "temporarily".
Internally it's the same flag; the distinction just helps whoever reads the code.
Point markers: mark()
use function OxPHP\Profile\mark;
mark('cache_miss');
mark('got_auth_token', ['user_id' => (string) $user->id]);Attaches a SpanEventKind::Mark event to the topmost open span. No-op when no
span is open. Useful for interim timestamps in a long function or for marking
if/else branches.
Numeric metrics: metric()
use function OxPHP\Profile\metric;
$rows = $pdo->query('SELECT ...')->fetchAll();
metric('db.rows', (float) count($rows));
metric('payload.kb', strlen($body) / 1024.0);Appends metric.<name>=<value> to the attributes of the current span. Unlike
mark(), this is a plain key-value pair (no timestamp). It shows up in
speedscope/xhgui as a span property.
Status check: is_active()
if (OxPHP\Profile\is_active()) {
// can afford an expensive debug dump —
// this request is being profiled anyway
error_log(json_encode($debug_state));
}Two TLS reads. Safe to call in hot code.
Attributes (PHP 8)
The seven attributes split into two categories: observer filters run before span creation; decorators run after the span closes.
| Attribute | Category | Effect |
|---|---|---|
#[Profile] |
filter | Force-include the function in the tree (even if general rules would exclude it). |
#[Exclude] |
filter | Skip the function; its children re-parent to the nearest included ancestor. |
#[Sample(rate: 0.1)] |
filter | Keep only a fraction of calls (rate ∈ [0.0; 1.0]). Probabilistic and lock-free. |
#[Tag(key, value)] |
filter | Attach a label to the span. Repeatable: multiple #[Tag] accumulate. |
#[Mark(label?)] |
decorator | Emit a Mark event on function entry. |
#[SlowThreshold(ms)] |
decorator | Emit a Slow event + set status when wall-time ≥ ms. |
#[MemoryThreshold(kb)] |
decorator | Emit MemorySpike + status when net allocation ≥ kb. |
Class vs method composition
use OxPHP\Profile\{Tag, Profile, Exclude};
#[Tag(key: 'layer', value: 'domain')]
#[Profile] // the whole class is always profiled
class OrderService
{
#[Tag(key: 'op', value: 'create')]
public function create(array $data): Order { /* ... */ }
#[Exclude] // excluded, despite class-level #[Profile]
public function debug_dump(): void { /* ... */ }
public function find(int $id): ?Order { /* ... */ } // inherits #[Profile] and #[Tag(layer)]
}- Class-level attributes propagate to every method.
- Method-level attributes add to class-level ones (tags accumulate).
#[Exclude]on a method overrides class-level#[Profile].
Slow-function threshold
use OxPHP\Profile\SlowThreshold;
#[SlowThreshold(ms: 250)]
function render_dashboard(User $u): string
{
// if it runs ≥ 250 ms — a Slow event is appended to the span and
// status_code=2 (error). Immediately visible in xhgui / speedscope.
}Memory threshold
use OxPHP\Profile\MemoryThreshold;
#[MemoryThreshold(kb: 512)]
function import_csv(string $path): int
{
// if the function net-allocates ≥ 512 KB during execution —
// MemorySpike event + status=error
}Sampling individual functions
use OxPHP\Profile\Sample;
#[Sample(rate: 0.01)]
function log_event(string $evt, array $ctx): void
{
// ≈ 1% of calls land in the tree; the rest are skipped entirely —
// neither the span nor its children are created. Useful for functions
// called millions of times per request.
}When a function is called very often and you want to reduce capture cost, use
#[Sample] or #[Exclude] (they work before span creation). When you want to
flag an event above a threshold, use #[SlowThreshold] / #[MemoryThreshold]
(they look at the already-collected span).
What a captured span contains
FinishedSpan {
span_id # Arc<str>, W3C-compatible
parent_span_id # Arc<str>
trace_id # Arc<str>, shared with APM
name # Fully-qualified PHP function/method name
start_ns # wall-clock, ns since the profiler epoch
end_ns
cpu_ns # CLOCK_THREAD_CPUTIME_ID (0 when the platform doesn't provide it)
memory_start # zend_memory_usage(0) on entry
memory_end # zend_memory_usage(0) on exit
attributes # Vec<(Arc<str>, Arc<str>)> — from #[Tag], metric(), APM SQL/HTTP
events # Vec<SpanEvent { ts, kind, label, attrs }>
status_code # 0 = unset, 1 = ok, 2 = error
status_message
leaked # true if the span was force-closed by finalize (PHP threw past the observer)
}Event kinds (SpanEvent::kind):
| Kind | Emitted by |
|---|---|
Mark |
mark(), metric(), #[Mark] |
Slow |
#[SlowThreshold] |
MemorySpike |
#[MemoryThreshold] |
Sql |
APM hooks (PDO, mysqli) |
Http |
APM hooks (cURL, HTTP streams) |
Exception |
APM exception handler |
Alloc |
(reserved for heap sampling) |
Other |
fallback |
Export formats
Files live under PROFILER_OUTPUT_DIR, named <run_id>.<ext>, where
run_id = <ts_ms>-<req_id_prefix>-<rand4> (e.g.
1713600000000-a1b2c3d4-0f5e). The name is minted when the run is stored, so
every run is named this way whatever turned profiling on: a trigger, the
sampling draw, or OxPHP\Profile\start() from PHP.
req_id_prefix is the first eight characters of the request id and is not a
key on its own: in the id the server mints, those characters are the epoch
seconds, shared by every request of that second. Use the request_id field of
the index.json entry instead. It carries the id in full and is what the
access log prints.
speedscope (default for interactive analysis)
Extension: .speedscope.json
- In-browser flamegraph with zoom, search, CPU / time / memory toggle.
- Needs no setup: open it directly at speedscope.app.
- OxPHP returns a 302 redirect at
/__profiler/runs/{id}/speedscope→ speedscope.app with aprofileURL=…parameter that fetches the profile straight from your server.
# Ctrl-click in macOS Terminal / xdg-open on Linux
open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"xhprof (for xhgui: timeline and historical diff)
Extension: .xhprof.json
- Compatible with xhgui (URL search, trends, diff between two runs).
- Suited to production accumulation: run an xhgui container next to your
app, point
PROFILER_EXPORT_URL=http://xhgui/run/import, and history piles up in the UI. - Ready docker-compose:
tests/compose.xhgui.yml.
pprof (Google pprof tooling, Grafana pprof plugin, Pyroscope)
Extension: .pprof (protobuf + gzip, level fast, zlib backend)
# save and open
curl -H "Authorization: Bearer dev-secret" \
http://localhost:9090/__profiler/runs/<run_id>.pprof > profile.pprof
go tool pprof -http=:8080 profile.pprof
# or
pyroscope-cli adhoc --input profile.pprofcollapsed (Brendan Gregg's flamegraph.pl)
Extension: .collapsed
- Text format
func;child;grandchild <count>. - De facto input for SVG flamegraphs.
- Three metric variants: wall-time, CPU, memory. OxPHP writes
.collapsed(wall); internal pathways also produce.collapsed.cpuand.collapsed.mem(seetests/fixtures/profiler_exports/).
curl -H "Authorization: Bearer dev-secret" \
http://localhost:9090/__profiler/runs/<run_id>.collapsed \
| flamegraph.pl --title "Checkout $run_id" > flame.svgBuggregator (local debug server)
Buggregator is a single-binary debug server that,
among other things, renders xhprof profiles as flame graphs grouped by project.
The xhprof push targets its POST /api/profiler/store endpoint directly. You
need no xhprof PHP extension or client library, since OxPHP's native profiler
produces the data.
services:
buggregator:
image: ghcr.io/buggregator/server:latest
ports: ["8000:8000"]
app:
image: ghcr.io/oxphp/oxphp:0.13.0
environment:
PROFILER_ENABLED: "true"
PROFILER_SAMPLE_RATE: "0.01"
PROFILER_EXPORT_URL: "http://buggregator:8000/api/profiler/store"
PROFILER_EXPORT_FORMAT: "xhprof"
PROFILER_EXPORT_APP_NAME: "checkout" # groups profiles by project
PROFILER_EXPORT_TAGS: "env=staging,region=eu" # filterable in the UIA URL whose path ends in /api/profiler/store auto-selects the Buggregator
envelope (PROFILER_EXPORT_BUGGREGATOR: "true" forces it for a custom URL;
"false" opts out). This envelope always emits xhprof, so PROFILER_EXPORT_FORMAT
is ignored for it (a non-xhprof value draws a warning at startup and is not
fatal; the profiler never crashes the server over an export knob). app_name and tags
drive Buggregator's project grouping and filtering; without them the profile
still renders but lands ungrouped. hostname comes from $HOSTNAME, falling back
to the gethostname(2) syscall when that variable is not set.
Storage and cleanup
/tmp/oxphp-profiles/
├── index.json # NDJSON — one record per line
├── 1713600000000-a1b2c3d4-0f5e.xhprof.json
├── 1713600000000-a1b2c3d4-0f5e.speedscope.json
└── 1713600001234-b2c3d4e5-4a2b.xhprof.jsonindex.json entry schema
{
"run_id": "1713600000000-a1b2c3d4-0f5e",
"request_id": "a1b2c3d4e5f67890",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"timestamp_ms": 1713600000000,
"duration_ms": 123,
"method": "GET",
"url": "/checkout",
"status": 200,
"user_agent": "Mozilla/5.0 …",
"client_ip": "10.0.0.42",
"source": "Header", // Header | Cookie | Query | SampleRate | Sdk
"span_count": 4821,
"event_count": 7,
"error_count": 0,
"leaked_count": 0,
"truncated": false, // true — exceeded PROFILER_MAX_SPANS
"oxphp_version": "0.13.0",
"formats": ["xhprof.json", "speedscope.json"]
}source names what admitted the run. Header, Cookie and Query are the
three activation triggers and SampleRate is the
sampling draw; Sdk covers every run no trigger admitted: a request that
turned profiling on for itself with OxPHP\Profile\start(), and also a
plugin that selected full profiling on its own.
user_agent is the request's User-Agent header, cut to 512 bytes (a
…(truncated) marker included) when longer, with bytes that are not valid UTF-8
replaced with U+FFFD. It is null when the request sent none.
index.json is parsed by /__profiler/runs, sorted newest-first and paginated
via ?limit=N&offset=M.
Retention
- A background task every 5 seconds deletes entries past
PROFILER_RETENTION_COUNT(atomicrename→index.json). - Files without an
index.jsonentry (orphaned by a crash) are swept. - The
PROFILER_DISK_MAX_PER_SECtoken bucket protects the disk: if the rate is higher, runs are not written andoxphp_profiler_disk_drops_totalincrements.
Internal HTTP routes
With INTERNAL_ADDR=0.0.0.0:9090, the plugin registers 8 endpoints under the
/__profiler/ prefix. All require Authorization: Bearer <PROFILER_AUTH_TOKEN>
when a token is configured. Comparison is constant time.
| Route | Method | Purpose |
|---|---|---|
/__profiler/ |
GET | HTML landing page with endpoint index. |
/__profiler/runs |
GET | JSON array of runs. ?limit=N&offset=M. |
/__profiler/runs/{id} |
GET | JSON metadata for one run. |
/__profiler/runs/{id}.{format} |
GET | Raw profile bytes. format ∈ xhprof.json, speedscope.json, pprof, collapsed. |
/__profiler/runs/{id}/speedscope |
GET | 302 → speedscope.app with profileURL=…. |
/__profiler/runs/{id} |
DELETE | Delete all format files + index entry (returns 204). |
/__profiler/config |
GET | Current plugin configuration (tokens redacted). |
/__profiler/stats |
GET | JSON counter snapshot. |
Example scripts
# Top-5 slowest of the last 20 runs
curl -s -H "Authorization: Bearer $TOK" \
"http://localhost:9090/__profiler/runs?limit=20" \
| jq '.runs | sort_by(.duration_ms) | reverse | .[:5]'
# All profiles for a given URL
curl -s -H "Authorization: Bearer $TOK" \
"http://localhost:9090/__profiler/runs?limit=500" \
| jq '.runs[] | select(.url == "/checkout")'
# Delete all runs older than 1 hour (independent of the plugin's retention)
NOW=$(date +%s%3N)
CUTOFF=$((NOW - 3600000))
curl -s -H "Authorization: Bearer $TOK" \
"http://localhost:9090/__profiler/runs?limit=1000" \
| jq -r --arg c "$CUTOFF" '.runs[] | select(.timestamp_ms < ($c|tonumber)) | .run_id' \
| xargs -I{} curl -X DELETE -H "Authorization: Bearer $TOK" \
"http://localhost:9090/__profiler/runs/{}"HTTP push and xhgui
Send each run to a remote collector:
environment:
PROFILER_EXPORT_URL: "http://xhgui/run/import"
PROFILER_EXPORT_FORMAT: "xhprof"
PROFILER_EXPORT_AUTH_TOKEN: "shared-secret" # optional- xhgui envelope auto-detection: URL path ends with
/run/import(the canonical xhgui endpoint). A fuzzyxhguisubstring in the host or query is not matched; for such URLs, force it viaPROFILER_EXPORT_XHGUI=true|false. - Retry plan: 3 attempts with exponential backoff
100/200/400 ms, total budget 5 s wall-clock. The request body is shared across attempts asbytes::Bytes(zero retry allocations). - Errors increment
oxphp_profiler_http_push_failures_total.
Full demo stack
docker compose -f tests/compose.xhgui.yml up -d
# app: :80, xhgui: :8142 (UI), :27017 (mongo)E2E smoke test: tests/php/profiler/test_xhgui_import.php.
Prometheus metrics
Exposed on /metrics:
oxphp_profiler_runs_total{source="header"|"cookie"|"query"|"sample"|"sdk"}
oxphp_profiler_spans_collected_total
oxphp_profiler_bytes_written_total{format="xhprof"|"speedscope"|"pprof"|"collapsed"}
oxphp_profiler_disk_drops_total
oxphp_profiler_http_push_failures_total
oxphp_profiler_truncated_total
oxphp_profiler_in_memory_runsStarter Prometheus alerts:
- alert: ProfilerDiskDrops
expr: rate(oxphp_profiler_disk_drops_total[5m]) > 0
annotations:
summary: "Profiler is dropping runs on disk — check PROFILER_DISK_MAX_PER_SEC"
- alert: ProfilerPushFailing
expr: rate(oxphp_profiler_http_push_failures_total[5m]) > 0
annotations:
summary: "xhgui / collector is unreachable"
- alert: ProfilerTruncatingTrees
expr: rate(oxphp_profiler_truncated_total[5m]) > 0
annotations:
summary: "Requests exceed PROFILER_MAX_SPANS — raise the cap or investigate"Workflows
Find a slow endpoint
- Turn on
PROFILER_SAMPLE_RATE=0.001in production. Let it accumulate. - Sort runs by
duration_ms:curl -s -H "Authorization: Bearer $TOK" \ "http://INT_ADDR/__profiler/runs?limit=500" \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:10] | map({run_id, url, duration_ms, span_count})' - Open the top one in speedscope:
.../__profiler/runs/<id>/speedscope. - Enable Left Heavy mode in speedscope to see the functions with the most cumulative time.
- Click the widest bar to get its file:line and the list of children.
Validate a before/after hypothesis
- Run a benchmark BEFORE your changes:
for i in $(seq 1 20); do curl -s -H "X-OxPHP-Profile: dev-secret" http://localhost/api/report > /dev/null done curl -s -H "Authorization: Bearer dev-secret" \ "http://localhost:9090/__profiler/runs?limit=20" \ | jq '.runs | map(.duration_ms) | sort | .[length/2|floor]' > /tmp/p50_before.txt - Apply changes, rebuild, repeat. Compare the medians.
- For a detailed diff, download two xhprof profiles and upload them into xhgui, which has a built-in diff view.
Hunting a memory leak
- Send the request that "grows":
curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv - Open it in speedscope, switch to the memory metric (via
.collapsed.memor speedscope's memory view). - Add
#[MemoryThreshold(kb: 1024)]on suspicious functions. The next run then carries explicitMemorySpikeevents. - Use
metric('mem.after', memory_get_usage())for targeted instrumentation.
Continuous monitoring of a critical path
#[Profile]
#[SlowThreshold(ms: 500)]
public function chargeCard(PaymentRequest $r): PaymentResult
{
// always captured + an explicit Slow mark when it lags
}In Grafana, add a panel for oxphp_profiler_runs_total{source="sample"} and an
alert on duration_ms outliers from index.json (via a log-based metric or a
sidecar exporter).
Reproduce a bug via a link
A coworker says "/admin/report returns 500 for me". You reply:
https://app.local/admin/report?__oxprof=<one-time-token>After they visit, check /__profiler/runs?limit=5, open the profile, and see exactly
where the exception landed (status_code=2 + Exception event).
Interaction with APM
- Both plugins share a single
Arc<SpanTree>and do not collect anything twice. - No profiler trigger + APM enabled →
mode=ApmOnly. The tree contains only explicitly tagged spans (#[Trace], APM SQL/HTTP hooks). - Profiler trigger hit →
mode=ProfileAll. The tree contains everything plus APM annotations. - APM still ships only its explicit spans to OTLP (Jaeger/Tempo cap at ~10k
spans per trace). For the full picture, open
/__profiler/runs/<id>.
Best practices
- Never commit
PROFILER_AUTH_TOKEN. Read from Vault / Docker secrets / Kubernetes secrets. - In production, use only
SAMPLE_RATE. Header/cookie/query are developer tools. If you need on-demand prod profiling, use a dedicated, daily-rotated token. - Don't turn on
PROFILER_INTERNAL=trueglobally. Its 2× to 5× overhead turns production into a lab. Use it only for targeted runs in isolation. - Keep
PROFILER_RETENTION_COUNTrealistic. A run can weigh anything from hundreds of KB (small request) to megabytes (large tree), and 500 runs × 2 MB = 1 GB. Size the disk accordingly. #[Exclude]noisy helpers (logging, i18n, the autoloader). The tree becomes readable without losing meaning.- Link profiles to traces:
trace_idis shared. In Grafana / Kibana, link/__profiler/runs/<id>from the trace view. - Git-friendly identifiers. In this build,
span_idis a deterministic big-endian monotonic counter. Diffing two stored profiles is clean. - APM + profiler is free. Keep both enabled; the tree is shared, overhead comes only from APM's accumulated coverage.
Troubleshooting
No profiles appear
- Is the plugin compiled in?
docker compose buildincludes it by default. Check you didn't pass--build-arg OXPHP_WITH_PROFILER=0or a customCARGO_FEATURESwithoutplugin-profiler. PROFILER_ENABLED=true?- Does the trigger actually match
PROFILER_AUTH_TOKEN?- Look for a stray
\nin the env var. - For the query trigger, is the value correctly URL-encoded?
- Look for a stray
- Does the server see your request at all? Check the access log.
401 from /__profiler/runs
The bearer token in the header doesn't match PROFILER_AUTH_TOKEN. Common trap:
echo "secret" > secret.txt appends \n. Use printf or pass via env.
xhgui doesn't show new runs
- Check reachability:
docker compose exec app curl -v $PROFILER_EXPORT_URL - Look at
oxphp_profiler_http_push_failures_total. - Check the logs:
tracing::warn!withrun_idand HTTP status is emitted on each failure.
No files on disk
- Is
PROFILER_OUTPUT_DIRabsolute? Relative paths are ignored. - Writable by
www-data?docker compose exec app ls -la /tmp/oxphp-profiles - Is
PROFILER_DISK_MAX_PER_SECtoo low? Look atoxphp_profiler_disk_drops_total.
Too much overhead in production
PROFILER_INTERNAL=false(this is the default).PROFILER_SAMPLE_RATEin a reasonable range (0.0005..0.002).- Reasonable
PROFILER_MAX_SPANS. Once it is reached no further spans are recorded, the request itself is not interrupted, and the prefix collected up to that point is written withtruncated: true. For very large requests prefer a targetedstart()/stop()around the section of interest.
truncated=true in index.json
A request exceeded PROFILER_MAX_SPANS (default 50,000). Options:
- Raise the cap (trading memory for detail).
- Add
#[Exclude]/#[Sample(rate: 0.01)]on functions called tens of thousands of times. - Wrap only the suspect region in
start()/stop().
Command cheatsheet
# Activate for one request
curl -H "X-OxPHP-Profile: $TOK" http://localhost/endpoint
# List runs, top-10 by duration
curl -sH "Authorization: Bearer $TOK" http://localhost:9090/__profiler/runs \
| jq '.runs | sort_by(.duration_ms) | reverse | .[:10]'
# Open in speedscope
open "http://localhost:9090/__profiler/runs/$RUN_ID/speedscope"
# Download as xhprof for xhgui import
curl -sH "Authorization: Bearer $TOK" \
http://localhost:9090/__profiler/runs/$RUN_ID.xhprof.json > run.xhprof.json
# Download as pprof and open
curl -sH "Authorization: Bearer $TOK" \
http://localhost:9090/__profiler/runs/$RUN_ID.pprof > run.pprof
go tool pprof -http=:8080 run.pprof
# flamegraph.pl
curl -sH "Authorization: Bearer $TOK" \
http://localhost:9090/__profiler/runs/$RUN_ID.collapsed \
| flamegraph.pl > flame.svg
# Delete a run
curl -X DELETE -H "Authorization: Bearer $TOK" \
http://localhost:9090/__profiler/runs/$RUN_ID
# Metrics
curl -s http://localhost:9090/metrics | grep oxphp_profiler_
# Current plugin config (safe — tokens are redacted)
curl -sH "Authorization: Bearer $TOK" http://localhost:9090/__profiler/config | jqPractical examples
Below are ready-to-run PHP scenarios you can drop into www/public/ and hit with
curl.
Simple controller with manual control
<?php
declare(strict_types=1);
use function OxPHP\Profile\{start, stop, mark, metric, is_active};
function fetch_rows(PDO $db, int $user_id): array
{
$stmt = $db->prepare('SELECT * FROM orders WHERE user_id = ? LIMIT 1000');
$stmt->execute([$user_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function render_report(array $rows): string
{
$sum = array_sum(array_column($rows, 'amount'));
return json_encode(['count' => count($rows), 'total' => $sum]);
}
$db = new PDO('mysql:host=db;dbname=app', 'app', 'secret');
$user_id = (int) ($_GET['user_id'] ?? 1);
// Explicitly profile only the heavy block — even if the trigger wasn't set.
start();
mark('report.begin', ['user_id' => (string) $user_id]);
$rows = fetch_rows($db, $user_id);
metric('db.rows', (float) count($rows));
$body = render_report($rows);
metric('response.bytes', (float) strlen($body));
mark('report.done');
stop();
header('Content-Type: application/json');
echo $body;
// Optional — tell the frontend that this request was profiled:
if (is_active()) {
header('X-Profiled: 1');
}Invoke:
curl -H "X-OxPHP-Profile: dev-secret" 'http://localhost/report.php?user_id=42'Service class with attributes
<?php
declare(strict_types=1);
use OxPHP\Profile\{Profile, Tag, Exclude, Sample, SlowThreshold, MemoryThreshold};
#[Profile]
#[Tag(key: 'layer', value: 'domain')]
#[Tag(key: 'svc', value: 'orders')]
final class OrderService
{
public function __construct(
private readonly PDO $db,
private readonly Mailer $mailer,
) {}
#[SlowThreshold(ms: 250)]
#[Tag(key: 'op', value: 'create')]
public function create(array $payload): int
{
$this->db->beginTransaction();
try {
$id = $this->insertOrder($payload);
$this->insertLines($id, $payload['items']);
$this->db->commit();
$this->mailer->sendReceipt($id);
return $id;
} catch (\Throwable $e) {
$this->db->rollBack();
throw $e;
}
}
#[MemoryThreshold(kb: 2048)]
#[Tag(key: 'op', value: 'export')]
public function exportCsv(int $user_id): string
{
$stmt = $this->db->prepare('SELECT * FROM orders WHERE user_id = ?');
$stmt->execute([$user_id]);
$buf = fopen('php://temp', 'r+');
fputcsv($buf, ['id', 'created_at', 'total']);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
fputcsv($buf, [$row['id'], $row['created_at'], $row['total']]);
}
rewind($buf);
return stream_get_contents($buf);
}
// Trivial getter — don't clutter the tree.
#[Exclude]
public function find(int $id): ?array
{
$stmt = $this->db->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
// Very frequent audit — sample to avoid inflating the tree.
#[Sample(rate: 0.05)]
private function audit(string $event, array $ctx): void
{
$this->db->prepare('INSERT INTO audit (event, ctx) VALUES (?, ?)')
->execute([$event, json_encode($ctx)]);
}
private function insertOrder(array $p): int { /* ... */ return 0; }
private function insertLines(int $id, array $items): void { /* ... */ }
}Batch job: profile only the first iteration of N
<?php
declare(strict_types=1);
use function OxPHP\Profile\{start, stop, pause, resume, mark};
$files = glob('/data/incoming/*.csv');
$i = 0;
foreach ($files as $path) {
if ($i === 0) {
start(); // profile only the first file in full
mark('batch.begin', ['path' => $path]);
} else {
pause(); // the rest — no-op for capture
}
import_one($path);
if ($i === 0) {
mark('batch.first_done');
stop();
}
$i++;
}
function import_one(string $path): void { /* ... */ }Compare two implementations (micro-benchmark with profiles)
<?php
// naive vs streaming comparison
declare(strict_types=1);
use function OxPHP\Profile\{start, stop, mark, metric};
function naive_sum(string $path): int
{
$rows = array_map('str_getcsv', file($path)); // whole file into memory
return array_sum(array_column($rows, 1));
}
function streaming_sum(string $path): int
{
$h = fopen($path, 'r');
$total = 0;
while (($row = fgetcsv($h)) !== false) {
$total += (int) ($row[1] ?? 0);
}
fclose($h);
return $total;
}
$path = '/data/big.csv';
$which = $_GET['impl'] ?? 'naive';
start();
mark('bench.begin', ['impl' => $which]);
$t0 = hrtime(true);
$result = $which === 'naive' ? naive_sum($path) : streaming_sum($path);
$elapsed_ms = (hrtime(true) - $t0) / 1e6;
metric('bench.elapsed_ms', $elapsed_ms);
metric('bench.result', (float) $result);
mark('bench.done');
stop();
echo json_encode(['impl' => $which, 'elapsed_ms' => $elapsed_ms, 'result' => $result]);Workflow:
# Naive
curl -H "X-OxPHP-Profile: dev-secret" "http://localhost/bench.php?impl=naive"
# Streaming
curl -H "X-OxPHP-Profile: dev-secret" "http://localhost/bench.php?impl=streaming"
# Diff in xhgui (two latest xhprof runs)
curl -sH "Authorization: Bearer dev-secret" \
"http://localhost:9090/__profiler/runs?limit=2" | jq '.runs[] | .run_id'Conditional profiling in production code
<?php
// Classic case: a suspected function is slow for certain users only.
declare(strict_types=1);
use function OxPHP\Profile\{start, stop, is_active};
function charge(User $user, Money $amount): PaymentResult
{
// Audit: if this request is being profiled,
// enable extra logging inside the third-party call.
$verbose = is_active();
$gateway = new StripeClient(verbose: $verbose);
return $gateway->charge($user->id, $amount);
}
function oncall_path(Order $order): void
{
// Profile only VIP users — no external trigger needed.
if ($order->user->tier === 'vip') {
start();
}
process($order);
if ($order->user->tier === 'vip') {
stop();
}
}Integration test that profiles itself
<?php
declare(strict_types=1);
require __DIR__ . '/test_helper.php';
use function OxPHP\Profile\{start, stop, mark, is_active};
$t = new TestCase('profile_smoke', 'my-app');
// Turn profiling on from PHP — no trigger needed to test the SDK, but the
// profiler must be enabled (PROFILER_ENABLED=true), or start() does nothing.
$t->assertFalse('initially not active', is_active());
start();
$t->assertTrue('active after start', is_active());
mark('test.midpoint');
// some work
$sum = 0;
for ($i = 0; $i < 100_000; $i++) { $sum += $i; }
stop();
$t->assertFalse('inactive after stop', is_active());
$t->assertSame('computation OK', $sum, 4999950000);
$t->done();Finding a hotspot from a series of Postman requests
Scenario: "/api/search is sometimes slow, not always."
// Postman Pre-request Script
pm.request.headers.add({
key: 'X-OxPHP-Profile',
value: pm.environment.get('PROFILE_TOKEN')
});After 100 runs, a jq one-liner for top anomalies:
curl -sH "Authorization: Bearer $TOK" \
"http://int.app.local:9090/__profiler/runs?limit=200" \
| jq -r '.runs
| map(select(.url | startswith("/api/search")))
| sort_by(-.duration_ms)
| .[:5]
| map("\(.duration_ms)ms \(.run_id) \(.url)")
| .[]'A custom decorator that feeds the profiler
Your own #[ProfileDb], which logs the row count and automatically calls
metric('db.rows', …):
<?php
use OxPHP\Decorator\{AttributeInterface, Context};
use function OxPHP\Profile\metric;
#[Attribute(Attribute::TARGET_METHOD)]
class ProfileDb implements AttributeInterface
{
public function before(Context $ctx): void {}
public function after(Context $ctx): void
{
$result = $ctx->returnValue;
if (is_array($result)) {
metric('db.rows', (float) count($result));
} elseif ($result instanceof PDOStatement) {
metric('db.rows', (float) $result->rowCount());
}
}
}
oxphp_register_decorator(ProfileDb::class);
class UserRepository
{
#[ProfileDb]
public function findAll(): array { /* ... */ return []; }
}The decorator + profiler pairing works out of the box: metric() automatically
attaches to the span of the function the Observer API is currently watching.
Prompts for your agent
These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.
Profile a slow endpoint of mine with OxPHP's profiler. Read the `features/profiling` page through the OxPHP documentation MCP server.
Turn the profiler on in this repository's Compose or Kubernetes configuration, pick the trigger that suits how I reach that endpoint, and give me the commands that capture a run and open it.Read my captured OxPHP profile and tell me what is slow. Read the `features/profiling` page through the OxPHP documentation MCP server.
Pull the run through the internal profiler routes, then map its widest frames back onto the code in this repository and tell me what is worth changing first.References
- In-tree spec:
src/profiling/mod.rs,src/plugins/ox_profiler/ - Bridge (C):
ext/bridge/oxphp_bridge.c,ext/oxphp_sapi.c - PHP tests:
tests/php/profiler/ - Format fixtures:
tests/fixtures/profiler_exports/ - xhgui demo:
tests/compose.xhgui.yml - speedscope: https://www.speedscope.app/
- xhgui: https://github.com/perftools/xhgui
- Google pprof: https://github.com/google/pprof
- flamegraph.pl: https://github.com/brendangregg/FlameGraph