Skip to content

Commit 1e8bf8d

Browse files
feat: implement PostHog event tracking for user interactions (#3218)
* feat: implement PostHog event tracking for user interactions * fix code style issues * test: add label to event tracking for CallToActionSection * feedback * feat: enhance PostHog event tracking for course and program enrollment buttons * fix test * feat: add PostHog event tracking for CTA clicks in UAIAnnouncementCard and UniversalAIBanner * feat: remove resourceType from PostHog event tracking in UAIAnnouncementCard * feedback * fix: add allowConsoleErrors to posthog tracking tests and fix label assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: mock enrollment POST in PostHog tracking tests to prevent console.error Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bb56c81 commit 1e8bf8d

22 files changed

Lines changed: 521 additions & 27 deletions

frontends/main/src/app-pages/HomePage/HomePage.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ import {
1717
import invariant from "tiny-invariant"
1818
import * as routes from "@/common/urls"
1919
import { FeatureFlags } from "@/common/feature_flags"
20-
import { assertHeadings } from "ol-test-utilities"
20+
import { assertHeadings, allowConsoleErrors } from "ol-test-utilities"
2121
import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react"
22+
import { PostHogEvents } from "@/common/constants"
2223

2324
jest.mock("posthog-js/react")
2425
const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled)
@@ -128,6 +129,28 @@ describe("UAI Announcement Card", () => {
128129
})
129130
assertLinksTo(cta, { pathname: "/programs/program-v1:UAI+B2C" })
130131
})
132+
133+
test("CTA click fires cta_clicked with label and readableId", async () => {
134+
allowConsoleErrors()
135+
setupAPIs()
136+
mockedUseFeatureFlagEnabled.mockImplementation(
137+
(flag) => flag === FeatureFlags.UniversalAI,
138+
)
139+
process.env.NEXT_PUBLIC_POSTHOG_API_KEY = "test-key"
140+
renderWithProviders(<HomePage heroImageIndex={1} />)
141+
const cta = await screen.findByRole("link", {
142+
name: "Learn about Universal AI",
143+
})
144+
await user.click(cta)
145+
expect(mockedPostHogCapture).toHaveBeenCalledWith(
146+
PostHogEvents.CallToActionClicked,
147+
expect.objectContaining({
148+
label: "Learn about Universal AI",
149+
readableId: "program-v1:UAI+B2C",
150+
}),
151+
)
152+
delete process.env.NEXT_PUBLIC_POSTHOG_API_KEY
153+
})
131154
})
132155

133156
describe("Home Page Hero", () => {

frontends/main/src/app-pages/HomePage/UAIAnnouncementCard.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
"use client"
12
import React from "react"
23
import { Typography, styled } from "ol-components"
34
import { ButtonLink } from "@mitodl/smoot-design"
45
import { RiArrowRightLine } from "@remixicon/react"
5-
import { useFeatureFlagEnabled } from "posthog-js/react"
6+
import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react"
67
import { FeatureFlags } from "@/common/feature_flags"
78
import { programPageView } from "@/common/urls"
9+
import { PostHogEvents } from "@/common/constants"
810

911
const UAI_PROGRAM_READABLE_ID = "program-v1:UAI+B2C"
1012

@@ -243,15 +245,26 @@ const CTAButton = styled(ButtonLink)(({ theme }) => ({
243245

244246
const UAIAnnouncementCard: React.FC = () => {
245247
const showUAICard = useFeatureFlagEnabled(FeatureFlags.UniversalAI)
246-
if (!showUAICard) {
247-
return null
248-
}
248+
const posthog = usePostHog()
249249

250250
const ctaHref = programPageView({
251251
readable_id: UAI_PROGRAM_READABLE_ID,
252252
display_mode: null,
253253
})
254254

255+
const handleCTAClick = () => {
256+
if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) {
257+
posthog.capture(PostHogEvents.CallToActionClicked, {
258+
label: "Learn about Universal AI",
259+
readableId: UAI_PROGRAM_READABLE_ID,
260+
})
261+
}
262+
}
263+
264+
if (!showUAICard) {
265+
return null
266+
}
267+
255268
return (
256269
<Card>
257270
<CardHeader>
@@ -275,6 +288,7 @@ const UAIAnnouncementCard: React.FC = () => {
275288
variant="primary"
276289
href={ctaHref}
277290
endIcon={<RiArrowRightLine />}
291+
onClick={handleCTAClick}
278292
>
279293
Learn about Universal AI
280294
</CTAButton>
@@ -294,6 +308,7 @@ const UAIAnnouncementCard: React.FC = () => {
294308
variant="primary"
295309
href={ctaHref}
296310
endIcon={<RiArrowRightLine />}
311+
onClick={handleCTAClick}
297312
>
298313
Learn about Universal AI
299314
</CTAButton>

frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ import {
1515
} from "api/mitxonline-test-utils"
1616
import { mitxonlineLegacyUrl } from "@/common/mitxonline"
1717
import * as routes from "@/common/urls"
18+
import { usePostHog } from "posthog-js/react"
19+
import { PostHogEvents } from "@/common/constants"
20+
21+
jest.mock("posthog-js/react", () => ({
22+
...jest.requireActual("posthog-js/react"),
23+
usePostHog: jest.fn(),
24+
}))
25+
const mockCapture = jest.fn()
26+
jest.mocked(usePostHog).mockReturnValue(
27+
// @ts-expect-error Not mocking all of posthog
28+
{ capture: mockCapture },
29+
)
1830

1931
const makeCourse = mitxFactories.courses.course
2032
const makeRun = mitxFactories.courses.courseRun
@@ -505,4 +517,70 @@ describe("CourseEnrollmentButton", () => {
505517
expect(button).toBeInTheDocument()
506518
expect(button).toBeDisabled()
507519
})
520+
521+
describe("PostHog tracking", () => {
522+
beforeEach(() => {
523+
process.env.NEXT_PUBLIC_POSTHOG_API_KEY = "test-key"
524+
mockCapture.mockClear()
525+
})
526+
afterEach(() => {
527+
delete process.env.NEXT_PUBLIC_POSTHOG_API_KEY
528+
})
529+
530+
test("fires cta_clicked when authenticated user clicks enroll", async () => {
531+
const run = makeRun({
532+
is_archived: false,
533+
is_enrollable: true,
534+
enrollment_modes: [makeEnrollmentMode({ requires_payment: false })],
535+
})
536+
const course = makeCourse({ next_run_id: run.id, courseruns: [run] })
537+
setMockResponse.get(
538+
urls.userMe.get(),
539+
makeUser({ is_authenticated: true }),
540+
)
541+
// Clicking enroll triggers the enrollment API call; mock it to avoid failing on console.error.
542+
setMockResponse.post(mitxUrls.enrollment.enrollmentsListV1(), {})
543+
544+
renderWithProviders(<CourseEnrollmentButton course={course} />)
545+
await user.click(
546+
await screen.findByRole("button", { name: "Enroll for Free" }),
547+
)
548+
549+
expect(mockCapture).toHaveBeenCalledWith(
550+
PostHogEvents.CallToActionClicked,
551+
expect.objectContaining({
552+
resourceId: course.id,
553+
readableId: course.readable_id,
554+
resourceType: "course",
555+
}),
556+
)
557+
})
558+
559+
test("fires cta_clicked when unauthenticated user clicks enroll", async () => {
560+
const run = makeRun({
561+
is_archived: false,
562+
is_enrollable: true,
563+
enrollment_modes: [makeEnrollmentMode({ requires_payment: false })],
564+
})
565+
const course = makeCourse({ next_run_id: run.id, courseruns: [run] })
566+
setMockResponse.get(
567+
urls.userMe.get(),
568+
makeUser({ is_authenticated: false }),
569+
)
570+
571+
renderWithProviders(<CourseEnrollmentButton course={course} />)
572+
await user.click(
573+
await screen.findByRole("button", { name: "Enroll for Free" }),
574+
)
575+
576+
expect(mockCapture).toHaveBeenCalledWith(
577+
PostHogEvents.CallToActionClicked,
578+
expect.objectContaining({
579+
resourceId: course.id,
580+
readableId: course.readable_id,
581+
resourceType: "course",
582+
}),
583+
)
584+
})
585+
})
508586
})

frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { productQueries } from "api/mitxonline-hooks/products"
2121
import { useReplaceBasketItem } from "api/mitxonline-hooks/baskets"
2222
import { useCreateEnrollment } from "api/mitxonline-hooks/enrollment"
2323
import { useRouter } from "next-nprogress-bar"
24+
import { usePostHog } from "posthog-js/react"
25+
import { PostHogEvents } from "@/common/constants"
2426

2527
const DiscountedPriceContent = styled.span({
2628
display: "inline-flex",
@@ -60,6 +62,7 @@ const CourseEnrollmentButton: React.FC<CourseEnrollmentButtonProps> = ({
6062
const replaceBasketItem = useReplaceBasketItem()
6163
const createEnrollment = useCreateEnrollment()
6264
const router = useRouter()
65+
const posthog = usePostHog()
6366
const nextRunId = course.next_run_id
6467
const nextRun = course.courseruns.find((run) => run.id === nextRunId)
6568
const enrollmentDecision = getCourseEnrollmentAction(course)
@@ -90,7 +93,16 @@ const CourseEnrollmentButton: React.FC<CourseEnrollmentButtonProps> = ({
9093
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
9194
if (me.isLoading) {
9295
return
93-
} else if (me.data?.is_authenticated) {
96+
}
97+
if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) {
98+
posthog.capture(PostHogEvents.CallToActionClicked, {
99+
resourceId: course.id,
100+
readableId: course.readable_id,
101+
resourceType: "course",
102+
label: getButtonText(nextRun, price?.finalPrice),
103+
})
104+
}
105+
if (me.data?.is_authenticated) {
94106
if (enrollmentDecision.type === "dialog") {
95107
NiceModal.show(CourseEnrollmentDialog, { course })
96108
} else if (enrollmentDecision.type === "checkout") {

frontends/main/src/app-pages/ProductPages/MitxOnlineResourceCard.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"use client"
22

33
import React from "react"
4+
import { usePostHog } from "posthog-js/react"
45
import { BaseLearningResourceCard } from "ol-components"
6+
import { PostHogEvents } from "@/common/constants"
57
import type {
68
CourseWithCourseRunsSerializerV2,
79
V2ProgramDetail,
@@ -17,6 +19,7 @@ type CommonCardProps = {
1719
headingLevel?: number
1820
className?: string
1921
list?: boolean
22+
label?: string
2023
}
2124

2225
type MitxOnlineCourseCardProps = CommonCardProps & {
@@ -156,13 +159,15 @@ const extractCardData = (
156159
const MitxOnlineResourceCard: React.FC<MitxOnlineResourceCardProps> = (
157160
props,
158161
) => {
162+
const posthog = usePostHog()
159163
const {
160164
href,
161165
size = "small",
162166
isLoading,
163167
headingLevel = 6,
164168
className,
165169
list,
170+
label,
166171
} = props
167172

168173
if (isLoading) {
@@ -198,6 +203,16 @@ const MitxOnlineResourceCard: React.FC<MitxOnlineResourceCardProps> = (
198203
startDate={data.startDate}
199204
ariaLabel={`${data.displayType}: ${data.title}`}
200205
list={list}
206+
onClick={() => {
207+
if (process.env.NEXT_PUBLIC_POSTHOG_API_KEY) {
208+
posthog.capture(PostHogEvents.CourseCardClicked, {
209+
label,
210+
resourceId: props.resource?.id,
211+
readableId: props.resource?.readable_id,
212+
resourceType: props.resourceType,
213+
})
214+
}
215+
}}
201216
/>
202217
)
203218
}

frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.test.tsx

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,21 @@ import {
1313
urls as mitxUrls,
1414
factories as mitxFactories,
1515
} from "api/mitxonline-test-utils"
16-
import { useFeatureFlagEnabled } from "posthog-js/react"
16+
import { useFeatureFlagEnabled, usePostHog } from "posthog-js/react"
1717
import { programView } from "@/common/urls"
1818
import { mitxonlineLegacyUrl } from "@/common/mitxonline"
1919
import * as routes from "@/common/urls"
20+
import { PostHogEvents } from "@/common/constants"
2021

2122
jest.mock("posthog-js/react")
2223
const mockedUseFeatureFlagEnabled = jest
2324
.mocked(useFeatureFlagEnabled)
2425
.mockImplementation(() => false)
26+
const mockCapture = jest.fn()
27+
jest.mocked(usePostHog).mockReturnValue(
28+
// @ts-expect-error Not mocking all of posthog
29+
{ capture: mockCapture },
30+
)
2531

2632
const makeProgram = mitxFactories.programs.program
2733
const makeProgramEnrollment = mitxFactories.enrollment.programEnrollmentV3
@@ -399,4 +405,75 @@ describe("ProgramEnrollmentButton", () => {
399405

400406
screen.getByTestId("signup-popover")
401407
})
408+
409+
describe("PostHog tracking", () => {
410+
beforeEach(() => {
411+
process.env.NEXT_PUBLIC_POSTHOG_API_KEY = "test-key"
412+
mockCapture.mockClear()
413+
})
414+
afterEach(() => {
415+
delete process.env.NEXT_PUBLIC_POSTHOG_API_KEY
416+
})
417+
418+
test("fires cta_clicked when authenticated user clicks enroll", async () => {
419+
const program = makeProgram({
420+
enrollment_modes: [makeEnrollmentMode({ requires_payment: false })],
421+
})
422+
setMockResponse.get(mitxUrls.programEnrollments.enrollmentsListV3(), [])
423+
setMockResponse.get(
424+
urls.userMe.get(),
425+
makeUser({ is_authenticated: true }),
426+
)
427+
setMockResponse.post(
428+
mitxUrls.programEnrollments.enrollmentsListV3(),
429+
null,
430+
{ code: 201 },
431+
)
432+
433+
renderWithProviders(
434+
<ProgramEnrollmentButton program={program} variant="primary" />,
435+
)
436+
await user.click(
437+
await screen.findByRole("button", { name: "Enroll for Free" }),
438+
)
439+
440+
expect(mockCapture).toHaveBeenCalledWith(
441+
PostHogEvents.CallToActionClicked,
442+
expect.objectContaining({
443+
resourceId: program.id,
444+
readableId: program.readable_id,
445+
resourceType: "program",
446+
}),
447+
)
448+
})
449+
450+
test("fires cta_clicked when unauthenticated user clicks enroll", async () => {
451+
const program = makeProgram({
452+
enrollment_modes: [makeEnrollmentMode({ requires_payment: false })],
453+
})
454+
setMockResponse.get(mitxUrls.programEnrollments.enrollmentsListV3(), [], {
455+
code: 403,
456+
})
457+
setMockResponse.get(
458+
urls.userMe.get(),
459+
makeUser({ is_authenticated: false }),
460+
)
461+
462+
renderWithProviders(
463+
<ProgramEnrollmentButton program={program} variant="primary" />,
464+
)
465+
await user.click(
466+
await screen.findByRole("button", { name: "Enroll for Free" }),
467+
)
468+
469+
expect(mockCapture).toHaveBeenCalledWith(
470+
PostHogEvents.CallToActionClicked,
471+
expect.objectContaining({
472+
resourceId: program.id,
473+
readableId: program.readable_id,
474+
resourceType: "program",
475+
}),
476+
)
477+
})
478+
})
402479
})

0 commit comments

Comments
 (0)