diff --git a/package.json b/package.json
index 9744aa7685..b7e06fe012 100644
--- a/package.json
+++ b/package.json
@@ -87,6 +87,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/@types/posthog.d.ts b/src/@types/posthog.d.ts
new file mode 100644
index 0000000000..1ca475cd3b
--- /dev/null
+++ b/src/@types/posthog.d.ts
@@ -0,0 +1,748 @@
+// 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 {
+ /**
+ * 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/src/Lifecycle.ts b/src/Lifecycle.ts
index 410124a637..e48fd52cb1 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 { PosthogAnalytics } from "./PosthogAnalytics";
import CallHandler from './CallHandler';
import LifecycleCustomisations from "./customisations/Lifecycle";
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
@@ -573,6 +574,8 @@ async function doSetLoggedIn(
await abortLogin();
}
+ PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
+
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials);
@@ -700,6 +703,8 @@ export function logout(): void {
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
+ PosthogAnalytics.instance.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/PosthogAnalytics.ts b/src/PosthogAnalytics.ts
new file mode 100644
index 0000000000..860a155aff
--- /dev/null
+++ b/src/PosthogAnalytics.ts
@@ -0,0 +1,355 @@
+/*
+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';
+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_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`, 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 {
+ // 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. This is just a placeholder,
+ // extending interfaces must override this with a concrete definition to do type validation.
+ properties: {};
+}
+
+export enum Anonymity {
+ Disabled,
+ Anonymous,
+ Pseudonymous
+}
+
+// If an event extends IPseudonymousEvent, the event contains pseudonymous data
+// that won't be sent unless the user has explicitly consented to pseudonymous tracking.
+// For example, 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;
+// i.e. no identifiers that can be associated with the user.
+export interface IAnonymousEvent extends IEvent {}
+
+export interface IRoomEvent extends IPseudonymousEvent {
+ hashedRoomId: string;
+}
+
+interface IPageView extends IAnonymousEvent {
+ eventName: "$pageview";
+ properties: {
+ durationMs?: number;
+ screen?: string;
+ };
+}
+
+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("");
+};
+
+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",
+]);
+
+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
+ if (origin.startsWith('file://')) {
+ pathname = "//";
+ }
+
+ let hashStr;
+ if (hash == "") {
+ hashStr = "";
+ } else {
+ let [beforeFirstSlash, screen, ...parts] = hash.split("/");
+
+ if (!whitelistedScreens.has(screen)) {
+ screen = "";
+ }
+
+ for (let i = 0; i < parts.length; i++) {
+ parts[i] = anonymity === Anonymity.Anonymous ? `` : await hashHex(parts[i]);
+ }
+
+ hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
+ }
+ 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
+ * - 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.Disabled;
+ // set true during the constructor if posthog config is present, otherwise false
+ private enabled = false;
+ private static _instance = null;
+ private platformSuperProperties = {};
+
+ public static get instance(): PosthogAnalytics {
+ if (!this._instance) {
+ this._instance = new PosthogAnalytics(posthog);
+ }
+ return this._instance;
+ }
+
+ constructor(private readonly posthog: PostHog) {
+ const posthogConfig = SdkConfig.get()["posthog"];
+ if (posthogConfig) {
+ this.posthog.init(posthogConfig.projectApiKey, {
+ api_host: posthogConfig.apiHost,
+ autocapture: false,
+ mask_all_text: true,
+ mask_all_element_attributes: true,
+ // This only triggers on page load, which for our SPA isn't particularly useful.
+ // Plus, the .capture call originating from somewhere in posthog makes it hard
+ // to redact URLs, which requires async code.
+ //
+ // To raise this manually, just call .capture("$pageview") or posthog.capture_pageview.
+ capture_pageview: false,
+ sanitize_properties: this.sanitizeProperties,
+ respect_dnt: true,
+ });
+ this.enabled = true;
+ } else {
+ this.enabled = false;
+ }
+ }
+
+ 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.
+ // See utils.js _.info.properties in posthog-js.
+
+ // Replace the $current_url with a redacted version.
+ // $redacted_current_url is injected by this class earlier in capture(), as its generation
+ // is async and can't be done in this non-async callback.
+ if (!properties['$redacted_current_url']) {
+ console.log("$redacted_current_url not set in sanitizeProperties, will drop $current_url entirely");
+ }
+ properties['$current_url'] = properties['$redacted_current_url'];
+ delete properties['$redacted_current_url'];
+
+ if (this.anonymity == Anonymity.Anonymous) {
+ // drop referrer information for anonymous users
+ properties['$referrer'] = null;
+ properties['$referring_domain'] = null;
+ properties['$initial_referrer'] = null;
+ properties['$initial_referring_domain'] = null;
+
+ // drop device ID, which is a UUID persisted in local storage
+ properties['$device_id'] = null;
+ }
+
+ return properties;
+ };
+
+ private static getAnonymityFromSettings(): Anonymity {
+ // 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);
+
+ // (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_pseudonymous_analytics_opt_in", null, true);
+
+ let anonymity;
+ if (pseudonumousOptIn) {
+ anonymity = Anonymity.Pseudonymous;
+ } else if (analyticsOptIn) {
+ anonymity = Anonymity.Anonymous;
+ } else {
+ anonymity = Anonymity.Disabled;
+ }
+
+ return anonymity;
+ }
+
+ private registerSuperProperties(properties: posthog.Properties) {
+ if (this.enabled) {
+ this.posthog.register(properties);
+ }
+ }
+
+ private static async getPlatformProperties(): Promise {
+ 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(): boolean {
+ return this.enabled;
+ }
+
+ 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.
+ 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;
+ }
+
+ public async identifyUser(userId: string): Promise {
+ if (this.anonymity == Anonymity.Pseudonymous) {
+ this.posthog.identify(await hashHex(userId));
+ }
+ }
+
+ public getAnonymity(): Anonymity {
+ return this.anonymity;
+ }
+
+ public logout(): void {
+ if (this.enabled) {
+ this.posthog.reset();
+ }
+ this.setAnonymity(Anonymity.Anonymous);
+ }
+
+ public async trackPseudonymousEvent(
+ eventName: E["eventName"],
+ properties: E["properties"] = {},
+ ) {
+ if (this.anonymity == Anonymity.Anonymous || this.anonymity == Anonymity.Disabled) return;
+ await this.capture(eventName, properties);
+ }
+
+ public async trackAnonymousEvent(
+ eventName: E["eventName"],
+ properties: E["properties"] = {},
+ ): Promise {
+ if (this.anonymity == Anonymity.Disabled) return;
+ await this.capture(eventName, properties);
+ }
+
+ public async trackRoomEvent(
+ eventName: E["eventName"],
+ roomId: string,
+ properties: Omit,
+ ): Promise {
+ const updatedProperties = {
+ ...properties,
+ hashedRoomId: roomId ? await hashHex(roomId) : null,
+ };
+ await this.trackPseudonymousEvent(eventName, updatedProperties);
+ }
+
+ public async trackPageView(durationMs: number): Promise {
+ 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,
+ });
+ }
+
+ public async updatePlatformSuperProperties(): Promise {
+ // 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);
+ }
+
+ 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());
+ if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
+ await this.identifyUser(userId);
+ }
+ }
+}
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 8cfe35c4cf..60c78b5f9e 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 { PosthogAnalytics } from '../../PosthogAnalytics';
/** constants for MatrixChat.state.view */
export enum Views {
@@ -387,6 +388,10 @@ export default class MatrixChat extends React.PureComponent {
if (SettingsStore.getValue("analyticsOptIn")) {
Analytics.enable();
}
+
+ PosthogAnalytics.instance.updateAnonymityFromSettings();
+ PosthogAnalytics.instance.updatePlatformSuperProperties();
+
CountlyAnalytics.instance.enable(/* anonymous = */ true);
}
@@ -443,6 +448,7 @@ export default class MatrixChat extends React.PureComponent {
const durationMs = this.stopPageChangeTimer();
Analytics.trackPageChange(durationMs);
CountlyAnalytics.instance.trackPageChange(durationMs);
+ PosthogAnalytics.instance.trackPageView(durationMs);
}
if (this.focusComposer) {
dis.fire(Action.FocusSendMessageComposer);
diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js
index 79d501e712..25b0b86cb1 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 { PosthogAnalytics } 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);
+ PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId());
};
_onExportE2eKeysClicked = () => {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 3ad8daa85c..87cd9afb5b 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -813,6 +813,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",
"New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)",
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index c36e2b90bf..cfe2c097fc 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';
import NewLayoutSwitcherController from './controllers/NewLayoutSwitcherController';
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
@@ -268,6 +269,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
+ "feature_pseudonymous_analytics_opt_in": {
+ 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..a82b9685ef
--- /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 { PosthogAnalytics } from "../../PosthogAnalytics";
+import { MatrixClientPeg } from "../../MatrixClientPeg";
+
+export default class PseudonymousAnalyticsController extends SettingController {
+ public onChange(level: SettingLevel, roomId: string, newValue: any) {
+ PosthogAnalytics.instance.updateAnonymityFromSettings(MatrixClientPeg.get().getUserId());
+ }
+}
diff --git a/test/PosthogAnalytics-test.ts b/test/PosthogAnalytics-test.ts
new file mode 100644
index 0000000000..6cb1743051
--- /dev/null
+++ b/test/PosthogAnalytics-test.ts
@@ -0,0 +1,232 @@
+/*
+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,
+ IAnonymousEvent,
+ IPseudonymousEvent,
+ IRoomEvent,
+ PosthogAnalytics,
+} from '../src/PosthogAnalytics';
+
+import SdkConfig from '../src/SdkConfig';
+
+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();
+ }
+}
+
+export interface ITestEvent extends IAnonymousEvent {
+ key: "jest_test_event";
+ properties: {
+ foo: string;
+ };
+}
+
+export interface ITestPseudonymousEvent extends IPseudonymousEvent {
+ key: "jest_test_pseudo_event";
+ properties: {
+ foo: string;
+ };
+}
+
+export interface ITestRoomEvent extends IRoomEvent {
+ key: "jest_test_room_event";
+ properties: {
+ foo: string;
+ };
+}
+
+describe("PosthogAnalytics", () => {
+ let fakePosthog: FakePosthog;
+ const shaHashes = {
+ "42": "73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049",
+ "some": "a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b",
+ "pii": "bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4",
+ "foo": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
+ };
+
+ beforeEach(() => {
+ fakePosthog = new FakePosthog();
+
+ window.crypto = {
+ 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;
+ },
+ },
+ };
+ });
+
+ afterEach(() => {
+ window.crypto = null;
+ });
+
+ describe("Initialisation", () => {
+ it("Should not be enabled without config being set", () => {
+ jest.spyOn(SdkConfig, "get").mockReturnValue({});
+ const analytics = new PosthogAnalytics(fakePosthog);
+ expect(analytics.isEnabled()).toBe(false);
+ });
+
+ it("Should be enabled if config is set", () => {
+ jest.spyOn(SdkConfig, "get").mockReturnValue({
+ posthog: {
+ projectApiKey: "foo",
+ apiHost: "bar",
+ },
+ });
+ 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: {
+ projectApiKey: "foo",
+ apiHost: "bar",
+ },
+ });
+
+ analytics = new PosthogAnalytics(fakePosthog);
+ });
+
+ it("Should pass trackAnonymousEvent() to posthog", async () => {
+ analytics.setAnonymity(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]["foo"]).toEqual("bar");
+ });
+
+ it("Should pass trackRoomEvent to posthog", async () => {
+ analytics.setAnonymity(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]["foo"]).toEqual("bar");
+ expect(fakePosthog.capture.mock.calls[0][1]["hashedRoomId"])
+ .toEqual("73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049");
+ });
+
+ it("Should pass trackPseudonymousEvent() to posthog", async () => {
+ analytics.setAnonymity(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 not track pseudonymous messages if anonymous", async () => {
+ analytics.setAnonymity(Anonymity.Anonymous);
+ await analytics.trackPseudonymousEvent("jest_test_event", {
+ foo: "bar",
+ });
+ expect(fakePosthog.capture.mock.calls.length).toBe(0);
+ });
+
+ it("Should not track any events if disabled", async () => {
+ analytics.setAnonymity(Anonymity.Disabled);
+ await analytics.trackPseudonymousEvent("jest_test_event", {
+ foo: "bar",
+ });
+ await analytics.trackAnonymousEvent("jest_test_event", {
+ foo: "bar",
+ });
+ await analytics.trackRoomEvent("room id", "foo", {
+ foo: "bar",
+ });
+ await analytics.trackPageView(200);
+ 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 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 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.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.setAnonymity(Anonymity.Anonymous);
+ await analytics.identifyUser("foo");
+ expect(fakePosthog.identify.mock.calls.length).toBe(0);
+ });
+ });
+});
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 2a03f640ee..daed6f4377 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, file-entry-cache@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
@@ -6249,6 +6254,13 @@ postcss@^8.0.2:
nanoid "^3.1.23"
source-map-js "^0.6.2"
+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"