Overhead cost¶
This module records what your site does without an external APM agent. That is not free, and this page states the price instead of reassuring you about it.
For the measurement protocol itself, meaning pairing, alternation, the sign test and the noise floor, see Measuring the overhead. This page is for deciding whether to install the module; that one is for anyone who wants to reproduce or contest a number.
What you get for those milliseconds¶
The cost only means something next to what it buys. On every request, without installing anything else on the server:
- the request trace: route, duration, status code, exception, correlation between a page and its AJAX calls;
- the span tree: where the time inside a request went, not just how much was served;
- slow queries with their text, past a threshold you set;
- cache observation: page-cache outcome, response cacheability, tag invalidations;
- outbound HTTP calls, with duration and outcome;
- export to Prometheus, OpenTelemetry and Elastic, if you already run one of those platforms;
- a dashboard that reads all of this without depending on an external service.
The right comparison isn't zero¶
None of the alternatives are free. An APM agent has its own cost on every request, and you pay that too. Not observing at all costs when a regression reaches production and nobody can say when it got there.
Many observability platforms can be self-hosted, and the data does not have to leave your infrastructure: Elastic APM, Jaeger, Tempo, SigNoz and the OpenTelemetry Collector all run on infrastructure you control. This module exports to those same stacks, so there is no contrast to sell on that point.
The difference is elsewhere.
Nothing to install on the server. The most common PHP APM solutions ship as a compiled extension
to load, or as a collector process to run alongside the application. This module requires
ext-zlib, which is already present everywhere because Drupal requires it regardless: no extension,
no daemon, no sidecar. It works where you cannot install anything: shared hosting, closed
platforms, environments where the server is not yours.
Nothing leaves unless you configure it. Data stays in your database. Export to Prometheus, OpenTelemetry or Elastic is something you turn on; it is not the default behavior.
It knows Drupal. A generic profiler sees PHP functions and SQL queries. This one sees route
names, cache tags, page-cache outcome, and knows that writes happen after
fastcgi_finish_request(). That is the difference between knowing a request took 300 ms and knowing
which route, with which cache tag invalidated, and from which controller.
The cost exists for the same reason those advantages do: the work an agent written in C does outside the process, PHP does here, inside the request.
The only one that tells you what it costs¶
No APM agent publishes its own overhead on your hardware. This module measures it, with a protocol you can contest, and leaves the decision to you:
drush no:overhead:measure
If the number does not convince you, capture.enabled zeroes it out without uninstalling anything.
It is the same gate the command itself uses to measure.
No number on this page holds on your server
Every figure below was measured once, on one development machine: a laptop, the database in Docker, all twelve sub-modules enabled, tables freshly emptied. That is the most expensive configuration in the slowest environment reasonably available, not a typical production deployment. On your server the numbers will differ, and the only way to know by how much is to run the measurement yourself:
drush no:overhead:measure
The fixed cost per request¶
The module ships two calibration routes for exactly this question: a minimal route that runs no query and no rendering and returns a fixed response, and a calibrated route that runs 20 declared iterations of a query plus a cache operation. Both routes are closed outside a measurement run, answering 404 and not 403 to anyone probing the site, and their load never changes with the site's content, theme or third-party modules.
Why calibration routes, and not a real page¶
A real page carries its own theme, its own blocks, its own views and whatever third-party modules the site has installed. Two installations cannot be compared on that denominator, and neither can two versions of this module while checking whether a change made things worse: the page itself changed underneath the measurement. The calibration routes run a declared, constant amount of work instead, so the same measurement asks the same question on any installation and at any point in this module's history.
That is the same question, not the same answer. The calibrated route's cache operations go to a cache bin of this module's own, but the backend serving that bin is the site's: twenty cache writes cost one thing on a database backend and another on Redis. What is fixed is the load, not its price. Two runs on one installation are directly comparable; two installations are comparable once you know what differs between them.
The front-page numbers further down are still published, but as context: they say what this particular site costs, not what the module costs.
What was measured¶
Run with 9 measured pairs (plus one discarded warm-up pair, per the protocol) of 6 requests each, on the same development machine described above:
| quantity | value | verdict |
|---|---|---|
| a, the fixed cost per request | 4.684 ms/req | measurable |
| b, the cost per declared unit of load | indistinguishable from zero | below noise floor |
a is the delta measured on the minimal route alone: what a request costs before any per-page complexity, meaning queries, cache lookups and rendering, is added. Against a page-response budget of 800 ms that is 0.59%, a denominator you should replace with your own budget, the same way the percentages further down should be read against the page they were measured on, not copied as-is.
That 800 ms is the "good" Time To First Byte threshold documented by web.dev, and it comes with two caveats the source states itself: TTFB is not a Core Web Vital, so a site need not meet it provided the metrics that are, LCP, INP and CLS, stay within theirs; and the thresholds are described there as a rough guide, to be weighed against how a site delivers its own content. It is a configurable default here, not a standard this module asks you to meet.
b is not a small positive number rounded down: the difference between the calibrated route's
delta and the minimal route's delta, divided by 20 declared units, does not clear this run's own
noise floor. That is the result, not a failed measurement: it says the module's cost does not
grow with the declared load the calibrated route runs. It is consistent with the rest of this run:
probe-cache-storm, which performs 150 cache operations, cost about the same as probe-internal,
which performs almost none.
A single number that mixed a and b together would hide exactly this: whether the cost grows with a page's complexity is the question the calibration routes exist to answer, and collapsing the two into one figure erases the answer.
A b of zero does not make the module free. It makes it predictable: every request pays a,
and pages that do more work do not pay proportionally more on top of it. What the module costs is
a, and on this machine it fell between 4.59 and 5.72 ms. Eight repeats, run with between 9 and 15
pairs over two days, gave 4.59, 4.66, 4.68, 4.81, 4.85, 5.29, 5.40 and 5.72 ms. Every one of those verdicts was
measurable, so the spread is not doubt about whether the cost exists: it is the resolution of a
laptop that was also doing other things. Quoting a single decimal from it would be a precision the
machine never had, which is why the figure travels with its parameters and its date.
What this looks like on a real page¶
On the same development machine, the site's own front page went from 25.0 ms to 30.9 ms: +5.9 ms inside the response. This is context, not the headline figure above: it mixes the module's fixed cost with however many queries and cache operations that specific page happens to run.
The same absolute quantity reads as very different percentages depending on the page:
| the page cost | the added cost | as a percentage |
|---|---|---|
| 9 ms | +5.3 ms | +56% |
| 25 ms | +5.9 ms | +23% |
| 128 ms | +10.0 ms | +8% |
The percentage follows the page; the milliseconds do not. On an already-fast site the percentage looks high precisely because the site is fast. It is not a sign of anything wrong, and should not be read as one.
The same arithmetic explains a number the report can publish and that looks alarming at first
sight. On calibration-minimal the baseline is about 2 ms, because that route is built to run no
query and no rendering, so a fixed cost of roughly 4.6 ms against it reads as +230%. The
percentage is correct and says nothing useful: it measures how empty the route is. That is why the
report publishes the absolute figure as its headline and prints the percentage underneath, next to
the baseline it was computed on.
What is excluded from these numbers¶
The module's own pages never show up in the data you are looking at. Opening the dashboard, scraping the Prometheus endpoint, or browsing the settings forms is excluded from capture by default (see Configuration), so none of that traffic inflates the request counters, shifts the latency percentiles, or fills the trace list with self-referential rows.
The two calibration routes above are the one exception, and only while a measurement is running: they exist to be measured, so a measurement records them like any other request. Outside a measurement window they are excluded like every other page the module ships.
That is also why the fixed cost reported above is the cost the module adds to your pages: it does not include whatever the module spends serving its own, because it writes nothing about its own pages to begin with.
It isn't the record writes that cost¶
That is the first guess, and the measurement says otherwise. The deferred flush, the moment rows are actually written to the database, costs 0.379 ms out of 6.253, 6.1%.
The remaining 93.9% is instrumentation that runs during the request and writes nothing:
| ledger key | cost | ledger key | cost |
|---|---|---|---|
execution.response |
1.80 ms | span.trace_link |
1.30 ms |
cache.page |
0.80 ms | trace.response |
0.80 ms |
cache.response |
0.72 ms | terminate.flush |
0.38 ms |
execution.response and span.trace_link alone account for half of the total. That is the basis for
the next point.
Turn off what you don't need, not everything¶
Two sub-modules, Execution and Spans, account for most of the cost. That was measured directly, not inferred: the same paired protocol, on the same machine, once with all eleven sub-modules installed and once with those two uninstalled.
| all eleven sub-modules | without Execution and Spans | |
|---|---|---|
| fixed cost per request | 4.726 ms | 1.795 ms |
| of it, inside the response | 4.384 ms | 1.789 ms |
| of it, after the response was sent | 0.428 ms | 0.048 ms |
| rows written per request | 6.0 | 4.0 |
| this machine's noise floor | 0.120 ms | 0.044 ms |
| sign test | p = 0.0039 | p = 0.0039 |
Those two sub-modules are 2.931 ms, 62% of the total. A site that wants request traces, slow
queries and cache observation, but not the span tree and the execution timeline, pays roughly a
third of the price. The per-function ledger above reached the same figure by a different route,
1.80 ms for execution.response plus 1.30 ms for span.trace_link, and two methods agreeing is
worth more than either on its own.
A module that has to be told "keep it off in production" to be worth running has disqualified itself. This one tells you instead which part of itself you can remove.
Uninstalling Execution and Spans also removes the Dashboard, the Report and the Export sub-modules, which depend on them. Those three register no event subscriber and no tagged service, so none of the 2.931 ms belongs to them: they are administrative pages and export endpoints, not work done on every request.
If you need to stop everything without removing anything, capture.enabled halts all capture
while leaving the module installed. That is not the same as uninstalling. With capture off the
subscribers are still registered and still traversed on every request: the capture-off baseline
measured 2.056 ms with all eleven sub-modules and 1.705 ms without those two. Those two figures
come from two separate runs rather than from one paired comparison, so read the 0.35 ms between
them as an indication, not as a measured result.
What never reaches the database¶
Not everything observed becomes a row. Query counts are aggregated in memory for the whole request, and only queries past the slow-query threshold, 100 ms by default and capped at 20 per request, are written. A page that runs 200 fast queries does not produce 200 rows.
Where these numbers come from¶
All measured in one session, on the development machine described above, with the paired protocol of the bundled command:
| number | source |
|---|---|
| a = 4.684 ms/req, measurable | calibration-minimal workload delta, 15 measured pairs, 6 requests per block |
| the 4.59 to 5.72 ms spread | eight repeats of that same measurement over two days, 9 to 15 pairs each, every one with a measurable verdict |
| b, indistinguishable from zero | difference between the calibration-calibrated and calibration-minimal deltas across 20 declared units, same run |
| 0.59% of an 800 ms budget | 4.684 ms / 800 ms |
| 25.0 to 30.9 ms, +5.9 ms | request-log census on the front page, warm-up discarded, 20 requests |
| +56% / +23% / +8% | a 7-pair run on probe-many-queries, front-page, probe-spans-mix |
| 0.379 ms out of 6.253, 6.1% | terminate.flush against the total censused across the ledger's 14 keys |
| 3.1 ms out of 6.253 | execution.response 1.80 ms + span.trace_link 1.30 ms |
| 4.726 vs 1.795 ms/req, 62% | two calibration-minimal runs on 16 September 2026, 9 pairs of 6 requests each, control pass on: all eleven sub-modules installed, then with Execution and Spans uninstalled |
| capture-off baseline 2.056 vs 1.705 ms | the OFF blocks of those same two runs, unpaired |
| threshold 100 ms, cap 20 | native_observability_database_observer.settings: slow_query_threshold_ms, max_stored_queries_per_request |