GA4 Event Tracking on Episerver | OpsBlu Docs

GA4 Event Tracking on Episerver

Implement custom event tracking for Google Analytics 4 on Episerver CMS and Commerce

Learn how to implement custom event tracking for Google Analytics 4 on Episerver (Optimizely) CMS and Commerce.

Prerequisites

  • GA4 Setup completed
  • GA4 base tracking code installed
  • Basic understanding of Razor templates

Event Tracking Methods

Method 1: Inline Event Tracking

Add events directly in Razor templates where user interactions occur.

Basic Event Example

<button 'button_click', {
  'event_category': 'engagement',
  'event_label': 'cta_button',
  'value': 1
});">
  Click Me
</button>

With Server-Side Data

@model MyPageType

<button 'content_interaction', {
  'content_type': '@Model.ContentType.Name',
  'content_id': '@Model.ContentLink.ID',
  'content_name': '@Model.Name',
  'language': '@Model.Language.Name'
});">
  Download Resource
</button>

Method 2: JavaScript Event Handlers

Create reusable JavaScript functions for cleaner markup.

Step 1: Create Tracking Script

Create /Scripts/ga4-events.js:

// GA4 Event Tracking for Episerver
window.EpiserverGA4 = window.EpiserverGA4 || {};

EpiserverGA4.trackEvent = function(eventName, params) {
  if (typeof gtag === 'function') {
    gtag('event', eventName, params);
  } else {
    console.warn('GA4 not loaded - event not tracked:', eventName);
  }
};

// Form tracking
EpiserverGA4.trackFormSubmit = function(formName, formId) {
  this.trackEvent('form_submit', {
    'form_name': formName,
    'form_id': formId,
    'form_destination': window.location.pathname
  });
};

// Download tracking
EpiserverGA4.trackDownload = function(fileName, fileUrl, contentType) {
  this.trackEvent('file_download', {
    'file_name': fileName,
    'file_url': fileUrl,
    'content_type': contentType,
    'link_text': event.target.innerText || event.target.alt
  });
};

// Video tracking
EpiserverGA4.trackVideo = function(action, videoTitle, videoUrl) {
  this.trackEvent('video_' + action, {
    'video_title': videoTitle,
    'video_url': videoUrl,
    'video_current_time': event.target.currentTime || 0,
    'video_duration': event.target.duration || 0
  });
};

// Search tracking
EpiserverGA4.trackSearch = function(searchTerm, resultCount) {
  this.trackEvent('search', {
    'search_term': searchTerm,
    'search_results': resultCount
  });
};

// Outbound link tracking
EpiserverGA4.trackOutbound = function(url, linkText) {
  this.trackEvent('click', {
    'link_url': url,
    'link_text': linkText,
    'outbound': true
  });
};

Step 2: Register Script in Episerver

For CMS 12+, add to Startup.cs:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseStaticFiles(new StaticFileOptions
    { =>
        {
            // Cache static files
            ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=31536000");
        }
    });
}

Or use Client Resources:

[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class ClientResourceInitialization : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        var service = context.Locate.Advanced.GetInstance<IClientResourceService>();
        service.RequireScript("/Scripts/ga4-events.js").AtFooter();
    }

    public void Uninitialize(InitializationEngine context) { }
}

Step 3: Use in Templates

<button {
  'button_name': 'subscribe',
  'page_location': '@Request.Url'
});">
  Subscribe
</button>

<a href="/documents/file.pdf" this.href, 'PDF');">
  Download PDF
</a>

Method 3: Automatic Event Tracking

Automatically track common interactions without manual implementation.

Create Auto-Tracking Script

Create /Scripts/ga4-auto-tracking.js:

(function() {
  'use strict';

  // Wait for DOM and GA4 to load
  function initAutoTracking() {
    if (typeof gtag !== 'function') {
      console.warn('GA4 not loaded - auto-tracking disabled');
      return;
    }

    // Track all external links
    document.addEventListener('click', function(e) {
      var link = e.target.closest('a');
      if (!link) return;

      var href = link.href;
      var hostname = window.location.hostname;

      // External link tracking
      if (href &&
          href.indexOf('http') === 0 &&
          href.indexOf(hostname) === -1) {
        gtag('event', 'click', {
          'link_url': href,
          'link_text': link.innerText || link.title,
          'link_domain': new URL(href).hostname,
          'outbound': true
        });
      }

      // File download tracking
      var fileExtensions = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|zip|rar|txt|csv)$/i;
      if (href && fileExtensions.test(href)) {
        var fileName = href.split('/').pop();
        var fileType = fileName.split('.').pop().toUpperCase();

        gtag('event', 'file_download', {
          'file_name': fileName,
          'file_extension': fileType,
          'link_url': href,
          'link_text': link.innerText || link.title
        });
      }
    });

    // Track form submissions
    document.addEventListener('submit', function(e) {
      var form = e.target;
      var formName = form.getAttribute('name') ||
                     form.getAttribute('id') ||
                     'unnamed_form';

      gtag('event', 'form_submit', {
        'form_name': formName,
        'form_id': form.id,
        'form_action': form.action,
        'form_method': form.method
      });
    });

    // Track video interactions (HTML5 video)
    var videos = document.querySelectorAll('video');
    videos.forEach(function(video) {
      var videoName = video.title || video.currentSrc || 'unnamed_video';

      video.addEventListener('play', function() {
        gtag('event', 'video_start', {
          'video_title': videoName,
          'video_url': video.currentSrc
        });
      });

      video.addEventListener('ended', function() {
        gtag('event', 'video_complete', {
          'video_title': videoName,
          'video_url': video.currentSrc,
          'video_duration': video.duration
        });
      });

      // Track 25%, 50%, 75% progress
      var progressMarks = [25, 50, 75];
      var marksReached = [];

      video.addEventListener('timeupdate', function() {
        var percent = (video.currentTime / video.duration) * 100;

        progressMarks.forEach(function(mark) {
          if (percent >= mark && marksReached.indexOf(mark) === -1) {
            marksReached.push(mark);
            gtag('event', 'video_progress', {
              'video_title': videoName,
              'video_url': video.currentSrc,
              'video_percent': mark
            });
          }
        });
      });
    });

    // Track scroll depth
    var scrollDepths = [25, 50, 75, 100];
    var depthsReached = [];

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

      scrollDepths.forEach(function(depth) {
        if (percent >= depth && depthsReached.indexOf(depth) === -1) {
          depthsReached.push(depth);
          gtag('event', 'scroll', {
            'percent_scrolled': depth
          });
        }
      });
    });
  }

  // Initialize when DOM is ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initAutoTracking);
  } else {
    initAutoTracking();
  }
})();

Episerver-Specific Events

Block Tracking

Track when specific blocks are viewed or interacted with.

@model HeroBlockType

<div class="hero-block"
     data-block-id="@Model.ContentLink.ID"
     data-block-type="@Model.GetOriginalType().Name">

    @Model.Heading

    <button 'block_interaction', {
      'block_type': '@Model.GetOriginalType().Name',
      'block_id': '@Model.ContentLink.ID',
      'block_name': '@Model.Name',
      'interaction_type': 'cta_click'
    });">
        @Model.ButtonText
    </button>
</div>

<script>
  // Track block impression
  (function() {
    var blockElement = document.querySelector('[data-block-id="@Model.ContentLink.ID"]');
    var observer = new IntersectionObserver(function(entries) {
      entries.forEach(function(entry) {
        if (entry.isIntersecting) {
          gtag('event', 'block_view', {
            'block_type': entry.target.getAttribute('data-block-type'),
            'block_id': entry.target.getAttribute('data-block-id')
          });
          observer.unobserve(entry.target);
        }
      });
    }, { threshold: 0.5 });

    observer.observe(blockElement);
  })();
</script>

Content Area Tracking

Track which content areas are most engaged with.

@model ContentArea

@if (Model != null && Model.Items.Any())
{
    <div class="content-area" data-area-name="@ViewData["area-name"]">
        @foreach (var item in Model.Items)
        {
            var content = item.GetContent();
            <div class="content-area-item"
                 data-content-id="@content.ContentLink.ID"
                 data-content-type="@content.GetOriginalType().Name">
                @Html.PropertyFor(m => item)
            </div>
        }
    </div>

    <script>
      // Track content area item views
      (function() {
        var items = document.querySelectorAll('[data-area-name="@ViewData["area-name"]"] .content-area-item');
        var observer = new IntersectionObserver(function(entries) {
          entries.forEach(function(entry) {
            if (entry.isIntersecting) {
              gtag('event', 'content_area_view', {
                'area_name': '@ViewData["area-name"]',
                'content_type': entry.target.getAttribute('data-content-type'),
                'content_id': entry.target.getAttribute('data-content-id')
              });
              observer.unobserve(entry.target);
            }
          });
        }, { threshold: 0.5 });

        items.forEach(function(item) {
          observer.observe(item);
        });
      })();
    </script>
}

Personalization Tracking

Track personalized content variations.

@{
    var visitor = ServiceLocator.Current.GetInstance<IVisitorGroupRepository>();
    var visitorGroups = visitor.List().Where(vg => vg.IsMatch(HttpContext.User));
}

<script>
  gtag('event', 'personalization_view', {
    'visitor_groups': '@string.Join(",", visitorGroups.Select(vg => vg.Name))',
    'content_id': '@Model.ContentLink.ID',
    'page_type': '@Model.GetOriginalType().Name'
  });
</script>

Search Tracking

Track Episerver Find or custom search implementations.

@model SearchPageType

<form method="get" action="@Url.ContentUrl(Model.ContentLink)"
    <input type="text" name="q" value="@Request.QueryString["q"]" placeholder="Search..." />
    <button type="submit">Search</button>
</form>

@if (!string.IsNullOrEmpty(Request.QueryString["q"]))
{
    var searchTerm = Request.QueryString["q"];
    var resultCount = Model.SearchResults?.TotalMatching ?? 0;

    <script>
      gtag('event', 'search', {
        'search_term': '@searchTerm',
        'search_results': @resultCount,
        'search_type': 'episerver_find'
      });
    </script>
}

<script>
  function trackSearch(event) {
    var searchTerm = event.target.querySelector('[name="q"]').value;
    gtag('event', 'search_initiated', {
      'search_term': searchTerm
    });
  }
</script>

Form Tracking with Episerver Forms

Track Episerver Forms submissions and interactions.

using EPiServer.Forms.Core.Events;

[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class FormEventsInitialization : IInitializableModule
{
    public void Initialize(InitializationEngine context)
    {
        FormsEvents.Instance.FormSubmitting += OnFormSubmitting;
        FormsEvents.Instance.FormSubmitted += OnFormSubmitted;
    }

    private void OnFormSubmitting(object sender, FormsEventArgs e)
    {
        // Track form submission start
        var script = $@"
            <script>
                gtag('event', 'form_submit_start', {{
                    'form_name': '{e.FormsContent.Name}',
                    'form_id': '{e.FormsContent.ContentLink.ID}'
                }});
            </script>
        ";

        HttpContext.Current.Response.Write(script);
    }

    private void OnFormSubmitted(object sender, FormsEventArgs e)
    {
        // Add GA4 event to thank you page or redirect
        HttpContext.Current.Items["FormSubmitEvent"] = new
        {
            FormName = e.FormsContent.Name,
            FormId = e.FormsContent.ContentLink.ID,
            Success = true
        };
    }

    public void Uninitialize(InitializationEngine context)
    {
        FormsEvents.Instance.FormSubmitting -= OnFormSubmitting;
        FormsEvents.Instance.FormSubmitted -= OnFormSubmitted;
    }
}

Add to thank you page or layout:

@{
    var formEvent = HttpContext.Items["FormSubmitEvent"] as dynamic;
}

@if (formEvent != null)
{
    <script>
      gtag('event', 'form_submit_complete', {
        'form_name': '@formEvent.FormName',
        'form_id': '@formEvent.FormId',
        'success': @(formEvent.Success.ToString().ToLower())
      });
    </script>
}

Custom Events via Controller Actions

Track events from server-side controller actions.

public class TrackingController : Controller
{
    [HttpPost]
    public JsonResult TrackEvent(string eventName, Dictionary<string, object> parameters)
    {
        // Validate request
        if (string.IsNullOrEmpty(eventName))
        {
            return Json(new { success = false, error = "Event name required" });
        }

        // Log to server logs if needed
        var logger = ServiceLocator.Current.GetInstance<ILogger>();
        logger.Information($"GA4 Event: {eventName} - {JsonConvert.SerializeObject(parameters)}");

        // Return JavaScript to execute
        var script = $"gtag('event', '{eventName}', {JsonConvert.SerializeObject(parameters)});";

        return Json(new { success = true, script = script });
    }
}

Use via AJAX:

function trackServerEvent(eventName, params) {
  fetch('/tracking/trackevent', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      eventName: eventName,
      parameters: params
    })
  })
  .then(response => response.json())
  .then(data => {
    if (data.success && data.script) {
      eval(data.script);
    }
  });
}

Content Engagement

  • Page scroll depth
  • Time on page (via User Engagement)
  • Block impressions
  • Content area interactions
  • Video plays/completions

Conversion Actions

  • Form submissions
  • Newsletter signups
  • Contact requests
  • Resource downloads
  • CTA button clicks

User Behavior

  • Language switches
  • Site switches (multi-site)
  • Personalized content views
  • A/B test variations

Testing Events

1. Browser Console

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

// Manually fire test event
gtag('event', 'test_event', {
  'test_parameter': 'test_value'
});

2. GA4 DebugView

  1. Enable debug mode in your template
  2. Visit your site
  3. Open GA4 > Configure > DebugView
  4. Perform actions and verify events appear

3. Browser DevTools

  1. Open Network tab
  2. Filter for collect
  3. Trigger an event
  4. Check the request payload for your event

4. Tag Assistant

Use Google Tag Assistant to validate events in real-time.

Best Practices

  1. Consistent Naming: Use snake_case for event and parameter names
  2. Avoid PII: Don't track personally identifiable information
  3. Parameter Limits: GA4 allows 25 parameters per event
  4. Event Limits: 500 distinct event names per property
  5. Edit Mode: Never track events in Episerver edit mode
  6. Testing: Always test events before deploying to production

Next Steps