2021-07-21 12:48:10 +00:00
|
|
|
import posthog, { PostHog } from 'posthog-js';
|
2021-07-21 17:35:25 +00:00
|
|
|
import PlatformPeg from './PlatformPeg';
|
2021-07-21 06:40:39 +00:00
|
|
|
import SdkConfig from './SdkConfig';
|
2021-07-23 16:58:31 +00:00
|
|
|
import SettingsStore from './settings/SettingsStore';
|
2021-07-21 06:40:39 +00:00
|
|
|
|
2021-07-21 07:23:42 +00:00
|
|
|
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.
|
2021-07-21 06:40:39 +00:00
|
|
|
properties: {}
|
|
|
|
}
|
|
|
|
|
2021-07-21 12:48:10 +00:00
|
|
|
export enum Anonymity {
|
2021-07-23 15:47:02 +00:00
|
|
|
Disabled,
|
2021-07-21 12:48:10 +00:00
|
|
|
Anonymous,
|
|
|
|
Pseudonymous
|
|
|
|
}
|
|
|
|
|
2021-07-21 07:23:42 +00:00
|
|
|
// If an event extends IPseudonymousEvent, the event contains pseudonymous data
|
|
|
|
// that won't be sent unless the user has explicitly consented to pseudonymous tracking.
|
2021-07-28 08:54:37 +00:00
|
|
|
// For example, it might contain hashed user IDs or room IDs.
|
|
|
|
// Such events will be automatically dropped if PosthogAnalytics.anonymity isn't set to Pseudonymous.
|
2021-07-21 07:23:42 +00:00
|
|
|
export interface IPseudonymousEvent extends IEvent {}
|
|
|
|
|
2021-07-28 08:54:37 +00:00
|
|
|
// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data;
|
|
|
|
// i.e. no identifiers that can be associated with the user.
|
2021-07-21 07:23:42 +00:00
|
|
|
export interface IAnonymousEvent extends IEvent {}
|
|
|
|
|
|
|
|
export interface IRoomEvent extends IPseudonymousEvent {
|
|
|
|
hashedRoomId: string
|
|
|
|
}
|
|
|
|
|
2021-07-21 15:52:57 +00:00
|
|
|
interface IPageView extends IAnonymousEvent {
|
|
|
|
eventName: "$pageview",
|
|
|
|
properties: {
|
|
|
|
durationMs?: number
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-21 15:09:22 +00:00
|
|
|
export interface IWelcomeScreenLoad extends IAnonymousEvent {
|
2021-07-21 16:01:45 +00:00
|
|
|
eventName: "welcome_screen_load",
|
2021-07-21 06:40:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const hashHex = async (input: string): Promise<string> => {
|
2021-07-21 17:25:05 +00:00
|
|
|
// 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 <input> | shasum -a 256
|
2021-07-21 06:40:39 +00:00
|
|
|
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("");
|
|
|
|
};
|
|
|
|
|
2021-07-21 12:48:10 +00:00
|
|
|
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 /<screen name>/might/be/pii
|
|
|
|
if (origin.startsWith('file://')) {
|
|
|
|
pathname = "/<redacted_file_scheme_url>/";
|
|
|
|
}
|
|
|
|
|
2021-07-27 12:29:56 +00:00
|
|
|
let hashStr;
|
|
|
|
if (hash == "") {
|
|
|
|
hashStr = "";
|
|
|
|
} else {
|
|
|
|
let [_, screen, ...parts] = hash.split("/");
|
|
|
|
|
|
|
|
if (!knownScreens.has(screen)) {
|
|
|
|
screen = "<redacted_screen_name>";
|
|
|
|
}
|
2021-07-21 12:48:10 +00:00
|
|
|
|
2021-07-27 12:29:56 +00:00
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
|
|
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
|
|
|
|
}
|
2021-07-21 12:48:10 +00:00
|
|
|
|
2021-07-27 12:29:56 +00:00
|
|
|
hashStr = `${_}/${screen}/${parts.join("/")}`;
|
2021-07-21 12:48:10 +00:00
|
|
|
}
|
|
|
|
return origin + pathname + hashStr;
|
|
|
|
}
|
|
|
|
|
2021-07-21 06:40:39 +00:00
|
|
|
export class PosthogAnalytics {
|
2021-07-28 08:54:37 +00:00
|
|
|
/* 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 all identifers will be redacted before being passed to posthog
|
|
|
|
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being 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 IAnonymousEvent, IPseudonymousEvent or IRoomEvent.
|
|
|
|
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
|
|
|
|
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
|
|
|
|
*/
|
|
|
|
|
2021-07-21 13:57:24 +00:00
|
|
|
private anonymity = Anonymity.Anonymous;
|
2021-07-21 12:48:10 +00:00
|
|
|
private posthog?: PostHog = null;
|
2021-07-28 08:37:08 +00:00
|
|
|
// set true during the constructor if posthog config is present, otherwise false
|
2021-07-21 13:57:24 +00:00
|
|
|
private enabled = false;
|
2021-07-21 06:40:39 +00:00
|
|
|
private static _instance = null;
|
2021-07-28 08:37:08 +00:00
|
|
|
private platformSuperProperties = {};
|
2021-07-21 06:40:39 +00:00
|
|
|
|
|
|
|
public static instance(): PosthogAnalytics {
|
2021-07-21 10:23:18 +00:00
|
|
|
if (!this._instance) {
|
2021-07-21 06:40:39 +00:00
|
|
|
this._instance = new PosthogAnalytics(posthog);
|
|
|
|
}
|
|
|
|
return this._instance;
|
|
|
|
}
|
|
|
|
|
2021-07-21 12:48:10 +00:00
|
|
|
constructor(posthog: PostHog) {
|
2021-07-21 06:40:39 +00:00
|
|
|
this.posthog = posthog;
|
|
|
|
const posthogConfig = SdkConfig.get()["posthog"];
|
|
|
|
if (posthogConfig) {
|
2021-07-21 12:48:10 +00:00
|
|
|
this.posthog.init(posthogConfig.projectApiKey, {
|
|
|
|
api_host: posthogConfig.apiHost,
|
|
|
|
autocapture: false,
|
|
|
|
mask_all_text: true,
|
|
|
|
mask_all_element_attributes: true,
|
2021-07-21 15:29:11 +00:00
|
|
|
// 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.
|
2021-07-21 15:06:09 +00:00
|
|
|
capture_pageview: false,
|
2021-07-21 12:48:10 +00:00
|
|
|
sanitize_properties: this.sanitizeProperties.bind(this),
|
2021-07-21 15:06:09 +00:00
|
|
|
respect_dnt: true,
|
2021-07-21 12:48:10 +00:00
|
|
|
});
|
2021-07-21 13:57:24 +00:00
|
|
|
this.enabled = true;
|
|
|
|
} else {
|
|
|
|
this.enabled = false;
|
2021-07-21 06:40:39 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-21 12:48:10 +00:00
|
|
|
private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties {
|
2021-07-21 15:06:09 +00:00
|
|
|
// 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'];
|
2021-07-21 12:48:10 +00:00
|
|
|
|
2021-07-21 13:57:24 +00:00
|
|
|
if (this.anonymity == Anonymity.Anonymous) {
|
2021-07-21 12:48:10 +00:00
|
|
|
// 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;
|
|
|
|
}
|
|
|
|
|
2021-07-28 08:37:08 +00:00
|
|
|
private static 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."
|
2021-07-28 09:20:16 +00:00
|
|
|
const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true);
|
2021-07-28 08:37:08 +00:00
|
|
|
|
2021-07-28 08:54:37 +00:00
|
|
|
// (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie."
|
2021-07-28 08:37:08 +00:00
|
|
|
//
|
2021-07-28 08:54:37 +00:00
|
|
|
// TODO: Currently, this is only a labs flag, for testing purposes.
|
2021-07-28 09:20:16 +00:00
|
|
|
const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn", null, true);
|
2021-07-28 08:37:08 +00:00
|
|
|
|
|
|
|
let anonymity;
|
|
|
|
if (pseudonumousOptIn) {
|
|
|
|
anonymity = Anonymity.Pseudonymous;
|
2021-07-28 09:20:16 +00:00
|
|
|
} else if (analyticsOptIn || analyticsOptIn === null) {
|
|
|
|
// If no analyticsOptIn has been set (i.e. before the user has logged in, or if they haven't answered the
|
|
|
|
// opt-in question, assume Anonymous)
|
2021-07-28 08:37:08 +00:00
|
|
|
anonymity = Anonymity.Anonymous;
|
|
|
|
} else {
|
|
|
|
anonymity = Anonymity.Disabled;
|
|
|
|
}
|
|
|
|
|
|
|
|
return anonymity;
|
|
|
|
}
|
|
|
|
|
|
|
|
private registerSuperProperties(properties) {
|
2021-07-23 16:58:31 +00:00
|
|
|
if (this.enabled) {
|
|
|
|
this.posthog.register(properties);
|
|
|
|
}
|
2021-07-21 17:24:14 +00:00
|
|
|
}
|
|
|
|
|
2021-07-28 08:54:37 +00:00
|
|
|
private static async 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(),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
private async capture(eventName: string, properties: posthog.Properties) {
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
|
2021-07-21 13:57:24 +00:00
|
|
|
public isEnabled() {
|
|
|
|
return this.enabled;
|
|
|
|
}
|
|
|
|
|
|
|
|
public setAnonymity(anonymity: Anonymity) {
|
2021-07-28 08:54:37 +00:00
|
|
|
// Update this.anonymity.
|
|
|
|
// This is public for testing purposes, typically you want to call updateAnonymityFromSettings
|
|
|
|
// to ensure this value is in step with the user's settings.
|
2021-07-28 08:37:08 +00:00
|
|
|
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.registerSuperProperties(this.platformSuperProperties);
|
|
|
|
}
|
2021-07-21 13:57:24 +00:00
|
|
|
this.anonymity = anonymity;
|
|
|
|
}
|
|
|
|
|
2021-07-28 08:54:37 +00:00
|
|
|
public async identifyUser(userId: string) {
|
|
|
|
if (this.anonymity == Anonymity.Pseudonymous) {
|
|
|
|
this.posthog.identify(await hashHex(userId));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-07-21 13:57:24 +00:00
|
|
|
public getAnonymity() {
|
|
|
|
return this.anonymity;
|
2021-07-21 06:40:39 +00:00
|
|
|
}
|
|
|
|
|
2021-07-21 13:57:24 +00:00
|
|
|
public logout() {
|
|
|
|
if (this.enabled) {
|
|
|
|
this.posthog.reset();
|
|
|
|
}
|
|
|
|
this.setAnonymity(Anonymity.Anonymous);
|
|
|
|
}
|
|
|
|
|
2021-07-21 12:48:10 +00:00
|
|
|
public async trackPseudonymousEvent<E extends IPseudonymousEvent>(
|
2021-07-21 07:23:42 +00:00
|
|
|
eventName: E["eventName"],
|
2021-07-21 06:40:39 +00:00
|
|
|
properties: E["properties"],
|
|
|
|
) {
|
2021-07-23 15:47:02 +00:00
|
|
|
if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return;
|
2021-07-21 13:57:24 +00:00
|
|
|
await this.capture(eventName, properties);
|
2021-07-21 07:23:42 +00:00
|
|
|
}
|
2021-07-21 06:40:39 +00:00
|
|
|
|
2021-07-21 12:48:10 +00:00
|
|
|
public async trackAnonymousEvent<E extends IAnonymousEvent>(
|
2021-07-21 07:23:42 +00:00
|
|
|
eventName: E["eventName"],
|
|
|
|
properties: E["properties"],
|
|
|
|
) {
|
2021-07-23 15:47:02 +00:00
|
|
|
if (this.anonymity == Anonymity.Disabled) return;
|
2021-07-21 13:57:24 +00:00
|
|
|
await this.capture(eventName, properties);
|
2021-07-21 06:40:39 +00:00
|
|
|
}
|
|
|
|
|
2021-07-21 07:23:42 +00:00
|
|
|
public async trackRoomEvent<E extends IRoomEvent>(
|
|
|
|
eventName: E["eventName"],
|
2021-07-21 06:40:39 +00:00
|
|
|
roomId: string,
|
2021-07-21 07:23:42 +00:00
|
|
|
properties: Omit<E["properties"], "roomId">,
|
2021-07-21 06:40:39 +00:00
|
|
|
) {
|
|
|
|
const updatedProperties = {
|
|
|
|
...properties,
|
|
|
|
hashedRoomId: roomId ? await hashHex(roomId) : null,
|
|
|
|
};
|
2021-07-21 13:57:24 +00:00
|
|
|
await this.trackPseudonymousEvent(eventName, updatedProperties);
|
2021-07-21 06:40:39 +00:00
|
|
|
}
|
2021-07-21 15:52:57 +00:00
|
|
|
|
|
|
|
public async trackPageView(durationMs: number) {
|
|
|
|
await this.trackAnonymousEvent<IPageView>("$pageview", {
|
|
|
|
durationMs,
|
|
|
|
});
|
|
|
|
}
|
2021-07-21 07:42:29 +00:00
|
|
|
|
2021-07-28 08:37:08 +00:00
|
|
|
public async updatePlatformSuperProperties() {
|
2021-07-28 08:54:37 +00:00
|
|
|
// 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.
|
2021-07-28 08:37:08 +00:00
|
|
|
this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties();
|
|
|
|
this.registerSuperProperties(this.platformSuperProperties);
|
2021-07-21 17:35:25 +00:00
|
|
|
}
|
|
|
|
|
2021-07-28 08:37:08 +00:00
|
|
|
public async updateAnonymityFromSettings(userId?: string) {
|
2021-07-28 08:54:37 +00:00
|
|
|
// Update this.anonymity based on the user's analytics opt-in settings
|
|
|
|
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
|
2021-07-28 08:37:08 +00:00
|
|
|
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
|
|
|
|
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
|
|
|
|
await this.identifyUser(userId);
|
|
|
|
}
|
2021-07-23 16:58:31 +00:00
|
|
|
}
|
2021-07-28 08:37:08 +00:00
|
|
|
}
|
2021-07-23 16:58:31 +00:00
|
|
|
|
2021-07-28 08:37:08 +00:00
|
|
|
export function getAnalytics(): PosthogAnalytics {
|
|
|
|
return PosthogAnalytics.instance();
|
2021-07-23 16:58:31 +00:00
|
|
|
}
|