diff --git a/src/DDTrace/Integrations/Drupal/DrupalIntegration.php b/src/DDTrace/Integrations/Drupal/DrupalIntegration.php index 27cd7cffb05..358e09bb24d 100644 --- a/src/DDTrace/Integrations/Drupal/DrupalIntegration.php +++ b/src/DDTrace/Integrations/Drupal/DrupalIntegration.php @@ -228,115 +228,159 @@ static function ($registry, $scope, $args) { } ); - trace_method( - 'Drupal\Core\Theme\ThemeManager', - 'render', - [ - 'recurse' => true, - 'prehook' => function (SpanData $span, $args) { - $span->name = 'drupal.theme.render'; - $span->service = \ddtrace_config_app_name('drupal'); - Integration::tagFrameworkServiceSource($span, 'drupal'); - $span->type = Type::WEB_SERVLET; - $span->meta[Tag::COMPONENT] = DrupalIntegration::NAME; + // The span of the executing ThemeManager::render frame. The render function is hooked in its + // own frame, so it cannot reach that span on its own. + $renderSpan = null; + $legacyRenderHooks = []; + + $tagTemplateFile = static function ($file) use (&$renderSpan) { + if ($renderSpan) { + $renderSpan->meta['drupal.template.file'] = $file; + } + }; - /** @var \Drupal\Core\Theme\ThemeManager $this */ - $activeTheme = $this->getActiveTheme(); - $themeName = $activeTheme->getName(); - $themeEngine = $activeTheme->getEngine(); + $tagLegacyRender = static function (HookData $renderHook) use ($tagTemplateFile) { + $tagTemplateFile($renderHook->args[0]); + }; + // Twig is both the default engine and core's fallback when {engine}_render_template() is + // missing, and the deprecated global does not delegate to the 11.3+ service. + $legacyRenderHooks['twig'] = install_hook('twig_render_template', $tagLegacyRender); - if (!empty($themeName)) { - $span->meta['drupal.render.theme'] = $themeName; - } + // Drupal 11.3+ renders through a theme_engine service instead of {engine}_render_template(). + install_hook( + 'Drupal\Core\Theme\ThemeEngineInterface::renderTemplate', + static function (HookData $hook) use ($tagTemplateFile) { + $file = $hook->args[0]; + // Core passes the path without its extension here; re-append it to keep the pre-11.3 value. + if (isset($hook->instance) && $hook->instance instanceof \Drupal\Core\Template\TwigThemeEngine) { + $file .= '.html.twig'; + } + $tagTemplateFile($file); + } + ); - if (!empty($themeEngine)) { - $span->meta['drupal.render.engine'] = $themeEngine; - if (function_exists("{$themeEngine}_render_template")) { - $renderFunction = "{$themeEngine}_render_template"; + install_hook( + 'Drupal\Core\Theme\ThemeManager::render', + function (HookData $hookData) use (&$renderSpan, &$legacyRenderHooks, $tagLegacyRender) { + // install_hook, unlike trace_method, still runs both callbacks past the span limit; + // clearing $renderSpan keeps this frame's template off the enclosing render's span. + if (\dd_trace_tracer_is_limited()) { + $hookData->data = $renderSpan; + $renderSpan = null; + return; + } - // The theme engine may use a different extension and a different renderer - // Moreover, Drupal can use different themes in the same application - // The render function will always be called during the ThemeManager::render call - install_hook( - $renderFunction, - static function (HookData $hook) use ($span) { - $span->meta['drupal.template.file'] = $hook->args[0]; - remove_hook($hook->id); - } - ); + $span = $hookData->span(); + $hookData->data = $renderSpan; + $renderSpan = $span; + + $span->name = 'drupal.theme.render'; + $span->service = \ddtrace_config_app_name('drupal'); + Integration::tagFrameworkServiceSource($span, 'drupal'); + $span->type = Type::WEB_SERVLET; + $span->meta[Tag::COMPONENT] = DrupalIntegration::NAME; + + /** @var \Drupal\Core\Theme\ThemeManager $this */ + $activeTheme = $this->getActiveTheme(); + $themeName = $activeTheme->getName(); + $themeEngine = $activeTheme->getEngine(); + + if (!empty($themeName)) { + $span->meta['drupal.render.theme'] = $themeName; + } + + if (empty($themeEngine)) { + return; + } + $span->meta['drupal.render.engine'] = $themeEngine; + + // Drupal <= 11.2 renders through the {engine}_render_template() global. One hook per + // engine name for the whole request; $renderSpan does the attribution. + if (!isset($legacyRenderHooks[$themeEngine])) { + $legacyRenderHooks[$themeEngine] = + install_hook("{$themeEngine}_render_template", $tagLegacyRender); + } + }, + function (HookData $hookData) use (&$renderSpan) { + // $renderSpan is this frame's span, or null when the begin callback bailed out + // past the span limit. $hookData->data holds the enclosing render's span. + $span = $renderSpan; + $renderSpan = $hookData->data; + if (!$span) { + return; + } + + /** @var null|\Drupal\Core\Theme\Registry $themeRegistry */ + $themeRegistry = ObjectKVStore::get($this, 'theme_registry'); + if (!$themeRegistry) { + return; + } + + $runtimeThemeRegistry = $themeRegistry->getRuntime(); + $hook = $hookData->args[0]; + + if (is_array($hook)) { + foreach ($hook as $candidate) { + if ($runtimeThemeRegistry->has($candidate)) { + break; } } - }, - 'posthook' => function (SpanData $span, $args) { - /** @var null|\Drupal\Core\Theme\Registry $themeRegistry */ - $themeRegistry = ObjectKVStore::get($this, 'theme_registry'); - if ($themeRegistry) { - $runtimeThemeRegistry = $themeRegistry->getRuntime(); - $hook = $args[0]; - - if (is_array($hook)) { - foreach ($hook as $candidate) { - if ($runtimeThemeRegistry->has($candidate)) { - break; - } - } - $hook = $candidate; - } + $hook = $candidate; + } - $originalHook = $hook; + $originalHook = $hook; - if (!$runtimeThemeRegistry->has($hook)) { - // Iteratively strip everything after the last '__' delimiter, until an - // implementation is found - while ($pos = strrpos($hook, '__')) { - $hook = substr($hook, 0, $pos); - if ($runtimeThemeRegistry->has($hook)) { - break; - } - } + if (!$runtimeThemeRegistry->has($hook)) { + // Iteratively strip everything after the last '__' delimiter, until an + // implementation is found + while ($pos = strrpos($hook, '__')) { + $hook = substr($hook, 0, $pos); + if ($runtimeThemeRegistry->has($hook)) { + break; } + } + } - if ($runtimeThemeRegistry->has($hook)) { - $span->meta['drupal.render.hook'] = $span->resource = $hook; - $info = $runtimeThemeRegistry->get($hook); + if (!$runtimeThemeRegistry->has($hook)) { + $span->meta['drupal.render.hook'] = $span->resource = $originalHook; + return; + } - if (isset($info['base hook'])) { - $span->meta['drupal.render.base_hook'] = $info['base hook']; - } + $span->meta['drupal.render.hook'] = $span->resource = $hook; + $info = $runtimeThemeRegistry->get($hook); - if (isset($info['type'])) { - $span->meta['drupal.render.type'] = $info['type']; - } + if (isset($info['base hook'])) { + $span->meta['drupal.render.base_hook'] = $info['base hook']; + } - if (isset($info['render element'])) { - $span->meta['drupal.render.element'] = $info['render element']; - } + if (isset($info['type'])) { + $span->meta['drupal.render.type'] = $info['type']; + } - if (isset($info['template'])) { - $span->meta['drupal.template.template'] = $info['template']; - } + if (isset($info['render element'])) { + $span->meta['drupal.render.element'] = $info['render element']; + } - if (isset($info['function'])) { - $span->meta['drupal.render.theme_function'] = $info['function']; - } + if (isset($info['template'])) { + $span->meta['drupal.template.template'] = $info['template']; + } - if (isset($info['path'])) { - // The template can be from a different theme than the active one - // Format: '.../themes//...' - $path = $info['path']; - $themePathStart = strpos($path, '/themes/'); - if ($themePathStart !== false) { - $themePath = substr($path, $themePathStart + 8); // Between '/themes/', 8 = strlen('/themes/') - $themePath = substr($themePath, 0, strpos($themePath, '/')); // Until the next '/' - $span->meta['drupal.template.theme'] = $themePath; - } - } - } else { - $span->meta['drupal.render.hook'] = $span->resource = $originalHook; - } + if (isset($info['function'])) { + $span->meta['drupal.render.theme_function'] = $info['function']; + } + + if (isset($info['path'])) { + // The template can be from a different theme than the active one + // Format: '.../themes//...' + $path = $info['path']; + $themePathStart = strpos($path, '/themes/'); + if ($themePathStart !== false) { + $themePath = substr($path, $themePathStart + 8); // Between '/themes/', 8 = strlen('/themes/') + $themePath = substr($themePath, 0, strpos($themePath, '/')); // Until the next '/' + $span->meta['drupal.template.theme'] = $themePath; } } - ] + } ); hook_method( diff --git a/tests/ext/integrations/drupal/drupal_integration.inc b/tests/ext/integrations/drupal/drupal_integration.inc new file mode 100644 index 00000000000..e065e051666 --- /dev/null +++ b/tests/ext/integrations/drupal/drupal_integration.inc @@ -0,0 +1,65 @@ +'; + $tags[$file] = (isset($tags[$file]) ? $tags[$file] : 0) + 1; + // Also reported so that a begin hook exception swallowed by the sandbox cannot pass silently. + $engine = isset($span['meta']['drupal.render.engine']) ? $span['meta']['drupal.render.engine'] : ''; + $engines[$engine] = (isset($engines[$engine]) ? $engines[$engine] : 0) + 1; + } + ksort($tags); + ksort($engines); + foreach ($engines as $engine => $count) { + echo "drupal.render.engine[$engine] = $count\n"; + } + foreach ($tags as $file => $count) { + echo "drupal.template.file[$file] = $count\n"; + } + + // A leaked per-render hook shows up as an exhausted budget on its target. + foreach ($probeTargets as $target) { + $probe = DDTrace\install_hook($target, function () { + }); + echo "hook budget left [$target]: " . ($probe ? "yes" : "no") . "\n"; + if ($probe) { + DDTrace\remove_hook($probe); + } + } +} diff --git a/tests/ext/integrations/drupal/drupal_root.inc b/tests/ext/integrations/drupal/drupal_root.inc new file mode 100644 index 00000000000..190ad2188d3 --- /dev/null +++ b/tests/ext/integrations/drupal/drupal_root.inc @@ -0,0 +1,15 @@ +nested) { + $nested = $this->nested; + $this->nested = null; + $rendered .= $nested(); + } + return $rendered; + } +} diff --git a/tests/ext/integrations/drupal/theme_engine_service.phpt b/tests/ext/integrations/drupal/theme_engine_service.phpt new file mode 100644 index 00000000000..af5d743d3c8 --- /dev/null +++ b/tests/ext/integrations/drupal/theme_engine_service.phpt @@ -0,0 +1,118 @@ +--TEST-- +Drupal 11.3+ renders through the theme engine service (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +engines = $engines; + } + + public function has($name) + { + return isset($this->engines[$name]); + } + + public function get($name) + { + return $this->engines[$name]; + } + } + + class ThemeManager + { + // Protected upstream, so the prehook only reaches it when rebound to this scope. + protected $themeEngines; + + public function __construct(ThemeEngineCollection $themeEngines) + { + $this->themeEngines = $themeEngines; + } + + public function getActiveTheme() + { + return new ActiveTheme(); + } + + public function render($hook, array $variables = []) + { + $render_function = [$this->themeEngines->get('twig'), 'renderTemplate']; + return $render_function("core/themes/claro/templates/$hook", $variables); + } + } +} + +namespace +{ + // twig.engine is still included on 11.3+, so the deprecated global exists but is dead. + function twig_render_template($template_file, array $variables) + { + return "legacy $template_file"; + } + + include __DIR__ . '/drupal_integration.inc'; + + DDTrace\Integrations\Drupal\DrupalIntegration::init(); + + // Only touched after init(), so the engine class is autoloaded mid-request. + var_dump(class_exists('Drupal\Core\Template\TwigThemeEngine', false)); + $engines = new Drupal\Core\Theme\ThemeEngineCollection([ + 'twig' => new Drupal\Core\Template\TwigThemeEngine(), + ]); + + $themeManager = new Drupal\Core\Theme\ThemeManager($engines); + for ($i = 0; $i < 30; ++$i) { + $themeManager->render('page'); + } + + dd_drupal_render_report(dd_trace_serialize_closed_spans(), [ + 'Drupal\Core\Template\TwigThemeEngine::renderTemplate', + 'twig_render_template', + ]); +} +?> +--EXPECT-- +bool(false) +drupal.render.engine[twig] = 30 +drupal.template.file[core/themes/claro/templates/page.html.twig] = 30 +hook budget left [Drupal\Core\Template\TwigThemeEngine::renderTemplate]: yes +hook budget left [twig_render_template]: yes diff --git a/tests/ext/integrations/drupal/theme_engine_service_dropped_nested_span.phpt b/tests/ext/integrations/drupal/theme_engine_service_dropped_nested_span.phpt new file mode 100644 index 00000000000..f4024575b61 --- /dev/null +++ b/tests/ext/integrations/drupal/theme_engine_service_dropped_nested_span.phpt @@ -0,0 +1,127 @@ +--TEST-- +Drupal render span is not tagged with a dropped nested render's template, engine service (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +engines = $engines; + } + + public function has($name) + { + return isset($this->engines[$name]); + } + + public function get($name) + { + return $this->engines[$name]; + } + } + + class ThemeManager + { + protected $themeEngines; + + public function __construct(ThemeEngineCollection $themeEngines) + { + $this->themeEngines = $themeEngines; + } + + public function getActiveTheme() + { + return new ActiveTheme(); + } + + public function render($hook, array $variables = []) + { + if ($hook === 'block') { + // Dropped after the begin hook ran, so the interface hook still fires, against a + // dropped span rather than the surviving outer one. + \DDTrace\try_drop_span(\DDTrace\active_span()); + } + + $render_function = [$this->themeEngines->get('twig'), 'renderTemplate']; + return $render_function("core/themes/claro/templates/$hook", $variables); + } + } +} + +namespace +{ + include __DIR__ . '/drupal_integration.inc'; + + DDTrace\Integrations\Drupal\DrupalIntegration::init(); + + $engine = new Drupal\Core\Template\TwigThemeEngine(); + $engines = new Drupal\Core\Theme\ThemeEngineCollection(['twig' => $engine]); + $themeManager = new Drupal\Core\Theme\ThemeManager($engines); + + // An outer span is required: dropping a stack's root span closes it instead. + $root = DDTrace\start_span(); + $root->name = 'test.root'; + + // Twig renders an embedded theme hook from inside the outer template. + $engine->nested = function () use ($themeManager) { + return $themeManager->render('block'); + }; + + $themeManager->render('page'); + + DDTrace\close_span(); + + $spans = dd_trace_serialize_closed_spans(); + foreach ($spans as $span) { + if ($span['name'] !== 'drupal.theme.render') { + continue; + } + echo "surviving render span template.file = ", + isset($span['meta']['drupal.template.file']) ? $span['meta']['drupal.template.file'] : '', + "\n"; + } + dd_drupal_render_report($spans, ['Drupal\Core\Template\TwigThemeEngine::renderTemplate']); +} +?> +--EXPECT-- +surviving render span template.file = core/themes/claro/templates/page.html.twig +drupal.render.engine[twig] = 1 +drupal.template.file[core/themes/claro/templates/page.html.twig] = 1 +hook budget left [Drupal\Core\Template\TwigThemeEngine::renderTemplate]: yes diff --git a/tests/ext/integrations/drupal/theme_engine_service_preprocess.phpt b/tests/ext/integrations/drupal/theme_engine_service_preprocess.phpt new file mode 100644 index 00000000000..446d5414927 --- /dev/null +++ b/tests/ext/integrations/drupal/theme_engine_service_preprocess.phpt @@ -0,0 +1,111 @@ +--TEST-- +Drupal render spans keep their own template when a sub-element renders first (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +engines = $engines; + } + + public function has($name) + { + return isset($this->engines[$name]); + } + + public function get($name) + { + return $this->engines[$name]; + } + } + + class ThemeManager + { + protected $themeEngines; + + public function __construct(ThemeEngineCollection $themeEngines) + { + $this->themeEngines = $themeEngines; + } + + public function getActiveTheme() + { + return new ActiveTheme(); + } + + public function render($hook, array $variables = []) + { + // A preprocess renders a sub-element before the outer template (ThemeManager.php:366 + // vs :428), so the child's renderTemplate() fires first: the tag cannot be first-write-wins. + if ($hook === 'page') { + $this->render('child'); + } + $render_function = [$this->themeEngines->get('twig'), 'renderTemplate']; + return $render_function("core/themes/claro/templates/$hook", $variables); + } + } +} + +namespace +{ + include __DIR__ . '/drupal_integration.inc'; + + DDTrace\Integrations\Drupal\DrupalIntegration::init(); + + $engines = new Drupal\Core\Theme\ThemeEngineCollection([ + 'twig' => new Drupal\Core\Template\TwigThemeEngine(), + ]); + $themeManager = new Drupal\Core\Theme\ThemeManager($engines); + + for ($i = 0; $i < 10; ++$i) { + $themeManager->render('page'); + } + + dd_drupal_render_report(dd_trace_serialize_closed_spans(), [ + 'Drupal\Core\Template\TwigThemeEngine::renderTemplate', + ]); +} +?> +--EXPECT-- +drupal.render.engine[twig] = 20 +drupal.template.file[core/themes/claro/templates/child.html.twig] = 10 +drupal.template.file[core/themes/claro/templates/page.html.twig] = 10 +hook budget left [Drupal\Core\Template\TwigThemeEngine::renderTemplate]: yes diff --git a/tests/ext/integrations/drupal/theme_engine_service_span_limit.phpt b/tests/ext/integrations/drupal/theme_engine_service_span_limit.phpt new file mode 100644 index 00000000000..6003425f772 --- /dev/null +++ b/tests/ext/integrations/drupal/theme_engine_service_span_limit.phpt @@ -0,0 +1,118 @@ +--TEST-- +Drupal render span is not tagged with a nested template past the span limit (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +engines = $engines; + } + + public function has($name) + { + return isset($this->engines[$name]); + } + + public function get($name) + { + return $this->engines[$name]; + } + } + + class ThemeManager + { + protected $themeEngines; + + public function __construct(ThemeEngineCollection $themeEngines) + { + $this->themeEngines = $themeEngines; + } + + public function getActiveTheme() + { + return new ActiveTheme(); + } + + public function render($hook, array $variables = []) + { + $render_function = [$this->themeEngines->get('twig'), 'renderTemplate']; + return $render_function("core/themes/claro/templates/$hook", $variables); + } + } +} + +namespace +{ + include __DIR__ . '/drupal_integration.inc'; + + DDTrace\Integrations\Drupal\DrupalIntegration::init(); + + $engine = new Drupal\Core\Template\TwigThemeEngine(); + $engines = new Drupal\Core\Theme\ThemeEngineCollection(['twig' => $engine]); + $themeManager = new Drupal\Core\Theme\ThemeManager($engines); + + // Twig renders an embedded theme hook, i.e. a nested render during the outer renderTemplate(). + $engine->nested = function () use ($themeManager) { + return $themeManager->render('block'); + }; + + // Room for exactly one more span: the outer render gets a real one, the nested render only a + // dummy that is never pushed, so it must not reach the outer's tag. init() forces >= 1500. + ini_set('datadog.trace.spans_limit', 2); + DDTrace\start_span(); + DDTrace\close_span(); + + $rendered = $themeManager->render('page'); + + // Proves the nested render really ran, so the tag assertion below cannot pass vacuously. + echo "nested rendered: ", var_export(strpos($rendered, 'block.html.twig') !== false, true), "\n"; + echo "limited: ", var_export((bool) dd_trace_tracer_is_limited(), true), "\n"; + dd_drupal_render_report(dd_trace_serialize_closed_spans(), [ + 'Drupal\Core\Template\TwigThemeEngine::renderTemplate', + ]); +} +?> +--EXPECT-- +nested rendered: true +limited: true +drupal.render.engine[twig] = 1 +drupal.template.file[core/themes/claro/templates/page.html.twig] = 1 +hook budget left [Drupal\Core\Template\TwigThemeEngine::renderTemplate]: yes diff --git a/tests/ext/integrations/drupal/theme_render_dropped_nested_span.phpt b/tests/ext/integrations/drupal/theme_render_dropped_nested_span.phpt new file mode 100644 index 00000000000..21323d191d0 --- /dev/null +++ b/tests/ext/integrations/drupal/theme_render_dropped_nested_span.phpt @@ -0,0 +1,99 @@ +--TEST-- +Drupal legacy render span is not tagged with a dropped nested render's template (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +name = 'test.root'; + + $themeManager = new Drupal\Core\Theme\ThemeManager(); + $themeManager->render('page', ['nested' => function () use ($themeManager) { + return $themeManager->render('block'); + }]); + + DDTrace\close_span(); + + $spans = dd_trace_serialize_closed_spans(); + foreach ($spans as $span) { + if ($span['name'] !== 'drupal.theme.render') { + continue; + } + echo "surviving render span template.file = ", + isset($span['meta']['drupal.template.file']) ? $span['meta']['drupal.template.file'] : '', + "\n"; + } + dd_drupal_render_report($spans, ['twig_render_template']); +} +?> +--EXPECT-- +surviving render span template.file = core/themes/olivero/templates/page.html.twig +drupal.render.engine[twig] = 1 +drupal.template.file[core/themes/olivero/templates/page.html.twig] = 1 +hook budget left [twig_render_template]: yes diff --git a/tests/ext/integrations/drupal/theme_render_dropped_span.phpt b/tests/ext/integrations/drupal/theme_render_dropped_span.phpt new file mode 100644 index 00000000000..a2c4a53587b --- /dev/null +++ b/tests/ext/integrations/drupal/theme_render_dropped_span.phpt @@ -0,0 +1,100 @@ +--TEST-- +Drupal render tagging survives a dropped render span (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +name = 'test.root'; + + $themeManager = new Drupal\Core\Theme\ThemeManager(); + for ($i = 0; $i < 30; ++$i) { + $themeManager->render('dropped'); + } + // Were a hook leaked above, the budget would be exhausted and this render left untagged. + $themeManager->render('page'); + + DDTrace\close_span(); + + $spans = dd_trace_serialize_closed_spans(); + $names = []; + foreach ($spans as $span) { + $names[$span['name']] = (isset($names[$span['name']]) ? $names[$span['name']] : 0) + 1; + } + ksort($names); + foreach ($names as $name => $count) { + echo "span[$name] = $count\n"; + } + + dd_drupal_render_report($spans, ['twig_render_template']); +} +?> +--EXPECT-- +span[drupal.theme.render] = 1 +span[test.root] = 1 +drupal.render.engine[twig] = 1 +drupal.template.file[core/themes/olivero/templates/page.html.twig] = 1 +hook budget left [twig_render_template]: yes diff --git a/tests/ext/integrations/drupal/theme_render_early_return.phpt b/tests/ext/integrations/drupal/theme_render_early_return.phpt new file mode 100644 index 00000000000..0812c00357a --- /dev/null +++ b/tests/ext/integrations/drupal/theme_render_early_return.phpt @@ -0,0 +1,90 @@ +--TEST-- +Drupal renders that never call the render function do not mis-tag later ones (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +render('block'); + } + return $rendered; + } + } +} + +namespace +{ + function twig_render_template($template_file, array $variables) + { + return "rendered $template_file"; + } + + include __DIR__ . '/drupal_integration.inc'; + + DDTrace\Integrations\Drupal\DrupalIntegration::init(); + + $themeManager = new Drupal\Core\Theme\ThemeManager(); + // Renders that never reach the render function must not exhaust the hook budget... + for ($i = 0; $i < 30; ++$i) { + $themeManager->render('missing'); + } + // ...nor leave a hook behind that later mis-tags an unrelated render. + for ($i = 0; $i < 30; ++$i) { + $themeManager->render('page'); + } + + // Only the legacy global is installed on this shape, so it is the only budget to probe. + dd_drupal_render_report(dd_trace_serialize_closed_spans(), ['twig_render_template']); +} +?> +--EXPECT-- +drupal.render.engine[twig] = 90 +drupal.template.file[] = 30 +drupal.template.file[core/themes/olivero/templates/block.html.twig] = 30 +drupal.template.file[core/themes/olivero/templates/page.html.twig] = 30 +hook budget left [twig_render_template]: yes diff --git a/tests/ext/integrations/drupal/theme_render_engine_fallback.phpt b/tests/ext/integrations/drupal/theme_render_engine_fallback.phpt new file mode 100644 index 00000000000..1aa43913dff --- /dev/null +++ b/tests/ext/integrations/drupal/theme_render_engine_fallback.phpt @@ -0,0 +1,79 @@ +--TEST-- +Drupal render span is tagged when core falls back to twig's render function (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- +render('fallback'); + } + + dd_drupal_render_report(dd_trace_serialize_closed_spans(), [ + 'twig_render_template', + 'phptemplate_render_template', + ]); +} +?> +--EXPECT-- +drupal.render.engine[phptemplate] = 30 +drupal.template.file[core/themes/claro/templates/fallback.html.twig] = 30 +hook budget left [twig_render_template]: yes +hook budget left [phptemplate_render_template]: yes diff --git a/tests/ext/integrations/drupal/theme_render_span_limit.phpt b/tests/ext/integrations/drupal/theme_render_span_limit.phpt new file mode 100644 index 00000000000..dae38375260 --- /dev/null +++ b/tests/ext/integrations/drupal/theme_render_span_limit.phpt @@ -0,0 +1,90 @@ +--TEST-- +Drupal legacy render span is not tagged with a nested template past the span limit (APMS-20395) +--SKIPIF-- + +--ENV-- +DD_TRACE_AUTO_FLUSH_ENABLED=0 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 +DD_TRACE_LOG_LEVEL=warn,startup=off +--INI-- +datadog.trace.hook_limit=20 +--FILE-- += 1500. + ini_set('datadog.trace.spans_limit', 2); + DDTrace\start_span(); + DDTrace\close_span(); + + $rendered = $themeManager->render('page', ['nested' => function () use ($themeManager) { + return $themeManager->render('block'); + }]); + + // Proves the nested render really ran, so the tag assertion below cannot pass vacuously. + echo "nested rendered: ", var_export(strpos($rendered, 'block.html.twig') !== false, true), "\n"; + echo "limited: ", var_export((bool) dd_trace_tracer_is_limited(), true), "\n"; + dd_drupal_render_report(dd_trace_serialize_closed_spans(), ['twig_render_template']); +} +?> +--EXPECT-- +nested rendered: true +limited: true +drupal.render.engine[twig] = 1 +drupal.template.file[core/themes/olivero/templates/page.html.twig] = 1 +hook budget left [twig_render_template]: yes