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
+92 -55
View File
@@ -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", () => {
+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 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<string | null> {
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<void> {
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:
+35
View File
@@ -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);
},