diff --git a/src/analytics/PosthogAnalytics.test.ts b/src/analytics/PosthogAnalytics.test.ts index 7c1128ad4..ba51ba276 100644 --- a/src/analytics/PosthogAnalytics.test.ts +++ b/src/analytics/PosthogAnalytics.test.ts @@ -22,75 +22,121 @@ import { PosthogAnalytics, } from "./PosthogAnalytics"; import { mockConfig } from "../utils/test"; +import { analyticsConfigFromEnvironment } from "../initializer"; describe("PosthogAnalytics", () => { - describe("embedded package", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "embedded"); - }); - + describe("enablement", () => { beforeEach(() => { - mockConfig({}); - window.location.hash = "#"; PosthogAnalytics.resetInstance(); }); - afterAll(() => { - vi.unstubAllEnvs(); - }); - - it("does not create instance without config value or URL params", () => { + it("stays off until it is configured", () => { expect(PosthogAnalytics.instance.isEnabled()).toBe(false); }); - it("ignores config value and does not create instance", () => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, + it("stays off when configured without credentials", () => { + PosthogAnalytics.configure({ matrixBackend: "jssdk" }); + expect(PosthogAnalytics.instance.isEnabled()).toBe(false); + }); + + it("stays off when given only a key", () => { + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", }); expect(PosthogAnalytics.instance.isEnabled()).toBe(false); }); - it("uses URL params if both set", () => { - window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; + it("turns on when given both a key and a host", () => { + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }); expect(PosthogAnalytics.instance.isEnabled()).toBe(true); }); }); - describe("full package", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "full"); - }); - + // Which of the URL and config.json the credentials come from is a deliberate + // policy: an embedder is responsible for its own users' telemetry, so it must + // not pick up the deployment's, and vice versa. + describe("analyticsConfigFromEnvironment", () => { beforeEach(() => { mockConfig({}); window.location.hash = "#"; - PosthogAnalytics.resetInstance(); }); afterAll(() => { vi.unstubAllEnvs(); }); - it("does not create instance without config value", () => { - expect(PosthogAnalytics.instance.isEnabled()).toBe(false); - }); + const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`; + const configCredentials = { + posthog: { + api_host: "https://config.example.com.localhost", + api_key: "config_key", + }, + }; - it("ignores URL params and does not create instance", () => { - window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; - expect(PosthogAnalytics.instance.isEnabled()).toBe(false); - }); - - it("creates instance with config value", () => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, + describe("embedded package", () => { + beforeAll(() => { + vi.stubEnv("VITE_PACKAGE", "embedded"); }); - expect(PosthogAnalytics.instance.isEnabled()).toBe(true); + + it("has no credentials without URL params", () => { + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + + it("takes the credentials from the URL", () => { + window.location.hash = `#?${urlCredentials}`; + expect(analyticsConfigFromEnvironment()).toMatchObject({ + apiKey: "url_key", + apiHost: "https://url.example.com.localhost", + }); + }); + + it("ignores the deployment's config", () => { + mockConfig(configCredentials); + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + }); + + describe("full package", () => { + beforeAll(() => { + vi.stubEnv("VITE_PACKAGE", "full"); + }); + + it("has no credentials without config", () => { + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + + it("takes the credentials from the config", () => { + mockConfig(configCredentials); + expect(analyticsConfigFromEnvironment()).toMatchObject({ + apiKey: "config_key", + apiHost: "https://config.example.com.localhost", + }); + }); + + it("ignores the URL params", () => { + window.location.hash = `#?${urlCredentials}`; + expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined(); + }); + }); + + // Who owns the user's analytics identity depends on how Element Call is + // running, not on which package it was built as. + it("reports the embedded backend when running as a widget", () => { + vi.stubEnv("VITE_PACKAGE", "full"); + window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`; + expect(analyticsConfigFromEnvironment()).toMatchObject({ + matrixBackend: "embedded", + hostAnalyticsId: "given_id", + }); + }); + + it("reports the jssdk backend when running standalone", () => { + expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk"); }); }); @@ -204,22 +250,13 @@ describe("PosthogAnalytics", () => { // posthog-js bumps renaming/removing the hook. The filter logic itself is // covered by the applyPrivacyFilters block above. describe("posthog.init wiring", () => { - beforeAll(() => { - vi.stubEnv("VITE_PACKAGE", "full"); - }); - beforeEach(() => { - mockConfig({ - posthog: { - api_host: "https://api.example.com.localhost", - api_key: "api_key", - }, - }); PosthogAnalytics.resetInstance(); - }); - - afterAll(() => { - vi.unstubAllEnvs(); + PosthogAnalytics.configure({ + matrixBackend: "jssdk", + apiKey: "api_key", + apiHost: "https://api.example.com.localhost", + }); }); it("passes events through the privacy filter via before_send", () => { diff --git a/src/analytics/PosthogAnalytics.ts b/src/analytics/PosthogAnalytics.ts index 01a146e0d..01a05bfb9 100644 --- a/src/analytics/PosthogAnalytics.ts +++ b/src/analytics/PosthogAnalytics.ts @@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger"; import { type MatrixClient } from "matrix-js-sdk"; import { type Subscription } from "rxjs"; -import { widget } from "../widget"; import { CallEndedTracker, CallStartedTracker, @@ -29,8 +28,6 @@ import { CallConnectDurationTracker, CallReconnectingTracker, } from "./PosthogEvents"; -import { Config } from "../config/Config"; -import { getUrlParams } from "../UrlParams"; import { optInAnalytics } from "../settings/settings"; /* Posthog analytics tracking. @@ -140,6 +137,27 @@ interface PlatformProperties { cryptoVersion?: string; } +/** + * How analytics reporting should be set up, supplied by whoever is starting + * Element Call rather than discovered from the page it happens to be on. + */ +export interface AnalyticsConfig { + /** The PostHog project key. Without one, analytics stay switched off. */ + apiKey?: string; + apiHost?: string; + /** + * How Element Call reaches Matrix. When `embedded`, the host owns the user's + * identity: it supplies the analytics ID, and Element Call must not store one + * in the user's account data. + */ + matrixBackend: "embedded" | "jssdk"; + /** The analytics ID the host has assigned to this user, when embedded. */ + hostAnalyticsId?: string | null; +} + +/** Analytics are off until someone asks for them. */ +const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" }; + export class PosthogAnalytics { /* Wrapper for Posthog analytics. * 3 modes of anonymity are supported, governed by this.anonymity @@ -167,13 +185,32 @@ export class PosthogAnalytics { private registrationType: RegistrationType = RegistrationType.Guest; private optInListener: Subscription | null = null; + private static analyticsConfig: AnalyticsConfig = analyticsDisabled; + + /** + * Sets up analytics reporting. Must be called before the instance is first + * used; without it, analytics stay switched off. + */ + public static configure(config: AnalyticsConfig): void { + if (this.internalInstance) + // Configuration is read once, when the instance is built, so arriving + // late means analytics are already running unconfigured. + logger.warn( + "Analytics were configured after they had already been started; the new configuration will not take effect", + ); + this.analyticsConfig = config; + } + public static hasInstance(): boolean { return Boolean(this.internalInstance); } public static get instance(): PosthogAnalytics { if (!this.internalInstance) { - this.internalInstance = new PosthogAnalytics(posthog); + this.internalInstance = new PosthogAnalytics( + posthog, + PosthogAnalytics.analyticsConfig, + ); } return this.internalInstance; } @@ -181,20 +218,14 @@ export class PosthogAnalytics { public static resetInstance(): void { // Reset the singleton instance this.internalInstance = null; + this.analyticsConfig = analyticsDisabled; } - private constructor(private readonly posthog: PostHog) { - let apiKey: string | undefined; - let apiHost: string | undefined; - if (import.meta.env.VITE_PACKAGE === "embedded") { - // for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration - apiKey = getUrlParams().posthogApiKey ?? undefined; - apiHost = getUrlParams().posthogApiHost ?? undefined; - } else if (import.meta.env.VITE_PACKAGE === "full") { - // in full package it is the server responsible for the analytics - apiKey = Config.get().posthog?.api_key; - apiHost = Config.get().posthog?.api_host; - } + private constructor( + private readonly posthog: PostHog, + private readonly config: AnalyticsConfig, + ) { + const { apiKey, apiHost } = config; if (apiKey && apiHost) { const beforeSend = (event: CaptureResult | null): CaptureResult | null => @@ -225,15 +256,15 @@ export class PosthogAnalytics { } } - private static getPlatformProperties(): PlatformProperties { + private getPlatformProperties(): PlatformProperties { const appVersion = import.meta.env.VITE_APP_VERSION || "dev"; return { appVersion, - matrixBackend: widget ? "embedded" : "jssdk", + matrixBackend: this.config.matrixBackend, callBackend: "livekit", - cryptoVersion: widget - ? undefined - : window.matrixclient?.getCrypto()?.getVersion(), + // Undefined when Element Call has no crypto of its own, which is the case + // whenever a host is doing the encrypting for it. + cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(), }; } @@ -283,8 +314,8 @@ export class PosthogAnalytics { // different devices to send the same ID. let analyticsID = await this.getAnalyticsId(); try { - if (!analyticsID && !widget) { - // only try setting up a new analytics ID in the standalone app. + if (!analyticsID && this.config.matrixBackend !== "embedded") { + // only mint an analytics ID when we are the ones storing it. // Couldn't retrieve an analytics ID from user settings, so create one and set it on the server. // Note there's a race condition here - if two devices do these steps at the same time, last write @@ -313,8 +344,8 @@ export class PosthogAnalytics { private async getAnalyticsId(): Promise { const client: MatrixClient = window.matrixclient; - if (widget) { - return getUrlParams().posthogUserId; + if (this.config.matrixBackend === "embedded") { + return this.config.hostAnalyticsId ?? null; } else { const accountData = await client.getAccountDataFromServer( PosthogAnalytics.ANALYTICS_EVENT_TYPE, @@ -324,7 +355,7 @@ export class PosthogAnalytics { } private async setAccountAnalyticsId(analyticsID: string): Promise { - if (!widget) { + if (this.config.matrixBackend !== "embedded") { const client = window.matrixclient; // the analytics ID only needs to be set in the standalone version. @@ -362,7 +393,7 @@ export class PosthogAnalytics { // These properties will be subsequently passed in every event. // // This only needs to be done once per page lifetime. Note that getPlatformProperties - this.platformSuperProperties = PosthogAnalytics.getPlatformProperties(); + this.platformSuperProperties = this.getPlatformProperties(); this.registerSuperProperties({ ...this.platformSuperProperties, registrationType: diff --git a/src/initializer.tsx b/src/initializer.tsx index c0e8407ec..0927d7aba 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -34,6 +34,10 @@ import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; import { initializeWidget } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; +import { + type AnalyticsConfig, + PosthogAnalytics, +} from "./analytics/PosthogAnalytics.ts"; import { i18n } from "./utils/i18n.ts"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: @@ -98,6 +102,36 @@ const Backend = { }, } satisfies BackendModule; +/** + * Where analytics reporting is configured from. + * + * Note the two halves are decided differently, and deliberately so. *Where the + * PostHog credentials come from* depends on the package: an embedder passes + * them in through the URL because it is responsible for its own users' + * telemetry, whereas a standalone deployment is configured by whoever operates + * it. *Who owns the user's analytics identity*, on the other hand, depends on + * how Element Call is actually running right now — the full package can be used + * as a widget too. + */ +// Exported for testing +export function analyticsConfigFromEnvironment(): AnalyticsConfig { + const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } = + getUrlParams(); + return { + matrixBackend: isWidget ? "embedded" : "jssdk", + hostAnalyticsId: posthogUserId, + ...(import.meta.env.VITE_PACKAGE === "embedded" + ? { + apiKey: posthogApiKey ?? undefined, + apiHost: posthogApiHost ?? undefined, + } + : { + apiKey: Config.get().posthog?.api_key, + apiHost: Config.get().posthog?.api_host, + }), + }; +} + enum LoadState { None, Loading, @@ -241,6 +275,7 @@ export class Initializer { Config.init().then( () => { seedSettingsFromConfig(Config.get().media_quality); + PosthogAnalytics.configure(analyticsConfigFromEnvironment()); this.loadStates.config = LoadState.Loaded; this.initStep(resolve); },