From 67a1c9d761bdd99d308bbc198d50b8204176dc23 Mon Sep 17 00:00:00 2001 From: Urvashi Sharma Date: Thu, 6 Feb 2025 16:32:20 +0530 Subject: [PATCH] feat(analytics): Implement usage analytics with react-ga4 --- package.json | 2 +- src/components/App/App.test.tsx | 20 +++++++++-------- src/components/App/App.tsx | 9 +++++--- .../CaptureRoutes/CaptureRoutes.test.tsx | 9 +++++--- src/components/LogIn/LogIn.tsx | 22 +++++++++++++++++++ src/hooks/useAnalytics.test.ts | 20 +++++++++++------ src/hooks/useAnalytics.tsx | 15 +++++++++---- src/testing/setup.ts | 2 +- yarn.lock | 13 +++++------ 9 files changed, 76 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index c6704891a..eab0b659b 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "prismjs": "1.29.0", "react": "18.3.1", "react-dom": "18.3.1", - "react-ga": "3.3.1", + "react-ga4": "^2.1.0", "react-hot-toast": "2.5.1", "react-json-tree": "0.19.0", "react-redux": "9.2.0", diff --git a/src/components/App/App.test.tsx b/src/components/App/App.test.tsx index 39252e7a1..74454c0af 100644 --- a/src/components/App/App.test.tsx +++ b/src/components/App/App.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from "@testing-library/react"; -import * as reactGA from "react-ga"; +import ReactGA from "react-ga4"; import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; import { vi } from "vitest"; @@ -14,9 +14,8 @@ vi.mock("components/Routes", () => ({ default: vi.fn(), })); -vi.mock("react-ga", () => ({ - initialize: vi.fn(), - pageview: vi.fn(), +vi.mock("react-ga4", () => ({ + default: { initialize: vi.fn(), send: vi.fn() }, })); vi.mock("react-router", async () => { @@ -73,8 +72,8 @@ describe("App", () => { it("sends pageview events", () => { vi.stubEnv("PROD", true); window.happyDOM.setURL("/models"); - const initializeSpy = vi.spyOn(reactGA, "initialize"); - const pageviewSpy = vi.spyOn(reactGA, "pageview"); + const initializeSpy = vi.spyOn(ReactGA, "initialize"); + const pageviewSpy = vi.spyOn(ReactGA, "send"); const state = rootStateFactory.withGeneralConfig().build(); const store = mockStore(state); render( @@ -83,14 +82,17 @@ describe("App", () => { , ); expect(initializeSpy).toHaveBeenCalled(); - expect(pageviewSpy).toHaveBeenCalledWith("/models"); + expect(pageviewSpy).toHaveBeenCalledWith({ + hitType: "page_view", + page: "/models", + }); }); it("does not send pageview events if analytics is disabled", () => { vi.stubEnv("PROD", true); window.happyDOM.setURL("/models"); - const initializeSpy = vi.spyOn(reactGA, "initialize"); - const pageviewSpy = vi.spyOn(reactGA, "pageview"); + const initializeSpy = vi.spyOn(ReactGA, "initialize"); + const pageviewSpy = vi.spyOn(ReactGA, "send"); const state = rootStateFactory.build({ general: generalStateFactory.build({ config: configFactory.build({ diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index 46e6fa219..781f0b0e1 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -1,4 +1,4 @@ -import { initialize, pageview } from "react-ga"; +import ReactGA from "react-ga4"; import ConnectionError from "components/ConnectionError"; import ErrorBoundary from "components/ErrorBoundary"; @@ -12,8 +12,11 @@ function App() { const isProduction = import.meta.env.PROD; const analyticsEnabled = useAppSelector(getAnalyticsEnabled); if (isProduction && analyticsEnabled) { - initialize("UA-1018242-68"); - pageview(window.location.href.replace(window.location.origin, "")); + ReactGA.initialize("UA-1018242-68"); // TODO: should use the GA4 Measurement ID (which starts with "G-") + ReactGA.send({ + hitType: "page_view", + page: window.location.href.replace(window.location.origin, ""), + }); } return ( diff --git a/src/components/CaptureRoutes/CaptureRoutes.test.tsx b/src/components/CaptureRoutes/CaptureRoutes.test.tsx index 99e6a7c70..7804e14f0 100644 --- a/src/components/CaptureRoutes/CaptureRoutes.test.tsx +++ b/src/components/CaptureRoutes/CaptureRoutes.test.tsx @@ -1,4 +1,4 @@ -import * as reactGA from "react-ga"; +import ReactGA from "react-ga4"; import { BrowserRouter, Route, Routes } from "react-router"; import type { MockInstance } from "vitest"; import { vi } from "vitest"; @@ -17,7 +17,7 @@ describe("CaptureRoutes", () => { beforeEach(() => { vi.stubEnv("PROD", true); - pageviewSpy = vi.spyOn(reactGA, "pageview"); + pageviewSpy = vi.spyOn(ReactGA, "send"); state = rootStateFactory.build({ general: generalStateFactory.build({ config: configFactory.build({ @@ -43,6 +43,9 @@ describe("CaptureRoutes", () => { , ); - expect(pageviewSpy).toHaveBeenCalledWith("/new/path"); + expect(pageviewSpy).toHaveBeenCalledWith({ + hitType: "page_view", + page: "/new/path", + }); }); }); diff --git a/src/components/LogIn/LogIn.tsx b/src/components/LogIn/LogIn.tsx index 7b6426141..95b0d8571 100644 --- a/src/components/LogIn/LogIn.tsx +++ b/src/components/LogIn/LogIn.tsx @@ -8,6 +8,7 @@ import FadeUpIn from "animations/FadeUpIn"; import AuthenticationButton from "components/AuthenticationButton"; import Logo from "components/Logo"; import ToastCard from "components/ToastCard"; +import useAnalytics from "hooks/useAnalytics"; import { getConfig, getLoginError, @@ -15,6 +16,8 @@ import { getWSControllerURL, isLoggedIn, getIsJuju, + getAppVersion, + getControllerConnection, } from "store/general/selectors"; import { AuthMethod } from "store/general/types"; import { useAppSelector } from "store/store"; @@ -27,9 +30,14 @@ import { ErrorResponse, Label, TestId } from "./types"; export default function LogIn() { const viewedAuthRequests = useRef([]); + const sendAnalytics = useAnalytics(); const config = useSelector(getConfig); const isJuju = useSelector(getIsJuju); + const appVersion = useSelector(getAppVersion); const wsControllerURL = useAppSelector(getWSControllerURL); + const controllerVersion = useAppSelector((state) => + getControllerConnection(state, wsControllerURL), + )?.serverVersion; const userIsLoggedIn = useAppSelector((state) => isLoggedIn(state, wsControllerURL), ); @@ -71,6 +79,20 @@ export default function LogIn() { }); }, [visitURLs]); + useEffect(() => { + if (userIsLoggedIn) { + sendAnalytics({ + category: "Authentication", + action: "User Login", + eventParams: { + dashboardVersion: appVersion ?? "", + controllerVersion: controllerVersion ?? "", + isJuju: (!!isJuju).toString(), + }, + }); + } + }, [userIsLoggedIn, appVersion, controllerVersion, isJuju, sendAnalytics]); + let form: ReactNode = null; switch (config?.authMethod) { case AuthMethod.CANDID: diff --git a/src/hooks/useAnalytics.test.ts b/src/hooks/useAnalytics.test.ts index c7bb3a1d5..1eafe842b 100644 --- a/src/hooks/useAnalytics.test.ts +++ b/src/hooks/useAnalytics.test.ts @@ -1,5 +1,5 @@ import { renderHook } from "@testing-library/react"; -import * as reactGA from "react-ga"; +import ReactGA from "react-ga4"; import type { MockInstance } from "vitest"; import { vi } from "vitest"; @@ -7,9 +7,12 @@ import * as store from "store/store"; import useAnalytics from "./useAnalytics"; -vi.mock("react-ga", () => ({ - event: vi.fn(), - pageview: vi.fn(), +vi.mock("react-ga4", () => ({ + default: { + initialize: vi.fn(), + send: vi.fn(), + event: vi.fn(), + }, })); describe("useAnalytics", () => { @@ -18,8 +21,8 @@ describe("useAnalytics", () => { beforeEach(() => { vi.stubEnv("PROD", true); - eventSpy = vi.spyOn(reactGA, "event"); - pageviewSpy = vi.spyOn(reactGA, "pageview"); + eventSpy = vi.spyOn(ReactGA, "event"); + pageviewSpy = vi.spyOn(ReactGA, "send"); }); afterEach(() => { @@ -57,7 +60,10 @@ describe("useAnalytics", () => { ); const { result } = renderHook(() => useAnalytics()); result.current({ path: "/some/path" }); - expect(pageviewSpy).toHaveBeenCalledWith("/some/path"); + expect(pageviewSpy).toHaveBeenCalledWith({ + hitType: "page_view", + page: "/some/path", + }); }); it("can send events", () => { diff --git a/src/hooks/useAnalytics.tsx b/src/hooks/useAnalytics.tsx index 9874ffb94..dd687cb1f 100644 --- a/src/hooks/useAnalytics.tsx +++ b/src/hooks/useAnalytics.tsx @@ -1,4 +1,4 @@ -import { pageview, event } from "react-ga"; +import ReactGA from "react-ga4"; import { getAnalyticsEnabled } from "store/general/selectors"; import { useAppSelector } from "store/store"; @@ -7,21 +7,28 @@ type AnalyticMessage = { path?: string; category?: string; action?: string; + eventParams?: { [key: string]: string }; }; export default function useAnalytics() { const analyticsEnabled = useAppSelector(getAnalyticsEnabled); - return ({ path, category = "", action = "" }: AnalyticMessage) => { + return ({ + path, + category = "", + action = "", + eventParams = {}, + }: AnalyticMessage) => { const isProduction = import.meta.env.PROD; if (!isProduction || !analyticsEnabled) { return; } if (path) { - pageview(path); + ReactGA.send({ hitType: "page_view", page: path }); } else { - event({ + ReactGA.event({ category, action, + ...eventParams, }); } }; diff --git a/src/testing/setup.ts b/src/testing/setup.ts index c43293aa3..77bf0902c 100644 --- a/src/testing/setup.ts +++ b/src/testing/setup.ts @@ -3,7 +3,7 @@ import type { DetachedWindowAPI } from "happy-dom"; import { vi } from "vitest"; import createFetchMock from "vitest-fetch-mock"; -vi.mock("react-ga"); +vi.mock("react-ga4"); const fetchMocker = createFetchMock(vi); // sets globalThis.fetch and globalThis.fetchMock to our mocked version diff --git a/yarn.lock b/yarn.lock index 7dab55d8a..a76d3bb9b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8365,7 +8365,7 @@ __metadata: react: "npm:18.3.1" react-anchorme: "npm:4.0.1" react-dom: "npm:18.3.1" - react-ga: "npm:3.3.1" + react-ga4: "npm:^2.1.0" react-hot-toast: "npm:2.5.1" react-json-tree: "npm:0.19.0" react-redux: "npm:9.2.0" @@ -10060,13 +10060,10 @@ __metadata: languageName: node linkType: hard -"react-ga@npm:3.3.1": - version: 3.3.1 - resolution: "react-ga@npm:3.3.1" - peerDependencies: - prop-types: ^15.6.0 - react: ^15.6.2 || ^16.0 || ^17 || ^18 - checksum: 10c0/a2a61c138887c651a59b49669141246e9a01803bd67c5d7581016949d2c8acdbef14db1a0039b855dcd3c979c711b4c45d910fcaa49e27165c13cdc685b4c2e0 +"react-ga4@npm:^2.1.0": + version: 2.1.0 + resolution: "react-ga4@npm:2.1.0" + checksum: 10c0/314aa86dd7cb868535f26bfb8b537d3b3c20649c66b2b942fba72e081295441446932a4ae96499231c8a4836ab0a222a97b1bd03633b8cc1477991efe93444cd languageName: node linkType: hard