import posthog, { PostHog } from 'posthog-js'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import SettingsStore from './settings/SettingsStore'; interface IEvent { // The event name that will be used by PostHog. // TODO: standard format (camel case? snake? UpperCase?) eventName: string; // The properties of the event that will be stored in PostHog. properties: {} } export enum Anonymity { Disabled, Anonymous, Pseudonymous } // If an event extends IPseudonymousEvent, the event contains pseudonymous data // that won't be sent unless the user has explicitly consented to pseudonymous tracking. // For example, hashed user IDs or room IDs. export interface IPseudonymousEvent extends IEvent {} // If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data which // may be sent without explicit user consent. export interface IAnonymousEvent extends IEvent {} export interface IRoomEvent extends IPseudonymousEvent { hashedRoomId: string } interface IPageView extends IAnonymousEvent { eventName: "$pageview", properties: { durationMs?: number } } export interface IWelcomeScreenLoad extends IAnonymousEvent { eventName: "welcome_screen_load", } const hashHex = async (input: string): Promise => { // on os x (e.g. if you want to know the sha-256 of your own matrix ID so you can look it up): // echo -n | shasum -a 256 const buf = new TextEncoder().encode(input); const digestBuf = await window.crypto.subtle.digest("sha-256", buf); return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); }; const knownScreens = new Set([ "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", ]); export async function getRedactedCurrentLocation(origin: string, hash: string, pathname: string, anonymity: Anonymity) { // Redact PII from the current location. // If anonymous is true, redact entirely, if false, substitute it with a hash. // For known screens, assumes a URL structure of //might/be/pii if (origin.startsWith('file://')) { pathname = "//"; } let [_, screen, ...parts] = hash.split("/"); if (!knownScreens.has(screen)) { screen = ""; } for (let i = 0; i < parts.length; i++) { parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); } const hashStr = `${_}/${screen}/${parts.join("/")}`; return origin + pathname + hashStr; } export class PosthogAnalytics { private anonymity = Anonymity.Anonymous; private posthog?: PostHog = null; // set true during init() if posthog config is present private enabled = false; // set to true after init() has been called private initialised = false; private static _instance = null; public static instance(): PosthogAnalytics { if (!this._instance) { this._instance = new PosthogAnalytics(posthog); } return this._instance; } constructor(posthog: PostHog) { this.posthog = posthog; } public init(anonymity: Anonymity) { this.anonymity = anonymity; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost, autocapture: false, mask_all_text: true, mask_all_element_attributes: true, // This only triggers on page load, which for our SPA isn't particularly useful. // Plus, the .capture call originating from somewhere in posthog makes it hard // to redact URLs, which requires async code. // // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview. capture_pageview: false, sanitize_properties: this.sanitizeProperties.bind(this), respect_dnt: true, }); this.initialised = true; this.enabled = true; } else { this.enabled = false; } } private sanitizeProperties(properties: posthog.Properties, _: string): posthog.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. // Replace the $current_url with a redacted version. // $redacted_current_url is injected by this class earlier in capture(), as its generation // is async and can't be done in this non-async callback. if (!properties['$redacted_current_url']) { console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely"); } properties['$current_url'] = properties['$redacted_current_url']; delete properties['$redacted_current_url']; 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; } return properties; } public async identifyUser(userId: string) { if (this.anonymity == Anonymity.Anonymous) return; this.posthog.identify(await hashHex(userId)); } public registerSuperProperties(properties) { if (this.enabled) { this.posthog.register(properties); } } public isInitialised() { return this.initialised; } public isEnabled() { return this.enabled; } public setAnonymity(anonymity: Anonymity) { this.anonymity = anonymity; } public getAnonymity() { return this.anonymity; } public logout() { if (this.enabled) { this.posthog.reset(); } this.setAnonymity(Anonymity.Anonymous); } private async capture(eventName: string, properties: posthog.Properties) { if (!this.initialised) { throw Error("Tried to track event before PoshogAnalytics.init has completed"); } if (!this.enabled) { return; } const { origin, hash, pathname } = window.location; properties['$redacted_current_url'] = await getRedactedCurrentLocation( origin, hash, pathname, this.anonymity); this.posthog.capture(eventName, properties); } public async trackPseudonymousEvent( eventName: E["eventName"], properties: E["properties"], ) { if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } public async trackAnonymousEvent( eventName: E["eventName"], properties: E["properties"], ) { if (this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } public async trackRoomEvent( eventName: E["eventName"], roomId: string, properties: Omit, ) { const updatedProperties = { ...properties, hashedRoomId: roomId ? await hashHex(roomId) : null, }; await this.trackPseudonymousEvent(eventName, updatedProperties); } public async trackPageView(durationMs: number) { await this.trackAnonymousEvent("$pageview", { durationMs, }); } } export async function getPlatformProperties() { const platform = PlatformPeg.get(); let appVersion; try { appVersion = await platform.getAppVersion(); } catch (e) { // this happens if no version is set i.e. in dev appVersion = "unknown"; } return { appVersion, appPlatform: platform.getHumanReadableName(), }; } export function getAnalytics(): PosthogAnalytics { return PosthogAnalytics.instance(); } export function getAnonymityFromSettings(): Anonymity { // determine the current anonymity level based on curernt user settings // "Send anonymous usage data which helps us improve Element. This will use a cookie." const analyticsOptIn = SettingsStore.getValue("analyticsOptIn"); // "Send pseudonymous usage data which helps us improve Element. This will use a cookie." const pseudonumousOptIn = SettingsStore.getValue("pseudonymousAnalyticsOptIn"); let anonymity; if (pseudonumousOptIn) { anonymity = Anonymity.Pseudonymous; } else if (analyticsOptIn) { anonymity = Anonymity.Anonymous; } else { anonymity = Anonymity.Disabled; } return anonymity; }