From 2a48d3c9bc83e9083c3a9d7366c008f3909c9f6d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 07:40:39 +0100 Subject: [PATCH 01/64] First pass at a PosthogAnalytics class --- package.json | 1 + src/PosthogAnalytics.ts | 82 ++++++++++++++++++++++++++++++++++ test/PosthogAnalytics-test.ts | 84 +++++++++++++++++++++++++++++++++++ yarn.lock | 12 +++++ 4 files changed, 179 insertions(+) create mode 100644 src/PosthogAnalytics.ts create mode 100644 test/PosthogAnalytics-test.ts diff --git a/package.json b/package.json index e80ed8dd5a..805531abff 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", + "posthog-js": "^1.12.1", "prop-types": "^15.7.2", "qrcode": "^1.4.4", "re-resizable": "^6.9.0", diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts new file mode 100644 index 0000000000..1ca2d37de7 --- /dev/null +++ b/src/PosthogAnalytics.ts @@ -0,0 +1,82 @@ +import posthog from 'posthog-js'; +import SdkConfig from './SdkConfig'; + +export interface IEvent { + key: string; + properties: {} +} + +export interface IOnboardingLoginBegin extends IEvent { + key: "onboarding_login_begin", +} + +const hashHex = async (input: string): Promise => { + 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(""); +}; + +export class PosthogAnalytics { + private onlyTrackAnonymousEvents = false; + private initialised = false; + private posthog = null; + + private static _instance = null; + + public static instance(): PosthogAnalytics { + if (!this.instance) { + this._instance = new PosthogAnalytics(posthog); + } + return this._instance; + } + + constructor(posthog) { + this.posthog = posthog; + } + + public init(onlyTrackAnonymousEvents: boolean) { + if (Boolean(navigator.doNotTrack === "1")) { + this.initialised = false; + return; + } + this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; + const posthogConfig = SdkConfig.get()["posthog"]; + if (posthogConfig) { + console.log(`Initialising Posthog for ${posthogConfig.apiHost}`); + this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost }); + this.initialised = true; + } + } + + public isInitialised(): boolean { + return this.initialised; + } + + public setOnlyTrackAnonymousEvents(enabled: boolean) { + this.onlyTrackAnonymousEvents = enabled; + } + + public track( + key: E["key"], + properties: E["properties"], + anonymous = false, + ) { + if (!this.initialised) return; + if (this.onlyTrackAnonymousEvents && !anonymous) return; + + this.posthog.capture(key, properties); + } + + public async trackRoomEvent( + key: E["key"], + roomId: string, + properties: E["properties"], + ...args + ) { + const updatedProperties = { + ...properties, + hashedRoomId: roomId ? await hashHex(roomId) : null, + }; + this.track(key, updatedProperties, ...args); + } +} diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts new file mode 100644 index 0000000000..a37d5cb2c8 --- /dev/null +++ b/test/PosthogAnalytics-test.ts @@ -0,0 +1,84 @@ +import { IEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; +import SdkConfig from '../src/SdkConfig'; +const crypto = require('crypto'); + +class FakePosthog { + public capture; + public init; + + constructor() { + this.capture = jest.fn(); + this.init = jest.fn(); + } +} + +export interface ITestEvent extends IEvent { + key: "jest_test_event", + properties: { + foo: string + } +} + +describe("PosthogAnalytics", () => { + let analytics: PosthogAnalytics; + let fakePosthog: FakePosthog; + + beforeEach(() => { + fakePosthog = new FakePosthog(); + analytics = new PosthogAnalytics(fakePosthog); + window.crypto = { + subtle: crypto.webcrypto.subtle, + }; + }); + + afterEach(() => { + navigator.doNotTrack = null; + window.crypto = null; + }); + + it("Should not initialise if DNT is enabled", () => { + navigator.doNotTrack = "1"; + analytics.init(false); + expect(analytics.isInitialised()).toBe(false); + }); + + it("Should not initialise if config is not set", () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({}); + analytics.init(false); + expect(analytics.isInitialised()).toBe(false); + }); + + it("Should initialise if config is set", () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({ + posthog: { + projectApiKey: "foo", + apiHost: "bar", + }, + }); + analytics.init(false); + expect(analytics.isInitialised()).toBe(true); + }); + + it("Should pass track() to posthog", () => { + analytics.init(false); + analytics.track("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); + }); + + it("Should pass trackRoomEvent to posthog", () => { + analytics.init(false); + const roomId = "42"; + return analytics.trackRoomEvent("jest_test_event", roomId, { + foo: "bar", + }).then(() => { + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ + foo: "bar", + hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 96c02681fd..9d41c37b12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3601,6 +3601,11 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +fflate@^0.4.1: + version "0.4.8" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" + integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== + file-entry-cache@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a" @@ -6287,6 +6292,13 @@ postcss@^8.0.2: nanoid "^3.1.20" source-map "^0.6.1" +posthog-js@^1.12.1: + version "1.12.1" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.12.1.tgz#97834ee2574f34ffb5db2f5b07452c847e3c4d27" + integrity sha512-Y3lzcWkS8xFY6Ryj3I4ees7qWP2WGkLw0Arcbk5xaT0+5YlA6UC2jlL/+fN9bz/Bl62EoN3BML901Cuot/QNjg== + dependencies: + fflate "^0.4.1" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" From d4550c1a28a61a8026590f83ebea5c6589406a8f Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 07:42:44 +0100 Subject: [PATCH 02/64] Remove console logging --- src/PosthogAnalytics.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 1ca2d37de7..5b2a601adc 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -42,7 +42,6 @@ export class PosthogAnalytics { this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { - console.log(`Initialising Posthog for ${posthogConfig.apiHost}`); this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost }); this.initialised = true; } From 3135e425865232bfd1e1b131a97ec067c150802d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 07:42:55 +0100 Subject: [PATCH 03/64] Add test for silently ignoring messages when not initialised --- test/PosthogAnalytics-test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a37d5cb2c8..56e6af8666 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -81,4 +81,12 @@ describe("PosthogAnalytics", () => { }); }); }); + + it("Should silently not send messages if not inititalised", () => { + analytics.track("jest_test_event", { + foo: "bar", + }); + + expect(fakePosthog.capture.mock.calls.length).toBe(0); + }); }); From 74b0e52f9a2ac243af50ab419da9c296b74a1540 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 08:23:42 +0100 Subject: [PATCH 04/64] Enforce anon/pseudo-anon via types --- src/PosthogAnalytics.ts | 48 +++++++++++++++++++++++++---------- test/PosthogAnalytics-test.ts | 25 +++++++++++++----- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 5b2a601adc..133c9275d4 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,11 +1,28 @@ import posthog from 'posthog-js'; import SdkConfig from './SdkConfig'; -export interface IEvent { - key: string; +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: {} } +// 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 +} + export interface IOnboardingLoginBegin extends IEvent { key: "onboarding_login_begin", } @@ -55,27 +72,32 @@ export class PosthogAnalytics { this.onlyTrackAnonymousEvents = enabled; } - public track( - key: E["key"], + public trackPseudonymousEvent( + eventName: E["eventName"], properties: E["properties"], - anonymous = false, ) { if (!this.initialised) return; - if (this.onlyTrackAnonymousEvents && !anonymous) return; - - this.posthog.capture(key, properties); + if (this.onlyTrackAnonymousEvents) return; + this.posthog.capture(eventName, properties); } - public async trackRoomEvent( - key: E["key"], - roomId: string, + public trackAnonymousEvent( + eventName: E["eventName"], properties: E["properties"], - ...args + ) { + if (!this.initialised) return; + this.posthog.capture(eventName, properties); + } + + public async trackRoomEvent( + eventName: E["eventName"], + roomId: string, + properties: Omit, ) { const updatedProperties = { ...properties, hashedRoomId: roomId ? await hashHex(roomId) : null, }; - this.track(key, updatedProperties, ...args); + this.trackPseudonymousEvent(eventName, updatedProperties); } } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 56e6af8666..dfadac921d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,4 +1,4 @@ -import { IEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; +import { IAnonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; const crypto = require('crypto'); @@ -12,13 +12,20 @@ class FakePosthog { } } -export interface ITestEvent extends IEvent { +export interface ITestEvent extends IAnonymousEvent { key: "jest_test_event", properties: { foo: string } } +export interface ITestRoomEvent extends IRoomEvent { + key: "jest_test_room_event", + properties: { + foo: string + } +} + describe("PosthogAnalytics", () => { let analytics: PosthogAnalytics; let fakePosthog: FakePosthog; @@ -61,7 +68,7 @@ describe("PosthogAnalytics", () => { it("Should pass track() to posthog", () => { analytics.init(false); - analytics.track("jest_test_event", { + analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); @@ -71,7 +78,7 @@ describe("PosthogAnalytics", () => { it("Should pass trackRoomEvent to posthog", () => { analytics.init(false); const roomId = "42"; - return analytics.trackRoomEvent("jest_test_event", roomId, { + return analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", }).then(() => { expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); @@ -82,11 +89,17 @@ describe("PosthogAnalytics", () => { }); }); - it("Should silently not send messages if not inititalised", () => { - analytics.track("jest_test_event", { + it("Should silently not track if not inititalised", () => { + analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls.length).toBe(0); }); + + it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", () => { + analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + }); }); From 4b0cb409a078e0baa701e91ff88eae2d49e41bb9 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 08:38:58 +0100 Subject: [PATCH 05/64] Add identifyUser --- src/PosthogAnalytics.ts | 5 +++++ test/PosthogAnalytics-test.ts | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 133c9275d4..404f0e5f20 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -64,6 +64,11 @@ export class PosthogAnalytics { } } + public async identifyUser(userId: string) { + if (this.onlyTrackAnonymousEvents) return; + this.posthog.identify(await hashHex(userId)); + } + public isInitialised(): boolean { return this.initialised; } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index dfadac921d..fd49255fa1 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -5,10 +5,12 @@ const crypto = require('crypto'); class FakePosthog { public capture; public init; + public identify; constructor() { this.capture = jest.fn(); this.init = jest.fn(); + this.identify = jest.fn(); } } @@ -75,7 +77,7 @@ describe("PosthogAnalytics", () => { expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); }); - it("Should pass trackRoomEvent to posthog", () => { + it("Should pass trackRoomEvent to posthog", async () => { analytics.init(false); const roomId = "42"; return analytics.trackRoomEvent("jest_test_event", roomId, { @@ -102,4 +104,18 @@ describe("PosthogAnalytics", () => { foo: "bar", }); }); + + it("Should identify the user to posthog if onlyTrackAnonymousEvents is false", async () => { + analytics.init(false); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls[0][0]) + .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); + }); + + it("Should not identify the user to posthog if onlyTrackAnonymousEvents is true", async () => { + analytics.init(true); + return analytics.identifyUser("foo").then(() => { + expect(fakePosthog.identify.mock.calls.length).toBe(0); + }); + }); }); From b5564a0de08088ad59179f7948c110b118f52e54 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 08:42:29 +0100 Subject: [PATCH 06/64] Add getAnalytics helper --- src/PosthogAnalytics.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 404f0e5f20..0195142d9b 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -106,3 +106,7 @@ export class PosthogAnalytics { this.trackPseudonymousEvent(eventName, updatedProperties); } } + +export default function getAnalytics() { + return PosthogAnalytics.instance(); +} From 678474c0e8abac4bb1353329ed85aebe835def03 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 11:23:18 +0100 Subject: [PATCH 07/64] Fix missing underscore --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 0195142d9b..1b5988e0df 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -41,7 +41,7 @@ export class PosthogAnalytics { private static _instance = null; public static instance(): PosthogAnalytics { - if (!this.instance) { + if (!this._instance) { this._instance = new PosthogAnalytics(posthog); } return this._instance; From d9594c428a8defd13ca31dd4200619bbf39c6141 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 11:23:42 +0100 Subject: [PATCH 08/64] login event should be IAnonymousEvent --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 1b5988e0df..d1149095e0 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -23,7 +23,7 @@ export interface IRoomEvent extends IPseudonymousEvent { hashedRoomId: string } -export interface IOnboardingLoginBegin extends IEvent { +export interface IOnboardingLoginBegin extends IAnonymousEvent { key: "onboarding_login_begin", } From 7e549f84e7da7a6286c1cfc1f806f9b0b1fb91fc Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 11:23:55 +0100 Subject: [PATCH 09/64] Don't make getAnalytics the default export, its weird --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d1149095e0..80b5861c17 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -107,6 +107,6 @@ export class PosthogAnalytics { } } -export default function getAnalytics() { +export function getAnalytics(): PosthogAnalytics { return PosthogAnalytics.instance(); } From 6da3cc8ca1baf268d768ed63e4b9cb16e40ce33d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 13:48:10 +0100 Subject: [PATCH 10/64] Redact sensitive data --- src/PosthogAnalytics.ts | 103 +++++++++++++++++++++++++++++----- test/PosthogAnalytics-test.ts | 66 ++++++++++++++++------ 2 files changed, 138 insertions(+), 31 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 80b5861c17..d5f9b1d83c 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,4 +1,4 @@ -import posthog from 'posthog-js'; +import posthog, { PostHog } from 'posthog-js'; import SdkConfig from './SdkConfig'; interface IEvent { @@ -10,6 +10,11 @@ interface IEvent { properties: {} } +export enum Anonymity { + 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. @@ -33,10 +38,38 @@ const hashHex = async (input: string): Promise => { 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 onlyTrackAnonymousEvents = false; private initialised = false; - private posthog = null; + private posthog?: PostHog = null; + private redactedCurrentLocation = null; private static _instance = null; @@ -47,23 +80,63 @@ export class PosthogAnalytics { return this._instance; } - constructor(posthog) { + constructor(posthog: PostHog) { this.posthog = posthog; } - public init(onlyTrackAnonymousEvents: boolean) { + public async init(onlyTrackAnonymousEvents: boolean) { if (Boolean(navigator.doNotTrack === "1")) { this.initialised = false; return; } this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; + const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { - this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost }); + // Update the redacted current location before initialising posthog, as posthog.init triggers + // an immediate pageview event which calls the sanitize_properties callback + await this.updateRedactedCurrentLocation(); + + this.posthog.init(posthogConfig.projectApiKey, { + api_host: posthogConfig.apiHost, + autocapture: false, + mask_all_text: true, + mask_all_element_attributes: true, + sanitize_properties: this.sanitizeProperties.bind(this), + }); this.initialised = true; } } + private async updateRedactedCurrentLocation() { + // TODO only calculate this when the location changes as its expensive + const { origin, hash, pathname } = window.location; + this.redactedCurrentLocation = await getRedactedCurrentLocation( + origin, hash, pathname, this.onlyTrackAnonymousEvents ? Anonymity.Anonymous : Anonymity.Pseudonymous); + } + + private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { + // Sanitize posthog's built in properties which leak PII e.g. url reporting + // see utils.js _.info.properties in posthog-js + + // this.redactedCurrentLocation needs to have been updated prior to reaching this point as + // updating it involves async, which this callback is not + properties['$current_url'] = this.redactedCurrentLocation; + + if (this.onlyTrackAnonymousEvents) { + // 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.onlyTrackAnonymousEvents) return; this.posthog.identify(await hashHex(userId)); @@ -77,21 +150,25 @@ export class PosthogAnalytics { this.onlyTrackAnonymousEvents = enabled; } - public trackPseudonymousEvent( - eventName: E["eventName"], - properties: E["properties"], - ) { + private async capture(eventName: string, properties: posthog.Properties, anonymity: Anonymity) { if (!this.initialised) return; - if (this.onlyTrackAnonymousEvents) return; + await this.updateRedactedCurrentLocation(anonymity); this.posthog.capture(eventName, properties); } - public trackAnonymousEvent( + public async trackPseudonymousEvent( eventName: E["eventName"], properties: E["properties"], ) { - if (!this.initialised) return; - this.posthog.capture(eventName, properties); + if (this.onlyTrackAnonymousEvents) return; + this.capture(eventName, properties, Anonymity.Pseudonyomous); + } + + public async trackAnonymousEvent( + eventName: E["eventName"], + properties: E["properties"], + ) { + this.capture(eventName, properties, Anonymity.Anonymous); } public async trackRoomEvent( diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index fd49255fa1..e9efeffa7d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,4 +1,5 @@ -import { IAnonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; +import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IRoomEvent, + PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; const crypto = require('crypto'); @@ -68,9 +69,9 @@ describe("PosthogAnalytics", () => { expect(analytics.isInitialised()).toBe(true); }); - it("Should pass track() to posthog", () => { + it("Should pass track() to posthog", async () => { analytics.init(false); - analytics.trackAnonymousEvent("jest_test_event", { + await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); @@ -80,29 +81,29 @@ describe("PosthogAnalytics", () => { it("Should pass trackRoomEvent to posthog", async () => { analytics.init(false); const roomId = "42"; - return analytics.trackRoomEvent("jest_test_event", roomId, { + await analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", - }).then(() => { - expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ - foo: "bar", - hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", - }); + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ + foo: "bar", + hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", }); }); - it("Should silently not track if not inititalised", () => { - analytics.trackAnonymousEvent("jest_test_event", { + it("Should silently not track if not inititalised", async () => { + await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); - expect(fakePosthog.capture.mock.calls.length).toBe(0); }); - it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", () => { - analytics.trackAnonymousEvent("jest_test_event", { + it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", async () => { + analytics.init(true); + await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); + expect(fakePosthog.capture.mock.calls.length).toBe(0); }); it("Should identify the user to posthog if onlyTrackAnonymousEvents is false", async () => { @@ -114,8 +115,37 @@ describe("PosthogAnalytics", () => { it("Should not identify the user to posthog if onlyTrackAnonymousEvents is true", async () => { analytics.init(true); - return analytics.identifyUser("foo").then(() => { - expect(fakePosthog.identify.mock.calls.length).toBe(0); - }); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls.length).toBe(0); + }); + + it("Should pseudonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#/register/\ +a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ +bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); + }); + + it("Should anonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#/register//"); + }); + + it("Should pseudonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#//\ +a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ +bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); + }); + + it("Should anonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#///"); }); }); From 4c6b0d35add7ae8d58f71ea1711587e31081444b Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 14:57:24 +0100 Subject: [PATCH 11/64] Improve analytics interface * Make it an error to call it before its initialised, and separately track whether its been enabled * Use anonmity enum in the public interface * Properly await upstream calls * Fix accidental test fixture cross-reliance --- src/PosthogAnalytics.ts | 60 ++++++++---- test/PosthogAnalytics-test.ts | 177 ++++++++++++++++++---------------- 2 files changed, 136 insertions(+), 101 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d5f9b1d83c..63cb3bc422 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -66,10 +66,11 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p } export class PosthogAnalytics { - private onlyTrackAnonymousEvents = false; + private anonymity = Anonymity.Anonymous; private initialised = false; private posthog?: PostHog = null; private redactedCurrentLocation = null; + private enabled = false; private static _instance = null; @@ -84,12 +85,8 @@ export class PosthogAnalytics { this.posthog = posthog; } - public async init(onlyTrackAnonymousEvents: boolean) { - if (Boolean(navigator.doNotTrack === "1")) { - this.initialised = false; - return; - } - this.onlyTrackAnonymousEvents = onlyTrackAnonymousEvents; + public async init(anonymity: Anonymity) { + this.anonymity = Boolean(navigator.doNotTrack === "1") ? Anonymity.Anonymous : anonymity; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { @@ -105,6 +102,9 @@ export class PosthogAnalytics { sanitize_properties: this.sanitizeProperties.bind(this), }); this.initialised = true; + this.enabled = true; + } else { + this.enabled = false; } } @@ -112,7 +112,7 @@ export class PosthogAnalytics { // TODO only calculate this when the location changes as its expensive const { origin, hash, pathname } = window.location; this.redactedCurrentLocation = await getRedactedCurrentLocation( - origin, hash, pathname, this.onlyTrackAnonymousEvents ? Anonymity.Anonymous : Anonymity.Pseudonymous); + origin, hash, pathname, this.anonymity); } private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { @@ -123,7 +123,7 @@ export class PosthogAnalytics { // updating it involves async, which this callback is not properties['$current_url'] = this.redactedCurrentLocation; - if (this.onlyTrackAnonymousEvents) { + if (this.anonymity == Anonymity.Anonymous) { // drop referrer information for anonymous users properties['$referrer'] = null; properties['$referring_domain'] = null; @@ -138,21 +138,41 @@ export class PosthogAnalytics { } public async identifyUser(userId: string) { - if (this.onlyTrackAnonymousEvents) return; + if (this.anonymity == Anonymity.Anonymous) return; this.posthog.identify(await hashHex(userId)); } - public isInitialised(): boolean { + public isInitialised() { return this.initialised; } - public setOnlyTrackAnonymousEvents(enabled: boolean) { - this.onlyTrackAnonymousEvents = enabled; + public isEnabled() { + return this.enabled; } - private async capture(eventName: string, properties: posthog.Properties, anonymity: Anonymity) { - if (!this.initialised) return; - await this.updateRedactedCurrentLocation(anonymity); + 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.enabled) { + return; + } + if (!this.initialised) { + throw Error("Tried to track event before PoshogAnalytics.init has completed"); + } + await this.updateRedactedCurrentLocation(); this.posthog.capture(eventName, properties); } @@ -160,15 +180,15 @@ export class PosthogAnalytics { eventName: E["eventName"], properties: E["properties"], ) { - if (this.onlyTrackAnonymousEvents) return; - this.capture(eventName, properties, Anonymity.Pseudonyomous); + if (this.anonymity == Anonymity.Anonymous) return; + await this.capture(eventName, properties); } public async trackAnonymousEvent( eventName: E["eventName"], properties: E["properties"], ) { - this.capture(eventName, properties, Anonymity.Anonymous); + await this.capture(eventName, properties); } public async trackRoomEvent( @@ -180,7 +200,7 @@ export class PosthogAnalytics { ...properties, hashedRoomId: roomId ? await hashHex(roomId) : null, }; - this.trackPseudonymousEvent(eventName, updatedProperties); + await this.trackPseudonymousEvent(eventName, updatedProperties); } } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index e9efeffa7d..515d51b8e4 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -46,106 +46,121 @@ describe("PosthogAnalytics", () => { window.crypto = null; }); - it("Should not initialise if DNT is enabled", () => { - navigator.doNotTrack = "1"; - analytics.init(false); - expect(analytics.isInitialised()).toBe(false); - }); - - it("Should not initialise if config is not set", () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({}); - analytics.init(false); - expect(analytics.isInitialised()).toBe(false); - }); - - it("Should initialise if config is set", () => { - jest.spyOn(SdkConfig, "get").mockReturnValue({ - posthog: { - projectApiKey: "foo", - apiHost: "bar", - }, + describe("Initialisation", () => { + it("Should not initialise if config is not set", async () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({}); + await analytics.init(Anonymity.Pseudonymous); + expect(analytics.isEnabled()).toBe(false); }); - analytics.init(false); - expect(analytics.isInitialised()).toBe(true); - }); - it("Should pass track() to posthog", async () => { - analytics.init(false); - await analytics.trackAnonymousEvent("jest_test_event", { - foo: "bar", + it("Should initialise if config is set", async () => { + jest.spyOn(SdkConfig, "get").mockReturnValue({ + posthog: { + projectApiKey: "foo", + apiHost: "bar", + }, + }); + await analytics.init(Anonymity.Pseudonymous); + expect(analytics.isInitialised()).toBe(true); + expect(analytics.isEnabled()).toBe(true); }); - expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); - }); - it("Should pass trackRoomEvent to posthog", async () => { - analytics.init(false); - const roomId = "42"; - await analytics.trackRoomEvent("jest_test_event", roomId, { - foo: "bar", - }); - expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ - foo: "bar", - hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + it("Should force anonymous if DNT is enabled", async () => { + navigator.doNotTrack = "1"; + await analytics.init(Anonymity.Pseudonymous); + expect(analytics.getAnonymity()).toBe(Anonymity.Anonymous); }); }); - it("Should silently not track if not inititalised", async () => { - await analytics.trackAnonymousEvent("jest_test_event", { - foo: "bar", + describe("Tracking", () => { + beforeEach(() => { + navigator.doNotTrack = null; + jest.spyOn(SdkConfig, "get").mockReturnValue({ + posthog: { + projectApiKey: "foo", + apiHost: "bar", + }, + }); }); - expect(fakePosthog.capture.mock.calls.length).toBe(0); - }); - it("Should not track non-anonymous messages if onlyTrackAnonymousEvents is true", async () => { - analytics.init(true); - await analytics.trackPseudonymousEvent("jest_test_event", { - foo: "bar", + it("Should pass track() to posthog", async () => { + await analytics.init(Anonymity.Pseudonymous); + await analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); }); - expect(fakePosthog.capture.mock.calls.length).toBe(0); - }); - it("Should identify the user to posthog if onlyTrackAnonymousEvents is false", async () => { - analytics.init(false); - await analytics.identifyUser("foo"); - expect(fakePosthog.identify.mock.calls[0][0]) - .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); - }); + it("Should pass trackRoomEvent to posthog", async () => { + await analytics.init(Anonymity.Pseudonymous); + const roomId = "42"; + await analytics.trackRoomEvent("jest_test_event", roomId, { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); + expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ + foo: "bar", + hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + }); + }); - it("Should not identify the user to posthog if onlyTrackAnonymousEvents is true", async () => { - analytics.init(true); - await analytics.identifyUser("foo"); - expect(fakePosthog.identify.mock.calls.length).toBe(0); - }); + it("Should silently not track if not inititalised", async () => { + await analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls.length).toBe(0); + }); - it("Should pseudonymise a location of a known screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); - expect(location).toBe( - `https://foo.bar/#/register/\ + it("Should not track non-anonymous messages if anonymous", async () => { + await analytics.init(Anonymity.Anonymous); + await analytics.trackPseudonymousEvent("jest_test_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls.length).toBe(0); + }); + + it("Should pseudonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#/register/\ a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); - }); + }); - it("Should anonymise a location of a known screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); - expect(location).toBe("https://foo.bar/#/register//"); - }); + it("Should anonymise a location of a known screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#/register//"); + }); - it("Should pseudonymise a location of an unknown screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); - expect(location).toBe( - `https://foo.bar/#//\ + it("Should pseudonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); + expect(location).toBe( + `https://foo.bar/#//\ a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); - }); + }); - it("Should anonymise a location of an unknown screen", async () => { - const location = await getRedactedCurrentLocation( - "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); - expect(location).toBe("https://foo.bar/#///"); + it("Should anonymise a location of an unknown screen", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/#///"); + }); + + it("Should identify the user to posthog if pseudonymous", async () => { + await analytics.init(Anonymity.Pseudonymous); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls[0][0]) + .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); + }); + + it("Should not identify the user to posthog if anonymous", async () => { + await analytics.init(Anonymity.Anonymous); + await analytics.identifyUser("foo"); + expect(fakePosthog.identify.mock.calls.length).toBe(0); + }); }); }); From 726b4497b2a54fcf6fe743d988325879d8e77890 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:06:09 +0100 Subject: [PATCH 12/64] Remove redactedCurrentLocation and rely on posthog for DNT * Redact and pass the redacted url as a property. redactedCurrentLocation might have issues with concurrent events * Remove DNT code and rely on posthog --- src/PosthogAnalytics.ts | 42 +++++++++++++++++------------------ test/PosthogAnalytics-test.ts | 30 +++++++++---------------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 63cb3bc422..fd8bb44e0b 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -69,7 +69,6 @@ export class PosthogAnalytics { private anonymity = Anonymity.Anonymous; private initialised = false; private posthog?: PostHog = null; - private redactedCurrentLocation = null; private enabled = false; private static _instance = null; @@ -85,21 +84,20 @@ export class PosthogAnalytics { this.posthog = posthog; } - public async init(anonymity: Anonymity) { - this.anonymity = Boolean(navigator.doNotTrack === "1") ? Anonymity.Anonymous : anonymity; - + public init(anonymity: Anonymity) { const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { - // Update the redacted current location before initialising posthog, as posthog.init triggers - // an immediate pageview event which calls the sanitize_properties callback - await this.updateRedactedCurrentLocation(); - this.posthog.init(posthogConfig.projectApiKey, { api_host: posthogConfig.apiHost, autocapture: false, mask_all_text: true, mask_all_element_attributes: true, + // this is disabled for now as its tricky to sanitize properties of the pageview + // event because sanitization requires async crypto calls and the sanitize_properties + // callback is synchronous. + capture_pageview: false, sanitize_properties: this.sanitizeProperties.bind(this), + respect_dnt: true, }); this.initialised = true; this.enabled = true; @@ -108,20 +106,20 @@ export class PosthogAnalytics { } } - private async updateRedactedCurrentLocation() { - // TODO only calculate this when the location changes as its expensive - const { origin, hash, pathname } = window.location; - this.redactedCurrentLocation = await getRedactedCurrentLocation( - origin, hash, pathname, this.anonymity); - } - private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { - // Sanitize posthog's built in properties which leak PII e.g. url reporting - // see utils.js _.info.properties in posthog-js + // 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. - // this.redactedCurrentLocation needs to have been updated prior to reaching this point as - // updating it involves async, which this callback is not - properties['$current_url'] = this.redactedCurrentLocation; + // 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 @@ -172,7 +170,9 @@ export class PosthogAnalytics { if (!this.initialised) { throw Error("Tried to track event before PoshogAnalytics.init has completed"); } - await this.updateRedactedCurrentLocation(); + const { origin, hash, pathname } = window.location; + properties['$redacted_current_url'] = await getRedactedCurrentLocation( + origin, hash, pathname, this.anonymity); this.posthog.capture(eventName, properties); } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 515d51b8e4..a1e54dc05d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -49,7 +49,7 @@ describe("PosthogAnalytics", () => { describe("Initialisation", () => { it("Should not initialise if config is not set", async () => { jest.spyOn(SdkConfig, "get").mockReturnValue({}); - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); expect(analytics.isEnabled()).toBe(false); }); @@ -60,21 +60,14 @@ describe("PosthogAnalytics", () => { apiHost: "bar", }, }); - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); expect(analytics.isInitialised()).toBe(true); expect(analytics.isEnabled()).toBe(true); }); - - it("Should force anonymous if DNT is enabled", async () => { - navigator.doNotTrack = "1"; - await analytics.init(Anonymity.Pseudonymous); - expect(analytics.getAnonymity()).toBe(Anonymity.Anonymous); - }); }); describe("Tracking", () => { beforeEach(() => { - navigator.doNotTrack = null; jest.spyOn(SdkConfig, "get").mockReturnValue({ posthog: { projectApiKey: "foo", @@ -84,25 +77,24 @@ describe("PosthogAnalytics", () => { }); it("Should pass track() to posthog", async () => { - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ foo: "bar" }); + expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); }); it("Should pass trackRoomEvent to posthog", async () => { - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); const roomId = "42"; await analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", }); expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event"); - expect(fakePosthog.capture.mock.calls[0][1]).toEqual({ - foo: "bar", - hashedRoomId: "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", - }); + expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); + expect(fakePosthog.capture.mock.calls[0][1]["hashedRoomId"]) + .toEqual("73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"); }); it("Should silently not track if not inititalised", async () => { @@ -113,7 +105,7 @@ describe("PosthogAnalytics", () => { }); it("Should not track non-anonymous messages if anonymous", async () => { - await analytics.init(Anonymity.Anonymous); + analytics.init(Anonymity.Anonymous); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); @@ -151,14 +143,14 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); }); it("Should identify the user to posthog if pseudonymous", async () => { - await analytics.init(Anonymity.Pseudonymous); + analytics.init(Anonymity.Pseudonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls[0][0]) .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); }); it("Should not identify the user to posthog if anonymous", async () => { - await analytics.init(Anonymity.Anonymous); + analytics.init(Anonymity.Anonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls.length).toBe(0); }); From 5697eeaab8aab98d5a8294775ef60a4bc0ebf577 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:08:00 +0100 Subject: [PATCH 13/64] Put back accidentally removed anonymity update --- src/PosthogAnalytics.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index fd8bb44e0b..2e5b9446de 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -85,6 +85,7 @@ export class PosthogAnalytics { } public init(anonymity: Anonymity) { + this.anonymity = anonymity; const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { this.posthog.init(posthogConfig.projectApiKey, { From 53b6749f73d5b8f545024f64597d267dc7aa18ec Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:09:22 +0100 Subject: [PATCH 14/64] Change onboarding_login_begin to welcome_screen_load --- src/PosthogAnalytics.ts | 4 ++-- src/components/views/auth/Welcome.js | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 2e5b9446de..faf9af6ba5 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -28,8 +28,8 @@ export interface IRoomEvent extends IPseudonymousEvent { hashedRoomId: string } -export interface IOnboardingLoginBegin extends IAnonymousEvent { - key: "onboarding_login_begin", +export interface IWelcomeScreenLoad extends IAnonymousEvent { + key: "welcome_screen_load", } const hashHex = async (input: string): Promise => { diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index e3f7a601f2..470ea4223c 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getAnalytics } from "../../../PosthogAnalytics"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -68,4 +69,8 @@ export default class Welcome extends React.PureComponent { ); } + + componentDidMount() { + getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); + } } From 6737cfd2978e44c47d8eef73375457c0b91f4505 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:10:06 +0100 Subject: [PATCH 15/64] remove superflous dnt clear --- test/PosthogAnalytics-test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a1e54dc05d..a33544e738 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -42,7 +42,6 @@ describe("PosthogAnalytics", () => { }); afterEach(() => { - navigator.doNotTrack = null; window.crypto = null; }); From 34f8c60b346a76ac3511999c7f6f256f4231d17e Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:15:18 +0100 Subject: [PATCH 16/64] Hook analytics into the SDK --- src/Lifecycle.ts | 3 +++ src/components/structures/MatrixChat.tsx | 5 +++++ .../views/settings/tabs/user/SecurityUserSettingsTab.js | 2 ++ 3 files changed, 10 insertions(+) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 410124a637..8536f808ff 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,6 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; +import { getAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -700,6 +701,8 @@ export function logout(): void { CountlyAnalytics.instance.enable(/* anonymous = */ true); } + getAnalytics().logout(); + if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions // Also we sometimes want to re-log in a guest session if we abort the login. diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 785838ffca..3edc463a23 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,6 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; +import { Anonymity, getAnalytics } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -387,6 +388,7 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } + getAnalytics().init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -498,6 +500,8 @@ export default class MatrixChat extends React.PureComponent { } else if (SettingsStore.getValue("analyticsOptIn")) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } + getAnalytics().setAnonymity(SettingsStore.getValue("analyticsOptIn") ? + Anonymity.Pseudonymous: Anonymity.Anonymous); }); // Note we don't catch errors from this: we catch everything within // loadSession as there's logic there to ask the user if they want @@ -822,6 +826,7 @@ export default class MatrixChat extends React.PureComponent { if (CountlyAnalytics.instance.canEnable()) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } + getAnalytics().setAnonymity(Anonymity.Pseudonymous); break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index a03598b21f..15b4992cd8 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -36,6 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import CountlyAnalytics from "../../../../../CountlyAnalytics"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import { Anonymity, getAnalytics } from "../../../../../PosthogAnalytics"; export class IgnoredUser extends React.Component { static propTypes = { @@ -106,6 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component { _updateAnalytics = (checked) => { checked ? Analytics.enable() : Analytics.disable(); CountlyAnalytics.instance.enable(/* anonymous = */ !checked); + getAnalytics().setAnonymity(checked ? Anonymity.Pseudonymous : Anonymity.Anonymous); }; _onExportE2eKeysClicked = () => { From 93962c0acaa0887aef2a5bf425226d8004798fcd Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:29:11 +0100 Subject: [PATCH 17/64] Update reasoning around disabling capture_pageview --- src/PosthogAnalytics.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index faf9af6ba5..c18cb98f03 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -93,9 +93,11 @@ export class PosthogAnalytics { autocapture: false, mask_all_text: true, mask_all_element_attributes: true, - // this is disabled for now as its tricky to sanitize properties of the pageview - // event because sanitization requires async crypto calls and the sanitize_properties - // callback is synchronous. + // 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, From f358deb6c4dc2281993d275f5c8fdcff7d328da1 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 16:52:57 +0100 Subject: [PATCH 18/64] Manually track page views --- src/PosthogAnalytics.ts | 13 +++++++++++++ src/components/structures/MatrixChat.tsx | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index c18cb98f03..026c8d9c5e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -28,6 +28,13 @@ export interface IRoomEvent extends IPseudonymousEvent { hashedRoomId: string } +interface IPageView extends IAnonymousEvent { + eventName: "$pageview", + properties: { + durationMs?: number + } +} + export interface IWelcomeScreenLoad extends IAnonymousEvent { key: "welcome_screen_load", } @@ -205,6 +212,12 @@ export class PosthogAnalytics { }; await this.trackPseudonymousEvent(eventName, updatedProperties); } + + public async trackPageView(durationMs: number) { + await this.trackAnonymousEvent("$pageview", { + durationMs, + }); + } } export function getAnalytics(): PosthogAnalytics { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 3edc463a23..b29ede409b 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics } from '../../PosthogAnalytics'; +import { Anonymity, getAnalytics, IPageChange } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -445,6 +445,7 @@ export default class MatrixChat extends React.PureComponent { const durationMs = this.stopPageChangeTimer(); Analytics.trackPageChange(durationMs); CountlyAnalytics.instance.trackPageChange(durationMs); + getAnalytics().trackPageView(durationMs); } if (this.focusComposer) { dis.fire(Action.FocusSendMessageComposer); From b380a89ac6ab72526faba13dabfd00ab4ca3cffb Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 17:01:45 +0100 Subject: [PATCH 19/64] Fix wrong overriden attribute --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 026c8d9c5e..d49fb567a0 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -36,7 +36,7 @@ interface IPageView extends IAnonymousEvent { } export interface IWelcomeScreenLoad extends IAnonymousEvent { - key: "welcome_screen_load", + eventName: "welcome_screen_load", } const hashHex = async (input: string): Promise => { From e9e0e4847f45abda0eef86e98c9e01d75ceabe81 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 17:02:14 +0100 Subject: [PATCH 20/64] Move Welcome.js to tsx --- src/components/views/auth/{Welcome.js => Welcome.tsx} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/components/views/auth/{Welcome.js => Welcome.tsx} (93%) diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.tsx similarity index 93% rename from src/components/views/auth/Welcome.js rename to src/components/views/auth/Welcome.tsx index 470ea4223c..92fe9df4d5 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.tsx @@ -25,7 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { getAnalytics } from "../../../PosthogAnalytics"; +import { getAnalytics, IWelcomeScreenLoad } from "../../../PosthogAnalytics"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -71,6 +71,6 @@ export default class Welcome extends React.PureComponent { } componentDidMount() { - getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); + getAnalytics().trackAnonymousEvent("welcome_screen_load", { foo: "bar" }); } } From 0c89eb51d4df8ce683584ec93d04d17bd9b11974 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 18:24:14 +0100 Subject: [PATCH 21/64] add registerSuperProperties --- src/PosthogAnalytics.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d49fb567a0..3f13762327 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -150,6 +150,10 @@ export class PosthogAnalytics { this.posthog.identify(await hashHex(userId)); } + public registerSuperProperties(properties) { + this.posthog.register(properties); + } + public isInitialised() { return this.initialised; } From 585b702652d1ea1e86de130177867151bc29c5aa Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 18:25:05 +0100 Subject: [PATCH 22/64] Add tip about shasum --- src/PosthogAnalytics.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 3f13762327..8ebeff3565 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -40,6 +40,8 @@ export interface IWelcomeScreenLoad extends IAnonymousEvent { } 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(""); From c34afdb4bdd834ea833016d0da795532c41b21b7 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 21 Jul 2021 18:35:25 +0100 Subject: [PATCH 23/64] Refactor platform properties loading --- src/Lifecycle.ts | 10 +++++++++- src/PosthogAnalytics.ts | 17 +++++++++++++++++ src/components/structures/MatrixChat.tsx | 14 ++++++++++---- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 8536f808ff..c27c774cd7 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,7 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; -import { getAnalytics } from "./PosthogAnalytics"; +import { Anonymity, getAnalytics, getPlatformProperties } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -574,6 +574,14 @@ async function doSetLoggedIn( await abortLogin(); } + if (SettingsStore.getValue("analyticsOptIn")) { + const analytics = getAnalytics(); + analytics.setAnonymity(Anonymity.Pseudonymous); + await analytics.identifyUser(credentials.userId); + } else { + getAnalytics().setAnonymity(Anonymity.Anonymous); + } + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); MatrixClientPeg.replaceUsingCreds(credentials); diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 8ebeff3565..9c167f5464 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,4 +1,5 @@ import posthog, { PostHog } from 'posthog-js'; +import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; interface IEvent { @@ -226,6 +227,22 @@ export class PosthogAnalytics { } } +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(); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index b29ede409b..513200520f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics, IPageChange } from '../../PosthogAnalytics'; +import { Anonymity, getAnalytics, getPlatformProperties } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -388,7 +388,11 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } - getAnalytics().init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); + + const analytics = getAnalytics(); + analytics.init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); + getPlatformProperties().then((properties) => analytics.registerSuperProperties(properties)); + CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -501,8 +505,6 @@ export default class MatrixChat extends React.PureComponent { } else if (SettingsStore.getValue("analyticsOptIn")) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } - getAnalytics().setAnonymity(SettingsStore.getValue("analyticsOptIn") ? - Anonymity.Pseudonymous: Anonymity.Anonymous); }); // Note we don't catch errors from this: we catch everything within // loadSession as there's logic there to ask the user if they want @@ -828,6 +830,10 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.enable(/* anonymous = */ false); } getAnalytics().setAnonymity(Anonymity.Pseudonymous); + // TODO: this is an async call and we're not waiting for it to complete - + // so potentially an event could be fired prior to it completing and would be + // missing the user identification. + getAnalytics().identifyUser(MatrixClientPeg.get().getUserId()); break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); From 95f4275807ca3207f179288ae7cbde6352f61167 Mon Sep 17 00:00:00 2001 From: James Salter Date: Fri, 23 Jul 2021 16:47:02 +0100 Subject: [PATCH 24/64] Add Disabled anonymity, improve tests --- src/PosthogAnalytics.ts | 10 ++++---- test/PosthogAnalytics-test.ts | 45 ++++++++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 9c167f5464..3e757060db 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -12,6 +12,7 @@ interface IEvent { } export enum Anonymity { + Disabled, Anonymous, Pseudonymous } @@ -181,12 +182,12 @@ export class PosthogAnalytics { } 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"); } + if (!this.enabled) { + return; + } const { origin, hash, pathname } = window.location; properties['$redacted_current_url'] = await getRedactedCurrentLocation( origin, hash, pathname, this.anonymity); @@ -197,7 +198,7 @@ export class PosthogAnalytics { eventName: E["eventName"], properties: E["properties"], ) { - if (this.anonymity == Anonymity.Anonymous) return; + if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } @@ -205,6 +206,7 @@ export class PosthogAnalytics { eventName: E["eventName"], properties: E["properties"], ) { + if (this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a33544e738..cefaafe78f 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,4 +1,4 @@ -import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IRoomEvent, +import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; const crypto = require('crypto'); @@ -22,6 +22,13 @@ export interface ITestEvent extends IAnonymousEvent { } } +export interface ITestPseudonymousEvent extends IPseudonymousEvent { + key: "jest_test_pseudo_event", + properties: { + foo: string + } +} + export interface ITestRoomEvent extends IRoomEvent { key: "jest_test_room_event", properties: { @@ -75,7 +82,7 @@ describe("PosthogAnalytics", () => { }); }); - it("Should pass track() to posthog", async () => { + it("Should pass trackAnonymousEvent() to posthog", async () => { analytics.init(Anonymity.Pseudonymous); await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", @@ -96,18 +103,44 @@ describe("PosthogAnalytics", () => { .toEqual("73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049"); }); - it("Should silently not track if not inititalised", async () => { - await analytics.trackAnonymousEvent("jest_test_event", { + it("Should pass trackPseudonymousEvent() to posthog", async () => { + analytics.init(Anonymity.Pseudonymous); + await analytics.trackPseudonymousEvent("jest_test_pseudo_event", { + foo: "bar", + }); + expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_pseudo_event"); + expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); + }); + + it("Should blow up if not inititalised prior to tracking", async () => { + const fn = () => { + return analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + }; + await expect(fn()).rejects.toThrow(); + }); + + it("Should not track pseudonymous messages if anonymous", async () => { + analytics.init(Anonymity.Anonymous); + await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); expect(fakePosthog.capture.mock.calls.length).toBe(0); }); - it("Should not track non-anonymous messages if anonymous", async () => { - analytics.init(Anonymity.Anonymous); + it("Should not track any events if disabled", async () => { + analytics.init(Anonymity.Disabled); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); + await analytics.trackAnonymousEvent("jest_test_event", { + foo: "bar", + }); + await analytics.trackRoomEvent("room id", "jest_test_room_event", { + foo: "bar", + }); + await analytics.trackPageView(200); expect(fakePosthog.capture.mock.calls.length).toBe(0); }); From 5e0a3976316d267ebedb285c6dd61f7e471dde22 Mon Sep 17 00:00:00 2001 From: James Salter Date: Fri, 23 Jul 2021 17:58:31 +0100 Subject: [PATCH 25/64] Refactor anonymity derivation --- src/PosthogAnalytics.ts | 32 ++++++++++++++++++++++-- src/components/structures/MatrixChat.tsx | 6 +++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 3e757060db..535781cb08 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,6 +1,7 @@ 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. @@ -78,10 +79,14 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p export class PosthogAnalytics { private anonymity = Anonymity.Anonymous; - private initialised = false; 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 { @@ -155,7 +160,9 @@ export class PosthogAnalytics { } public registerSuperProperties(properties) { - this.posthog.register(properties); + if (this.enabled) { + this.posthog.register(properties); + } } public isInitialised() { @@ -248,3 +255,24 @@ export async function getPlatformProperties() { 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; +} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 513200520f..bd54b0ebc9 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics, getPlatformProperties } from '../../PosthogAnalytics'; +import { Anonymity, getAnalytics, getAnonymityFromSettings, getPlatformProperties } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -390,7 +390,9 @@ export default class MatrixChat extends React.PureComponent { } const analytics = getAnalytics(); - analytics.init(SettingsStore.getValue("analyticsOptIn") ? Anonymity.Pseudonymous : Anonymity.Anonymous); + analytics.init(getAnonymityFromSettings()); + // note this requires a network request in the browser, so some events can potentially + // before before registerSuperProperties has been called getPlatformProperties().then((properties) => analytics.registerSuperProperties(properties)); CountlyAnalytics.instance.enable(/* anonymous = */ true); From 474561600e4e04cf112e367e1b3e1c1b8937a956 Mon Sep 17 00:00:00 2001 From: James Salter Date: Tue, 27 Jul 2021 13:29:56 +0100 Subject: [PATCH 26/64] Fix hash == "" --- src/PosthogAnalytics.ts | 23 ++++++++++++++--------- test/PosthogAnalytics-test.ts | 6 ++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 535781cb08..cdb23e582c 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -63,17 +63,22 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p pathname = "//"; } - let [_, screen, ...parts] = hash.split("/"); + let hashStr; + if (hash == "") { + hashStr = ""; + } else { + let [_, screen, ...parts] = hash.split("/"); - if (!knownScreens.has(screen)) { - screen = ""; + if (!knownScreens.has(screen)) { + screen = ""; + } + + for (let i = 0; i < parts.length; i++) { + parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); + } + + hashStr = `${_}/${screen}/${parts.join("/")}`; } - - 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; } diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index cefaafe78f..7d81b6e86d 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -174,6 +174,12 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); expect(location).toBe("https://foo.bar/#///"); }); + it("Should currently handle an empty hash", async () => { + const location = await getRedactedCurrentLocation( + "https://foo.bar", "", "/", Anonymity.Anonymous); + expect(location).toBe("https://foo.bar/"); + }); + it("Should identify the user to posthog if pseudonymous", async () => { analytics.init(Anonymity.Pseudonymous); await analytics.identifyUser("foo"); From 1d81bdc6f9a676d075e6ad83b055b4ee43080a86 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 09:37:08 +0100 Subject: [PATCH 27/64] Interface changes and anonymity fixes --- src/Lifecycle.ts | 10 +- src/PosthogAnalytics.ts | 118 +++++++++--------- src/components/structures/MatrixChat.tsx | 13 +- .../tabs/user/SecurityUserSettingsTab.js | 4 +- src/settings/Settings.tsx | 8 ++ .../PseudonymousAnalyticsController.ts | 26 ++++ test/PosthogAnalytics-test.ts | 44 ++++--- 7 files changed, 124 insertions(+), 99 deletions(-) create mode 100644 src/settings/controllers/PseudonymousAnalyticsController.ts diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index c27c774cd7..b0a521d886 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,7 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; -import { Anonymity, getAnalytics, getPlatformProperties } from "./PosthogAnalytics"; +import { getAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -574,13 +574,7 @@ async function doSetLoggedIn( await abortLogin(); } - if (SettingsStore.getValue("analyticsOptIn")) { - const analytics = getAnalytics(); - analytics.setAnonymity(Anonymity.Pseudonymous); - await analytics.identifyUser(credentials.userId); - } else { - getAnalytics().setAnonymity(Anonymity.Anonymous); - } + getAnalytics().updateAnonymityFromSettings(credentials.userId); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index cdb23e582c..fa530b5309 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -85,14 +85,10 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p export class PosthogAnalytics { private anonymity = Anonymity.Anonymous; private posthog?: PostHog = null; - - // set true during init() if posthog config is present + // set true during the constructor if posthog config is present, otherwise false private enabled = false; - - // set to true after init() has been called - private initialised = false; - private static _instance = null; + private platformSuperProperties = {}; public static instance(): PosthogAnalytics { if (!this._instance) { @@ -103,10 +99,6 @@ export class PosthogAnalytics { 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, { @@ -123,7 +115,6 @@ export class PosthogAnalytics { sanitize_properties: this.sanitizeProperties.bind(this), respect_dnt: true, }); - this.initialised = true; this.enabled = true; } else { this.enabled = false; @@ -159,19 +150,39 @@ export class PosthogAnalytics { return properties; } - public async identifyUser(userId: string) { - if (this.anonymity == Anonymity.Anonymous) return; - this.posthog.identify(await hashHex(userId)); + 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." + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn"); + + // "Send pseudonymous usage data which helps us improve Element. This will use a cookie." + // + // Currently, this is only a labs flag, for testing purposes. + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn"); + + let anonymity; + if (pseudonumousOptIn) { + anonymity = Anonymity.Pseudonymous; + } else if (analyticsOptIn) { + anonymity = Anonymity.Anonymous; + } else { + anonymity = Anonymity.Disabled; + } + + return anonymity; } - public registerSuperProperties(properties) { - if (this.enabled) { - this.posthog.register(properties); + public async identifyUser(userId: string) { + if (this.anonymity == Anonymity.Pseudonymous) { + this.posthog.identify(await hashHex(userId)); } } - public isInitialised() { - return this.initialised; + private registerSuperProperties(properties) { + if (this.enabled) { + this.posthog.register(properties); + } } public isEnabled() { @@ -179,6 +190,13 @@ export class PosthogAnalytics { } public setAnonymity(anonymity: Anonymity) { + 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); + } this.anonymity = anonymity; } @@ -194,9 +212,6 @@ export class PosthogAnalytics { } 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; } @@ -239,45 +254,36 @@ export class PosthogAnalytics { 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"; + public async updatePlatformSuperProperties() { + this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties(); + this.registerSuperProperties(this.platformSuperProperties); } - return { - appVersion, - appPlatform: platform.getHumanReadableName(), - }; + 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(), + }; + } + + public async updateAnonymityFromSettings(userId?: string) { + this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); + if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { + await this.identifyUser(userId); + } + } } 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; -} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index bd54b0ebc9..1a477970fa 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { Anonymity, getAnalytics, getAnonymityFromSettings, getPlatformProperties } from '../../PosthogAnalytics'; +import { getAnalytics } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -390,10 +390,8 @@ export default class MatrixChat extends React.PureComponent { } const analytics = getAnalytics(); - analytics.init(getAnonymityFromSettings()); - // note this requires a network request in the browser, so some events can potentially - // before before registerSuperProperties has been called - getPlatformProperties().then((properties) => analytics.registerSuperProperties(properties)); + analytics.updateAnonymityFromSettings(); + analytics.updatePlatformSuperProperties(); CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -831,11 +829,6 @@ export default class MatrixChat extends React.PureComponent { if (CountlyAnalytics.instance.canEnable()) { CountlyAnalytics.instance.enable(/* anonymous = */ false); } - getAnalytics().setAnonymity(Anonymity.Pseudonymous); - // TODO: this is an async call and we're not waiting for it to complete - - // so potentially an event could be fired prior to it completing and would be - // missing the user identification. - getAnalytics().identifyUser(MatrixClientPeg.get().getUserId()); break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 15b4992cd8..670e2ec757 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -36,7 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import CountlyAnalytics from "../../../../../CountlyAnalytics"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; -import { Anonymity, getAnalytics } from "../../../../../PosthogAnalytics"; +import { getAnalytics } from "../../../../../PosthogAnalytics"; export class IgnoredUser extends React.Component { static propTypes = { @@ -107,7 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component { _updateAnalytics = (checked) => { checked ? Analytics.enable() : Analytics.disable(); CountlyAnalytics.instance.enable(/* anonymous = */ !checked); - getAnalytics().setAnonymity(checked ? Anonymity.Pseudonymous : Anonymity.Anonymous); + getAnalytics().updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); }; _onExportE2eKeysClicked = () => { diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 830ea9e32e..db0cb05c9f 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -41,6 +41,7 @@ import { Layout } from "./Layout"; import ReducedMotionController from './controllers/ReducedMotionController'; import IncompatibleController from "./controllers/IncompatibleController"; import SdkConfig from "../SdkConfig"; +import PseudonymousAnalyticsController from './controllers/PseudonymousAnalyticsController'; // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times const LEVELS_ROOM_SETTINGS = [ @@ -297,6 +298,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_pseudonymousAnalyticsOptIn": { + isFeature: true, + supportedLevels: LEVELS_FEATURE, + displayName: _td('Send pseudonymous analytics data'), + default: false, + controller: new PseudonymousAnalyticsController(), + }, "advancedRoomListLogging": { // TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231 displayName: _td("Enable advanced debugging for the room list"), diff --git a/src/settings/controllers/PseudonymousAnalyticsController.ts b/src/settings/controllers/PseudonymousAnalyticsController.ts new file mode 100644 index 0000000000..d55efe3c74 --- /dev/null +++ b/src/settings/controllers/PseudonymousAnalyticsController.ts @@ -0,0 +1,26 @@ +/* +Copyright 2021 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 SettingController from "./SettingController"; +import { SettingLevel } from "../SettingLevel"; +import { getAnalytics } from "../../PosthogAnalytics"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; + +export default class PseudonymousAnalyticsController extends SettingController { + public onChange(level: SettingLevel, roomId: string, newValue: any) { + getAnalytics().updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); + } +} diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 7d81b6e86d..f726fe0b13 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -7,11 +7,15 @@ class FakePosthog { public capture; public init; public identify; + public reset; + public register; constructor() { this.capture = jest.fn(); this.init = jest.fn(); this.identify = jest.fn(); + this.reset = jest.fn(); + this.register = jest.fn(); } } @@ -37,12 +41,11 @@ export interface ITestRoomEvent extends IRoomEvent { } describe("PosthogAnalytics", () => { - let analytics: PosthogAnalytics; let fakePosthog: FakePosthog; beforeEach(() => { fakePosthog = new FakePosthog(); - analytics = new PosthogAnalytics(fakePosthog); + window.crypto = { subtle: crypto.webcrypto.subtle, }; @@ -53,26 +56,28 @@ describe("PosthogAnalytics", () => { }); describe("Initialisation", () => { - it("Should not initialise if config is not set", async () => { + it("Should not be enabled without config being set", () => { jest.spyOn(SdkConfig, "get").mockReturnValue({}); - analytics.init(Anonymity.Pseudonymous); + const analytics = new PosthogAnalytics(fakePosthog); expect(analytics.isEnabled()).toBe(false); }); - it("Should initialise if config is set", async () => { + it("Should be enabled if config is set", () => { jest.spyOn(SdkConfig, "get").mockReturnValue({ posthog: { projectApiKey: "foo", apiHost: "bar", }, }); - analytics.init(Anonymity.Pseudonymous); - expect(analytics.isInitialised()).toBe(true); + const analytics = new PosthogAnalytics(fakePosthog); + analytics.setAnonymity(Anonymity.Pseudonymous); expect(analytics.isEnabled()).toBe(true); }); }); describe("Tracking", () => { + let analytics: PosthogAnalytics; + beforeEach(() => { jest.spyOn(SdkConfig, "get").mockReturnValue({ posthog: { @@ -80,10 +85,12 @@ describe("PosthogAnalytics", () => { apiHost: "bar", }, }); + + analytics = new PosthogAnalytics(fakePosthog); }); it("Should pass trackAnonymousEvent() to posthog", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); @@ -92,7 +99,7 @@ describe("PosthogAnalytics", () => { }); it("Should pass trackRoomEvent to posthog", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); const roomId = "42"; await analytics.trackRoomEvent("jest_test_event", roomId, { foo: "bar", @@ -104,7 +111,7 @@ describe("PosthogAnalytics", () => { }); it("Should pass trackPseudonymousEvent() to posthog", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); await analytics.trackPseudonymousEvent("jest_test_pseudo_event", { foo: "bar", }); @@ -112,17 +119,8 @@ describe("PosthogAnalytics", () => { expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); }); - it("Should blow up if not inititalised prior to tracking", async () => { - const fn = () => { - return analytics.trackAnonymousEvent("jest_test_event", { - foo: "bar", - }); - }; - await expect(fn()).rejects.toThrow(); - }); - it("Should not track pseudonymous messages if anonymous", async () => { - analytics.init(Anonymity.Anonymous); + analytics.setAnonymity(Anonymity.Anonymous); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); @@ -130,7 +128,7 @@ describe("PosthogAnalytics", () => { }); it("Should not track any events if disabled", async () => { - analytics.init(Anonymity.Disabled); + analytics.setAnonymity(Anonymity.Disabled); await analytics.trackPseudonymousEvent("jest_test_event", { foo: "bar", }); @@ -181,14 +179,14 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); }); it("Should identify the user to posthog if pseudonymous", async () => { - analytics.init(Anonymity.Pseudonymous); + analytics.setAnonymity(Anonymity.Pseudonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls[0][0]) .toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); }); it("Should not identify the user to posthog if anonymous", async () => { - analytics.init(Anonymity.Anonymous); + analytics.setAnonymity(Anonymity.Anonymous); await analytics.identifyUser("foo"); expect(fakePosthog.identify.mock.calls.length).toBe(0); }); From a6df687196916cb3b31a4fa0cc52cbebed1bb939 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 09:54:37 +0100 Subject: [PATCH 28/64] Tidy up interface and add some comments --- src/PosthogAnalytics.ts | 102 ++++++++++++++++++++++------------ test/PosthogAnalytics-test.ts | 2 +- 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index fa530b5309..6329598685 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -20,11 +20,12 @@ export enum Anonymity { // 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. +// 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. 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. +// If an event extends IAnonymousEvent, the event strictly contains *only* anonymous data; +// i.e. no identifiers that can be associated with the user. export interface IAnonymousEvent extends IEvent {} export interface IRoomEvent extends IPseudonymousEvent { @@ -83,6 +84,23 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p } 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 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. + */ + private anonymity = Anonymity.Anonymous; private posthog?: PostHog = null; // set true during the constructor if posthog config is present, otherwise false @@ -156,9 +174,9 @@ export class PosthogAnalytics { // "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." + // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." // - // Currently, this is only a labs flag, for testing purposes. + // TODO: Currently, this is only a labs flag, for testing purposes. const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn"); let anonymity; @@ -173,23 +191,46 @@ export class PosthogAnalytics { return anonymity; } - public async identifyUser(userId: string) { - if (this.anonymity == Anonymity.Pseudonymous) { - this.posthog.identify(await hashHex(userId)); - } - } - private registerSuperProperties(properties) { if (this.enabled) { this.posthog.register(properties); } } + 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); + } + public isEnabled() { return this.enabled; } public setAnonymity(anonymity: Anonymity) { + // 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. 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 @@ -200,6 +241,12 @@ export class PosthogAnalytics { this.anonymity = anonymity; } + public async identifyUser(userId: string) { + if (this.anonymity == Anonymity.Pseudonymous) { + this.posthog.identify(await hashHex(userId)); + } + } + public getAnonymity() { return this.anonymity; } @@ -211,16 +258,6 @@ export class PosthogAnalytics { this.setAnonymity(Anonymity.Anonymous); } - 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); - } - public async trackPseudonymousEvent( eventName: E["eventName"], properties: E["properties"], @@ -256,27 +293,18 @@ export class PosthogAnalytics { } public async updatePlatformSuperProperties() { + // 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. this.platformSuperProperties = await PosthogAnalytics.getPlatformProperties(); this.registerSuperProperties(this.platformSuperProperties); } - 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(), - }; - } - public async updateAnonymityFromSettings(userId?: string) { + // 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 this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { await this.identifyUser(userId); diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index f726fe0b13..a0cfec2406 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -172,7 +172,7 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`); expect(location).toBe("https://foo.bar/#///"); }); - it("Should currently handle an empty hash", async () => { + it("Should handle an empty hash", async () => { const location = await getRedactedCurrentLocation( "https://foo.bar", "", "/", Anonymity.Anonymous); expect(location).toBe("https://foo.bar/"); From 4048cb3c37132596ae52a3b5c2f336201213a3ac Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 10:20:16 +0100 Subject: [PATCH 29/64] Default to Anonymous tracking when no OptIn setting is present --- src/PosthogAnalytics.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 6329598685..bce6548cb3 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -172,17 +172,19 @@ export class PosthogAnalytics { // 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"); + const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true); // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." // // TODO: Currently, this is only a labs flag, for testing purposes. - const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn"); + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn", null, true); let anonymity; if (pseudonumousOptIn) { anonymity = Anonymity.Pseudonymous; - } else if (analyticsOptIn) { + } 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) anonymity = Anonymity.Anonymous; } else { anonymity = Anonymity.Disabled; From c206127f68f8ab72a42fa55d38aeb88604fd84b8 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 10:45:03 +0100 Subject: [PATCH 30/64] Track screen name when tracking page view --- src/PosthogAnalytics.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index bce6548cb3..e28972060a 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -36,6 +36,7 @@ interface IPageView extends IAnonymousEvent { eventName: "$pageview", properties: { durationMs?: number + screen?: string } } @@ -289,8 +290,17 @@ export class PosthogAnalytics { } public async trackPageView(durationMs: number) { + const hash = window.location.hash; + + let screen = null; + const split = hash.split("/"); + if (split.length >= 2) { + screen = split[1]; + } + await this.trackAnonymousEvent("$pageview", { durationMs, + screen, }); } From c3e715c1ca525beff68c8fa03ecbe64c4d8df6f3 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 10:45:32 +0100 Subject: [PATCH 31/64] i18n --- src/i18n/strings/en_EN.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 2790e17eed..403374edb4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -821,6 +821,7 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", + "Send pseudonymous analytics data": "Send pseudonymous analytics data", "Enable advanced debugging for the room list": "Enable advanced debugging for the room list", "Show info about bridges in room settings": "Show info about bridges in room settings", "Font size": "Font size", From 8ef18d0f9a97067676741349710853faac16fc07 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:01:59 +0100 Subject: [PATCH 32/64] Add module level comment about anonymity behaviour --- src/PosthogAnalytics.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index e28972060a..0435a0f22c 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -3,6 +3,21 @@ import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import SettingsStore from './settings/SettingsStore'; +/* 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 `feature_pseudonymousAnalyticsOptIn` labs flag is `true`, track pseudonomously, i.e. + * hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. + * - Otherwise, if the existing `analyticsOptIn` flag is `true`, or not present (i.e. prior to + * logging in), track anonymously, i.e. redact all matrix identifiers in tracking events. + * - If both flags are false, events are not sent. +*/ + interface IEvent { // The event name that will be used by PostHog. // TODO: standard format (camel case? snake? UpperCase?) @@ -86,7 +101,6 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p 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 all identifers will be redacted before being passed to posthog From c697079eb42727f84cfb6b9f79d73931710d10b9 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:22:40 +0100 Subject: [PATCH 33/64] Fix import --- test/PosthogAnalytics-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index a0cfec2406..095e216262 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,7 +1,7 @@ import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; -const crypto = require('crypto'); +import crypto = require('crypto'); class FakePosthog { public capture; From 7c62386915b2e027d260c77d700610bea16791ef Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:28:19 +0100 Subject: [PATCH 34/64] lint --- src/PosthogAnalytics.ts | 14 +++++++------- test/PosthogAnalytics-test.ts | 18 +++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 0435a0f22c..ee45f57e70 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -24,7 +24,7 @@ interface IEvent { eventName: string; // The properties of the event that will be stored in PostHog. - properties: {} + properties: {}; } export enum Anonymity { @@ -44,19 +44,19 @@ export interface IPseudonymousEvent extends IEvent {} export interface IAnonymousEvent extends IEvent {} export interface IRoomEvent extends IPseudonymousEvent { - hashedRoomId: string + hashedRoomId: string; } interface IPageView extends IAnonymousEvent { - eventName: "$pageview", + eventName: "$pageview"; properties: { - durationMs?: number - screen?: string - } + durationMs?: number; + screen?: string; + }; } export interface IWelcomeScreenLoad extends IAnonymousEvent { - eventName: "welcome_screen_load", + eventName: "welcome_screen_load"; } const hashHex = async (input: string): Promise => { diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 095e216262..920f449bab 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -20,24 +20,24 @@ class FakePosthog { } export interface ITestEvent extends IAnonymousEvent { - key: "jest_test_event", + key: "jest_test_event"; properties: { - foo: string - } + foo: string; + }; } export interface ITestPseudonymousEvent extends IPseudonymousEvent { - key: "jest_test_pseudo_event", + key: "jest_test_pseudo_event"; properties: { - foo: string - } + foo: string; + }; } export interface ITestRoomEvent extends IRoomEvent { - key: "jest_test_room_event", + key: "jest_test_room_event"; properties: { - foo: string - } + foo: string; + }; } describe("PosthogAnalytics", () => { From 7cf28de9c90a53708821ce4a7cb82752f4401502 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 11:32:17 +0100 Subject: [PATCH 35/64] take 2 at fixing import --- test/PosthogAnalytics-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 920f449bab..57eb8ed72b 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,7 +1,7 @@ import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; -import crypto = require('crypto'); +import crypto from 'crypto'; class FakePosthog { public capture; From d96e7e3375d64931af8eb9bebf49664a3fde0df6 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 12:08:55 +0100 Subject: [PATCH 36/64] Add transitive dev dependencies of posthog This is needed during tsc lint as posthog imports types from these libraries into its type definitions --- package.json | 4 +++- yarn.lock | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c0062db46c..084f413605 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, - "devDependencies": { + "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/eslint-parser": "^7.12.10", @@ -125,6 +125,7 @@ "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", "@sinonjs/fake-timers": "^7.0.2", + "@sentry/types": "^6.2.2", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", @@ -167,6 +168,7 @@ "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", + "rrweb": "^0.9.9", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", diff --git a/yarn.lock b/yarn.lock index c4d1456612..78f4838a09 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1352,6 +1352,11 @@ tslib "^2.2.0" webcrypto-core "^1.2.0" +"@sentry/types@^6.2.2": + version "6.10.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.10.0.tgz#6b1f44e5ed4dbc2710bead24d1b32fb08daf04e1" + integrity sha512-M7s0JFgG7/6/yNVYoPUbxzaXDhnzyIQYRRJJKRaTD77YO4MHvi4Ke8alBWqD5fer0cPIfcSkBqa9BLdqRqcMWw== + "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -1448,6 +1453,11 @@ resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== +"@types/css-font-loading-module@0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.4.tgz#94a835e27d1af444c65cba88523533c174463d64" + integrity sha512-ENdXf7MW4m9HeDojB2Ukbi7lYMIuQNBHVf98dbzaiG4EEJREBd6oleVAjrLRCrp7dm6CK1mmdmU9tcgF61acbw== + "@types/css-font-loading-module@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.6.tgz#1ac3417ed31eeb953134d29b56bca921644b87c0" @@ -1790,6 +1800,11 @@ object.fromentries "^2.0.0" prop-types "^15.7.0" +"@xstate/fsm@^1.4.0": + version "1.6.1" + resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.1.tgz#c92972b835540c4e3c5e14277f40dbcbdaee9571" + integrity sha512-xYKDNuPR36/fUK+jmhM+oauBmbdUAfuJKnDjg3/7NbN+Pj03TX7e94LXnzkwGgAR+U/HWoMqM5UPTuGIYfIx9g== + abab@^2.0.3, abab@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" @@ -3601,7 +3616,7 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -fflate@^0.4.1: +fflate@^0.4.1, fflate@^0.4.4: version "0.4.8" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== @@ -5639,6 +5654,11 @@ minimist@>=1.2.2, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== +mitt@^1.1.3: + version "1.2.0" + resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d" + integrity sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw== + mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -6841,6 +6861,22 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rrweb-snapshot@^1.0.3: + version "1.1.7" + resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.7.tgz#92a3b47b1112a1b566c2fae2edb02fa48a6f6653" + integrity sha512-+f2kCCvIQ1hbEeCWnV7mPVPDEdWEExqwcYqMd/r1nfK52QE7qU52jefUOyTe85Vy67rZGqWnfK/B25e/OTSgYg== + +rrweb@^0.9.9: + version "0.9.14" + resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-0.9.14.tgz#09bec604fc44c74801e4fe910606e5a6cde008ec" + integrity sha512-nm2rrVNoyWFPrbGQmcvTTlA7XjbbgPIgO7qsW0Zyr5iOURIFJDGPHFmOVLRyLpWiriVtEoXh6a+x+D1sj+qwWg== + dependencies: + "@types/css-font-loading-module" "0.0.4" + "@xstate/fsm" "^1.4.0" + fflate "^0.4.4" + mitt "^1.1.3" + rrweb-snapshot "^1.0.3" + rst-selector-parser@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" From 55e8173ee9011e6dff733090f6671639244be692 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 12:12:10 +0100 Subject: [PATCH 37/64] remove whitespace --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 084f413605..e5ecbb31a9 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "what-input": "^5.2.10", "zxcvbn": "^4.4.2" }, - "devDependencies": { + "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/eslint-parser": "^7.12.10", From 3ff7de3c967a7937830fcddf5e6785b1e0c5ff5a Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 13:43:06 +0100 Subject: [PATCH 38/64] Mock SHA-256 to avoid problems loading crypto on Node 14 --- test/PosthogAnalytics-test.ts | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 57eb8ed72b..b7fae3c196 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,7 +1,6 @@ import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, PosthogAnalytics } from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; -import crypto from 'crypto'; class FakePosthog { public capture; @@ -42,12 +41,37 @@ export interface ITestRoomEvent extends IRoomEvent { describe("PosthogAnalytics", () => { let fakePosthog: FakePosthog; + const shaHashes = { + "42": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049", + "some": "a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b", + "pii": "bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4", + "foo": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", + }; beforeEach(() => { fakePosthog = new FakePosthog(); window.crypto = { - subtle: crypto.webcrypto.subtle, + subtle: { + digest: async (_, encodedMessage) => { + const message = new TextDecoder().decode(encodedMessage); + const hexHash = shaHashes[message]; + const bytes = []; + for (let c = 0; c < hexHash.length; c += 2) { + bytes.push(parseInt(hexHash.substr(c, 2), 16)); + } + return bytes; + }, + /*console.log(message); + const digest = sha256(new WordArray(message)); + const digestBuf = new ArrayBuffer(digest.words.length * 4); + console.log(digest); + const view = new Uint32Array(digestBuf); + for (let i = 0; i < digest.words.length; i++) { + view[i] = digest.words[i]; + } + return digestBuf*/ + }, }; }); @@ -135,7 +159,7 @@ describe("PosthogAnalytics", () => { await analytics.trackAnonymousEvent("jest_test_event", { foo: "bar", }); - await analytics.trackRoomEvent("room id", "jest_test_room_event", { + await analytics.trackRoomEvent("room id", "foo", { foo: "bar", }); await analytics.trackPageView(200); From df7ebb2e7ce5894afd349f3a1f8cf930f80f5f90 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:00:37 +0100 Subject: [PATCH 39/64] Remove commented out block --- test/PosthogAnalytics-test.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index b7fae3c196..9b8e703c8e 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -62,15 +62,6 @@ describe("PosthogAnalytics", () => { } return bytes; }, - /*console.log(message); - const digest = sha256(new WordArray(message)); - const digestBuf = new ArrayBuffer(digest.words.length * 4); - console.log(digest); - const view = new Uint32Array(digestBuf); - for (let i = 0; i < digest.words.length; i++) { - view[i] = digest.words[i]; - } - return digestBuf*/ }, }; }); From 868d92781d402a46a5ec758c8ad10a77ef11f27d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:01:21 +0100 Subject: [PATCH 40/64] Add copyright header --- src/PosthogAnalytics.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index ee45f57e70..9628ed1e4e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -1,3 +1,19 @@ +/* +Copyright 2021 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, { PostHog } from 'posthog-js'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; From d5bef53f8bc9f7c523afdc78c3c0c3500e268a30 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:03:17 +0100 Subject: [PATCH 41/64] Use snake case for feature name --- src/PosthogAnalytics.ts | 4 ++-- src/settings/Settings.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 9628ed1e4e..15bd10ad67 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -27,7 +27,7 @@ import SettingsStore from './settings/SettingsStore'; * - 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 `feature_pseudonymousAnalyticsOptIn` labs flag is `true`, track pseudonomously, i.e. + * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. * hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. * - Otherwise, if the existing `analyticsOptIn` flag is `true`, or not present (i.e. prior to * logging in), track anonymously, i.e. redact all matrix identifiers in tracking events. @@ -208,7 +208,7 @@ export class PosthogAnalytics { // (proposed wording) "Send pseudonymous usage data which helps us improve Element. This will use a cookie." // // TODO: Currently, this is only a labs flag, for testing purposes. - const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymousAnalyticsOptIn", null, true); + const pseudonumousOptIn = SettingsStore.getValue("feature_pseudonymous_analytics_opt_in", null, true); let anonymity; if (pseudonumousOptIn) { diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 810d8bb323..c287a3fd9d 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -299,7 +299,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_pseudonymousAnalyticsOptIn": { + "feature_pseudonymous_analytics_opt_in": { isFeature: true, supportedLevels: LEVELS_FEATURE, displayName: _td('Send pseudonymous analytics data'), From a09e046c18d62dd3f14f18b6bf991f646bcad9d8 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:11:55 +0100 Subject: [PATCH 42/64] Update test/PosthogAnalytics-test.ts Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- test/PosthogAnalytics-test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index 9b8e703c8e..d80f2946c3 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,5 +1,11 @@ -import { Anonymity, getRedactedCurrentLocation, IAnonymousEvent, IPseudonymousEvent, IRoomEvent, - PosthogAnalytics } from '../src/PosthogAnalytics'; +import { + Anonymity, + getRedactedCurrentLocation, + IAnonymousEvent, + IPseudonymousEvent, + IRoomEvent, + PosthogAnalytics, +} from '../src/PosthogAnalytics'; import SdkConfig from '../src/SdkConfig'; class FakePosthog { From ecbc536a3eb67bee130793a396fa2693ceb02f74 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:15:27 +0100 Subject: [PATCH 43/64] Add copyright header --- test/PosthogAnalytics-test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts index d80f2946c3..6cb1743051 100644 --- a/test/PosthogAnalytics-test.ts +++ b/test/PosthogAnalytics-test.ts @@ -1,3 +1,19 @@ +/* +Copyright 2021 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 { Anonymity, getRedactedCurrentLocation, @@ -6,6 +22,7 @@ import { IRoomEvent, PosthogAnalytics, } from '../src/PosthogAnalytics'; + import SdkConfig from '../src/SdkConfig'; class FakePosthog { From da3bf5a097b437b912d5d1ad78e00e5f0aceb7c2 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:14:15 +0100 Subject: [PATCH 44/64] rename knownScreens -> whitelistedScreens --- src/PosthogAnalytics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 15bd10ad67..66f17a4937 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -83,7 +83,7 @@ const hashHex = async (input: string): Promise => { return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); }; -const knownScreens = new Set([ +const whitelistedScreens = 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", ]); @@ -102,7 +102,7 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p } else { let [_, screen, ...parts] = hash.split("/"); - if (!knownScreens.has(screen)) { + if (!whitelistedScreens.has(screen)) { screen = ""; } From 9420b81eebdfc2b4edb3d6dcf0f179c4d29e358c Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:22:32 +0100 Subject: [PATCH 45/64] Rename mysterious _ to beforeFirstSlash --- src/PosthogAnalytics.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 66f17a4937..7331b2edd1 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -100,7 +100,7 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p if (hash == "") { hashStr = ""; } else { - let [_, screen, ...parts] = hash.split("/"); + let [beforeFirstSlash, screen, ...parts] = hash.split("/"); if (!whitelistedScreens.has(screen)) { screen = ""; @@ -110,7 +110,7 @@ export async function getRedactedCurrentLocation(origin: string, hash: string, p parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]); } - hashStr = `${_}/${screen}/${parts.join("/")}`; + hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`; } return origin + pathname + hashStr; } From 60bc283455f22e3bbf640b538e9590a28dea4ef7 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:22:57 +0100 Subject: [PATCH 46/64] Add return type to getRedactedCurrentLocation --- src/PosthogAnalytics.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 7331b2edd1..9ba33e37c1 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -88,7 +88,12 @@ const whitelistedScreens = new Set([ "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", ]); -export async function getRedactedCurrentLocation(origin: string, hash: string, pathname: string, anonymity: Anonymity) { +export async function getRedactedCurrentLocation( + origin: string, + hash: string, + pathname: string, + anonymity: Anonymity, +): Promise { // 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 From a687bab52f6e5bcd66b9883a9a519b1fff54714e Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:38:41 +0100 Subject: [PATCH 47/64] Use readonly shorthand for posthog param --- src/PosthogAnalytics.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 9ba33e37c1..d10ea01f4e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -138,7 +138,6 @@ export class PosthogAnalytics { */ private anonymity = Anonymity.Anonymous; - private posthog?: PostHog = null; // set true during the constructor if posthog config is present, otherwise false private enabled = false; private static _instance = null; @@ -151,8 +150,7 @@ export class PosthogAnalytics { return this._instance; } - constructor(posthog: PostHog) { - this.posthog = posthog; + constructor(private readonly posthog: PostHog) { const posthogConfig = SdkConfig.get()["posthog"]; if (posthogConfig) { this.posthog.init(posthogConfig.projectApiKey, { From df6d772d8d1df34988884d2af3acb6664657f8e0 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:40:32 +0100 Subject: [PATCH 48/64] Pin posthog version We'd like to manually review each posthog change to avoid unanticipated tracking leakages; each upgrade should include reviewing the data coming in on events --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5ecbb31a9..bd989ac9bc 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "pako": "^2.0.3", "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", - "posthog-js": "^1.12.1", + "posthog-js": "1.12.1", "prop-types": "^15.7.2", "qrcode": "^1.4.4", "re-resizable": "^6.9.0", From 279871ce01f6e9c01dc5d5f691bcb01b0afb9c90 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:44:16 +0100 Subject: [PATCH 49/64] Add types --- src/PosthogAnalytics.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d10ea01f4e..345f778c89 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -120,6 +120,11 @@ export async function getRedactedCurrentLocation( return origin + pathname + hashStr; } +interface PlatformProperties { + appVersion: string, + appPlatform: string +} + export class PosthogAnalytics { /* Wrapper for Posthog analytics. * 3 modes of anonymity are supported, governed by this.anonymity @@ -227,13 +232,13 @@ export class PosthogAnalytics { return anonymity; } - private registerSuperProperties(properties) { + private registerSuperProperties(properties: posthog.Properties) { if (this.enabled) { this.posthog.register(properties); } } - private static async getPlatformProperties() { + private static async getPlatformProperties(): Promise { const platform = PlatformPeg.get(); let appVersion; try { From ce80e5a4639cd1d698e58e229c609a50e0870743 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 14:44:28 +0100 Subject: [PATCH 50/64] Remove superfluous unused argument --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 345f778c89..7aae756894 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -178,7 +178,7 @@ export class PosthogAnalytics { } } - private sanitizeProperties(properties: posthog.Properties, _: string): posthog.Properties { + private sanitizeProperties(properties: posthog.Properties): 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. From 13ef819ba6a36e3c3d39f4de373a32ec284495d2 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 16:42:56 +0100 Subject: [PATCH 51/64] isEnabled returns a boolean --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 7aae756894..7e7703a9aa 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -264,7 +264,7 @@ export class PosthogAnalytics { this.posthog.capture(eventName, properties); } - public isEnabled() { + public isEnabled(): boolean { return this.enabled; } From b1bd5f57a4deb3674e59179e0e72962dc99d1edd Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 16:43:13 +0100 Subject: [PATCH 52/64] Document IEvent.properties, fix IWelcomeScreenLoad IEvent.properties is a placeholder that needs to be overriden by extenders for type validation to take place. IWelcomeScreenLoad should have had properties declared for it. Because it didn't, a faulty call using it was possible. --- src/PosthogAnalytics.ts | 11 ++++++----- src/components/views/auth/Welcome.tsx | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 7e7703a9aa..63bfbda72e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -32,15 +32,15 @@ import SettingsStore from './settings/SettingsStore'; * - Otherwise, if the existing `analyticsOptIn` flag is `true`, or not present (i.e. prior to * logging in), track anonymously, i.e. redact all matrix identifiers in tracking events. * - If both flags are false, events are not sent. -*/ + */ interface IEvent { - // The event name that will be used by PostHog. - // TODO: standard format (camel case? snake? UpperCase?) + // The event name that will be used by PostHog. Event names should use snake_case. eventName: string; - // The properties of the event that will be stored in PostHog. - properties: {}; + // The properties of the event that will be stored in PostHog. This is just a placeholder, + // extending interfaces must override this with a concrete definition to do type validation. + properties: {} } export enum Anonymity { @@ -73,6 +73,7 @@ interface IPageView extends IAnonymousEvent { export interface IWelcomeScreenLoad extends IAnonymousEvent { eventName: "welcome_screen_load"; + properties: Record; } const hashHex = async (input: string): Promise => { diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 4ba603eaf4..75bbe15411 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -76,6 +76,6 @@ export default class Welcome extends React.PureComponent { } componentDidMount() { - getAnalytics().trackAnonymousEvent("welcome_screen_load", { foo: "bar" }); + getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); } } From e5d36e9a81a387022094db844c9a66835b285a2d Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:02:26 +0100 Subject: [PATCH 53/64] Use arrow function instead of bind --- src/PosthogAnalytics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 63bfbda72e..8f338f6012 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -170,7 +170,7 @@ export class PosthogAnalytics { // // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview. capture_pageview: false, - sanitize_properties: this.sanitizeProperties.bind(this), + sanitize_properties: this.sanitizeProperties, respect_dnt: true, }); this.enabled = true; @@ -179,7 +179,7 @@ export class PosthogAnalytics { } } - private sanitizeProperties(properties: posthog.Properties): posthog.Properties { + private sanitizeProperties = (properties: posthog.Properties): 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. @@ -206,7 +206,7 @@ export class PosthogAnalytics { } return properties; - } + }; private static getAnonymityFromSettings(): Anonymity { // determine the current anonymity level based on curernt user settings From 0a951501b2f90c62419fcbd43af6f36616f59f74 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:04:18 +0100 Subject: [PATCH 54/64] lint --- src/PosthogAnalytics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 8f338f6012..6d15ca79ce 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -40,7 +40,7 @@ interface IEvent { // The properties of the event that will be stored in PostHog. This is just a placeholder, // extending interfaces must override this with a concrete definition to do type validation. - properties: {} + properties: {}; } export enum Anonymity { @@ -122,8 +122,8 @@ export async function getRedactedCurrentLocation( } interface PlatformProperties { - appVersion: string, - appPlatform: string + appVersion: string; + appPlatform: string; } export class PosthogAnalytics { From e4722ee4578dc7d06999c68b7e321731c598b1d8 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:14:36 +0100 Subject: [PATCH 55/64] Override posthog type definitions to point to a locally fixed type definition file Posthog's type definitions refer to types in transitive dependencies we don't want to include. Clone posthog.d.ts locally, remove the offending types from it, and provide an overriding mapping in tsconfig. If this proves annoying to maintain, posthog.d.ts could just be an empty file. --- package.json | 2 - src/@types/posthog.d.ts | 739 ++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 9 +- yarn.lock | 40 +-- 4 files changed, 748 insertions(+), 42 deletions(-) create mode 100644 src/@types/posthog.d.ts diff --git a/package.json b/package.json index bd989ac9bc..6adb6ce004 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,6 @@ "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", "@peculiar/webcrypto": "^1.1.4", "@sinonjs/fake-timers": "^7.0.2", - "@sentry/types": "^6.2.2", "@types/classnames": "^2.2.11", "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", @@ -168,7 +167,6 @@ "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", - "rrweb": "^0.9.9", "stylelint": "^13.9.0", "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", diff --git a/src/@types/posthog.d.ts b/src/@types/posthog.d.ts new file mode 100644 index 0000000000..1108e2c6df --- /dev/null +++ b/src/@types/posthog.d.ts @@ -0,0 +1,739 @@ +// Type definitions for exported methods + +declare class posthog { + /** + * This function initializes a new instance of the PostHog capturing object. + * All new instances are added to the main posthog object as sub properties (such as + * posthog.library_name) and also returned by this function. To define a + * second instance on the page, you would call: + * + * posthog.init('new token', { your: 'config' }, 'library_name'); + * + * and use it like so: + * + * posthog.library_name.capture(...); + * + * @param {String} token Your PostHog API token + * @param {Object} [config] A dictionary of config options to override. See a list of default config options. + * @param {String} [name] The name for the new posthog instance that you want created + */ + static init(token: string, config?: posthog.Config, name?: string): posthog + + /** + * Clears super properties and generates a new random distinct_id for this instance. + * Useful for clearing data when a user logs out. + */ + static reset(reset_device_id?: boolean): void + + /** + * Capture an event. This is the most important and + * frequently used PostHog function. + * + * ### Usage: + * + * // capture an event named 'Registered' + * posthog.capture('Registered', {'Gender': 'Male', 'Age': 21}); + * + * // capture an event using navigator.sendBeacon + * posthog.capture('Left page', {'duration_seconds': 35}, {transport: 'sendBeacon'}); + * + * @param {String} event_name The name of the event. This can be anything the user does - 'Button Click', 'Sign Up', 'Item Purchased', etc. + * @param {Object} [properties] A set of properties to include with the event you're sending. These describe the user who did the event or details about the event itself. + * @param {Object} [options] Optional configuration for this capture request. + * @param {String} [options.transport] Transport method for network request ('XHR' or 'sendBeacon'). + */ + static capture( + event_name: string, + properties?: posthog.Properties, + options?: { transport: 'XHR' | 'sendBeacon' } + ): posthog.CaptureResult + + /** + * Capture a page view event, which is currently ignored by the server. + * This function is called by default on page load unless the + * capture_pageview configuration variable is false. + * + * @param {String} [page] The url of the page to record. If you don't include this, it defaults to the current url. + * @api private + */ + static capture_pageview(page?: string): void + + /** + * Register a set of super properties, which are included with all + * events. This will overwrite previous super property values. + * + * ### Usage: + * + * // register 'Gender' as a super property + * posthog.register({'Gender': 'Female'}); + * + * // register several super properties when a user signs up + * posthog.register({ + * 'Email': 'jdoe@example.com', + * 'Account Type': 'Free' + * }); + * + * @param {Object} properties An associative array of properties to store about the user + * @param {Number} [days] How many days since the user's last visit to store the super properties + */ + static register(properties: posthog.Properties, days?: number): void + + /** + * Register a set of super properties only once. This will not + * overwrite previous super property values, unlike register(). + * + * ### Usage: + * + * // register a super property for the first time only + * posthog.register_once({ + * 'First Login Date': new Date().toISOString() + * }); + * + * ### Notes: + * + * If default_value is specified, current super properties + * with that value will be overwritten. + * + * @param {Object} properties An associative array of properties to store about the user + * @param {*} [default_value] Value to override if already set in super properties (ex: 'False') Default: 'None' + * @param {Number} [days] How many days since the users last visit to store the super properties + */ + static register_once(properties: posthog.Properties, default_value?: posthog.Property, days?: number): void + + /** + * Delete a super property stored with the current user. + * + * @param {String} property The name of the super property to remove + */ + static unregister(property: string): void + + /** + * Identify a user with a unique ID instead of a PostHog + * randomly generated distinct_id. If the method is never called, + * then unique visitors will be identified by a UUID generated + * the first time they visit the site. + * + * If user properties are passed, they are also sent to posthog. + * + * ### Usage: + * + * posthog.identify('[user unique id]') + * posthog.identify('[user unique id]', { email: 'john@example.com' }) + * posthog.identify('[user unique id]', {}, { referral_code: '12345' }) + * + * ### Notes: + * + * You can call this function to overwrite a previously set + * unique ID for the current user. PostHog cannot translate + * between IDs at this time, so when you change a user's ID + * they will appear to be a new user. + * + * When used alone, posthog.identify will change the user's + * distinct_id to the unique ID provided. When used in tandem + * with posthog.alias, it will allow you to identify based on + * unique ID and map that back to the original, anonymous + * distinct_id given to the user upon her first arrival to your + * site (thus connecting anonymous pre-signup activity to + * post-signup activity). Though the two work together, do not + * call identify() at the same time as alias(). Calling the two + * at the same time can cause a race condition, so it is best + * practice to call identify on the original, anonymous ID + * right after you've aliased it. + * + * @param {String} [unique_id] A string that uniquely identifies a user. If not provided, the distinct_id currently in the persistent store (cookie or localStorage) will be used. + * @param {Object} [userProperties] Optional: An associative array of properties to store about the user + * @param {Object} [userPropertiesToSetOnce] Optional: An associative array of properties to store about the user. If property is previously set, this does not override that value. + */ + static identify( + unique_id?: string, + userPropertiesToSet?: posthog.Properties, + userPropertiesToSetOnce?: posthog.Properties + ): void + + /** + * Create an alias, which PostHog will use to link two distinct_ids going forward (not retroactively). + * Multiple aliases can map to the same original ID, but not vice-versa. Aliases can also be chained - the + * following is a valid scenario: + * + * posthog.alias('new_id', 'existing_id'); + * ... + * posthog.alias('newer_id', 'new_id'); + * + * If the original ID is not passed in, we will use the current distinct_id - probably the auto-generated GUID. + * + * ### Notes: + * + * The best practice is to call alias() when a unique ID is first created for a user + * (e.g., when a user first registers for an account and provides an email address). + * alias() should never be called more than once for a given user, except to + * chain a newer ID to a previously new ID, as described above. + * + * @param {String} alias A unique identifier that you want to use for this user in the future. + * @param {String} [original] The current identifier being used for this user. + */ + static alias(alias: string, original?: string): posthog.CaptureResult | number + + /** + * Update the configuration of a posthog library instance. + * + * The default config is: + * + * { + * // HTTP method for capturing requests + * api_method: 'POST' + * + * // transport for sending requests ('XHR' or 'sendBeacon') + * // NB: sendBeacon should only be used for scenarios such as + * // page unload where a "best-effort" attempt to send is + * // acceptable; the sendBeacon API does not support callbacks + * // or any way to know the result of the request. PostHog + * // capturing via sendBeacon will not support any event- + * // batching or retry mechanisms. + * api_transport: 'XHR' + * + * // Automatically capture clicks, form submissions and change events + * autocapture: true + * + * // Capture rage clicks (beta) - useful for session recording + * rageclick: false + * + * // super properties cookie expiration (in days) + * cookie_expiration: 365 + * + * // super properties span subdomains + * cross_subdomain_cookie: true + * + * // debug mode + * debug: false + * + * // if this is true, the posthog cookie or localStorage entry + * // will be deleted, and no user persistence will take place + * disable_persistence: false + * + * // if this is true, PostHog will automatically determine + * // City, Region and Country data using the IP address of + * //the client + * ip: true + * + * // opt users out of capturing by this PostHog instance by default + * opt_out_capturing_by_default: false + * + * // opt users out of browser data storage by this PostHog instance by default + * opt_out_persistence_by_default: false + * + * // persistence mechanism used by opt-in/opt-out methods - cookie + * // or localStorage - falls back to cookie if localStorage is unavailable + * opt_out_capturing_persistence_type: 'localStorage' + * + * // customize the name of cookie/localStorage set by opt-in/opt-out methods + * opt_out_capturing_cookie_prefix: null + * + * // type of persistent store for super properties (cookie/ + * // localStorage) if set to 'localStorage', any existing + * // posthog cookie value with the same persistence_name + * // will be transferred to localStorage and deleted + * persistence: 'cookie' + * + * // name for super properties persistent store + * persistence_name: '' + * + * // names of properties/superproperties which should never + * // be sent with capture() calls + * property_blacklist: [] + * + * // if this is true, posthog cookies will be marked as + * // secure, meaning they will only be transmitted over https + * secure_cookie: false + * + * // should we capture a page view on page load + * capture_pageview: true + * + * // if you set upgrade to be true, the library will check for + * // a cookie from our old js library and import super + * // properties from it, then the old cookie is deleted + * // The upgrade config option only works in the initialization, + * // so make sure you set it when you create the library. + * upgrade: false + * + * // extra HTTP request headers to set for each API request, in + * // the format {'Header-Name': value} + * xhr_headers: {} + * + * // protocol for fetching in-app message resources, e.g. + * // 'https://' or 'http://'; defaults to '//' (which defers to the + * // current page's protocol) + * inapp_protocol: '//' + * + * // whether to open in-app message link in new tab/window + * inapp_link_new_window: false + * + * // a set of rrweb config options that PostHog users can configure + * // see https://github.com/rrweb-io/rrweb/blob/master/guide.md + * session_recording: { + * blockClass: 'ph-no-capture', + * blockSelector: null, + * ignoreClass: 'ph-ignore-input', + * maskAllInputs: false, + * maskInputOptions: {}, + * maskInputFn: null, + * slimDOMOptions: {}, + * collectFonts: false + * } + * + * // prevent autocapture from capturing any attribute names on elements + * mask_all_element_attributes: false + * + * // prevent autocapture from capturing textContent on all elements + * mask_all_text: false + * + * // will disable requests to the /decide endpoint (please review documentation for details) + * // autocapture, feature flags, compression and session recording will be disabled when set to `true` + * advanced_disable_decide: false + * + * } + * + * + * @param {Object} config A dictionary of new configuration values to update + */ + static set_config(config: posthog.Config): void + + /** + * returns the current config object for the library. + */ + static get_config(prop_name: T): posthog.Config[T] + + /** + * Returns the value of the super property named property_name. If no such + * property is set, get_property() will return the undefined value. + * + * ### Notes: + * + * get_property() can only be called after the PostHog library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // grab value for 'user_id' after the posthog library has loaded + * posthog.init('YOUR PROJECT TOKEN', { + * loaded: function(posthog) { + * user_id = posthog.get_property('user_id'); + * } + * }); + * + * @param {String} property_name The name of the super property you want to retrieve + */ + static get_property(property_name: string): posthog.Property | undefined + + /** + * Returns the current distinct id of the user. This is either the id automatically + * generated by the library or the id that has been passed by a call to identify(). + * + * ### Notes: + * + * get_distinct_id() can only be called after the PostHog library has finished loading. + * init() has a loaded function available to handle this automatically. For example: + * + * // set distinct_id after the posthog library has loaded + * posthog.init('YOUR PROJECT TOKEN', { + * loaded: function(posthog) { + * distinct_id = posthog.get_distinct_id(); + * } + * }); + */ + static get_distinct_id(): string + + /** + * Opt the user out of data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * // opt user out + * posthog.opt_out_capturing(); + * + * // opt user out with different cookie configuration from PostHog instance + * posthog.opt_out_capturing({ + * cookie_expiration: 30, + * secure_cookie: true + * }); + * + * @param {Object} [options] A dictionary of config options to override + * @param {boolean} [options.clear_persistence=true] If true, will delete all data stored by the sdk in persistence + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @param {Number} [options.cookie_expiration] Number of days until the opt-in cookie expires (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.cross_subdomain_cookie] Whether the opt-in cookie is set as cross-subdomain or not (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.secure_cookie] Whether the opt-in cookie is set as secure or not (overrides value specified in this PostHog instance's config) + */ + static opt_out_capturing(options?: posthog.OptInOutCapturingOptions): void + + /** + * Opt the user in to data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * // opt user in + * posthog.opt_in_capturing(); + * + * // opt user in with specific event name, properties, cookie configuration + * posthog.opt_in_capturing({ + * capture_event_name: 'User opted in', + * capture_event_properties: { + * 'Email': 'jdoe@example.com' + * }, + * cookie_expiration: 30, + * secure_cookie: true + * }); + * + * @param {Object} [options] A dictionary of config options to override + * @param {function} [options.capture] Function used for capturing a PostHog event to record the opt-in action (default is this PostHog instance's capture method) + * @param {string} [options.capture_event_name=$opt_in] Event name to be used for capturing the opt-in action + * @param {Object} [options.capture_properties] Set of properties to be captured along with the opt-in action + * @param {boolean} [options.enable_persistence=true] If true, will re-enable sdk persistence + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @param {Number} [options.cookie_expiration] Number of days until the opt-in cookie expires (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.cross_subdomain_cookie] Whether the opt-in cookie is set as cross-subdomain or not (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.secure_cookie] Whether the opt-in cookie is set as secure or not (overrides value specified in this PostHog instance's config) + */ + static opt_in_capturing(options?: posthog.OptInOutCapturingOptions): void + + /** + * Check whether the user has opted out of data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * const has_opted_out = posthog.has_opted_out_capturing(); + * // use has_opted_out value + * + * @param {Object} [options] A dictionary of config options to override + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @returns {boolean} current opt-out status + */ + static has_opted_out_capturing(options?: posthog.HasOptedInOutCapturingOptions): boolean + + /** + * Check whether the user has opted in to data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * const has_opted_in = posthog.has_opted_in_capturing(); + * // use has_opted_in value + * + * @param {Object} [options] A dictionary of config options to override + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @returns {boolean} current opt-in status + */ + static has_opted_in_capturing(options?: posthog.HasOptedInOutCapturingOptions): boolean + + /** + * Clear the user's opt in/out status of data capturing and cookies/localstorage for this PostHog instance + * + * ### Usage + * + * // clear user's opt-in/out status + * posthog.clear_opt_in_out_capturing(); + * + * // clear user's opt-in/out status with specific cookie configuration - should match + * // configuration used when opt_in_capturing/opt_out_capturing methods were called. + * posthog.clear_opt_in_out_capturing({ + * cookie_expiration: 30, + * secure_cookie: true + * }); + * + * @param {Object} [options] A dictionary of config options to override + * @param {boolean} [options.enable_persistence=true] If true, will re-enable sdk persistence + * @param {string} [options.persistence_type=localStorage] Persistence mechanism used - cookie or localStorage - falls back to cookie if localStorage is unavailable + * @param {string} [options.cookie_prefix=__ph_opt_in_out] Custom prefix to be used in the cookie/localstorage name + * @param {Number} [options.cookie_expiration] Number of days until the opt-in cookie expires (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.cross_subdomain_cookie] Whether the opt-in cookie is set as cross-subdomain or not (overrides value specified in this PostHog instance's config) + * @param {boolean} [options.secure_cookie] Whether the opt-in cookie is set as secure or not (overrides value specified in this PostHog instance's config) + */ + static clear_opt_in_out_capturing(options?: posthog.ClearOptInOutCapturingOptions): void + + /* + * See if feature flag is enabled for user. + * + * ### Usage: + * + * if(posthog.isFeatureEnabled('beta-feature')) { // do something } + * + * @param {Object|String} prop Key of the feature flag. + * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog. + */ + static isFeatureEnabled(key: string, options?: posthog.isFeatureEnabledOptions): boolean + + /* + * See if feature flags are available. + * + * ### Usage: + * + * posthog.onFeatureFlags(function(featureFlags) { // do something }) + * + * @param {Function} [callback] The callback function will be called once the feature flags are ready. It'll return a list of feature flags enabled for the user. + */ + static onFeatureFlags(callback: (flags: string[]) => void): false | undefined + + /* + * Reload all feature flags for the user. + * + * ### Usage: + * + * posthog.reloadFeatureFlags() + */ + static reloadFeatureFlags(): void + + static toString(): string + + /* Will log all capture requests to the Javascript console, including event properties for easy debugging */ + static debug(): void + + /* + * Starts session recording and updates disable_session_recording to false. + * Used for manual session recording management. By default, session recording is enabled and + * starts automatically. + * + * ### Usage: + * + * posthog.startSessionRecording() + */ + static startSessionRecording(): void + + /* + * Stops session recording and updates disable_session_recording to true. + * + * ### Usage: + * + * posthog.stopSessionRecording() + */ + static stopSessionRecording(): void + + /* + * Check if session recording is currently running. + * + * ### Usage: + * + * const isSessionRecordingOn = posthog.sessionRecordingStarted() + */ + static sessionRecordingStarted(): boolean +} + +declare namespace posthog { + /* eslint-disable @typescript-eslint/no-explicit-any */ + type Property = any; + type Properties = Record; + type CaptureResult = { event: string; properties: Properties } | undefined; + type CaptureCallback = (response: any, data: any) => void; + /* eslint-enable @typescript-eslint/no-explicit-any */ + + interface Config { + api_host?: string + api_method?: string + api_transport?: string + autocapture?: boolean + rageclick?: boolean + cdn?: string + cross_subdomain_cookie?: boolean + persistence?: 'localStorage' | 'cookie' | 'memory' + persistence_name?: string + cookie_name?: string + loaded?: (posthog_instance: typeof posthog) => void + store_google?: boolean + save_referrer?: boolean + test?: boolean + verbose?: boolean + img?: boolean + capture_pageview?: boolean + debug?: boolean + cookie_expiration?: number + upgrade?: boolean + disable_session_recording?: boolean + disable_persistence?: boolean + disable_cookie?: boolean + secure_cookie?: boolean + ip?: boolean + opt_out_capturing_by_default?: boolean + opt_out_persistence_by_default?: boolean + opt_out_capturing_persistence_type?: 'localStorage' | 'cookie' + opt_out_capturing_cookie_prefix?: string | null + respect_dnt?: boolean + property_blacklist?: string[] + xhr_headers?: { [header_name: string]: string } + inapp_protocol?: string + inapp_link_new_window?: boolean + request_batching?: boolean + sanitize_properties?: (properties: posthog.Properties, event_name: string) => posthog.Properties + properties_string_max_length?: number + mask_all_element_attributes?: boolean + mask_all_text?: boolean + advanced_disable_decide?: boolean + } + + interface OptInOutCapturingOptions { + clear_persistence: boolean + persistence_type: string + cookie_prefix: string + cookie_expiration: number + cross_subdomain_cookie: boolean + secure_cookie: boolean + } + + interface HasOptedInOutCapturingOptions { + persistence_type: string + cookie_prefix: string + } + + interface ClearOptInOutCapturingOptions { + enable_persistence: boolean + persistence_type: string + cookie_prefix: string + cookie_expiration: number + cross_subdomain_cookie: boolean + secure_cookie: boolean + } + + interface isFeatureEnabledOptions { + send_event: boolean + } + + export class persistence { + static properties(): posthog.Properties + + static load(): void + + static save(): void + + static remove(): void + + static clear(): void + + /** + * @param {Object} props + * @param {*=} default_value + * @param {number=} days + */ + static register_once(props: Properties, default_value?: Property, days?: number): boolean + + /** + * @param {Object} props + * @param {number=} days + */ + static register(props: posthog.Properties, days?: number): boolean + + static unregister(prop: string): void + + static update_campaign_params(): void + + static update_search_keyword(referrer: string): void + + static update_referrer_info(referrer: string): void + + static get_referrer_info(): posthog.Properties + + static safe_merge(props: posthog.Properties): posthog.Properties + + static update_config(config: posthog.Config): void + + static set_disabled(disabled: boolean): void + + static set_cross_subdomain(cross_subdomain: boolean): void + + static get_cross_subdomain(): boolean + + static set_secure(secure: boolean): void + + static set_event_timer(event_name: string, timestamp: Date): void + + static remove_event_timer(event_name: string): Date | undefined + } + + export class people { + /* + * Set properties on a user record. + * + * ### Usage: + * + * posthog.people.set('gender', 'm'); + * + * // or set multiple properties at once + * posthog.people.set({ + * 'Company': 'Acme', + * 'Plan': 'Premium', + * 'Upgrade date': new Date() + * }); + * // properties can be strings, integers, dates, or lists + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after capturing the event. + */ + static set( + prop: posthog.Properties | string, + to?: posthog.Property, + callback?: posthog.CaptureCallback + ): posthog.Properties + + /* + * Set properties on a user record, only if they do not yet exist. + * This will not overwrite previous people property values, unlike + * people.set(). + * + * ### Usage: + * + * posthog.people.set_once('First Login Date', new Date()); + * + * // or set multiple properties at once + * posthog.people.set_once({ + * 'First Login Date': new Date(), + * 'Starting Plan': 'Premium' + * }); + * + * // properties can be strings, integers or dates + * + * @param {Object|String} prop If a string, this is the name of the property. If an object, this is an associative array of names and values. + * @param {*} [to] A value to set on the given property name + * @param {Function} [callback] If provided, the callback will be called after capturing the event. + */ + static set_once( + prop: posthog.Properties | string, + to?: posthog.Property, + callback?: posthog.CaptureCallback + ): posthog.Properties + + static toString(): string + } + + export class featureFlags { + static getFlags(): string[] + + static reloadFeatureFlags(): void + + /* + * See if feature flag is enabled for user. + * + * ### Usage: + * + * if(posthog.isFeatureEnabled('beta-feature')) { // do something } + * + * @param {Object|String} prop Key of the feature flag. + * @param {Object|String} options (optional) If {send_event: false}, we won't send an $feature_flag_call event to PostHog. + */ + static isFeatureEnabled(key: string, options?: { send_event?: boolean }): boolean + + /* + * See if feature flags are available. + * + * ### Usage: + * + * posthog.onFeatureFlags(function(featureFlags) { // do something }) + * + * @param {Function} [callback] The callback function will be called once the feature flags are ready. It'll return a list of feature flags enabled for the user. + */ + static onFeatureFlags(callback: (flags: string[]) => void): false | undefined + } + + export class feature_flags extends featureFlags {} +} + +export type PostHog = typeof posthog; + +export default posthog; diff --git a/tsconfig.json b/tsconfig.json index b139e8e8d1..b982d40b07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,10 +22,15 @@ "es2019", "dom", "dom.iterable" - ] + ], + "paths": { + "posthog-js": [ + "./src/@types/posthog.d.ts" + ] + } }, "include": [ "./src/**/*.ts", "./src/**/*.tsx" - ] + ], } diff --git a/yarn.lock b/yarn.lock index 78f4838a09..633bf99ee6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1352,11 +1352,6 @@ tslib "^2.2.0" webcrypto-core "^1.2.0" -"@sentry/types@^6.2.2": - version "6.10.0" - resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.10.0.tgz#6b1f44e5ed4dbc2710bead24d1b32fb08daf04e1" - integrity sha512-M7s0JFgG7/6/yNVYoPUbxzaXDhnzyIQYRRJJKRaTD77YO4MHvi4Ke8alBWqD5fer0cPIfcSkBqa9BLdqRqcMWw== - "@sinonjs/commons@^1.7.0": version "1.8.3" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d" @@ -1453,11 +1448,6 @@ resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ== -"@types/css-font-loading-module@0.0.4": - version "0.0.4" - resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.4.tgz#94a835e27d1af444c65cba88523533c174463d64" - integrity sha512-ENdXf7MW4m9HeDojB2Ukbi7lYMIuQNBHVf98dbzaiG4EEJREBd6oleVAjrLRCrp7dm6CK1mmdmU9tcgF61acbw== - "@types/css-font-loading-module@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@types/css-font-loading-module/-/css-font-loading-module-0.0.6.tgz#1ac3417ed31eeb953134d29b56bca921644b87c0" @@ -1800,11 +1790,6 @@ object.fromentries "^2.0.0" prop-types "^15.7.0" -"@xstate/fsm@^1.4.0": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@xstate/fsm/-/fsm-1.6.1.tgz#c92972b835540c4e3c5e14277f40dbcbdaee9571" - integrity sha512-xYKDNuPR36/fUK+jmhM+oauBmbdUAfuJKnDjg3/7NbN+Pj03TX7e94LXnzkwGgAR+U/HWoMqM5UPTuGIYfIx9g== - abab@^2.0.3, abab@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" @@ -3616,7 +3601,7 @@ fbjs@^0.8.4: setimmediate "^1.0.5" ua-parser-js "^0.7.18" -fflate@^0.4.1, fflate@^0.4.4: +fflate@^0.4.1: version "0.4.8" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== @@ -5654,11 +5639,6 @@ minimist@>=1.2.2, minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== -mitt@^1.1.3: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mitt/-/mitt-1.2.0.tgz#cb24e6569c806e31bd4e3995787fe38a04fdf90d" - integrity sha512-r6lj77KlwqLhIUku9UWYes7KJtsczvolZkzp8hbaDPPaE24OmWl5s539Mytlj22siEQKosZ26qCBgda2PKwoJw== - mixin-deep@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -6274,7 +6254,7 @@ postcss@^8.0.2: nanoid "^3.1.23" source-map-js "^0.6.2" -posthog-js@^1.12.1: +posthog-js@1.12.1: version "1.12.1" resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.12.1.tgz#97834ee2574f34ffb5db2f5b07452c847e3c4d27" integrity sha512-Y3lzcWkS8xFY6Ryj3I4ees7qWP2WGkLw0Arcbk5xaT0+5YlA6UC2jlL/+fN9bz/Bl62EoN3BML901Cuot/QNjg== @@ -6861,22 +6841,6 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rrweb-snapshot@^1.0.3: - version "1.1.7" - resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.7.tgz#92a3b47b1112a1b566c2fae2edb02fa48a6f6653" - integrity sha512-+f2kCCvIQ1hbEeCWnV7mPVPDEdWEExqwcYqMd/r1nfK52QE7qU52jefUOyTe85Vy67rZGqWnfK/B25e/OTSgYg== - -rrweb@^0.9.9: - version "0.9.14" - resolved "https://registry.yarnpkg.com/rrweb/-/rrweb-0.9.14.tgz#09bec604fc44c74801e4fe910606e5a6cde008ec" - integrity sha512-nm2rrVNoyWFPrbGQmcvTTlA7XjbbgPIgO7qsW0Zyr5iOURIFJDGPHFmOVLRyLpWiriVtEoXh6a+x+D1sj+qwWg== - dependencies: - "@types/css-font-loading-module" "0.0.4" - "@xstate/fsm" "^1.4.0" - fflate "^0.4.4" - mitt "^1.1.3" - rrweb-snapshot "^1.0.3" - rst-selector-parser@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz#81b230ea2fcc6066c89e3472de794285d9b03d91" From 7b4a7711b2c05b68fcabdae88b3674046c55036a Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:20:22 +0100 Subject: [PATCH 56/64] Declare return types for all public methods, even void ones --- src/PosthogAnalytics.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 6d15ca79ce..d5bb12621d 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -269,7 +269,7 @@ export class PosthogAnalytics { return this.enabled; } - public setAnonymity(anonymity: Anonymity) { + public setAnonymity(anonymity: Anonymity): void { // 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. @@ -283,17 +283,17 @@ export class PosthogAnalytics { this.anonymity = anonymity; } - public async identifyUser(userId: string) { + public async identifyUser(userId: string): Promise { if (this.anonymity == Anonymity.Pseudonymous) { this.posthog.identify(await hashHex(userId)); } } - public getAnonymity() { + public getAnonymity(): Anonymity { return this.anonymity; } - public logout() { + public logout(): void { if (this.enabled) { this.posthog.reset(); } @@ -311,7 +311,7 @@ export class PosthogAnalytics { public async trackAnonymousEvent( eventName: E["eventName"], properties: E["properties"], - ) { + ): Promise { if (this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); } @@ -320,7 +320,7 @@ export class PosthogAnalytics { eventName: E["eventName"], roomId: string, properties: Omit, - ) { + ): Promise { const updatedProperties = { ...properties, hashedRoomId: roomId ? await hashHex(roomId) : null, @@ -328,7 +328,7 @@ export class PosthogAnalytics { await this.trackPseudonymousEvent(eventName, updatedProperties); } - public async trackPageView(durationMs: number) { + public async trackPageView(durationMs: number): Promise { const hash = window.location.hash; let screen = null; @@ -343,7 +343,7 @@ export class PosthogAnalytics { }); } - public async updatePlatformSuperProperties() { + public async updatePlatformSuperProperties(): Promise { // Update super properties in posthog with our platform (app version, platform). // These properties will be subsequently passed in every event. // @@ -353,7 +353,7 @@ export class PosthogAnalytics { this.registerSuperProperties(this.platformSuperProperties); } - public async updateAnonymityFromSettings(userId?: string) { + public async updateAnonymityFromSettings(userId?: string): Promise { // 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 this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); From d401789f9ec3e284053bd5d8b00bf0723582c469 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:26:24 +0100 Subject: [PATCH 57/64] Ignore eslint conventions in disastrous posthog type definitions --- src/@types/posthog.d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/@types/posthog.d.ts b/src/@types/posthog.d.ts index 1108e2c6df..1ca475cd3b 100644 --- a/src/@types/posthog.d.ts +++ b/src/@types/posthog.d.ts @@ -1,3 +1,12 @@ +// A clone of the type definitions from posthog-js, stripped of references to transitive +// dependencies which we don't actually use, so that we don't need to install them. +// +// Original file lives in node_modules/posthog/dist/module.d.ts + +/* eslint-disable @typescript-eslint/member-delimiter-style */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable camelcase */ + // Type definitions for exported methods declare class posthog { From 07eaee25d29542a4fce9d02a976751ffc3e9c9a3 Mon Sep 17 00:00:00 2001 From: James Salter Date: Wed, 28 Jul 2021 17:54:35 +0100 Subject: [PATCH 58/64] Default properties to {} to avoid passing it superfluously for events with no properties --- src/PosthogAnalytics.ts | 4 ++-- src/components/views/auth/Welcome.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index d5bb12621d..c8e156f2fa 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -302,7 +302,7 @@ export class PosthogAnalytics { public async trackPseudonymousEvent( eventName: E["eventName"], - properties: E["properties"], + properties: E["properties"] = {}, ) { if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); @@ -310,7 +310,7 @@ export class PosthogAnalytics { public async trackAnonymousEvent( eventName: E["eventName"], - properties: E["properties"], + properties: E["properties"] = {}, ): Promise { if (this.anonymity == Anonymity.Disabled) return; await this.capture(eventName, properties); diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 75bbe15411..7c405b0835 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -76,6 +76,6 @@ export default class Welcome extends React.PureComponent { } componentDidMount() { - getAnalytics().trackAnonymousEvent("welcome_screen_load", {}); + getAnalytics().trackAnonymousEvent("welcome_screen_load"); } } From a1ffd240e1c3a3c8d57db4f8cbdc2d9ed0d519c5 Mon Sep 17 00:00:00 2001 From: James Salter Date: Thu, 29 Jul 2021 14:40:18 +0100 Subject: [PATCH 59/64] Use .instance pattern --- src/Lifecycle.ts | 6 +++--- src/PosthogAnalytics.ts | 6 +----- src/components/structures/MatrixChat.tsx | 9 ++++----- src/components/views/auth/Welcome.tsx | 4 ++-- .../views/settings/tabs/user/SecurityUserSettingsTab.js | 4 ++-- .../controllers/PseudonymousAnalyticsController.ts | 4 ++-- 6 files changed, 14 insertions(+), 19 deletions(-) diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index b0a521d886..e48fd52cb1 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -48,7 +48,7 @@ import { Jitsi } from "./widgets/Jitsi"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY } from "./BasePlatform"; import ThreepidInviteStore from "./stores/ThreepidInviteStore"; import CountlyAnalytics from "./CountlyAnalytics"; -import { getAnalytics } from "./PosthogAnalytics"; +import { PosthogAnalytics } from "./PosthogAnalytics"; import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; @@ -574,7 +574,7 @@ async function doSetLoggedIn( await abortLogin(); } - getAnalytics().updateAnonymityFromSettings(credentials.userId); + PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); @@ -703,7 +703,7 @@ export function logout(): void { CountlyAnalytics.instance.enable(/* anonymous = */ true); } - getAnalytics().logout(); + PosthogAnalytics.instance.logout(); if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index c8e156f2fa..314bd150d6 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -149,7 +149,7 @@ export class PosthogAnalytics { private static _instance = null; private platformSuperProperties = {}; - public static instance(): PosthogAnalytics { + public static get instance(): PosthogAnalytics { if (!this._instance) { this._instance = new PosthogAnalytics(posthog); } @@ -362,7 +362,3 @@ export class PosthogAnalytics { } } } - -export function getAnalytics(): PosthogAnalytics { - return PosthogAnalytics.instance(); -} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 6a1eae7e72..60c78b5f9e 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -107,7 +107,7 @@ import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import SoftLogout from './auth/SoftLogout'; import { makeRoomPermalink } from "../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../utils/strings"; -import { getAnalytics } from '../../PosthogAnalytics'; +import { PosthogAnalytics } from '../../PosthogAnalytics'; /** constants for MatrixChat.state.view */ export enum Views { @@ -389,9 +389,8 @@ export default class MatrixChat extends React.PureComponent { Analytics.enable(); } - const analytics = getAnalytics(); - analytics.updateAnonymityFromSettings(); - analytics.updatePlatformSuperProperties(); + PosthogAnalytics.instance.updateAnonymityFromSettings(); + PosthogAnalytics.instance.updatePlatformSuperProperties(); CountlyAnalytics.instance.enable(/* anonymous = */ true); } @@ -449,7 +448,7 @@ export default class MatrixChat extends React.PureComponent { const durationMs = this.stopPageChangeTimer(); Analytics.trackPageChange(durationMs); CountlyAnalytics.instance.trackPageChange(durationMs); - getAnalytics().trackPageView(durationMs); + PosthogAnalytics.instance.trackPageView(durationMs); } if (this.focusComposer) { dis.fire(Action.FocusSendMessageComposer); diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 7c405b0835..921eba114f 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -25,7 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { getAnalytics, IWelcomeScreenLoad } from "../../../PosthogAnalytics"; +import { PosthogAnalytics, IWelcomeScreenLoad } from "../../../PosthogAnalytics"; import LanguageSelector from "./LanguageSelector"; // translatable strings for Welcome pages @@ -76,6 +76,6 @@ export default class Welcome extends React.PureComponent { } componentDidMount() { - getAnalytics().trackAnonymousEvent("welcome_screen_load"); + PosthogAnalytics.instance.trackAnonymousEvent("welcome_screen_load"); } } diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index c24af6cb0d..25b0b86cb1 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -36,7 +36,7 @@ import { UIFeature } from "../../../../../settings/UIFeature"; import { isE2eAdvancedPanelPossible } from "../../E2eAdvancedPanel"; import CountlyAnalytics from "../../../../../CountlyAnalytics"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; -import { getAnalytics } from "../../../../../PosthogAnalytics"; +import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; export class IgnoredUser extends React.Component { static propTypes = { @@ -107,7 +107,7 @@ export default class SecurityUserSettingsTab extends React.Component { _updateAnalytics = (checked) => { checked ? Analytics.enable() : Analytics.disable(); CountlyAnalytics.instance.enable(/* anonymous = */ !checked); - getAnalytics().updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); + PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); }; _onExportE2eKeysClicked = () => { diff --git a/src/settings/controllers/PseudonymousAnalyticsController.ts b/src/settings/controllers/PseudonymousAnalyticsController.ts index d55efe3c74..a82b9685ef 100644 --- a/src/settings/controllers/PseudonymousAnalyticsController.ts +++ b/src/settings/controllers/PseudonymousAnalyticsController.ts @@ -16,11 +16,11 @@ limitations under the License. import SettingController from "./SettingController"; import { SettingLevel } from "../SettingLevel"; -import { getAnalytics } from "../../PosthogAnalytics"; +import { PosthogAnalytics } from "../../PosthogAnalytics"; import { MatrixClientPeg } from "../../MatrixClientPeg"; export default class PseudonymousAnalyticsController extends SettingController { public onChange(level: SettingLevel, roomId: string, newValue: any) { - getAnalytics().updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); + PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId()); } } From cd2bc79b6b2ae840fe942afdea3cce1b3b01c581 Mon Sep 17 00:00:00 2001 From: James Salter Date: Thu, 29 Jul 2021 14:43:19 +0100 Subject: [PATCH 60/64] Remove comment --- src/PosthogAnalytics.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 314bd150d6..40d9120b85 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -77,8 +77,6 @@ export interface IWelcomeScreenLoad extends IAnonymousEvent { } 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(""); From ce11e6c98171e65c7e5049f14fe948a8143393ff Mon Sep 17 00:00:00 2001 From: James Salter Date: Mon, 2 Aug 2021 11:45:49 +0100 Subject: [PATCH 61/64] Update src/PosthogAnalytics.ts Co-authored-by: J. Ryan Stinnett --- src/PosthogAnalytics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 40d9120b85..11c6980a90 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -207,7 +207,7 @@ export class PosthogAnalytics { }; private static getAnonymityFromSettings(): Anonymity { - // determine the current anonymity level based on curernt user settings + // determine the current anonymity level based on current user settings // "Send anonymous usage data which helps us improve Element. This will use a cookie." const analyticsOptIn = SettingsStore.getValue("analyticsOptIn", null, true); From 4755a81403cdfc8ecb46eab7397e04ce87c331dc Mon Sep 17 00:00:00 2001 From: James Salter Date: Mon, 2 Aug 2021 12:17:16 +0100 Subject: [PATCH 62/64] Disable analytics when user hasn't opted in or out --- src/PosthogAnalytics.ts | 11 ++--------- src/components/views/auth/Welcome.tsx | 4 ---- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 11c6980a90..80b51a3f2e 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -71,11 +71,6 @@ interface IPageView extends IAnonymousEvent { }; } -export interface IWelcomeScreenLoad extends IAnonymousEvent { - eventName: "welcome_screen_load"; - properties: Record; -} - const hashHex = async (input: string): Promise => { const buf = new TextEncoder().encode(input); const digestBuf = await window.crypto.subtle.digest("sha-256", buf); @@ -141,7 +136,7 @@ export class PosthogAnalytics { * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. */ - private anonymity = Anonymity.Anonymous; + private anonymity = Anonymity.Disabled; // set true during the constructor if posthog config is present, otherwise false private enabled = false; private static _instance = null; @@ -220,9 +215,7 @@ export class PosthogAnalytics { let anonymity; if (pseudonumousOptIn) { anonymity = Anonymity.Pseudonymous; - } 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) + } else if (analyticsOptIn) { anonymity = Anonymity.Anonymous; } else { anonymity = Anonymity.Disabled; diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 921eba114f..5c937d81c7 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -74,8 +74,4 @@ export default class Welcome extends React.PureComponent { ); } - - componentDidMount() { - PosthogAnalytics.instance.trackAnonymousEvent("welcome_screen_load"); - } } From 2cee2b5fd60894ea19d14760e2861b4c2b830be5 Mon Sep 17 00:00:00 2001 From: James Salter Date: Mon, 2 Aug 2021 12:26:18 +0100 Subject: [PATCH 63/64] Update comment --- src/PosthogAnalytics.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts index 80b51a3f2e..860a155aff 100644 --- a/src/PosthogAnalytics.ts +++ b/src/PosthogAnalytics.ts @@ -29,9 +29,9 @@ import SettingsStore from './settings/SettingsStore'; * `respect_dnt` flag being passed to `posthog.init`). * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. * hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. - * - Otherwise, if the existing `analyticsOptIn` flag is `true`, or not present (i.e. prior to - * logging in), track anonymously, i.e. redact all matrix identifiers in tracking events. - * - If both flags are false, events are not sent. + * - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. + * redact all matrix identifiers in tracking events. + * - If both flags are false or not set, events are not sent. */ interface IEvent { From 1ca547802f0a794a3cc018a40e0ce69847a8a6d4 Mon Sep 17 00:00:00 2001 From: James Salter Date: Mon, 2 Aug 2021 13:28:56 +0100 Subject: [PATCH 64/64] lint --- src/components/views/auth/Welcome.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 5c937d81c7..0e12025fbd 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -25,7 +25,6 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { PosthogAnalytics, IWelcomeScreenLoad } from "../../../PosthogAnalytics"; import LanguageSelector from "./LanguageSelector"; // translatable strings for Welcome pages