diff --git a/package.json b/package.json index dbabc6d01..4d634379c 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "prismjs": "1.29.0", "react": "19.0.0", "react-dom": "19.0.0", - "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 9c546435c..c1e000202 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 () => { @@ -63,8 +62,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( @@ -73,14 +72,17 @@ describe("App", () => { , ); expect(initializeSpy).toHaveBeenCalled(); - expect(pageviewSpy).toHaveBeenCalledWith("/models"); + expect(pageviewSpy).toHaveBeenCalledWith({ + hitType: "pageview", + 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..36118aa2f 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"; @@ -11,9 +11,15 @@ import "../../scss/index.scss"; 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, "")); + if (analyticsEnabled) { + ReactGA.initialize( + "G-JHXHM8VXJ1", + isProduction ? {} : { gaOptions: { debug_mode: true } }, + ); + ReactGA.send({ + hitType: "pageview", + 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..edbe7f7f9 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,12 @@ describe("CaptureRoutes", () => { , ); - expect(pageviewSpy).toHaveBeenCalledWith("/new/path"); + expect(pageviewSpy).toHaveBeenCalledWith({ + controllerVersion: "", + dashboardVersion: "", + hitType: "pageview", + isJuju: "false", + page: "/new/path", + }); }); }); diff --git a/src/hooks/useAnalytics.test.ts b/src/hooks/useAnalytics.test.ts index c7bb3a1d5..fbd607173 100644 --- a/src/hooks/useAnalytics.test.ts +++ b/src/hooks/useAnalytics.test.ts @@ -1,74 +1,99 @@ -import { renderHook } from "@testing-library/react"; -import * as reactGA from "react-ga"; -import type { MockInstance } from "vitest"; import { vi } from "vitest"; -import * as store from "store/store"; +import { rootStateFactory } from "testing/factories"; +import { configFactory, generalStateFactory } from "testing/factories/general"; +import { connectionInfoFactory } from "testing/factories/juju/jujulib"; +import { renderWrappedHook } from "testing/utils"; +import * as analyticsUtils from "utils/analytics"; import useAnalytics from "./useAnalytics"; -vi.mock("react-ga", () => ({ - event: vi.fn(), - pageview: vi.fn(), -})); - describe("useAnalytics", () => { - let pageviewSpy: MockInstance; - let eventSpy: MockInstance; - beforeEach(() => { - vi.stubEnv("PROD", true); - eventSpy = vi.spyOn(reactGA, "event"); - pageviewSpy = vi.spyOn(reactGA, "pageview"); + vi.spyOn(analyticsUtils, "default").mockImplementation(() => vi.fn()); }); afterEach(() => { localStorage.clear(); }); - afterAll(() => { - vi.unstubAllEnvs(); - }); - - it("does not send events in development", () => { - vi.spyOn(store, "useAppSelector").mockImplementation( - vi.fn().mockReturnValue(true), - ); - vi.stubEnv("PROD", false); - const { result } = renderHook(() => useAnalytics()); - result.current({ path: "/some/path" }); - expect(eventSpy).not.toHaveBeenCalled(); - expect(pageviewSpy).not.toHaveBeenCalled(); - }); - it("does not send events if analytics are disabled", () => { - vi.spyOn(store, "useAppSelector").mockImplementation( - vi.fn().mockReturnValue(false), - ); - const { result } = renderHook(() => useAnalytics()); + const { result } = renderWrappedHook(() => useAnalytics(), { + state: rootStateFactory.build({ + general: generalStateFactory.build({ + config: configFactory.build({ + analyticsEnabled: false, + }), + }), + }), + }); result.current({ path: "/some/path" }); - expect(eventSpy).not.toHaveBeenCalled(); - expect(pageviewSpy).not.toHaveBeenCalled(); + expect(analyticsUtils.default).toHaveBeenCalledWith( + false, + { controllerVersion: "", dashboardVersion: "", isJuju: "false" }, + { path: "/some/path" }, + ); }); it("can send pageview events", () => { - vi.spyOn(store, "useAppSelector").mockImplementation( - vi.fn().mockReturnValue(true), - ); - const { result } = renderHook(() => useAnalytics()); + const { result } = renderWrappedHook(() => useAnalytics(), { + state: rootStateFactory.build({ + general: generalStateFactory.build({ + config: configFactory.build({ + analyticsEnabled: true, + }), + }), + }), + }); result.current({ path: "/some/path" }); - expect(pageviewSpy).toHaveBeenCalledWith("/some/path"); + expect(analyticsUtils.default).toHaveBeenCalledWith( + true, + { controllerVersion: "", dashboardVersion: "", isJuju: "false" }, + { path: "/some/path" }, + ); }); it("can send events", () => { - vi.spyOn(store, "useAppSelector").mockImplementation( - vi.fn().mockReturnValue(true), - ); - const { result } = renderHook(() => useAnalytics()); + const { result } = renderWrappedHook(() => useAnalytics(), { + state: rootStateFactory.build({ + general: generalStateFactory.build({ + config: configFactory.build({ + analyticsEnabled: true, + }), + }), + }), + }); result.current({ category: "sidebar", action: "toggle" }); - expect(eventSpy).toHaveBeenCalledWith({ - category: "sidebar", - action: "toggle", + expect(analyticsUtils.default).toHaveBeenCalledWith( + true, + { controllerVersion: "", dashboardVersion: "", isJuju: "false" }, + { category: "sidebar", action: "toggle" }, + ); + }); + + it("can send events with correct event params", () => { + const { result } = renderWrappedHook(() => useAnalytics(), { + state: rootStateFactory.build({ + general: generalStateFactory.build({ + appVersion: "1.0.0", + controllerConnections: { + "wss://example.com/api": connectionInfoFactory.build({ + serverVersion: "1.2.3", + }), + }, + config: configFactory.build({ + analyticsEnabled: true, + isJuju: true, + controllerAPIEndpoint: "wss://example.com/api", + }), + }), + }), }); + result.current({ category: "sidebar", action: "toggle" }); + expect(analyticsUtils.default).toHaveBeenCalledWith( + true, + { controllerVersion: "1.2.3", dashboardVersion: "1.0.0", isJuju: "true" }, + { category: "sidebar", action: "toggle" }, + ); }); }); diff --git a/src/hooks/useAnalytics.tsx b/src/hooks/useAnalytics.tsx index 9874ffb94..c845a4b5d 100644 --- a/src/hooks/useAnalytics.tsx +++ b/src/hooks/useAnalytics.tsx @@ -1,28 +1,29 @@ -import { pageview, event } from "react-ga"; +import { useSelector } from "react-redux"; -import { getAnalyticsEnabled } from "store/general/selectors"; +import { + getAnalyticsEnabled, + getAppVersion, + getControllerConnection, + getIsJuju, + getWSControllerURL, +} from "store/general/selectors"; import { useAppSelector } from "store/store"; - -type AnalyticMessage = { - path?: string; - category?: string; - action?: string; -}; +import analytics, { type AnalyticsMessage } from "utils/analytics"; export default function useAnalytics() { const analyticsEnabled = useAppSelector(getAnalyticsEnabled); - return ({ path, category = "", action = "" }: AnalyticMessage) => { - const isProduction = import.meta.env.PROD; - if (!isProduction || !analyticsEnabled) { - return; - } - if (path) { - pageview(path); - } else { - event({ - category, - action, - }); - } + const isJuju = useSelector(getIsJuju); + const appVersion = useSelector(getAppVersion); + const wsControllerURL = useAppSelector(getWSControllerURL); + const controllerVersion = useAppSelector((state) => + getControllerConnection(state, wsControllerURL), + )?.serverVersion; + const eventParams = { + dashboardVersion: appVersion ?? "", + controllerVersion: controllerVersion ?? "", + isJuju: (!!isJuju).toString(), }; + + return (props: AnalyticsMessage) => + analytics(!!analyticsEnabled, eventParams, props); } diff --git a/src/store/middleware/model-poller.ts b/src/store/middleware/model-poller.ts index 8e7f5c26a..39d9b6e67 100644 --- a/src/store/middleware/model-poller.ts +++ b/src/store/middleware/model-poller.ts @@ -16,12 +16,18 @@ import { whoami } from "juju/jimm/thunks"; import type { ConnectionWithFacades } from "juju/types"; import { actions as appActions, thunks as appThunks } from "store/app"; import { actions as generalActions } from "store/general"; -import { isLoggedIn } from "store/general/selectors"; +import { + getAnalyticsEnabled, + getAppVersion, + getIsJuju, + isLoggedIn, +} from "store/general/selectors"; import { AuthMethod } from "store/general/types"; import { actions as jujuActions } from "store/juju"; import type { RootState, Store } from "store/store"; import { isSpecificAction } from "types"; import { toErrorString } from "utils"; +import analytics from "utils/analytics"; import { logger } from "utils/logger"; export enum LoginError { @@ -132,12 +138,24 @@ export const modelPollerMiddleware: Middleware< return; } + const isProduction = import.meta.env.PROD; + const analyticsEnabled = getAnalyticsEnabled(reduxStore.getState()); + const isJuju = (!!getIsJuju(reduxStore.getState())).toString(); + const dashboardVersion = getAppVersion(reduxStore.getState()) ?? ""; + const controllerVersion = conn.info.serverVersion ?? ""; + + analytics( + !!analyticsEnabled, + { dashboardVersion, controllerVersion, isJuju }, + { + category: "Authentication", + action: `User Login (${authMethod})`, + }, + ); + // XXX Now that we can register multiple controllers this needs // to be sent per controller. - if ( - import.meta.env.PROD && - window.jujuDashboardConfig?.analyticsEnabled - ) { + if (isProduction && analyticsEnabled) { Sentry.setTag("jujuVersion", conn.info.serverVersion); } diff --git a/src/testing/setup.ts b/src/testing/setup.ts index 4485e8993..428fefa39 100644 --- a/src/testing/setup.ts +++ b/src/testing/setup.ts @@ -5,7 +5,7 @@ import createFetchMock from "vitest-fetch-mock"; import { logger } from "utils/logger"; -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/src/utils/analytics.test.ts b/src/utils/analytics.test.ts new file mode 100644 index 000000000..421b12ec8 --- /dev/null +++ b/src/utils/analytics.test.ts @@ -0,0 +1,67 @@ +import ReactGA from "react-ga4"; +import type { MockInstance } from "vitest"; +import { vi } from "vitest"; + +import analytics from "./analytics"; + +vi.mock("react-ga4", () => ({ + default: { + initialize: vi.fn(), + send: vi.fn(), + event: vi.fn(), + set: vi.fn(), + }, +})); + +describe("analytics", () => { + let pageviewSpy: MockInstance; + let eventSpy: MockInstance; + + beforeEach(() => { + eventSpy = vi.spyOn(ReactGA, "event"); + pageviewSpy = vi.spyOn(ReactGA, "send"); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("does not send events if analytics are disabled", () => { + analytics( + false, + { dashboardVersion: "1.0.0", controllerVersion: "1.0.0", isJuju: "true" }, + { path: "/some/path" }, + ); + expect(eventSpy).not.toHaveBeenCalled(); + expect(pageviewSpy).not.toHaveBeenCalled(); + }); + + it("can send pageview events", () => { + analytics( + true, + { dashboardVersion: "1.0.0", controllerVersion: "1.0.0", isJuju: "true" }, + { path: "/some/path" }, + ); + expect(pageviewSpy).toHaveBeenCalledWith({ + controllerVersion: "1.0.0", + dashboardVersion: "1.0.0", + hitType: "pageview", + isJuju: "true", + page: "/some/path", + }); + }); + + it("can send events", () => { + analytics( + true, + { dashboardVersion: "1.0.0", controllerVersion: "1.0.0", isJuju: "true" }, + { category: "sidebar", action: "toggle" }, + ); + expect(eventSpy).toHaveBeenCalledWith("toggle", { + category: "sidebar", + controllerVersion: "1.0.0", + dashboardVersion: "1.0.0", + isJuju: "true", + }); + }); +}); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts new file mode 100644 index 000000000..a1d0a1b96 --- /dev/null +++ b/src/utils/analytics.ts @@ -0,0 +1,34 @@ +import ReactGA from "react-ga4"; + +type EventParams = { + dashboardVersion: string; + controllerVersion: string; + isJuju: string; +}; + +export type AnalyticsMessage = { + path?: string; + category?: string; + action?: string; +}; + +// In components, the useAnalytics hook can be used to fetch additional event data +const analytics = ( + analyticsEnabled: boolean, + eventParams: EventParams, + { path, category = "", action = "" }: AnalyticsMessage, +) => { + if (!analyticsEnabled) { + return; + } + if (path) { + ReactGA.send({ hitType: "pageview", page: path, ...eventParams }); + } else { + ReactGA.event(action, { + category, + ...eventParams, + }); + } +}; + +export default analytics; diff --git a/yarn.lock b/yarn.lock index 93ef5c23e..f578ed9c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7530,7 +7530,7 @@ __metadata: react: "npm:19.0.0" react-anchorme: "npm:4.0.1" react-dom: "npm:19.0.0" - 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" @@ -9194,13 +9194,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