diff --git a/apps/desktop/layer/renderer/src/initialize/analytics.ts b/apps/desktop/layer/renderer/src/initialize/analytics.ts index cd027c7c3..02e18d7db 100644 --- a/apps/desktop/layer/renderer/src/initialize/analytics.ts +++ b/apps/desktop/layer/renderer/src/initialize/analytics.ts @@ -1,5 +1,10 @@ import { env } from "@follow/shared/env.desktop" -import { setFirebaseTracker, setPostHogTracker, tracker } from "@follow/tracker" +import { + createPostHogBeforeSend, + setFirebaseTracker, + setPostHogTracker, + tracker, +} from "@follow/tracker" import { captureAttributionFromURL, getAttributionForAnalytics } from "@follow/utils" import type { AuthSessionResponse } from "@follow-app/client-sdk" import posthog from "posthog-js" @@ -30,6 +35,7 @@ export const initAnalytics = async () => { api_host: env.VITE_POSTHOG_HOST, person_profiles: "identified_only", defaults: "2025-05-24", + before_send: createPostHogBeforeSend(), capture_exceptions: { capture_unhandled_errors: true, capture_unhandled_rejections: true, diff --git a/apps/mobile/src/initialize/analytics.ts b/apps/mobile/src/initialize/analytics.ts index b182cceba..d123a928f 100644 --- a/apps/mobile/src/initialize/analytics.ts +++ b/apps/mobile/src/initialize/analytics.ts @@ -1,5 +1,10 @@ import { whoami } from "@follow/store/user/getters" -import { setFirebaseTracker, setPostHogTracker, tracker } from "@follow/tracker" +import { + createPostHogBeforeSend, + setFirebaseTracker, + setPostHogTracker, + tracker, +} from "@follow/tracker" import type { AuthUser } from "@follow-app/client-sdk" import { getAnalytics } from "@react-native-firebase/analytics" import { nativeApplicationVersion, nativeBuildVersion } from "expo-application" @@ -14,6 +19,7 @@ export const initAnalytics = async () => { setPostHogTracker( new PostHog(proxyEnv.POSTHOG_KEY, { host: proxyEnv.POSTHOG_HOST, + before_send: createPostHogBeforeSend(), errorTracking: { autocapture: { uncaughtExceptions: true, diff --git a/packages/internal/tracker/src/adapters/index.ts b/packages/internal/tracker/src/adapters/index.ts index d8958fc1d..9973772b1 100644 --- a/packages/internal/tracker/src/adapters/index.ts +++ b/packages/internal/tracker/src/adapters/index.ts @@ -1,4 +1,9 @@ export type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base" export { FirebaseAdapter, type FirebaseAdapterConfig } from "./firebase" -export { PostHogAdapter, type PostHogAdapterConfig } from "./posthog" +export { + createPostHogBeforeSend, + POSTHOG_SAMPLE_RATE, + PostHogAdapter, + type PostHogAdapterConfig, +} from "./posthog" export { ProxyAdapter } from "./proxy" diff --git a/packages/internal/tracker/src/adapters/posthog.test.ts b/packages/internal/tracker/src/adapters/posthog.test.ts new file mode 100644 index 000000000..a07064e37 --- /dev/null +++ b/packages/internal/tracker/src/adapters/posthog.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest" + +import { createPostHogBeforeSend, POSTHOG_SAMPLE_RATE } from "./posthog" + +describe("PostHogAdapter", () => { + it("uses a 5% PostHog sample rate", () => { + expect(POSTHOG_SAMPLE_RATE).toBe(0.05) + }) + + it("drops PostHog events when outside the configured sample rate", () => { + const beforeSend = createPostHogBeforeSend(0.05, () => 0.5) + + expect(beforeSend({ event: "app init" })).toBeNull() + }) + + it("keeps PostHog events when inside the configured sample rate", () => { + const beforeSend = createPostHogBeforeSend(0.05, () => 0.04) + const event = { event: "app init" } + + expect(beforeSend(event)).toBe(event) + }) +}) diff --git a/packages/internal/tracker/src/adapters/posthog.ts b/packages/internal/tracker/src/adapters/posthog.ts index 715f317d3..083d0340d 100644 --- a/packages/internal/tracker/src/adapters/posthog.ts +++ b/packages/internal/tracker/src/adapters/posthog.ts @@ -3,6 +3,25 @@ import type PostHogReactNative from "posthog-react-native" import type { CaptureExceptionPayload, IdentifyPayload, TrackerAdapter, TrackPayload } from "./base" +export const POSTHOG_SAMPLE_RATE = 0.05 + +const normalizeSampleRate = (sampleRate: number): number => { + if (!Number.isFinite(sampleRate)) return 1 + + return Math.min(Math.max(sampleRate, 0), 1) +} + +export const createPostHogBeforeSend = ( + sampleRate = POSTHOG_SAMPLE_RATE, + sampleRandom: () => number = Math.random, +) => { + const normalizedSampleRate = normalizeSampleRate(sampleRate) + const isSampled = + normalizedSampleRate >= 1 || (normalizedSampleRate > 0 && sampleRandom() < normalizedSampleRate) + + return (event: T): T | null => (isSampled ? event : null) +} + export interface PostHogAdapterConfig { instance: PostHog | PostHogReactNative enabled?: boolean diff --git a/packages/internal/tracker/src/index.ts b/packages/internal/tracker/src/index.ts index 2585a7856..182f01da5 100644 --- a/packages/internal/tracker/src/index.ts +++ b/packages/internal/tracker/src/index.ts @@ -9,9 +9,11 @@ export const tracker = new TrackerPoints() export { type CaptureExceptionPayload, + createPostHogBeforeSend, FirebaseAdapter, type FirebaseAdapterConfig, type IdentifyPayload, + POSTHOG_SAMPLE_RATE, PostHogAdapter, type PostHogAdapterConfig, ProxyAdapter,