Skip to content

FPM status: full OpenMetrics support with per-worker metrics - #23808

Open
1ma wants to merge 8 commits into
php:masterfrom
1ma:fpm-status-openmetrics-full
Open

1ma wants to merge 8 commits into
php:masterfrom
1ma:fpm-status-openmetrics-full

Conversation

@1ma

@1ma 1ma commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #9494, alternative to #12759

The former logic was quite a mess and I saw no way to fit the OpenMetrics per-worker stats into the existing control flow, so I extracted each format handling into its own static function and fpm_status_handle_request() became a simple dispatcher to the specialized handler. For text/html/xml/json these functions are a bit repetitive, but for OpenMetrics this design allows the flexibility to have a different control flow that doesn't interfere with the other formats.

The new metrics try to follow those of php-fpm_exporter as close as possible with only very minor differences (the scrape_uri label is not present, and the state "Getting request informations" is now "Getting request information" as per the existing constants in FPM. phpfpm_scrape_failures is also not present).

I tried to organize the commits to ease review, each one does a logical step and compiles and passes the tests.

@anun333

anun333 commented Oct 26, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

Ran this and #12759 side by side (this one onto today's master eefa52a5b3b, #12759 at its own head, which no longer applies), a static pool of two workers, and compared /status?openmetrics and ?openmetrics&full:

master this PR #12759
?openmetrics pool series phpfpm_up, phpfpm_idle_processes, … identical to master renamed and labelled: phpfpm_up{pool="…"}, phpfpm_processes{pool,state="idle"}, phpfpm_accepted_connections_total, phpfpm_start_since_seconds, phpfpm_slow_requests_total
worker identified by child="0", child="1" pid="…"
worker state one series per state, 1 for the current one (7 per worker) one series, the current state as a label
other per-worker series requests, request_duration, last_request_cpu, last_request_memory requests, request_seconds, request_cpu, request_memory_bytes, request_content_length_bytes, start_since_seconds

So this one leaves the existing output exactly as it is and adds the per-worker series only under &full, while #12759 changes the series current scrapers read. A child slot index stays the same when a worker is respawned, where a pid label starts a new series each time.

sapi/fpm/tests/status*.phpt (five on master, six with this PR's status-openmetrics-full.phpt) pass.

How this was tested

Linux x86_64, gcc 13.3, debug builds with FPM:

./buildconf --force
./configure --enable-debug --disable-cgi --disable-phpdbg --disable-fileinfo --enable-pcntl --enable-zend-test --enable-fpm
make -j4

The output came from a throwaway test in sapi/fpm/tests/ using the FPM tester, with this PR's test's pool (pm = static, pm.max_children = 2, pm.status_path = /status), printing $tester->request('openmetrics&full', [], '/status')->getBody() and the same for openmetrics and json&full. Tests: TEST_PHP_EXECUTABLE=sapi/cli/php TEST_FPM_BINARY=sapi/fpm/php-fpm sapi/cli/php run-tests.php -n -q sapi/fpm/tests/status*.phpt.

@anun333

anun333 commented Oct 29, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

One more thing from the same builds, checked against the OpenMetrics 1.0 spec: a Counter's sample "MUST have the suffix _total". FPM's counters have no suffix today, and the same holds with this PR (its new phpfpm_process_requests too) and with #12759, which moves _total into the family name, so the sample still equals it. Prometheus's Python client rejects all three at the first counter:

/status?openmetrics&full prometheus_client 0.26.0 with only the counters' samples renamed to <name>_total
master ValueError: Clashing name: phpfpm_start_since parses, 13 families
this PR the same parses, 18 families
#12759 Clashing name: phpfpm_start_since_seconds parses, 17 families

# EOF is there (fixed for GH-7842), so the counter names are the only thing it trips on. Since this rewrites the OpenMetrics handler, it might be the place to fix it, at least for the new per-worker counters; renaming the existing samples changes what current scrapers read, so that part is the maintainers' call. I've only tried this client's parser, not Prometheus's own scraper.

How this was tested

The /status?openmetrics&full bodies from my earlier comment's builds (this PR on master eefa52a5b3b, #12759 at 12a7c77ebd, master), each cut from its first # HELP line to # EOF, then:

from prometheus_client.openmetrics.parser import text_string_to_metric_families
families = list(text_string_to_metric_families(body))   # raises on the first invalid line

The renamed copies change only lines whose metric is declared # TYPE <name> counter, from <name>… to <name>_total….

@1ma

1ma commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

To be clear, my approach was to make the output as similar as possible to the well-established php-fpm_exporter to make the migration to php-fpm smooth. I didn't pay close attention to #12759, besides noticing that it doesn't even try to make the refactor that this part of the codebase was sorely needing.

Detailed output comparison

php-fpm_exporter (/metrics)

php-fpm_exporter output has lots of fluff metrics that start with go_, process_ and promhttp_. I snipped those from the following sample. Feel free to download both samples and compare them with your diff tool of choice.

# HELP phpfpm_up Could PHP-FPM be reached?
# TYPE phpfpm_up gauge
phpfpm_up{pool="www",scrape_uri="tcp://localhost:9000/status"} 1
# HELP phpfpm_start_since The number of seconds since FPM has started.
# TYPE phpfpm_start_since counter
phpfpm_start_since{pool="www",scrape_uri="tcp://localhost:9000/status"} 83
# HELP phpfpm_accepted_connections The number of requests accepted by the pool.
# TYPE phpfpm_accepted_connections counter
phpfpm_accepted_connections{pool="www",scrape_uri="tcp://localhost:9000/status"} 5
# HELP phpfpm_listen_queue The number of requests in the queue of pending connections.
# TYPE phpfpm_listen_queue gauge
phpfpm_listen_queue{pool="www",scrape_uri="tcp://localhost:9000/status"} 0
# HELP phpfpm_max_listen_queue The maximum number of requests in the queue of pending connections since FPM has started.
# TYPE phpfpm_max_listen_queue counter
phpfpm_max_listen_queue{pool="www",scrape_uri="tcp://localhost:9000/status"} 0
# HELP phpfpm_listen_queue_length The size of the socket queue of pending connections.
# TYPE phpfpm_listen_queue_length gauge
phpfpm_listen_queue_length{pool="www",scrape_uri="tcp://localhost:9000/status"} 4096
# HELP phpfpm_idle_processes The number of idle processes.
# TYPE phpfpm_idle_processes gauge
phpfpm_idle_processes{pool="www",scrape_uri="tcp://localhost:9000/status"} 1
# HELP phpfpm_active_processes The number of active processes.
# TYPE phpfpm_active_processes gauge
phpfpm_active_processes{pool="www",scrape_uri="tcp://localhost:9000/status"} 1
# HELP phpfpm_total_processes The number of idle + active processes.
# TYPE phpfpm_total_processes gauge
phpfpm_total_processes{pool="www",scrape_uri="tcp://localhost:9000/status"} 2
# HELP phpfpm_max_active_processes The maximum number of active processes since FPM has started.
# TYPE phpfpm_max_active_processes counter
phpfpm_max_active_processes{pool="www",scrape_uri="tcp://localhost:9000/status"} 1
# HELP phpfpm_max_children_reached The number of times, the process limit has been reached, when pm tries to start more children (works only for pm 'dynamic' and 'ondemand').
# TYPE phpfpm_max_children_reached counter
phpfpm_max_children_reached{pool="www",scrape_uri="tcp://localhost:9000/status"} 0
# HELP phpfpm_slow_requests The number of requests that exceeded your 'request_slowlog_timeout' value.
# TYPE phpfpm_slow_requests counter
phpfpm_slow_requests{pool="www",scrape_uri="tcp://localhost:9000/status"} 0
# HELP phpfpm_process_state The state of the process (Idle, Running, ...).
# TYPE phpfpm_process_state gauge
phpfpm_process_state{child="0",pool="www",scrape_uri="tcp://localhost:9000/status",state="Idle"} 0
phpfpm_process_state{child="0",pool="www",scrape_uri="tcp://localhost:9000/status",state="Reading headers"} 0
phpfpm_process_state{child="0",pool="www",scrape_uri="tcp://localhost:9000/status",state="Getting request informations"} 0
phpfpm_process_state{child="0",pool="www",scrape_uri="tcp://localhost:9000/status",state="Running"} 1
phpfpm_process_state{child="0",pool="www",scrape_uri="tcp://localhost:9000/status",state="Ending"} 0
phpfpm_process_state{child="0",pool="www",scrape_uri="tcp://localhost:9000/status",state="Finishing"} 0
phpfpm_process_state{child="1",pool="www",scrape_uri="tcp://localhost:9000/status",state="Idle"} 1
phpfpm_process_state{child="1",pool="www",scrape_uri="tcp://localhost:9000/status",state="Reading headers"} 0
phpfpm_process_state{child="1",pool="www",scrape_uri="tcp://localhost:9000/status",state="Getting request informations"} 0
phpfpm_process_state{child="1",pool="www",scrape_uri="tcp://localhost:9000/status",state="Running"} 0
phpfpm_process_state{child="1",pool="www",scrape_uri="tcp://localhost:9000/status",state="Ending"} 0
phpfpm_process_state{child="1",pool="www",scrape_uri="tcp://localhost:9000/status",state="Finishing"} 0
# HELP phpfpm_process_requests The number of requests the process has served.
# TYPE phpfpm_process_requests counter
phpfpm_process_requests{child="0",pool="www",scrape_uri="tcp://localhost:9000/status"} 3
phpfpm_process_requests{child="1",pool="www",scrape_uri="tcp://localhost:9000/status"} 2
# HELP phpfpm_process_request_duration The duration in microseconds of the requests.
# TYPE phpfpm_process_request_duration gauge
phpfpm_process_request_duration{child="0",pool="www",scrape_uri="tcp://localhost:9000/status"} 164
phpfpm_process_request_duration{child="1",pool="www",scrape_uri="tcp://localhost:9000/status"} 353
# HELP phpfpm_process_last_request_cpu The %cpu the last request consumed.
# TYPE phpfpm_process_last_request_cpu gauge
phpfpm_process_last_request_cpu{child="0",pool="www",scrape_uri="tcp://localhost:9000/status"} 0
phpfpm_process_last_request_cpu{child="1",pool="www",scrape_uri="tcp://localhost:9000/status"} 0
# HELP phpfpm_process_last_request_memory The max amount of memory the last request consumed.
# TYPE phpfpm_process_last_request_memory gauge
phpfpm_process_last_request_memory{child="0",pool="www",scrape_uri="tcp://localhost:9000/status"} 0
phpfpm_process_last_request_memory{child="1",pool="www",scrape_uri="tcp://localhost:9000/status"} 2.097152e+06
# HELP phpfpm_scrape_failures The number of failures scraping from PHP-FPM.
# TYPE phpfpm_scrape_failures counter
phpfpm_scrape_failures{pool="www",scrape_uri="tcp://localhost:9000/status"} 4

this PR (/status?openmetrics&full)

Everything above phpfpm_memory_peak already existed before this PR. I introduced the metrics that follow, which all have a child label.

# HELP phpfpm_up Could pool www using a dynamic PM on PHP-FPM be reached?
# TYPE phpfpm_up gauge
phpfpm_up 1
# HELP phpfpm_start_since The number of seconds since FPM has started.
# TYPE phpfpm_start_since counter
phpfpm_start_since 98
# HELP phpfpm_accepted_connections The number of requests accepted by the pool.
# TYPE phpfpm_accepted_connections counter
phpfpm_accepted_connections 7
# HELP phpfpm_listen_queue The number of requests in the queue of pending connections.
# TYPE phpfpm_listen_queue gauge
phpfpm_listen_queue 0
# HELP phpfpm_max_listen_queue The maximum number of requests in the queue of pending connections since FPM has started.
# TYPE phpfpm_max_listen_queue counter
phpfpm_max_listen_queue 0
# HELP phpfpm_listen_queue_length The size of the socket queue of pending connections.
# TYPE phpfpm_listen_queue_length gauge
phpfpm_listen_queue_length 4096
# HELP phpfpm_idle_processes The number of idle processes.
# TYPE phpfpm_idle_processes gauge
phpfpm_idle_processes 1
# HELP phpfpm_active_processes The number of active processes.
# TYPE phpfpm_active_processes gauge
phpfpm_active_processes 1
# HELP phpfpm_total_processes The number of idle + active processes.
# TYPE phpfpm_total_processes gauge
phpfpm_total_processes 2
# HELP phpfpm_max_active_processes The maximum number of active processes since FPM has started.
# TYPE phpfpm_max_active_processes counter
phpfpm_max_active_processes 1
# HELP phpfpm_max_children_reached The number of times, the process limit has been reached, when pm tries to start more children (works only for pm 'dynamic' and 'ondemand').
# TYPE phpfpm_max_children_reached counter
phpfpm_max_children_reached 0
# HELP phpfpm_slow_requests The number of requests that exceeded your 'request_slowlog_timeout' value.
# TYPE phpfpm_slow_requests counter
phpfpm_slow_requests 0
# HELP phpfpm_memory_peak The memory usage peak since FPM has started.
# TYPE phpfpm_memory_peak gauge
phpfpm_memory_peak 2097152
# HELP phpfpm_process_state The state of the process (Idle, Running, ...).
# TYPE phpfpm_process_state gauge
phpfpm_process_state{pool="www",child="0",state="Creating"} 0
phpfpm_process_state{pool="www",child="0",state="Idle"} 0
phpfpm_process_state{pool="www",child="0",state="Reading headers"} 0
phpfpm_process_state{pool="www",child="0",state="Getting request information"} 0
phpfpm_process_state{pool="www",child="0",state="Running"} 1
phpfpm_process_state{pool="www",child="0",state="Ending"} 0
phpfpm_process_state{pool="www",child="0",state="Finishing"} 0
phpfpm_process_state{pool="www",child="1",state="Creating"} 0
phpfpm_process_state{pool="www",child="1",state="Idle"} 1
phpfpm_process_state{pool="www",child="1",state="Reading headers"} 0
phpfpm_process_state{pool="www",child="1",state="Getting request information"} 0
phpfpm_process_state{pool="www",child="1",state="Running"} 0
phpfpm_process_state{pool="www",child="1",state="Ending"} 0
phpfpm_process_state{pool="www",child="1",state="Finishing"} 0
# HELP phpfpm_process_requests The number of requests the process has served.
# TYPE phpfpm_process_requests counter
phpfpm_process_requests{pool="www",child="0"} 4
phpfpm_process_requests{pool="www",child="1"} 3
# HELP phpfpm_process_request_duration The duration in microseconds of the current or last request.
# TYPE phpfpm_process_request_duration gauge
phpfpm_process_request_duration{pool="www",child="0"} 94
phpfpm_process_request_duration{pool="www",child="1"} 148
# HELP phpfpm_process_last_request_cpu The %cpu the last request consumed.
# TYPE phpfpm_process_last_request_cpu gauge
phpfpm_process_last_request_cpu{pool="www",child="0"} 0.00
phpfpm_process_last_request_cpu{pool="www",child="1"} 0.00
# HELP phpfpm_process_last_request_memory The max amount of memory the last request consumed.
# TYPE phpfpm_process_last_request_memory gauge
phpfpm_process_last_request_memory{pool="www",child="0"} 0
phpfpm_process_last_request_memory{pool="www",child="1"} 2097152
# EOF

Main differences

  • The first 12 metrics (from phpfpm_up to phpfpm_slow_requests) have labels pool and scrape_uri in the php-fpm_exporter, and no labels in my PR. This is because these metrics already existed in php-fpm with no labels, and this PR only adds the "&full" metrics that were missing while keeping everything else the same.
  • php-fpm has phpfpm_memory_peak, and php-fpm_exporter doesn't (but already existed in php-fpm before this PR).
  • phpfpm_process_state has an extra state "Creating" in this PR that the exporter does not. This is probably because this state was just introduced last year.
  • Lots of metrics in the php-fpm_exporter have the scrape_uri label and this PR doesn't have it in any metric. This is because the scrape_uri is implicitly php-fpm itself.
  • The exporter has the phpfpm_scrape_failures metric, but this PR does not have it because it doesn't make sense.
  • The exporter is missing # EOF and this PR does not.

@anun333

anun333 commented Oct 30, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

Thanks, that explains the naming. Since most of these metrics end up in Prometheus, I checked what it does with them. I captured /status?openmetrics&full from today's master (cfef166c007) with and without this PR (5a5db1a0f2), headers and body as FPM sends them, and had Prometheus 3.15.1 scrape them:

Served Prometheus 3.15.1 counters stored as prometheus_client 0.26.0 (OpenMetrics)
master, as is (application/openmetrics-text; version=1.0.0) up, no error phpfpm_accepted_connections, … (6) Clashing name: phpfpm_start_since
this PR, as is up, no error the same, plus phpfpm_process_requests (7) the same error
this PR, counters' samples renamed to <name>_total up, no error phpfpm_accepted_connections_total, … (7) parses, 18 families
this PR's body as Prometheus text format (text/plain; version=0.0.4), the way php-fpm_exporter serves it up, no error phpfpm_accepted_connections, … (7) –

So Prometheus takes the output as it is and stores the same series names as php-fpm_exporter, which is what the migration needs. Only parsers that hold to the OpenMetrics spec reject it. Renaming the samples to _total satisfies those, but Prometheus then stores every counter under a new name, so existing php-fpm_exporter dashboards and alerts would need updating. Which matters more is the maintainers' call; the table is what each choice costs.

How this was tested

Linux x86_64, gcc 13.3, debug builds of origin/master and of it with this PR's commits cherry-picked:

./buildconf --force
./configure --enable-debug --disable-cgi --disable-phpdbg --disable-fileinfo --enable-pcntl --enable-zend-test --enable-fpm --with-curl --enable-mbstring --disable-mbregex --with-mysqli --with-pdo-mysql
make -j4

Each sapi/fpm/php-fpm -n -F -y fpm.conf ran a pool with pm = static, pm.max_children = 2 and pm.status_path = /status, took three requests, then answered SCRIPT_NAME=/status QUERY_STRING='openmetrics&full' REQUEST_METHOD=GET cgi-fcgi -bind -connect 127.0.0.1:PORT; the headers and body were saved. A small Python HTTP server replayed each body with the Content-Type in the table; prom/prometheus 3.15.1 (Docker, host network) scraped all four every 2 s, and after 12 s /api/v1/targets gave health and last error, and /api/v1/label/__name__/values?match[]={job="…"} the stored names. The renamed variant changes only the counters' sample lines; families, HELP and TYPE are as FPM wrote them. prometheus_client 0.26.0: text_string_to_metric_families() from prometheus_client.openmetrics.parser on each body.

@1ma
1ma force-pushed the fpm-status-openmetrics-full branch from 5a5db1a to 12725c6 Compare October 31, 2026 21:19
@1ma

1ma commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor Author

I realized it makes sense to add the pool label to the non-worker metrics that already existed, like php-fpm_exporter also does. In particular because php-fpm can be configured with more than just the default www pool.

I did this in an extra commit, and took the opportunity to rebase the branch on master.

@1ma
1ma force-pushed the fpm-status-openmetrics-full branch from 12725c6 to a8ee816 Compare October 31, 2026 23:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FPM status with OpenMetrics format and FULL parameter

2 participants