diff --git a/torchci/components/commit/WorkflowBox.tsx b/torchci/components/commit/WorkflowBox.tsx index 58616ead47..d0405186d9 100644 --- a/torchci/components/commit/WorkflowBox.tsx +++ b/torchci/components/commit/WorkflowBox.tsx @@ -103,6 +103,10 @@ function WorkflowJobSummary({ Utilization Report{" "} diff --git a/torchci/components/queueTimeAnalysis/components/charts/QueueTimeEchartElement.tsx b/torchci/components/queueTimeAnalysis/components/charts/QueueTimeEchartElement.tsx index a21e80a72e..683fac92c3 100644 --- a/torchci/components/queueTimeAnalysis/components/charts/QueueTimeEchartElement.tsx +++ b/torchci/components/queueTimeAnalysis/components/charts/QueueTimeEchartElement.tsx @@ -314,7 +314,6 @@ const getPercentileLineChart = ( const lines = []; const date = params[0].axisValue; lines.push(`${date}`); - console.log(lines); for (const item of params) { const idx = item.data; const lineName = item.seriesName; diff --git a/torchci/components/queueTimeAnalysis/components/searchBarItems/QueueTimeSearchBar.tsx b/torchci/components/queueTimeAnalysis/components/searchBarItems/QueueTimeSearchBar.tsx index f700f9644e..8c97d1e8cd 100644 --- a/torchci/components/queueTimeAnalysis/components/searchBarItems/QueueTimeSearchBar.tsx +++ b/torchci/components/queueTimeAnalysis/components/searchBarItems/QueueTimeSearchBar.tsx @@ -5,6 +5,7 @@ import { propsReducer } from "components/benchmark/llms/context/BenchmarkProps"; import { DateRangePicker } from "components/queueTimeAnalysis/components/pickers/DateRangePicker"; import { TimeGranuityPicker } from "components/queueTimeAnalysis/components/pickers/TimeGranuityPicker"; import dayjs from "dayjs"; +import { trackEventWithContext } from "lib/tracking/track"; import { cloneDeep } from "lodash"; import { NextRouter } from "next/router"; import { ParsedUrlQuery } from "querystring"; @@ -232,6 +233,9 @@ export default function QueueTimeSearchBar({ const onSearch = () => { const newprops = cloneDeep(props); + trackEventWithContext("qta_search", "user_interaction", "button_click", { + data: newprops.category, + }); updateSearch({ type: "UPDATE_FIELDS", payload: newprops }); }; @@ -330,7 +334,15 @@ export default function QueueTimeSearchBar({ - Search + + Search + * Click to apply filter changes diff --git a/torchci/docs/event_tracking_guidance.md b/torchci/docs/event_tracking_guidance.md new file mode 100644 index 0000000000..4a84c948e6 --- /dev/null +++ b/torchci/docs/event_tracking_guidance.md @@ -0,0 +1,79 @@ +# Event Tracking guidance + +# Overview + +Guidance for event tracking in torchci. + +## Google Analytics Development Guide (Local Development) + +### Overview + +This guide explains how to enable Google Analytics 4 (GA4) debug mode during local development so you can verify event tracking in real time via GA DebugView. + +### Prerequisites + +- TorchCI front-end development environment is set up and running locally +- Chrome browser installed +- Install chrome extension [Google Analytics Debugger](https://chrome.google.com/webstore/detail/jnkmfdileelhofjcijamephohjechhna) +- Make sure you have permission to the GCP project `pytorch-hud` as admin. If not, reach out to `oss support (internal only)` or @pytorch/pytorch-dev-infra to add you + +## Steps + +### 1. Append `?debug_mode=true` to Your Local URL + +Go to the page you want to testing the tracking event, and add parameter `?debug_mode=true` +Example: + +``` +http://localhost:3000/queue_time_analysis?debug_mode=true +``` + +you should see the QA debugging info in console: + +### View debug view in Google Analytics + +[Analytics DebugView](https://analytics.google.com/analytics/web/#/a44373548p420079840/admin/debugview/overview) + +When click a tracking button or event, you should be able to see it logged in the debugview (it may have 2-15 secs delayed). + +### Adding event to track + +two options to add event: + +#### data attribute + +Provided customized listener to catch tracking event using data-attributes + +This is used to track simple user behaviours. + +```tsx + Example usage: + +``` + +Supported data attributes: + +- `data-ga-action` (required): GA action name +- `data-ga-category` (optional): GA category (defaults to event type) +- `data-ga-label` (optional): GA label +- `data-ga-event-types` (optional): comma-separated list of allowed event types for this element (e.g. "click,submit") + +#### using trackEventWithContext + +using trackEventWithContext to provide extra content. + +```tsx +trackEventWithContext( +action: string, +category?: string, +label?: string, +extra?: Record +) +``` diff --git a/torchci/lib/track.ts b/torchci/lib/track.ts deleted file mode 100644 index a2c93a03af..0000000000 --- a/torchci/lib/track.ts +++ /dev/null @@ -1,15 +0,0 @@ -import ReactGA from "react-ga4"; - -export function track(router: any, type: string, info: any) { - if (window.location.href.startsWith("https://hud.pytorch.org")) { - // TODO: I think there better ways to make sure it doesn't send dev data but - // for now this is easy to read. - ReactGA.event(type, { - ...info, - url: window.location.href, - windowPathname: window.location.pathname, - routerPathname: router.pathname, - routerPath: router.asPath, - }); - } -} diff --git a/torchci/lib/tracking/eventTrackingHandler.ts b/torchci/lib/tracking/eventTrackingHandler.ts new file mode 100644 index 0000000000..9ec7496db8 --- /dev/null +++ b/torchci/lib/tracking/eventTrackingHandler.ts @@ -0,0 +1,80 @@ +import { trackEventWithContext } from "./track"; + +/** + * Sets up global GA event tracking for DOM elements using `data-ga-*` attributes. + * + * 🔍 This enables declarative analytics tracking by simply adding attributes to HTML elements. + * You can limit tracking to specific DOM event types (e.g., "click") both globally and per-element. + * + * Example usage (in _app.tsx or layout): + * useEffect(() => { + * const teardown = setupGAAttributeEventTracking(["click", "submit"]); + * return teardown; // cleanup on unmount + * }, []); + * + * Example usage: + * + * + * Supported data attributes: + * - `data-ga-action` (required): GA action name + * - `data-ga-label` (optional): GA label + * - `data-ga-category` (optional): GA category (defaults to event type) + * - `data-ga-event-types` (optional): comma-separated list of allowed event types for this element (e.g. "click,submit") + * + * @param globalEventTypes - Array of DOM event types to listen for globally (default: ["click", "change", "submit", "mouseenter"]) + * @returns Cleanup function to remove all added event listeners + */ +export function setupGAAttributeEventTracking( + globalEventTypes: string[] = ["click", "change", "submit", "mouseenter"] +): () => void { + const handler = (e: Event) => { + const target = e.target as HTMLElement | null; + if (!target) return; + + const el = target.closest("[data-ga-action]") as HTMLElement | null; + if (!el) return; + + const action = el.dataset.gaAction; + if (!action) return; + + // Check if this element has a restricted set of allowed event types + const allowedTypes = el.dataset.gaEventTypes + ?.split(",") + .map((t) => t.trim()); + if (allowedTypes && !allowedTypes.includes(e.type)) { + return; // This event type is not allowed for this element + } + + const label = el.dataset.gaLabel; + const category = el.dataset.gaCategory || e.type; // Default category to event type if not provided + + // Construct event parameters for GA4 + const eventParams = { + category, + label, + url: window.location.href, + windowPathname: window.location.pathname, + }; + + trackEventWithContext(action, category, label); + }; + + // Add event listeners + globalEventTypes.forEach((eventType) => { + document.addEventListener(eventType, handler, true); // Use `true` for capture phase to catch events early + }); + + // Return cleanup function + return () => { + globalEventTypes.forEach((eventType) => { + document.removeEventListener(eventType, handler, true); + }); + }; +} diff --git a/torchci/lib/tracking/track.ts b/torchci/lib/tracking/track.ts new file mode 100644 index 0000000000..319ef7e22d --- /dev/null +++ b/torchci/lib/tracking/track.ts @@ -0,0 +1,134 @@ +import { NextRouter } from "next/router"; +import ReactGA from "react-ga4"; + +const GA_SESSION_ID = "ga_session_id"; +const GA_MEASUREMENT_ID = "G-HZEXJ323ZF"; + +// Add a global flag to window object +declare global { + interface Window { + __GA_INITIALIZED__?: boolean; + gtag?: (...args: any[]) => void; // Declare gtag for direct access check + } +} + +export const isGaInitialized = (): boolean => { + return typeof window !== "undefined" && !!window.__GA_INITIALIZED__; +}; + +function isDebugMode() { + return ( + typeof window !== "undefined" && + window.location.search.includes("debug_mode=true") + ); +} + +function isProdEnv() { + return ( + typeof window !== "undefined" && + window.location.href.startsWith("https://hud.pytorch.org") + ); +} + +function isGAEnabled(): boolean { + if (typeof window === "undefined") return false; + return isDebugMode() || isProdEnv(); +} + +/** + * initialize google analytics + * if withUserId is set, we generate random sessionId to track action sequence for a single page flow. + * Notice, we use session storage, if user create a new page tab due to navigation, it's considered new session + * @param withUserId + * @returns + */ +export const initGaAnalytics = (withSessionId = false) => { + // Block in non-production deployments unless the debug_mode is set to true in url. + if (!isGAEnabled()) { + console.info("[GA] Skipping GA init"); + return; + } + + if (isGaInitialized()) { + console.log("ReactGA already initialized."); + return; + } + + ReactGA.initialize(GA_MEASUREMENT_ID, { + // For enabling debug mode for GA4, the primary option is `debug: true` + // passed directly to ReactGA.initialize. + // The `gaOptions` and `gtagOptions` are for more advanced configurations + // directly passed to the underlying GA/Gtag library. + // @ts-ignore + debug: isDebugMode(), + gaOptions: { + debug_mode: isDebugMode(), + }, + gtagOptions: { + debug_mode: isDebugMode(), + cookie_domain: isDebugMode() ? "none" : "auto", + }, + }); + + window.__GA_INITIALIZED__ = true; // Set a global flag + + // generate random userId in session storage. + if (withSessionId) { + let id = sessionStorage.getItem(GA_SESSION_ID); + if (!id) { + id = crypto.randomUUID(); + sessionStorage.setItem(GA_SESSION_ID, id); + } + ReactGA.set({ user_id: id }); + } +}; + +export function trackRouteEvent( + router: NextRouter, + eventName: string, + info: Record = {} +) { + if (!isGAEnabled()) { + return; + } + + const payload = { + ...info, + url: window.location.href, + windowPathname: window.location.pathname, + routerPathname: router.pathname, + routerPath: router.asPath, + ...(isDebugMode() ? { debug_mode: true } : {}), + }; + + ReactGA.event(eventName.toLowerCase(), payload); +} + +/** + * track event with context using QA + * @param action + * @param category + * @param label + * @param extra + * @returns + */ +export function trackEventWithContext( + action: string, + category?: string, + label?: string, + extra?: Record +) { + if (!isGAEnabled()) { + return; + } + const payload = { + category, + label, + event_time: new Date().toISOString(), + page_title: document.title, + session_id: sessionStorage.getItem(GA_SESSION_ID) ?? undefined, + + ...(isDebugMode() ? { debug_mode: true } : {}), + }; + ReactGA.event(action, payload); +} diff --git a/torchci/package.json b/torchci/package.json index cf69aed0d6..00c379820e 100644 --- a/torchci/package.json +++ b/torchci/package.json @@ -52,6 +52,7 @@ "next": "14.2.30", "next-auth": "^4.24.5", "octokit": "^1.7.1", + "pino-std-serializers": "^7.0.0", "probot": "^12.3.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/torchci/pages/_app.tsx b/torchci/pages/_app.tsx index 3c5b2ed334..c2ff135862 100644 --- a/torchci/pages/_app.tsx +++ b/torchci/pages/_app.tsx @@ -7,25 +7,41 @@ import TitleProvider from "components/layout/DynamicTitle"; import NavBar from "components/layout/NavBar"; import SevReport from "components/sevReport/SevReport"; import { DarkModeProvider } from "lib/DarkModeContext"; -import { track } from "lib/track"; +import { setupGAAttributeEventTracking } from "lib/tracking/eventTrackingHandler"; +import { + initGaAnalytics, + isGaInitialized, + trackRouteEvent, +} from "lib/tracking/track"; import { SessionProvider } from "next-auth/react"; import type { AppProps } from "next/app"; import { useRouter } from "next/router"; import { useEffect } from "react"; -import ReactGA from "react-ga4"; import { useAppTheme } from "styles/MuiThemeOverrides"; import "styles/globals.css"; import("lib/chartTheme"); function MyApp({ Component, pageProps }: AppProps) { const router = useRouter(); + useEffect(() => { - // GA records page views on its own, but I want to see how it differs with - // this one. - track(router, "pageview", {}); - }, [router, router.pathname]); + let cleanup: (() => void) | undefined; + initGaAnalytics(true); + if (isGaInitialized()) { + cleanup = setupGAAttributeEventTracking(["click"]); + } else { + console.warn( + "GA not initialized, skipping attribute event tracking setup." + ); + } + return () => { + if (cleanup) cleanup(); + }; + }, []); - ReactGA.initialize("G-HZEXJ323ZF"); + useEffect(() => { + trackRouteEvent(router, "pageview", {}); + }, [router.asPath]); // Wrap everything in DarkModeProvider return ( diff --git a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx index 0e12e4052c..27d23e88ab 100644 --- a/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx +++ b/torchci/pages/hud/[repoOwner]/[repoName]/[branch]/[[...page]].tsx @@ -28,7 +28,7 @@ import { isUnstableJob, } from "lib/jobUtils"; import { ParamSelector } from "lib/ParamSelector"; -import { track } from "lib/track"; +import { trackRouteEvent } from "lib/tracking/track"; import { formatHudUrlForRoute, Highlight, @@ -618,7 +618,7 @@ function GroupedHudTable({ useEffect(() => { // Only run on component mount, this assumes that the user's preference is // the value in local storage - track(router, "groupingPreference", { useGrouping: useGrouping }); + trackRouteEvent(router, "groupingPreference", { useGrouping: useGrouping }); }, [router, useGrouping]); const groupNames = Array.from(groupNameMapping.keys()); diff --git a/torchci/yarn.lock b/torchci/yarn.lock index 1d6fcfb816..988c45e054 100644 --- a/torchci/yarn.lock +++ b/torchci/yarn.lock @@ -7615,6 +7615,11 @@ pino-std-serializers@^4.0.0: resolved "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-4.0.0.tgz" integrity sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q== +pino-std-serializers@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz#7c625038b13718dbbd84ab446bd673dc52259e3b" + integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== + pino@^6.13.0, pino@^6.7.0: version "6.14.0" resolved "https://registry.npmjs.org/pino/-/pino-6.14.0.tgz"