From a72081019eb5546b9d558881cda659b859c576f0 Mon Sep 17 00:00:00 2001 From: daniel-slaugh Date: Thu, 24 Sep 2026 09:39:48 -0600 Subject: [PATCH] Implement Google Analytics opt-in option --- apps/data-management/src/config/settings.ts | 2 + apps/data-management/src/main.ts | 6 ++ apps/data-management/src/models/settings.ts | 2 + .../plugins/__tests__/googleAnalytics.spec.ts | 39 +++++++++++ .../src/plugins/googleAnalytics.ts | 21 ++++++ .../using-the-administrator-dashboard.md | 18 ++++- .../web/migrations/0003_google_analytics.py | 25 +++++++ django/core/web/models.py | 7 ++ django/interfaces/web/views.py | 2 + django/tests/web/test_analytics.py | 65 +++++++++++++++++++ 10 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts create mode 100644 apps/data-management/src/plugins/googleAnalytics.ts create mode 100644 django/core/web/migrations/0003_google_analytics.py create mode 100644 django/tests/web/test_analytics.py diff --git a/apps/data-management/src/config/settings.ts b/apps/data-management/src/config/settings.ts index 6acbaf63..76f31b12 100644 --- a/apps/data-management/src/config/settings.ts +++ b/apps/data-management/src/config/settings.ts @@ -26,6 +26,8 @@ const defaultSettings: AppSettings = { analyticsConfiguration: { enableClarityAnalytics: false, clarityProjectId: null, + enableGoogleAnalytics: false, + googleAnalyticsMeasurementId: null, }, legalInformation: { termsOfUseLink: null, diff --git a/apps/data-management/src/main.ts b/apps/data-management/src/main.ts index 102015a3..176050e9 100644 --- a/apps/data-management/src/main.ts +++ b/apps/data-management/src/main.ts @@ -8,6 +8,7 @@ import router from './router/router' import vuetify from '@/plugins/vuetify' import { createPinia, storeToRefs } from 'pinia' import { injectClarity } from '@/plugins/clarity' +import { injectGoogleAnalytics } from '@/plugins/googleAnalytics' import { settings } from '@/config/settings' import hs, { createHydroServer, User } from '@hydroserver/client' import { useVocabularyStore } from './composables/useVocabulary' @@ -60,6 +61,11 @@ async function initializeApp() { settings.analyticsConfiguration.enableClarityAnalytics && settings.analyticsConfiguration.clarityProjectId && injectClarity(settings.analyticsConfiguration.clarityProjectId) + settings.analyticsConfiguration.enableGoogleAnalytics && + settings.analyticsConfiguration.googleAnalyticsMeasurementId && + injectGoogleAnalytics( + settings.analyticsConfiguration.googleAnalyticsMeasurementId + ) app.mount('#app') } diff --git a/apps/data-management/src/models/settings.ts b/apps/data-management/src/models/settings.ts index c255d1dd..d0fd9dcd 100644 --- a/apps/data-management/src/models/settings.ts +++ b/apps/data-management/src/models/settings.ts @@ -58,6 +58,8 @@ interface MapConfiguration { interface AnalyticsConfiguration { enableClarityAnalytics: boolean clarityProjectId?: string | null + enableGoogleAnalytics: boolean + googleAnalyticsMeasurementId?: string | null } interface LegalInformation { diff --git a/apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts b/apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts new file mode 100644 index 00000000..11f960e9 --- /dev/null +++ b/apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { injectGoogleAnalytics } from '../googleAnalytics' + +afterEach(() => { + document.head.innerHTML = '' + delete window.dataLayer + delete window.gtag +}) + +describe('Google Analytics', () => { + it('loads the Google tag asynchronously and queues GA4 initialization', () => { + injectGoogleAnalytics('G-TEST123456') + + const script = document.head.querySelector('script')! + expect(script.async).toBe(true) + expect(script.src).toBe( + 'https://www.googletagmanager.com/gtag/js?id=G-TEST123456' + ) + expect(window.dataLayer?.map((entry) => Array.from(entry))).toEqual([ + ['js', expect.any(Date)], + ['config', 'G-TEST123456'], + ]) + }) + + it('preserves an existing data layer and safely encodes the measurement ID', () => { + const dataLayer: IArguments[] = [] + window.dataLayer = dataLayer + const measurementId = 'G-TEST&extra="value"' + + injectGoogleAnalytics(measurementId) + + expect(window.dataLayer).toBe(dataLayer) + const script = document.head.querySelector('script')! + const url = new URL(script.src) + expect(Array.from(url.searchParams)).toEqual([['id', measurementId]]) + expect(document.head.querySelectorAll('script')).toHaveLength(1) + expect(Array.from(dataLayer[1])).toEqual(['config', measurementId]) + }) +}) diff --git a/apps/data-management/src/plugins/googleAnalytics.ts b/apps/data-management/src/plugins/googleAnalytics.ts new file mode 100644 index 00000000..d668ab9d --- /dev/null +++ b/apps/data-management/src/plugins/googleAnalytics.ts @@ -0,0 +1,21 @@ +declare global { + interface Window { + dataLayer?: IArguments[] + gtag?: (...args: unknown[]) => void + } +} + +export function injectGoogleAnalytics(measurementId: string) { + const dataLayer = (window.dataLayer = window.dataLayer || []) + window.gtag = function (..._args: unknown[]) { + dataLayer.push(arguments) + } + window.gtag('js', new Date()) + // GA4 enhanced measurement tracks subsequent browser history changes. + window.gtag('config', measurementId) + + const script = document.createElement('script') + script.async = true + script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(measurementId)}` + document.head.appendChild(script) +} diff --git a/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md b/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md index 22f21724..7fabed5d 100644 --- a/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md +++ b/apps/docs/docs/hosting-and-deployment/how-to/using-the-administrator-dashboard.md @@ -59,7 +59,7 @@ The following website settings can be configured under **Website Configuration** | Model | Purpose | | --- | --- | | **Instance Configuration** | Configure instance-wide website content and legal links. | -| **Analytics Configuration** | Enable or disable Microsoft Clarity analytics. | +| **Analytics Configuration** | Enable or disable Microsoft Clarity and Google Analytics independently. | | **Map Configuration** | Configure the default map view, default map layers, and map service providers. | | **Map Layers** | Create the map layers available to users in HydroServer map controls. | | **Contact Information** | Add contact cards shown on the About page. | @@ -81,12 +81,26 @@ Use **Website Configuration** > **Instance Configuration** to manage general web ### Analytics Configuration Use **Website Configuration** > **Analytics Configuration** to optionally enable -[Microsoft Clarity](https://clarity.microsoft.com/) analytics: +[Microsoft Clarity](https://clarity.microsoft.com/) and/or Google Analytics for the Data Management app. +Both are disabled by default. Each service only loads when its enable checkbox is selected and its ID is provided. | Field | Purpose | | --- | --- | | **Enable clarity analytics** | Turns Microsoft Clarity tracking on or off. | | **Clarity project id** | Stores the project ID from the Microsoft Clarity project settings. | +| **Enable google analytics** | Turns Google Analytics tracking on or off, independently of Clarity. | +| **Google analytics measurement id** | Stores the GA4 web stream measurement ID, such as `G-XXXXXXXXXX`. | + +For Google Analytics, create a GA4 web data stream for your HydroServer URL and copy its measurement ID +from **Admin** > **Data streams**. In the stream's **Enhanced measurement** settings, enable **Page views**, +including **Page changes based on browser history events** in the advanced settings. This tracks navigation +within the app using Google's [automatic page-view measurement](https://developers.google.com/analytics/devguides/collection/ga4/single-page-applications). +After saving the HydroServer settings, allow up to ten minutes for the cached page settings to expire, reload +the app, and verify visits and navigation in Google Analytics Realtime or DebugView. + +Set **Instance Configuration** > **Privacy policy link** to your organization's analytics/cookie notice; +the app already displays this link in its footer. +This integration tracks browser usage of the Data Management app; it does not add API request tracking. ### Map Configuration diff --git a/django/core/web/migrations/0003_google_analytics.py b/django/core/web/migrations/0003_google_analytics.py new file mode 100644 index 00000000..8ac58b80 --- /dev/null +++ b/django/core/web/migrations/0003_google_analytics.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("web", "0002_sitetypeicon"), + ] + + operations = [ + migrations.AddField( + model_name="analyticsconfiguration", + name="enable_google_analytics", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="analyticsconfiguration", + name="google_analytics_measurement_id", + field=models.CharField( + blank=True, + help_text="Google Analytics 4 measurement ID (for example, G-XXXXXXXXXX).", + max_length=255, + null=True, + ), + ), + ] diff --git a/django/core/web/models.py b/django/core/web/models.py index a6fa7479..4411c50f 100644 --- a/django/core/web/models.py +++ b/django/core/web/models.py @@ -136,6 +136,13 @@ class AnalyticsConfiguration(SingletonModel): ) enable_clarity_analytics = models.BooleanField(default=False) clarity_project_id = models.CharField(max_length=255, blank=True, null=True) + enable_google_analytics = models.BooleanField(default=False) + google_analytics_measurement_id = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="Google Analytics 4 measurement ID (for example, G-XXXXXXXXXX).", + ) def __str__(self): return "Analytics Configuration" diff --git a/django/interfaces/web/views.py b/django/interfaces/web/views.py index 63da79e6..63b8ce03 100644 --- a/django/interfaces/web/views.py +++ b/django/interfaces/web/views.py @@ -89,6 +89,8 @@ def get_app_settings_context(): "analyticsConfiguration": { "enableClarityAnalytics": instance_configuration.analytics_configuration.enable_clarity_analytics, "clarityProjectId": instance_configuration.analytics_configuration.clarity_project_id, + "enableGoogleAnalytics": instance_configuration.analytics_configuration.enable_google_analytics, + "googleAnalyticsMeasurementId": instance_configuration.analytics_configuration.google_analytics_measurement_id, }, "legalInformation": { "termsOfUseLink": instance_configuration.terms_of_use_link, diff --git a/django/tests/web/test_analytics.py b/django/tests/web/test_analytics.py new file mode 100644 index 00000000..f65b63dd --- /dev/null +++ b/django/tests/web/test_analytics.py @@ -0,0 +1,65 @@ +import pytest +from django.urls import reverse + +from core.web.models import InstanceConfiguration +from interfaces.web.views import get_app_settings_context + + +def test_analytics_disabled_by_default(): + assert get_app_settings_context()["analyticsConfiguration"] == { + "enableClarityAnalytics": False, + "clarityProjectId": None, + "enableGoogleAnalytics": False, + "googleAnalyticsMeasurementId": None, + } + + +@pytest.mark.parametrize("enable_google", [False, True]) +@pytest.mark.parametrize("enable_clarity", [False, True]) +def test_admin_can_configure_analytics_independently( + admin_client, enable_google, enable_clarity +): + instance = InstanceConfiguration.get_configuration() + configuration = instance.analytics_configuration + data = { + "instance_configuration": instance.pk, + "clarity_project_id": "clarity-project", + "google_analytics_measurement_id": "G-TEST123456", + } + if enable_google: + data["enable_google_analytics"] = "on" + if enable_clarity: + data["enable_clarity_analytics"] = "on" + + response = admin_client.post( + reverse("admin:web_analyticsconfiguration_change", args=(configuration.pk,)), + data, + ) + + assert response.status_code == 302 + assert get_app_settings_context()["analyticsConfiguration"] == { + "enableClarityAnalytics": enable_clarity, + "clarityProjectId": "clarity-project", + "enableGoogleAnalytics": enable_google, + "googleAnalyticsMeasurementId": "G-TEST123456", + } + + +def test_admin_can_leave_analytics_ids_blank(admin_client): + instance = InstanceConfiguration.get_configuration() + response = admin_client.post( + reverse( + "admin:web_analyticsconfiguration_change", + args=(instance.analytics_configuration.pk,), + ), + { + "instance_configuration": instance.pk, + "clarity_project_id": "", + "google_analytics_measurement_id": "", + }, + ) + + assert response.status_code == 302 + configuration = get_app_settings_context()["analyticsConfiguration"] + assert not configuration["enableGoogleAnalytics"] + assert not configuration["googleAnalyticsMeasurementId"]