element-web/src/PosthogAnalytics.ts

226 lines
7.5 KiB
TypeScript
Raw Normal View History

2021-07-21 12:48:10 +00:00
import posthog, { PostHog } from 'posthog-js';
2021-07-21 06:40:39 +00:00
import SdkConfig from './SdkConfig';
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 {
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.
// 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
}
2021-07-21 15:52:57 +00:00
interface IPageView extends IAnonymousEvent {
eventName: "$pageview",
properties: {
durationMs?: number
}
}
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> => {
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>/";
}
let [_, screen, ...parts] = hash.split("/");
if (!knownScreens.has(screen)) {
screen = "<redacted_screen_name>";
}
for (let i = 0; i < parts.length; i++) {
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
}
const hashStr = `${_}/${screen}/${parts.join("/")}`;
return origin + pathname + hashStr;
}
2021-07-21 06:40:39 +00:00
export class PosthogAnalytics {
private anonymity = Anonymity.Anonymous;
2021-07-21 06:40:39 +00:00
private initialised = false;
2021-07-21 12:48:10 +00:00
private posthog?: PostHog = null;
private enabled = false;
2021-07-21 06:40:39 +00:00
private static _instance = null;
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;
}
public init(anonymity: Anonymity) {
this.anonymity = anonymity;
2021-07-21 06:40:39 +00:00
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,
// 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,
2021-07-21 12:48:10 +00:00
sanitize_properties: this.sanitizeProperties.bind(this),
respect_dnt: true,
2021-07-21 12:48:10 +00:00
});
2021-07-21 06:40:39 +00:00
this.initialised = true;
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 {
// 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
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-21 07:38:58 +00:00
public async identifyUser(userId: string) {
if (this.anonymity == Anonymity.Anonymous) return;
2021-07-21 07:38:58 +00:00
this.posthog.identify(await hashHex(userId));
}
public isInitialised() {
2021-07-21 06:40:39 +00:00
return this.initialised;
}
public isEnabled() {
return this.enabled;
}
public setAnonymity(anonymity: Anonymity) {
this.anonymity = anonymity;
}
public getAnonymity() {
return this.anonymity;
2021-07-21 06:40:39 +00:00
}
public logout() {
if (this.enabled) {
this.posthog.reset();
}
this.setAnonymity(Anonymity.Anonymous);
}
private async capture(eventName: string, properties: posthog.Properties) {
if (!this.enabled) {
return;
}
if (!this.initialised) {
throw Error("Tried to track event before PoshogAnalytics.init has completed");
}
const { origin, hash, pathname } = window.location;
properties['$redacted_current_url'] = await getRedactedCurrentLocation(
origin, hash, pathname, this.anonymity);
2021-07-21 12:48:10 +00:00
this.posthog.capture(eventName, properties);
}
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"],
) {
if (this.anonymity == Anonymity.Anonymous) return;
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"],
) {
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,
};
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 06:40:39 +00:00
}
2021-07-21 07:42:29 +00:00
export function getAnalytics(): PosthogAnalytics {
2021-07-21 07:42:29 +00:00
return PosthogAnalytics.instance();
}