Skip to content

Commit

Permalink
Merge pull request #4794 from mozilla/mntor-3358-view-telemetry
Browse files Browse the repository at this point in the history
Add view telemetry for CTA buttons (MNTOR-3358)
  • Loading branch information
flozia authored Jul 22, 2024
2 parents 4066d81 + bcf8cff commit 5a3a31f
Show file tree
Hide file tree
Showing 10 changed files with 143 additions and 27 deletions.
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
"react-aria": "^3.33.1",
"react-cookie": "^7.1.0",
"react-dom": "^18.2.0",
"react-intersection-observer": "^9.10.3",
"react-stately": "^3.31.1",
"server-only": "^0.0.1",
"uuid": "^9.0.1",
Expand Down Expand Up @@ -146,7 +147,6 @@
"lint-staged": "^15.2.7",
"mjml-browser": "^4.15.3",
"prettier": "3.3.3",
"react-intersection-observer": "^9.10.3",
"sass": "^1.77.6",
"storybook": "^8.1.11",
"stylelint": "^16.7.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ export type TabData = {

export const View = (props: Props) => {
const l10n = useL10n();
const recordTelemetry = useTelemetry(props.experimentationId);
const recordTelemetry = useTelemetry({
experimentationId: props.experimentationId,
});
const countryCode = useContext(CountryCodeContext);
const pathname = usePathname();

Expand Down
25 changes: 10 additions & 15 deletions src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,14 @@

import { signIn } from "next-auth/react";
import { useCookies } from "react-cookie";
import { SignUpForm, Props as SignUpFormProps } from "./SignUpForm";
import { Props, SignUpForm } from "./SignUpForm";
import { TelemetryButton } from "../../../components/client/TelemetryButton";
import { modifyAttributionsForUrlSearchParams } from "../../../functions/universal/attributions";
import { ExperimentData } from "../../../../telemetry/generated/nimbus/experiments";
import { useL10n } from "../../../hooks/l10n";
import { WaitlistCta } from "./ScanLimit";

export type Props = {
eligibleForPremium: boolean;
signUpCallbackUrl: string;
isHero?: boolean;
eventId: {
cta: string;
field?: string;
};
scanLimitReached: boolean;
placeholder?: string;
};
import { useViewTelemetry } from "../../../hooks/useViewTelemetry";
import { RefObject } from "react";

export function getAttributionSearchParams({
cookies,
Expand Down Expand Up @@ -60,12 +50,16 @@ export function getAttributionSearchParams({
}

export const FreeScanCta = (
props: SignUpFormProps & {
props: Props & {
experimentData: ExperimentData;
},
) => {
const l10n = useL10n();
const [cookies] = useCookies(["attributionsFirstTouch"]);
const telemetryButtonId = `${props.eventId.cta}-${props.experimentData["landing-page-free-scan-cta"].variant}`;
const refViewTelemetry = useViewTelemetry("ctaButton", {
button_id: telemetryButtonId,
});
if (
!props.experimentData["landing-page-free-scan-cta"].enabled ||
props.experimentData["landing-page-free-scan-cta"].variant ===
Expand All @@ -88,12 +82,13 @@ export const FreeScanCta = (
) : (
<div>
<TelemetryButton
buttonRef={refViewTelemetry as RefObject<HTMLButtonElement>}
variant="primary"
event={{
module: "ctaButton",
name: "click",
data: {
button_id: `${props.eventId.cta}-${props.experimentData["landing-page-free-scan-cta"].variant}`,
button_id: telemetryButtonId,
},
}}
onPress={() => {
Expand Down
37 changes: 36 additions & 1 deletion src/app/(proper_react)/(redesign)/(public)/LandingView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { it, expect } from "@jest/globals";
import { composeStory } from "@storybook/react";
import {
act,
getAllByRole,
getByRole,
getByText,
Expand All @@ -27,6 +28,7 @@ import Meta, {
} from "./LandingView.stories";
import { deleteAllCookies } from "../../../functions/client/deleteAllCookies";
import { defaultExperimentData } from "../../../../telemetry/generated/nimbus/experiments";
import { mockIsIntersecting } from "react-intersection-observer/test-utils";

jest.mock("next-auth/react", () => {
return {
Expand Down Expand Up @@ -950,7 +952,7 @@ describe("Free scan CTA experiment", () => {
expect(waitlistCta[0]).toBeInTheDocument();
});

it("sends telemetry for the different experiment variants", async () => {
it("sends telemetry when clicking on one of the experiment variants", async () => {
const mockedRecord = useTelemetry();
const user = userEvent.setup();
const ComposedDashboard = composeStory(LandingUs, Meta);
Expand Down Expand Up @@ -983,6 +985,39 @@ describe("Free scan CTA experiment", () => {
);
});

it("sends telemetry when a free scan CTA is shown in the viewport", () => {
const mockedRecord = useTelemetry();
const ComposedDashboard = composeStory(LandingUs, Meta);
render(
<ComposedDashboard
experimentData={{
...defaultExperimentData,
"landing-page-free-scan-cta": {
enabled: true,
variant: "ctaOnly",
},
}}
/>,
);

// jsdom will complain about not being able to navigate to a different page
// after clicking the link; suppress that error, as it's not relevant to the
// test:
jest.spyOn(console, "error").mockImplementation(() => undefined);

const submitButton = screen.getAllByRole("button", {
name: "Get free scan",
});
act(() => {
mockIsIntersecting(submitButton[0], true);
});
expect(mockedRecord).toHaveBeenCalledWith(
"ctaButton",
"view",
expect.objectContaining({ button_id: "clicked_get_scan_header-ctaOnly" }),
);
});

it("passes the expected URL to the identity provider", async () => {
const user = userEvent.setup();
const ComposedDashboard = composeStory(LandingUs, Meta);
Expand Down
16 changes: 12 additions & 4 deletions src/app/(proper_react)/(redesign)/(public)/SignUpForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@

"use client";

import { FormEventHandler, useId, useState } from "react";
import { FormEventHandler, RefObject, useId, useState } from "react";
import { signIn } from "next-auth/react";
import { useL10n } from "../../../hooks/l10n";
import { Button } from "../../../components/client/Button";
import styles from "./SignUpForm.module.scss";
import { useTelemetry } from "../../../hooks/useTelemetry";
import { useViewTelemetry } from "../../../hooks/useViewTelemetry";
import { VisuallyHidden } from "../../../components/server/VisuallyHidden";
import { WaitlistCta } from "./ScanLimit";
import { useCookies } from "react-cookie";
Expand All @@ -18,14 +19,14 @@ import { ExperimentData } from "../../../../telemetry/generated/nimbus/experimen

export type Props = {
eligibleForPremium: boolean;
signUpCallbackUrl: string;
isHero?: boolean;
eventId: {
cta: string;
field?: string;
};
scanLimitReached: boolean;
signUpCallbackUrl: string;
experimentData?: ExperimentData;
isHero?: boolean;
placeholder?: string;
};

Expand All @@ -34,6 +35,9 @@ export const SignUpForm = (props: Props) => {
const l10n = useL10n();
const [emailInput, setEmailInput] = useState("");
const record = useTelemetry();
const refViewTelemetry = useViewTelemetry("ctaButton", {
button_id: props.eventId.cta,
});
const [cookies] = useCookies(["attributionsFirstTouch"]);

const onSubmit: FormEventHandler = (event) => {
Expand Down Expand Up @@ -62,7 +66,11 @@ export const SignUpForm = (props: Props) => {
return props.scanLimitReached ? (
<WaitlistCta />
) : (
<form className={styles.form} onSubmit={onSubmit}>
<form
ref={refViewTelemetry as RefObject<HTMLFormElement>}
className={styles.form}
onSubmit={onSubmit}
>
<input
name={emailInputId}
data-testid="signup-form-input"
Expand Down
4 changes: 3 additions & 1 deletion src/app/components/client/PageLoadEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ export const PageLoadEvent = (props: Props) => {
]);
const pathname = usePathname();

const recordTelemetry = useTelemetry(props.experimentationId);
const recordTelemetry = useTelemetry({
experimentationId: props.experimentationId,
});

if (
props.experimentationId.startsWith("guest") &&
Expand Down
8 changes: 6 additions & 2 deletions src/app/hooks/useTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ const TelemetryPlatforms = {
Ga: "ga",
} as const;

export const useTelemetry = (experimentationId?: string) => {
export type TelemetryArgs = {
experimentationId?: string;
};

export const useTelemetry = (args?: TelemetryArgs) => {
const path = usePathname();
const recordGlean = useGlean(experimentationId);
const recordGlean = useGlean(args?.experimentationId);

const { Glean, Ga } = TelemetryPlatforms;
const recordTelemetry = <
Expand Down
39 changes: 39 additions & 0 deletions src/app/hooks/useViewTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { IntersectionOptions, useInView } from "react-intersection-observer";
import { TelemetryArgs, useTelemetry } from "./useTelemetry";
import { GleanMetricMap } from "../../telemetry/generated/_map";
import { RefObject } from "react";

export function useViewTelemetry<
EventModule extends keyof Pick<GleanMetricMap, "ctaButton">,
EventName extends keyof GleanMetricMap[EventModule],
>(
eventModule: EventModule,
args: TelemetryArgs & GleanMetricMap[EventModule][EventName],
options?: IntersectionOptions,
) {
const { experimentationId, ...telemetryArgs } = args;
const recordTelemetry = useTelemetry({ experimentationId });
const { ref } = useInView({
skip: Object.keys(telemetryArgs).length === 0,
threshold: 1,
triggerOnce: true,
...options,
onChange: (inView) => {
// Since this function is only triggered once when an element is entering
// the viewport and not again after leaving it. With the current setting
// the following condition is not expected to get called. Keeping the
// condition in place in case this changes.
/* c8 ignore next 3 */
if (!inView) {
return;
}
recordTelemetry(eventModule, "view", telemetryArgs);
},
});

return ref as unknown as RefObject<unknown>;
}
32 changes: 32 additions & 0 deletions src/telemetry/metrics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,38 @@ cta_button:
description: Which tier of plan the user is on [Free, Plus]
type: string

view:
type: event
description: |
A button that has a specific call-to-action entered the viewport.
bugs:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1823766
data_reviews:
- https://bugzilla.mozilla.org/show_bug.cgi?id=1823766
data_sensitivity:
- interaction
notification_emails:
- [email protected]
expires: never
extra_keys:
path:
description: The path of the page.
type: string
user_id:
description: Mozilla accounts user ID.
type: string
session_id:
description: An ID that allows us to track “sessions” of the user experience within the product.
type: string
flow_id:
description: A randomly generated unique identifier for following user flows within the FxA system.
type: string
button_id:
description: The ID of the button that entered the viewport, or some way to identify where on the page the interaction is located.
plan_tier:
description: Which tier of plan the user is on [Free, Plus]
type: string

csat_survey:
click:
type: event
Expand Down

0 comments on commit 5a3a31f

Please sign in to comment.