418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
/*
|
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
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, setSetting, settingsBus } from "./settings/useSetting";
|
|
import {
|
|
CallEndedTracker,
|
|
CallStartedTracker,
|
|
LoginTracker,
|
|
SignupTracker,
|
|
MuteCameraTracker,
|
|
MuteMicrophoneTracker,
|
|
} from "./PosthogEvents";
|
|
import { Config } from "./config/Config";
|
|
import { getUrlParams } from "./UrlParams";
|
|
|
|
/* 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,
|
|
}
|
|
|
|
interface PlatformProperties {
|
|
appVersion: string;
|
|
matrixBackend: "embedded" | "jssdk";
|
|
}
|
|
|
|
interface PosthogSettings {
|
|
project_api_key?: string;
|
|
api_host?: 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.
|
|
*/
|
|
|
|
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics";
|
|
|
|
// 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 = {};
|
|
private registrationType: RegistrationType = RegistrationType.Guest;
|
|
|
|
public static get instance(): PosthogAnalytics {
|
|
if (!this.internalInstance) {
|
|
this.internalInstance = new PosthogAnalytics(posthog);
|
|
}
|
|
return this.internalInstance;
|
|
}
|
|
|
|
constructor(private readonly posthog: PostHog) {
|
|
const posthogConfig: PosthogSettings = {
|
|
project_api_key: Config.get().posthog?.api_key,
|
|
api_host: Config.get().posthog?.api_host,
|
|
};
|
|
|
|
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,
|
|
mask_all_text: true,
|
|
mask_all_element_attributes: true,
|
|
capture_pageview: false,
|
|
sanitize_properties: this.sanitizeProperties,
|
|
respect_dnt: true,
|
|
advanced_disable_decide: true,
|
|
});
|
|
this.enabled = true;
|
|
} else {
|
|
this.enabled = false;
|
|
}
|
|
this.startListeningToSettingsChanges();
|
|
const optInAnalytics = getSetting("opt-in-analytics", false);
|
|
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
|
}
|
|
|
|
private sanitizeProperties = (
|
|
properties: Properties,
|
|
_eventName: string
|
|
): Properties => {
|
|
// Callback from posthog to sanitize properties before sending them to the server.
|
|
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
|
|
// See utils.js _.info.properties in posthog-js.
|
|
|
|
if (this.anonymity == Anonymity.Anonymous) {
|
|
// drop referrer information for anonymous users
|
|
properties["$referrer"] = null;
|
|
properties["$referring_domain"] = null;
|
|
properties["$initial_referrer"] = null;
|
|
properties["$initial_referring_domain"] = null;
|
|
|
|
// 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;
|
|
};
|
|
|
|
private registerSuperProperties(properties: Properties) {
|
|
if (this.enabled) {
|
|
this.posthog.register(properties);
|
|
}
|
|
}
|
|
|
|
private static getPlatformProperties(): PlatformProperties {
|
|
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
|
|
return {
|
|
appVersion,
|
|
matrixBackend: widget ? "embedded" : "jssdk",
|
|
};
|
|
}
|
|
|
|
private capture(
|
|
eventName: string,
|
|
properties: Properties,
|
|
options?: CaptureOptions
|
|
) {
|
|
if (!this.enabled) {
|
|
return;
|
|
}
|
|
this.posthog.capture(eventName, { ...properties }, options);
|
|
}
|
|
|
|
public isEnabled(): boolean {
|
|
return this.enabled;
|
|
}
|
|
|
|
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("");
|
|
}
|
|
|
|
public async identifyUser(analyticsIdGenerator: () => string) {
|
|
// There might be a better way to get the client here.
|
|
|
|
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 {
|
|
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.
|
|
const accountDataAnalyticsId = analyticsIdGenerator();
|
|
await this.setAccountAnalyticsId(accountDataAnalyticsId);
|
|
analyticsID = await this.hashedEcAnalyticsId(accountDataAnalyticsId);
|
|
}
|
|
} 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)
|
|
);
|
|
}
|
|
}
|
|
|
|
public getAnonymity(): Anonymity {
|
|
return this.anonymity;
|
|
}
|
|
|
|
public logout(): void {
|
|
if (this.enabled) {
|
|
this.posthog.reset();
|
|
}
|
|
this.setAnonymity(Anonymity.Disabled);
|
|
}
|
|
|
|
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
|
|
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
|
|
this.registerSuperProperties({
|
|
...this.platformSuperProperties,
|
|
registrationType:
|
|
this.registrationType == RegistrationType.Guest
|
|
? "Guest"
|
|
: "Registered",
|
|
});
|
|
}
|
|
|
|
private userRegisteredInThisSession(): boolean {
|
|
// 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);
|
|
}
|
|
|
|
public async updateAnonymityAndIdentifyUser(
|
|
pseudonymousOptIn: boolean
|
|
): Promise<void> {
|
|
// Update this.anonymity based on the user's analytics opt-in settings
|
|
const anonymity = pseudonymousOptIn
|
|
? Anonymity.Pseudonymous
|
|
: Anonymity.Disabled;
|
|
this.setAnonymity(anonymity);
|
|
|
|
if (anonymity === Anonymity.Pseudonymous) {
|
|
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) {
|
|
this.updateSuperProperties();
|
|
}
|
|
}
|
|
|
|
public async trackEvent<E extends IPosthogEvent>(
|
|
{ eventName, ...properties }: E,
|
|
options?: CaptureOptions
|
|
): 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
|
|
)
|
|
return;
|
|
this.capture(eventName, properties, options);
|
|
}
|
|
|
|
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)
|
|
settingsBus.on("opt-in-analytics", (optInAnalytics) => {
|
|
this.updateAnonymityAndIdentifyUser(optInAnalytics);
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|