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"