From e3aa8102304f97fe8803ce7f71bdaf88878f68fa Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Mon, 19 Dec 2022 12:16:59 +0100 Subject: [PATCH] Posthog widget embedding (#767) * load analytics id from url in embedded mode Signed-off-by: Timo K * add start call in the widget code path Signed-off-by: Timo K * send group call id instead of call name Signed-off-by: Timo K * generate analyticsid based on account analyticsid This make it impossible to find users from the element web posthog instance in the element call instance * move registration type setup PosthogAnalytics.ts * Order identificaition and tracking. This fixes an issue that the widget version did not identify the user before sneding the first track event. Because start call is called right after app startup. Signed-off-by: Timo K --- src/ClientContext.tsx | 12 +--- src/PosthogAnalytics.ts | 139 +++++++++++++++++++++++++++++-------- src/PosthogEvents.ts | 12 ++-- src/UrlParams.ts | 5 ++ src/room/GroupCallView.tsx | 9 ++- src/room/useGroupCall.ts | 2 +- src/settings/useSetting.ts | 15 ++-- 7 files changed, 143 insertions(+), 51 deletions(-) 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);