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"