add GA4 un/subscribe events (#5101)
* store GA client_id in DB on page load, and transmit purchase event on subscribe notification from FxA
This commit is contained in:
Родитель
a8b6b4da70
Коммит
70c6f1a330
3
.env
3
.env
|
@ -162,4 +162,5 @@ SENTRY_AUTH_TOKEN=
|
|||
# Whether GA4 sends data or not. NOTE: must be set in build environment.
|
||||
NEXT_PUBLIC_GA4_DEBUG_MODE=true
|
||||
|
||||
CURRENT_COUPON_CODE_ID=
|
||||
CURRENT_COUPON_CODE_ID=
|
||||
GA4_API_SECRET=unsafe-default-secret-for-dev
|
||||
|
|
|
@ -75,25 +75,21 @@
|
|||
// restriction.
|
||||
"name": "react-aria",
|
||||
"importNames": ["VisuallyHidden"],
|
||||
"message":
|
||||
"Please use the <VisuallyHidden> component from `/src/app/components/server/VisuallyHidden.tsx` instead of the one from react-aria, since the latter's (inline) styles will be stripped by our Content Security Policy."
|
||||
"message": "Please use the <VisuallyHidden> component from `/src/app/components/server/VisuallyHidden.tsx` instead of the one from react-aria, since the latter's (inline) styles will be stripped by our Content Security Policy."
|
||||
},
|
||||
{
|
||||
"name": "next-auth",
|
||||
"importNames": ["getServerSession"],
|
||||
"message":
|
||||
"Please use the `getServerSession` wrapper function from `/src/app/functions/server/getServerSession.ts` instead of the one from next-auth, since the latter's doesn't enforce passing the auth configuration object, resulting in broken sessions."
|
||||
"message": "Please use the `getServerSession` wrapper function from `/src/app/functions/server/getServerSession.ts` instead of the one from next-auth, since the latter's doesn't enforce passing the auth configuration object, resulting in broken sessions."
|
||||
},
|
||||
{
|
||||
"name": "next-auth/next",
|
||||
"importNames": ["getServerSession"],
|
||||
"message":
|
||||
"Please use the `getServerSession` wrapper function from `/src/app/functions/server/getServerSession.ts` instead of the one from next-auth, since the latter's doesn't enforce passing the auth configuration object, resulting in broken sessions."
|
||||
"message": "Please use the `getServerSession` wrapper function from `/src/app/functions/server/getServerSession.ts` instead of the one from next-auth, since the latter's doesn't enforce passing the auth configuration object, resulting in broken sessions."
|
||||
},
|
||||
{
|
||||
"name": "server-only",
|
||||
"message":
|
||||
"Please import `/src/app/functions/server/notInClientComponent` instead of `server-only`, since the latter will also error in non-Next.js environments like cron jobs."
|
||||
"message": "Please import `/src/app/functions/server/notInClientComponent` instead of `server-only`, since the latter will also error in non-Next.js environments like cron jobs."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { headers } from "next/headers";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { getServerSession } from "../functions/server/getServerSession";
|
||||
import { getL10nBundles } from "../functions/l10n/serverComponents";
|
||||
import { getLocale } from "../functions/universal/getLocale";
|
||||
|
@ -14,6 +14,8 @@ import { getCountryCode } from "../functions/server/getCountryCode";
|
|||
import { PageLoadEvent } from "../components/client/PageLoadEvent";
|
||||
import { getExperimentationId } from "../functions/server/getExperimentationId";
|
||||
import { getEnabledFeatureFlags } from "../../db/tables/featureFlags";
|
||||
import { addClientIdForSubscriber } from "../../db/tables/google_analytics_clients";
|
||||
import { logger } from "../functions/server/logging";
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
const l10nBundles = getL10nBundles();
|
||||
|
@ -24,6 +26,39 @@ export default async function Layout({ children }: { children: ReactNode }) {
|
|||
email: session?.user.email ?? "",
|
||||
});
|
||||
|
||||
const cookieStore = cookies();
|
||||
// This expects the default Google Analytics cookie documented here: https://support.google.com/analytics/answer/11397207?hl=en
|
||||
const gaCookie = cookieStore.get("_ga");
|
||||
|
||||
if (gaCookie && gaCookie.value) {
|
||||
const [gaCookieVersion, gaCookiePath, gaCookieClientId, gaCookieTimestamp] =
|
||||
gaCookie.value.split(".");
|
||||
if (session?.user.subscriber?.id) {
|
||||
try {
|
||||
const parsedCookieTimestamp = new Date(
|
||||
parseInt(gaCookieTimestamp) * 1000,
|
||||
);
|
||||
await addClientIdForSubscriber(
|
||||
session?.user.subscriber?.id,
|
||||
gaCookieVersion,
|
||||
parseInt(gaCookiePath),
|
||||
gaCookieClientId,
|
||||
parsedCookieTimestamp,
|
||||
);
|
||||
} catch (ex) {
|
||||
if (ex instanceof Error) {
|
||||
logger.error("Could not parse _ga cookie from header", {
|
||||
message: ex.message,
|
||||
});
|
||||
} else {
|
||||
logger.error("Could not parse _ga cookie from header", {
|
||||
message: JSON.stringify(ex),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<L10nProvider bundleSources={l10nBundles}>
|
||||
<ReactAriaI18nProvider locale={getLocale(l10nBundles)}>
|
||||
|
|
|
@ -25,6 +25,8 @@ import { revokeOAuthTokens } from "../../../../utils/fxa";
|
|||
import { changeSubscription } from "../../../functions/server/changeSubscription";
|
||||
import { deleteAccount } from "../../../functions/server/deleteAccount";
|
||||
import { record } from "../../../functions/server/glean";
|
||||
import { sendPingToGA } from "../../../functions/server/googleAnalytics";
|
||||
import { getEnabledFeatureFlags } from "../../../../db/tables/featureFlags";
|
||||
|
||||
const FXA_PROFILE_CHANGE_EVENT =
|
||||
"https://schemas.accounts.firefox.com/event/profile-change";
|
||||
|
@ -284,6 +286,10 @@ export async function POST(request: NextRequest) {
|
|||
oneRepProfileId,
|
||||
});
|
||||
|
||||
const enabledFeatureFlags = await getEnabledFeatureFlags({
|
||||
isSignedOut: true,
|
||||
});
|
||||
|
||||
if (
|
||||
updatedSubscriptionFromEvent.isActive &&
|
||||
updatedSubscriptionFromEvent.capabilities.includes(
|
||||
|
@ -357,6 +363,10 @@ export async function POST(request: NextRequest) {
|
|||
monitorUserId: subscriber.id.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (enabledFeatureFlags.includes("GA4SubscriptionEvents")) {
|
||||
await sendPingToGA(subscriber.id, "subscribe");
|
||||
}
|
||||
} else if (
|
||||
!updatedSubscriptionFromEvent.isActive &&
|
||||
updatedSubscriptionFromEvent.capabilities.includes(
|
||||
|
@ -410,6 +420,10 @@ export async function POST(request: NextRequest) {
|
|||
monitorUserId: subscriber.id.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (enabledFeatureFlags.includes("GA4SubscriptionEvents")) {
|
||||
await sendPingToGA(subscriber.id, "unsubscribe");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
captureMessage(
|
||||
|
|
|
@ -0,0 +1,168 @@
|
|||
/* 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 { it, expect, jest } from "@jest/globals";
|
||||
import { CONST_GA4_MEASUREMENT_ID } from "../../../constants";
|
||||
|
||||
jest.mock("../../../db/tables/google_analytics_clients", () => {
|
||||
return {
|
||||
getClientIdForSubscriber: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
client_id: "testClientId1",
|
||||
cookie_timestamp: new Date(1234),
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("./logging", () => {
|
||||
class Logging {
|
||||
error(message: string, details: object) {
|
||||
console.error(message, details);
|
||||
}
|
||||
}
|
||||
|
||||
const logger = new Logging();
|
||||
return {
|
||||
logger,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("sends event name and parameters to GA", async () => {
|
||||
global.fetch = jest.fn<typeof global.fetch>().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(),
|
||||
} as Response);
|
||||
|
||||
const { sendPingToGA } = await import("./googleAnalytics");
|
||||
await sendPingToGA(0, "testEvent", { testParam1: "testValue1" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`https://www.google-analytics.com/mp/collect?measurement_id=${CONST_GA4_MEASUREMENT_ID}&api_secret=${process.env.GA4_API_SECRET}`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
client_id: "testClientId1.1",
|
||||
events: [
|
||||
{
|
||||
name: "testEvent",
|
||||
params: {
|
||||
testParam1: "testValue1",
|
||||
debug_mode: "true",
|
||||
engagement_time_msec: "1",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("sends event name and parameters to GA and receives error response", async () => {
|
||||
global.fetch = jest.fn<typeof global.fetch>().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("failed"),
|
||||
} as Response);
|
||||
|
||||
const loggingSpy = jest.spyOn(console, "error");
|
||||
|
||||
const { sendPingToGA } = await import("./googleAnalytics");
|
||||
await sendPingToGA(0, "testEvent", { testParam1: "testValue1" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`https://www.google-analytics.com/mp/collect?measurement_id=${CONST_GA4_MEASUREMENT_ID}&api_secret=${process.env.GA4_API_SECRET}`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
client_id: "testClientId1.1",
|
||||
events: [
|
||||
{
|
||||
name: "testEvent",
|
||||
params: {
|
||||
testParam1: "testValue1",
|
||||
debug_mode: "true",
|
||||
engagement_time_msec: "1",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
expect(loggingSpy).toHaveBeenCalledWith("Could not send backend ping to GA", {
|
||||
status: 500,
|
||||
text: "failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws exception when no client_id is stored", async () => {
|
||||
jest.mock("../../../db/tables/google_analytics_clients", () => {
|
||||
return {
|
||||
getClientIdForSubscriber: jest.fn(() => ""),
|
||||
};
|
||||
});
|
||||
|
||||
const { sendPingToGA } = await import("./googleAnalytics");
|
||||
await expect(
|
||||
sendPingToGA(0, "testEvent", { testParam1: "testValue1" }),
|
||||
).rejects.toEqual(Error("No stored GA cookie for subscriber [0]"));
|
||||
});
|
||||
|
||||
it("throws exception client_id is not present", async () => {
|
||||
jest.mock("../../../db/tables/google_analytics_clients", () => {
|
||||
return {
|
||||
getClientIdForSubscriber: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
cookie_timestamp: new Date(1234),
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
const { sendPingToGA } = await import("./googleAnalytics");
|
||||
await expect(
|
||||
sendPingToGA(0, "testEvent", { testParam1: "testValue1" }),
|
||||
).rejects.toEqual(
|
||||
Error(
|
||||
"No GA client_id found for subscriber [0], cannot send backend events to Google Analytics",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws exception cookie_timestamp is not present", async () => {
|
||||
jest.mock("../../../db/tables/google_analytics_clients", () => {
|
||||
return {
|
||||
getClientIdForSubscriber: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
client_id: "testClientId1",
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
const { sendPingToGA } = await import("./googleAnalytics");
|
||||
await expect(
|
||||
sendPingToGA(0, "testEvent", { testParam1: "testValue1" }),
|
||||
).rejects.toEqual(
|
||||
Error(
|
||||
"No GA client_id found for subscriber [0], cannot send backend events to Google Analytics",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws exception when required env vars are not set", async () => {
|
||||
delete process.env.GA4_API_SECRET;
|
||||
const { sendPingToGA } = await import("./googleAnalytics");
|
||||
await expect(
|
||||
sendPingToGA(0, "testEvent", { testParam1: "testValue1" }),
|
||||
).rejects.toEqual(
|
||||
Error(
|
||||
"No GA4 API secret is defined, cannot send backend events Google Analytics",
|
||||
),
|
||||
);
|
||||
});
|
|
@ -0,0 +1,77 @@
|
|||
/* 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 { getClientIdForSubscriber } from "../../../db/tables/google_analytics_clients";
|
||||
import { CONST_GA4_MEASUREMENT_ID } from "../../../constants";
|
||||
import { logger } from "./logging";
|
||||
|
||||
/**
|
||||
* Send an event to GA4 backend using the GA Measurement Protocol.
|
||||
*
|
||||
* @param subscriberId - Monitor subscriber ID.
|
||||
* @param eventName - the name of the ping (e.g. "purchase").
|
||||
* @param eventParams - optional object containing key/value string pairs with additional information about this ping.
|
||||
* @see https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag
|
||||
*/
|
||||
export async function sendPingToGA(
|
||||
subscriberId: number,
|
||||
eventName: string,
|
||||
eventParams: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
const apiSecret = process.env.GA4_API_SECRET;
|
||||
if (!apiSecret) {
|
||||
throw new Error(
|
||||
"No GA4 API secret is defined, cannot send backend events Google Analytics",
|
||||
);
|
||||
}
|
||||
|
||||
const gaClientInfo = await getClientIdForSubscriber(subscriberId);
|
||||
if (!gaClientInfo) {
|
||||
throw new Error(`No stored GA cookie for subscriber [${subscriberId}]`);
|
||||
}
|
||||
|
||||
const { client_id, cookie_timestamp } = gaClientInfo;
|
||||
|
||||
if (!client_id || !cookie_timestamp) {
|
||||
throw new Error(
|
||||
`No GA client_id found for subscriber [${subscriberId}], cannot send backend events to Google Analytics`,
|
||||
);
|
||||
}
|
||||
|
||||
const clientId = `${client_id}.${Math.floor(cookie_timestamp.getTime() / 1000)}`;
|
||||
|
||||
// Do not show these pings in the production environment by default. These will show up in the DebugView dashboard.
|
||||
// @see https://developers.google.com/analytics/devguides/collection/protocol/ga4/verify-implementation?client_type=gtag
|
||||
if (process.env.APP_ENV !== "production") {
|
||||
eventParams["debug_mode"] = "true";
|
||||
}
|
||||
|
||||
// GA will not display unless there is a non-0 amount of engagement time.
|
||||
eventParams["engagement_time_msec"] = "1";
|
||||
|
||||
const result = await fetch(
|
||||
// Pings can alternatively be sent to the validation server for debugging purposes.
|
||||
// @see https://developers.google.com/analytics/devguides/collection/protocol/ga4/validating-events?client_type=gtag
|
||||
`https://www.google-analytics.com/mp/collect?measurement_id=${CONST_GA4_MEASUREMENT_ID}&api_secret=${apiSecret}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
client_id: clientId,
|
||||
events: [
|
||||
{
|
||||
name: eventName,
|
||||
params: eventParams,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
logger.error("Could not send backend ping to GA", {
|
||||
status: result.status,
|
||||
text: await result.text(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/* 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/. */
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable('google_analytics_clients', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.integer("subscriber_id").references("subscribers.id").unique().notNullable().onDelete("CASCADE");
|
||||
table.string("cookie_version");
|
||||
table.integer("cookie_path");
|
||||
table.string("client_id").unique();
|
||||
table.timestamp("cookie_timestamp");
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param { import("knex").Knex } knex
|
||||
* @returns { Promise<void> }
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.dropTable("google_analytics_clients")
|
||||
}
|
|
@ -50,6 +50,7 @@ export const featureFlagNames = [
|
|||
"PetitionBannerCsatSurvey",
|
||||
"MonthlyReportFreeUser",
|
||||
"BreachEmailRedesign",
|
||||
"GA4SubscriptionEvents",
|
||||
] as const;
|
||||
export type FeatureFlagName = (typeof featureFlagNames)[number];
|
||||
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/* 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 { GoogleAnalyticsClientsRow } from "../../knex-tables";
|
||||
import createDbConnection from "../connect";
|
||||
|
||||
const knex = createDbConnection();
|
||||
|
||||
async function addClientIdForSubscriber(
|
||||
subscriberId: number,
|
||||
cookieVersion: string,
|
||||
cookiePath: number,
|
||||
gaClientId: string,
|
||||
cookieTimestamp: Date,
|
||||
): Promise<void> {
|
||||
await knex("google_analytics_clients")
|
||||
.insert({
|
||||
subscriber_id: subscriberId,
|
||||
cookie_version: cookieVersion,
|
||||
cookie_path: cookiePath,
|
||||
client_id: gaClientId,
|
||||
cookie_timestamp: cookieTimestamp,
|
||||
})
|
||||
.onConflict("subscriber_id")
|
||||
.merge()
|
||||
.onConflict("client_id")
|
||||
.merge();
|
||||
}
|
||||
|
||||
async function getClientIdForSubscriber(
|
||||
subscriberId: number,
|
||||
): Promise<GoogleAnalyticsClientsRow> {
|
||||
return (await knex("google_analytics_clients")
|
||||
.select("client_id", "cookie_timestamp")
|
||||
.where(
|
||||
"subscriber_id",
|
||||
subscriberId,
|
||||
)) as unknown as GoogleAnalyticsClientsRow;
|
||||
}
|
||||
|
||||
export { addClientIdForSubscriber, getClientIdForSubscriber };
|
|
@ -520,3 +520,12 @@ declare module "knex/types/tables" {
|
|||
>;
|
||||
}
|
||||
}
|
||||
|
||||
interface GoogleAnalyticsClientsRow {
|
||||
id: number;
|
||||
subscriber_id: number;
|
||||
cookie_version: string;
|
||||
cookie_path: string;
|
||||
client_id: string;
|
||||
cookie_timestamp: Date;
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче