Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/data-management/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const defaultSettings: AppSettings = {
analyticsConfiguration: {
enableClarityAnalytics: false,
clarityProjectId: null,
enableGoogleAnalytics: false,
googleAnalyticsMeasurementId: null,
},
legalInformation: {
termsOfUseLink: null,
Expand Down
6 changes: 6 additions & 0 deletions apps/data-management/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
}

Expand Down
2 changes: 2 additions & 0 deletions apps/data-management/src/models/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ interface MapConfiguration {
interface AnalyticsConfiguration {
enableClarityAnalytics: boolean
clarityProjectId?: string | null
enableGoogleAnalytics: boolean
googleAnalyticsMeasurementId?: string | null
}

interface LegalInformation {
Expand Down
39 changes: 39 additions & 0 deletions apps/data-management/src/plugins/__tests__/googleAnalytics.spec.ts
Original file line number Diff line number Diff line change
@@ -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])
})
})
21 changes: 21 additions & 0 deletions apps/data-management/src/plugins/googleAnalytics.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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

Expand Down
25 changes: 25 additions & 0 deletions django/core/web/migrations/0003_google_analytics.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
7 changes: 7 additions & 0 deletions django/core/web/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions django/interfaces/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions django/tests/web/test_analytics.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading