* store GA client_id in DB on page load, and transmit purchase event on subscribe notification from FxA
This commit is contained in:
Robert Helmer 2024-09-30 14:51:55 -07:00 коммит произвёл GitHub
Родитель a8b6b4da70
Коммит 70c6f1a330
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
10 изменённых файлов: 379 добавлений и 10 удалений

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 };

9
src/knex-tables.d.ts поставляемый
Просмотреть файл

@ -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;
}