Grav GA4 Event Tracking: Setup Guide | OpsBlu Docs

Grav GA4 Event Tracking: Setup Guide

Implement custom event tracking for Grav flat-file CMS including form submissions, downloads, video tracking, and custom interactions using Twig templates.

Track user interactions on your Grav site using GA4 events. This guide covers tracking forms, downloads, clicks, and custom events using Twig templates and JavaScript.

Grav-Specific Event Tracking

Form Submission Tracking

Grav forms are defined in page frontmatter. Track submissions using JavaScript.

Basic Form Tracking

{# templates/forms/default.html.twig #}

<form name="{{ form.name }}"
      action="{{ form.action ?: page.route }}"
      method="{{ form.method|upper ?: 'POST' }}"

    {{ form.fields|join|raw }}

    <button type="submit">{{ form.buttons.submit.value ?: 'Submit' }}</button>
</form>

<script>
function trackFormSubmit(form) {
    if (typeof gtag !== 'undefined') {
        gtag('event', 'form_submit', {
            'event_category': 'Form',
            'event_label': form.name,
            'form_name': form.name,
            'page_location': window.location.href
        });
    }
}
</script>

Track Contact Form

# pages/contact/form.md
---
title: Contact Form
form:
  name: contact-form
  fields:
    - name: name
      label: Name
      type: text
      validate:
        required: true
    - name: email
      label: Email
      type: email
      validate:
        required: true
    - name: message
      label: Message
      type: textarea
      validate:
        required: true
  buttons:
    submit:
      type: submit
      value: Send Message
  process:
    - email:
        from: "{{ config.plugins.email.from }}"
        to: "{{ config.plugins.email.to }}"
        subject: "Contact Form Submission"
        body: "{% include 'forms/data.html.twig' %}"
    - save:
        fileprefix: contact-
        dateformat: Ymd-His-u
        extension: txt
        body: "{% include 'forms/data.txt.twig' %}"
    - display: thankyou
---
{# templates/forms/fields/submit/submit.html.twig - Custom submit button #}

<button type="submit"
        class="{{ form_button_classes ?: 'button' }}" form.name }}')">
    {{ field.value }}
</button>

<script>
function trackFormStart(formName) {
    if (typeof gtag !== 'undefined') {
        gtag('event', 'form_start', {
            'event_category': 'Form',
            'event_label': formName,
            'form_name': formName
        });
    }
}

// Track successful submission
window.addEventListener('DOMContentLoaded', function() {
    if (window.location.search.includes('thank-you') ||
        document.querySelector('.form-success')) {

        gtag('event', 'form_success', {
            'event_category': 'Form',
            'event_label': '{{ form.name }}',
            'form_name': '{{ form.name }}'
        });
    }
});
</script>

Download Tracking

Track file downloads from Grav media library.

Track PDF Downloads

{# Track all PDF links automatically #}
<script>
document.addEventListener('DOMContentLoaded', function() {
    // Track all download links
    var downloadLinks = document.querySelectorAll('a[href$=".pdf"], a[href$=".zip"], a[href$=".doc"], a[href$=".docx"]');

    downloadLinks.forEach(function(link) {
        link.addEventListener('click', function(e) {
            var url = this.href;
            var fileName = url.substring(url.lastIndexOf('/') + 1);
            var fileType = fileName.substring(fileName.lastIndexOf('.') + 1);

            if (typeof gtag !== 'undefined') {
                gtag('event', 'file_download', {
                    'event_category': 'Download',
                    'event_label': fileName,
                    'file_name': fileName,
                    'file_type': fileType,
                    'link_url': url,
                    'link_text': this.textContent.trim()
                });
            }
        });
    });
});
</script>

Track Specific Downloads

{# In your template or page content #}

<a href="{{ page.media['whitepaper.pdf'].url }}" 'whitepaper', 'pdf')">
    Download Whitepaper (PDF)
</a>

<script>
function trackDownload(element, fileName, fileType) {
    if (typeof gtag !== 'undefined') {
        gtag('event', 'file_download', {
            'event_category': 'Download',
            'event_label': fileName,
            'file_name': fileName + '.' + fileType,
            'file_type': fileType,
            'link_text': element.textContent.trim(),
            'page_title': '{{ page.title }}'
        });
    }
}
</script>

Blog Post Engagement

Track reading time and scroll depth on blog posts.

{# templates/blog.html.twig or item.html.twig #}

{% extends 'partials/base.html.twig' %}

{% block content %}
<article id="blog-post" data-post-id="{{ page.slug }}">
    <h1>{{ page.title }}</h1>

    {% if page.header.author %}
    <p class="author">By {{ page.header.author }}</p>
    {% endif %}

    {{ page.content|raw }}
</article>

<script>
// Track blog post view
if (typeof gtag !== 'undefined') {
    gtag('event', 'blog_post_view', {
        'event_category': 'Blog',
        'event_label': '{{ page.title }}',
        'post_title': '{{ page.title }}',
        'post_category': '{{ page.taxonomy.category|first ?: "Uncategorized" }}',
        'post_author': '{{ page.header.author ?: "Unknown" }}',
        'post_date': '{{ page.date|date("Y-m-d") }}',
        'post_tags': '{{ page.taxonomy.tag|join(",") }}'
    });
}

// Track scroll depth
var scrollDepths = [25, 50, 75, 90];
var scrolledTo = [];

window.addEventListener('scroll', function() {
    var scrollPercentage = (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100;

    scrollDepths.forEach(function(depth) {
        if (scrollPercentage >= depth && scrolledTo.indexOf(depth) === -1) {
            scrolledTo.push(depth);

            if (typeof gtag !== 'undefined') {
                gtag('event', 'scroll', {
                    'event_category': 'Blog',
                    'event_label': '{{ page.title }}',
                    'percent_scrolled': depth,
                    'post_title': '{{ page.title }}'
                });
            }
        }
    });
});

// Track time on page
var startTime = Date.now();

window.addEventListener('beforeunload', function() {
    var timeOnPage = Math.round((Date.now() - startTime) / 1000);

    if (typeof gtag !== 'undefined' && timeOnPage > 5) {
        gtag('event', 'content_engagement', {
            'event_category': 'Blog',
            'event_label': '{{ page.title }}',
            'time_on_page': timeOnPage,
            'engagement_level': timeOnPage > 60 ? 'high' : (timeOnPage > 30 ? 'medium' : 'low')
        });
    }
});
</script>
{% endblock %}

Search Tracking

Track SimpleSearch plugin usage.

{# templates/simplesearch_results.html.twig #}

{% if query %}
<script>
if (typeof gtag !== 'undefined') {
    gtag('event', 'search', {
        'search_term': '{{ query }}',
        'results_count': {{ results|length }},
        'event_category': 'Search'
    });
}
</script>
{% endif %}

Track menu clicks and internal navigation.

{# templates/partials/navigation.html.twig #}

<nav id="main-nav">
    {% for item in pages.children.visible %}
        <a href="{{ item.url }}" '{{ item.menu }}')">
            {{ item.menu }}
        </a>
    {% endfor %}
</nav>

<script>
function trackNavClick(element, menuLabel) {
    if (typeof gtag !== 'undefined') {
        gtag('event', 'nav_click', {
            'event_category': 'Navigation',
            'event_label': menuLabel,
            'link_text': element.textContent.trim(),
            'link_url': element.href,
            'nav_position': 'main_menu'
        });
    }
}
</script>

Track clicks to external sites.

{# Add to base.html.twig #}

<script>
document.addEventListener('DOMContentLoaded', function() {
    var internalDomain = '{{ grav.uri.host() }}';

    // Track all external links
    var links = document.querySelectorAll('a[href^="http"]');

    links.forEach(function(link) {
        var url = new URL(link.href);

        if (url.hostname !== internalDomain) {
            link.addEventListener('click', function(e) {
                if (typeof gtag !== 'undefined') {
                    gtag('event', 'click', {
                        'event_category': 'Outbound Link',
                        'event_label': url.hostname,
                        'link_url': link.href,
                        'link_text': link.textContent.trim(),
                        'link_domain': url.hostname
                    });
                }
            });
        }
    });
});
</script>

Video Tracking

Track embedded videos (YouTube, Vimeo).

{# For YouTube videos in markdown/content #}

<script>
// Track YouTube video interactions
var tag = document.createElement('script');
tag.src = "https://www.youtube.com/iframe_api";
var firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);

var players = [];

function onYouTubeIframeAPIReady() {
    var videos = document.querySelectorAll('iframe[src*="youtube.com"]');

    videos.forEach(function(iframe, index) {
        var videoId = iframe.src.match(/embed\/([^?]+)/)[1];
        iframe.id = 'youtube-player-' + index;

        players[index] = new YT.Player(iframe.id, {
            events: {
                'onStateChange': function(event) {
                    trackYouTubeEvent(event, videoId);
                }
            }
        });
    });
}

function trackYouTubeEvent(event, videoId) {
    var eventName = '';

    switch(event.data) {
        case YT.PlayerState.PLAYING:
            eventName = 'video_start';
            break;
        case YT.PlayerState.PAUSED:
            eventName = 'video_pause';
            break;
        case YT.PlayerState.ENDED:
            eventName = 'video_complete';
            break;
    }

    if (eventName && typeof gtag !== 'undefined') {
        gtag('event', eventName, {
            'event_category': 'Video',
            'event_label': videoId,
            'video_provider': 'YouTube',
            'video_id': videoId,
            'video_url': event.target.getVideoUrl()
        });
    }
}
</script>

Plugin-Based Event Tracking

Track events from popular Grav plugins.

SimpleSearch Events

{# After search form #}

<script>
// Track search form usage
document.querySelector('.simplesearch-form').addEventListener('submit', function(e) {
    var searchTerm = this.querySelector('input[name="searchfield"]').value;

    if (typeof gtag !== 'undefined' && searchTerm) {
        gtag('event', 'search', {
            'event_category': 'Search',
            'search_term': searchTerm,
            'search_type': 'site_search'
        });
    }
});
</script>

Comments Plugin Events

{# Track comment submissions #}

<script>
// Track comment form submission
var commentForm = document.querySelector('.comment-form form');

if (commentForm) {
    commentForm.addEventListener('submit', function(e) {
        if (typeof gtag !== 'undefined') {
            gtag('event', 'comment_submit', {
                'event_category': 'Engagement',
                'event_label': '{{ page.title }}',
                'page_title': '{{ page.title }}',
                'page_url': '{{ page.url(true) }}'
            });
        }
    });
}
</script>

Modular Page Tracking

Track interactions on modular pages.

{# templates/modular.html.twig #}

{% extends 'partials/base.html.twig' %}

{% block content %}
    {% for module in page.collection() %}
        <div class="modular-section" data-module="{{ module.template() }}">
            {{ module.content|raw }}
        </div>
    {% endfor %}
{% endblock %}

{% block bottom %}
<script>
// Track modular page view
if (typeof gtag !== 'undefined') {
    var modules = [];
    document.querySelectorAll('.modular-section').forEach(function(section) {
        modules.push(section.dataset.module);
    });

    gtag('event', 'modular_page_view', {
        'event_category': 'Page',
        'event_label': '{{ page.title }}',
        'modules': modules.join(','),
        'module_count': modules.length
    });
}

// Track scroll to each module
var observedModules = new Set();

var observer = new IntersectionObserver(function(entries) {
    entries.forEach(function(entry) {
        if (entry.isIntersecting && !observedModules.has(entry.target)) {
            observedModules.add(entry.target);

            var moduleName = entry.target.dataset.module;

            if (typeof gtag !== 'undefined') {
                gtag('event', 'module_view', {
                    'event_category': 'Modular',
                    'event_label': moduleName,
                    'module_name': moduleName,
                    'page_title': '{{ page.title }}'
                });
            }
        }
    });
}, { threshold: 0.5 });

document.querySelectorAll('.modular-section').forEach(function(section) {
    observer.observe(section);
});
</script>
{% endblock %}

Custom Event Helper

Create a reusable Twig macro for tracking events.

{# templates/macros/analytics.html.twig #}

{% macro trackEvent(eventName, category, label, params) %}
<script>
(function() {
    if (typeof gtag !== 'undefined') {
        var eventParams = {
            'event_category': '{{ category }}',
            'event_label': '{{ label }}'
        };

        {% if params %}
        var customParams = {{ params|json_encode|raw }};
        Object.assign(eventParams, customParams);
        {% endif %}

        gtag('event', '{{ eventName }}', eventParams);
    }
})();
</script>
{% endmacro %}

Usage:

{% import 'macros/analytics.html.twig' as analytics %}

{# Track custom event #}
{{ analytics.trackEvent('button_click', 'CTA', 'Sign Up', {
    'button_position': 'header',
    'page_type': page.template()
}) }}

E-commerce Tracking (ShoppingCart Plugin)

Track e-commerce events if using Grav e-commerce plugins.

{# Track add to cart #}

<button product.id }}', '{{ product.title }}', {{ product.price }})">
    Add to Cart
</button>

<script>
function addToCart(productId, productName, productPrice) {
    // Your add to cart logic here

    // Track event
    if (typeof gtag !== 'undefined') {
        gtag('event', 'add_to_cart', {
            'currency': 'USD',
            'value': productPrice,
            'items': [{
                'item_id': productId,
                'item_name': productName,
                'price': productPrice,
                'quantity': 1
            }]
        });
    }
}
</script>

Testing Events

Debug Mode

<script>
// Enable GA4 debug mode
gtag('config', 'G-XXXXXXXXXX', {
    'debug_mode': true
});

// Log events to console in development
{% if grav.config.system.debugger.enabled %}
var originalGtag = gtag;
gtag = function() {
    console.log('GA4 Event:', Array.from(arguments));
    originalGtag.apply(this, arguments);
};
{% endif %}
</script>

Test Events in Console

// Test form event
gtag('event', 'form_submit', {
    'event_category': 'Form',
    'event_label': 'test-form'
});

// Test download event
gtag('event', 'file_download', {
    'event_category': 'Download',
    'file_name': 'test.pdf'
});

Best Practices

1. Use Consistent Event Naming

// Good - consistent naming
gtag('event', 'form_submit', { ... });
gtag('event', 'button_click', { ... });
gtag('event', 'file_download', { ... });

// Bad - inconsistent
gtag('event', 'FormSubmit', { ... });
gtag('event', 'btnClick', { ... });
gtag('event', 'downloadFile', { ... });

2. Include Page Context

gtag('event', 'custom_event', {
    'event_category': 'Category',
    'event_label': 'Label',
    // Context
    'page_title': '{{ page.title }}',
    'page_type': '{{ page.template() }}',
    'page_url': '{{ page.url(true) }}'
});

3. Avoid PII

// Bad - includes email
gtag('event', 'form_submit', {
    'user_email': 'user@example.com'  // Don't do this
});

// Good - anonymous
gtag('event', 'form_submit', {
    'form_name': 'contact',
    'user_type': 'visitor'
});

Next Steps

For general GA4 event concepts, see GA4 Event Tracking Guide.