Learn how to implement custom event tracking for Google Analytics 4 on Episerver (Optimizely) CMS and Commerce.
Prerequisites
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);
}
});
}
Recommended Events to Track
Content Engagement
- Page scroll depth
- Time on page (via User Engagement)
- Block impressions
- Content area interactions
- Video plays/completions
Navigation
- Menu clicks
- Breadcrumb usage
- Internal search
- Filter usage
- Pagination
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
- Enable debug mode in your template
- Visit your site
- Open GA4 > Configure > DebugView
- Perform actions and verify events appear
3. Browser DevTools
- Open Network tab
- Filter for
collect - Trigger an event
- Check the request payload for your event
4. Tag Assistant
Use Google Tag Assistant to validate events in real-time.
Best Practices
- Consistent Naming: Use snake_case for event and parameter names
- Avoid PII: Don't track personally identifiable information
- Parameter Limits: GA4 allows 25 parameters per event
- Event Limits: 500 distinct event names per property
- Edit Mode: Never track events in Episerver edit mode
- Testing: Always test events before deploying to production
Next Steps
- Ecommerce Tracking - Track commerce events
- GTM Implementation - Use GTM for event management
- Troubleshooting - Fix event tracking issues