fix: defer PostHog/Sentry loading until user consents to telemetry

PostHog SDK was initialized on app mount based only on the server-level
config flag, ignoring user consent. This caused network requests to
us-assets.i.posthog.com (config.js, web-vitals.js, dead-clicks-autocapture.js)
even when the user had not opted in or had explicitly declined telemetry.

- Replace static imports of posthog-js and @sentry/react with dynamic
  import() so the SDK bundles are not downloaded until consent is granted
- Gate initAnalytics on analyticsConsent.analyticsEnabled === true,
  not just server config.enabled
- Add consent re-check after each await import() to handle revocation
  during the async load
- Add shutdownAnalytics() that calls opt_out_capturing() + reset()
  for mid-session consent revocation
- setAnalyticsConsent(false) now triggers full SDK shutdown automatically
- Rewrite analytics test suite with 44 tests covering init gating,
  shutdown lifecycle, consent toggle, race conditions, and Sentry callbacks

Closes #98
This commit is contained in:
SnapOtter
2026-04-29 14:16:38 +08:00
parent 03f82567d0
commit 4d3e5e9c02
4 changed files with 377 additions and 195 deletions
+32 -5
View File
@@ -1,8 +1,8 @@
import * as Sentry from "@sentry/react";
import type { AnalyticsConfig } from "@snapotter/shared";
import posthogJs from "posthog-js";
let posthog: import("posthog-js").PostHog | null = null;
type PostHogInstance = import("posthog-js").PostHog;
let posthog: PostHogInstance | null = null;
let initialized = false;
let consentGranted = false;
@@ -14,11 +14,16 @@ function scrubString(str: string): string {
return str.replace(FILE_EXT_PATTERN, ".[REDACTED]").replace(FILE_PATH_PATTERN, "/[REDACTED]/");
}
export function initAnalytics(config: AnalyticsConfig): void {
export async function initAnalytics(config: AnalyticsConfig): Promise<void> {
if (initialized || !config.enabled) return;
initialized = true;
try {
const posthogJs = (await import("posthog-js")).default;
if (!consentGranted) {
initialized = false;
return;
}
posthog =
posthogJs.init(config.posthogApiKey, {
api_host: config.posthogHost,
@@ -35,11 +40,16 @@ export function initAnalytics(config: AnalyticsConfig): void {
persistence: "localStorage",
}) ?? null;
} catch {
// SDK blocked or unavailable — use null provider
// SDK blocked or unavailable
}
try {
if (config.sentryDsn) {
const Sentry = await import("@sentry/react");
if (!consentGranted) {
initialized = false;
return;
}
Sentry.init({
dsn: config.sentryDsn,
sendDefaultPii: false,
@@ -81,8 +91,25 @@ export function initAnalytics(config: AnalyticsConfig): void {
}
}
export function shutdownAnalytics(): void {
if (posthog) {
try {
posthog.opt_out_capturing();
posthog.reset();
} catch {
// never throw
}
}
posthog = null;
initialized = false;
consentGranted = false;
}
export function setAnalyticsConsent(enabled: boolean): void {
consentGranted = enabled;
if (!enabled) {
shutdownAnalytics();
}
}
export function identify(instanceId: string, properties: Record<string, unknown>): void {