Which external service is slowing down this Drupal route?

Deep dive into the Native Observability project
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, and what the module costs in performance in the article on its overhead.

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

Native Observability sits at the one point every outgoing 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. 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.

A real case, told to The Drop Times (opens in a new tab): a cron job aggregated data for an API gateway, and after about ten requests the upstream endpoints slowed down without returning errors. The mobile apps ended up receiving data that was already old.

What the module does not do

Native Observability 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

An OpenTelemetry backend pays off 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

Measuring external calls takes 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). Submodules and requirements. Primary source.
  2. Module source (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. 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.
15 min 2,827 words

How do you let an AI agent read Drupal data without misreading it?

Native Observability started as a way to look inside Drupal with a dashboard of its own: route timings, cache and slow queries, measured by the site and read by a person on an admin page. Now an AI ag...

12 min 2,251 words

Why isn't this Drupal page cached?

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

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.