diff --git a/src/lib/analytics/gtag.ts b/src/lib/analytics/gtag.ts
new file mode 100644
index 00000000..564d06ef
--- /dev/null
+++ b/src/lib/analytics/gtag.ts
@@ -0,0 +1,64 @@
+// Google Analytics 4 (gtag.js) plumbing.
+//
+// SvelteKit is a single-page app after hydration, so GA's automatic page_view
+// only fires for the very first server response. We disable that automatic hit
+// (`send_page_view: false`) and emit a page_view ourselves on every client-side
+// navigation. See Analytics.svelte for the lifecycle wiring.
+
+type GtagArgs = unknown[];
+
+declare global {
+ interface Window {
+ dataLayer?: GtagArgs[];
+ gtag?: (...args: GtagArgs) => void;
+ }
+}
+
+// The id we have already injected, so initAnalytics is idempotent and
+// setAnalyticsUser knows which config to address.
+let activeId: string | null = null;
+
+/**
+ * Inject gtag.js for `measurementId` and prime the data layer. Browser-only and
+ * idempotent — safe to call from afterNavigate on every navigation. The initial
+ * automatic page_view is disabled; callers emit page_view via trackPageView.
+ */
+export function initAnalytics(measurementId: string, options: { userId?: string | null } = {}): void {
+ if (typeof window === 'undefined' || !measurementId || activeId === measurementId) {
+ return;
+ }
+ activeId = measurementId;
+
+ const script = document.createElement('script');
+ script.async = true;
+ script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`;
+ document.head.appendChild(script);
+
+ window.dataLayer = window.dataLayer ?? [];
+ // gtag forwards its raw argument list onto the data layer; gtag.js reads each
+ // entry by index/length, so pushing the rest array behaves like `arguments`.
+ window.gtag = function gtag(...args: GtagArgs) {
+ window.dataLayer!.push(args);
+ };
+ window.gtag('js', new Date());
+ window.gtag('config', measurementId, {
+ send_page_view: false,
+ ...(options.userId ? { user_id: options.userId } : {})
+ });
+}
+
+/** Emit a page_view for the current document/location. No-op until initAnalytics has run. */
+export function trackPageView(path: string): void {
+ if (typeof window === 'undefined' || !window.gtag) return;
+ window.gtag('event', 'page_view', {
+ page_path: path,
+ page_location: window.location.href,
+ page_title: document.title
+ });
+}
+
+/** Align GA's user_id with the logged-in user (pass null on logout). No-op until initAnalytics has run. */
+export function setAnalyticsUser(userId: string | null): void {
+ if (typeof window === 'undefined' || !window.gtag || !activeId) return;
+ window.gtag('config', activeId, { user_id: userId });
+}
diff --git a/src/lib/components/Analytics.svelte b/src/lib/components/Analytics.svelte
new file mode 100644
index 00000000..ea40a9b1
--- /dev/null
+++ b/src/lib/components/Analytics.svelte
@@ -0,0 +1,39 @@
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 23af5280..54a24cee 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -16,6 +16,7 @@
import { page } from '$app/state';
import OverviewDrawer from './OverviewDrawer.svelte';
import Sidebar from './Sidebar.svelte';
+ import Analytics from '$lib/components/Analytics.svelte';
import { createAppContext, setAppContext } from '$lib/appContext.svelte';
import type { DeviceStatusSummary, RuleTemplateDto } from '$lib/api/api.dtos';
import type { IJWT } from '$lib/interfaces/jwt.interface';
@@ -114,6 +115,8 @@
+
+