diff --git a/app/routes/action.send-rating.ts b/app/routes/action.send-rating.ts index 452061ce1..bac146130 100644 --- a/app/routes/action.send-rating.ts +++ b/app/routes/action.send-rating.ts @@ -3,17 +3,12 @@ import { json, redirect } from "@remix-run/node"; import { BannerState } from "~/components/UserFeedback"; import { userRatingFieldname } from "~/components/UserFeedback/RatingBox"; import { getSessionForContext } from "~/services/session"; -import { PostHog } from "posthog-node"; import { config } from "~/services/env/web"; import { bannerStateName } from "~/services/feedback/handleFeedback"; +import { getPosthogClient } from "~/services/analytics/posthogClient.server"; export const loader = () => redirect("/"); -const { POSTHOG_API_KEY, POSTHOG_API_HOST, ENVIRONMENT } = config(); -const posthogClient = POSTHOG_API_KEY - ? new PostHog(POSTHOG_API_KEY, { host: POSTHOG_API_HOST }) - : undefined; - export const action = async ({ request }: ActionFunctionArgs) => { const { searchParams } = new URL(request.url); const clientJavaScriptAvailable = searchParams.get("js") === "true"; @@ -37,8 +32,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { const headers = { "Set-Cookie": await commitSession(session) }; - posthogClient?.capture({ - distinctId: ENVIRONMENT, + getPosthogClient()?.capture({ + distinctId: config().ENVIRONMENT, event: "rating given", // eslint-disable-next-line camelcase properties: { wasHelpful: userRatings[url], $current_url: url, context }, diff --git a/app/routes/shared/step.tsx b/app/routes/shared/step.tsx index fb72035ab..e8b9c62be 100644 --- a/app/routes/shared/step.tsx +++ b/app/routes/shared/step.tsx @@ -41,6 +41,7 @@ import { navItemsFromFlowSpecifics } from "~/services/flowNavigation"; import type { z } from "zod"; import type { CollectionSchemas } from "~/services/cms/schemas"; import { getButtonNavigationProps } from "~/util/getButtonNavigationProps"; +import { sendCustomEvent } from "~/services/analytics/customEvent"; const structureCmsContent = ( formPageContent: z.infer< @@ -221,6 +222,11 @@ export const action = async ({ params, request }: ActionFunctionArgs) => { data: flowSession.data, guards: flowSpecifics[flowId].guards, }); + + const customEventName = flowController.getMeta(stepId)?.customEventName; + if (customEventName) + void sendCustomEvent(customEventName, validationResult.data, request); + return redirect(flowController.getNext(stepId).url, { headers }); }; diff --git a/app/services/analytics/customEvent.ts b/app/services/analytics/customEvent.ts new file mode 100644 index 000000000..b81c6df59 --- /dev/null +++ b/app/services/analytics/customEvent.ts @@ -0,0 +1,33 @@ +import { hasTrackingConsent } from "./gdprCookie.server"; +import { config } from "../env/web"; +import { parse } from "cookie"; +import { getPosthogClient } from "./posthogClient.server"; + +function idFromCookie(request: Request) { + // Note: can't use cookie.parse(): https://github.com/remix-run/remix/discussions/5198 + // Returns ENVIRONMENT if posthog's distinct_id can't be extracted + const { POSTHOG_API_KEY, ENVIRONMENT } = config(); + if (!POSTHOG_API_KEY) return ENVIRONMENT; + const parsedCookie = parse(request.headers.get("Cookie") ?? ""); + const phCookieString = parsedCookie[`ph_${POSTHOG_API_KEY}_posthog`] ?? "{}"; + const phCookieObject = JSON.parse(phCookieString) as Record; + return phCookieObject["distinct_id"] ?? ENVIRONMENT; +} + +export async function sendCustomEvent( + eventName: string, + context: Record, + request: Request, +) { + if (!(await hasTrackingConsent({ request }))) return; + + getPosthogClient()?.capture({ + distinctId: idFromCookie(request), + event: eventName, + properties: { + // eslint-disable-next-line camelcase + $current_url: new URL(request.url).pathname, + ...context, + }, + }); +} diff --git a/app/services/analytics/posthogClient.server.ts b/app/services/analytics/posthogClient.server.ts new file mode 100644 index 000000000..862993b19 --- /dev/null +++ b/app/services/analytics/posthogClient.server.ts @@ -0,0 +1,9 @@ +import { PostHog } from "posthog-node"; +import { config } from "../env/web"; + +const { POSTHOG_API_KEY, POSTHOG_API_HOST } = config(); +const posthogClient = POSTHOG_API_KEY + ? new PostHog(POSTHOG_API_KEY, { host: POSTHOG_API_HOST }) + : undefined; + +export const getPosthogClient = () => posthogClient; diff --git a/app/services/feedback/handleFeedback.ts b/app/services/feedback/handleFeedback.ts index b0acfd948..0d8c2d681 100644 --- a/app/services/feedback/handleFeedback.ts +++ b/app/services/feedback/handleFeedback.ts @@ -7,13 +7,8 @@ import { import { userRatingFieldname } from "~/components/UserFeedback/RatingBox"; import { validationError } from "remix-validated-form"; import { config } from "../env/web"; -import { PostHog } from "posthog-node"; import { type Session, redirect } from "@remix-run/node"; - -const { POSTHOG_API_KEY, POSTHOG_API_HOST, ENVIRONMENT } = config(); -const posthogClient = POSTHOG_API_KEY - ? new PostHog(POSTHOG_API_KEY, { host: POSTHOG_API_HOST }) - : undefined; +import { getPosthogClient } from "../analytics/posthogClient.server"; export const bannerStateName = "bannerState"; @@ -35,8 +30,8 @@ export const handleFeedback = async (formData: FormData, request: Request) => { return validationError(result.error, result.submittedData); } bannerState[pathname] = BannerState.FeedbackGiven; - posthogClient?.capture({ - distinctId: ENVIRONMENT, + getPosthogClient()?.capture({ + distinctId: config().ENVIRONMENT, event: "feedback given", properties: { wasHelpful: userRating[pathname], diff --git a/app/services/flow/buildFlowController.ts b/app/services/flow/buildFlowController.ts index aa5614ffe..a938e8214 100644 --- a/app/services/flow/buildFlowController.ts +++ b/app/services/flow/buildFlowController.ts @@ -12,6 +12,7 @@ type StateMachine = ReturnType< export type Config = MachineConfig; export type Guards = Record boolean>; export type Meta = { + customEventName?: string; progressPosition: number | undefined; isUneditable: boolean | undefined; done: (context: Context) => boolean | undefined; @@ -70,18 +71,15 @@ export const buildFlowController = ({ const isInitialStepId = (currentStepId: string) => initialStepId === normalizeStepId(currentStepId); + const getMeta = (currentStepId: string): Meta | undefined => + machine.getStateNodeByPath(normalizeStepId(currentStepId)).meta; + return { - getMeta: (currentStepId: string): Meta => { - return machine.getStateNodeByPath(normalizeStepId(currentStepId)).meta; - }, - isDone: (currentStepId: string) => { - const meta: Meta = machine.getStateNodeByPath(currentStepId).meta; - return meta && "done" in meta && meta.done(context) === true; - }, - isUneditable: (currentStepId: string) => { - const meta: Meta = machine.getStateNodeByPath(currentStepId).meta; - return meta && meta.isUneditable === true; - }, + getMeta, + isDone: (currentStepId: string) => + Boolean(getMeta(currentStepId)?.done(context)), + isUneditable: (currentStepId: string) => + Boolean(getMeta(currentStepId)?.isUneditable), getFlow: () => config, isInitial: isInitialStepId, isFinal: (currentStepId: string) =>