diff --git a/apps/reactotron-app/package.json b/apps/reactotron-app/package.json index 3c2e81f8d..6cb859c7e 100644 --- a/apps/reactotron-app/package.json +++ b/apps/reactotron-app/package.json @@ -59,6 +59,8 @@ "lodash.debounce": "^4.0.8", "react": "18.2.0", "react-dom": "18.2.0", + "react-fade-in": "^2.0.1", + "react-ga4": "^2.1.0", "react-hotkeys": "^2.0.0", "react-icons": "^4.11.0", "react-modal": "3.16.1", diff --git a/apps/reactotron-app/src/main/menu.ts b/apps/reactotron-app/src/main/menu.ts index 258e350fe..c86046e01 100644 --- a/apps/reactotron-app/src/main/menu.ts +++ b/apps/reactotron-app/src/main/menu.ts @@ -1,7 +1,5 @@ import { Menu, app, shell } from "electron" -import Store from "electron-store" - -const configStore = new Store() +import configStore from "../renderer/config" const isDarwin = process.platform === "darwin" diff --git a/apps/reactotron-app/src/renderer/App.tsx b/apps/reactotron-app/src/renderer/App.tsx index b5778c4b1..cf3351989 100644 --- a/apps/reactotron-app/src/renderer/App.tsx +++ b/apps/reactotron-app/src/renderer/App.tsx @@ -1,9 +1,10 @@ -import React from "react" +import React, { useEffect } from "react" import { HashRouter as Router, Route, Routes } from "react-router-dom" import styled from "styled-components" import SideBar from "./components/SideBar" import Footer from "./components/Footer" +import AnalyticsOptOut from "./components/AnalyticsOptOut" import RootContextProvider from "./contexts" import RootModals from "./RootModals" @@ -15,8 +16,9 @@ import Overlay from "./pages/reactNative/Overlay" import Storybook from "./pages/reactNative/Storybook" import CustomCommands from "./pages/customCommands" import Help from "./pages/help" +import { useAnalytics, usePageTracking } from "./util/analyticsHelpers" -const AppContainer = styled.div` +const AppContainerComponent = styled.div` position: absolute; top: 0; bottom: 0; @@ -28,6 +30,35 @@ const AppContainer = styled.div` background-color: ${(props) => props.theme.background}; ` +// This wrapper container is used to track page views within the app automatically using react-router-dom +// as well as to initialize analytics on app load. +const AppContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => { + usePageTracking() + const { initializeAnalytics } = useAnalytics() + + const [showsAnalyticsInterface, setShowsAnalyticsInterface] = React.useState(false) + + useEffect(() => { + const timer = setTimeout(() => { + const status = initializeAnalytics() + if (status === "unknown" && showsAnalyticsInterface === false) { + // Show the user the interface to enable/disable analytics + setShowsAnalyticsInterface(true) + } + }, 250) + return () => clearTimeout(timer) + }, [showsAnalyticsInterface]) + + return ( + + {children} + {showsAnalyticsInterface && ( + setShowsAnalyticsInterface(false)} /> + )} + + ) +} + const TopSection = styled.div` overflow: hidden; display: flex; diff --git a/apps/reactotron-app/src/renderer/KeybindHandler.tsx b/apps/reactotron-app/src/renderer/KeybindHandler.tsx index e5d20af8c..96be83a39 100644 --- a/apps/reactotron-app/src/renderer/KeybindHandler.tsx +++ b/apps/reactotron-app/src/renderer/KeybindHandler.tsx @@ -2,6 +2,7 @@ import React, { useContext } from "react" import { GlobalHotKeys, KeyEventName } from "react-hotkeys" import { ReactotronContext, StateContext, TimelineContext } from "reactotron-core-ui" import LayoutContext from "./contexts/Layout" +import { useAnalytics } from "./util/analyticsHelpers" const keyMap = { // Application wide @@ -94,26 +95,33 @@ function KeybindHandler({ children }) { const { openDispatchModal, openSubscriptionModal, clearCommands } = useContext(ReactotronContext) const { openSearch, toggleSearch } = useContext(TimelineContext) const { createSnapshot } = useContext(StateContext) + const { sendKeyboardShortcutAnalyticsEvent } = useAnalytics() const handlers = { // Tab Navigation OpenHomeTab: () => { window.location.hash = "/" + sendKeyboardShortcutAnalyticsEvent("OpenHomeTab") }, OpenTimelineTab: () => { window.location.hash = "/timeline" + sendKeyboardShortcutAnalyticsEvent("OpenTimelineTab") }, OpenStateTab: () => { window.location.hash = "/state/subscriptions" + sendKeyboardShortcutAnalyticsEvent("OpenStateTab") }, OpenReactNativeTab: () => { window.location.hash = "/native/overlay" + sendKeyboardShortcutAnalyticsEvent("OpenReactNativeTab") }, OpenCustomCommandsTab: () => { window.location.hash = "/customCommands" + sendKeyboardShortcutAnalyticsEvent("OpenCustomCommandsTab") }, OpenHelpTab: () => { window.location.hash = "/help" + sendKeyboardShortcutAnalyticsEvent("OpenHelpTab") }, // Modals @@ -128,11 +136,13 @@ function KeybindHandler({ children }) { }, TakeSnapshot: () => { createSnapshot() + sendKeyboardShortcutAnalyticsEvent("TakeSnapshot") }, // Miscellaneous ToggleSidebar: () => { toggleSideBar() + sendKeyboardShortcutAnalyticsEvent("ToggleSidebar") }, ToggleSearch: () => { // If we're on the timeline page, toggle the search, otherwise switch to the timeline tab and open search @@ -145,6 +155,7 @@ function KeybindHandler({ children }) { }, ClearTimeline: () => { clearCommands() + sendKeyboardShortcutAnalyticsEvent("ClearTimeline") }, } diff --git a/apps/reactotron-app/src/renderer/RootModals.tsx b/apps/reactotron-app/src/renderer/RootModals.tsx index 7c5194318..bc0e144d6 100644 --- a/apps/reactotron-app/src/renderer/RootModals.tsx +++ b/apps/reactotron-app/src/renderer/RootModals.tsx @@ -5,8 +5,10 @@ import { ReactotronContext, StateContext, } from "reactotron-core-ui" +import { useAnalytics } from "./util/analyticsHelpers" function RootModals() { + const { sendAnalyticsEvent } = useAnalytics() const { sendCommand, @@ -20,10 +22,6 @@ function RootModals() { } = useContext(ReactotronContext) const { addSubscription } = useContext(StateContext) - const dispatchAction = (action: any) => { - sendCommand("state.action.dispatch", { action }) - } - return ( <> { closeDispatchModal() + sendAnalyticsEvent({ + category: "dispatch", + action: "dispatchAbort", + }) + }} + onDispatchAction={(action: any) => { + sendCommand("state.action.dispatch", { action }) + sendAnalyticsEvent({ + category: "dispatch", + action: "dispatchConfirm", + }) }} - onDispatchAction={dispatchAction} isDarwin={window.process.platform === "darwin"} /> { closeSubscriptionModal() + sendAnalyticsEvent({ + category: "subscription", + action: "addAbort", + }) }} onAddSubscription={(path: string) => { // TODO: Get this out of here. closeSubscriptionModal() addSubscription(path) + sendAnalyticsEvent({ + category: "subscription", + action: "addConfirm", + }) }} /> diff --git a/apps/reactotron-app/src/renderer/components/AnalyticsOptOut/index.tsx b/apps/reactotron-app/src/renderer/components/AnalyticsOptOut/index.tsx new file mode 100644 index 000000000..2a510db91 --- /dev/null +++ b/apps/reactotron-app/src/renderer/components/AnalyticsOptOut/index.tsx @@ -0,0 +1,118 @@ +import React from "react" +import styled, { useTheme } from "styled-components" +import FadeIn from "react-fade-in" +import { reactotronAnalytics } from "../../images" +import configStore from "../../config" +import { useAnalytics } from "../../util/analyticsHelpers" + +const Overlay = styled.div` + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + background-color: ${(props) => props.theme.backgroundLighter}; + justify-content: center; + align-items: center; +` + +const AlertContainer = styled.div` + padding: 30px; + text-align: left; + border-radius: 10px; + background: ${(props) => props.theme.backgroundDarker}; + color: ${(props) => props.theme.foregroundLight}; + + box-shadow: 0 20px 75px ${(props) => props.theme.backgroundSubtleLight}; + width: 80%; + max-width: 500px; +` + +const AlertHeader = styled.div` + display: flex; + justify-content: center; + margin-bottom: 20px; +` + +const AlertHeaderImage = styled.img` + height: 128px; +` + +const ButtonGroup = styled.div` + display: flex; + flex-direction: column; + justify-content: flex-start; + margin-top: 20px; +` + +const Button = styled.button` + outline: none; + background: ${(props) => props.theme.backgroundLighter}; + border: none; + display: inline-block; + padding: 6px 18px; + color: ${(props) => props.theme.background}; + margin-right: 10px; + border-radius: 5px; + font-size: 16px; + cursor: pointer; +` + +// This is a custom alert that we use to ask the user if they want to opt-in to analytics +// We use this instead of the default alert because we want to style it to match our app. +const AnalyticsOptOut = ({ onClose }) => { + const theme = useTheme() + const { sendOptOutAnalyticsEvent } = useAnalytics() + + return ( + + + + + +

Opt in to Reactotron analytics?

+

Help us improve Reactotron!

+

+ We'd like to collect anonymous usage data to enhance Reactotron's performance + and features. This data includes general usage patterns and interactions. No personal + information will be collected. +

+

+ You can change this setting at any time and by opting in, you can contribute to making + Reactotron better for everyone! +

+

Would you like to participate?

+ + + + +
+
+ ) +} + +export default AnalyticsOptOut diff --git a/apps/reactotron-app/src/renderer/config.ts b/apps/reactotron-app/src/renderer/config.ts index e7114e5c8..2611d1cc3 100644 --- a/apps/reactotron-app/src/renderer/config.ts +++ b/apps/reactotron-app/src/renderer/config.ts @@ -1,6 +1,12 @@ -import Store from "electron-store" +import Store, { type Schema } from "electron-store" -const schema = { +type StoreType = { + serverPort: number, + commandHistory: number, + analyticsOptOut: string | boolean, +} + +const schema:Schema = { serverPort: { type: "number", default: 9090, @@ -9,16 +15,41 @@ const schema = { type: "number", default: 500, }, + analyticsOptOut: { + type: ["string", "boolean"], + default: "unknown", + }, } -const configStore = new Store({ schema } as any) +const configStore = new Store({ + schema, + defaults: { + serverPort: schema.serverPort.default, + commandHistory: schema.commandHistory.default, + analyticsOptOut: schema.analyticsOptOut.default, + }, + beforeEachMigration: (_store, context) => { + console.log(`[main-config] migrate from ${context.fromVersion} → ${context.toVersion}`) + }, + migrations: { + "1.0.0": (store) => { + // Added analyticsOptOut in order to track if the user has opted out of analytics + // and present the user with a blocking dialog box. + // When the user makes a choice, it will be set to "true" or "false" + store.set("analyticsOptOut", "unknown") + }, + }, +}) // Setup defaults if (!configStore.has("serverPort")) { - configStore.set("serverPort", 9090) + configStore.set("serverPort", schema.serverPort.default) } if (!configStore.has("commandHistory")) { - configStore.set("commandHistory", 500) + configStore.set("commandHistory", schema.commandHistory.default) +} +if (!configStore.has("analyticsOptOut")) { + configStore.set("analyticsOptOut", schema.analyticsOptOut.default) } export default configStore diff --git a/apps/reactotron-app/src/renderer/images/Reactotron-analytics.png b/apps/reactotron-app/src/renderer/images/Reactotron-analytics.png new file mode 100644 index 000000000..0727711a8 Binary files /dev/null and b/apps/reactotron-app/src/renderer/images/Reactotron-analytics.png differ diff --git a/apps/reactotron-app/src/renderer/images/index.ts b/apps/reactotron-app/src/renderer/images/index.ts index cc92bf95b..567e4ad2d 100644 --- a/apps/reactotron-app/src/renderer/images/index.ts +++ b/apps/reactotron-app/src/renderer/images/index.ts @@ -1,3 +1,4 @@ export const reactotronLogo: string = require("./Reactotron-128.png").default +export const reactotronAnalytics: string = require("./Reactotron-analytics.png").default export const storybookActiveImg: string = require("./storybook-logo-color.png").default export const storybookInactiveImg: string = require("./storybook-logo.png").default diff --git a/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx b/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx index b297826ee..8c9c69b9c 100644 --- a/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx +++ b/apps/reactotron-app/src/renderer/pages/customCommands/index.tsx @@ -5,6 +5,7 @@ import styled from "styled-components" import { MdSearch } from "react-icons/md" import { FaMagic } from "react-icons/fa" import { produce } from "immer" +import { useAnalytics } from "../../util/analyticsHelpers" const Container = styled.div` display: flex; @@ -113,6 +114,7 @@ function CustomCommandItem({ customCommand: CustomCommand sendCustomCommand: (command: any, args: any) => void }) { + const { sendCustomCommandAnalyticsEvent } = useAnalytics() const [state, dispatch] = useReducer(customCommandItemReducer, customCommand.args, (args) => { if (!args) return {} @@ -157,6 +159,7 @@ function CustomCommandItem({ { sendCustomCommand(customCommand.command, state) + sendCustomCommandAnalyticsEvent(customCommand.command) }} > Send Command diff --git a/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx b/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx index b64e53cab..6276193ce 100644 --- a/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx +++ b/apps/reactotron-app/src/renderer/pages/help/components/AndroidDeviceHelp.tsx @@ -8,6 +8,12 @@ import { IoReloadOutline as ReloadAppIcon } from "react-icons/io5" import { EmptyState, Tooltip } from "reactotron-core-ui" import { FaAndroid } from "react-icons/fa" import { ItemContainer, ItemIconContainer } from "../SharedStyles" +import { useAnalytics } from "../../../util/analyticsHelpers" + +interface IAndroidDevice { + id: string + state: string +} const Container = styled.div` margin: 50px 0px; @@ -101,10 +107,11 @@ const PortSettingsIconContainer = styled.div` ` function AndroidDeviceHelp() { - const [androidDevices, setAndroidDevices] = React.useState([]) + const [androidDevices, setAndroidDevices] = React.useState([]) const [portsVisible, setPortsVisible] = React.useState(false) const [reactotronPort, setReactotronPort] = React.useState("9090") const [metroPort, setMetroPort] = React.useState("8081") + const { sendAnalyticsEvent } = useAnalytics() // When the page loads, get the list of devices from ADB to help users debug android issues. React.useEffect(() => { @@ -112,7 +119,7 @@ function AndroidDeviceHelp() { arg = arg.replace(/(\r\n|\n|\r)/gm, "\n").trim() // Fix newlines const rawDevices = arg.split("\n") rawDevices.shift() // Remove the first line - const devices = rawDevices.map((device) => { + const devices: IAndroidDevice[] = rawDevices.map((device) => { const [id, state] = device.split("\t") return { id, state } }) @@ -142,7 +149,14 @@ function AndroidDeviceHelp() { setPortsVisible(!portsVisible)} + onClick={() => { + setPortsVisible(!portsVisible) + sendAnalyticsEvent({ + category: "android", + action: "settings", + label: portsVisible ? "close" : "open", + }) + }} > @@ -210,6 +224,7 @@ const AndroidDeviceList = ({ reactotronPort: string metroPort: string }) => { + const { sendAnalyticsEvent } = useAnalytics() return ( <> {devices.map((device) => ( @@ -217,9 +232,13 @@ const AndroidDeviceList = ({ {device.id} + onClick={() => { ipcRenderer.send("reverse-tunnel-device", device.id, reactotronPort, metroPort) - } + sendAnalyticsEvent({ + category: "android", + action: "reverse-tunnel", + }) + }} data-tip={`This will allow reactotron to connect to your device via USB
by running adb reverse tcp:${reactotronPort} tcp:${reactotronPort}

Reload your React Native app after pressing this.`} data-for="reverse-tunnel" > @@ -230,7 +249,13 @@ const AndroidDeviceList = ({
ipcRenderer.send("reload-app", device.id)} + onClick={() => { + ipcRenderer.send("reload-app", device.id) + sendAnalyticsEvent({ + category: "android", + action: "reload-app", + }) + }} data-tip="This will reload the React Native app currently running on this device.
If you get the React Native red screen, relaunch the app from the build process." data-for="reload-app" > @@ -241,7 +266,13 @@ const AndroidDeviceList = ({
ipcRenderer.send("shake-device", device.id)} + onClick={() => { + ipcRenderer.send("shake-device", device.id) + sendAnalyticsEvent({ + category: "android", + action: "shake-device", + }) + }} data-tip="This will shake the device to bring up
the React Native developer menu." data-for="shake-device" > diff --git a/apps/reactotron-app/src/renderer/pages/help/index.tsx b/apps/reactotron-app/src/renderer/pages/help/index.tsx index 6f9e661f9..572020e72 100644 --- a/apps/reactotron-app/src/renderer/pages/help/index.tsx +++ b/apps/reactotron-app/src/renderer/pages/help/index.tsx @@ -12,8 +12,8 @@ import { getApplicationKeyMap } from "react-hotkeys" import { ItemContainer, ItemIconContainer } from "./SharedStyles" import KeybindGroup from "./components/KeybindGroup" import { reactotronLogo } from "../../images" - -const projectJson = require("../../../../package.json") +import { useAnalytics } from "../../util/analyticsHelpers" +import projectJson from "../../../../package.json" const Container = styled.div` display: flex; @@ -85,6 +85,8 @@ function Keybinds() { } function Help() { + const { sendExternalLinkAnalyticsEvent } = useAnalytics() + return (
@@ -94,25 +96,45 @@ function Help() { Let's Connect! - + { + openRepo() + sendExternalLinkAnalyticsEvent("repo") + }} + > GitHub Docs - + { + openFeedback() + sendExternalLinkAnalyticsEvent("feedback") + }} + > Feedback - + { + openUpdates() + sendExternalLinkAnalyticsEvent("updates") + }} + > Updates - + { + openTwitter() + sendExternalLinkAnalyticsEvent("twitter") + }} + > diff --git a/apps/reactotron-app/src/renderer/pages/home/welcome.tsx b/apps/reactotron-app/src/renderer/pages/home/welcome.tsx index d102e31ca..0ded74427 100644 --- a/apps/reactotron-app/src/renderer/pages/home/welcome.tsx +++ b/apps/reactotron-app/src/renderer/pages/home/welcome.tsx @@ -3,6 +3,7 @@ import { shell } from "electron" import styled from "styled-components" import { reactotronLogo } from "../../images" import { EmptyState } from "reactotron-core-ui" +import { useAnalytics } from "../../util/analyticsHelpers" const WelcomeText = styled.div` font-size: 1.25em; @@ -26,11 +27,15 @@ function openDocs() { } function Welcome() { + const { sendExternalLinkAnalyticsEvent } = useAnalytics() + return ( Connect a device or simulator to get started. Need to set up your app to use Reactotron? - Check out the docs here! + { + sendExternalLinkAnalyticsEvent("docs") + openDocs()}}>Check out the docs here! ) } diff --git a/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx b/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx index f06c73bd5..224701083 100644 --- a/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx +++ b/apps/reactotron-app/src/renderer/pages/reactNative/Overlay.tsx @@ -17,6 +17,7 @@ import { OverlayMargins } from "./components/OverlayMargins" import type { DragEvent } from "react" import type { JustifyContent, AlignItems } from "./components/OverlayAlignment" import type { ResizeMode } from "./components/OverlayResizeMode" +import { useAnalytics } from "../../util/analyticsHelpers" const isDevelopment = process.env.NODE_ENV !== "production" @@ -54,6 +55,7 @@ const ReapplyContainer = styled.div` ` function Overlay() { + const { sendAnalyticsEvent } = useAnalytics() const { overlayParams, updateOverlayParams } = useContext(ReactNativeContext) const { uri, @@ -123,8 +125,18 @@ function Overlay() { event.preventDefault() event.stopPropagation() if (event.dataTransfer.files.length !== 1) { + sendAnalyticsEvent({ + category: "error", + action: "OverlayDropImage", + label: "Too many files", + }) return } + sendAnalyticsEvent({ + category: "overlay", + action: "OverlayDropImage", + label: "Success", + }) const file = event.dataTransfer.files[0] importFile(file.path) } @@ -144,6 +156,11 @@ function Overlay() { alignItems: "center", justifyContent: "center", }) + sendAnalyticsEvent({ + category: "overlay", + action: "OverlayRemoveImage", + label: "Success", + }) } function handlePreventDefault(event: DragEvent) { @@ -200,6 +217,11 @@ function Overlay() { event.stopPropagation() event.preventDefault() updateOverlayParams({ showDebug: newShowDebug }) + sendAnalyticsEvent({ + category: "overlay", + action: "OverlayShowDebug", + label: newShowDebug ? "On" : "Off", + }) } function renderDropZone() { diff --git a/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx b/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx index 3e3605e83..99efc2ac7 100644 --- a/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx +++ b/apps/reactotron-app/src/renderer/pages/reactNative/Storybook.tsx @@ -9,6 +9,7 @@ import { MdWarning, } from "react-icons/md" import { storybookActiveImg, storybookInactiveImg } from "../../images" +import { useAnalytics } from "../../util/analyticsHelpers" const Container = styled.div` display: flex; @@ -60,6 +61,7 @@ const WarningDescription = styled.div` ` function Storybook() { + const { sendAnalyticsEvent } = useAnalytics() const { isStorybookOn, turnOffStorybook, turnOnStorybook } = useContext(ReactNativeContext) return ( @@ -121,7 +123,16 @@ function Storybook() { - turnOnStorybook()}> + { + turnOnStorybook() + sendAnalyticsEvent({ + category: "storybook", + action: "ToggleStorybook", + label: "On", + }) + }} + > {isStorybookOn ? ( ) : ( @@ -129,7 +140,16 @@ function Storybook() { )}
On
- turnOffStorybook()}> + { + turnOffStorybook() + sendAnalyticsEvent({ + category: "storybook", + action: "ToggleStorybook", + label: "Off", + }) + }} + > {isStorybookOn ? ( ) : ( diff --git a/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx b/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx index f587dbd21..eebb04217 100644 --- a/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx +++ b/apps/reactotron-app/src/renderer/pages/state/Snapshots.tsx @@ -19,6 +19,7 @@ import { MdCallReceived, MdFileDownload, } from "react-icons/md" +import { useAnalytics } from "../../util/analyticsHelpers" const Container = styled.div` display: flex; @@ -68,6 +69,7 @@ function SnapshotItem({ removeSnapshot: (snapshot: Snapshot) => void openSnapshotRenameModal: (snapshot: Snapshot) => void }) { + const { sendAnalyticsEvent } = useAnalytics() const [isOpen, setIsOpen] = useState(false) return ( @@ -84,6 +86,10 @@ function SnapshotItem({ onClick={(e) => { e.stopPropagation() clipboard.writeText(JSON.stringify(snapshot)) + sendAnalyticsEvent({ + category: "snapshot", + action: "copy", + }) }} > @@ -95,6 +101,10 @@ function SnapshotItem({ onClick={(e) => { e.stopPropagation() restoreSnapshot(snapshot) + sendAnalyticsEvent({ + category: "snapshot", + action: "restore", + }) }} > @@ -117,6 +127,10 @@ function SnapshotItem({ onClick={(e) => { e.stopPropagation() removeSnapshot(snapshot) + sendAnalyticsEvent({ + category: "snapshot", + action: "remove", + }) }} > @@ -133,6 +147,7 @@ function SnapshotItem({ } function Snapshots() { + const { sendAnalyticsEvent } = useAnalytics() const { snapshots, createSnapshot, @@ -173,6 +188,10 @@ function Snapshots() { icon: MdCallReceived, onClick: () => { clipboard.writeText(JSON.stringify(snapshots)) + sendAnalyticsEvent({ + category: "snapshot", + action: "copy", + }) }, }, { @@ -180,6 +199,10 @@ function Snapshots() { icon: MdFileDownload, onClick: () => { createSnapshot() + sendAnalyticsEvent({ + category: "snapshot", + action: "add", + }) }, }, ]} diff --git a/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx b/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx index b7c8d87f8..c63024497 100644 --- a/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx +++ b/apps/reactotron-app/src/renderer/pages/state/Subscriptions.tsx @@ -13,6 +13,7 @@ import { getApplicationKeyMap } from "react-hotkeys" // Move this out of this page. We are just hacking around this for now import { KeybindKeys, getPlatformSequence } from "../help/components/Keybind" +import { useAnalytics } from "../../util/analyticsHelpers" const Container = styled.div` display: flex; @@ -57,6 +58,7 @@ function getLatestChanges(commands: any[]) { } function Subscriptions() { + const { sendAnalyticsEvent } = useAnalytics() const { commands, openSubscriptionModal } = useContext(ReactotronContext) const { removeSubscription, clearSubscriptions } = useContext(StateContext) @@ -96,6 +98,10 @@ function Subscriptions() { icon: MdAdd, onClick: () => { openSubscriptionModal() + sendAnalyticsEvent({ + category: "subscription", + action: "add", + }) }, }, { @@ -103,6 +109,10 @@ function Subscriptions() { icon: MdDeleteSweep, onClick: () => { clearSubscriptions() + sendAnalyticsEvent({ + category: "subscription", + action: "clear", + }) }, }, ]} diff --git a/apps/reactotron-app/src/renderer/pages/timeline/index.tsx b/apps/reactotron-app/src/renderer/pages/timeline/index.tsx index 3051a66d5..b0faa7ce5 100644 --- a/apps/reactotron-app/src/renderer/pages/timeline/index.tsx +++ b/apps/reactotron-app/src/renderer/pages/timeline/index.tsx @@ -14,6 +14,7 @@ import { import { MdSearch, MdDeleteSweep, MdFilterList, MdSwapVert, MdReorder } from "react-icons/md" import { FaTimes } from "react-icons/fa" import styled from "styled-components" +import { useAnalytics } from "../../util/analyticsHelpers" const Container = styled.div` display: flex; @@ -54,6 +55,7 @@ export const ButtonContainer = styled.div` ` function Timeline() { + const { sendAnalyticsEvent } = useAnalytics() const { sendCommand, clearCommands, commands, openDispatchModal } = useContext(ReactotronContext) const { isSearchOpen, @@ -93,6 +95,11 @@ function Timeline() { icon: MdSearch, onClick: () => { toggleSearch() + sendAnalyticsEvent({ + category: "timeline", + action: "search", + label: isSearchOpen ? "open" : "close", + }) }, }, { @@ -100,6 +107,11 @@ function Timeline() { icon: MdFilterList, onClick: () => { openFilter() + sendAnalyticsEvent({ + category: "timeline", + action: "filter", + label: isFilterOpen ? "open" : "close", + }) }, }, { @@ -107,6 +119,11 @@ function Timeline() { icon: MdSwapVert, onClick: () => { toggleReverse() + sendAnalyticsEvent({ + category: "timeline", + action: "reverse", + label: isReversed ? "on" : "off", + }) }, }, { @@ -114,6 +131,10 @@ function Timeline() { icon: MdDeleteSweep, onClick: () => { clearCommands() + sendAnalyticsEvent({ + category: "timeline", + action: "clear", + }) }, }, ]} @@ -174,6 +195,11 @@ function Timeline() { isOpen={isFilterOpen} onClose={() => { closeFilter() + sendAnalyticsEvent({ + category: "timeline", + action: "filter", + label: "close", + }) }} hiddenCommands={hiddenCommands} setHiddenCommands={setHiddenCommands} diff --git a/apps/reactotron-app/src/renderer/util/analyticsHelpers.tsx b/apps/reactotron-app/src/renderer/util/analyticsHelpers.tsx new file mode 100644 index 000000000..8522cb194 --- /dev/null +++ b/apps/reactotron-app/src/renderer/util/analyticsHelpers.tsx @@ -0,0 +1,191 @@ +import React from "react" +import ReactGA from "react-ga4" +import packageJson from "../../../package.json" +import { useLocation } from "react-router" +import configStore from "../config" + +// This is the Google Analytics 4 key for Reactotron +// TODO: Change this to the correct key for production. +const GA4_KEY = "G-WZE3E5XCQ7" + +type IAnalyticsEventCategory = + | { category: "opt-out"; actions: ["opt-out"] } + | { category: "android"; actions: ["settings", "reverse-tunnel", "reload-app", "shake-device"] } + | { category: "navigation"; actions: ["keyboard_shortcut"] } + | { category: "external_link"; actions: ["click"] } + | { category: "timeline"; actions: ["search", "filter", "reverse", "clear"] } + | { category: "error"; actions: ["OverlayDropImage"] } + | { category: "overlay"; actions: ["OverlayDropImage", "OverlayRemoveImage", "OverlayShowDebug"] } + | { category: "dispatch"; actions: ["dispatchAbort", "dispatchConfirm"] } + | { category: "subscription"; actions: ["addAbort", "addConfirm", "add", "clear"] } + | { category: "snapshot"; actions: ["copy", "restore", "remove", "copy", "add"] } + | { category: "storybook"; actions: ["ToggleStorybook"] } + | { category: "custom_command"; actions: ["sendCommand"] } + +// I had trouble importing this type from the react-ga4 package, so I'm defining it here. +type UaEventOptions = { + category: IAnalyticsEventCategory["category"] + action: IAnalyticsEventCategory["actions"][number] + label?: string + value?: number + nonInteraction?: boolean + transport?: "beacon" | "xhr" | "image" +} + +// Our user's opt-out status can be one of these three values. +// Analytics will never be initialized if the user has opted out or if the status is unknown. +type IOptOutStatus = "unknown" | true | false + +// This is the main analytics hook that we use throughout the app. +// It handles initializing analytics, sending events, and tracking page views. +// It also handles the user's opt-out status. +// We use a custom alert to ask the user if they want to opt-in to analytics. +export const useAnalytics = () => { + const [initialized, setInitialized] = React.useState(false) + const [optedOut, setOptedOut] = React.useState("unknown") + + React.useEffect(() => { + const storeWatcher = configStore.onDidChange("analyticsOptOut", (newValue) => { + console.log("[analytics] user has changed opt-out status", newValue) + setOptedOut(newValue as IOptOutStatus) + }) + return () => { + storeWatcher().removeAllListeners() + } + }, []) + + // Get the user's opt-out status from the config store + const initializeAnalytics = () => { + const status = configStore.get("analyticsOptOut") as IOptOutStatus + + if (status === "unknown") { + console.log(`[analytics] user has not opted in or out`) + } else { + // If the user has opted out, we'll disable analytics + setOptedOut(status) + setInitialized(false) + console.log(`[analytics] user has opted ${status ? "out" : "in"}`) + } + + return status + } + + // Initialize analytics and set some system data like the app version and platform + // as well as the mode we are running in. We don't want to send analytics events + // during tests, so we disable them if we are running in test mode. + // We also disable analytics if the user has opted out. + React.useEffect(() => { + const initialize = () => { + const testMode = process.env.NODE_ENV === "test" // we don't want to send analytics events during tests + ReactGA.initialize(GA4_KEY, { testMode: testMode || optedOut === true }) + !optedOut && + ReactGA.set({ + app_version: packageJson.version, + app_platform: process.platform, + app_arch: process.arch, + app_mode: process.env.NODE_ENV, + }) + } + + if (!initialized) { + initialize() + setInitialized(true) + } + }, [initialized, optedOut]) + + // Send an analytics event + // This is the main function we use to send events throughout the app. + // See documentation here for how to use react-ga4: + // https://github.com/codler/react-ga4 + const sendAnalyticsEvent = React.useCallback( + (event: UaEventOptions) => { + if (!optedOut) { + console.log("[analytics] Sending event", event) + ReactGA.event(event) + } + }, + [optedOut] + ) + + // Send a page view event + const sendPageViewAnalyticsEvent = React.useCallback( + (page: string) => { + if (!optedOut) { + console.log("[analytics] Sending page view event", page) + ReactGA.send({ hitType: "pageview", page }) + } + }, + [optedOut] + ) + + // Send a keyboard shortcut event + const sendKeyboardShortcutAnalyticsEvent = React.useCallback( + (label: string) => { + sendAnalyticsEvent({ + category: "navigation", + action: "keyboard_shortcut", + nonInteraction: false, + label, + }) + }, + [sendAnalyticsEvent] + ) + + // Send a custom command event + const sendCustomCommandAnalyticsEvent = React.useCallback( + (command: string) => { + sendAnalyticsEvent({ + category: "custom_command", + action: "sendCommand", + nonInteraction: false, + label: command, + }) + }, + [sendAnalyticsEvent] + ) + + // Send an external link event + const sendExternalLinkAnalyticsEvent = React.useCallback( + (label: string) => { + sendAnalyticsEvent({ + category: "external_link", + action: "click", + nonInteraction: false, + label, + }) + }, + [sendAnalyticsEvent] + ) + + const sendOptOutAnalyticsEvent = React.useCallback(() => { + const event = { + category: "opt-out", + action: "opt-out", + nonInteraction: false, + } + console.log("[analytics] Sending opt-out event", event) + ReactGA.event(event) // this is the only time we send an event without checking the optedOut status + }, []) + + return { + initializeAnalytics, + sendAnalyticsEvent, + sendPageViewAnalyticsEvent, + sendKeyboardShortcutAnalyticsEvent, + sendCustomCommandAnalyticsEvent, + sendExternalLinkAnalyticsEvent, + sendOptOutAnalyticsEvent, + } +} + +// This hook is used to track page views within the app automatically using react-router-dom +// This hook should only be used one time in the app, near the root of the component tree +// but inside the HashRouter. +export const usePageTracking = () => { + const location = useLocation() + const { sendPageViewAnalyticsEvent } = useAnalytics() + + React.useEffect(() => { + sendPageViewAnalyticsEvent(location.pathname) + }, [location, sendPageViewAnalyticsEvent]) +} diff --git a/apps/reactotron-app/tsconfig.json b/apps/reactotron-app/tsconfig.json index 7014afeeb..e5fc8c2d5 100644 --- a/apps/reactotron-app/tsconfig.json +++ b/apps/reactotron-app/tsconfig.json @@ -18,6 +18,7 @@ "types": ["jest"], "skipLibCheck": true, "baseUrl": ".", + "resolveJsonModule": true, }, "exclude": ["node_modules"], "include": ["src"], diff --git a/yarn.lock b/yarn.lock index d739692c9..f81f82349 100644 --- a/yarn.lock +++ b/yarn.lock @@ -751,18 +751,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-flow@npm:^7.0.0, @babel/plugin-syntax-flow@npm:^7.24.1": - version: 7.24.1 - resolution: "@babel/plugin-syntax-flow@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/87dfe32f3a3ea77941034fb2a39fdfc9ea18a994b8df40c3659a11c8787b2bc5adea029259c4eafc03cd35f11628f6533aa2a06381db7fcbe3b2cc3c2a2bb54f - languageName: node - linkType: hard - -"@babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.18.0, @babel/plugin-syntax-flow@npm:^7.23.3": +"@babel/plugin-syntax-flow@npm:^7.0.0, @babel/plugin-syntax-flow@npm:^7.12.1, @babel/plugin-syntax-flow@npm:^7.18.0, @babel/plugin-syntax-flow@npm:^7.23.3": version: 7.23.3 resolution: "@babel/plugin-syntax-flow@npm:7.23.3" dependencies: @@ -817,18 +806,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-syntax-jsx@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/712f7e7918cb679f106769f57cfab0bc99b311032665c428b98f4c3e2e6d567601d45386a4f246df6a80d741e1f94192b3f008800d66c4f1daae3ad825c243f0 - languageName: node - linkType: hard - -"@babel/plugin-syntax-jsx@npm:^7.23.3, @babel/plugin-syntax-jsx@npm:^7.7.2": +"@babel/plugin-syntax-jsx@npm:^7.0.0, @babel/plugin-syntax-jsx@npm:^7.23.3, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.23.3 resolution: "@babel/plugin-syntax-jsx@npm:7.23.3" dependencies: @@ -988,18 +966,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-block-scoped-functions@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/d8e18bd57b156da1cd4d3c1780ab9ea03afed56c6824ca8e6e74f67959d7989a0e953ec370fe9b417759314f2eef30c8c437395ce63ada2e26c2f469e4704f82 - languageName: node - linkType: hard - -"@babel/plugin-transform-block-scoped-functions@npm:^7.23.3": +"@babel/plugin-transform-block-scoped-functions@npm:^7.0.0, @babel/plugin-transform-block-scoped-functions@npm:^7.23.3": version: 7.23.3 resolution: "@babel/plugin-transform-block-scoped-functions@npm:7.23.3" dependencies: @@ -1076,18 +1043,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-destructuring@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-transform-destructuring@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/03d9a81cd9eeb24d48e207be536d460d6ad228238ac70da9b7ad4bae799847bb3be0aecfa4ea6223752f3a8d4ada3a58cd9a0f8fc70c01fdfc87ad0618f897d3 - languageName: node - linkType: hard - -"@babel/plugin-transform-destructuring@npm:^7.12.1, @babel/plugin-transform-destructuring@npm:^7.20.0, @babel/plugin-transform-destructuring@npm:^7.23.3": +"@babel/plugin-transform-destructuring@npm:^7.0.0, @babel/plugin-transform-destructuring@npm:^7.12.1, @babel/plugin-transform-destructuring@npm:^7.20.0, @babel/plugin-transform-destructuring@npm:^7.23.3": version: 7.23.3 resolution: "@babel/plugin-transform-destructuring@npm:7.23.3" dependencies: @@ -1169,19 +1125,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-flow-strip-types@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-transform-flow-strip-types@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - "@babel/plugin-syntax-flow": "npm:^7.24.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/6e1db557d7d34a8dbfdf430557f47c75930a9044b838bb3cc706f9c816e11cd68a61c68239478dd05bbe3ec197113ad0c22c5be1bdddac8723040dd9e9cb9dc0 - languageName: node - linkType: hard - -"@babel/plugin-transform-flow-strip-types@npm:^7.20.0, @babel/plugin-transform-flow-strip-types@npm:^7.23.3": +"@babel/plugin-transform-flow-strip-types@npm:^7.0.0, @babel/plugin-transform-flow-strip-types@npm:^7.20.0, @babel/plugin-transform-flow-strip-types@npm:^7.23.3": version: 7.23.3 resolution: "@babel/plugin-transform-flow-strip-types@npm:7.23.3" dependencies: @@ -1193,19 +1137,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-for-of@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-transform-for-of@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - "@babel/helper-skip-transparent-expression-wrappers": "npm:^7.22.5" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/befd0908c3f6b31f9fa9363a3c112d25eaa0bc4a79cfad1f0a8bb5010937188b043a44fb23443bc8ffbcc40c015bb25f80e4cc585ce5cc580708e2d56e76fe37 - languageName: node - linkType: hard - -"@babel/plugin-transform-for-of@npm:^7.12.1, @babel/plugin-transform-for-of@npm:^7.23.6": +"@babel/plugin-transform-for-of@npm:^7.0.0, @babel/plugin-transform-for-of@npm:^7.12.1, @babel/plugin-transform-for-of@npm:^7.23.6": version: 7.23.6 resolution: "@babel/plugin-transform-for-of@npm:7.23.6" dependencies: @@ -1265,18 +1197,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-member-expression-literals@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-transform-member-expression-literals@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/4ea641cc14a615f9084e45ad2319f95e2fee01c77ec9789685e7e11a6c286238a426a98f9c1ed91568a047d8ac834393e06e8c82d1ff01764b7aa61bee8e9023 - languageName: node - linkType: hard - -"@babel/plugin-transform-member-expression-literals@npm:^7.23.3": +"@babel/plugin-transform-member-expression-literals@npm:^7.0.0, @babel/plugin-transform-member-expression-literals@npm:^7.23.3": version: 7.23.3 resolution: "@babel/plugin-transform-member-expression-literals@npm:7.23.3" dependencies: @@ -1425,19 +1346,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-object-super@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-transform-object-super@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - "@babel/helper-replace-supers": "npm:^7.24.1" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/d34d437456a54e2a5dcb26e9cf09ed4c55528f2a327c5edca92c93e9483c37176e228d00d6e0cf767f3d6fdbef45ae3a5d034a7c59337a009e20ae541c8220fa - languageName: node - linkType: hard - -"@babel/plugin-transform-object-super@npm:^7.23.3": +"@babel/plugin-transform-object-super@npm:^7.0.0, @babel/plugin-transform-object-super@npm:^7.23.3": version: 7.23.3 resolution: "@babel/plugin-transform-object-super@npm:7.23.3" dependencies: @@ -1548,18 +1457,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-property-literals@npm:^7.0.0": - version: 7.24.1 - resolution: "@babel/plugin-transform-property-literals@npm:7.24.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.24.0" - peerDependencies: - "@babel/core": ^7.0.0-0 - checksum: 10/a73646d7ecd95b3931a3ead82c7d5efeb46e68ba362de63eb437d33531f294ec18bd31b6d24238cd3b6a3b919a6310c4a0ba4a2629927721d4d10b0518eb7715 - languageName: node - linkType: hard - -"@babel/plugin-transform-property-literals@npm:^7.23.3": +"@babel/plugin-transform-property-literals@npm:^7.0.0, @babel/plugin-transform-property-literals@npm:^7.23.3": version: 7.23.3 resolution: "@babel/plugin-transform-property-literals@npm:7.23.3" dependencies: @@ -23036,6 +22934,15 @@ __metadata: languageName: node linkType: hard +"react-fade-in@npm:^2.0.1": + version: 2.0.1 + resolution: "react-fade-in@npm:2.0.1" + peerDependencies: + react: ^16.8 || 17 + checksum: 10/34e6ddf1641417e170a268b07b514d3207603b14a56bc9836372828b72782df16e51d3bd066a271e6f6c7e2bf2916b100b72b06dd20a5447d965b16f78160cd5 + languageName: node + linkType: hard + "react-fast-compare@npm:^3.0.1, react-fast-compare@npm:^3.2.0": version: 3.2.2 resolution: "react-fast-compare@npm:3.2.2" @@ -23072,6 +22979,13 @@ __metadata: languageName: node linkType: hard +"react-ga4@npm:^2.1.0": + version: 2.1.0 + resolution: "react-ga4@npm:2.1.0" + checksum: 10/e47edd040b7a272d35df732e08c4a8b6b14add659db04fb81a3a2cbde59bf34dc6eae9e0b8b4abd8937d11c837954773a6ba86feb2c605e5eb916f4c99e420c7 + languageName: node + linkType: hard + "react-helmet-async@npm:^1.0.2": version: 1.3.0 resolution: "react-helmet-async@npm:1.3.0" @@ -23759,6 +23673,8 @@ __metadata: prettier: "npm:^3.0.3" react: "npm:18.2.0" react-dom: "npm:18.2.0" + react-fade-in: "npm:^2.0.1" + react-ga4: "npm:^2.1.0" react-hotkeys: "npm:^2.0.0" react-icons: "npm:^4.11.0" react-modal: "npm:3.16.1"