Skip to content

16 — Analytics and Tracking

PRD Document · Savoy Signature Hotels — Multi-Site Headless Platform
Version: 1.0 · Date: 2026-03-04
Related docs: 15_Security_and_Data_Protection.md, 04_Frontend_Architecture.md


This document defines the analytics and tracking strategy for the Savoy Signature platform. It covers the Google Tag Manager (GTM) architecture, the dataLayer specification, consent-aware script loading via Cookiebot, and the events taxonomy used across all 8 sites.


LayerToolPurpose
Tag ManagementGoogle Tag Manager (GTM)Central hub for all marketing and analytics tags
Web AnalyticsGoogle Analytics 4 (GA4)Traffic, engagement, conversions
Consent ManagementCookiebotGDPR-compliant consent gate for all tracking scripts
Server-SideAzure Application InsightsBackend performance, API errors, server health

Third-Party

Google

Browser

Consent granted

Events + Data

Pageviews, Events

Conversions

Booking clicks

Cookiebot Consent

dataLayer

GTM Container

Google Analytics 4

Meta Pixel

Navarino Events


3.1 Container Strategy — Global Default + Per-Hotel Override

Section titled “3.1 Container Strategy — Global Default + Per-Hotel Override”

The platform supports a global default GTM container and GA4 Measurement ID, with the ability to override per hotel. Each hotel may have its own GTM container and/or its own independent GA4 property.

Config LevelGTM Container IDGA4 Measurement IDDefined In
Global DefaultGTM-XXXXXXXG-XXXXXXXSite Registry (packages/config/sites.ts)
Per-Hotel OverrideGTM-YYYYYYY (optional)G-YYYYYYY (optional)Umbraco siteRoot properties
helpers/analytics-config.ts
export function getAnalyticsConfig(siteConfig: SiteConfig): AnalyticsConfig {
return {
// Per-hotel override takes precedence over global default
gtmId: siteConfig.gtmContainerId || process.env.NEXT_PUBLIC_GTM_ID || null,
ga4Id: siteConfig.ga4MeasurementId || process.env.NEXT_PUBLIC_GA4_ID || null,
};
}

Umbraco siteRoot Properties (Analytics Tab)

Section titled “Umbraco siteRoot Properties (Analytics Tab)”
PropertyTypeDescription
gtmContainerIdTextstringHotel-specific GTM container ID. Leave empty to use global default.
ga4MeasurementIdTextstringHotel-specific GA4 Measurement ID. Leave empty to use global default.
enableGtmToggleEnable/disable GTM for this site (default: true)
enableGa4DirectToggleLoad GA4 gtag.js independently, outside GTM (default: false)

[!IMPORTANT] GA4 can be loaded in two ways:

  1. Via GTM (default): GA4 is configured as a tag inside the GTM container. This is the recommended approach.
  2. Directly via gtag.js (independent): When enableGa4Direct is true, the GA4 script is loaded directly in the page <head>, independently of GTM. This is useful when the hotel’s GA4 is not configured inside GTM.

Both can coexist — a hotel may have GTM for marketing tags AND a separate direct GA4 for a different analytics property.

GTM VariableSourceValue Example
siteKeydataLayersavoy-palace
localedataLayerpt
pageTypedataLayerroomDetailPage
environmentdataLayerproduction

GTM is loaded with the afterInteractive strategy to avoid blocking LCP:

apps/web/src/app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }) {
const analytics = getAnalyticsConfig(siteConfig);
const isProduction = process.env.APP_ENV === 'production';
return (
<html>
<head>
{/* Cookiebot must load BEFORE any tracking scripts */}
<script
id="Cookiebot"
src="https://consent.cookiebot.com/uc.js"
data-cbid={process.env.NEXT_PUBLIC_COOKIEBOT_ID}
data-blockingmode="auto"
type="text/javascript"
/>
{/* Direct GA4 (independent of GTM) — only if enabled for this hotel */}
{isProduction && analytics.ga4Id && siteConfig.enableGa4Direct && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${analytics.ga4Id}`}
strategy="afterInteractive"
/>
<Script id="ga4-config" strategy="afterInteractive">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${analytics.ga4Id}');`}
</Script>
</>
)}
</head>
<body>
{children}
{/* GTM — uses per-hotel ID or global default */}
{isProduction && analytics.gtmId && siteConfig.enableGtm !== false && (
<Script
id="gtm"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${analytics.gtmId}');
`,
}}
/>
)}
</body>
</html>
);
}

[!IMPORTANT] GTM script must not load on non-production environments (DEV, STAGE, QA). Use process.env.APP_ENV === 'production' to conditionally render the GTM &lt;Script&gt; tag.


4.1 Page View Event (Auto-fired on every navigation)

Section titled “4.1 Page View Event (Auto-fired on every navigation)”
// Pushed on every page render in the [locale]/layout.tsx
window.dataLayer.push({
event: 'page_view',
siteKey: 'savoy-palace',
locale: 'pt',
pageType: 'roomDetailPage',
pagePath: '/pt/alojamento/quartos/deluxe-ocean',
pageTitle: 'Deluxe Ocean | Savoy Palace',
environment: 'production',
});
Event NameTriggerKey Parameters
page_viewEvery page navigationsiteKey, locale, pageType, pagePath, pageTitle
booking_bar_openUser clicks “Book Now” or interacts with Booking BarsiteKey, locale, variant (inline/sticky/overlay)
booking_bar_submitUser submits dates → redirect to SynxissiteKey, locale, checkIn, checkOut, guests, promoCode
form_submitContact/enquiry form submitted successfullysiteKey, locale, formId, formName
newsletter_signupNewsletter form submitted successfullysiteKey, locale, email_domain (anonymized)
gallery_openUser opens the lightbox gallerysiteKey, locale, galleryName
cta_clickUser clicks a CTA button (hero, card, etc.)siteKey, locale, ctaLabel, ctaDestination
language_switchUser changes language via switchersiteKey, fromLocale, toLocale
scroll_depthUser scrolls 25% / 50% / 75% / 100%siteKey, locale, depth
file_downloadUser downloads a PDF (menu, brochure)siteKey, locale, fileName, fileType
packages/ui/src/hooks/useTrackEvent.ts
'use client';
export function useTrackEvent() {
return function trackEvent(eventName: string, params: Record<string, string | number>) {
if (typeof window !== 'undefined' && window.dataLayer) {
window.dataLayer.push({
event: eventName,
...params,
});
}
};
}
// Usage:
const trackEvent = useTrackEvent();
trackEvent('booking_bar_submit', { siteKey, locale, checkIn, checkOut, guests: 2 });

All non-essential tracking is blocked until user opt-in via Cookiebot.

CategoryExamplesBlocked Before Consent?
NecessaryLocale cookie, CSRF token, Cloudflare __cf_bm❌ No (always active)
StatisticsGA4, scroll depth, page_view events✅ Yes
MarketingMeta Pixel, Google Ads remarketing✅ Yes
PreferencesLanguage preference cookie (if separate from locale URL)✅ Yes
// Only fire analytics event if user has consented to statistics cookies
if (window.Cookiebot?.consent?.statistics) {
trackEvent('page_view', { ... });
}

  • GTM container loads only on production, after Cookiebot consent is initialized.
  • Per-hotel GTM/GA4 overrides work correctly (hotel-specific ID takes precedence over global default).
  • Direct GA4 (gtag.js) loads independently when enableGa4Direct is enabled, even without GTM.
  • page_view event fires on every client-side navigation with correct siteKey, locale, and pageType.
  • All custom events defined in the taxonomy are implemented and testable in GTM Preview Mode.
  • GA4 correctly receives events segmented by site property.
  • No tracking cookies are set before user consent via Cookiebot.
  • dataLayer is populated server-side on initial render (for SSR pages) and client-side on subsequent navigations.
  • Meta Pixel and other marketing tags are gated behind “Marketing” consent category.

Next document: 17_AB_Testing_and_Experimentation.md