Prometheus Metrics
OxPHP exposes Prometheus-compatible metrics in text exposition format at GET /metrics on the internal server. They cover request throughput, response times, connection state, worker pool health, static file caching, compression efficiency, and worker mode performance.
Enabling metrics
Set INTERNAL_ADDR to start the internal server:
INTERNAL_ADDR=127.0.0.1:9090Then scrape from Prometheus or any compatible collector:
curl http://localhost:9090/metricsServer metrics
| Metric | Type | Description |
|---|---|---|
oxphp_uptime_seconds |
gauge | Seconds since the server process started |
oxphp_requests_total |
counter | Total HTTP requests received on the main port |
Request metrics
| Metric | Type | Description |
|---|---|---|
oxphp_requests_by_method_total |
counter | Requests by HTTP method. Label: method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT, QUERY, OTHER) |
oxphp_responses_by_status_total |
counter | Responses by status class. Label: status (1xx, 2xx, 3xx, 4xx, 5xx) |
oxphp_request_bytes_total |
counter | Total request body bytes received |
oxphp_response_bytes_total |
counter | Total response body bytes sent |
oxphp_request_cancelled_total |
counter | Cancelled requests by reason. Label: reason (client_abort, timeout, shutdown). Always emitted |
Only methods and status classes with at least one recorded event are emitted. Zero-count labels are omitted.
Request duration histogram
| Metric | Type | Description |
|---|---|---|
oxphp_request_duration_us |
histogram | End-to-end request duration in microseconds for all requests (static files and PHP) |
Bucket boundaries (microseconds): 100, 500, 1000, 2500, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000, +Inf.
Use this histogram to track overall latency, identify slow endpoints, and measure tail latency percentiles.
Connection metrics
| Metric | Type | Description |
|---|---|---|
oxphp_active_connections |
gauge | Currently open TCP connections on the main port |
oxphp_pending_requests |
gauge | PHP requests accepted but not yet answered — waiting for a queue slot, queued, or executing. Only requests routed to PHP: a static file, a 404 or a denied path is answered without the queue and never appears here |
oxphp_dropped_requests_total |
counter | Requests where the PHP worker failed after accepting the request |
oxphp_admission_refused_total |
counter | Requests answered without reaching a worker. Label: reason — wait_timeout (waited the full QUEUE_WAIT_TIMEOUT_MS, give the pool more headroom), waiting_full (already QUEUE_MAX_WAITING requests waiting, raise that or MAX_CONNECTIONS), waiting_bytes (the bodies already parked fill QUEUE_MAX_WAITING_BYTES, so this request's body had nowhere to sit — raise that, or lower how much a client may upload), queue_full (QUEUE_WAIT_TIMEOUT_MS=0, waiting is off), shutting_down (the drain deadline passed while the request was still waiting for admission), pool_unavailable (no worker thread is left to hand the request to — the pool is gone, not busy). Only the first four are overload and answer 529; shutting_down answers 503 like the rest of graceful drain, and pool_unavailable answers 500. Alert on overload with those four specifically: the metric as a whole also moves on a restart. Excluded from oxphp_queue_wait_us |
Worker pool metrics
| Metric | Type | Description |
|---|---|---|
oxphp_workers_current |
gauge | Current number of PHP worker threads |
oxphp_workers_min |
gauge | Minimum worker count (equals current count in static mode) |
oxphp_workers_max |
gauge | Maximum worker count (equals current count in static mode) |
oxphp_workers_idle |
gauge | Worker threads with no request in flight, computed as workers_current - busy_workers |
oxphp_busy_workers |
gauge | Worker threads currently executing at least one request; never exceeds oxphp_workers_current. Counts threads, not requests — in worker mode one thread multiplexes many request fibers and still counts once. Requests waiting for admission or sitting in the queue are not counted; those appear in oxphp_pending_requests |
oxphp_workers_spawned_total |
counter | Total workers spawned since startup (includes initial workers) |
oxphp_workers_retired_total |
counter | Total workers retired due to idle timeout (dynamic mode only) |
Worker supervisor metrics
Per-worker observability emitted by the worker supervisor. Each series carries a worker_id label (slot index). These appear once the supervisor is tracking per-worker state.
| Metric | Type | Description |
|---|---|---|
oxphp_worker_request_age_seconds |
gauge | Age of the in-flight request on each worker, in seconds. Label: worker_id |
oxphp_worker_long_running_total |
counter | Supervisor scans that observed a request older than the stuck threshold. Label: worker_id |
oxphp_worker_stuck_total |
counter | Stuck-classification counter per worker. Labels: worker_id, kind (io, c_call, cpu) |
Queue wait histogram
| Metric | Type | Description |
|---|---|---|
oxphp_queue_wait_us |
histogram | Time a request waits in the queue before a worker picks it up, in microseconds |
Bucket boundaries (microseconds): 50, 100, 250, 500, 1000, 2500, 5000, 10000, 50000, 100000, 250000, 500000, 1000000, +Inf.
This measures time spent waiting — for admission and then in the queue — with the script's own execution time subtracted, so it answers "how long before a worker picked this up" rather than "how long did the request take". Requests refused with 529 never queued and are not recorded here; count those with oxphp_admission_refused_total.
High queue wait times indicate that all workers are busy and you should increase PHP_WORKERS. The range reaches one second, matching the default QUEUE_WAIT_TIMEOUT_MS, so a request that spent most of its wait budget before running is quantified rather than lumped into +Inf. Nothing that gets served waits longer than the budget — past it the request is refused instead — so raising QUEUE_WAIT_TIMEOUT_MS is the one setting that puts waits back into +Inf.
Rate limiting metrics
| Metric | Type | Description |
|---|---|---|
oxphp_rate_limited_total |
counter | Requests rejected by the rate limiter (returned 429) |
oxphp_php_deny_total |
counter | Requests blocked by PHP_DENY_PATHS (.php execution denied). See PHP Execution Deny-List |
Static file cache metrics
| Metric | Type | Description |
|---|---|---|
oxphp_static_cache_hits_total |
counter | Static file requests served from the in-memory cache |
oxphp_static_cache_misses_total |
counter | Static file requests that required a disk read |
Compression metrics
| Metric | Type | Description |
|---|---|---|
oxphp_compressed_responses_total |
counter | Responses compressed with Brotli |
oxphp_compression_bytes_saved_total |
counter | Total bytes saved by compression (original size minus compressed size) |
Worker mode metrics
These metrics are only emitted when worker mode is active (WORKER_MODE_ENABLED=true).
Global counters
| Metric | Type | Description |
|---|---|---|
oxphp_worker_mode_enabled |
gauge | Always 1 when worker mode is active |
oxphp_worker_requests_handled_total |
counter | Total requests processed by persistent workers |
oxphp_worker_recycles_total |
counter | Total worker recycles (worker exited and was respawned) |
oxphp_worker_recycles_by_reason_total |
counter | Recycles by reason. Label: reason (scheduled, max_memory, error) |
oxphp_worker_soft_resets_total |
counter | Total soft resets performed between requests |
Per-worker gauges
| Metric | Type | Description |
|---|---|---|
oxphp_worker_memory_bytes |
gauge | Current PHP heap usage per worker. Label: worker (slot index, e.g., "0", "1") |
oxphp_worker_uptime_seconds |
gauge | Seconds since each worker was spawned. Label: worker |
oxphp_worker_requests_count |
gauge | Requests handled by each worker instance. Label: worker |
Worker request duration histogram
| Metric | Type | Description |
|---|---|---|
oxphp_worker_request_duration_us |
histogram | PHP handler execution time per request in microseconds (worker mode only) |
Bucket boundaries (microseconds): 100, 250, 500, 1000, 2500, 5000, 10000, 25000, 50000, +Inf.
This histogram measures time spent inside the PHP handler callback, excluding queue wait time. Use it to identify slow handlers and track tail latency in worker mode.
Async pool metrics
These metrics require ASYNC_WORKERS set to a non-zero value, and each has its own emission gate: the counters appear only after at least one task has been dispatched or rejected, the _in_flight / _in_flight_limit gauges appear once the pool has wired its in-flight counter, and oxphp_async_output_discarded_bytes_total appears only after some output has been discarded.
| Metric | Type | Description |
|---|---|---|
oxphp_async_tasks_dispatched_total |
counter | Total async tasks dispatched to the background pool |
oxphp_async_tasks_completed_total |
counter | Async tasks that completed successfully |
oxphp_async_tasks_failed_total |
counter | Async tasks that threw an exception |
oxphp_async_tasks_cancelled_total |
counter | Async tasks that were cancelled |
oxphp_async_tasks_rejected_total |
counter | Async tasks rejected at dispatch — because the pool queue was full or the in-flight cap (ASYNC_MAX_FIBERS × ASYNC_WORKERS) was reached |
oxphp_async_tasks_stranded_total |
counter | Workers left running past an await_race / await_any timeout. Each stranded task can extend RSHUTDOWN by up to 5 seconds. |
oxphp_async_tasks_in_flight |
gauge | Async tasks currently queued or running (emitted once the pool wires its in-flight counter) |
oxphp_async_tasks_in_flight_limit |
gauge | Maximum concurrent async tasks (ASYNC_MAX_FIBERS × ASYNC_WORKERS) |
oxphp_async_output_discarded_bytes_total |
counter | Bytes of async-task output discarded at worker idle (an echo in an async task has no client to receive it) |
Grafana dashboard tips
The following PromQL queries are useful for building dashboards:
Request rate (requests per second):
rate(oxphp_requests_total[5m])Average response time (milliseconds):
rate(oxphp_request_duration_us_sum[5m])
/ rate(oxphp_requests_total[5m]) / 1000p99 request duration (milliseconds):
histogram_quantile(0.99, rate(oxphp_request_duration_us_bucket[5m])) / 1000Error rate (5xx responses as a percentage):
rate(oxphp_responses_by_status_total{status="5xx"}[5m])
/ rate(oxphp_requests_total[5m]) * 100Worker pool utilization:
oxphp_busy_workers / oxphp_workers_currentThis is a true fraction between 0 and 1. Sustained values at 1 mean every worker is occupied and further arrivals are queueing. Pair it with rate(oxphp_admission_refused_total{reason=~"queue_full|wait_timeout|waiting_full|waiting_bytes"}[5m]) to see whether that backlog is turning into refusals, and with oxphp_pending_requests to see how deep it is.
Queue saturation (drop rate per second):
rate(oxphp_dropped_requests_total[5m])p99 queue wait (microseconds):
histogram_quantile(0.99, rate(oxphp_queue_wait_us_bucket[5m]))Static file cache hit rate:
rate(oxphp_static_cache_hits_total[5m])
/ (rate(oxphp_static_cache_hits_total[5m]) + rate(oxphp_static_cache_misses_total[5m]))Bytes saved by compression per second:
rate(oxphp_compression_bytes_saved_total[5m])Worker mode p99 latency (microseconds):
histogram_quantile(0.99, rate(oxphp_worker_request_duration_us_bucket[5m]))Worker recycle rate (per minute):
rate(oxphp_worker_recycles_total[5m]) * 60Average worker memory usage:
avg(oxphp_worker_memory_bytes)Prometheus scrape config
Add a scrape job to your prometheus.yml:
scrape_configs:
- job_name: "oxphp"
scrape_interval: 15s
static_configs:
- targets: ["oxphp:9090"]For Kubernetes service discovery:
scrape_configs:
- job_name: "oxphp"
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: oxphp
action: keep
- source_labels: [__meta_kubernetes_pod_ip]
target_label: __address__
replacement: "$1:9090"See also
- Health Checks — the
/healthand/configendpoints on the internal server - Configuration Reference — all environment variables including
INTERNAL_ADDR - Graceful Shutdown — how connection draining affects
oxphp_active_connections - Worker Mode — persistent workers and the metrics they emit