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
90 changes: 90 additions & 0 deletions frontend/src/utils/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, EventListener[]>;

beforeEach(async () => {
vi.resetModules();
Expand All @@ -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/",
Expand Down Expand Up @@ -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<PointerEvent>) => {
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();
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/utils/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
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";
Expand Down Expand Up @@ -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()
Expand Down
Loading