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
64 changes: 64 additions & 0 deletions src/lib/analytics/gtag.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
39 changes: 39 additions & 0 deletions src/lib/components/Analytics.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
// Google Analytics 4 for app.cropwatch.io.
//
// Mounted once in the root layout so it covers every route (including /auth).
// page_view is emitted on each client-side navigation, and GA's user_id is
// kept in sync with the logged-in Supabase user so per-user journeys are
// visible across pages and devices. Set PUBLIC_GA_MEASUREMENT_ID to enable;
// disabled automatically in dev so local traffic never reaches the property.
import { afterNavigate } from '$app/navigation';
import { dev } from '$app/environment';
import { tick } from 'svelte';
import { page } from '$app/state';
import { env } from '$env/dynamic/public';
import { getAppContext } from '$lib/appContext.svelte';
import { initAnalytics, trackPageView, setAnalyticsUser } from '$lib/analytics/gtag';

const app = getAppContext();

// env is read inside browser-only callbacks (never at module top level) so it
// is never touched during SSR/prerender, which $env/dynamic/public forbids.
const measurementId = () => (dev ? '' : (env.PUBLIC_GA_MEASUREMENT_ID ?? ''));

// Keep GA's user_id aligned with the session `sub` claim. Re-runs on
// login / logout / token refresh; no-ops until initAnalytics has run.
$effect(() => {
const sub = app.session?.sub ?? null;
if (measurementId()) setAnalyticsUser(sub);
});

// afterNavigate fires on first mount AND every client-side navigation, so one
// handler covers the initial page view and all subsequent ones.
afterNavigate(async () => {
const id = measurementId();
if (!id) return;
initAnalytics(id, { userId: app.session?.sub ?? null });
await tick(); // let <svelte:head><title> update before reading document.title
trackPageView(page.url.pathname + page.url.search);
});
</script>
3 changes: 3 additions & 0 deletions src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -114,6 +115,8 @@
<meta name="theme-color" content="#1f283b" media="(prefers-color-scheme: dark)" />
</svelte:head>

<Analytics />

<CwOfflineOverlay labels={cwOfflineOverlayLabels()} />
<CwToastContainer />

Expand Down