diff --git a/src/common/analytics/entities.ts b/src/common/analytics/entities.ts index 36d9104b..7e844416 100644 --- a/src/common/analytics/entities.ts +++ b/src/common/analytics/entities.ts @@ -16,6 +16,7 @@ export enum EVENT_NAME { FILTER_SELECTED = "filter_selected", INDEX_ANALYZE_IN_TERRA_REQUESTED = "index_analyze_in_terra_requested", INDEX_FILE_MANIFEST_REQUESTED = "index_file_manifest_requested", + LOGIN = "login", // GA4 recommended event name; not past tense to match GA4 standard. } /** @@ -82,4 +83,5 @@ export type EventParams = { [EVENT_NAME.INDEX_FILE_MANIFEST_REQUESTED]: { [EVENT_PARAM.ENTITY_NAME]: string; }; + [EVENT_NAME.LOGIN]: { [EVENT_PARAM.TOOL_NAME]: string }; }; diff --git a/src/google/provider.tsx b/src/google/provider.tsx index 03e4b85f..26fa6e57 100644 --- a/src/google/provider.tsx +++ b/src/google/provider.tsx @@ -8,6 +8,7 @@ import { useCredentialsReducer } from "../auth/hooks/useCredentialsReducer"; import { useSessionIdleTimer } from "../auth/hooks/useSessionIdleTimer"; import { useSessionActive } from "../hooks/authentication/session/useSessionActive"; import { useSessionCallbackUrl } from "../hooks/authentication/session/useSessionCallbackUrl"; +import { useLoginTracking } from "../hooks/authentication/useLoginTracking"; import { AUTH_STATE, AUTHENTICATION_STATE } from "./constants"; import { useGoogleSignInService } from "./hooks/useGoogleSignInService"; import { useTokenReducer } from "./hooks/useTokenReducer"; @@ -41,6 +42,7 @@ export function GoogleSignInAuthenticationProvider({ const { authDispatch, authState } = authReducer; const { isAuthenticated } = authState; const { authenticationState } = authenticationReducer; + useLoginTracking(isAuthenticated, authState.status); useSessionActive(authState, authenticationState); useSessionIdleTimer({ disabled: !isAuthenticated, diff --git a/src/hooks/authentication/useLoginTracking.ts b/src/hooks/authentication/useLoginTracking.ts new file mode 100644 index 00000000..85e5b12c --- /dev/null +++ b/src/hooks/authentication/useLoginTracking.ts @@ -0,0 +1,28 @@ +import { useEffect, useRef } from "react"; +import { AUTH_STATUS } from "../../auth/types/auth"; +import { track } from "../../common/analytics/analytics"; +import { EVENT_NAME, EVENT_PARAM } from "../../common/analytics/entities"; +import { GOOGLE_SIGN_IN_PROVIDER_ID } from "../../google/constants"; + +/** + * Tracks a GA4 login event when the user transitions from unauthenticated to authenticated. + * Does not fire during initial session hydration or on mount if already authenticated. + * @param isAuthenticated - Current authentication state. + * @param status - Current auth status; tracking only begins after status has settled. + * @returns void. + */ +export function useLoginTracking( + isAuthenticated: boolean, + status: AUTH_STATUS, +): void { + const wasAuthenticated = useRef(undefined); + useEffect(() => { + if (status !== AUTH_STATUS.SETTLED) return; + if (wasAuthenticated.current === false && isAuthenticated) { + track(EVENT_NAME.LOGIN, { + [EVENT_PARAM.TOOL_NAME]: GOOGLE_SIGN_IN_PROVIDER_ID, + }); + } + wasAuthenticated.current = isAuthenticated; + }, [isAuthenticated, status]); +} diff --git a/src/nextauth/provider.tsx b/src/nextauth/provider.tsx index 0b140967..b031ca62 100644 --- a/src/nextauth/provider.tsx +++ b/src/nextauth/provider.tsx @@ -6,6 +6,7 @@ import { useAuthenticationReducer } from "../auth/hooks/useAuthenticationReducer import { useAuthReducer } from "../auth/hooks/useAuthReducer"; import { useSessionIdleTimer } from "../auth/hooks/useSessionIdleTimer"; import { useSessionCallbackUrl } from "../hooks/authentication/session/useSessionCallbackUrl"; +import { useLoginTracking } from "../hooks/authentication/useLoginTracking"; import { useNextAuthService } from "./hooks/useNextAuthService"; import { SessionController } from "./session-controller"; import { NextAuthAuthenticationProviderProps } from "./types"; @@ -30,6 +31,7 @@ export function NextAuthAuthenticationProvider({ const service = useNextAuthService(); const { authDispatch, authState } = authReducer; const { isAuthenticated } = authState; + useLoginTracking(isAuthenticated, authState.status); const { callbackUrl } = useSessionCallbackUrl(); useSessionIdleTimer({ crossTab: true, diff --git a/tests/useLoginTracking.test.ts b/tests/useLoginTracking.test.ts new file mode 100644 index 00000000..e0781d03 --- /dev/null +++ b/tests/useLoginTracking.test.ts @@ -0,0 +1,75 @@ +import { jest } from "@jest/globals"; +import { renderHook } from "@testing-library/react"; +import { AUTH_STATUS } from "../src/auth/types/auth"; + +const mockTrack = jest.fn(); + +jest.unstable_mockModule("../src/common/analytics/analytics", () => ({ + track: mockTrack, +})); + +const { useLoginTracking } = + await import("../src/hooks/authentication/useLoginTracking"); + +const SETTLED = AUTH_STATUS.SETTLED; +const PENDING = AUTH_STATUS.PENDING; + +describe("useLoginTracking", () => { + beforeEach(() => { + mockTrack.mockReset(); + }); + + test("does not fire on initial mount when unauthenticated", () => { + renderHook(() => useLoginTracking(false, SETTLED)); + expect(mockTrack).not.toHaveBeenCalled(); + }); + + test("does not fire on initial mount when already authenticated", () => { + renderHook(() => useLoginTracking(true, SETTLED)); + expect(mockTrack).not.toHaveBeenCalled(); + }); + + test("fires login event on transition from unauthenticated to authenticated", () => { + const { rerender } = renderHook( + ({ isAuthenticated, status }) => + useLoginTracking(isAuthenticated, status), + { initialProps: { isAuthenticated: false, status: SETTLED } }, + ); + rerender({ isAuthenticated: true, status: SETTLED }); + expect(mockTrack).toHaveBeenCalledTimes(1); + expect(mockTrack).toHaveBeenCalledWith("login", { tool_name: "google" }); + }); + + test("does not fire on transition from authenticated to unauthenticated", () => { + const { rerender } = renderHook( + ({ isAuthenticated, status }) => + useLoginTracking(isAuthenticated, status), + { initialProps: { isAuthenticated: true, status: SETTLED } }, + ); + rerender({ isAuthenticated: false, status: SETTLED }); + expect(mockTrack).not.toHaveBeenCalled(); + }); + + test("fires for each login transition", () => { + const { rerender } = renderHook( + ({ isAuthenticated, status }) => + useLoginTracking(isAuthenticated, status), + { initialProps: { isAuthenticated: false, status: SETTLED } }, + ); + rerender({ isAuthenticated: true, status: SETTLED }); + rerender({ isAuthenticated: false, status: SETTLED }); + rerender({ isAuthenticated: true, status: SETTLED }); + expect(mockTrack).toHaveBeenCalledTimes(2); + }); + + test("does not fire during session hydration when status is pending", () => { + const { rerender } = renderHook( + ({ isAuthenticated, status }) => + useLoginTracking(isAuthenticated, status), + { initialProps: { isAuthenticated: false, status: PENDING } }, + ); + // Session hydrates: isAuthenticated becomes true while status settles. + rerender({ isAuthenticated: true, status: SETTLED }); + expect(mockTrack).not.toHaveBeenCalled(); + }); +});