Configure analytics explicitly instead of discovering it

PosthogAnalytics read its own configuration out of the environment on
first use: the URL parameters, config.json, and the widget global. An
embedded Element Call has none of those to offer, and analytics that
configure themselves cannot be switched off by a host that does its own
reporting.

Take an AnalyticsConfig through PosthogAnalytics.configure() instead,
called from the initializer once the config has loaded. Unconfigured
analytics stay off.

Note the two halves of that config are decided differently, and have to
be: where the credentials come from depends on the package, but who owns
the user's analytics identity depends on how Element Call is running,
since the full package can be used as a widget too.

Drop the widget check around cryptoVersion, which never did anything —
widget mode never initialises crypto, so getCrypto() is already undefined
there.

Move the tests covering which package reads which credential source onto
analyticsConfigFromEnvironment, where that decision now lives.
This commit is contained in:
Valere
2026-09-02 15:05:12 +02:00
parent f28d9e9b06
commit eb117249d1
3 changed files with 185 additions and 82 deletions
+86 -49
View File
@@ -22,40 +22,82 @@ import {
PosthogAnalytics, PosthogAnalytics,
} from "./PosthogAnalytics"; } from "./PosthogAnalytics";
import { mockConfig } from "../utils/test"; import { mockConfig } from "../utils/test";
import { analyticsConfigFromEnvironment } from "../initializer";
describe("PosthogAnalytics", () => { describe("PosthogAnalytics", () => {
describe("embedded package", () => { describe("enablement", () => {
beforeAll(() => { beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded"); PosthogAnalytics.resetInstance();
}); });
it("stays off until it is configured", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
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("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);
});
});
// 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(() => { beforeEach(() => {
mockConfig({}); mockConfig({});
window.location.hash = "#"; window.location.hash = "#";
PosthogAnalytics.resetInstance();
}); });
afterAll(() => { afterAll(() => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it("does not create instance without config value or URL params", () => { const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`;
expect(PosthogAnalytics.instance.isEnabled()).toBe(false); const configCredentials = {
});
it("ignores config value and does not create instance", () => {
mockConfig({
posthog: { posthog: {
api_host: "https://api.example.com.localhost", api_host: "https://config.example.com.localhost",
api_key: "api_key", api_key: "config_key",
}, },
}); };
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
}); });
it("uses URL params if both set", () => { it("has no credentials without URL params", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
expect(PosthogAnalytics.instance.isEnabled()).toBe(true); });
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();
}); });
}); });
@@ -64,33 +106,37 @@ describe("PosthogAnalytics", () => {
vi.stubEnv("VITE_PACKAGE", "full"); vi.stubEnv("VITE_PACKAGE", "full");
}); });
beforeEach(() => { it("has no credentials without config", () => {
mockConfig({}); expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
window.location.hash = "#";
PosthogAnalytics.resetInstance();
}); });
afterAll(() => { it("takes the credentials from the config", () => {
vi.unstubAllEnvs(); mockConfig(configCredentials);
expect(analyticsConfigFromEnvironment()).toMatchObject({
apiKey: "config_key",
apiHost: "https://config.example.com.localhost",
});
}); });
it("does not create instance without config value", () => { it("ignores the URL params", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false); window.location.hash = `#?${urlCredentials}`;
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
});
}); });
it("ignores URL params and does not create instance", () => { // Who owns the user's analytics identity depends on how Element Call is
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`; // running, not on which package it was built as.
expect(PosthogAnalytics.instance.isEnabled()).toBe(false); 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("creates instance with config value", () => { it("reports the jssdk backend when running standalone", () => {
mockConfig({ expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk");
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
}); });
}); });
@@ -204,22 +250,13 @@ describe("PosthogAnalytics", () => {
// posthog-js bumps renaming/removing the hook. The filter logic itself is // posthog-js bumps renaming/removing the hook. The filter logic itself is
// covered by the applyPrivacyFilters block above. // covered by the applyPrivacyFilters block above.
describe("posthog.init wiring", () => { describe("posthog.init wiring", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => { beforeEach(() => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
PosthogAnalytics.resetInstance(); PosthogAnalytics.resetInstance();
PosthogAnalytics.configure({
matrixBackend: "jssdk",
apiKey: "api_key",
apiHost: "https://api.example.com.localhost",
}); });
afterAll(() => {
vi.unstubAllEnvs();
}); });
it("passes events through the privacy filter via before_send", () => { it("passes events through the privacy filter via before_send", () => {
+58 -27
View File
@@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { type Subscription } from "rxjs"; import { type Subscription } from "rxjs";
import { widget } from "../widget";
import { import {
CallEndedTracker, CallEndedTracker,
CallStartedTracker, CallStartedTracker,
@@ -29,8 +28,6 @@ import {
CallConnectDurationTracker, CallConnectDurationTracker,
CallReconnectingTracker, CallReconnectingTracker,
} from "./PosthogEvents"; } from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
import { optInAnalytics } from "../settings/settings"; import { optInAnalytics } from "../settings/settings";
/* Posthog analytics tracking. /* Posthog analytics tracking.
@@ -140,6 +137,27 @@ interface PlatformProperties {
cryptoVersion?: string; 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 { export class PosthogAnalytics {
/* Wrapper for Posthog analytics. /* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity * 3 modes of anonymity are supported, governed by this.anonymity
@@ -167,13 +185,32 @@ export class PosthogAnalytics {
private registrationType: RegistrationType = RegistrationType.Guest; private registrationType: RegistrationType = RegistrationType.Guest;
private optInListener: Subscription | null = null; 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 { public static hasInstance(): boolean {
return Boolean(this.internalInstance); return Boolean(this.internalInstance);
} }
public static get instance(): PosthogAnalytics { public static get instance(): PosthogAnalytics {
if (!this.internalInstance) { if (!this.internalInstance) {
this.internalInstance = new PosthogAnalytics(posthog); this.internalInstance = new PosthogAnalytics(
posthog,
PosthogAnalytics.analyticsConfig,
);
} }
return this.internalInstance; return this.internalInstance;
} }
@@ -181,20 +218,14 @@ export class PosthogAnalytics {
public static resetInstance(): void { public static resetInstance(): void {
// Reset the singleton instance // Reset the singleton instance
this.internalInstance = null; this.internalInstance = null;
this.analyticsConfig = analyticsDisabled;
} }
private constructor(private readonly posthog: PostHog) { private constructor(
let apiKey: string | undefined; private readonly posthog: PostHog,
let apiHost: string | undefined; private readonly config: AnalyticsConfig,
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 const { apiKey, apiHost } = config;
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;
}
if (apiKey && apiHost) { if (apiKey && apiHost) {
const beforeSend = (event: CaptureResult | null): CaptureResult | null => 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"; const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
return { return {
appVersion, appVersion,
matrixBackend: widget ? "embedded" : "jssdk", matrixBackend: this.config.matrixBackend,
callBackend: "livekit", callBackend: "livekit",
cryptoVersion: widget // Undefined when Element Call has no crypto of its own, which is the case
? undefined // whenever a host is doing the encrypting for it.
: window.matrixclient?.getCrypto()?.getVersion(), cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(),
}; };
} }
@@ -283,8 +314,8 @@ export class PosthogAnalytics {
// different devices to send the same ID. // different devices to send the same ID.
let analyticsID = await this.getAnalyticsId(); let analyticsID = await this.getAnalyticsId();
try { try {
if (!analyticsID && !widget) { if (!analyticsID && this.config.matrixBackend !== "embedded") {
// only try setting up a new analytics ID in the standalone app. // 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. // 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 // 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<string | null> { private async getAnalyticsId(): Promise<string | null> {
const client: MatrixClient = window.matrixclient; const client: MatrixClient = window.matrixclient;
if (widget) { if (this.config.matrixBackend === "embedded") {
return getUrlParams().posthogUserId; return this.config.hostAnalyticsId ?? null;
} else { } else {
const accountData = await client.getAccountDataFromServer( const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE, PosthogAnalytics.ANALYTICS_EVENT_TYPE,
@@ -324,7 +355,7 @@ export class PosthogAnalytics {
} }
private async setAccountAnalyticsId(analyticsID: string): Promise<void> { private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
if (!widget) { if (this.config.matrixBackend !== "embedded") {
const client = window.matrixclient; const client = window.matrixclient;
// the analytics ID only needs to be set in the standalone version. // 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. // These properties will be subsequently passed in every event.
// //
// This only needs to be done once per page lifetime. Note that getPlatformProperties // This only needs to be done once per page lifetime. Note that getPlatformProperties
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties(); this.platformSuperProperties = this.getPlatformProperties();
this.registerSuperProperties({ this.registerSuperProperties({
...this.platformSuperProperties, ...this.platformSuperProperties,
registrationType: registrationType:
+35
View File
@@ -34,6 +34,10 @@ import { platform } from "./Platform";
import { isFailure } from "./utils/fetch"; import { isFailure } from "./utils/fetch";
import { initializeWidget } from "./widget"; import { initializeWidget } from "./widget";
import { enableExtendedLivekitLogs } from "./settings/settings.ts"; import { enableExtendedLivekitLogs } from "./settings/settings.ts";
import {
type AnalyticsConfig,
PosthogAnalytics,
} from "./analytics/PosthogAnalytics.ts";
import { i18n } from "./utils/i18n.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: // 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; } 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 { enum LoadState {
None, None,
Loading, Loading,
@@ -241,6 +275,7 @@ export class Initializer {
Config.init().then( Config.init().then(
() => { () => {
seedSettingsFromConfig(Config.get().media_quality); seedSettingsFromConfig(Config.get().media_quality);
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
this.loadStates.config = LoadState.Loaded; this.loadStates.config = LoadState.Loaded;
this.initStep(resolve); this.initStep(resolve);
}, },