Google Analytics with SaaS Headless CMS and ASP.NET Core Razor Pages

This blog post explains one way to add tracking for google analytics to a web content delivery solution for a SaaS headless content management system.

To add google analytics tracking analytics, we can embed a piece of JavaScript with a google identifier on every page. While .NET provides various techniques for managing store such identifiers, we might want to store the google analytics identifier in the CMS. Not only does this allow CMS users to maintain the value, but existing infrastructure for accessing entries in the CMS can retrieve this value as JSON over HTTPS rather than implementing any new API or infrastructure as would be required for exposing values using other configuration techniques.

We likely want a different identifier associated with each website. It could even make sense to implement a configuration provider for the CMS, but another easy approach is to simply add a field for the tag identifier content type for the home page. If there are numerous such site settings, it might make sense to create an additional content type for those values, and a separate entry referenced by a field of the home entry.

I think these are the steps for the easiest possible solution.

  1. Add a field for the google identifier to the content type for the home entry.
  2. Add a corresponding property to the entry model for the content type for the home entry.
  3. Render the identifier into a script block on every page.

The first step is somewhat specific to my requirements. I want to apply the same google identifier to the entire site. Some organizations might want to apply the identifier from the nearest ancestor that defines a tag. For example, the home page and most of the site might use one google identifier while certain sections and their contents might use different identifiers. To achieve this, which also meets my requirements, we can add a field for the google identifier to an existing implementation for collecting page metadata.

For the second step, we need to add a property to the PageData class to expose values in the google analytics field.

There are several possible approaches to the third step. I use the same view on all pages, so putting the script in that view would be easiest, but it might be best to render the script within the <head> element, which is generated by the layout invoked by the view. In either case, the script seems to belong in a component. As the model for the component, we can pass the google identifier, the identifier of the home entry that contains that value, or the entry itself. We can use the Web API for paths to iterate ancestors for the nearest one that defines the field.

My prototype consists of the following.

  • Adding a field for the google identifier to an existing group of fields used to collect metadata for all pages.
  • Add a corresponding property to the class that models that collection of metadata fields. I am halfway between CMS-specific and generic code, so my implementation is both wrong and complicated by additional classes for handling this value.
  • Create a view component to render the tag.
  • Update the existing partial view that renders metadata to call the new view component.

This is the class I use to model metadata for page entries.

using Deliverystack.StackContent.Models.Fields;

public class PageData
{
    private string _navTitle;

    public string NavTitle
    {
        set
        {
            _navTitle = value;
        }

        get
        {
            return _navTitle ?? PageTitle;
        }
    }

    public string Layout { get; set; }
    public string PartialView { get; set; }
    public string Url { get; set; }
    public string PageTitle { get; set; }
    public string PageDescription { get; set; }
    public string SearchKeywords { get; set; }
    public bool ExcludeFromSearch { get; set; }
    public bool IncludeInSitemap { get; set; }
    public string SearchChangeFrequency { get; set; }
    public string SearchPriority { get; set; }
    public FileField OpenGraphImage { get; set; }
    public string GoogleAnalyticsId { get; set; }
    public string FavIcon { get; set; }
}

This is the class for the view component. It uses a nested class to store the google identifier, which it determines from the nearest ancestor of the entry that corresponds to the URL path that contains a value. If no such ancestor exists, it does not invoke the view.

using System;
using System.Linq;
using System.Threading.Tasks;

using Microsoft.AspNetCore.Mvc;

using Deliverystack.DeliveryApi.Models;
using Deliverystack.Models;

public class GoogleAnalytics : ViewComponent
{
    public class GoogleAnalyticsModel
    {
        public string GoogleAnalyticsId { get; set; }

        public GoogleAnalyticsModel(string googleAnalyticsId)
        {
            GoogleAnalyticsId = googleAnalyticsId;
        }
    }

    private readonly PathApiClient _client;

    public GoogleAnalytics(PathApiClient client)
    {
        _client = client;
    }

    // May 18 2021 - logic is synchronous and lightweight; ignore warnings about async
    #pragma warning disable CS1998
    public async Task<IViewComponentResult> InvokeAsync()
    {
        foreach (PathApiEntryModel result in _client.Get(new()
        {
            Path = HttpContext.Request.Path, Ancestors = int.MaxValue
        }).Entries.Reverse())
        {
            if (!String.IsNullOrEmpty(result.PageData.GoogleAnalyticsId))
            {
                return View(new GoogleAnalyticsModel(result.PageData.GoogleAnalyticsId));
            }
        }
        
        return Content(String.Empty);
    }
    #pragma warning restore CS1998
}

Here is the view.

@model HeadlessArchitect.Website.Pages.Shared.Components.TopNav.GoogleAnalytics.GoogleAnalyticsModel

<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', '@Model.GoogleAnalyticsId', 'auto');
ga('send', 'pageview');
</script>

Here is the partial view that calls this view component.

@model Deliverystack.StackContent.Models.Groups.PageData

<title>@Model.PageTitle</title>
<meta name="description" content="@Model.PageDescription" />
<meta name="keywords" content="@Model.SearchKeywords" />
<meta property="og:title" content="@Model.PageTitle" />
<meta property="og:description" content="@Model.PageDescription" />

@if (@Model.OpenGraphImage != null && !String.IsNullOrEmpty(@Model.OpenGraphImage.Url))
{
    <meta property="og:image" content="@Model.OpenGraphImage.Url"/>
    <meta property="og:image:url" content="@Model.OpenGraphImage.Url"/>
    <meta property="twitter:image" content="@Model.OpenGraphImage.Url"/>
}

@await Component.InvokeAsync("FavIcon")
@await Component.InvokeAsync("GoogleAnalytics")

One thought on “Google Analytics with SaaS Headless CMS and ASP.NET Core Razor Pages

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: