diff --git a/frontend/src/utils/analytics.test.ts b/frontend/src/utils/analytics.test.ts index f14171de..d98f96e6 100644 --- a/frontend/src/utils/analytics.test.ts +++ b/frontend/src/utils/analytics.test.ts @@ -24,6 +24,7 @@ let sanitizeAnalyticsUrl: typeof import("./analytics").sanitizeAnalyticsUrl; let sanitizeEventProperties: typeof import("./analytics").sanitizeEventProperties; let sanitizePostHogCapture: typeof import("./analytics").sanitizePostHogCapture; let trackPageView: typeof import("./analytics").trackPageView; +let documentListeners: Map; beforeEach(async () => { vi.resetModules(); @@ -39,6 +40,20 @@ beforeEach(async () => { }; })(); vi.stubGlobal("localStorage", storage); + documentListeners = new Map(); + vi.stubGlobal( + "Element", + class { + classList = { contains: () => false }; + }, + ); + vi.stubGlobal("document", { + addEventListener: (type: string, listener: EventListener) => { + const listeners = documentListeners.get(type) ?? []; + listeners.push(listener); + documentListeners.set(type, listeners); + }, + }); vi.stubGlobal("window", { location: { href: "https://deadtrees.earth/", @@ -218,6 +233,81 @@ describe("initializePostHog", () => { ); }); + it("records only bounded OpenLayers canvas frames", () => { + initializePostHog("accepted"); + + expect(posthogMock.init).toHaveBeenCalledWith( + "ph_test_key", + expect.objectContaining({ + session_recording: { + captureCanvas: { + recordCanvas: true, + canvasFps: 1, + canvasQuality: "0.3", + }, + canvasCapture: { + resolutionScale: 0.5, + maskRegionsFn: expect.any(Function), + }, + }, + }), + ); + + const config = posthogMock.init.mock.calls[0]?.[1]; + const scannerCanvas = { + closest: () => null, + } as unknown as HTMLCanvasElement; + + expect( + config?.session_recording.canvasCapture.maskRegionsFn(scannerCanvas), + ).toBeNull(); + }); + + it("captures settled and periodic OpenLayers frames without polling", () => { + let now = 10_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + initializePostHog("accepted"); + + const viewport = new Element(); + viewport.classList.contains = (className: string) => + className === "ol-viewport"; + const canvas = { + closest: () => viewport, + } as unknown as HTMLCanvasElement; + const maskRegionsFn = + posthogMock.init.mock.calls[0]?.[1].session_recording.canvasCapture + .maskRegionsFn; + const emit = (type: string, event: Partial) => { + documentListeners + .get(type) + ?.forEach((listener) => + listener({ composedPath: () => [viewport], ...event } as Event), + ); + }; + + expect(maskRegionsFn(canvas)).toEqual([]); + expect(maskRegionsFn(canvas)).toBeNull(); + + now += 5_000; + expect(maskRegionsFn(canvas)).toEqual([]); + expect(maskRegionsFn(canvas)).toBeNull(); + + now += 30_000; + expect(maskRegionsFn(canvas)).toEqual([]); + expect(maskRegionsFn(canvas)).toBeNull(); + + emit("pointerdown", { pointerId: 7 }); + expect(maskRegionsFn(canvas)).toBeNull(); + + emit("pointerup", { pointerId: 7 }); + now += 999; + expect(maskRegionsFn(canvas)).toBeNull(); + + now += 2; + expect(maskRegionsFn(canvas)).toEqual([]); + expect(maskRegionsFn(canvas)).toBeNull(); + }); + it("falls back to direct EU ingestion when the proxy host is unset", async () => { vi.stubEnv("VITE_POSTHOG_API_HOST", ""); vi.resetModules(); diff --git a/frontend/src/utils/analytics.ts b/frontend/src/utils/analytics.ts index 608f0649..2d587502 100644 --- a/frontend/src/utils/analytics.ts +++ b/frontend/src/utils/analytics.ts @@ -35,6 +35,108 @@ const POSTHOG_API_HOST = resolvePostHogApiHost( let hasInitializedPostHog = false; let initializedPostHogMode: "limited" | "accepted" | null = null; +const MAP_CANVAS_CAPTURE_RESUME_DELAY_MS = 1_000; +const MAP_CANVAS_INITIAL_REFRESH_MS = 5_000; +const MAP_CANVAS_PERIODIC_REFRESH_MS = 30_000; +const activeMapPointerIds = new Set(); +const mapCanvasCaptureState = new WeakMap< + HTMLCanvasElement, + { capturedAt: number; captureCount: number; generation: number } +>(); +let mapCanvasGeneration = 0; +let mapCanvasCapturePausedUntil = 0; +let hasInstalledMapCaptureListeners = false; + +const eventTargetsOpenLayersMap = (event: Event): boolean => + event + .composedPath() + .some( + (target) => + target instanceof Element && target.classList.contains("ol-viewport"), + ); + +const pauseMapCanvasCapture = (): void => { + mapCanvasCapturePausedUntil = + Date.now() + MAP_CANVAS_CAPTURE_RESUME_DELAY_MS; +}; + +const markMapCanvasChanged = (): void => { + mapCanvasGeneration += 1; + pauseMapCanvasCapture(); +}; + +const installMapCaptureListeners = (): void => { + if (hasInstalledMapCaptureListeners || typeof document === "undefined") return; + + document.addEventListener( + "pointerdown", + (event) => { + if (!eventTargetsOpenLayersMap(event)) return; + activeMapPointerIds.add(event.pointerId); + markMapCanvasChanged(); + }, + true, + ); + const finishPointerInteraction = (event: PointerEvent): void => { + if (!activeMapPointerIds.delete(event.pointerId)) return; + pauseMapCanvasCapture(); + }; + document.addEventListener("pointerup", finishPointerInteraction, true); + document.addEventListener("pointercancel", finishPointerInteraction, true); + document.addEventListener( + "wheel", + (event) => { + if (eventTargetsOpenLayersMap(event)) markMapCanvasChanged(); + }, + { capture: true, passive: true }, + ); + hasInstalledMapCaptureListeners = true; +}; + +const getCanvasMaskRegions = (canvas: HTMLCanvasElement): [] | null => { + const isMapCanvas = canvas.closest(".ol-viewport") !== null; + if (!isMapCanvas) return null; + + const now = Date.now(); + const isMapInteractionActive = + activeMapPointerIds.size > 0 || now < mapCanvasCapturePausedUntil; + if (isMapInteractionActive) return null; + + const previousCapture = mapCanvasCaptureState.get(canvas); + const refreshInterval = + previousCapture?.captureCount === 1 + ? MAP_CANVAS_INITIAL_REFRESH_MS + : MAP_CANVAS_PERIODIC_REFRESH_MS; + const hasPendingInteractionFrame = + previousCapture?.generation !== mapCanvasGeneration; + const isPeriodicRefreshDue = + previousCapture !== undefined && + now - previousCapture.capturedAt >= refreshInterval; + + if (!hasPendingInteractionFrame && !isPeriodicRefreshDue) { + return null; + } + + mapCanvasCaptureState.set(canvas, { + capturedAt: now, + captureCount: (previousCapture?.captureCount ?? 0) + 1, + generation: mapCanvasGeneration, + }); + return []; +}; + +const MAP_SAFE_SESSION_RECORDING_CONFIG = { + captureCanvas: { + recordCanvas: true, + canvasFps: 1, + canvasQuality: "0.3", + }, + canvasCapture: { + resolutionScale: 0.5, + maskRegionsFn: getCanvasMaskRegions, + }, +} as const; + export const COOKIE_CONSENT_VERSION = "1.1"; export const COOKIE_CONSENT_KEY = "cookieConsent"; export const COOKIE_CONSENT_VERSION_KEY = "cookieConsentVersion"; @@ -509,11 +611,14 @@ export const initializePostHog = (consent: string | null = null): void => { autocapture: mode === "accepted", capture_exceptions: mode === "accepted", disable_session_recording: mode !== "accepted", + session_recording: MAP_SAFE_SESSION_RECORDING_CONFIG, capture_pageview: false, capture_pageleave: false, before_send: sanitizePostHogCapture, } as const; + if (mode === "accepted") installMapCaptureListeners(); + // Persisted opt-in/out flags survive reloads, so they are not a safe proxy for whether // the current page has actually initialized PostHog after a deploy. We initialize the // SDK once per page load, then use set_config for consent upgrades because posthog.init()