Skip to content

fix(drupal): hook the theme engine service and guarantee render hook removal - #4145

Draft
Leiyks wants to merge 1 commit into
masterfrom
leiyks/fix-apms-20395
Draft

fix(drupal): hook the theme engine service and guarantee render hook removal#4145
Leiyks wants to merge 1 commit into
masterfrom
leiyks/fix-apms-20395

Conversation

@Leiyks

@Leiyks Leiyks commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes APMS-20395.

On Drupal >= 11.3, drupal.template.file is never set on any drupal.theme.render span, and hooks accumulate on twig_render_template until DD_TRACE_HOOK_LIMIT is reached. A customer measured the tag present on 0 of 61,160 render spans over 2 days across 7 services — including services that never reached the hook limit.

Root cause

DrupalIntegration installs a hook on {$engine}_render_template from inside the prehook of trace_method('Drupal\Core\Theme\ThemeManager', 'render', ['recurse' => true]) — once per render, nested renders included — and the only remove_hook lives inside that hook's own callback. The now-deleted comment stated the invariant it relied on:

The render function will always be called during the ThemeManager::render call

That invariant broke in Drupal 11.3.0. ThemeManager::render() now prefers a theme_engine-tagged service (ThemeManager.php:377-380 @ 11.4.4):

if ($theme_engine_service = $this->getThemeEngine($theme_engine)) {
  $render_function = [$theme_engine_service, 'renderTemplate'];
  $extension = '';
}

while ThemeInitialization.php:157-158 still unconditionally include_onces twig.engine, which still defines the deprecated twig_render_template(). So function_exists() returns true, the hook installs on every render, the function is never called, the hook is never removed, and the tag is never set. function_exists() is useless as a version discriminator here — that is the bug.

Verified boundary (md5 / HTTP probes of upstream tags): ThemeManager.php is byte-identical 11.3.0 ↔ 11.4.4; TwigThemeEngine.php 404s through 11.2.14 and appears at 11.3.0. Deprecated in drupal:11.3.0, removed from drupal:12.0.0.

Drupal twig_render_template() defined called by core engine service
10.x, 11.0–11.2.x yes yes no
11.3.0 – 11.4.5 yes (still included) no yes
12.x (main) no (twig.engine deleted) n/a yes

A second defect, affecting every Drupal version

ThemeManager::render() has an early return FALSE at 11.4.4:178 (theme hook not found), where the render function is never reached and today's hook is never removed. This is not merely a leak — the leaked hooks fire on later renders and retroactively mis-tag unrelated spans. From the new regression test on master:

drupal.template.file[<missing>]           = 11   (expected 30)
drupal.template.file[page.html.twig]      = 49   (expected 30)

19 stale hooks re-tagged 19 unrelated spans. This affects Drupal <= 10.1 too, i.e. versions CI already covers.

Changes

  1. Install one hook at init() on Drupal\Core\Theme\ThemeEngineInterface::renderTemplate. Interface-targeted, so it covers Twig, contrib engines, and the $info['type'] == 'module' case (where core forces Twig) in a single install. Installed once, it cannot accumulate. On Drupal <= 11.2 the interface does not exist and the hook simply never resolves.

    Core passes the template path without its extension on the service path ($extension = ''; TwigThemeEngine::renderTemplate appends self::EXTENSION itself), so .html.twig is re-appended for Twig to keep the tag value byte-identical to what Drupal <= 10 emitted. On Drupal 12 the $extension variable is gone upstream entirely, so this stays correct.

  2. Take the legacy branch only when core itself would — when no engine service resolves — instead of probing function_exists().

  3. Remove the legacy hook unconditionally in the posthook. Self-removal inside the callback is kept, because it is load-bearing for correct outer/inner render pairing, but it can no longer be the only removal path.

Ids are held in a map keyed by spl_object_hash($span) rather than a positional stack: the posthook is skipped for a dropped span (tracer/hook/uhook_legacy.c:226) while the prehook still runs, which would permanently desync a stack. The prehook resets the entry before use, so a recycled object handle cannot resurface a stale id; the map is bounded (measured: 21 entries at both 60 and 400 dropped renders).

themeEngines->has() is used rather than getThemeEngine(), because the latter's ->get() instantiates the engine service — including on the early-return path where core never resolves it — and adds a throw surface to a hot prehook. has() is side-effect-free; core itself guards get() behind it.

Notes for reviewers

  • The span-limit guard is load-bearing. install_hook's begin callback is not gated by the span limit (dd_uhook_begin, uhook.c:311-324) while trace_method is (uhook_legacy.c:102). Without dd_trace_tracer_is_limited() in the guard, a nested render past the limit has no span of its own, active_span() returns the still-open outer render span, and the outer span gets tagged with the nested template — turning "tag missing" into "tag confidently wrong". This was caught in review and is covered by theme_engine_service_span_limit.phpt.
  • isset($span->meta['drupal.template.file']) (first-write-wins) was tried and rejected: it breaks the preprocess ordering, where core renders sub-elements at ThemeManager.php:366 before the outer template at :428. Correctness depends on the render frame, not write order. An exact fix via depth tracking was deferred — it roughly doubles per-render hook invocations on a 100+/request path.
  • To be precise about the guard's cost: the wrong tag only ever appeared in the nested-from-inside-renderTemplate ordering. In the preprocess ordering the unguarded code was already correct, so the guard trades a small precision loss there (a correct value becomes <missing> at the limit boundary) for never being wrong.
  • In theme_render_early_return.phpt the "hook budget left" probe does not discriminate — master also prints yes, because leaked hooks self-remove once they finally fire. What catches that defect is the count skew above.
  • Net cost on the hot path is lower than master on 11.3+: this removes an install_hook per render and adds two spl_object_hash calls, two array ops, and an O(1) membership test.

Known limitations

  • No test against a real Drupal 11.3+ application — the 11.3 path is modelled with fakes plus verbatim drupal/core@11.4.4 source. Adding a Version_11_4 fixture (~50k vendored files) is deferred.
  • Pre-existing and unchanged: on 11.3+ the deprecated twig_render_template() does not delegate to TwigThemeEngine::renderTemplate; it duplicates the logic inline via \Drupal::service('twig'). Contrib calling it directly on 11.3+ is tagged by neither hook.
  • Pre-existing and unchanged: the integration installs on {$engine}_render_template only and does not model core's twig_render_template fallback at ThemeManager.php:384-386.

Testing

Four .phpt under tests/ext/integrations/drupal/, chosen over the PHPUnit harness because the latter needs mysql plus a ~50k-file vendored fixture per version and only covers Drupal <= 10.1, so it cannot express the 11.3+ shape at all.

Test Covers
theme_engine_service.phpt 11.3+ service path; engine class autoloaded mid-request (asserts class_exists(..., false) === false before use)
theme_engine_service_span_limit.phpt nested render past the span limit — the wrong-tag regression
theme_engine_service_preprocess.phpt preprocess ordering; pins last-write-wins so the rejected isset() approach cannot be reintroduced
theme_render_early_return.phpt return FALSE path and exception unwinding, version-independent

Each defect test fails on the code it targets and passes with the fix. All four are also clean under valgrind (Tests leaked: 0).

Guards against false-green: DD_TRACE_LOG_LEVEL=warn (the tracer sandbox otherwise swallows prehook exceptions — an early draft passed for exactly that reason), drupal.render.engine count assertions, and hook-budget probes naming the actual target. The budget probe was validated by re-introducing the regression it is meant to catch and confirming the test goes red.

Existing snapshots: test_web_drupal_101 and test_web_drupal_95 were run and A/B'd against origin/master — byte-identical results, with testScenarioGetWithView (the 12 tagged render spans) passing in all runs. test_web_drupal_89 was not run: the app does not boot on PHP 8.3 and CI runs it on 7.2–7.4 only.

phpcs on the changed file: same 6 pre-existing findings, zero new.

…removal

Drupal 11.3 moved template rendering to a theme_engine service
(ThemeEngineInterface::renderTemplate), and ThemeManager::render() only falls
back to the deprecated {engine}_render_template() global when no such service
resolves. Core still includes twig.engine, so function_exists() stays true
while the function is never called: the integration installed a hook per
render that never fired and never self-removed, so drupal.template.file was
missing on every drupal.theme.render span and datadog.trace.hook_limit was
reached within a single request.

Hook ThemeEngineInterface::renderTemplate once at init() to cover every engine
implementation, re-appending .html.twig for TwigThemeEngine so the tag keeps
its pre-11.3 value, and take the legacy per-render branch only when no engine
service resolves. The membership test goes through the themeEngines service
collection rather than getThemeEngine(), which would instantiate the engine
even on core's early-return path.

install_hook's callback is not gated by the span limit while trace_method is,
so past the limit a nested render has no span of its own and active_span()
returns the outer render's. Bail out when the tracer is limited rather than
tag that span with the nested template.

Independently, ThemeManager::render() can return without ever calling the
render function (unknown theme hook, exception) on every Drupal version, so
the callback's self-removal is no longer the only removal path: the posthook
now removes the id the prehook recorded for that span. Keyed by span rather
than a stack because the posthook is skipped for a dropped span.

APMS-20395
@datadog-official

datadog-official Bot commented Aug 27, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 7 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-php | test_extension_ci: [8.5] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-php | test_metrics: [8.5, cli-server] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-php | ASAN test_c: [8.0, arm64]

View more details · View in GitLab

View all 7 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog auto-retried 1 job - 1 passed on retry View in Datadog

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 60.63% (+0.00%)

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 6ca1f71 | Docs | View more details | Give us feedback!

@pr-commenter

pr-commenter Bot commented Aug 27, 2026

Copy link
Copy Markdown

Benchmarks [ tracer ]

Benchmark execution time: 2026-08-27 15:27:14

Comparing candidate commit 6ca1f71 in PR branch leiyks/fix-apms-20395 with baseline commit 57dcf90 in branch master.

Found 1 performance improvements and 0 performance regressions! Performance is the same for 192 metrics, 1 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:TraceFlushBench/benchFlushTrace

  • 🟩 execution_time [-17.592µs; -8.508µs] or [-5.160%; -2.495%]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant