From 0734de1bbd8560f2298573c5e86feb6e2accd7b6 Mon Sep 17 00:00:00 2001 From: xrendan Date: Mon, 28 Sep 2026 09:50:10 -0600 Subject: [PATCH 1/2] Track conversions and direct-mail Toronto visits --- instrumentation-client.ts | 55 ++++++++++++++ src/app/layout.tsx | 30 ++++---- src/app/voteTO/route.ts | 23 ++++++ src/components/elections/PledgeButton.tsx | 6 +- src/components/subscribe/ui/SubscribeForm.tsx | 3 +- src/lib/conversions.ts | 76 +++++++++++++++++++ 6 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 src/app/voteTO/route.ts create mode 100644 src/lib/conversions.ts diff --git a/instrumentation-client.ts b/instrumentation-client.ts index ee2e7d38..a612c378 100644 --- a/instrumentation-client.ts +++ b/instrumentation-client.ts @@ -1,10 +1,65 @@ import posthog from "posthog-js"; +const IGNORED_EXTENSION_ERROR = + "Invalid call to runtime.sendMessage(). Tab not found."; + +const EXTENSION_FRAME_PREFIXES = [ + "chrome-extension://", + "moz-extension://", + "safari-extension:", + "safari-web-extension:", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isKnownExtensionError(exception: unknown): boolean { + if (!isRecord(exception) || exception.value !== IGNORED_EXTENSION_ERROR) { + return false; + } + + const stacktrace = exception.stacktrace; + if (!isRecord(stacktrace) || !Array.isArray(stacktrace.frames)) { + return false; + } + + return stacktrace.frames.some((frame) => { + if (!isRecord(frame) || typeof frame.filename !== "string") { + return false; + } + + const filename = frame.filename; + return EXTENSION_FRAME_PREFIXES.some((prefix) => + filename.startsWith(prefix), + ); + }); +} + if (typeof window !== "undefined" && process.env.NEXT_PUBLIC_POSTHOG_TOKEN) { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_TOKEN, { api_host: "/ph", ui_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", defaults: "2026-01-30", capture_exceptions: true, + before_send: (event) => { + if (event?.event !== "$exception") { + return event; + } + + const exceptionList = event.properties.$exception_list; + const rootException = Array.isArray(exceptionList) + ? exceptionList[0] + : undefined; + + // Do not filter on the message alone: require extension provenance from + // the root exception's own stack. Stackless or app-originated lookalikes + // are deliberately retained to avoid hiding a real application error. + if (isKnownExtensionError(rootException)) { + return null; + } + + return event; + }, }); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 702ac183..28ae9e3e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,6 +14,7 @@ import { XPixel } from "@/components/XPixel"; import { MetaPixel } from "@/components/MetaPixel"; const GA_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; +const GOOGLE_ADS_ID = "AW-17738155749"; export const metadata: Metadata = { title: { @@ -57,22 +58,19 @@ export default function RootLayout({ ))} - {GA_ID && ( - <> - - - )} +
diff --git a/src/app/voteTO/route.ts b/src/app/voteTO/route.ts new file mode 100644 index 00000000..2bc864bb --- /dev/null +++ b/src/app/voteTO/route.ts @@ -0,0 +1,23 @@ +import { type NextRequest, NextResponse } from "next/server"; + +const DESTINATION = "https://www.buildcanada.com/toronto/vote/2026/"; +const CAMPAIGN_PARAMS = { + utm_source: "direct_mail", + utm_medium: "offline", + utm_campaign: "voteTO", +} as const; + +export function GET(request: NextRequest) { + const destination = new URL(DESTINATION); + destination.search = request.nextUrl.search; + + // Keep every supplied value, including custom tracking fields and repeated + // parameters. Add attribution only when the mail URL did not supply it. + for (const [key, value] of Object.entries(CAMPAIGN_PARAMS)) { + if (!destination.searchParams.has(key)) { + destination.searchParams.append(key, value); + } + } + + return NextResponse.redirect(destination, 307); +} diff --git a/src/components/elections/PledgeButton.tsx b/src/components/elections/PledgeButton.tsx index cc944c60..9f8510a8 100644 --- a/src/components/elections/PledgeButton.tsx +++ b/src/components/elections/PledgeButton.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useState, type FormEvent } from "react"; import posthog from "posthog-js"; import { Button } from "@/components/ui/button"; +import { reportConversion } from "@/lib/conversions"; import { hubspotPageContext } from "@/lib/hubspot-context"; import { pledgeSharePath } from "@/lib/elections/pledge-share"; import { DEFAULT_ELECTION_SLUG, getElection } from "@/lib/elections/registry"; @@ -108,7 +109,10 @@ export function PledgeButton({ return; } - posthog.capture("pledged_to_vote", { source, election: config.slug }); + await reportConversion("voterPledge", { + source, + election: config.slug, + }); trackXEvent("pledgedToVote", email); // keep the button disabled while we navigate to the shared page; // prefer the server's record (canonical name + unguessable token) diff --git a/src/components/subscribe/ui/SubscribeForm.tsx b/src/components/subscribe/ui/SubscribeForm.tsx index 67e0a478..1c88091b 100644 --- a/src/components/subscribe/ui/SubscribeForm.tsx +++ b/src/components/subscribe/ui/SubscribeForm.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from "react"; import posthog from "posthog-js"; import { Button } from "@/components/ui/button"; +import { reportConversion } from "@/lib/conversions"; import { hubspotPageContext } from "@/lib/hubspot-context"; import { useSubscribeStore, type SubscribeSource } from "../store"; @@ -69,7 +70,7 @@ export function SubscribeForm({ return; } posthog.identify(email, { email }); - posthog.capture("subscribed", { source }); + void reportConversion("signup", { source }); setSubscribed(); onSuccess?.(); } catch { diff --git a/src/lib/conversions.ts b/src/lib/conversions.ts new file mode 100644 index 00000000..c82d8130 --- /dev/null +++ b/src/lib/conversions.ts @@ -0,0 +1,76 @@ +"use client"; + +import posthog from "posthog-js"; + +type ConversionProperties = Record< + string, + string | number | boolean | null | undefined +>; + +const conversions = { + signup: { + posthogEvent: "subscribed", + googleAdsDestination: "AW-17738155749/W-XUCMCNx-AcEOWNm4pC", + }, + voterPledge: { + posthogEvent: "pledged_to_vote", + googleAdsDestination: "AW-17738155749/xgobCL2Nx-AcEOWNm4pC", + }, +} as const; + +export type Conversion = keyof typeof conversions; + +declare global { + interface Window { + gtag?: (...args: unknown[]) => void; + } +} + +/** + * Reports a completed conversion to every configured analytics/ad platform. + * + * The promise gives navigations a brief chance to let providers send their + * event. It resolves immediately when Google Tag is unavailable and never + * lets an analytics failure interrupt the user's successful action. + */ +export function reportConversion( + conversion: Conversion, + properties: ConversionProperties = {}, +): Promise { + const definition = conversions[conversion]; + + try { + posthog.capture(definition.posthogEvent, properties); + } catch { + // Analytics must not turn a successful conversion into a user-facing error. + } + + return reportGoogleAdsConversion(definition.googleAdsDestination); +} + +function reportGoogleAdsConversion(destination: string): Promise { + if (typeof window === "undefined" || typeof window.gtag !== "function") { + return Promise.resolve(); + } + + return new Promise((resolve) => { + const done = () => { + clearTimeout(timeout); + resolve(); + }; + + // Do not hold up a client-side navigation if an ad blocker prevents the + // Google callback from firing. + const timeout = setTimeout(resolve, 1_000); + + try { + window.gtag?.("event", "conversion", { + send_to: destination, + event_callback: done, + event_timeout: 1_000, + }); + } catch { + done(); + } + }); +} From 2c8c4fd2c04d0982fc1c9d34a37966b37291b155 Mon Sep 17 00:00:00 2001 From: xrendan Date: Mon, 28 Sep 2026 11:01:50 -0600 Subject: [PATCH 2/2] Queue early Ads conversions and restrict production tracking --- README.md | 2 +- src/app/layout.tsx | 28 ++++++++++++++++++---------- src/lib/conversions.ts | 24 ++++++++++++++++++++---- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index eb347e37..3d07a0fd 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Recommended: |----------|---------| | `NEXT_PUBLIC_POSTHOG_TOKEN` | PostHog analytics + client-side exception capture (`error.tsx` boundaries report here). | | `NEXT_PUBLIC_POSTHOG_HOST` | PostHog UI host. Defaults to `https://us.i.posthog.com`. | -| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics. Loader is conditional — omit to disable. | +| `NEXT_PUBLIC_GA_MEASUREMENT_ID` | Google Analytics. Omit to disable Analytics; the Google Ads tag still loads on `buildcanada.com` and `www.buildcanada.com` to track completed signups and voter pledges. Ads does not load on other hosts. | ## Deployment notes diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 28ae9e3e..67b265bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -58,17 +58,25 @@ export default function RootLayout({ ))} - diff --git a/src/lib/conversions.ts b/src/lib/conversions.ts index c82d8130..5295db86 100644 --- a/src/lib/conversions.ts +++ b/src/lib/conversions.ts @@ -23,6 +23,7 @@ export type Conversion = keyof typeof conversions; declare global { interface Window { gtag?: (...args: unknown[]) => void; + dataLayer?: IArguments[]; } } @@ -30,8 +31,8 @@ declare global { * Reports a completed conversion to every configured analytics/ad platform. * * The promise gives navigations a brief chance to let providers send their - * event. It resolves immediately when Google Tag is unavailable and never - * lets an analytics failure interrupt the user's successful action. + * event. It never lets an analytics failure interrupt the user's successful + * action. */ export function reportConversion( conversion: Conversion, @@ -49,10 +50,25 @@ export function reportConversion( } function reportGoogleAdsConversion(destination: string): Promise { - if (typeof window === "undefined" || typeof window.gtag !== "function") { + if ( + typeof window === "undefined" || + !["buildcanada.com", "www.buildcanada.com"].includes( + window.location.hostname, + ) + ) { return Promise.resolve(); } + // The tag loader runs asynchronously. Queue a completed conversion even if + // it finishes before the bootstrap script installs gtag. + window.dataLayer = window.dataLayer || []; + const gtag = window.gtag || function () { + // gtag.js consumes the arguments object from each queued command. + // eslint-disable-next-line prefer-rest-params + window.dataLayer?.push(arguments); + }; + window.gtag = gtag; + return new Promise((resolve) => { const done = () => { clearTimeout(timeout); @@ -64,7 +80,7 @@ function reportGoogleAdsConversion(destination: string): Promise { const timeout = setTimeout(resolve, 1_000); try { - window.gtag?.("event", "conversion", { + gtag("event", "conversion", { send_to: destination, event_callback: done, event_timeout: 1_000,