Merge remote-tracking branch 'origin/main' into dbkr/spatial_audio_ff_only

This commit is contained in:
David Baker 2022-12-19 13:18:12 +00:00
commit 4ad5ea49c2
7 changed files with 144 additions and 51 deletions

View file

@ -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} />;

View file

@ -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);
});
}

View file

@ -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,
});
}
}

View file

@ -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"),
};
};

View file

@ -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
);

View file

@ -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);

View file

@ -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 canEnableSpatialAudio = () => {
const { userAgent } = navigator;
// Spatial audio means routing audio through audio contexts. On Chrome,
@ -78,6 +85,7 @@ export const useSpatialAudio = (): [boolean, (val: boolean) => void] => {
return [false, (_: boolean) => {}];
};
export const useShowInspector = () => useSetting("show-inspector", false);
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);
export const useKeyboardShortcuts = () =>