diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx
index ede9ed266a..f050ae7f9b 100644
--- a/frontend/editor/src/core/components/AppProviders.tsx
+++ b/frontend/editor/src/core/components/AppProviders.tsx
@@ -27,6 +27,7 @@ import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestra
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
+import { usePosthogTracking } from "@app/hooks/usePosthogTracking";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
@@ -43,6 +44,11 @@ function ScarfTrackingInitializer() {
return null;
}
+function PosthogTrackingInitializer() {
+ usePosthogTracking();
+ return null;
+}
+
// Component to run app-level initialization (must be inside AppProviders for context access)
function AppInitializer() {
useAppInitialization();
@@ -122,6 +128,7 @@ export function AppProviders({
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
+
diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx
new file mode 100644
index 0000000000..41f5fad7dc
--- /dev/null
+++ b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx
@@ -0,0 +1,76 @@
+import { ReactNode } from "react";
+import { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AppConfigProvider } from "@app/contexts/AppConfigContext";
+
+const posthogState = vi.hoisted(() => ({ loaded: false }));
+const posthogMock = vi.hoisted(() => ({
+ get __loaded() {
+ return posthogState.loaded;
+ },
+ init: vi.fn(() => {
+ posthogState.loaded = true;
+ }),
+ opt_out_capturing: vi.fn(),
+ opt_in_capturing: vi.fn(),
+ set_config: vi.fn(),
+ has_opted_in_capturing: vi.fn(() => false),
+}));
+
+vi.mock("posthog-js", () => ({
+ default: posthogMock,
+}));
+
+import { usePosthogTracking } from "@app/hooks/usePosthogTracking";
+
+describe("usePosthogTracking", () => {
+ beforeEach(() => {
+ posthogState.loaded = false;
+ posthogMock.init.mockClear();
+ posthogMock.opt_out_capturing.mockClear();
+ posthogMock.opt_in_capturing.mockClear();
+ posthogMock.set_config.mockClear();
+ vi.stubEnv("VITE_PUBLIC_POSTHOG_KEY", "test-key");
+ vi.stubEnv("VITE_PUBLIC_POSTHOG_HOST", "https://eu.i.posthog.com");
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it("does not initialize PostHog when analytics is disabled", async () => {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ renderHook(() => usePosthogTracking(), { wrapper });
+
+ await waitFor(() => {
+ expect(posthogMock.init).not.toHaveBeenCalled();
+ });
+ });
+
+ it("initializes PostHog when analytics is enabled", async () => {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+
+ renderHook(() => usePosthogTracking(), { wrapper });
+
+ await waitFor(() => {
+ expect(posthogMock.init).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.ts b/frontend/editor/src/core/hooks/usePosthogTracking.ts
new file mode 100644
index 0000000000..a8037ec4a7
--- /dev/null
+++ b/frontend/editor/src/core/hooks/usePosthogTracking.ts
@@ -0,0 +1,83 @@
+import { useEffect } from "react";
+import posthog from "posthog-js";
+import { useAppConfig } from "@app/contexts/AppConfigContext";
+
+function applyPosthogConsent(): void {
+ if (typeof window === "undefined" || !posthog.__loaded) {
+ return;
+ }
+
+ const optedIn =
+ window.CookieConsent?.acceptedService?.("posthog", "analytics") || false;
+
+ if (optedIn) {
+ posthog.set_config({ persistence: "localStorage+cookie" });
+ posthog.opt_in_capturing();
+ return;
+ }
+
+ posthog.opt_out_capturing();
+ posthog.set_config({ persistence: "memory" });
+}
+
+function ensurePosthogInitialized(): boolean {
+ if (typeof window === "undefined") {
+ return false;
+ }
+
+ const posthogKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY;
+ const posthogHost = import.meta.env.VITE_PUBLIC_POSTHOG_HOST;
+
+ if (!posthogKey || !posthogHost) {
+ return false;
+ }
+
+ if (!posthog.__loaded) {
+ posthog.init(posthogKey, {
+ api_host: posthogHost,
+ defaults: "2025-05-24",
+ capture_exceptions: true,
+ debug: false,
+ opt_out_capturing_by_default: true,
+ persistence: "memory",
+ cross_subdomain_cookie: false,
+ });
+ }
+
+ return true;
+}
+
+export function usePosthogTracking(): void {
+ const { config } = useAppConfig();
+
+ useEffect(() => {
+ const analyticsEnabled = config?.enableAnalytics === true;
+ const posthogEnabled = analyticsEnabled && config?.enablePosthog !== false;
+
+ if (!posthogEnabled) {
+ if (posthog.__loaded) {
+ posthog.opt_out_capturing();
+ posthog.set_config({ persistence: "memory" });
+ }
+ return;
+ }
+
+ if (!ensurePosthogInitialized()) {
+ return;
+ }
+
+ applyPosthogConsent();
+
+ const handleConsentChange = () => {
+ applyPosthogConsent();
+ };
+
+ window.addEventListener("cc:onConsent", handleConsentChange);
+ window.addEventListener("cc:onChange", handleConsentChange);
+
+ return () => {
+ window.removeEventListener("cc:onConsent", handleConsentChange);
+ window.removeEventListener("cc:onChange", handleConsentChange);
+ };
+ }, [config?.enableAnalytics, config?.enablePosthog]);
+}
diff --git a/frontend/editor/src/index.tsx b/frontend/editor/src/index.tsx
index 017138cebc..d776918bd1 100644
--- a/frontend/editor/src/index.tsx
+++ b/frontend/editor/src/index.tsx
@@ -13,8 +13,6 @@ import { ColorSchemeScript } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import App from "@app/App";
import "@app/i18n"; // Initialize i18next
-import posthog from "posthog-js";
-import { PostHogProvider } from "@posthog/react";
import { BASE_PATH } from "@app/constants/app";
import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler";
@@ -35,33 +33,6 @@ if (typeof window !== "undefined") {
}
}
-posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
- api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST,
- defaults: "2025-05-24",
- capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
- debug: false,
- opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent
- persistence: "memory", // No cookies/localStorage written until user opts in
- cross_subdomain_cookie: false,
-});
-
-function updatePosthogConsent() {
- if (!posthog.__loaded) return;
- const optIn =
- window.CookieConsent?.acceptedService?.("posthog", "analytics") || false;
- if (optIn) {
- posthog.set_config({ persistence: "localStorage+cookie" });
- posthog.opt_in_capturing();
- } else {
- posthog.opt_out_capturing();
- posthog.set_config({ persistence: "memory" });
- }
- console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out");
-}
-
-window.addEventListener("cc:onConsent", updatePosthogConsent);
-window.addEventListener("cc:onChange", updatePosthogConsent);
-
const container = document.getElementById("root");
if (!container) {
throw new Error("Root container missing in index.html");
@@ -71,10 +42,8 @@ const root = ReactDOM.createRoot(container); // Finds the root DOM element
root.render(
-
-
-
-
-
+
+
+
,
);