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

compose.yml
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:
bash
# 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 (for flamegraph.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_ADDR to 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&lt;SpanTree&gt;,<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:

Dockerfile
ARG OXPHP_WITH_PROFILER=0 # or ARG CARGO_FEATURES="plugin-apm,plugin-otel" # no plugin-profiler

To 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:

bash
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.

bash
curl -H "X-OxPHP-Profile: dev-secret" https://app.local/checkout

Ideal for CI benchmarks, Postman collections, curl scripts.

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:

bash
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.

Exclusion affects automatic sampling only

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 with PROFILER_ENABLED=true it 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

yaml
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

php
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.

Warning

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

php
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()

php
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()

php
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()

php
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

php
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

php
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

php
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

php
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. }
Filters vs decorators?

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

ruby
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.

Linking a profile to its access-log line

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 a profileURL=… parameter that fetches the profile straight from your server.
bash
# 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)

bash
# 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.pprof

collapsed (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.cpu and .collapsed.mem (see tests/fixtures/profiler_exports/).
bash
curl -H "Authorization: Bearer dev-secret" \ http://localhost:9090/__profiler/runs/<run_id>.collapsed \ | flamegraph.pl --title "Checkout $run_id" > flame.svg

Buggregator (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.

yaml
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 UI

A 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

text
/tmp/oxphp-profiles/ ├── index.json # NDJSON — one record per line ├── 1713600000000-a1b2c3d4-0f5e.xhprof.json ├── 1713600000000-a1b2c3d4-0f5e.speedscope.json └── 1713600001234-b2c3d4e5-4a2b.xhprof.json

index.json entry schema

json
{ "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 (atomic rename → index.json).
  • Files without an index.json entry (orphaned by a crash) are swept.
  • The PROFILER_DISK_MAX_PER_SEC token bucket protects the disk: if the rate is higher, runs are not written and oxphp_profiler_disk_drops_total increments.

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

bash
# 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:

yaml
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 fuzzy xhgui substring in the host or query is not matched; for such URLs, force it via PROFILER_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 as bytes::Bytes (zero retry allocations).
  • Errors increment oxphp_profiler_http_push_failures_total.

Full demo stack

bash
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:

text
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_runs

Starter Prometheus alerts:

yaml
- 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

  1. Turn on PROFILER_SAMPLE_RATE=0.001 in production. Let it accumulate.
  2. Sort runs by duration_ms:
    bash
    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})'
  3. Open the top one in speedscope: .../__profiler/runs/<id>/speedscope.
  4. Enable Left Heavy mode in speedscope to see the functions with the most cumulative time.
  5. Click the widest bar to get its file:line and the list of children.

Validate a before/after hypothesis

  1. Run a benchmark BEFORE your changes:
    bash
    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
  2. Apply changes, rebuild, repeat. Compare the medians.
  3. For a detailed diff, download two xhprof profiles and upload them into xhgui, which has a built-in diff view.

Hunting a memory leak

  1. Send the request that "grows":
    bash
    curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv
  2. Open it in speedscope, switch to the memory metric (via .collapsed.mem or speedscope's memory view).
  3. Add #[MemoryThreshold(kb: 1024)] on suspicious functions. The next run then carries explicit MemorySpike events.
  4. Use metric('mem.after', memory_get_usage()) for targeted instrumentation.

Continuous monitoring of a critical path

php
#[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).

A coworker says "/admin/report returns 500 for me". You reply:

text
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

  1. Never commit PROFILER_AUTH_TOKEN. Read from Vault / Docker secrets / Kubernetes secrets.
  2. 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.
  3. Don't turn on PROFILER_INTERNAL=true globally. Its 2× to 5× overhead turns production into a lab. Use it only for targeted runs in isolation.
  4. Keep PROFILER_RETENTION_COUNT realistic. 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.
  5. #[Exclude] noisy helpers (logging, i18n, the autoloader). The tree becomes readable without losing meaning.
  6. Link profiles to traces: trace_id is shared. In Grafana / Kibana, link /__profiler/runs/<id> from the trace view.
  7. Git-friendly identifiers. In this build, span_id is a deterministic big-endian monotonic counter. Diffing two stored profiles is clean.
  8. APM + profiler is free. Keep both enabled; the tree is shared, overhead comes only from APM's accumulated coverage.

Troubleshooting

No profiles appear
  1. Is the plugin compiled in? docker compose build includes it by default. Check you didn't pass --build-arg OXPHP_WITH_PROFILER=0 or a custom CARGO_FEATURES without plugin-profiler.
  2. PROFILER_ENABLED=true?
  3. Does the trigger actually match PROFILER_AUTH_TOKEN?
    • Look for a stray \n in the env var.
    • For the query trigger, is the value correctly URL-encoded?
  4. 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
  1. Check reachability:
    bash
    docker compose exec app curl -v $PROFILER_EXPORT_URL
  2. Look at oxphp_profiler_http_push_failures_total.
  3. Check the logs: tracing::warn! with run_id and HTTP status is emitted on each failure.
No files on disk
  • Is PROFILER_OUTPUT_DIR absolute? Relative paths are ignored.
  • Writable by www-data?
    bash
    docker compose exec app ls -la /tmp/oxphp-profiles
  • Is PROFILER_DISK_MAX_PER_SEC too low? Look at oxphp_profiler_disk_drops_total.
Too much overhead in production
  • PROFILER_INTERNAL=false (this is the default).
  • PROFILER_SAMPLE_RATE in 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 with truncated: true. For very large requests prefer a targeted start()/stop() around the section of interest.
truncated=true in index.json

A request exceeded PROFILER_MAX_SPANS (default 50,000). Options:

  1. Raise the cap (trading memory for detail).
  2. Add #[Exclude] / #[Sample(rate: 0.01)] on functions called tens of thousands of times.
  3. Wrap only the suspect region in start()/stop().

Command cheatsheet

bash
# 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 | jq

Practical examples

Below are ready-to-run PHP scenarios you can drop into www/public/ and hit with curl.

Simple controller with manual control

www/public/report.php
<?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:

bash
curl -H "X-OxPHP-Profile: dev-secret" 'http://localhost/report.php?user_id=42'

Service class with attributes

www/lib/OrderService.php
<?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

www/bin/import.php
<?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)

www/public/bench.php
<?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:

bash
# 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
<?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

tests/php/profile_smoke.php
<?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."

javascript
// 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:

bash
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
<?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.

prompt
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.
prompt
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

Found a mistake? Report it →