Skip to content

Commit 5c217a5

Browse files
committed
feat: implement global analytics tracking for user interactions
1 parent b5326ba commit 5c217a5

File tree

4 files changed

+214
-0
lines changed

4 files changed

+214
-0
lines changed

components/MixpanelInit/MixpanelInit.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import { useEffect } from "react"
44
import { usePathname, useSearchParams } from "next/navigation"
55

66
import { trackPageView } from "@/lib/mixpanel"
7+
import { useGlobalAnalytics } from "@/hooks/use-global-analytics"
78

89
export function MixpanelInit() {
910
const pathname = usePathname()
1011
const searchParams = useSearchParams()
1112

13+
useGlobalAnalytics()
14+
1215
useEffect(() => {
1316
if (!pathname) {
1417
return

hooks/use-global-analytics.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { useEffect } from "react"
2+
3+
import { ANALYTICS_EVENTS } from "@/lib/analytics-events"
4+
import { track } from "@/lib/mixpanel"
5+
import type { GlobalInteractionEventKey, GlobalInteractionProps } from "@/types/analytics"
6+
7+
const INTERACTIVE_SELECTOR =
8+
"[data-analytics-event], button, a[href], input, select, textarea, [role='button'], [role='menuitem'], [role='option']"
9+
10+
const IGNORED_SELECTOR = "[data-analytics-ignore='true']"
11+
12+
const MAX_TEXT_LENGTH = 120
13+
14+
const getInteractiveElement = (target: EventTarget | null): HTMLElement | null => {
15+
if (!(target instanceof HTMLElement)) {
16+
return null
17+
}
18+
19+
if (target.closest(IGNORED_SELECTOR)) {
20+
return null
21+
}
22+
23+
const element = target.closest(INTERACTIVE_SELECTOR)
24+
25+
if (!element || !(element instanceof HTMLElement)) {
26+
return null
27+
}
28+
29+
if (element.dataset.analyticsIgnore === "true") {
30+
return null
31+
}
32+
33+
return element
34+
}
35+
36+
const cleanText = (value: string | null | undefined) => {
37+
if (!value) {
38+
return undefined
39+
}
40+
41+
const trimmed = value.replace(/\s+/g, " ").trim()
42+
43+
if (!trimmed) {
44+
return undefined
45+
}
46+
47+
return trimmed.slice(0, MAX_TEXT_LENGTH)
48+
}
49+
50+
const getElementLabel = (element: HTMLElement) => {
51+
if (element.dataset.analyticsLabel) {
52+
return element.dataset.analyticsLabel
53+
}
54+
55+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
56+
return cleanText(element.placeholder) ?? element.name ?? element.id ?? undefined
57+
}
58+
59+
if (element instanceof HTMLSelectElement) {
60+
return element.name || element.id || undefined
61+
}
62+
63+
if (element instanceof HTMLAnchorElement) {
64+
return cleanText(element.textContent)
65+
}
66+
67+
return cleanText(element.textContent)
68+
}
69+
70+
const getHref = (element: HTMLElement) => {
71+
if (!(element instanceof HTMLAnchorElement)) {
72+
return undefined
73+
}
74+
75+
const href = element.getAttribute("href")
76+
77+
if (!href) {
78+
return undefined
79+
}
80+
81+
return href.startsWith("http") ? href : href.slice(0, MAX_TEXT_LENGTH)
82+
}
83+
84+
const getInputMeta = (element: HTMLElement) => {
85+
if (element instanceof HTMLInputElement) {
86+
if (element.type === "checkbox" || element.type === "radio") {
87+
return {
88+
inputType: element.type,
89+
name: element.name || undefined,
90+
checked: element.checked,
91+
}
92+
}
93+
94+
return {
95+
inputType: element.type,
96+
name: element.name || undefined,
97+
valueLength: element.value.length,
98+
}
99+
}
100+
101+
if (element instanceof HTMLTextAreaElement) {
102+
return {
103+
inputType: "textarea",
104+
name: element.name || undefined,
105+
valueLength: element.value.length,
106+
}
107+
}
108+
109+
if (element instanceof HTMLSelectElement) {
110+
return {
111+
inputType: "select",
112+
name: element.name || undefined,
113+
value: element.value ? element.value.slice(0, MAX_TEXT_LENGTH) : undefined,
114+
}
115+
}
116+
117+
return undefined
118+
}
119+
120+
const buildInteractionProps = (
121+
element: HTMLElement,
122+
eventType: GlobalInteractionEventKey,
123+
event: Event
124+
): GlobalInteractionProps => {
125+
const label = getElementLabel(element)
126+
const inputMeta = getInputMeta(element)
127+
128+
return {
129+
eventType,
130+
tag: element.tagName.toLowerCase(),
131+
id: element.id || undefined,
132+
role: element.getAttribute("role") ?? undefined,
133+
analyticsEvent: element.dataset.analyticsEvent || undefined,
134+
analyticsContext: element.dataset.analyticsContext || undefined,
135+
analyticsLabel: label,
136+
href: getHref(element),
137+
page: typeof window !== "undefined" ? window.location.pathname : undefined,
138+
inputMeta,
139+
key: event instanceof KeyboardEvent ? event.key : undefined,
140+
}
141+
}
142+
143+
const trackInteraction = (eventType: GlobalInteractionEventKey, event: Event) => {
144+
const target = event.target as EventTarget | null
145+
const element = getInteractiveElement(target)
146+
147+
if (!element) {
148+
return
149+
}
150+
151+
track(ANALYTICS_EVENTS[eventType], buildInteractionProps(element, eventType, event))
152+
}
153+
154+
export const useGlobalAnalytics = () => {
155+
useEffect(() => {
156+
if (typeof window === "undefined") {
157+
return
158+
}
159+
160+
const capture = true
161+
162+
const handleClick = (event: MouseEvent) => {
163+
trackInteraction("UI_CLICK", event)
164+
}
165+
166+
const handleChange = (event: Event) => {
167+
trackInteraction("UI_INPUT", event)
168+
}
169+
170+
const handleSubmit = (event: Event) => {
171+
trackInteraction("UI_SUBMIT", event)
172+
}
173+
174+
document.addEventListener("click", handleClick, { capture })
175+
document.addEventListener("change", handleChange, { capture })
176+
document.addEventListener("submit", handleSubmit, { capture })
177+
178+
return () => {
179+
document.removeEventListener("click", handleClick, { capture })
180+
document.removeEventListener("change", handleChange, { capture })
181+
document.removeEventListener("submit", handleSubmit, { capture })
182+
}
183+
}, [])
184+
}

lib/analytics-events.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export const ANALYTICS_EVENTS = {
1717
REPO_SCAN_START: "Repo Scan Start",
1818
REPO_SCAN_RETRY: "Repo Scan Retry",
1919
REPO_SCAN_GENERATE_FILE: "Repo Scan Generate File",
20+
// Global UI events
21+
UI_CLICK: "UI Click",
22+
UI_INPUT: "UI Input",
23+
UI_SUBMIT: "UI Submit",
2024
} as const
2125

2226
export type AnalyticsEvent =

types/analytics.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export type GlobalInteractionEventKey = "UI_CLICK" | "UI_INPUT" | "UI_SUBMIT"
2+
3+
export interface GlobalInteractionProps {
4+
eventType: GlobalInteractionEventKey
5+
tag: string
6+
id?: string
7+
role?: string | null
8+
analyticsEvent?: string
9+
analyticsContext?: string
10+
analyticsLabel?: string
11+
href?: string
12+
page?: string
13+
inputMeta?:
14+
| {
15+
inputType: string
16+
name?: string
17+
valueLength?: number
18+
checked?: boolean
19+
value?: string
20+
}
21+
| undefined
22+
key?: string
23+
}

0 commit comments

Comments
 (0)