Why isn't this Drupal page cached?

Drupal writes the answer into the response headers, and the response is gone as soon as it arrives. Native Observability keeps, for every request, what the page cache and the dynamic page cache did, the tags, the contexts and the max-age, and the cache tag invalidations the request caused. It also records requests served by the page cache, which Drupal itself never handles.

How do you find out why a Drupal page was not cached?

You read the reason Drupal itself writes on the response, provided you kept it. The page cache and the dynamic page cache put their verdict in the X-Drupal-Cache and X-Drupal-Dynamic-Cache headers: HIT, MISS, or UNCACHEABLE followed by the rule that decided it in brackets. Those headers reach the browser and stop there: the server keeps no copy of them.

Native Observability is a Drupal module that records from the inside what happens during each request. Its Cache Observer submodule, native_observability_cache_observer, stores in the database the outcome of each cache layer, the cacheability metadata of the response and the cache tag invalidations, tied to the route and to the trace of the same request. Slow queries for the same request are covered in the article on the queries that slow down a route, calls to external services in the one on the external service that slows down a route.

The rows shown here come from a measurement environment I built, with Drupal 11.4.5 on DDEV. How to rebuild it is at the bottom, in the section on repeating the measurement.

What Cache Observer records for each request

Three kinds of event, each stored in the native_observability_cache_event table with the id of the request that produced it.

Event type Layer What it holds Where it comes from
response_cache page_cache HIT, MISS or UNCACHEABLE (reason) the X-Drupal-Cache header, read by a middleware
response_cache dynamic_page_cache the same values the X-Drupal-Dynamic-Cache header, read on kernel.response
cacheability response_cacheability count and list of tags and contexts, max-age the response metadata, getCacheableMetadata()
tag_invalidation cache_tags the tags invalidated by one call an invalidator added next to core's own

The cacheability metadata is read from the response, from the same data core uses to build its debug headers. That means http.response.debug_cacheability_headers can stay false, as the comment in default.services.yml asks: «Enabling cacheability debugging is not recommended in production environments». Every test on this page ran with that parameter off.

A page that is not cached: the three rows that explain why

A GET /node/XXX/edit by a logged-in user leaves three rows with the same request id, XXXXXXXXXXXXXXXXXXXXXXMJ7M, and the same route, entity.node.edit_form:

Layer Recorded outcome
page_cache UNCACHEABLE (REQUEST POLICY)
dynamic_page_cache UNCACHEABLE (POOR CACHEABILITY)
response_cacheability 22 tags, 11 contexts, max-age 0

Each label maps to a specific rule in core.

  • REQUEST POLICY on the page cache: the request carries a session. The page cache only serves

anonymous visitors, and it decides this before it even looks the page up.

  • POOR CACHEABILITY on the dynamic page cache: the response matches one of the

auto_placeholder_conditions in renderer.config. shouldCacheResponse() rejects it when max-age is 0, when a high-cardinality context such as user or session is present, or when a tag is invalidated too often.

  • The cacheability row tells you which of the three fired: max-age 0. The payload column also

holds the names, including the user and session.exists contexts.

Two more outcomes from the same run complete the picture. user.reset.login, the one-time login link, gives UNCACHEABLE (RESPONSE POLICY): the page was built, then a response rule kept it out of the cache. A response that carries no cacheability metadata at all gives UNCACHEABLE (NO CACHEABILITY) on both layers.

The page cache HIT that Drupal never sees

A page served from the page cache gets recorded even though Drupal never handled it. The page cache is the http_middleware.page_cache middleware, at priority 200, and on a HIT it returns the stored response before the HTTP kernel starts: no routing, no kernel.response, no kernel.terminate. As far as the rest of Drupal knows, that request never happened.

Cache Observer sits one step above it, with a middleware at priority 210, CachePageObserverMiddleware, reads X-Drupal-Cache on the response on its way out and writes the row. Since the kernel never assigned an id, the middleware mints one and puts it on the response as X-Native-Observability-Request-Id.

Two anonymous requests to the same page, one after the other:

a1.txt:x-drupal-cache: MISS
a1.txt:x-native-observability-request-id: XXXXXXXXXXXXXXXXXXXXXXP9EJ
a2.txt:x-drupal-cache: HIT
a2.txt:x-native-observability-request-id: XXXXXXXXXXXXXXXXXXXXXXP40A

And in the database, with the number of trace rows each id finds:

id    request_id                  status  path                                 route_name             trace_rows
XXXX  XXXXXXXXXXXXXXXXXXXXXXP9EJ  MISS    /review/what-changed-in-drupal-11-4  entity.node.canonical  1
XXXX  XXXXXXXXXXXXXXXXXXXXXXP40A  HIT     /review/what-changed-in-drupal-11-4                         0

The HIT has zero trace rows and an empty route, and that is the correct answer: Drupal neither routed nor traced that request. For a HIT, what remains is the path. So HITs for a page are counted by path, because route_name only exists on requests that reached the kernel.

Another request's id, inside a cached response

The page cache stores the whole response, headers included. A site that adds a correlation id to every response, to find the request again in its logs, gets that id stored together with the page: the second visitor receives the first visitor's id. Searching the logs for it leads to somebody else's request, possibly from hours earlier.

Cache Observer removes it. The middleware spots a response served from the cache by the missing attribute that the kernel sets on every request it handles, drops the old header and writes a new one. The two requests above show it: different ids, one per request.

A core header has the same problem. On the HIT the response still says x-drupal-dynamic-cache: MISS, which is the dynamic page cache's outcome for the request that filled the page cache. On a HIT the dynamic page cache did no work at all. Anyone reading headers by hand to understand caching on an anonymous page should know this: on a page cache HIT, X-Drupal-Dynamic-Cache describes a different request.

Which invalidation emptied the page

Next to core's invalidators, Cache Observer adds one of its own, ObservedCacheTagsInvalidator, which writes one row per call to invalidateTags() with the tags and the request that invalidated them. Saving a node from its form, that single POST produces these rows:

tag_count  tags
1          node:XXX:revisions
4          node_list, node_list:article, 4xx-response, node:XXX
1          native_observability_trace:list

The second row is the one that explains why the article lists and the node page were rebuilt on the next visit. The third comes from the module itself, and it is covered under the limits.

Invalidations have a per-request cap, max_stored_invalidations_per_request, 50 by default. The cap counts calls, not tags, and keeps the first ones: the invalidation always happens, only the recording stops. The same save with the cap at 1 and at 50:

Cap Rows stored Tags recorded What is left
1 1 1 node:XXX:revisions
50 3 6 all three calls

With the cap at 1, the tags that went missing were exactly node:XXX and node_list, the useful ones.

The real total is not recorded anywhere. For slow queries the module also keeps how many it saw, and comparing rows stored with rows seen proves the truncation. For invalidations that comparison is missing. The only hint is a request with exactly as many rows as the cap allows, and proof comes only from repeating the measurement with a higher cap. It is a limit of Cache Observer, and a counter of the real total is the missing piece.

Limits of Cache Observer

Each one was observed in the same run.

  • A POST leaves only the cacheability row. Core sets X-Drupal-Cache only on requests with a

cacheable method, as RFC 7231 §4.2.3 prescribes, and without the header the middleware writes no page cache row.

  • CACHEABLE means declared cacheability. The label on the cacheability row looks only at tags,

contexts and max-age. A POST to the login form comes out CACHEABLE, although a POST always stays out of the cache. The real outcome is in the response_cache rows.

  • A HIT has the path and an empty route, for the reason given above.
  • Invalidations from drush, and from cron run on the command line, stay out. The invalidator

writes only when the current request has a correlation id, and under drush that request is a synthetic one without it. A drush cr empties everything and leaves the table as it was.

  • The module records its own invalidations. Every request that writes a trace invalidates

native_observability_trace:list, and that call lands in the table like any other: in the run it was 8 invalidation rows out of 12. With a low cap they take slots that belong to the site's tags.

  • The cap on invalidations cuts silently, as described in the previous section.

How it compares with other tools

Each one answers a different question. The table reports what each project states on its own page: the other tools were not tested in the measurement environment.

Tool Which question it answers For production
Core's debug_cacheability_headers which tags, contexts and max-age the response I am looking at has discouraged by core
Log Cache Tags which tags were invalidated, written to dblog no warning, with a switch for the volume
Trace Cache Tags which tags were invalidated, one notice per invalidation «Not recommended for production sites»
Cache review how the page cache and dynamic page cache work, with demo pages a teaching tool, and it says so
WebProfiler what the cache did on the page in front of me no statement
Native Observability what each cache layer did for a past request, and who invalidated what built to stay on

Debug headers and WebProfiler help when the page is in front of you. The invalidation logging modules help when the only question is who emptied the cache. None of the first five records page cache HITs, which are exactly the requests Drupal knows nothing about.

Getting started

  1. Enable the submodule: drush en native_observability_cache_observer -y. It requires

native_observability and runs on Drupal 10 and 11.

  1. Grant access native observability cache observer to whoever needs to read the data, and

administer native observability cache observer to whoever changes the settings or deletes rows.

  1. Check the settings at /admin/config/development/native-observability/settings/cache-observer.

Every capture is on by default, the invalidation cap is 50 and rows are kept for 72 hours.

  1. Verify with two anonymous requests to the same page: the second must answer

X-Drupal-Cache: HIT with an X-Native-Observability-Request-Id different from the first.

The report lives at /admin/reports/native-observability/cache-observer, filterable by layer and status, with a detail view for every event and its payload.

The settings are in the native_observability_cache_observer.settings configuration object:

drush cget native_observability_cache_observer.settings
drush cset native_observability_cache_observer.settings max_stored_invalidations_per_request 50 -y

Repeating the measurement

The two page cache requests, anonymous and without cookies:

URL=https://example.ddev.site/some-published-page
curl -sk -D a1.txt -o /dev/null "$URL"
curl -sk -D a2.txt -o /dev/null "$URL"
grep -iE 'x-drupal-cache:|x-drupal-dynamic-cache:|x-native-observability-request-id' a1.txt a2.txt

The query that ties each page cache event to its trace, when there is one:

SELECT c.id, c.request_id, c.status, c.path, c.route_name,
       (SELECT COUNT(*) FROM native_observability_trace t
         WHERE t.request_id = c.request_id) AS trace_rows
FROM native_observability_cache_event c
WHERE c.cache_layer = 'page_cache'
ORDER BY c.id;

Invalidations stored per request, to compare with the configured cap:

SELECT request_id, method, route_name,
       COUNT(*) AS stored_rows, SUM(tag_count) AS stored_tags
FROM native_observability_cache_event
WHERE event_type = 'tag_invalidation'
GROUP BY request_id, method, route_name
ORDER BY MIN(id);

Saving the node needs a real HTTP request made through the form: a save run from drush would stay out, for the reason given under the limits.

What this test does not prove

  • It is a measurement done by hand, one request at a time, on DDEV. It shows the mechanism, not the

behaviour under a real site's traffic.

  • The measurement environment runs in development mode. For the test I switched off

debug_cacheability_headers and Twig debug by hand, and turned them back on afterwards.

  • The module's tables already held rows from earlier tests and were not emptied. Every count is

filtered on the rows written after the test started.

Sources and references

All sources were checked on 27 September 2026.

  1. Native Observability project page (opens in a new tab). 2.0.x branch, submodules and requirements. Primary source.
  2. Module source, 2.0.x branch (opens in a new tab). CachePageObserverMiddleware for HITs and the request id, CacheResponseObserverSubscriber for cacheability and the dynamic page cache, ObservedCacheTagsInvalidator for invalidations and the cap, config/install for the defaults. Primary source.
  3. Drupal 11 core source: PageCache for the page cache UNCACHEABLE labels and the rule on non-cacheable methods, DynamicPageCacheSubscriber::shouldCacheResponse() for POOR CACHEABILITY, default.services.yml for the warning on debug headers. Primary source.
  4. Improve X-Drupal-Cache and X-Drupal-Dynamic-Cache headers, even for responses that are not cacheable (opens in a new tab), core issue #2951814, open. Read from the rendered DOM.
  5. Cache tags, Drupal guide (opens in a new tab). Read from the rendered DOM.
  6. Log Cache Tags project page (opens in a new tab). Read from the rendered DOM.
  7. Trace Cache Tags project page (opens in a new tab). Read from the rendered DOM.
  8. Cache review project page (opens in a new tab). Read from the rendered DOM.
  9. WebProfiler project page (opens in a new tab). Read from the rendered DOM.
  10. The numbers on this page come from a measurement environment I built, with Drupal 11.4.5 on DDEV and Native Observability 2.0.x. Anyone who wants to check them can repeat them with the commands and queries shown above.
AI modified

This text was translated by AI.

How was AI used?

Translated from the Italian original with AI assistance, then read and corrected by a person, who holds editorial responsibility.

Which external service is slowing down this Drupal route?

Drupal keeps no record of the calls it makes to other services. Native Observability times every outgoing call and ties it to the page that made it: for each route it shows which services it calls, how often, how long they take and how many fail. It also works backwards, from a slow service to the pages that call it.

How do you find out which external service slows down a Drupal page?

You time every outgoing call while the page is still being built, and write the page name next to the timing. The external service cannot tell you, because all it sees is a request from an IP address, and Drupal on its own keeps nothing of what it called.

Native Observability is a Drupal module that records from the inside what happens during each request. Its Spans submodule times outgoing HTTP calls, and the Dashboard groups them by page: for each route it tells you which services were called, how many times, with what average duration and how many errors. The same problem for database queries is covered in the article on the queries that slow down a route.

The numbers on this page come from a measurement environment I built, with a fake slow service whose delay I set myself. How to rebuild it is at the bottom, in the section on repeating the measurement.

Why it sees the calls of every module without changing them

It sits at the one point every call goes through. Drupal builds its HTTP client, the http_client service, through a factory that hands it a shared handler stack, http_handler_stack. Any module calling an external service through http_client, or through a client created with http_client_factory, goes through that stack.

The module pushes its own Guzzle middleware onto the stack, GuzzleSpanMiddleware, tagged http_client_middleware. The middleware starts a timer before the call and stops it when the response comes back, or the error does. The calling modules stay exactly as they are.

The only calls it misses are those made with a client someone built by hand:

<?php

// Measured: goes through Drupal's shared handler stack.
$response = \Drupal::httpClient()->request('GET', $url);

// Not measured: a hand-built client has its own handler stack.
$client = new \GuzzleHttp\Client();
$response = $client->request('GET', $url);

Adding a middleware instead of replacing the http_client service was deliberate, and the service definition says why: some modules type-hint the concrete GuzzleHttp\Client class, and a replaced service would break them.

Naming the calls your own module makes

A module that integrates an external service can name its calls with the Guzzle option native_observability, which takes subtype and name:

<?php

$response = \Drupal::httpClient()->request('POST', $url, [
  'json' => $payload,
  'native_observability' => [
    'subtype' => 'payments',
    'name' => 'Payment gateway: authorize',
  ],
]);

Without the option the call is named GET <host> and filed under the http.client category. With it, the call carries the chosen name and the category http.client.payments, and still shows up among the external dependencies of the route. On the 2.0.x branch the time breakdown on the overview only counts the exact http.client category, so a call with a subtype does not appear there.

How much time a route spends outside, and waiting on whom

The answer is in the External Dependency Signals table of the Forensic Route Analysis, under /admin/reports/native-observability/dashboard/forensic-route-analysis. One row per destination, with method, address, number of calls, average duration and errors.

In the measurement environment the page /nos-bench/http makes three calls: one to a service that answers after 800 milliseconds, one to the same service answering after 20, and one to a domain that does not exist. After four visits the table reads:

Method Destination Calls Average duration Errors
GET http://this-host-does-not-resolve.invalid/ 4 13.5 ms 4
GET http://127.0.0.1:8099/?ms=20 4 25.8 ms 0
GET http://127.0.0.1:8099/?ms=800&api_key=BENCH-KEY-12345 4 813 ms 0
POST http://grafana-alloy:4318/v1/traces 4 2.5 ms 0

The page answers in about one second, and the 813 millisecond row accounts for nearly all of it. That is the answer to the question in the title: on this route the time goes there, to that service.

The last row is not a call the page makes. It is the module itself sending traces to an OpenTelemetry collector that runs in this environment, and that call also goes through Drupal's HTTP client. Anyone using the export will find it among the dependencies of every route.

Reading the dashboard requires the access native observability dashboard permission.

From the slow service back to the pages that call it

The Forensic Route Analysis also accepts the address of an external service instead of a page. You paste the service address in the field used to add a subject, and the module finds every route that called it within the selected time range.

Pasting 127.0.0.1:8099 in the field gives this:

The search term "127.0.0.1:8099" matched 1 candidates.
/nos-bench/http · nos_bench_http.page (4 requests)

It helps when you know the service and not who uses it: you start from the address and end up at the pages.

The search scans the stored data of each call, which has no index, and stops at the first thousand requests it finds (SPAN_PAYLOAD_SEARCH_REQUEST_ID_CAP). During the measurement there were four requests, so the cap never came into play. On a busy site with a window of several days, the result can be cut short.

A call that fails on DNS has no status code

A call that fails before reaching the service gets no response, so it has no status code. The module still counts it as an error, because it reads the outcome field it stores with every call, not the status code.

The four calls to the non-existent domain are stored like this:

status_code   null
outcome       error
error         cURL error 6: Could not resolve host: this-host-does-not-resolve.invalid

An error count built on 4xx and 5xx codes loses these calls without a word. A DNS failure, a refused connection and a timeout are exactly the cases where an external service does the most damage, because the page waits until the time limit runs out.

Calls made from cron and drush belong to no page

A call started from cron or drush is timed, but there is no page for it to belong to, because no HTTP request is behind it. The module stores it without a request ID.

A call launched with drush php:eval shows up as a single 67 millisecond row with an empty request ID. It sits under no trace and in no route's table. It only feeds the time breakdown on the general overview.

Worth knowing before comparing numbers: a nightly import that calls an external service thousands of times will not show up on any page.

What the module does not do

It measures only what happens inside Drupal: how long it waited, whom it called, how the call ended. Everything else it leaves to other tools.

  • It does not link Drupal's trace to the trace of the service it called. It adds no header to outgoing calls, neither traceparent nor one of its own. Following a request across several systems takes distributed tracing, meaning OpenTelemetry (opens in a new tab) with a backend that collects the traces of every service.
  • It groups by full address, not by host. In the table above the same service takes two rows, because the query string differs. A service called with a parameter that changes on every request splits into as many rows as there are calls.
  • It stores the address with its query string, keys included. The fake key api_key=BENCH-KEY-12345 used in the measurement can be read in the database and in the dashboard table. The module strips credentials from headers and body, not from the address. If a service expects its key in the URL, that key ends up in the observability data.
  • A page served from cache calls nobody. In the measurement environment the first anonymous visit took about 3.3 seconds, the next two about 0.5 and 0.1, and only the first left a trace. The Internal Page Cache served the other two without running the page code. That is correct behaviour, but anyone measuring by reloading the same page as an anonymous user sees one call where they expected three.

How it compares with other tools

Each one answers a different question, and the choice depends on the question. The table reports what each project states on its own page: the other tools were not tried in the measurement environment.

Tool The question it answers
HTTP Client Logs what one specific call sent and what it got back
HTTP Client logger which calls went out, written to the site logs
WebProfiler what the page I am looking at right now did
OpenTelemetry which path a request took across several systems
Native Observability how long each route waits on external services, and which routes call a given service

A log helps when you want to read the body of a wrong response. A toolbar helps when you have the page in front of you. An OpenTelemetry backend helps when you run several services and someone looks after them. None of the first three groups calls by route, and none starts from the service to get to the pages.

Where to start

Three steps.

  1. Install the dashboard, which brings in Spans and the other submodules it needs: drush en native_observability_dashboard -y.
  2. Grant the access native observability dashboard permission to whoever needs to read the data.
  3. Check that calls are being timed with the module's test command, which makes five calls to known destinations, including a domain that does not resolve:
drush native-observability-span:http-test

After the command, the five calls can be read at /admin/reports/native-observability/execution. The command also calls httpbin.org, so it needs network access. From then on every call the site makes lands in the table, and the Forensic Route Analysis shows the dependencies of each route.

How to repeat the measurement

The slow service is a PHP file that sleeps for the number of milliseconds it is asked for. No internet connection is needed, and whoever runs the test sets the delay:

<?php

// Bench-only stub of a slow third party service.
// ?ms=800 sleeps for 800 milliseconds, ?status=500 answers with an error.
$ms = isset($_GET['ms']) ? max(0, (int) $_GET['ms']) : 0;
$status = isset($_GET['status']) ? (int) $_GET['status'] : 200;

usleep($ms * 1000);

http_response_code($status);
header('Content-Type: application/json');
echo json_encode(['slept_ms' => $ms, 'status' => $status]);
php -S 127.0.0.1:8099 /tmp/slow-service.php

The measured page is a controller that makes the three calls with \Drupal::httpClient(). After a few visits, this query confirms in the database what the dashboard shows:

SELECT t.route_name,
       COUNT(*) AS calls,
       ROUND(AVG(s.duration_ms), 1) AS avg_ms,
       MAX(s.duration_ms) AS max_ms
FROM native_observability_spans s
JOIN native_observability_trace t ON t.request_id = s.request_id
WHERE s.category LIKE 'http.client%'
GROUP BY t.route_name;

And this one repeats the reverse search by hand, from the service address to the route:

SELECT DISTINCT t.route_name, t.path
FROM native_observability_spans s
JOIN native_observability_trace t ON t.request_id = s.request_id
WHERE s.category LIKE 'http.client%'
  AND CAST(s.payload AS CHAR) LIKE '%127.0.0.1:8099%';

With capture switched off, capture.enabled set to zero in native_observability.settings, a new visit to the page adds no row. That check confirms the rows are written by the module.

What this measurement does not prove

The slow service is fake, and you should know it.

  • The delay is a usleep I chose. The measurement shows the mechanism, not how a real service behaves under load, with its queues and its uneven timeouts.
  • One request at a time, in a DDEV measurement environment with Xdebug on. The absolute timings are not a performance figure.
  • The module tables already held rows from earlier tests and were not emptied. Every count is filtered on the rows written after the start of the test, at 19:07:50 UTC on 27 September 2026.
  • Four visits, not a day of traffic. An average over four calls describes the measurement environment, not a production site.

Sources and references

All sources were checked on 27 September 2026.

  1. Native Observability project page (opens in a new tab). The 2.0.x branch, submodules and requirements. Primary source.
  2. Module source, 2.0.x branch (opens in a new tab). GuzzleSpanMiddleware for call timing and the outcome field, ForensicRouteAnalysisBuilder for the dependency table and the reverse search with its cap of one thousand requests, docs/usage/spans.md for cron and drush calls and for the test command. Primary source.
  3. Drupal 11 core source, core.services.yml and Drupal\Core\Http\ClientFactory: http_client and http_client_factory share http_handler_stack. Primary source.
  4. Project page of HTTP Client Logs (opens in a new tab). Read from the rendered page.
  5. Project page of HTTP Client logger (opens in a new tab). Read from the rendered page.
  6. Project page of WebProfiler (opens in a new tab). Read from the rendered page.
  7. Project page of OpenTelemetry (opens in a new tab). Read from the rendered page.
  8. Trace Context, W3C Recommendation of 23 November 2021 (opens in a new tab). Defines the traceparent header. Primary source.
  9. The numbers on this page come from a measurement environment I built, running Drupal 11 in DDEV with Native Observability 2.0.x. The measurements were read back from the database with the queries shown above. Anyone who wants to check them can rerun them with the code and queries on this page.
AI modified

This content was produced by AI and edited by a person.

How was AI used?

Drafts are produced with AI assistance and then directed, edited and fact checked by a person. Figures, dates and version numbers are verified against their public sources before publication, and the date of that check is stated in the text.

How do I apply Article 50(4) of the AI Act to the content of a Drupal site?

On a Drupal site, Article 50(4) of the AI Act comes down to four questions per content type: what was produced, whether it is an artistic work, whether it is published to inform the public, and whether it deals with matters of public interest. The AI Disclosure module stores those answers next to how much AI was involved, works out whether a label is due, and shows it on the page.

This article is not legal advice. It sets out what Regulation (EU) 2024/1689 and the European Commission FAQ say, and what the module does with those rules. Whether a piece of content needs a label is up to whoever publishes it.

What AI Disclosure is and how it is built is covered on the project page. This article looks only at how the module turns Article 50(4) into data and into a label on the page.

How does AI Disclosure decide whether content needs a label?

AI Disclosure keeps two things apart: how much AI went into the content, and whether the rule applies to that content at all.

The first is the grade, one of nine. Six grades say that AI generated or manipulated what was published: translation, summary, partial rewrite, text written with a person leading, text written with nobody leading, deep fake. The question about the rule only makes sense for those six. The other three, meaning no AI, metadata picked by AI and content rated by AI, stay out of it.

The second is the set of four scope answers:

Question What it decides
Is it a deep fake in image, audio or video, or is it text? which of the two rules in Article 50(4) applies
Is it part of an evidently artistic, satirical or fictional work? for deep fakes, how the label may be shown
Is it published to inform the public? for text, the first condition
Does it deal with matters of public interest? for text, the second condition

Each answer is yes, no or not assessed. The module infers none of them, neither from the content type nor from the grade.

What does Article 50(4) of the AI Act require?

Article 50(4) sets two separate obligations for publishers, each with its own trigger.

The first covers images, audio and video that make up a deep fake: whoever puts them out must disclose that they were artificially generated or manipulated. Human review does not remove the obligation. When the deep fake is part of an evidently artistic, creative, satirical or fictional work, the duty shrinks to disclosing that generated or manipulated content is there, in a way that does not get in the way of the work.

The second covers text generated or manipulated with AI and published to inform the public on matters of public interest. This one has an exemption, and it needs two conditions at once: the text went through human review or editorial control, and a natural or legal person holds editorial responsibility for it. The Commission FAQ defines editorial responsibility as holding the ultimate legal responsibility for the publication.

Since when does Article 50 of the AI Act apply?

Article 50 has applied since 2 August 2026, the general date set by Article 113 of the Regulation. Content generated before that date does not have to be labelled retroactively. The Commission encourages deployers to do it where possible.

Regulation (EU) 2026/1744, the Digital Omnibus on AI published in the Official Journal on 24 July 2026, left that date alone for publishers. It added a paragraph to Article 111 that applies to providers only: generative systems placed on the market before 2 August 2026 must comply with Article 50(2), the marking of the file itself, by 2 December 2026. That is the date often quoted as a general postponement. Article 50(4) is not among the amendments, so publishers are still on the August date.

What does "not assessed" mean in the verdict?

The AI Disclosure verdict has three values: label required, not required, not assessed. A single no on any condition settles the matter even when other answers are missing, and the same holds for the text exemption. "Not assessed" only appears when a missing answer would have changed the outcome.

On the page, content that has not been assessed still shows the disclosure card, with its own wording and a dashed border. Hiding the card would tell readers that no label is due, which is exactly what the site has not yet decided.

How do you answer once for a whole content type?

Answers live on the profile, and every piece of content inherits the default profile of its type. Individual content only steps in for exceptions. Grade and scope answers always come from the same level: the module does not mix answers taken from different levels.

A profile that claims human review without naming who is responsible cannot be saved, neither from the form nor through a configuration import.

On this site the editorial_default profile, set up on 20 September 2026, declares text, published to inform, with human review and a named person responsible.

The profile answers no to the public interest question, and that is my own call, not a fact. The Commission FAQ counts as public interest any economic, financial, political, scientific or cultural development that may become a subject of public debate, and a technical article about the AI Act could fall inside that. The verdict would not change: with a yes, human review and a named responsible person still trigger the exemption.

With these answers the verdict is "not required", and the page says so in its meta tag:

<meta name="ai-disclosure" content="ai_assisted_hitl; required=not-required; icon=ai_modified">

The card is still shown, because the site policy is set to "Always disclose AI involvement".

What happens when a label is required?

When a label is required, the official EU icon and the grade sentence appear, and the site cannot hide them. Card visibility is configurable, but a required label always renders. The rest of the card, meaning the legal line, the description and the link to the editorial policy, follows the site settings.

What happens if I change the grade configuration?

Content that inherits its settings reads grade and profile from configuration at the moment it is displayed. Changing the sentence of a grade therefore changes what every piece of content using it shows, including pages published months earlier. The Audit submodule is there for that reason: on every save it copies into its log what the grade meant at that moment, and later configuration changes do not rewrite that record.

What does AI Disclosure not do?

AI Disclosure does not decide whether content needs a label, and it does not check the answers. It accepts whatever name is entered as the editorially responsible person without judging it, and a generic role such as "the editorial team" will not hold up in an audit. It does not mark the file: the JSON-LD is a page-level signal, and the marking required by Article 50(2) is a job for the provider of the AI system, not for the publisher.

Sources and references

All sources were consulted on 27 September 2026.

  1. Regulation (EU) 2024/1689, Official Journal text (opens in a new tab), EUR-Lex. Article 50(4), and Article 113 on the date of application. Read from the rendered page, primary source.
  2. Regulation (EU) 2026/1744, Digital Omnibus on AI (opens in a new tab), EUR-Lex, Official Journal of 24 July 2026. Point 39 adds paragraph 4 to Article 111, setting 2 December 2026 for Article 50(2). Within Article 50 only paragraph 7 is amended. Read from the rendered page, primary source.
  3. FAQ on the transparency obligations under Article 50 (opens in a new tab), European Commission, last updated 24 July 2026. The three criteria for text, the areas of public interest, what counts as human review, editorial responsibility, 2 December 2026 limited to Article 50(2), and retroactive labelling. Read from the rendered page, primary source.
  4. AI Disclosure project page (opens in a new tab), drupal.org. Release 1.0.0-alpha2 of 12 September 2026, for Drupal 10.4 and 11. Read from the rendered page.
  5. AI Disclosure documentation on Article 50 (opens in a new tab), page "Article 50, and what this module does not do", updated on 11 September 2026. Primary source on how the module behaves.
  6. AI Disclosure Module Adds Article 50 Labelling Tools for Drupal (opens in a new tab), The Drop Times, 16 September 2026. News of the two alpha releases and of what the module does. Read from the rendered page.
  7. The meta tag quoted in the profile section is the one this very page writes in its <head>. You can check it by viewing the page source.
AI modified

This content was produced by AI and edited by a person.

How was AI used?

Drafts are produced with AI assistance and then directed, edited and fact checked by a person. Figures, dates and version numbers are verified against their public sources before publication, and the date of that check is stated in the text.

Which queries are slowing down a Drupal route, and how you isolate them in production

The database records slow queries but does not know which page they came from, and a development profiler was not running when that page was slow. Native Observability is a Drupal module that writes both pieces of information together while the request is still in flight: for every query above a threshold it stores one row holding the query and the page that ran it. The rows are readable from the admin area, and one check tells you whether what you see is complete or whether some rows were dropped.

Which query is slowing down a Drupal page, and how do you find it?

You find it only if something wrote the page name next to the query while the request was running. Afterwards it cannot be reconstructed: the database keeps the slow queries, but to it they are statements that arrived over a connection, not pages of a site.

Native Observability is a Drupal module that records from the inside what happens during every request. Its Database Observer submodule does one thing: when a query goes over a duration threshold, it stores one row holding two pieces of information side by side, the query and the page that ran it.

This page covers three things: what you find inside that row, how far you can trust what you read, and where to start. How much the module slows the site down is a different question, and the measured answer is in the article about its cost. The same problem for outgoing HTTP calls to external services is covered in the article on the external service that slows down a route.

Why the tools you already have do not answer this

They answer nearby questions, and none of them is this one.

The MySQL slow query log knows queries and ignores pages. That is not a configuration limit, it is its definition: the manual describes it as the list of SQL statements that take longer than a given duration. Statements, not pages. The fields it writes are the database's own, that is duration, rows read and rows sent, and none of them says where the request came from: the database receives commands over a connection and does not know a site sits above it. You end up with the guilty query and go looking for it by hand inside the code.

Development tools look at the page you have open right now, not at yesterday's slow one that you cannot reproduce.

Tool Which question it answers
Devel which queries the page I am looking at is running
WebProfiler how much time and how many queries the page I am looking at spent
XHProf where the time goes function by function, and who calls whom
DB Performance which queries are slow across the site, grouped by query shape
Native Observability which queries slowed down a given page, yesterday too, while I was not looking at it

Two points worth straightening, because they circulate upside down. First: Devel states that it is safe on a production site, in those words, on its project page, as long as the development information permission goes only to developers. So the boundary is not development against production: it is between the request you are making and the history of everybody else's requests. Second: XHProf goes into a level of detail you will not find here, because it reconstructs the call tree, while Native Observability times services and stops there.

DB Performance comes closest, and the difference is sharp: it groups slow queries by shape and suggests indexes, but the word route and the word URL never appear on its project page. None of these tools writes down which page the query came from.

What you see, once the link is there

One row for every slow query, and nothing for the others.

In a measurement environment running Drupal 11, with the module's default settings, a page built to be slow answers in about 0.6 seconds. This is what stays in the table:

route_name        testing_native_observability.probe_slow_db
duration_ms       603.968
query_text        SELECT SLEEP(:s)
query_hash        0f907bc4...

Four pieces of information, and all of them earn their place:

  • the route is the name Drupal gives a page from the code's point of view. It says which part of the site produced the query, even when the address changes on every visit;
  • the duration is in milliseconds, so about 0.6 seconds;
  • the query text sits next to the class and the method that ran it;
  • the fingerprint is a code computed on the SQL text with the values stripped out. It lets you find the same query on other pages and on other days, even when the values change.

A second heavy query ran during the same request, and it does not appear. It took less than the default threshold of one hundred milliseconds, so it was not stored. The threshold behaves like a gate, not like a recorder.

The same rows are readable on screen at /admin/reports/native-observability/database-observer, with the access native observability database observer permission.

How far you can trust that table

It depends on two settings, and the second one deserves attention.

The first is the threshold, one hundred milliseconds by default: it decides how long a query has to take before it reaches the table. Lowering it does not bring more truth, it brings more rows, and below a few milliseconds you are recording the site working normally.

The second is the maximum number of rows per request, twenty by default. If one page produces more than twenty slow queries, the rest are dropped, and for now no screen tells you so. Twenty rows in the table are indistinguishable from twenty slow queries that really happened.

There is a way to notice, though, because the module also stores how many slow queries it saw, before dropping any. This query lines up the requests where it happened, comparing the stored rows (stored_rows) with the ones seen (slow_queries_seen):

SELECT request_id, route_name, COUNT(*) AS stored_rows,
       MAX(JSON_EXTRACT(payload, '$.request_summary.slow_query_count')) AS slow_queries_seen
FROM native_observability_database_query
GROUP BY request_id, route_name
HAVING slow_queries_seen > stored_rows;

In the measurement environment the cut is visible with the naked eye: a page running twenty five slow queries leaves exactly twenty rows in the table, and the five extra ones exist nowhere.

One last thing, so you do not go looking elsewhere: the Database Observer writes nothing to Drupal's log. What it saw lives in its own table and its own report, and nowhere else.

If another system is the one calling the slow page

The link survives through two HTTP headers. The caller puts its own identifier in X-Native-Observability-Parent-Request-Id, Drupal answers with X-Native-Observability-Request-Id, and the module keeps the tie between the two requests. From there you reach the slow queries of that page.

One warning, so nobody is disappointed: towards a domain other than yours, the identifier has to be set by the caller. The module adds it by itself only to calls that stay on the same site.

Where to start

Four steps, and after the first one the table starts filling up.

  1. Install the submodule: drush en native_observability_database_observer -y. It pulls in the main module.
  2. Give the access native observability database observer permission to whoever reads the report, and administer native observability database observer to whoever changes its settings.
  3. Leave the threshold at one hundred milliseconds for a few days, then look at the report. If the table stays empty, the pages you are watching have no slow queries: that is a result, not a failure.
  4. Before drawing conclusions, run the check query above. If it returns rows, you are reading only part of what happened.

What this demonstration does not prove

The slowness in the measurement environment is fake, and it is fair to know that.

  • The delay is injected by a test page. The demonstration shows the mechanism, not that the module can diagnose real slowness on its own, which usually comes from a missing index or from a query repeated inside a loop.
  • To show rows being dropped, the threshold was lowered on purpose. The case where real slowness produces more than twenty slow queries in a single request was not reproduced.
  • No concurrency and no fix measured again: one request at a time, on a page that is slow by construction. The absolute timings are not to be read as a performance measurement.

Sources and references

All sources were consulted on 27 September 2026.

  1. The Slow Query Log, MySQL 8.4 Reference Manual (opens in a new tab). Definition of the slow log and the fields it writes. Read from the rendered DOM: the site answers 403 to curl.
  2. Devel, project page (opens in a new tab). The sentence about production safety and the permission to grant. Read from the rendered DOM.
  3. WebProfiler, project page (opens in a new tab). The toolbar at the bottom of every page. Read from the rendered DOM.
  4. XHProf, project page (opens in a new tab). Hierarchical profiler, breakdown by callers and callees. Read from the rendered DOM.
  5. DB Performance, project page (opens in a new tab). Grouping by query shape and index suggestions, release 1.0.0-alpha2. Read from the rendered DOM.
  6. Native Observability, project page (opens in a new tab). Requirements and submodules.
  7. Module source, 2.0.x branch (opens in a new tab). The classes named on this page open from there: DatabaseQueryObserverSubscriber for the slow query count taken before the per request limit, DatabaseObserverSettingsForm for the constraints on the two settings, TraceSubscriber for the two HTTP headers.
  8. The numbers on this page come from a Drupal 11 measurement environment I built myself, not from a public site: commands and results are reproduced above in full, and the conditions they hold under are in the section "What this demonstration does not prove". It is not a source you can open, it is a measurement you can repeat.
AI modified

This content was produced by AI and edited by a person.

How was AI used?

Drafts are produced with AI assistance and then directed, edited and fact checked by a person. Figures, dates and version numbers are verified against their public sources before publication, and the date of that check is stated in the text.

How much does Native Observability slow Drupal down, and how do you measure it on your server

Native Observability is the Drupal module that records what happens inside every request. Instrumenting a site with it costs about 4.7 milliseconds per request on one machine and about 7.0 on another, and drops to about 1.8 with two submodules switched off. The figure depends on the server: you get yours with drush no:overhead:measure.

How much does Native Observability slow Drupal down?

Native Observability (opens in a new tab) is the Drupal module that records what happens inside every request, and instrumenting a site with it costs about 4.7 and about 7.0 milliseconds per request on the two machines where I measured it. Same code, same version, same measurement command, two numbers 48% apart.

What the module does and how it is built belongs to the project page. This page is only about what it costs and how you measure that.

The first figure is published in the module documentation (opens in a new tab), which states its conditions and where it came from. I took the second one myself, on a containerised DDEV environment, on 25 September 2026.

Value Where Stated conditions
~4.7 ms/req development laptop, database in Docker twelve submodules enabled, empty tables, 15 pairs of 6 requests; ~4.6 to ~5.7 across eight runs over two days
~7.0 ms/req containerised DDEV on an iMac Drupal 11.4.5, PHP 8.3, Xdebug off, 15 pairs, tables not empty
~1.8 ms/req same development laptop without the Execution and Spans submodules. Its own paired run, 9 pairs of 6 requests, against a control of ~4.7 ms/req measured in the same session with eleven submodules

The third row carries the most weight: switching off two submodules takes the cost from about 4.7 to about 1.8 milliseconds, cutting it by 62%. Both figures behind that cut come from the same paired run, not from comparing the first and third rows of this table. None of the three numbers answers your question, because none of those installations is yours.

Why two machines give two different numbers

What it costs to instrument an application depends on the machine running the extra instruction, and the two machines above differ in everything that matters: CPU clock and generation, opcache state, storage type, competing load at the moment of the measurement, and whatever virtualisation layer sits underneath.

Here is what I did not hold constant: the two Drupal installations did not carry the same content. The documentation states that the lower figure came from empty tables. Mine did not. Data volume moves the work some instrumentation does, so the gap between the two figures reads as a difference between two whole environments, not between two processors. Saying so costs one line and makes the number usable. A figure without its conditions is the same kind of data that APM vendors publish without saying where they got it.

Why the module stopped declaring a percentage

Before 2.0.0, which means up to and including 1.x, native_observability published an estimate nobody had ever measured. The 2.0.0 release notes, which removed that code, put it plainly:

The report used to sum four hardcoded constants and print a made-up "Estimated overhead: 10%".

Four constants written by hand in the source, added up, printed as a percentage. That code went away in 2.0.0. The Drop Times (opens in a new tab) wrote an article about the decision and quotes my written response, where I called that 10% an overestimate drawn from my own testing, one that could not be carried from one server to another. The wording is mine, not theirs: a quotation, not independent confirmation. Their coverage stops at what the module costs, which is one of the things the module does. This page stays on that same ground. The rest lives on the project page.

In its place, 2.0.0 shipped a command. That choice costs something to anyone who looks for a figure on the project page and finds none, and it pays back for anyone deciding whether to install the module on one particular server.

I maintain the module, so this section is my account of my own decision. The measurements you can repeat with the same command. The judgement about the decision stays mine.

The measurements on this page were taken on 2.0.1. The stable release published on the project is 2.0.1, and if that is newer it is worth running the measurement again before trusting the numbers below.

How you measure it: the command

One command, no configuration:

drush no:overhead:measure

The command compares two states of the same installation: capture on and capture off. Measuring both states on one site separates what the module costs from the differences between one server and another, which is exactly what makes comparing your number with mine pointless.

The module ships two calibration routes, kept out of an ordinary measurement: one that runs no query and no rendering, one that runs a declared, constant load. They exist so the measurement asks the same question on any installation.

How the command avoids lying to you

The protocol is the one used for benchmarking in noisy environments, applied to an HTTP request. The 2.0.0 release notes describe it in full, and it comes down to five precautions:

  1. Paired blocks. Each pair runs one block with capture on and one with capture off, so a server

hiccup hits both sides.

  1. Alternation. Which side goes first changes from one pair to the next, because the first of the

two pays for the cold start.

  1. First pair discarded. It warms up caches and opcache and stays out of the calculation.
  2. Median of the paired differences. A median survives one outlier. A mean does not.
  3. Exact two-sided sign test, at p ≤ 0.05, plus a separate pass with capture off on both sides

to measure the machine's noise floor.

That last point is what separates a measurement from a number. Without the noise floor you cannot tell whether the delta you got is the module or your server breathing.

How I read the result

Four values decide whether the number is worth anything, and they all sit in the output. Here is mine, cut down to the lines that matter:

Fixed cost per request (a): 6.953 ms/req

calibration-minimal:
  - budget: 0.87% of the declared TTFB budget (good)
  - N 15 | IQR [6.908, 7.020] | noise 0.040 | signs 7/7 | p=0.01562
Value Mine How to read it
N 15 pairs left after the warm-up is discarded. Below 10 the sign test cannot reach significance
IQR [6.908, 7.020] middle half of the differences. If it is as wide as the value, the measurement is not stable
noise 0.040 ms the machine's noise floor. My effect sits a hundred and seventy-three times above it
p 0.01562 the chance that the observed signs come from randomness. Above 0.05 the tool marks the result unclear

A delta that does not clear the noise is reported as unclear rather than meas, and the useful move at that point is to measure again on a quieter machine.

Are about 7 milliseconds a lot or a little?

It depends on your site's response budget, and against mine they are about 0.9%.

The reference threshold comes from Solving Big Data Challenges for Enterprise Application Performance Management (opens in a new tab) (Rabl, Gómez-Villamor, Sadoghi, Muntés-Mulero, Jacobsen, Mankovskii, VLDB 2012), which puts it this way: "As a rule of thumb, a maximum tolerable overhead is five percent, but a smaller rate is preferable". Past that threshold, monitoring degrades the thing it is supposed to watch.

Whether the threshold is respected is a separate question. groundcover notes that 3-5% is what turns up in the benchmarks APM vendors run on themselves, and cites a measurement by Scout APM, a New Relic competitor, where the New Relic agent added more than 44% in a Ruby scenario.

Site TTFB budget ~7.0 ms are worth Verdict against the 5% threshold
800 ms ~0.9% comfortably inside
300 ms ~2.3% inside
140 ms ~5% at the limit

I did not pick the 800 milliseconds: that is the value of overhead.budget_ms in the module's configuration, and the comment in the source traces it to the "good" TTFB threshold documented by web.dev. It exists to colour the verdict, and it should be replaced with your site's real budget. A figure in milliseconds, with no budget to compare it against, says nothing about whether the module is sustainable.

The visitor does not pay for the database write

Rows are not written during the request. DeferredPersistenceBuffer collects them in memory and flushes them with a single multi-row INSERT hooked to KernelEvents::TERMINATE, the event Drupal fires after the response has reached the client. The comment in the source says where this came from: a busy request used to ship "around 130 individual INSERTs".

Three details of the mechanism, because the difference between buffering and deferred writing lives there:

  • the buffer flushes itself past 1000 rows, so one unusual request does not hold an unbounded volume

in memory;

  • flush() swallows exceptions, because a fault in the observability must never bring down the

response it was watching;

  • the deferred_persistence_enabled setting puts the module back to immediate writes, for when you

need to see what the buffer is doing.

What the visitor does pay for is the instrumentation, and that stays inside the request. In the request-level census published in the module documentation (opens in a new tab) and picked up by The Drop Times (opens in a new tab), out of about 6.3 milliseconds of module work about 0.4 sat in the deferred flush (6.1%) while the remaining 93.9% ran before the response left: execution tracking, span linking, cache observation.

That census answers a different question from the fixed cost per request, and the two numbers do not belong in the same column. The census counts the work the module attributes to itself. The paired measurement counts the difference between an instrumented site and the same site without instrumentation, and that is the one the visitor pays.

How much the module collects is up to you, and the limits ship configured

Every data source has a ceiling written into the default configuration, and none of these values is hardcoded. How to set them is covered on the configuration page of the documentation (opens in a new tab). These are the defaults, from config/install.

In native_observability.settings:

Setting Default What it caps
retention.max_rows 50,000 total rows kept
trace_retention_hours 72 how long traces live
spans_retention_hours 24 how long execution spans live
metrics_retention_days 7 how long aggregated metrics live
cleanup_batch_limit 5,000 rows deleted per cleanup pass

The two observers are submodules and carry a configuration object of their own. In native_observability_database_observer.settings:

Setting Default What it caps
slow_query_threshold_ms 100 how many milliseconds a query has to take before it is recorded
max_stored_queries_per_request 20 queries kept per request
retention_hours 72 how long recorded queries live

In native_observability_cache_observer.settings:

Setting Default What it caps
max_stored_invalidations_per_request 50 cache invalidations kept per request
retention_hours 72 how long recorded cache events live

The two per-request ceilings are what keeps the cost flat when a page misbehaves: a route that runs three hundred queries gets twenty of them recorded, not three hundred.

On the personal data side the defaults start closed, and that also lowers the work: the request body, the query string and the headers are not collected (body_mode, query_mode and headers_mode are off), the IP address is anonymised, and only the user agent family is kept. The module also keeps its own routes out of capture (exclude_own_routes), so opening the dashboard does not inflate the counters you are reading.

What the other tools declare

None of the PHP APM vendors publishes a figure obtained on the customer's machine, and two of the four I checked publish no figure at all. The column that matters is not what they declare, it is under which conditions.

Tool What it publishes Under which conditions
Tideways 4.93% on PHP 5.6 and 17.11% on PHP 7 for Timeline at 10% sampling, 13.41% and 23.86% for XHProf the oss-performance benchmark against WordPress, with the machine pushed to its limit and every core saturated. The vendor warns that "for actual applications the overhead is smaller" and that the result "is just representive of WordPress"
Blackfire "close-to-no overhead" on standard traces, and up to 15% measured on Extended Traces maximum observed by the vendor, with Drupal named explicitly alongside Symfony, Prestashop 1.7+ and Ibexa DXP
New Relic no figure in the documentation I checked (opens in a new tab), which explains how to reduce overhead not applicable
Datadog no figure in the documentation I checked (opens in a new tab), which covers agent rate limits not applicable

The Tideways percentages do not compare with mine. They measure WordPress on PHP 5.6 and PHP 7, under a load built to saturate the machine, while my figure comes from Drupal 11.4.5 on PHP 8.3. A different CMS, two major PHP versions apart, opposite conditions.

What does compare is the behaviour. Tideways and Blackfire publish measured numbers and state their limits, which is the right thing to do even when the number that comes out is unflattering. New Relic and Datadog, on the pages I read, publish nothing. That is the comparison that holds, and it has no percentages in it.

What to ask whoever publishes a performance number

Four questions, and they hold for any tool, not only this one. They are not theoretical: I wrote them after finding three defects that skewed my own module's measurement, each listed with its number in the 2.0.0 release notes (opens in a new tab).

  1. Does the tool exclude itself from its own measurement? Anything that also traces the routes

it measures with is counting itself, and the direction of the error is not predictable: in my case the cost came out 55% lower, not higher.

  1. Is the repeat run protected from the cache? If the second run can be served from the first

run's cache, the published number is the cache's timing, roughly two orders of magnitude lower, so the cost on the page is one that nobody pays.

  1. Are the settings reread? A service that reads its settings once, for the lifetime of its own

instance, works under PHP-FPM and lies in a persistent runtime. That is the class of problem behind the core meta issue on persistent application servers (opens in a new tab), which lists ReactPHP, PHP-PM, PHPFastCGI, FrankenPHP and Swoole.

  1. Is the noise floor stated? Without it nothing separates the measured effect from the

machine's own breathing, which is why noise sits next to the value in the command's result.

If whoever publishes a number cannot answer these four, that number is worth what the "10%" printed by 1.x was worth.

What I do with this number

I measure before installing in production, and I measure again after any change of machine or of PHP version. The procedure is four steps:

  1. Install the module on an environment matching production in PHP, Drupal and database version,

with Xdebug off: Xdebug instruments every function call, and any measurement taken with it running belongs in the bin.

  1. Run drush no:overhead:measure with no other load on the machine.
  2. Read N, IQR, noise and p before you read the value. If the verdict is unclear, the value

does not get used.

  1. Divide the cost by the site's response budget and compare it with the threshold you set yourself.

The installation steps are in the module documentation (opens in a new tab), and what the module actually does is on the project page on this site. The number you get holds for your machine and that installation. I published mine so it can serve as a reference point for the order of magnitude, and so it is visible that two measurements of the same code can sit half a millisecond or two milliseconds apart.

Sources and references

All sources were checked on 27 September 2026.

  1. Native Observability, project page (opens in a new tab). Requirements, measurement command, stable release 2.0.1.
  2. Native Observability documentation: what the module costs (opens in a new tab). The figures 4.684, 1.795, 6.253 and 0.379 with their conditions and the provenance table.
  3. Native Observability documentation: configuration (opens in a new tab) and installation (opens in a new tab).
  4. Native Observability 2.0.0, release notes (opens in a new tab). Removal of the constant-based estimate, paired measurement protocol, sign test.
  5. Native Observability 2.0.0 Measures Its Own Performance Cost, The Drop Times (opens in a new tab). Request-level census, 6.253 ms and the breakdown of the deferred flush.
  6. APM Overheads: Is Your APM Slowing You Down?, groundcover (opens in a new tab). The 3-5% in vendor benchmarks, and the independent measurement above 44% in a Ruby scenario.
  7. Profiling Overhead and PHP 7, Tideways (opens in a new tab). Vendor-measured overhead on PHP 5.6 and PHP 7, from 4.93% to 23.86%.
  8. Configuring Blackfire Monitoring (opens in a new tab). "close-to-no overhead" on standard traces, up to 15% measured on Extended Traces.
  9. PHP agent overhead reduction tips, New Relic (opens in a new tab). No declared figure.
  10. Agent Rate Limits, Datadog (opens in a new tab). No declared figure. The page covers agent rate limits.
  11. Robust benchmarking in noisy environments (opens in a new tab). Operating system noise, warm-up, outlier filtering.
  12. Make Drupal compatible with persistent app servers like ReactPHP, PHP-PM, PHPFastCGI, FrankenPHP, Swoole, core #2218651 (opens in a new tab). The class of defect behind settings read once per service instance.
  13. Solving Big Data Challenges for Enterprise Application Performance Management, Rabl et al., VLDB 2012 (opens in a new tab). The 5% threshold as a rule of thumb.
AI modified

This content was produced by AI and edited by a person.

How was AI used?

Drafts are produced with AI assistance and then directed, edited and fact checked by a person. Figures, dates and version numbers are verified against their public sources before publication, and the date of that check is stated in the text.

AI Disclosure

AI Disclosure is a Drupal module that records, for every piece of content and every translation, how much AI contributed to the text or the media. The reader sees it in a card carrying the official European Union icons, while engines and agents read it in the page source. The module also flags when Article 50(4) of the AI Act makes the label mandatory.

AI Disclosure started in the Drupal AI Initiative in August 2026, when Article 50 of the AI Act had only just begun to apply and Drupal still had no way of telling the reader how a piece of content had been written. I developed it, starting from a plan by Marcus Johansson.

Sector
AI, publishing, compliance
Role
Author and maintainer, with @marcus_johansson and @joevagyok
Year
2026

What the AI Act asks of anyone who publishes content

Since 2 August 2026, Article 50 of the AI Act has required publishers to declare two kinds of content: images, audio and video that imitate real people, places or events closely enough to pass as authentic, and AI-generated text that informs the public on matters of public interest. There is an exception for text. If a person has reviewed it and takes editorial responsibility for it, the label is not mandatory.

So the rule does not ban AI, and it does not require you to declare it every time. What it requires is that you know, content by content, whether one of those cases applies.

How AI Disclosure decides whether the label is due

AI Disclosure sorts every piece of content into one of nine grades, from "Human only" to "AI deep fake". Six of the nine carry the precondition for the obligation: without it the module does not even ask the questions that follow, and the verdict is "not required". The full list, with the sentence each grade shows the reader and the icon it carries, is in the module documentation (opens in a new tab).

With the precondition in place, the rule splits into two branches, depending on the medium.

Audiovisual deep fake: paragraph 1

The label is due unconditionally. Human review, editorial responsibility and public interest are never even consulted.

AI-generated text: paragraph 2

The label is due if the content informs the public and concerns a matter of public interest. A no to either one settles the verdict as "not required", even when the other is still unanswered.

Text has an exemption, and it applies only when both conditions hold: substantive review with editorial control and a named editorial responsibility. Review on its own is not enough. That is the difference between "I read it again" and "someone signs it".

What AI Disclosure solves for an editorial team

AI Disclosure turns the labelling assessment into data the content carries. The team describes, once per content type, how it uses AI: articles drafted with AI and reviewed by a journalist, say, or machine translations that someone has read through. Every new item inherits that description, and you only change it for the exceptions.

From there the module does three things. It shows the reader a card with the official icon published by the European Commission and a plain sentence. It writes the same information into the page source, where search engines and agents read it. And it keeps a site-wide report saying, for each item, whether the label is due, not due, or still waiting for an answer.

The module follows the Commission's guidance on Article 50, but it does not decide for the publisher. It records the assessment and makes it visible.

How I built AI Disclosure in Drupal

On 3 August 2026 Marcus Johansson published the module plan in the Drupal AI Initiative: sixteen steps, from the foundations to the integrations with the other Drupal AI modules. I wrote to him and took the development over, and on 17 August I opened the project on drupal.org.

Between 18 and 24 August I finished the first three phases of the plan, ten steps out of sixteen: the structure of the AI levels, the reusable descriptions, the field on content, the reader-facing card, the machine-readable data, the report and the handling of translations. Still open are the project documentation and the integrations with five existing AI modules, which will let translators and generators declare their own contribution.

Who I develop AI Disclosure with

AI Disclosure has three maintainers. Marcus Johansson (@marcus_johansson (opens in a new tab)) wrote the plan in the Drupal AI Initiative and is a co-maintainer of the project. Adam Nagy (@joevagyok (opens in a new tab)) joined later as a co-maintainer. His drupal.org profile says he works as a Drupal consultant for the European Commission on the Europa Web Platform. I write the code. As of 22 September 2026, 221 of the repository's 226 commits are mine.

How I use AI Disclosure on this site

Every page on this site carries the AI Disclosure card at the foot of the text. The Italian originals declare that they were written with AI assistance and reviewed by me. The English versions declare that they were translated with AI and read through.

On 22 September 2026, while I was writing this page, I found that the site was not doing what the module promises. There was a single disclosure covering both languages, and the English pages were describing how the Italian had come about. I fixed it the same day. This is the case the module exists for: a disclosure holds for the language the reader has in front of them.

The two versions of this page show the two ways the module gets its answer. The Italian original carries no disclosure of its own. Its field is empty, and the module falls back to a default profile, Editorial default, which says grade "AI assisted (human led)", human review done, editorial responsibility in my name, medium text, content that informs the public, matter of public interest no. This English page carries an explicit one instead, the Translation, reviewed profile, whose grade is "AI translated".

Different grades, same verdict: both pages work out required=not-required, and the reason is worth stating. Not the review exemption, which would in fact be satisfied on both. Paragraph 2 asks for two conditions together, and these pages carry only one. A project page informs the public, yet it is not a matter of public interest. If I ever wrote here about European politics, the same configuration would produce required=required without my touching anything, and the label would appear on its own.

The card at the foot of this page is that calculation made visible: the European Commission's ai_modified icon, the grade's sentence, and a collapsible detail holding the profile description. The line naming editorial responsibility does not appear because the grade keeps it switched off among the visible parts, not because the name is missing.

You do not have to take the card's word for it: the same information sits in the page source, and you read it from the command line.

curl -s https://giorgiopagano.org/en/projects/ai-disclosure | grep 'name="ai-disclosure"'

It answers ai_translated; required=not-required; icon=ai_modified: the grade, the verdict on the obligation, and which European Commission icon to show. Installing and configuring it is covered in the module documentation (opens in a new tab).

Sources on AI Disclosure and on Article 50

AI modified

This text was translated by AI.

How was AI used?

Translated from the Italian original with AI assistance, then read and corrected by a person, who holds editorial responsibility.

AI Content Migrate

AI modified

This content was produced by AI and edited by a person.

How was AI used?

Drafts are produced with AI assistance and then directed, edited and fact checked by a person. Figures, dates and version numbers are verified against their public sources before publication, and the date of that check is stated in the text.

DOC to HTML

AI modified

This content was produced by AI and edited by a person.

How was AI used?

Drafts are produced with AI assistance and then directed, edited and fact checked by a person. Figures, dates and version numbers are verified against their public sources before publication, and the date of that check is stated in the text.

Native Observability

Native Observability is a Drupal module that records what happens on every request from inside the application: traces, execution spans, cache events, slow queries and metrics aggregated per route. It needs no external agent and no core patch, and it measures its own cost on the server it runs on instead of declaring one.

Native Observability started on 12 February 2026 out of a practical question: working out what Drupal was doing in production on a site where I could not install an external agent. I wrote it, and on drupal.org I am its only maintainer, so read every comparison on this page knowing that. The figures about the alternative modules come from their own project pages, read on 24 September 2026, and each one can be checked. The judgement about when to reach for one module rather than another is mine, and you will find it kept apart from the data.

Sector
Developer tools
Role
@sjpagan (Maintainer)
Year
2026
Tech Stack
Drupal
PHP

What Native Observability records

The module records five families of data, all tied to the single HTTP request.

Traces

Every request is given a ULID, and child requests, AJAX calls included, are tied back to the request that started them. The correlation identifier also comes back in a response header, so it can be picked up from outside.

Execution spans

The timings of the services that ran during the request, categorised and viewable per trace. Outbound HTTP calls are watched by a middleware on Guzzle's shared handler stack, so every request made through Drupal's http_client lands in the trace without touching the module that issued it.

Route metrics

Request volume, average duration and two percentiles, aggregated per route rather than per URL.

A percentile tells you how slow the page is for whoever has it worst. P95 is the time below which 95 requests out of 100 stay: if it reads 800 milliseconds, five requests in a hundred took longer. P99 raises the bar to 99 out of 100 and photographs the worst tail. They matter because an average hides exactly those: ninety-five fast pages and five very slow ones make a reassuring average and a site somebody finds unusable.

Aggregating per route rather than per URL is what makes two pages of the same kind comparable when their content differs.

Cache events

Real behaviour of the response cache layer, cacheability and tag invalidations, each with a filterable report and a JSON export.

How to read these events on real requests, including the page cache HIT that Drupal never handles, is explained in the article why a Drupal page is not cached.

Database queries

Slow or otherwise relevant queries, with the threshold set by slow_query_threshold_ms and a per request ceiling set by max_stored_queries_per_request, both in native_observability_database_observer.settings.

Requirements and compatibility

Requirement Value
Drupal 10 or 11, declared on every module in the family
PHP 8.2 or newer
Database any database Drupal core supports
Current stable release 2.0.1, 21 September 2026
Security advisory policy covered

The PHP 8.2 floor is not caution: the module uses readonly classes, which PHP 8.1 cannot parse. Drupal 10 still accepts PHP 8.1, so on that major the module is stricter than core, and composer.json refuses the install up front instead of letting it fail later.

The database has one special case. The dashboard ranks its samples with a window function, and MySQL 5.7 is the only server Drupal allows that has none. It only concerns Drupal 10, because Drupal 11 already requires MySQL 8.0. On MySQL 5.7 the ranking moves into PHP, the pages keep working, and the status report says so.

How to install it

Installation is Composer, then one of three Drush commands that install a whole tier.

composer require drupal/native_observability
drush en native_observability -y

The second command is needed the first time and is not a formality: Drush only discovers a module's commands once that module is enabled, so on a clean site the drush no:preset:* commands do not exist yet.

Tier For Command
raw external scrapers, Prometheus, Mimir, CI. No interface drush no:preset:raw
dashboard people administering the site and reading the data inside Drupal drush no:preset:dashboard
integrations sites pushing telemetry to an external stack over OTLP, or wiring the events into ECA drush no:preset:integrations

Every preset is idempotent: running it again on a provisioned site prints "Already enabled" for the modules it knows and exits without error.

Why the module may refuse to install

Native Observability wraps core services rather than patching them, and that leaves room for a precise problem: another module decorating or re-tagging the same service can quietly take precedence. The instrumentation would stop seeing part of the data with no error showing anywhere, and the charts would keep drawing, simply incomplete.

The sanity check answers one question: is the instrumentation actually wired up? It lists every integration point the family installs, confirms it is the live implementation, and flags anything else competing for the same point. It sits at /admin/reports/native-observability/sanity-check or behind drush no:sanity-check.

When it finds an incompatible override, the base module and every sub-module refuse to install. A measuring instrument that measures badly is worse than no instrument, because nobody has any reason to doubt its numbers.

What it costs, and who measures it

You measure the cost yourself, on your own server, with a command the module carries. There is no percentage to take on trust, because a percentage measured somewhere else does not describe your installation.

drush no:overhead:measure

The measurement compares two states of one site, interleaves the blocks, works on paired differences with a deterministic significance test, discards the warm-up and declares what is not inside the number. The module ships two calibration routes for it, closed outside a measurement run: one that runs no query and no rendering, and one that runs a declared, constant load. They serve two purposes: making the same measurement ask the same question on any installation, and making two runs on one site comparable.

Two machines, two different numbers

The same measurement, with the same protocol, on two machines of mine gives 4.684 and 6.953 milliseconds per request. Neither is a mistake: it is the reason the module stopped publishing a single figure.

The first comes from the development machine the module was born on, and is the one carried in the documentation with its provenance table. Repeating it eight times over two days, 9 to 15 pairs each, the spread there ran from 4.59 to 5.72 milliseconds.

The second comes from a containerised DDEV environment on an iMac, Drupal 11.4.5 and PHP 8.3, measured on 25 September 2026 with Xdebug switched off, because Xdebug instruments every PHP function call and any measurement taken with it running is worthless. This is the output, cut down to the lines that matter:

Fixed cost per request (a): 6.953 ms/req

| Workload               | OFF       | ON     | Delta | Verdict |
| calibration-minimal    | 3.778 ms  | 10.757 | 6.953 | meas    |
| calibration-calibrated | 22.476 ms | 30.279 | 7.707 | meas    |

calibration-minimal:
  - budget: 0.87% of the declared TTFB budget (good)
  - N 15 | IQR [6.908, 7.020] | noise 0.040 | signs 7/7 | p=0.01562

The last two lines are what make the figure readable. The machine's noise floor is 0.040 milliseconds and the measured effect is 6.953: the signal sits a hundred and seventy-three times above the noise, with the sign test at 7 out of 7 and p = 0.01562. A delta that failed to clear the noise would be marked unclear rather than meas, and I would not publish it.

Against a response budget of 800 milliseconds that is 0.87%, but the denominator is mine: replace it with your own site's budget.

The cost does not grow with the load

The two calibration routes run different amounts of work, and the module's cost between them does not change measurably: the difference is 0.754 milliseconds across 20 declared units, with p = 0.125 against a noise floor of 0.293. The tool does not round it to zero and does not dress it up as a positive result. It declares it unresolved and explains what that means:

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

In practice: the module costs a fixed amount per request, and no more on a page that works harder. If you need that difference resolved, the command takes more pairs to raise the test's power.

What a production site reported

Under the Talking Drupal episode about the module, a reader described installing it on a production site serving around a hundred pages a minute and leaving it on for a couple of days without noticing any drop in performance. In the same comment he flags an effect that weighs more: the database grew three or four times.

That is exactly what has to happen. A module that records what happens on every request writes rows, and those rows take disk space. Public discussion of this module has been almost entirely about milliseconds: whoever runs it notices the disk first.

Keeping the space in check

Setting Default What it caps
retention.max_rows 50,000 row ceiling across traces, spans, cache events and queries
trace_retention_hours 72 how many hours before traces are deleted
spans_retention_hours 24 the same for spans
metrics_retention_days 7 the same for aggregated metrics
max_stored_queries_per_request 20 how many queries are kept per request
max_stored_invalidations_per_request 50 how many cache invalidations per request

There is also a master switch, capture.enabled: turning it off stops the recording without uninstalling anything, and what has already been collected stays available for analysis. That is the intended way to open the observation over a window of time, close it, and work through what was gathered at your own pace.

The number you will not find here

The table also reports +184.05% on the minimal route, and that percentage is not to be read. The tool's own legend says why: the minimal calibration route runs no query and no rendering, so its denominator is artificially small and the percentage coming out of it describes no real page. Read the absolute delta, and the share of your own page's budget.

Why the number was not there before

Up to version 1.1.x the module published "Estimated overhead: 10%", a percentage arrived at by adding four constants written into the code: not one of the four had been measured, and the figure described no real installation, including the one reading it. Version 2.0.0 deleted that code. After the upgrade the overhead section of the report starts hidden, and comes back only once a measurement has actually been run.

How it sits next to the other tools

Image
The Drupal drop holds up one request identifier, next to a panel with its response time and the five things the module recorded for it.

Native Observability is not a profiler. It is a flight recorder. The distinction is not marketing, and it changes who is standing in front of the tool.

You switch a profiler on when you are already investigating: you are at the keyboard, you reproduce the problem, and in exchange you get a level of detail you will not find here. XHProf breaks the time down per function, with the tree of who calls whom. Native Observability times services and stops there.

A flight recorder, instead, was already running when the thing happened. It records less, and all of its value lies in not having had to predict the failure. It is the difference between asking "why is this page slow while I watch it" and "why did that request yesterday, the one I cannot reproduce, take six seconds".

Figures read from the project pages on drupal.org on 24 September 2026.

Tool Latest release Sites Security policy What it measures
webprofiler 11.2.3, 9 September 2026 1,164 covered one request at a time, from a toolbar on the page you are looking at
monitoring 8.x-1.22, 9 July 2026 2,595 covered the health of the site through sensors, not per-route latency
opentelemetry 1.0.0-beta7, 1 April 2026 765 no stable release exists request time and queries, exported to an external collector
xhprof 2.0.0-beta1, 12 March 2025 205 not covered cost per PHP function, needs an extension on the server
native_observability 2.0.1, 21 September 2026 12 covered traces, spans, per-route metrics, cache and queries, with export

Two rows in that table deserve attention and are rarely written down. The opentelemetry module, installed on 765 sites, has no stable release: its page states "There are currently no supported stable releases". The monitoring module, the most widely installed of the group at 2,595 sites, measures whether cron runs and whether updates are pending, which is health, a different question from which route is slow.

The judgement, kept apart from the data, and it reads as directions rather than as a comparison. If you are looking at the page while it is slow, open WebProfiler: the feedback is immediate and you have nothing to run. If you need to know which function is spending the time, XHProf or Blackfire answer that question and Native Observability does not. If you want index suggestions on your slow queries, db_performance does that. If you have several services and an infrastructure to correlate, an external stack is still the right call.

Native Observability answers a question the others do not take on: what happened to one specific request, already served, that you cannot reproduce. Every response carries an identifier in the X-Native-Observability-Request-Id header, and whoever hit the problem can hand that string to you. From there you reach the route, the spans and the slow queries of that request, not of a similar one.

The price belongs in the same breath. It records less than a profiler. There are thresholds and caps that throw data away, which is what staying always on costs. The per-request cost is real, and it is measured rather than estimated. It suggests no indexes. In exchange there is no external agent, no subscription, and the data stays in your own database.

Where the project stands today

Item Value
Stable release 2.0.1, 21 September 2026
Current branch 2.0.x. The 1.1.x branch takes fixes only
Sites reported on drupal.org 12
Open issues 2, both minor
Maintainer Giorgio Pagano, sole
Coverage drupal.org security advisory policy

Twelve installations are few, and I write it rather than leave it out. The two issues open on 24 September 2026 are a wrong version reference in the installation documentation and an automated Drupal 12 compatibility check raised by a bot: no functional bug, and no support request left unanswered.

The full documentation, twenty-seven pages of user guide and developer reference, is published at project.pages.drupalcode.org/native_observability and is generated from the module's own repository, so it follows the code instead of being a copy that ages somewhere else.

AI modified

This content was produced by AI and edited by a person.

How was AI used?

Drafts are produced with AI assistance and then directed, edited and fact checked by a person. Figures, dates and version numbers are verified against their public sources before publication, and the date of that check is stated in the text.