diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index d660039..a48101f 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -44,6 +44,7 @@ import { useEventTarget } from "./useEvents"; declare global { interface Window { matrixclient: MatrixClient; + isPasswordlessUser: boolean; } } @@ -118,9 +119,6 @@ export const ClientProvider: FC = ({ children }) => { if (widget) { // We're inside a widget, so let's engage *matryoshka mode* logger.log("Using a matryoshka client"); - PosthogAnalytics.instance.setRegistrationType( - RegistrationType.Registered - ); return { client: await widget.client, isPasswordlessUser: false, @@ -138,11 +136,6 @@ export const ClientProvider: FC = ({ children }) => { session; try { - PosthogAnalytics.instance.setRegistrationType( - passwordlessUser - ? RegistrationType.Guest - : RegistrationType.Registered - ); return { client: await initClient( { @@ -345,7 +338,8 @@ export const ClientProvider: FC = ({ children }) => { useEffect(() => { window.matrixclient = client; - }, [client]); + window.isPasswordlessUser = isPasswordlessUser; + }, [client, isPasswordlessUser]); if (error) { return ; diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index b3f03c7..0e40ab0 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -16,9 +16,11 @@ limitations under the License. import posthog, { CaptureOptions, PostHog, Properties } from "posthog-js"; import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from "matrix-js-sdk"; +import { Buffer } from "buffer"; import { widget } from "./widget"; -import { getSetting, settingsBus } from "./settings/useSetting"; +import { getSetting, setSetting, settingsBus } from "./settings/useSetting"; import { CallEndedTracker, CallStartedTracker, @@ -28,6 +30,7 @@ import { MuteMicrophoneTracker, } from "./PosthogEvents"; import { Config } from "./config/Config"; +import { getUrlParams } from "./UrlParams"; /* Posthog analytics tracking. * @@ -96,6 +99,7 @@ export class PosthogAnalytics { // set true during the constructor if posthog config is present, otherwise false private static internalInstance = null; + private identificationPromise: Promise; private readonly enabled: boolean = false; private anonymity = Anonymity.Disabled; private platformSuperProperties = {}; @@ -113,11 +117,17 @@ export class PosthogAnalytics { project_api_key: Config.instance.config.posthog?.api_key, api_host: Config.instance.config.posthog?.api_host, }; - if ( - posthogConfig.project_api_key && - posthogConfig.api_host && - PosthogAnalytics.getPlatformProperties().matrixBackend === "jssdk" - ) { + + if (posthogConfig.project_api_key && posthogConfig.api_host) { + if ( + PosthogAnalytics.getPlatformProperties().matrixBackend === "embedded" + ) { + const { analyticsID } = getUrlParams(); + // if the embedding platform (element web) already got approval to communicating with posthog + // element call can also send events to posthog + setSetting("opt-in-analytics", Boolean(analyticsID)); + } + this.posthog.init(posthogConfig.project_api_key, { api_host: posthogConfig.api_host, autocapture: false, @@ -132,9 +142,9 @@ export class PosthogAnalytics { } else { this.enabled = false; } - const optInAnalytics = getSetting("opt-in-analytics", false); - this.updateAnonymityFromSettingsAndIdentifyUser(optInAnalytics); this.startListeningToSettingsChanges(); + const optInAnalytics = getSetting("opt-in-analytics", false); + this.updateAnonymityAndIdentifyUser(optInAnalytics); } private sanitizeProperties = ( @@ -155,6 +165,12 @@ export class PosthogAnalytics { // drop device ID, which is a UUID persisted in local storage properties["$device_id"] = null; } + // the url leaks a lot of private data like the call name or the user. + // Its stripped down to the bare minimum to only give insights about the host (develop, main or sfu) + properties["$current_url"] = (properties["$current_url"] as string) + .split("/") + .slice(0, 3) + .join(""); return properties; }; @@ -166,7 +182,7 @@ export class PosthogAnalytics { } private static getPlatformProperties(): PlatformProperties { - const appVersion = import.meta.env.VITE_APP_VERSION || "unknown"; + const appVersion = import.meta.env.VITE_APP_VERSION || "dev"; return { appVersion, matrixBackend: widget ? "embedded" : "jssdk", @@ -211,36 +227,86 @@ export class PosthogAnalytics { .join(""); } - public async identifyUser(analyticsIdGenerator: () => string): Promise { + public async identifyUser(analyticsIdGenerator: () => string) { // There might be a better way to get the client here. - const client = window.matrixclient; if (this.anonymity == Anonymity.Pseudonymous) { // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows // different devices to send the same ID. + let analyticsID = await this.getAnalyticsId(); try { - const accountData = await client.getAccountDataFromServer( - PosthogAnalytics.ANALYTICS_EVENT_TYPE - ); - let analyticsID = accountData?.id; - if (!analyticsID) { + if (!analyticsID && !widget) { + // only try setting up a new analytics ID in the standalone app. + // 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 // wins, and the first writer will send tracking with an ID that doesn't match the one on the server // until the next time account data is refreshed and this function is called (most likely on next // page load). This will happen pretty infrequently, so we can tolerate the possibility. - analyticsID = analyticsIdGenerator(); - await client.setAccountData( - PosthogAnalytics.ANALYTICS_EVENT_TYPE, - Object.assign({ id: analyticsID }, accountData) - ); + const accountDataAnalyticsId = analyticsIdGenerator(); + await this.setAccountAnalyticsId(accountDataAnalyticsId); + analyticsID = await this.hashedEcAnalyticsId(accountDataAnalyticsId); } - this.posthog.identify(analyticsID); } catch (e) { // The above could fail due to network requests, but not essential to starting the application, // so swallow it. logger.log("Unable to identify user for tracking" + e.toString()); } + if (analyticsID) { + this.posthog.identify(analyticsID); + } else { + logger.info( + "No analyticsID is availble. Should not try to setup posthog" + ); + } + } + } + + async getAnalyticsId() { + const client: MatrixClient = window.matrixclient; + let accountAnalyticsId; + if (widget) { + accountAnalyticsId = getUrlParams().analyticsID; + } else { + const accountData = await client.getAccountDataFromServer( + PosthogAnalytics.ANALYTICS_EVENT_TYPE + ); + accountAnalyticsId = accountData?.id; + } + if (accountAnalyticsId) { + // we dont just use the element web analytics ID because that would allow to associate + // users between the two posthog instances. By using a hash from the username and the element web analytics id + // it is not possible to conclude the element web posthog user id from the element call user id and vice versa. + return await this.hashedEcAnalyticsId(accountAnalyticsId); + } + return null; + } + + async hashedEcAnalyticsId(accountAnalyticsId: string): Promise { + const client: MatrixClient = window.matrixclient; + const posthogIdMaterial = "ec" + accountAnalyticsId + client.getUserId(); + const bufferForPosthogId = await crypto.subtle.digest( + "sha-256", + Buffer.from(posthogIdMaterial, "utf-8") + ); + const view = new Int32Array(bufferForPosthogId); + return Array.from(view) + .map((b) => Math.abs(b).toString(16).padStart(2, "0")) + .join(""); + } + + async setAccountAnalyticsId(analyticsID: string) { + if (!widget) { + const client = window.matrixclient; + + // the analytics ID only needs to be set in the standalone version. + const accountData = await client.getAccountDataFromServer( + PosthogAnalytics.ANALYTICS_EVENT_TYPE + ); + await client.setAccountData( + PosthogAnalytics.ANALYTICS_EVENT_TYPE, + Object.assign({ id: analyticsID }, accountData) + ); } } @@ -255,12 +321,11 @@ export class PosthogAnalytics { this.setAnonymity(Anonymity.Disabled); } - public async updateSuperProperties(): Promise { + public updateSuperProperties() { // Update super properties in posthog with our platform (app version, platform). // These properties will be subsequently passed in every event. // // This only needs to be done once per page lifetime. Note that getPlatformProperties - // is async and can involve a network request if we are running in a browser. this.platformSuperProperties = PosthogAnalytics.getPlatformProperties(); this.registerSuperProperties({ ...this.platformSuperProperties, @@ -276,7 +341,7 @@ export class PosthogAnalytics { return this.eventSignup.getSignupEndTime() > new Date(0); } - public async updateAnonymityFromSettingsAndIdentifyUser( + public async updateAnonymityAndIdentifyUser( pseudonymousOptIn: boolean ): Promise { // Update this.anonymity based on the user's analytics opt-in settings @@ -284,22 +349,36 @@ export class PosthogAnalytics { ? Anonymity.Pseudonymous : Anonymity.Disabled; this.setAnonymity(anonymity); + if (anonymity === Anonymity.Pseudonymous) { - await this.identifyUser(PosthogAnalytics.getRandomAnalyticsId); + this.setRegistrationType( + window.matrixclient.isGuest() || window.isPasswordlessUser + ? RegistrationType.Guest + : RegistrationType.Registered + ); + // store the promise to await posthog-tracking-events until the identification is done. + this.identificationPromise = this.identifyUser( + PosthogAnalytics.getRandomAnalyticsId + ); + await this.identificationPromise; if (this.userRegisteredInThisSession()) { this.eventSignup.track(); } } if (anonymity !== Anonymity.Disabled) { - await this.updateSuperProperties(); + this.updateSuperProperties(); } } - public trackEvent( + public async trackEvent( { eventName, ...properties }: E, options?: IPostHogEventOptions - ): void { + ): Promise { + if (this.identificationPromise) { + // only make calls to posthog after the identificaion is done + await this.identificationPromise; + } if ( this.anonymity == Anonymity.Disabled || this.anonymity == Anonymity.Anonymous @@ -318,7 +397,7 @@ export class PosthogAnalytics { // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings // won't be called (i.e. this.anonymity will be left as the default, until the setting changes) settingsBus.on("opt-in-analytics", (optInAnalytics) => { - this.updateAnonymityFromSettingsAndIdentifyUser(optInAnalytics); + this.updateAnonymityAndIdentifyUser(optInAnalytics); }); } diff --git a/src/PosthogEvents.ts b/src/PosthogEvents.ts index 4fb6181..c30cc3d 100644 --- a/src/PosthogEvents.ts +++ b/src/PosthogEvents.ts @@ -22,7 +22,7 @@ import { interface CallEnded extends IPosthogEvent { eventName: "CallEnded"; - callName: string; + callId: string; callParticipantsOnLeave: number; callParticipantsMax: number; callDuration: number; @@ -45,10 +45,10 @@ export class CallEndedTracker { ); } - track(callName: string, callParticipantsNow: number) { + track(callId: string, callParticipantsNow: number) { PosthogAnalytics.instance.trackEvent({ eventName: "CallEnded", - callName, + callId: callId, callParticipantsMax: this.cache.maxParticipantsCount, callParticipantsOnLeave: callParticipantsNow, callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000, @@ -58,14 +58,14 @@ export class CallEndedTracker { interface CallStarted extends IPosthogEvent { eventName: "CallStarted"; - callName: string; + callId: string; } export class CallStartedTracker { - track(callName: string) { + track(callId: string) { PosthogAnalytics.instance.trackEvent({ eventName: "CallStarted", - callName, + callId: callId, }); } } diff --git a/src/UrlParams.ts b/src/UrlParams.ts index ce90427..df62aef 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -75,6 +75,10 @@ export interface UrlParams { * The factor by which to scale the interface's font size. */ fontScale: number | null; + /** + * The Posthog analytics ID. It is only available if the user has given consent for sharing telemetry in element web. + */ + analyticsID: string | null; } /** @@ -130,6 +134,7 @@ export const getUrlParams = ( lang: getParam("lang"), fonts: getAllParams("font"), fontScale: Number.isNaN(fontScale) ? null : fontScale, + analyticsID: getParam("analyticsID"), }; }; diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index b101b57..27b907c 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -142,6 +142,10 @@ export function GroupCallView({ ]); await groupCall.enter(); + + PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); + PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId); + await Promise.all([ widget.api.setAlwaysOnScreen(true), widget.api.transport.reply(ev.detail, {}), @@ -159,6 +163,9 @@ export function GroupCallView({ if (isEmbedded && !preload) { // In embedded mode, bypass the lobby and just enter the call straight away groupCall.enter(); + + PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); + PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId); } }, [groupCall, isEmbedded, preload]); @@ -178,7 +185,7 @@ export function GroupCallView({ } PosthogAnalytics.instance.eventCallEnded.track( - groupCall.room.name, + groupCall.groupCallId, participantCount ); diff --git a/src/room/useGroupCall.ts b/src/room/useGroupCall.ts index 2e6978a..1704f0a 100644 --- a/src/room/useGroupCall.ts +++ b/src/room/useGroupCall.ts @@ -346,7 +346,7 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType { } PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); - PosthogAnalytics.instance.eventCallStarted.track(groupCall.room.name); + PosthogAnalytics.instance.eventCallStarted.track(groupCall.groupCallId); groupCall.enter().catch((error) => { console.error(error); diff --git a/src/settings/useSetting.ts b/src/settings/useSetting.ts index 7e52fe8..b592535 100644 --- a/src/settings/useSetting.ts +++ b/src/settings/useSetting.ts @@ -20,12 +20,15 @@ import { useMemo, useState, useEffect, useCallback } from "react"; // Bus to notify other useSetting consumers when a setting is changed export const settingsBus = new EventEmitter(); +const getSettingKey = (name: string): string => { + return `matrix-setting-${name}`; +}; // Like useState, but reads from and persists the value to localStorage const useSetting = ( name: string, defaultValue: T ): [T, (value: T) => void] => { - const key = useMemo(() => `matrix-setting-${name}`, [name]); + const key = useMemo(() => getSettingKey(name), [name]); const [value, setValue] = useState(() => { const item = localStorage.getItem(key); @@ -51,13 +54,17 @@ const useSetting = ( ), ]; }; -export const getSetting = (name: string, defaultValue: T): T => { - const key = `matrix-setting-${name}`; - const item = localStorage.getItem(key); +export const getSetting = (name: string, defaultValue: T): T => { + const item = localStorage.getItem(getSettingKey(name)); return item === null ? defaultValue : JSON.parse(item); }; +export const setSetting = (name: string, newValue: T) => { + localStorage.setItem(getSettingKey(name), JSON.stringify(newValue)); + settingsBus.emit(name, newValue); +}; + export const useSpatialAudio = () => useSetting("spatial-audio", false); export const useShowInspector = () => useSetting("show-inspector", false); export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);