Skip to content

Commit

Permalink
feat(analytics): Implement usage analytics with react-ga4
Browse files Browse the repository at this point in the history
  • Loading branch information
Ninfa-Jeon committed Feb 6, 2025
1 parent 2410cdc commit 67a1c9d
Show file tree
Hide file tree
Showing 9 changed files with 76 additions and 36 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 11 additions & 9 deletions src/components/App/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand All @@ -83,14 +82,17 @@ describe("App", () => {
</Provider>,
);
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({
Expand Down
9 changes: 6 additions & 3 deletions src/components/App/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 (
Expand Down
9 changes: 6 additions & 3 deletions src/components/CaptureRoutes/CaptureRoutes.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand All @@ -43,6 +43,9 @@ describe("CaptureRoutes", () => {
</Routes>
</BrowserRouter>,
);
expect(pageviewSpy).toHaveBeenCalledWith("/new/path");
expect(pageviewSpy).toHaveBeenCalledWith({
hitType: "page_view",
page: "/new/path",
});
});
});
22 changes: 22 additions & 0 deletions src/components/LogIn/LogIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@ 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,
getVisitURLs,
getWSControllerURL,
isLoggedIn,
getIsJuju,
getAppVersion,
getControllerConnection,
} from "store/general/selectors";
import { AuthMethod } from "store/general/types";
import { useAppSelector } from "store/store";
Expand All @@ -27,9 +30,14 @@ import { ErrorResponse, Label, TestId } from "./types";

export default function LogIn() {
const viewedAuthRequests = useRef<string[]>([]);
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),
);
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 13 additions & 7 deletions src/hooks/useAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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";

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", () => {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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", () => {
Expand Down
15 changes: 11 additions & 4 deletions src/hooks/useAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
});
}
};
Expand Down
2 changes: 1 addition & 1 deletion src/testing/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 5 additions & 8 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down

0 comments on commit 67a1c9d

Please sign in to comment.