merge: upstream Element Call v0.28.0 (cinny #201) #61

Merged
jared merged 197 commits from lotus-upstream-v0.28.0 into lotus 2026-10-24 01:14:32 -04:00
3 changed files with 185 additions and 82 deletions
Showing only changes of commit eb117249d1 - Show all commits
+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);
},