Drupal Analytics Implementation Guide | OpsBlu Docs

Drupal Analytics Implementation Guide

Complete guide to implementing analytics on Drupal sites using contrib modules, Twig templates, hook_page_attachments, and Drupal Commerce data layers.

Analytics Architecture on Drupal

Drupal's analytics architecture centers on three mechanisms: contrib modules, the hook system, and Twig template rendering. Understanding how these interact determines whether your tracking fires correctly, in the right order, and with the data you expect.

Contrib modules are the primary method for adding analytics to Drupal. The google_tag module (formerly google_analytics) handles GTM container injection. The dataLayer module exposes page-level metadata to the window.dataLayer array before GTM loads. These modules use Drupal's render pipeline, meaning they respect caching layers, access controls, and asset aggregation settings.

hook_page_attachments is the PHP hook that lets custom modules inject JavaScript into the <head> of every page. This fires during Drupal's page build phase, before Twig renders the template. Scripts added here go through Drupal's asset library system, which means they are aggregated and cached alongside core JavaScript unless you explicitly mark them as external.

Twig templates control the final HTML output. Drupal 8+ uses Twig exclusively. The html.html.twig template contains the outer <html> and <head> structure, while page.html.twig handles the page body. Injecting scripts directly in Twig bypasses Drupal's library system, which means no aggregation, no cache busting, and no dependency management. This approach is occasionally necessary but should be avoided when a module-based method exists.

Drupal's caching is aggressive by default. Page Cache (for anonymous users) and Dynamic Page Cache (for authenticated users) will serve stale HTML unless cache tags and contexts are configured correctly. If your data layer includes user-specific or page-specific values, the cache must be aware of those variations or you will see incorrect data in your analytics.

BigPipe and Lazy Builders further complicate script execution order. BigPipe streams page content in chunks, which means scripts attached via #attached may execute before the full DOM is available. Plan your GTM trigger timing accordingly.

Installing Tracking Scripts

Via the google_tag Module

The google_tag module is the recommended method for injecting GTM containers. Install it with Composer:

composer require drupal/google_tag
drush en google_tag -y
drush cr

Configure the container at /admin/config/services/google-tag. Enter your GTM container ID (e.g., GTM-XXXXXX). The module supports multiple containers, each with visibility conditions based on path, role, or response status.

The module injects the GTM snippet into <head> and the <noscript> fallback after <body>. It respects Drupal's caching system and sets the correct cache tags so that changes to configuration invalidate cached pages.

Via hook_page_attachments (Custom Module)

For direct GA4 or other script injection without GTM, create a custom module. In your module's .module file:

<?php

/**
 * Implements hook_page_attachments().
 */
function mysite_analytics_page_attachments(array &$attachments) {
  $attachments['#attached']['html_head'][] = [
    [
      '#type' => 'html_tag',
      '#tag' => 'script',
      '#attributes' => [
        'async' => TRUE,
        'src' => 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX',
      ],
    ],
    'ga4_script_tag',
  ];

  $attachments['#attached']['html_head'][] = [
    [
      '#type' => 'html_tag',
      '#tag' => 'script',
      '#value' => "window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');",
    ],
    'ga4_config',
  ];
}

Define the module in mysite_analytics.info.yml:

name: MySite Analytics
type: module
description: 'Injects analytics scripts via hook_page_attachments.'
core_version_requirement: ^10 || ^11
package: Custom

Enable with drush en mysite_analytics -y && drush cr.

Via Twig Template (Last Resort)

Edit html.html.twig in your theme's templates/ directory. Place the script immediately after <head>:

<head>
  <!-- GTM container -->
  <script>
    (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
    new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
    j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
    j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
    f.parentNode.insertBefore(j,f);
    })(window,document,'script','dataLayer','GTM-XXXXXX');
  </script>
  <title>{{ head_title|safe_join(' | ') }}</title>
  <css-placeholder token="{{ placeholder_token }}">
  <js-placeholder token="{{ placeholder_token }}">
  {{ page_top }}
</head>

This bypasses Drupal's asset pipeline entirely. You lose aggregation, cache metadata, and configuration management through the admin UI.

Data Layer Setup

Using the dataLayer Contrib Module

The dataLayer module pushes page metadata to window.dataLayer before GTM loads. Install it:

composer require drupal/datalayer
drush en datalayer -y
drush cr

Configure at /admin/config/search/datalayer. Enable entity metadata for content types, taxonomy terms, and user data. The module outputs structured data like this on every page:

window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  "drupalLanguage": "en",
  "drupalCountry": "US",
  "entityType": "node",
  "entityBundle": "article",
  "entityId": "42",
  "entityLabel": "Getting Started with Analytics",
  "entityTaxonomy": {
    "tags": ["analytics", "implementation"]
  },
  "userUid": "0",
  "userRoles": ["anonymous"]
});

Custom Data Layer via Preprocess Functions

For data the dataLayer module does not expose, use a preprocess function in your theme's .theme file:

<?php

/**
 * Implements hook_preprocess_html().
 */
function mytheme_preprocess_html(array &$variables) {
  $route_match = \Drupal::routeMatch();
  $node = $route_match->getParameter('node');

  $data = [
    'pageType' => 'default',
    'contentGroup' => 'other',
  ];

  if ($node instanceof \Drupal\node\NodeInterface) {
    $data['pageType'] = $node->getType();
    $data['contentGroup'] = $node->getType();
    $data['contentTitle'] = $node->getTitle();
    $data['contentAuthor'] = $node->getOwner()->getDisplayName();
    $data['contentPublished'] = date('Y-m-d', $node->getCreatedTime());

    if ($node->hasField('field_category') && !$node->get('field_category')->isEmpty()) {
      $term = $node->get('field_category')->entity;
      $data['contentCategory'] = $term ? $term->getName() : '';
    }
  }

  $variables['#attached']['html_head'][] = [
    [
      '#type' => 'html_tag',
      '#tag' => 'script',
      '#value' => 'window.dataLayer = window.dataLayer || []; window.dataLayer.push(' . json_encode($data) . ');',
      '#weight' => -100,
    ],
    'custom_datalayer',
  ];
}

The #weight of -100 ensures this script renders before GTM's container snippet.

Ecommerce Tracking with Drupal Commerce

Drupal Commerce uses its own entity types (commerce_product, commerce_order, commerce_order_item). Push ecommerce data using an event subscriber:

<?php

namespace Drupal\mysite_analytics\EventSubscriber;

use Drupal\commerce_cart\Event\CartEntityAddEvent;
use Drupal\commerce_cart\Event\CartEvents;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CommerceAnalyticsSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents() {
    return [
      CartEvents::CART_ENTITY_ADD => 'onCartAdd',
    ];
  }

  public function onCartAdd(CartEntityAddEvent $event) {
    $order_item = $event->getOrderItem();
    $product_variation = $order_item->getPurchasedEntity();
    $product = $product_variation->getProduct();

    $item_data = [
      'event' => 'add_to_cart',
      'ecommerce' => [
        'currency' => $order_item->getTotalPrice()->getCurrencyCode(),
        'value' => (float) $order_item->getTotalPrice()->getNumber(),
        'items' => [[
          'item_id' => $product_variation->getSku(),
          'item_name' => $product->getTitle(),
          'price' => (float) $order_item->getUnitPrice()->getNumber(),
          'quantity' => (int) $order_item->getQuantity(),
        ]],
      ],
    ];

    // Store in session for the next page load to push via dataLayer
    $session = \Drupal::request()->getSession();
    $pending = $session->get('analytics_events', []);
    $pending[] = $item_data;
    $session->set('analytics_events', $pending);
  }

}

Register the subscriber in mysite_analytics.services.yml:

services:
  mysite_analytics.commerce_subscriber:
    class: Drupal\mysite_analytics\EventSubscriber\CommerceAnalyticsSubscriber
    tags:
      - { name: event_subscriber }

Then flush pending events on the next page load via hook_page_attachments:

function mysite_analytics_page_attachments(array &$attachments) {
  $session = \Drupal::request()->getSession();
  $events = $session->get('analytics_events', []);

  if (!empty($events)) {
    $js = 'window.dataLayer = window.dataLayer || [];';
    foreach ($events as $event) {
      $js .= 'window.dataLayer.push(' . json_encode($event) . ');';
    }
    $attachments['#attached']['html_head'][] = [
      [
        '#type' => 'html_tag',
        '#tag' => 'script',
        '#value' => $js,
        '#weight' => -99,
      ],
      'commerce_analytics_events',
    ];
    $session->remove('analytics_events');
  }
}

Common Errors

Error Cause Fix
Data layer shows stale values on repeat visits Drupal Page Cache serves cached HTML to anonymous users, including old dataLayer.push() calls Add cache contexts to your render elements: '#cache' => ['contexts' => ['url.path']]
GTM container not loading on some pages The google_tag module has path or role visibility conditions excluding those pages Review conditions at /admin/config/services/google-tag and check the "All pages except listed" setting
Duplicate dataLayer.push() calls Both the dataLayer module and custom preprocess code are pushing overlapping keys Choose one source of truth per data point; disable overlapping fields in the dataLayer module config
Scripts missing after drush cr (cache rebuild) Asset aggregation regenerates files; old aggregated JS URLs return 404 until new ones are built This is normal behavior; the first page request after cache rebuild triggers regeneration
Ecommerce events fire with undefined values Commerce entity fields are not loaded in the current context (e.g., accessing the entity from an event subscriber before it is fully saved) Use $event->getOrderItem() after the entity save is complete; check that the product variation has a SKU
BigPipe causes GTM to fire before data layer is ready BigPipe streams content in chunks; the GTM script may execute before later chunks containing data layer values arrive Set GTM triggers to fire on Window Loaded instead of DOM Ready, or use drupalSettings which loads in the initial response
hook_page_attachments code does not run on cached pages Anonymous page cache serves the full response from cache, skipping all hooks Use cache tags/contexts so the page varies by the data you need, or use Dynamic Page Cache for authenticated users
User role data missing from data layer for anonymous users Anonymous users all share the same cached page, so role-based data layer values are meaningless Use session cache context or move user-specific data to a separate AJAX endpoint loaded client-side
Google Tag module shows "Container ID not set" The configuration was not saved after entering the ID, or configuration is overridden in settings.php Check /admin/config/services/google-tag and verify no config override exists in $config['google_tag.settings']
Tracking scripts load twice Both a contrib module and a Twig template are injecting the same script Remove the Twig-based injection; always prefer the module approach

Performance Considerations

  • Enable asset aggregation in /admin/config/development/performance. Drupal concatenates and minifies JS files, reducing HTTP requests. Your analytics library added via #attached benefits from this automatically.

  • Use the async attribute on external script tags. The google_tag module adds async by default. If you inject scripts via hook_page_attachments, set '#attributes' => ['async' => TRUE] to prevent render blocking.

  • Leverage Drupal's cache tags for data layer values that change per entity. Tag your render array with '#cache' => ['tags' => ['node:' . $node->id()]] so the data layer updates only when the entity changes, not on every request.

  • Avoid inline script injection in Twig templates. Scripts added directly in Twig bypass aggregation, cannot be deferred, and create an additional parser-blocking resource on every page load.

  • Defer non-critical tracking pixels (Meta Pixel, TikTok, etc.) by loading them after the DOMContentLoaded event. Use GTM's built-in trigger scheduling rather than injecting multiple <script> tags in hook_page_attachments.

  • Monitor BigPipe interactions with your analytics stack. Run Lighthouse with BigPipe enabled and disabled to compare Total Blocking Time. If BigPipe-streamed content triggers excessive layout shifts that affect CLS, consider moving those elements to lazy builders instead.