Posthog widget embedding (#767)
* load analytics id from url in embedded mode Signed-off-by: Timo K <timok@element.io> * add start call in the widget code path Signed-off-by: Timo K <timok@element.io> * send group call id instead of call name Signed-off-by: Timo K <timok@element.io> * 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 <timok@element.io>
This commit is contained in:
parent
b60a92112f
commit
e3aa810230
7 changed files with 143 additions and 51 deletions
|
@ -44,6 +44,7 @@ import { useEventTarget } from "./useEvents";
|
|||
declare global {
|
||||
interface Window {
|
||||
matrixclient: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -118,9 +119,6 @@ export const ClientProvider: FC<Props> = ({ 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<Props> = ({ children }) => {
|
|||
session;
|
||||
|
||||
try {
|
||||
PosthogAnalytics.instance.setRegistrationType(
|
||||
passwordlessUser
|
||||
? RegistrationType.Guest
|
||||
: RegistrationType.Registered
|
||||
);
|
||||
return {
|
||||
client: await initClient(
|
||||
{
|
||||
|
@ -345,7 +338,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||
|
||||
useEffect(() => {
|
||||
window.matrixclient = client;
|
||||
}, [client]);
|
||||
window.isPasswordlessUser = isPasswordlessUser;
|
||||
}, [client, isPasswordlessUser]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorView error={error} />;
|
||||
|
|
|
@ -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<void>;
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<E extends IPosthogEvent>(
|
||||
public async trackEvent<E extends IPosthogEvent>(
|
||||
{ eventName, ...properties }: E,
|
||||
options?: IPostHogEventOptions
|
||||
): void {
|
||||
): Promise<void> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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<CallEnded>({
|
||||
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<CallStarted>({
|
||||
eventName: "CallStarted",
|
||||
callName,
|
||||
callId: callId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 = <T>(
|
||||
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<T>(() => {
|
||||
const item = localStorage.getItem(key);
|
||||
|
@ -51,13 +54,17 @@ const useSetting = <T>(
|
|||
),
|
||||
];
|
||||
};
|
||||
export const getSetting = <T>(name: string, defaultValue: T): T => {
|
||||
const key = `matrix-setting-${name}`;
|
||||
|
||||
const item = localStorage.getItem(key);
|
||||
export const getSetting = <T>(name: string, defaultValue: T): T => {
|
||||
const item = localStorage.getItem(getSettingKey(name));
|
||||
return item === null ? defaultValue : JSON.parse(item);
|
||||
};
|
||||
|
||||
export const setSetting = <T>(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);
|
||||
|
|
Loading…
Reference in a new issue