Files
element-call/src/analytics/PosthogAnalytics.ts
T

470 lines
16 KiB
TypeScript
Raw Normal View History

/*
2024-09-06 10:35:10 +02:00
Copyright 2022-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import posthog, {
type CaptureOptions,
type CaptureResult,
type PostHog,
type Properties,
} from "posthog-js";
2025-03-13 13:58:43 +01:00
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk";
2025-03-05 08:52:31 -05:00
import { type Subscription } from "rxjs";
import { widget } from "../widget";
import {
CallEndedTracker,
CallStartedTracker,
LoginTracker,
SignupTracker,
MuteCameraTracker,
MuteMicrophoneTracker,
2023-01-13 18:27:22 +00:00
UndecryptableToDeviceEventTracker,
QualitySurveyEventTracker,
2023-07-20 18:22:17 +01:00
CallDisconnectedEventTracker,
CallConnectDurationTracker,
2026-05-14 23:07:02 +02:00
CallReconnectingTracker,
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
2024-05-08 15:29:39 -04:00
import { optInAnalytics } from "../settings/settings";
/* Posthog analytics tracking.
*
* Anonymity behaviour is as follows:
*
* - If Posthog isn't configured in `config.json`, events are not sent.
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
* enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`).
* - If the posthog analytics are explicitly activated by the user in the element call settings,
* a randomised analytics ID is created and stored in account_data for that user (shared between devices)
* so that the user can be identified in posthog.
*/
export interface IPosthogEvent {
// The event name that will be used by PostHog. Event names should use camelCase.
eventName: string;
// do not allow these to be sent manually, we enqueue them all for caching purposes
$set?: void;
$set_once?: void;
}
export enum Anonymity {
Disabled,
Anonymous,
Pseudonymous,
}
export enum RegistrationType {
Guest,
Registered,
}
// Sanitize URL / referrer / device fields on a single posthog properties bag.
// Applied to event.properties and to the person-profile bags ($set / $set_once),
// since posthog mirrors the same URL fields into those.
function stripSensitiveFields(
obj: Properties | undefined,
anonymity: Anonymity,
): void {
if (!obj) return;
if (anonymity === Anonymity.Anonymous) {
// drop referrer information for anonymous users
delete obj["$referrer"];
delete obj["$referring_domain"];
delete obj["$initial_referrer"];
delete obj["$initial_referring_domain"];
// drop device ID, which is a UUID persisted in local storage
delete obj["$device_id"];
}
// the url leaks a lot of private data like the call name or the user
// (room password / room ID can land in the hash/query). Strip down to
// scheme + host so we still get host-level insights (develop / main / sfu).
for (const key of ["$current_url", "$initial_current_url"]) {
if (typeof obj[key] === "string") {
try {
const url = new URL(obj[key]);
obj[key] = url.protocol + "//" + url.hostname + url.pathname;
} catch {
obj[key] = null;
}
}
}
// $session_entry_url carries the full untrimmed URL; $initial_person_info
// bundles initial referrer + URL into a nested object that bypasses the
// per-key strips above. Drop both.
delete obj["$session_entry_url"];
delete obj["$initial_person_info"];
}
/**
* Strip PII from posthog's built-in properties (URL, referrer fields,
* device ID, $initial_person_info, $session_entry_url) before events leave
* the client. Also applied to the person-profile bags ($set / $set_once),
* which mirror the same URL fields.
* See src/utils/event-utils.ts in posthog-js (getEventProperties, getPersonInfo)
* for the list of properties posthog sets automatically.
*/
export function santizeSensitiveData(
event: CaptureResult | null,
anonymity: Anonymity,
): CaptureResult | null {
if (event === null) return null;
stripSensitiveFields(event.properties, anonymity);
// posthog can stash person-profile updates either at the top level
// of CaptureResult or nested inside properties depending on the pipeline
// stage; clean both spots so nothing slips through.
stripSensitiveFields(event.$set, anonymity);
stripSensitiveFields(event.$set_once, anonymity);
stripSensitiveFields(event.properties["$set"], anonymity);
stripSensitiveFields(event.properties["$set_once"], anonymity);
return event;
}
interface PlatformProperties {
appVersion: string;
matrixBackend: "embedded" | "jssdk";
callBackend: "livekit" | "full-mesh";
2024-09-23 14:35:41 +01:00
cryptoVersion?: string;
}
export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means no identifier is passed to posthog
* - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
* is passed to posthog.
*
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
*
* To pass an event to Posthog:
*
* 1. Declare a type for the event, extending IPosthogEvent.
*/
2025-01-16 15:31:05 +00:00
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics" as const;
// set true during the constructor if posthog config is present, otherwise false
private static internalInstance: PosthogAnalytics | null = null;
2023-06-30 16:43:28 +01:00
private identificationPromise?: Promise<void>;
private readonly enabled: boolean = false;
2022-11-07 18:00:35 +01:00
private anonymity = Anonymity.Disabled;
private platformSuperProperties = {};
private registrationType: RegistrationType = RegistrationType.Guest;
2025-03-05 08:52:31 -05:00
private optInListener: Subscription | null = null;
2023-04-05 15:00:14 +01:00
public static hasInstance(): boolean {
return Boolean(this.internalInstance);
}
public static get instance(): PosthogAnalytics {
if (!this.internalInstance) {
this.internalInstance = new PosthogAnalytics(posthog);
}
return this.internalInstance;
}
public static resetInstance(): void {
// Reset the singleton instance
this.internalInstance = null;
}
2022-12-19 12:16:59 +01:00
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;
}
2022-12-19 12:16:59 +01:00
if (apiKey && apiHost) {
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
santizeSensitiveData(event, this.anonymity);
this.posthog.init(apiKey, {
api_host: apiHost,
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
mask_personal_data_properties: true,
capture_pageview: false,
before_send: beforeSend,
respect_dnt: true,
advanced_disable_decide: true,
});
this.enabled = true;
} else if (import.meta.env.MODE !== "test") {
logger.info(
2023-10-11 10:42:04 -04:00
"Posthog is not enabled because there is no api key or no host given in the config",
);
this.enabled = false;
}
}
2023-09-22 18:05:13 -04:00
private registerSuperProperties(properties: Properties): void {
if (this.enabled) {
this.posthog.register(properties);
}
}
private static getPlatformProperties(): PlatformProperties {
2022-12-19 12:16:59 +01:00
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
return {
appVersion,
matrixBackend: widget ? "embedded" : "jssdk",
callBackend: "livekit",
2024-09-23 14:35:41 +01:00
cryptoVersion: widget
? undefined
: window.matrixclient?.getCrypto()?.getVersion(),
};
}
private capture(
eventName: string,
properties: Properties,
2023-10-11 10:42:04 -04:00
options?: CaptureOptions,
2023-09-22 18:05:13 -04:00
): void {
if (!this.enabled) {
return;
}
this.posthog.capture(eventName, { ...properties }, options);
}
public isEnabled(): boolean {
return this.enabled;
}
2023-09-22 18:05:13 -04:00
private setAnonymity(anonymity: Anonymity): void {
// Update this.anonymity.
// To update the anonymity typically you want to call updateAnonymityFromSettings
// to ensure this value is in step with the user's settings.
if (
this.enabled &&
(anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)
) {
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
// set in posthog e.g. distinct ID
this.posthog.reset();
// Restore any previously set platform super properties
this.updateSuperProperties();
}
this.anonymity = anonymity;
}
private static getRandomAnalyticsId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))]
.map((c) => c.toString(16))
.join("");
}
2023-09-22 18:05:13 -04:00
private async identifyUser(
2023-10-11 10:42:04 -04:00
analyticsIdGenerator: () => string,
2023-09-22 18:05:13 -04:00
): Promise<void> {
if (this.anonymity == Anonymity.Pseudonymous && this.enabled) {
// 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.
2022-12-19 12:16:59 +01:00
let analyticsID = await this.getAnalyticsId();
try {
2022-12-19 12:16:59 +01:00
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.
2025-12-03 10:42:04 -05:00
analyticsID = analyticsIdGenerator();
await this.setAccountAnalyticsId(analyticsID);
}
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
2023-06-30 16:43:28 +01:00
logger.log(
2023-10-11 10:42:04 -04:00
"Unable to identify user for tracking" + (e as Error)?.toString(),
2023-06-30 16:43:28 +01:00
);
}
2022-12-19 12:16:59 +01:00
if (analyticsID) {
this.posthog.identify(analyticsID);
} else {
logger.info(
"No analyticsID is available. Should not try to setup posthog",
2022-12-19 12:16:59 +01:00
);
}
}
}
2023-09-22 18:05:13 -04:00
private async getAnalyticsId(): Promise<string | null> {
2022-12-19 12:16:59 +01:00
const client: MatrixClient = window.matrixclient;
if (widget) {
2025-12-03 10:42:04 -05:00
return getUrlParams().posthogUserId;
2022-12-19 12:16:59 +01:00
} else {
const accountData = await client.getAccountDataFromServer(
2023-10-11 10:42:04 -04:00
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
2022-12-19 12:16:59 +01:00
);
2025-12-03 10:42:04 -05:00
return accountData?.id ?? null;
2022-12-19 12:16:59 +01:00
}
}
2023-09-22 18:05:13 -04:00
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
2022-12-19 12:16:59 +01:00
if (!widget) {
const client = window.matrixclient;
// the analytics ID only needs to be set in the standalone version.
const accountData = await client.getAccountDataFromServer(
2023-10-11 10:42:04 -04:00
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
2022-12-19 12:16:59 +01:00
);
await client.setAccountData(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
2023-10-11 10:42:04 -04:00
Object.assign({ id: analyticsID }, accountData),
2022-12-19 12:16:59 +01:00
);
}
}
public getAnonymity(): Anonymity {
return this.anonymity;
}
public logout(): void {
if (this.enabled) {
this.posthog.reset();
}
2025-03-05 08:52:31 -05:00
this.optInListener?.unsubscribe();
this.optInListener = null;
this.setAnonymity(Anonymity.Disabled);
}
2023-04-05 13:06:55 +01:00
public onLoginStatusChanged(): void {
this.maybeIdentifyUser().catch(() =>
logger.log("Could not identify user on login status change"),
);
2023-04-05 13:06:55 +01:00
}
2023-09-22 18:05:13 -04:00
private updateSuperProperties(): void {
// 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
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
this.registerSuperProperties({
...this.platformSuperProperties,
registrationType:
this.registrationType == RegistrationType.Guest
? "Guest"
: "Registered",
});
}
private userRegisteredInThisSession(): boolean {
2022-11-07 18:00:35 +01:00
// only if the signup end got tracked the end time is set. Otherwise its default value is Date(0).
return this.eventSignup.getSignupEndTime() > new Date(0);
}
2024-05-08 15:29:39 -04:00
private async maybeIdentifyUser(): Promise<void> {
2023-04-05 13:06:55 +01:00
// We may not yet have a Matrix client at this point, if not, bail. This should get
// triggered again by onLoginStatusChanged once we do have a client.
if (!window.matrixclient) return;
2024-05-08 15:29:39 -04:00
if (this.anonymity === Anonymity.Pseudonymous) {
2022-12-19 12:16:59 +01:00
this.setRegistrationType(
2023-06-30 16:43:28 +01:00
window.matrixclient.isGuest() || window.passwordlessUser
2022-12-19 12:16:59 +01:00
? RegistrationType.Guest
2023-10-11 10:42:04 -04:00
: RegistrationType.Registered,
2022-12-19 12:16:59 +01:00
);
// store the promise to await posthog-tracking-events until the identification is done.
this.identificationPromise = this.identifyUser(
2023-10-11 10:42:04 -04:00
PosthogAnalytics.getRandomAnalyticsId,
2022-12-19 12:16:59 +01:00
);
await this.identificationPromise;
if (this.userRegisteredInThisSession()) {
this.eventSignup.track();
}
}
2024-05-08 15:29:39 -04:00
if (this.anonymity !== Anonymity.Disabled) {
2022-12-19 12:16:59 +01:00
this.updateSuperProperties();
}
}
public trackEvent<E extends IPosthogEvent>(
{ eventName, ...properties }: E,
2023-10-11 10:42:04 -04:00
options?: CaptureOptions,
): void {
const doCapture = (): void => {
if (
this.anonymity == Anonymity.Disabled ||
this.anonymity == Anonymity.Anonymous
)
return;
this.capture(eventName, properties, options);
};
2022-12-19 12:16:59 +01:00
if (this.identificationPromise) {
// only make calls to posthog after the identification is done
this.identificationPromise.then(doCapture, (e) => {
logger.error("Failed to identify user for tracking", e);
});
} else {
doCapture();
2022-12-19 12:16:59 +01:00
}
}
2025-03-05 08:52:31 -05:00
public startListeningToSettingsChanges(): void {
// Listen to account data changes from sync so we can observe changes to relevant flags and update.
// This is called -
// * On page load, when the account data is first received by sync
// * On login
// * When another device changes account data
// * When the user changes their preferences on this device
// 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)
2025-03-05 08:52:31 -05:00
this.optInListener ??= optInAnalytics.value$.subscribe((optIn) => {
2024-05-08 15:29:39 -04:00
this.setAnonymity(optIn ? Anonymity.Pseudonymous : Anonymity.Disabled);
this.maybeIdentifyUser().catch(() =>
logger.log("Could not identify user"),
);
});
}
public setRegistrationType(registrationType: RegistrationType): void {
this.registrationType = registrationType;
if (
this.anonymity == Anonymity.Disabled ||
this.anonymity == Anonymity.Anonymous
)
return;
this.updateSuperProperties();
}
// ----- Events
public eventCallEnded = new CallEndedTracker();
public eventSignup = new SignupTracker();
public eventCallStarted = new CallStartedTracker();
public eventLogin = new LoginTracker();
public eventMuteMicrophone = new MuteMicrophoneTracker();
public eventMuteCamera = new MuteCameraTracker();
2023-01-13 18:27:22 +00:00
public eventUndecryptableToDevice = new UndecryptableToDeviceEventTracker();
public eventQualitySurvey = new QualitySurveyEventTracker();
2023-07-20 18:22:17 +01:00
public eventCallDisconnected = new CallDisconnectedEventTracker();
public eventCallConnectDuration = new CallConnectDurationTracker();
2026-05-14 23:07:02 +02:00
public eventCallReconnecting = new CallReconnectingTracker();
}