From 72503d033597a91021aced71bd7895ae7f4d3da8 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Fri, 4 Nov 2022 13:07:14 +0100 Subject: [PATCH] Add posthog Telemetry (Anonymity Logic + call duration telemetry) (#658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Timo K Co-authored-by: Šimon Brandner --- package.json | 1 + src/ClientContext.tsx | 11 +- src/PosthogAnalytics.ts | 336 ++++++++++++++++++++++++++++++++ src/PosthogEvents.ts | 145 ++++++++++++++ src/auth/LoginPage.tsx | 2 + src/auth/RegisterPage.tsx | 4 + src/config/ConfigOptions.ts | 1 + src/room/GroupCallInspector.tsx | 4 +- src/room/GroupCallView.tsx | 16 +- src/room/InCallView.tsx | 4 + src/room/useGroupCall.ts | 12 +- src/settings/SettingsModal.tsx | 19 +- src/settings/useSetting.ts | 3 +- yarn.lock | 23 +++ 14 files changed, 574 insertions(+), 7 deletions(-) create mode 100644 src/PosthogAnalytics.ts create mode 100644 src/PosthogEvents.ts diff --git a/package.json b/package.json index 2993c986..d7efc5d2 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "normalize.css": "^8.0.1", "pako": "^2.0.4", "postcss-preset-env": "^7", + "posthog-js": "^1.29.0", "re-resizable": "^6.9.0", "react": "18", "react-dom": "18", diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 8f81c85a..23d87a4d 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -38,6 +38,7 @@ import { fallbackICEServerAllowed, } from "./matrix-utils"; import { widget } from "./widget"; +import { PosthogAnalytics, RegistrationType } from "./PosthogAnalytics"; import { translatedError } from "./TranslatedError"; declare global { @@ -114,7 +115,9 @@ export const ClientProvider: FC = ({ children }) => { if (widget) { // We're inside a widget, so let's engage *matryoshka mode* logger.log("Using a matryoshka client"); - + PosthogAnalytics.instance.setRegistrationType( + RegistrationType.Registered + ); return { client: await widget.client, isPasswordlessUser: false, @@ -132,6 +135,11 @@ export const ClientProvider: FC = ({ children }) => { session; try { + PosthogAnalytics.instance.setRegistrationType( + passwordlessUser + ? RegistrationType.Guest + : RegistrationType.Registered + ); return { client: await initClient( { @@ -279,6 +287,7 @@ export const ClientProvider: FC = ({ children }) => { error: undefined, }); history.push("/"); + PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest); }, [history, client]); const { t } = useTranslation(); diff --git a/src/PosthogAnalytics.ts b/src/PosthogAnalytics.ts new file mode 100644 index 00000000..2932f137 --- /dev/null +++ b/src/PosthogAnalytics.ts @@ -0,0 +1,336 @@ +/* +Copyright 2022 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, { CaptureOptions, PostHog, Properties } from "posthog-js"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { widget } from "./widget"; +import { settingsBus } from "./settings/useSetting"; +import { + CallEndedTracker, + CallStartedTracker, + LoginTracker, + SignupTracker, + MuteCameraTracker, + MuteMicrophoneTracker, +} from "./PosthogEvents"; +import { Config } from "./config/Config"; + +/* 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 posthog analytics are explicitly activated by the user in the element call settings, + * a randomised analytics ID is created and stored in account_data for that user (shared between devices) + * so that the user can be identified in posthog. + */ + +export interface IPosthogEvent { + // The event name that will be used by PostHog. Event names should use camelCase. + eventName: string; + + // do not allow these to be sent manually, we enqueue them all for caching purposes + $set?: void; + $set_once?: void; +} + +export interface IPostHogEventOptions { + timestamp?: Date; +} + +export enum Anonymity { + Disabled, + Anonymous, + Pseudonymous, +} + +export enum RegistrationType { + Guest, + Registered, +} + +interface PlatformProperties { + appVersion: string; + matrixBackend: "embedded" | "jssdk"; +} + +interface PosthogSettings { + project_api_key?: string; + api_host?: 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 no identifier is passed to posthog + * - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices + * is 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 IPosthogEvent. + */ + + private static ANALYTICS_EVENT_TYPE = "im.vector.analytics"; + + // set true during the constructor if posthog config is present, otherwise false + private static internalInstance = null; + + private readonly enabled: boolean = false; + private anonymity = Anonymity.Pseudonymous; + private platformSuperProperties = {}; + private registrationType: RegistrationType = RegistrationType.Guest; + + public static get instance(): PosthogAnalytics { + if (!this.internalInstance) { + this.internalInstance = new PosthogAnalytics(posthog); + } + return this.internalInstance; + } + + constructor(private readonly posthog: PostHog) { + const posthogConfig: PosthogSettings = { + project_api_key: Config.instance.config.posthog?.api_key, + api_host: Config.instance.config.posthog?.api_host, + }; + if (posthogConfig.project_api_key && posthogConfig.api_host) { + this.posthog.init(posthogConfig.project_api_key, { + api_host: posthogConfig.api_host, + autocapture: false, + mask_all_text: true, + mask_all_element_attributes: true, + capture_pageview: false, + sanitize_properties: this.sanitizeProperties, + respect_dnt: true, + advanced_disable_decide: true, + }); + this.enabled = true; + } else { + this.enabled = false; + } + this.startListeningToSettingsChanges(); + } + + private sanitizeProperties = ( + properties: Properties, + _eventName: string + ): 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. + + 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 registerSuperProperties(properties: Properties) { + if (this.enabled) { + this.posthog.register(properties); + } + } + + private static getPlatformProperties(): PlatformProperties { + const appVersion = import.meta.env.VITE_APP_VERSION || "unknown"; + return { + appVersion, + matrixBackend: widget ? "embedded" : "jssdk", + }; + } + + private capture( + eventName: string, + properties: Properties, + options?: CaptureOptions + ) { + if (!this.enabled) { + return; + } + this.posthog.capture(eventName, { ...properties }, options); + } + + public isEnabled(): boolean { + return this.enabled; + } + + setAnonymity(anonymity: Anonymity): void { + // Update this.anonymity. + // To update the anonymity 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.updateSuperProperties(); + } + this.anonymity = anonymity; + } + + private static getRandomAnalyticsId(): string { + return [...crypto.getRandomValues(new Uint8Array(16))] + .map((c) => c.toString(16)) + .join(""); + } + + public async identifyUser(analyticsIdGenerator: () => string): Promise { + // There might be a better way to get the client here. + const client = window.matrixclient; + + if (this.anonymity == Anonymity.Pseudonymous) { + // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows + // different devices to send the same ID. + try { + const accountData = await client.getAccountDataFromServer( + PosthogAnalytics.ANALYTICS_EVENT_TYPE + ); + let analyticsID = accountData?.id; + if (!analyticsID) { + // Couldn't retrieve an analytics ID from user settings, so create one and set it on the server. + // Note there's a race condition here - if two devices do these steps at the same time, last write + // wins, and the first writer will send tracking with an ID that doesn't match the one on the server + // until the next time account data is refreshed and this function is called (most likely on next + // page load). This will happen pretty infrequently, so we can tolerate the possibility. + analyticsID = analyticsIdGenerator(); + await client.setAccountData( + PosthogAnalytics.ANALYTICS_EVENT_TYPE, + Object.assign({ id: analyticsID }, accountData) + ); + } + this.posthog.identify(analyticsID); + } catch (e) { + // The above could fail due to network requests, but not essential to starting the application, + // so swallow it. + logger.log("Unable to identify user for tracking" + e.toString()); + } + } + } + + public getAnonymity(): Anonymity { + return this.anonymity; + } + + public logout(): void { + if (this.enabled) { + this.posthog.reset(); + } + this.setAnonymity(Anonymity.Disabled); + } + + public async updateSuperProperties(): 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 = PosthogAnalytics.getPlatformProperties(); + this.registerSuperProperties({ + ...this.platformSuperProperties, + registrationType: + this.registrationType == RegistrationType.Guest + ? "Guest" + : "Registered", + }); + } + + private userRegisteredInThisSession(): boolean { + return this.eventSignup.getSignupEndTime() > new Date(0); + } + + public async updateAnonymityFromSettings( + pseudonymousOptIn: boolean + ): Promise { + // Update this.anonymity based on the user's analytics opt-in settings + const anonymity = pseudonymousOptIn + ? Anonymity.Pseudonymous + : Anonymity.Disabled; + this.setAnonymity(anonymity); + if (anonymity === Anonymity.Pseudonymous) { + await this.identifyUser(PosthogAnalytics.getRandomAnalyticsId); + if (this.userRegisteredInThisSession()) { + this.eventSignup.track(); + } + } + + if (anonymity !== Anonymity.Disabled) { + await this.updateSuperProperties(); + } + } + + public trackEvent( + { eventName, ...properties }: E, + options?: IPostHogEventOptions + ): void { + if ( + this.anonymity == Anonymity.Disabled || + this.anonymity == Anonymity.Anonymous + ) + return; + this.capture(eventName, properties, options); + } + + public startListeningToSettingsChanges(): void { + // Listen to account data changes from sync so we can observe changes to relevant flags and update. + // This is called - + // * On page load, when the account data is first received by sync + // * On login + // * When another device changes account data + // * When the user changes their preferences on this device + // Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings + // won't be called (i.e. this.anonymity will be left as the default, until the setting changes) + settingsBus.on("opt-in-analytics", (optInAnalytics) => { + this.updateAnonymityFromSettings(optInAnalytics); + }); + } + + public setRegistrationType(registrationType: RegistrationType): void { + this.registrationType = registrationType; + if ( + this.anonymity == Anonymity.Disabled || + this.anonymity == Anonymity.Anonymous + ) + return; + this.updateSuperProperties(); + } + + // ----- Events + + public eventCallEnded = new CallEndedTracker(); + public eventSignup = new SignupTracker(); + public eventCallStarted = new CallStartedTracker(); + public eventLogin = new LoginTracker(); + public eventMuteMicrophone = new MuteMicrophoneTracker(); + public eventMuteCamera = new MuteCameraTracker(); +} diff --git a/src/PosthogEvents.ts b/src/PosthogEvents.ts new file mode 100644 index 00000000..5dacac9d --- /dev/null +++ b/src/PosthogEvents.ts @@ -0,0 +1,145 @@ +/* +Copyright 2022 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 { + IPosthogEvent, + PosthogAnalytics, + RegistrationType, +} from "./PosthogAnalytics"; + +interface CallEnded extends IPosthogEvent { + eventName: "CallEnded"; + callName: string; + callParticipantsOnLeave: number; + callParticipantsMax: number; + callDuration: number; +} + +export class CallEndedTracker { + private cache: { startTime: Date; maxParticipantsCount: number } = { + startTime: new Date(0), + maxParticipantsCount: 0, + }; + + cacheStartCall(time: Date) { + this.cache.startTime = time; + } + + cacheParticipantCountChanged(count: number) { + this.cache.maxParticipantsCount = Math.max( + count, + this.cache.maxParticipantsCount + ); + } + + track(callName: string, callParticipantsNow: number) { + PosthogAnalytics.instance.trackEvent({ + eventName: "CallEnded", + callName, + callParticipantsMax: this.cache.maxParticipantsCount, + callParticipantsOnLeave: callParticipantsNow, + callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000, + }); + } +} + +interface CallStarted extends IPosthogEvent { + eventName: "CallStarted"; + callName: string; +} + +export class CallStartedTracker { + track(callName: string) { + PosthogAnalytics.instance.trackEvent({ + eventName: "CallStarted", + callName, + }); + } +} + +interface Signup extends IPosthogEvent { + eventName: "Signup"; + signupDuration: number; +} + +export class SignupTracker { + private cache: { signupStart: Date; signupEnd: Date } = { + signupStart: new Date(0), + signupEnd: new Date(0), + }; + + cacheSignupStart(time: Date) { + this.cache.signupStart = time; + } + + getSignupEndTime() { + return this.cache.signupEnd; + } + + cacheSignupEnd(time: Date) { + this.cache.signupEnd = time; + } + + track() { + PosthogAnalytics.instance.trackEvent({ + eventName: "Signup", + signupDuration: + new Date().getSeconds() - this.cache.signupStart.getSeconds(), + }); + PosthogAnalytics.instance.setRegistrationType(RegistrationType.Registered); + } +} + +interface Login extends IPosthogEvent { + eventName: "Login"; +} + +export class LoginTracker { + track() { + PosthogAnalytics.instance.trackEvent({ + eventName: "Login", + }); + PosthogAnalytics.instance.setRegistrationType(RegistrationType.Registered); + } +} + +interface MuteMicrophone { + eventName: "MuteMicrophone"; + targetMuteState: "mute" | "unmute"; +} + +export class MuteMicrophoneTracker { + track(targetIsMute: boolean) { + PosthogAnalytics.instance.trackEvent({ + eventName: "MuteMicrophone", + targetMuteState: targetIsMute ? "mute" : "unmute", + }); + } +} + +interface MuteCamera { + eventName: "MuteCamera"; + targetMuteState: "mute" | "unmute"; +} + +export class MuteCameraTracker { + track(targetIsMute: boolean) { + PosthogAnalytics.instance.trackEvent({ + eventName: "MuteCamera", + targetMuteState: targetIsMute ? "mute" : "unmute", + }); + } +} diff --git a/src/auth/LoginPage.tsx b/src/auth/LoginPage.tsx index b7ea8349..3dc4840d 100644 --- a/src/auth/LoginPage.tsx +++ b/src/auth/LoginPage.tsx @@ -33,6 +33,7 @@ import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils"; import styles from "./LoginPage.module.css"; import { useInteractiveLogin } from "./useInteractiveLogin"; import { usePageTitle } from "../usePageTitle"; +import { PosthogAnalytics } from "../PosthogAnalytics"; export const LoginPage: FC = () => { const { t } = useTranslation(); @@ -64,6 +65,7 @@ export const LoginPage: FC = () => { } else { history.push("/"); } + PosthogAnalytics.instance.eventLogin.track(); }) .catch((error) => { setError(error); diff --git a/src/auth/RegisterPage.tsx b/src/auth/RegisterPage.tsx index f8583b74..9da1d327 100644 --- a/src/auth/RegisterPage.tsx +++ b/src/auth/RegisterPage.tsx @@ -39,6 +39,7 @@ import { LoadingView } from "../FullScreenView"; import { useRecaptcha } from "./useRecaptcha"; import { Caption, Link } from "../typography/Typography"; import { usePageTitle } from "../usePageTitle"; +import { PosthogAnalytics } from "../PosthogAnalytics"; export const RegisterPage: FC = () => { const { t } = useTranslation(); @@ -98,6 +99,7 @@ export const RegisterPage: FC = () => { } setClient(newClient, session); + PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date()); }; submit() @@ -142,6 +144,8 @@ export const RegisterPage: FC = () => { if (loading) { return ; + } else { + PosthogAnalytics.instance.eventSignup.cacheSignupStart(new Date()); } return ( diff --git a/src/config/ConfigOptions.ts b/src/config/ConfigOptions.ts index 6e29a944..f32806ea 100644 --- a/src/config/ConfigOptions.ts +++ b/src/config/ConfigOptions.ts @@ -1,6 +1,7 @@ export interface IConfigOptions { posthog?: { api_key: string; + api_host: string; }; sentry?: { DSN: string; diff --git a/src/room/GroupCallInspector.tsx b/src/room/GroupCallInspector.tsx index 80dcbacf..279a5825 100644 --- a/src/room/GroupCallInspector.tsx +++ b/src/room/GroupCallInspector.tsx @@ -353,7 +353,7 @@ function useGroupCallState( client: MatrixClient, groupCall: GroupCall, showPollCallStats: boolean -) { +): InspectorContextState { const [state, dispatch] = useReducer(reducer, { localUserId: client.getUserId(), localSessionId: client.getSessionId(), @@ -410,11 +410,13 @@ function useGroupCallState( return state; } + interface GroupCallInspectorProps { client: MatrixClient; groupCall: GroupCall; show: boolean; } + export function GroupCallInspector({ client, groupCall, diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index ee44ef60..b05182a6 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -32,6 +32,7 @@ import { CallEndedView } from "./CallEndedView"; import { useRoomAvatar } from "./useRoomAvatar"; import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler"; import { useLocationNavigation } from "../useLocationNavigation"; +import { PosthogAnalytics } from "../PosthogAnalytics"; import { useMediaHandler } from "../settings/useMediaHandler"; import { findDeviceByName, getDevices } from "../media-utils"; @@ -170,6 +171,12 @@ export function GroupCallView({ const onLeave = useCallback(() => { setLeft(true); + + PosthogAnalytics.instance.eventCallEnded.track( + groupCall.room.name, + groupCall.participants.length + ); + leave(); if (widget) { widget.api.transport.send(ElementWidgetActions.HangupCall, {}); @@ -179,7 +186,14 @@ export function GroupCallView({ if (!isPasswordlessUser && !isEmbedded) { history.push("/"); } - }, [leave, isPasswordlessUser, isEmbedded, history]); + }, [ + groupCall.room.name, + groupCall.participants.length, + leave, + isPasswordlessUser, + isEmbedded, + history, + ]); useEffect(() => { if (widget && state === GroupCallState.Entered) { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b52beae8..b4de4b9d 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -57,6 +57,7 @@ import { useAudioContext } from "../video-grid/useMediaStream"; import { useFullscreen } from "../video-grid/useFullscreen"; import { AudioContainer } from "../video-grid/AudioContainer"; import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice"; +import { PosthogAnalytics } from "../PosthogAnalytics"; import { widget, ElementWidgetActions } from "../widget"; import { useJoinRule } from "./useJoinRule"; import { useUrlParams } from "../UrlParams"; @@ -210,6 +211,9 @@ export function InCallView({ }); } + PosthogAnalytics.instance.eventCallEnded.cacheParticipantCountChanged( + participants.length + ); // add the screenshares too for (const screenshareFeed of screenshareFeeds) { const userMediaItem = tileDescriptors.find( diff --git a/src/room/useGroupCall.ts b/src/room/useGroupCall.ts index 6fa60862..3a917f95 100644 --- a/src/room/useGroupCall.ts +++ b/src/room/useGroupCall.ts @@ -30,6 +30,7 @@ import { useTranslation } from "react-i18next"; import { IWidgetApiRequest } from "matrix-widget-api"; import { usePageUnload } from "./usePageUnload"; +import { PosthogAnalytics } from "../PosthogAnalytics"; import { TranslatedError, translatedError } from "../TranslatedError"; import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget"; @@ -280,6 +281,9 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType { return; } + PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); + PosthogAnalytics.instance.eventCallStarted.track(groupCall.room.name); + groupCall.enter().catch((error) => { console.error(error); updateState({ error }); @@ -289,11 +293,15 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType { const leave = useCallback(() => groupCall.leave(), [groupCall]); const toggleLocalVideoMuted = useCallback(() => { - groupCall.setLocalVideoMuted(!groupCall.isLocalVideoMuted()); + const toggleToMute = !groupCall.isLocalVideoMuted(); + groupCall.setLocalVideoMuted(toggleToMute); + PosthogAnalytics.instance.eventMuteCamera.track(toggleToMute); }, [groupCall]); const toggleMicrophoneMuted = useCallback(() => { - groupCall.setMicrophoneMuted(!groupCall.isMicrophoneMuted()); + const toggleToMute = !groupCall.isMicrophoneMuted(); + groupCall.setMicrophoneMuted(toggleToMute); + PosthogAnalytics.instance.eventMuteMicrophone.track(toggleToMute); }, [groupCall]); const toggleScreensharing = useCallback(async () => { diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx index b2c98b82..b3777180 100644 --- a/src/settings/SettingsModal.tsx +++ b/src/settings/SettingsModal.tsx @@ -26,7 +26,11 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg"; import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg"; import { SelectInput } from "../input/SelectInput"; import { useMediaHandler } from "./useMediaHandler"; -import { useSpatialAudio, useShowInspector } from "./useSetting"; +import { + useSpatialAudio, + useShowInspector, + useOptInAnalytics, +} from "./useSetting"; import { FieldRow, InputField } from "../input/Input"; import { Button } from "../button"; import { useDownloadDebugLog } from "./submit-rageshake"; @@ -53,6 +57,7 @@ export const SettingsModal = (props: Props) => { const [spatialAudio, setSpatialAudio] = useSpatialAudio(); const [showInspector, setShowInspector] = useShowInspector(); + const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics(); const downloadDebugLog = useDownloadDebugLog(); @@ -115,6 +120,18 @@ export const SettingsModal = (props: Props) => { } /> + + ) => + setOptInAnalytics(event.target.checked) + } + /> + ( @@ -54,3 +54,4 @@ const useSetting = ( export const useSpatialAudio = () => useSetting("spatial-audio", false); export const useShowInspector = () => useSetting("show-inspector", false); +export const useOptInAnalytics = () => useSetting("opt-in-analytics", false); diff --git a/yarn.lock b/yarn.lock index efa3dedd..8e621183 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2403,6 +2403,11 @@ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.7.tgz#c6b337912e588083fc2896eb012526cf7cfec7c7" integrity sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg== +"@sentry/types@^7.2.0": + version "7.13.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.13.0.tgz#398e33e5c92ea0ce91e2c86e3ab003fe00c471a2" + integrity sha512-ttckM1XaeyHRLMdr79wmGA5PFbTGx2jio9DCD/mkEpSfk6OGfqfC7gpwy7BNstDH/VKyQj/lDCJPnwvWqARMoQ== + "@sentry/utils@6.19.7": version "6.19.7" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.7.tgz#6edd739f8185fd71afe49cbe351c1bbf5e7b7c79" @@ -7638,6 +7643,11 @@ fetch-retry@^5.0.2: resolved "https://registry.yarnpkg.com/fetch-retry/-/fetch-retry-5.0.3.tgz#edfa3641892995f9afee94f25b168827aa97fe3d" integrity sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw== +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== + figgy-pudding@^3.5.1: version "3.5.2" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" @@ -11680,6 +11690,15 @@ postcss@^8.4.13: picocolors "^1.0.0" source-map-js "^1.0.2" +posthog-js@^1.29.0: + version "1.31.0" + resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.31.0.tgz#43ffb5a11948a5b10af75e749108936a07519963" + integrity sha512-d6vBb/ChS+t33voi37HA76etwWIukEcvJLZLZvkhJZcIrR29shwkAFUzd8lL7VdAelLlaAtmoPMwr820Yq5GUg== + dependencies: + "@sentry/types" "^7.2.0" + fflate "^0.4.1" + rrweb-snapshot "^1.1.14" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -12585,6 +12604,10 @@ rollup@^2.59.0: optionalDependencies: fsevents "~2.3.2" +rrweb-snapshot@^1.1.14: + version "1.1.14" + resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.14.tgz#9d4d9be54a28a893373428ee4393ec7e5bd83fcc" + integrity sha512-eP5pirNjP5+GewQfcOQY4uBiDnpqxNRc65yKPW0eSoU1XamDfc4M8oqpXGMyUyvLyxFDB0q0+DChuxxiU2FXBQ== rsvp@^4.8.2: version "4.8.5" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"