Skip to content

Commit

Permalink
feat(analytics): Implement usage analytics with react-ga4 (#1847)
Browse files Browse the repository at this point in the history
* feat(analytics): Implement usage analytics with react-ga4

* feat(analytics): Fix syntax for analytics with react-ga4

* feat(testing): Replace mocking store with renderWrappedHook state

* feat(analytics): Set custom dimensions with react-ga4

* feat(analytics): Send login event in poller

* feat(analytics): Enable debug view in development

* test(analytics): Add unit tests
  • Loading branch information
Ninfa-Jeon authored Mar 3, 2025
1 parent 602b277 commit 10e6d25
Show file tree
Hide file tree
Showing 11 changed files with 256 additions and 100 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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 @@ -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(
Expand All @@ -73,14 +72,17 @@ describe("App", () => {
</Provider>,
);
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({
Expand Down
14 changes: 10 additions & 4 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 @@ -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 (
Expand Down
12 changes: 9 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,12 @@ describe("CaptureRoutes", () => {
</Routes>
</BrowserRouter>,
);
expect(pageviewSpy).toHaveBeenCalledWith("/new/path");
expect(pageviewSpy).toHaveBeenCalledWith({
controllerVersion: "",
dashboardVersion: "",
hitType: "pageview",
isJuju: "false",
page: "/new/path",
});
});
});
121 changes: 73 additions & 48 deletions src/hooks/useAnalytics.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
);
});
});
43 changes: 22 additions & 21 deletions src/hooks/useAnalytics.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
28 changes: 23 additions & 5 deletions src/store/middleware/model-poller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down
2 changes: 1 addition & 1 deletion src/testing/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 10e6d25

Please sign in to comment.