Mert Özen Further With Every Line
SEO & GEO Analytics and Measurement — #04

Measuring On-Site Behavior with GA4: The Event Model, page_view in an Angular SPA, Custom Events and Key Events

Mert Özen Nov 15, 2026 11 min 3 views
Measuring On-Site Behavior with GA4: The Event Model, page_view in an Angular SPA, Custom Events and Key Events

In an Angular SPA you send page_view either through enhanced measurement's history tracking or by hand, never both; if you send it by hand, do it inside a TitleStrategy, because the router writes the title after NavigationEnd. We cover GA4's event model, key events, gtag with a CSP nonce in Razor pages, events that matter for a blog and Measurement Protocol code that sends no personal data.

Version note: The code in this post was run with .NET 10 (ASP.NET Core 10), Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports) and the Chromium of Playwright 1.65; no request was sent to Google, and the measurement ID G-TEST123 is a placeholder. This post is based on the official documentation: Google Analytics Help and the Google Analytics developer docs, as of 15 November 2026.

Guess First

You are adding GA4 to bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series. You pasted Google's setup snippet into index.html, and you also send a page_view by hand on the Router's NavigationEnd event. Enhanced measurement is on in the data stream. A reader lands on the home page and then opens two posts. How many page_view events does GA4 see? And is the page_title of your manual page_view events right?

From Universal Analytics to the Event Model

Standard Universal Analytics properties stopped processing data on 1 July 2023; for Analytics 360 the last date was 1 July 2024. GA4 has no session or pageview hit types; everything is an event with parameters. Events fall into four groups: the ones GA4 collects itself (such as session_start, first_visit, user_engagement), enhanced measurement events switched on in the data stream settings, recommended events whose names and parameters Google suggests, and custom events you define.

What enhanced measurement does matters for a blog: a page_view on every page load and browser history change; a scroll the first time the reader reaches 90 percent of the page; an outbound click on a link to another domain; view_search_results for query parameters such as q, s and search; a file_download on a click to a link with an extension such as PDF or zip; form events and YouTube video events. So PDF downloads and clicks to the code on GitHub come without writing any code.

The naming changed as well: Google now calls the events that measure actions important to the business "key events". The word "conversion" stayed on the Google Ads side: an action shared from a key event to Ads and used for campaign measurement and bid optimization. In Analytics you mark an event as a key event on the Key events page in Admin; if you rename an event that is marked as a key event, the mark is lost.

page_view in an Angular SPA

SPA navigation does not load a new page in the browser; it changes the URL with the History API. Google's SPA guide describes two paths: for apps using the History API, leave enhanced measurement's "Page changes based on browser history events" option on, or send virtual page views yourself. With both on, every navigation counts twice. The detail to watch: passing send_page_view: false to the config command turns off only the automatic page_view of the first load; to turn off the page_view on history changes you have to switch off that option in the data stream settings. So the answer to the opening question is "two per navigation": one from enhanced measurement and one from your code.

We chose to send them by hand, because we want to control the title and the URL. The small function that loads the tag:

// Loads gtag.js once. Page views are sent by AnalyticsService, so the
// automatic page_view of the config command is turned off.
type Gtag = (...args: unknown[]) => void;

declare global {
  interface Window {
    dataLayer: unknown[];
    gtag: Gtag;
  }
}

export function loadGoogleTag(doc: Document, measurementId: string): Gtag {
  const win = doc.defaultView!;
  win.dataLayer = win.dataLayer || [];
  win.gtag = function gtag() {
    // gtag.js reads the arguments object, not an array
    win.dataLayer.push(arguments);
  };
  win.gtag('js', new Date());
  win.gtag('config', measurementId, { send_page_view: false });

  const script = doc.createElement('script');
  script.async = true;
  script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}`;
  doc.head.appendChild(script);
  return win.gtag;
}

A note: in November 2026 the gtag.js reference page marks the gtag('config') command as deprecated and recommends configuring settings in the Google tag interface or with gtag('set'); GA4's page view documentation still gives the config example for turning off the automatic page_view. We used the form from the page view documentation.

In the first attempt I sent the page_view on the Router's NavigationEnd event. A small script (web/lab/spa-pageviews.mjs) drives the built app in Playwright's Chromium, blocks the requests to Google and prints the calls queued in dataLayer. The output showed a bug:

js <Date>
config G-TEST123 {"send_page_view":false}
get G-TEST123 client_id <callback>
event page_view {"page_location":"http://127.0.0.1:52714/","page_referrer":"","page_title":"BookstoreWeb"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"http://127.0.0.1:52714/","page_title":"Bookstore Blog"}
event code_copy {"language":"bash"}
event series_navigation {"series":"analytics","direction":"next"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/weekly-report","page_referrer":"http://127.0.0.1:52714/blog/search-console-api","page_title":"search-console-api - Bookstore Blog"}

Each page_title is the title of the previous page, and the first one is the static title in index.html. When the Router emits NavigationEnd, document.title has not been updated yet. The fix is to move the page view to where the title is set: extend Angular's TitleStrategy and send the page_view right after the title.

import { Injectable, inject } from '@angular/core';
import { DefaultTitleStrategy, RouterStateSnapshot } from '@angular/router';
import { AnalyticsService } from './analytics.service';

// NavigationEnd fires before the router updates document.title;
// sending page_view here gives every view its own title.
@Injectable()
export class AnalyticsTitleStrategy extends DefaultTitleStrategy {
  private readonly analytics = inject(AnalyticsService);

  override updateTitle(snapshot: RouterStateSnapshot): void {
    super.updateTitle(snapshot);
    this.analytics.pageView();
  }
}
import { isPlatformBrowser } from '@angular/common';
import { DOCUMENT, Injectable, PLATFORM_ID, inject } from '@angular/core';
import { ANALYTICS_CONFIG } from './analytics.config';
import { loadGoogleTag } from './google-tag';

@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  private readonly doc = inject(DOCUMENT);
  private readonly config = inject(ANALYTICS_CONFIG);
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private gtag?: (...args: unknown[]) => void;
  private previousUrl = '';

  start(): void {
    // no tag during SSR or when no measurement ID is configured
    if (!this.isBrowser || !this.config.measurementId || this.gtag) {
      return;
    }

    this.gtag = loadGoogleTag(this.doc, this.config.measurementId);
    this.rememberClientId();
    this.previousUrl = this.doc.referrer;
  }

  // Called by AnalyticsTitleStrategy after the router has set document.title.
  pageView(): void {
    const location = this.doc.location.href;
    this.gtag?.('event', 'page_view', {
      page_location: location,
      page_referrer: this.previousUrl,
      page_title: this.doc.title,
    });
    this.previousUrl = location;
  }

  codeCopied(language: string): void {
    this.gtag?.('event', 'code_copy', { language });
  }

  seriesNavigated(series: string, direction: 'next' | 'previous'): void {
    this.gtag?.('event', 'series_navigation', { series, direction });
  }

  // The server reads this first-party cookie for Measurement Protocol events.
  private rememberClientId(): void {
    this.gtag?.('get', this.config.measurementId, 'client_id', (clientId: string) => {
      this.doc.cookie = `bs_ga_cid=${clientId}; Path=/; Max-Age=${60 * 60 * 24 * 90}; SameSite=Lax; Secure`;
    });
  }
}
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { TitleStrategy, provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { ANALYTICS_CONFIG } from './analytics/analytics.config';
import { AnalyticsTitleStrategy } from './analytics/analytics-title.strategy';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes, withComponentInputBinding()),
    { provide: TitleStrategy, useClass: AnalyticsTitleStrategy },
    { provide: ANALYTICS_CONFIG, useValue: { measurementId: 'G-TEST123' } },
  ],
};

The output after the fix:

$ node spa-pageviews.mjs http://127.0.0.1:52714
js <Date>
config G-TEST123 {"send_page_view":false}
get G-TEST123 client_id <callback>
event page_view {"page_location":"http://127.0.0.1:52714/","page_referrer":"","page_title":"Bookstore Blog"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/search-console-api","page_referrer":"http://127.0.0.1:52714/","page_title":"search-console-api - Bookstore Blog"}
event code_copy {"language":"bash"}
event series_navigation {"series":"analytics","direction":"next"}
event page_view {"page_location":"http://127.0.0.1:52714/blog/weekly-report","page_referrer":"http://127.0.0.1:52714/blog/search-console-api","page_title":"weekly-report - Bookstore Blog"}

Every view has its own title and the right page_referrer. The isPlatformBrowser check is for SSR: even though TitleStrategy runs on the server, the tag is not loaded there, so pageView() sends nothing; after hydration the first navigation in the browser produces a single page_view. The code in this post does not ask for consent yet; tying the tag to the visitor's permission is the topic of the next post, so do not ship it to production like this.

gtag and a CSP Nonce in Razor Pages

Bookstore's Razor-rendered pages use a strict CSP and do not allow inline scripts. Google's tag platform guide recommends a nonce for this case: a random value generated per response and written both to the header and to the inline script tag. The guide's "Google Analytics without any Ads features" list asks for https://www.googletagmanager.com in script-src-elem, additionally https://*.google-analytics.com in img-src, and also https://*.google.com in connect-src.

using System.Buffers.Text;
using System.Security.Cryptography;

namespace Bookstore.Analytics.Web.Security;

// One random nonce per response; the inline Google tag script carries the same value.
// Base64url: Razor would HTML-encode the '+' and '=' of plain base64 inside the attribute.
public sealed class CspNonce
{
    public string Value { get; } = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(16));
}

public static class CspMiddleware
{
    public static IApplicationBuilder UseContentSecurityPolicy(this IApplicationBuilder app) =>
        app.Use(async (context, next) =>
        {
            CspNonce nonce = context.RequestServices.GetRequiredService<CspNonce>();
            // GA4 without Ads features, as listed in Google's CSP guide
            context.Response.Headers.ContentSecurityPolicy =
                $"default-src 'self'; " +
                $"script-src-elem 'self' 'nonce-{nonce.Value}' https://www.googletagmanager.com; " +
                "img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; " +
                "connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com";
            await next(context);
        });
}
@using Microsoft.Extensions.Options
@using Bookstore.Analytics.Web.Analytics
@using Bookstore.Analytics.Web.Security
@inject IOptions<AnalyticsOptions> Analytics
@inject CspNonce Nonce
@if (Analytics.Value.MeasurementId is { Length: > 0 } id)
{
    <script async src="https://www.googletagmanager.com/gtag/js?id=@id"></script>
    <script nonce="@Nonce.Value">
        window.dataLayer = window.dataLayer || [];
        function gtag() { dataLayer.push(arguments); }
        gtag('js', new Date());
        gtag('config', '@id');
    </script>
}

In the first version the nonce was plain base64 and the test failed: Razor HTML-encodes the + and = characters inside the attribute, so the value in the page did not look identical to the one in the header. It would have worked, since the browser decodes entities, but base64url makes both the test and the markup simpler. The header and the page from a local run:

$ curl -s -D - http://127.0.0.1:52704/ -o index.html | grep -i content-security
$ grep -n script index.html
Content-Security-Policy: default-src 'self'; script-src-elem 'self' 'nonce-TjL27PX7wddKJ8ooXesT5g' https://www.googletagmanager.com; img-src 'self' https://www.googletagmanager.com https://*.google-analytics.com; connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.google.com
6:        <script async src="https://www.googletagmanager.com/gtag/js?id=G-TEST123"></script>
7:    <script nonce="TjL27PX7wddKJ8ooXesT5g">
12:    </script>

Events That Mean Something for a Technical Blog

On a blog the answer to "what did the reader do?" lies in a few events: did they copy code, did they move on to the next post in the series, did they download the PDF, did they go to the code on GitHub. The last two come from enhanced measurement. For the first two, the codeCopied and seriesNavigated methods in AnalyticsService send custom events named code_copy and series_navigation; you can see both in the output. To see custom parameters (language, direction) in reports, you have to register them as custom dimensions in GA4. As a key event candidate, the most meaningful one for a blog is probably moving through a series: it shows the reader stayed after a post.

Linking Search Console to GA4 requires the Editor role on the GA4 property and being a verified owner of the Search Console property; a web data stream can link to only one Search Console property. After linking, the "Google Organic Search Queries" and "Google Organic Search Traffic" reports arrive, but unpublished in the Library; you have to publish them to see them. The data comes with a delay of about 48 hours and, like Search Console, covers at most 16 months.

Server-side Events with the Measurement Protocol

To count PDF downloads on the server there is the Measurement Protocol. Google's docs stress that the protocol augments gtag's automatic collection and does not replace it: for the web every request needs a client_id, and that is the identifier the tag in the browser created. In Bookstore the Angular app gets it with gtag('get', ..., 'client_id', ...) and writes it to a first-party cookie named bs_ga_cid; the PDF endpoint sends an event when that cookie is present:

using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;

namespace Bookstore.Analytics.Web.Analytics;

public sealed class Ga4MeasurementProtocolClient(
    HttpClient http, IOptions<AnalyticsOptions> options, ILogger<Ga4MeasurementProtocolClient> logger)
{
    public async Task<bool> SendAsync(
        string clientId, string eventName, IReadOnlyDictionary<string, object> parameters, CancellationToken ct)
    {
        AnalyticsOptions o = options.Value;
        if (string.IsNullOrEmpty(o.MeasurementId) || string.IsNullOrEmpty(o.ApiSecret))
        {
            return false;
        }

        EventParameterGuard.EnsureNoPersonalData(parameters);
        string path = $"mp/collect?measurement_id={Uri.EscapeDataString(o.MeasurementId)}" +
                      $"&api_secret={Uri.EscapeDataString(o.ApiSecret)}";
        Payload payload = new(clientId, [new Event(eventName, parameters)]);

        // The endpoint answers 2xx even for a malformed event; check payloads
        // against /debug/mp/collect while developing.
        using HttpResponseMessage response = await http.PostAsJsonAsync(new Uri(o.Endpoint, path), payload, ct);
        if (!response.IsSuccessStatusCode)
        {
            logger.LogWarning("Measurement Protocol returned {Status}", (int)response.StatusCode);
        }

        return response.IsSuccessStatusCode;
    }

    private sealed record Payload(
        [property: JsonPropertyName("client_id")] string ClientId,
        [property: JsonPropertyName("events")] Event[] Events);

    private sealed record Event(
        [property: JsonPropertyName("name")] string Name,
        [property: JsonPropertyName("params")] IReadOnlyDictionary<string, object> Params);
}
app.MapGet("/downloads/{name}.pdf", async (
    string name, HttpContext context, Ga4MeasurementProtocolClient ga, ILogger<Program> logger) =>
{
    if (!SeriesPdfs.TryGet(name, out byte[] pdf))
    {
        return Results.NotFound();
    }

    if (ClientIdCookie.Read(context.Request) is { } clientId)
    {
        try
        {
            await ga.SendAsync(clientId, "pdf_download_served",
                new Dictionary<string, object> { ["file_name"] = $"{name}.pdf" }, context.RequestAborted);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
        {
            // analytics must never break the download
            logger.LogWarning(ex, "Measurement Protocol call failed");
        }
    }

    return Results.File(pdf, "application/pdf", $"{name}.pdf");
});

Two important rules. First, the endpoint returns no error code even for a malformed event; while developing, check payloads with the /debug/mp/collect validation server. The limits: at most 25 events per request, 25 parameters per event, event and parameter names up to 40 characters, parameter values up to 100 characters; some names such as session_start are reserved. Second, no personal data is sent: EventParameterGuard rejects values that look like e-mail addresses and keys such as email and name. With a fake handler the tests check both the JSON that is sent and that nothing is sent without the cookie:

$ dotnet test --filter "FullyQualifiedName~GoogleTagTests|FullyQualifiedName~EventParameterGuardTests"
Passed!  - Failed:     0, Passed:     8, Skipped:     0, Total:     8, Duration: 515 ms - Bookstore.Analytics.Tests.dll (net10.0)

Production Notes

Personal data. Google's policy asks that no data Google could use to identify a person is sent, and says leaks happen most often through URLs, page titles, search terms and custom parameters. Web streams have a data redaction setting that strips e-mail addresses and query parameters you choose, but Google describes it as "best-effort"; the real safeguard is cleaning the data at the source. The KVKK side of this is in the Data Privacy and KVKK for Developers post "Personal Data in Logs, Telemetry and Analytics".

Data retention. GA4 keeps user-level and event-level data for 2 or 14 months (360 has longer options). The setting affects explorations and funnel reports, not the standard reports; decide on the period consciously and keep it consistent with your privacy notice.

Internal traffic. With an IP rule in the data stream you can mark your own traffic with the traffic_type parameter and exclude it with a data filter. Try the filter in the Testing state first; once Active its effect is permanent, and excluded data is never processed. There is a limit of 10 filters per property, and taking effect can take 24 to 36 hours.

api_secret stays on the server. The Measurement Protocol secret never goes into the Angular code or the repository; it is read from an environment variable.

Common Pitfalls

Sending page_view by hand while enhanced measurement's history tracking is on. Every navigation counts twice and per-session page metrics inflate.

Reading the title on NavigationEnd. The router updates the title later; in reports every page shows up with the previous page's title.

Putting personal data into event parameters. Text typed into the search box, form fields or an e-mail address in the URL can reach GA4 without you noticing.

Adding the internal traffic filter late. The filter does not fix past data; the team's own visits stay in the reports up to that day.

A Small Experiment

★☆☆ Run spa-pageviews.mjs, then temporarily wire AnalyticsService back to NavigationEnd and see the title shift in the output yourself.

★★☆ Add a series_complete event that is sent once when the reader reaches the end of the last post in a series. Which parameters would you add, and which would you never add?

★★★ Add a validation mode to Ga4MeasurementProtocolClient: in development it sends requests to /debug/mp/collect and logs a warning when validationMessages is not empty. Test it with the fake handler.

GA4 works, but in this form it writes cookies without asking the visitor. In the next post we look at the KVKK cookie guide, Google Consent Mode and cookieless alternatives from the measurement side.