Skip to content

Running the measurement and publishing it

This page is the walkthrough: how to run the overhead measurement, what every field on the form means, and what the report publishes afterwards.

Two neighbouring pages answer different questions. Measuring the overhead explains the protocol, so you can reproduce or contest a number. Overhead cost publishes the numbers measured on the development machine, with their parameters.

Nothing here is estimated. The report either publishes a value a human typed, a value this module measured on your server, or nothing at all, and it always says which of the three it is.

Every number on this page is an example, not a result you should expect

The tables, the terminal output and the screenshots below come from runs on one development laptop, with the database in Docker, all sub-modules installed, and the machine doing other things at the same time. They are here to show the shape of the output: which columns exist, how the rows are ordered, what the verdict changes. The figures themselves will be different on your server, and they differ between two runs on the same machine: the fixed cost measured here ranged from 4.59 to 5.72 ms across eight repeats of the identical command.

Read them as an illustration. The only figures that describe your installation are the ones your own run produces.

The three publication states

The report has exactly three states, and they are the whole model.

state what the report publishes where the value comes from
Hidden no overhead section at all nothing is published
Declared by the document author the percentage entered on the settings form a human
Measured on this server the last measurement stored by the command drush no:overhead:measure --record=...

The label above the value is not optional. There is no setting that prints a number without saying where it came from, and the document author cannot remove it. What the author can do is hide the section entirely, in the site defaults or for one document.

Step 1: run the measurement

From the command line

Prefer this one. A command-line run has the site to itself. A run started from the administrative interface happens while the site is serving real traffic, and that traffic adds variability the measurement cannot subtract.

drush no:overhead:measure

With no options it runs the full workload catalog, 7 measured pairs per workload, 15 requests per block for the HTTP workloads, plus the OFF/OFF control pass that establishes the noise floor. It prints a table and stores nothing.

To measure and publish in one step, name the workload whose row you want published:

drush no:overhead:measure --only=calibration-minimal,calibration-calibrated \
  --pairs=12 --requests=5 --record=calibration-minimal

--record is the only option that writes anything outside the run. It stores the named row and switches the report to measured. Without it, nothing is published: there is no sensible default, because the cost depends on which page you measured.

What it prints

The run above, on 17 September 2026, on the development machine described in Overhead cost:

Native Observability overhead matrix
====================================

Site path: sites/default | Drupal: 11.4.5
Source: cli | Site serving real traffic during this run: no
Pairs: 9 (plus one discarded warm-up pair) | Requests/block: 6 | Unit-requests/block: 500 |
Control pass: yes | Keep rows: no | Ledger: yes | Only: calibration-minimal,calibration-calibrated
page_cache installed: yes | dynamic_page_cache installed: yes
HTTP workload catalog: calibration-minimal -> /native-observability/calibration/minimal,
calibration-calibrated -> /native-observability/calibration/calibrated
native_observability_trace: 124 row(s) deleted (by request_id, plus id > 2779 for rows this run wrote without one).
native_observability_spans: 248 row(s) deleted (by request_id, plus id > 11898 for rows this run wrote without one).
native_observability_cache_event: 372 row(s) deleted (by request_id, plus id > 8708 for rows this run wrote without one).
native_observability_database_query: 0 row(s) deleted (by request_id, plus id > 0 for rows this run wrote without one).
native_observability_route_metric: 0 row(s) deleted (id > 0).

Headline figure
---------------

Fixed cost per request (a): 5.717 ms/req
Cost per unit of declared load (b): not resolved, the difference between the two routes' own
deltas is 'below-noise' ('unclear' in the table): delta 5.717 ms on calibration-minimal and
5.943 ms on calibration-calibrated, difference 0.225 ms over 20 unit(s), p = 1.00000, noise
floor 0.467 ms. This is a result, not a failure: it means the cost does not grow with the
declared load, as far as this run can resolve.

+------------------------+-----------+--------+-------+----------+---------+
| Workload               | OFF       | ON     | Delta | Delta %  | Verdict |
+------------------------+-----------+--------+-------+----------+---------+
| calibration-minimal    | 3.648 ms  | 9.331  | 5.717 | +156.72% | meas    |
| calibration-calibrated | 23.965 ms | 29.908 | 5.943 | +24.80%  | meas    |
+------------------------+-----------+--------+-------+----------+---------+

Per-workload detail
-------------------

  calibration-minimal:
    - throughput: 274.1 -> 107.2 /s (-60.9%)
    - latency (handle() only): OFF 3.075 | ON 8.277 | delta 5.251
    - deferred (after the response is sent, occupation minus latency): 1.055 (11.3% of ON occupation)
    - budget: 0.71% of the declared TTFB budget (good)
    - reconciliation: total 5.717ms = census 5.057ms + induced 0.000ms + residual 0.660ms
    - N 6 | IQR [5.540, 5.802] | noise 0.171 | signs 9/9 | p=0.00391 | rows off/on 0/324 | rows/req 6.000

Three things in that output are its own evidence rather than its conclusion.

signs 9/9 | p=0.00391 says every one of the nine paired differences had the same sign, and that an exact two-sided sign test puts the odds of that happening by chance at about one in 256. noise 0.171 is the OFF/OFF control pass: with capture off on both sides, this machine still moved by 0.171 ms, and a delta of 5.717 ms is thirty-three times that. rows off/on 0/324 proves the two states really differed: the OFF blocks wrote nothing, the ON blocks wrote 324 rows.

The second row answers "does it get worse on a heavier page". The calibrated route runs 20 declared units of extra work and costs 5.943 ms against the minimal route's 5.717 ms. The 0.225 ms difference over 20 units does not clear this run's noise floor, so b stays unresolved: as far as this run can see, the cost does not grow with the page's declared load. That is a result, not a failed measurement, and the command says so instead of rounding it to a small positive number.

Where the time goes inside a request

Pass --ledger and the run adds a timeline: for every instrumented point, when it fired, which sub-module owns it, and how long this module spent there.

    - request timeline (ledger, median per unit):
        Name                  | ClassName                          | Module              | Request  | Native Observability
        request start         |                                    | -                   | 0.000 ms | -
        db.log_start          | DatabaseQueryObserverSubscriber    | database_observer** | 0.108 ms | 0.002 ms
        trace.request         | TraceSubscriber                    | base                | 0.171 ms | 0.000 ms
        execution.request     | ExecutionRequestSubscriber         | execution**         | 2.824 ms | 0.000 ms
        db.classify           | DatabaseQueryObserverSubscriber    | database_observer** | 2.938 ms | 0.016 ms
        execution.response    | ExecutionRequestSubscriber         | execution**         | 3.019 ms | 1.539 ms
        cache.response        | CacheResponseObserverSubscriber    | cache_observer**    | 4.618 ms | 0.339 ms
        span.request_summary  | RequestSummarySpanSubscriber       | spans**             | 4.946 ms | 0.040 ms
        span.database_summary | DatabaseSummarySpanSubscriber      | spans**             | 4.988 ms | 0.013 ms
        trace.response        | TraceSubscriber                    | base                | 5.277 ms | 0.724 ms
        cache.invalidate      | ObservedCacheTagsInvalidator       | cache_observer**    | 5.681 ms | 0.313 ms
        otel.response         | OpenTelemetryTraceSubscriber       | otel*               | 6.286 ms | 0.022 ms
        span.trace_link       | SpanTraceLinkSubscriber            | spans**             | 6.315 ms | 0.856 ms
        cache.page            | CachePageObserverMiddleware        | cache_observer**    | 7.500 ms | 0.738 ms
        response sent         |                                    | -                   | 8.277 ms | -
        terminate.flush       | DeferredPersistenceFlushSubscriber | base                | 8.295 ms | 0.453 ms
        Total                 |                                    |                     | 9.331 ms | 5.057 ms
        optional sub-modules account for 3.880 ms of 5.057 ms: cache_observer, database_observer, execution, spans, otel
        not reached during this run
          trace.exception | TraceSubscriber       | base        | - | -  (reached only when the request throws)
          http.span       | GuzzleSpanMiddleware  | spans**     | - | -  (reached only when the request makes an outbound HTTP call)
          http.trace      | GuzzleTraceMiddleware | execution** | - | -  (reached only when the request makes an outbound HTTP call)
column what it holds
Name The cost-ledger key, declared in the integration-point manifest.
ClassName The class that owns that key.
Module The sub-module that owns it, or base for the module everything depends on.
Request When it fired, measured from the start of the request.
Native Observability How long this module spent there, on that one point.

Rows are sorted by the moment they happened, so the table reads top to bottom. request start and response sent are not instrumented points: they are the two anchors that tell you which work the visitor waits for and which happens afterwards.

The moments above are this machine's, on one run. On your server the points fire in the same order for the same reasons, and at different times.

What the marks mean

* is a sub-module you can uninstall on its own. ** is one that something else installed depends on, so uninstalling it uninstalls that too. The legend spells out which, computed for your site:

Only otel can be uninstalled on its own; removing cache_observer or spans also removes dashboard,
export and report; removing database_observer or execution also removes dashboard and report.

On a different installation that sentence says something different, because it reads that site's own dependency graph. It is the answer to "what do I actually lose", which the cost alone never gives you.

The table also states its own scope, because it lists what costs, not what is removable:

This table lists the sub-modules that cost something on a request; also installed and removable
without appearing here: dashboard, export, metrics, report, status_block.

Those five instrument nothing on a request, so their cost there really is zero. They are named so that a reader deciding what to uninstall is not misled by their absence.

Rows with no figures

Two things produce an empty cell, and only one of them can be filled.

When a run had the ledger switched off there is no timeline at all, and the administrative page says so where the table would have been, rather than showing nothing.

The administrative page with the ledger off, explaining why the breakdown is absent

The not reached during this run rows were never crossed, and each says what would cross it. http.span and http.trace need a request that makes an outbound HTTP call: run the outbound-call workload, or point --path (the "Extra paths" field, from the administrative page) at a page of yours that calls an external service. trace.exception needs a request that throws.

The n/a in the latency and deferred columns of the cli workloads is different: no option fills it, because a CLI unit never calls terminate() separately from itself. There is nothing to run.

Three things this table shows and no aggregate figure can

Where the cost concentrates. execution.response is 1.539 ms of the 5.057 ms total, about a third of it, on one point.

What falls after the response. response sent is at 8.277 ms and terminate.flush at 8.295 ms, so its 0.453 ms of database writes are paid after the visitor already has the page.

A point that fires later than its position suggests. cache.page belongs to an HTTP middleware, which wraps everything, yet it appears at 7.500 ms. That is correct: CachePageObserverMiddleware starts its measurement after the delegate kernel call, so it times only its own post-processing, on the way back out. Sorting by the measured moment puts it where it really happened.

The two value columns do not add up to each other, and that is not an error: between execution.response at 3.019 ms and cache.response at 4.618 ms the request spends about 1.6 ms inside Drupal core, where this module does nothing. The Native Observability column also double-counts nested points, because the ledger accumulates inclusive time. The figure that does reconcile is on the reconciliation: line of the same output.

When the run could not resolve its own delta

A row whose verdict is not measurable does not print the quantities derived from its delta: the query equivalent and the throughput comparison are replaced by one line saying why, and what would change it.

- throughput: suppressed, because this row's delta could not be distinguished from its own noise,
  and this run had no control pass to measure a noise floor against. More pairs, or a control pass,
  would change that.

The absolute OFF, ON and delta stay, with the verdict beside them. What goes is every restatement of an unresolved number in another unit, because each restatement reads as one more independent finding. A negative query equivalent is never printed at all, on any row: a count of queries does not admit a negative.

The same table appears on the overhead measurement page, and on the module's settings page under Where this module touches the request, where it is built from the manifest even before anything has been measured.

The timeline on the settings page, with its module column, its legend and the rows this run did not reach

Step 3: choose what the report publishes

Go to Configuration > Development > Native Observability > Report, section Observability overhead disclosure.

The disclosure fieldset in the hidden state, with the three publication states

Hidden

The default, and the state to leave it in when you have not measured anything. The report publishes no overhead section.

Value declared by the document author

Choosing it reveals two fields.

The disclosure fieldset in the manual state, with the percentage and note fields

field what it is for
Declared overhead percentage The number the report prints. Nothing validates it against reality; it is your claim.
Declared overhead note Free text printed under the value. Use it to say where your figure comes from.

Use this state when the measurement was taken somewhere else, on staging or on a machine that is not the one serving the report. The label tells the reader where the figure came from.

Value measured on this server

Available once a measurement has been stored with --record. If the state is selected and no measurement is stored, the settings form warns you, and the report degrades to publishing nothing rather than printing a stale or malformed value.

Include the overhead section by default

This is the initial value of the per-document choice, not a fourth state. It decides whether a new report includes the section; the author can still override it for a single document.

Step 4: what lands in the report

On the report builder, at Reports > Native Observability > Observability Report, the checkbox Include observability overhead hides or shows the section in that one document. It never changes the publication state and never touches the stored measurement.

The report builder, with the per-document Include observability overhead checkbox

The declared state

The report preview publishing a value declared by the document author

The label, the value, and the note. Nothing else, because there is nothing else to say about a number a human typed.

The measured state

The report preview publishing a value measured on this server

Two figures, then six facts.

5.287 ms per request
0.7% of an 800 ms page budget

  Measured        17 Sep 2026
  Route           calibration-minimal (test route, fixed load)
  Source          command line
  Vs that route   +192.2% of its 2.751 ms (that route does nothing)
  Sample          9 pairs x 6 requests
  Reliability     55x noise floor, 9/9 pairs agree, p = 0.0039
  Verdict         measurable

The first figure is the absolute difference in the unit it was measured in, never a percentage. The unit is not converted: a workload measured in microseconds per call is published in microseconds per call.

The second is the only percentage printed in bold, because it is the only one that stands on its own: the same difference as a share of the page budget in native_observability.settings:overhead.budget_ms, 800 ms by default.

line what it is for
Measured The date. The report never decides whether a measurement has gone stale; it reports when it was taken.
Route What was measured. A calibration route says so, a real page says so, and a workload measured outside a request says so.
Source Where the run came from. A measurement taken from the administrative page ran while the site was serving real traffic, which is noisier than a command-line run with the site to itself, and the report says which it was rather than leaving it to be guessed. A payload recorded before this field existed prints no Source line at all, instead of a guessed one.
Vs that route The percentage, next to the baseline it was computed on. Omitted entirely when there is no baseline to name.
Sample How many pairs, of how many requests each.
Reliability How many times larger the difference is than the noise floor, how many pairs agreed, the p-value. Each fragment is dropped when the measurement does not carry it.
Verdict measurable, or below-noise (not distinguishable from this run's own noise).

Why the percentage is not the headline. On calibration-minimal the baseline is 2.751 ms, because that route is built to do nothing, so a 5.287 ms difference against it reads as +192.2%. The arithmetic is right and the meaning is wrong: the percentage measures how empty the route is. It stays in the block, in plain weight, next to its own denominator, and the parenthetical says why it is large. That parenthetical only appears for the calibration routes, where it is true.

Measuring what one sub-module costs

The same command answers "what would I save by removing this part". Measure once with everything installed, uninstall the sub-modules you are considering, measure again with identical parameters, and compare the two fixed costs.

drush no:overhead:measure --only=calibration-minimal,calibration-calibrated --pairs=9 --requests=6
drush pm:uninstall native_observability_execution native_observability_spans
drush no:overhead:measure --only=calibration-minimal,calibration-calibrated --pairs=9 --requests=6

Use calibration-minimal for this, not a real page: its load is fixed and declared, so the only thing that changed between the two runs is the set of installed sub-modules.

Two cautions. Uninstalling a sub-module drops its tables, so do this on an environment whose data you can lose. And check what else comes with it: Drupal uninstalls the modules that depend on the ones you named, and their cost, or their lack of it, is in your second number too.

Overhead cost publishes the result of exactly this comparison on the development machine, for Execution and Spans.

Removing what was published

drush no:overhead:clear

Deletes the stored measurement and sets the report back to hidden. The declared percentage and its note are configuration and are not touched.

What a measurement leaves behind

The ON blocks write real observability rows, because that is what is being measured. When the run ends, the command deletes the rows it wrote, matching them by the request identifier of its own requests, and reports the count per table.

Rows that were already there are not touched. There is no option that widens the deletion beyond the run's own identity, so the command cannot remove someone else's data even if you ask it to.

Pass --keep-rows when you want to inspect what a run captured. You then own the cleanup, and this is where the one real risk lives: emptying the tables by hand, with TRUNCATE or a bare DELETE, removes every row in them, not only the ones your run wrote. On a shared or staging database that is somebody else's captured traffic. Delete by the run's own request_id values, or re-run the measurement without --keep-rows and let the command do it.