merge: upstream Element Call v0.28.0 (cinny #201) #61
@@ -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", () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user