Add Reddit conversion tracking (#208)

This commit is contained in:
Ben Senescu 2026-05-20 23:36:30 -04:00 committed by GitHub
parent aebc779b93
commit 4f638db702
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 2778 additions and 4 deletions

View File

@ -0,0 +1,23 @@
CREATE TABLE `reddit_attributions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`organization_id` text NOT NULL,
`click_id` text,
`uuid` text,
`landing_page` text,
`referrer` text,
`utm_source` text,
`utm_medium` text,
`utm_campaign` text,
`utm_term` text,
`utm_content` text,
`signup_sent_at` text,
`purchase_sent_at` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `reddit_attributions_user_idx` ON `reddit_attributions` (`user_id`);--> statement-breakpoint
CREATE INDEX `reddit_attributions_organization_idx` ON `reddit_attributions` (`organization_id`);

File diff suppressed because it is too large Load Diff

View File

@ -120,6 +120,13 @@
"when": 1779138211145, "when": 1779138211145,
"tag": "0016_magical_the_fallen", "tag": "0016_magical_the_fallen",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1779331224828,
"tag": "0017_omniscient_dagger",
"breakpoints": true
} }
] ]
} }

View File

@ -0,0 +1,83 @@
import type { RedditAttributionInput } from "@/shared/reddit-attribution";
import { redditAttributionSchema } from "@/shared/reddit-attribution";
const STORAGE_KEY = "openseo:reddit-attribution";
const SIGNUP_SENT_KEY = "openseo:reddit-signup-conversion-sent";
function readCookie(name: string) {
const prefix = `${name}=`;
return document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
?.slice(prefix.length);
}
function firstSearchValue(searchParams: URLSearchParams, names: string[]) {
for (const name of names) {
const value = searchParams.get(name)?.trim();
if (value) return value;
}
return undefined;
}
export function captureRedditAttributionFromLocation() {
if (typeof window === "undefined") return;
const url = new URL(window.location.href);
const current = getStoredRedditAttribution();
const next = redditAttributionSchema.parse({
clickId:
firstSearchValue(url.searchParams, ["rdt_cid", "reddit_click_id"]) ??
current?.clickId,
uuid: readCookie("_rdt_uuid") ?? current?.uuid,
landingPage: current?.landingPage ?? url.toString(),
referrer: (current?.referrer ?? document.referrer) || undefined,
utmSource:
firstSearchValue(url.searchParams, ["utm_source"]) ?? current?.utmSource,
utmMedium:
firstSearchValue(url.searchParams, ["utm_medium"]) ?? current?.utmMedium,
utmCampaign:
firstSearchValue(url.searchParams, ["utm_campaign"]) ??
current?.utmCampaign,
utmTerm:
firstSearchValue(url.searchParams, ["utm_term"]) ?? current?.utmTerm,
utmContent:
firstSearchValue(url.searchParams, ["utm_content"]) ??
current?.utmContent,
});
if (!next.clickId && next.utmSource?.toLowerCase() !== "reddit") return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
export function getStoredRedditAttribution(): RedditAttributionInput | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
try {
const parsed: unknown = JSON.parse(raw);
const result = redditAttributionSchema.safeParse(parsed);
return result.success ? result.data : null;
} catch {
return null;
}
}
export function hasMarkedRedditSignupConversion(userId: string) {
if (typeof window === "undefined") return true;
return window.localStorage.getItem(SIGNUP_SENT_KEY) === userId;
}
export function markRedditSignupConversion(userId: string) {
if (typeof window === "undefined") return;
window.localStorage.setItem(SIGNUP_SENT_KEY, userId);
}
export function unmarkRedditSignupConversion() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(SIGNUP_SENT_KEY);
}

View File

@ -0,0 +1,37 @@
import { sql } from "drizzle-orm";
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import { organization, user } from "./better-auth-schema";
export const redditAttributions = sqliteTable(
"reddit_attributions",
{
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
clickId: text("click_id"),
uuid: text("uuid"),
landingPage: text("landing_page"),
referrer: text("referrer"),
utmSource: text("utm_source"),
utmMedium: text("utm_medium"),
utmCampaign: text("utm_campaign"),
utmTerm: text("utm_term"),
utmContent: text("utm_content"),
signupSentAt: text("signup_sent_at"),
purchaseSentAt: text("purchase_sent_at"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
},
(table) => [
uniqueIndex("reddit_attributions_user_idx").on(table.userId),
index("reddit_attributions_organization_idx").on(table.organizationId),
],
);

View File

@ -1,2 +1,3 @@
export * from "./app.schema"; export * from "./app.schema";
export * from "./better-auth-schema"; export * from "./better-auth-schema";
export * from "./reddit-attribution.schema";

View File

@ -19,6 +19,13 @@ import {
startAnalyticsCapture, startAnalyticsCapture,
stopAnalyticsCapture, stopAnalyticsCapture,
} from "@/client/lib/posthog"; } from "@/client/lib/posthog";
import {
captureRedditAttributionFromLocation,
getStoredRedditAttribution,
hasMarkedRedditSignupConversion,
markRedditSignupConversion,
unmarkRedditSignupConversion,
} from "@/client/lib/reddit-attribution";
import { NotFound } from "@/client/components/NotFound"; import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url"; import appCss from "@/client/styles/app.css?url";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
@ -26,6 +33,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { queryClient } from "@/client/tanstack-db"; import { queryClient } from "@/client/tanstack-db";
import { getActiveOrganizationId } from "@/lib/auth-session"; import { getActiveOrganizationId } from "@/lib/auth-session";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
export const Route = createRootRoute({ export const Route = createRootRoute({
head: () => ({ head: () => ({
@ -90,6 +98,11 @@ function PostHogBootstrap() {
const optedOut = session?.user?.analyticsOptedOut === true; const optedOut = session?.user?.analyticsOptedOut === true;
const organizationId = getActiveOrganizationId(session); const organizationId = getActiveOrganizationId(session);
const previousUserIdRef = React.useRef<string | null>(null); const previousUserIdRef = React.useRef<string | null>(null);
const redditSignupInFlightRef = React.useRef(false);
React.useEffect(() => {
captureRedditAttributionFromLocation();
}, []);
React.useEffect(() => { React.useEffect(() => {
if (!isHostedMode || isSessionPending) { if (!isHostedMode || isSessionPending) {
@ -108,6 +121,32 @@ function PostHogBootstrap() {
} }
}, [isHostedMode, isSessionPending, optedOut, organizationId, userId]); }, [isHostedMode, isSessionPending, optedOut, organizationId, userId]);
React.useEffect(() => {
if (!isHostedMode || isSessionPending || !userId) return;
if (hasMarkedRedditSignupConversion(userId)) return;
const attribution = getStoredRedditAttribution();
if (!attribution) return;
if (redditSignupInFlightRef.current) return;
redditSignupInFlightRef.current = true;
void captureRedditConversionEvent({
data: { attribution, eventType: "SignUp" },
})
.then((result) => {
if (result.status === "sent" || result.status === "already_sent") {
markRedditSignupConversion(userId);
}
})
.catch(() => {
// The server deduplicates this event; allow a future session to retry.
unmarkRedditSignupConversion();
})
.finally(() => {
redditSignupInFlightRef.current = false;
});
}, [isHostedMode, isSessionPending, userId]);
return null; return null;
} }

View File

@ -1,9 +1,10 @@
import { createFileRoute, notFound } from "@tanstack/react-router"; import { createFileRoute, notFound } from "@tanstack/react-router";
import { AutumnProvider, useCustomer } from "autumn-js/react"; import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useSession } from "@/lib/auth-client"; import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart"; import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils"; import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
import { getBillingRouteState } from "@/client/features/billing/route-state"; import { getBillingRouteState } from "@/client/features/billing/route-state";
@ -17,6 +18,7 @@ import {
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
autumnSeoDataCreditsToUsd, autumnSeoDataCreditsToUsd,
} from "@/shared/billing"; } from "@/shared/billing";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
export const Route = createFileRoute("/_app/billing")({ export const Route = createFileRoute("/_app/billing")({
beforeLoad: () => { beforeLoad: () => {
@ -68,6 +70,20 @@ function BillingPageContent() {
const { isValid: isValidTopUp, parsed: parsedTopUpAmount } = const { isValid: isValidTopUp, parsed: parsedTopUpAmount } =
parseTopUpAmount(topUpAmount); parseTopUpAmount(topUpAmount);
const checkoutCompleted =
typeof window !== "undefined" &&
new URLSearchParams(window.location.search).get("checkout") === "success";
useEffect(() => {
if (!checkoutCompleted || billingRouteState !== "ready") return;
const attribution = getStoredRedditAttribution();
if (!attribution) return;
void captureRedditConversionEvent({
data: { attribution, eventType: "Purchase" },
});
}, [billingRouteState, checkoutCompleted]);
if (billingRouteState === "loading") { if (billingRouteState === "loading") {
return null; return null;
@ -202,7 +218,7 @@ function BillingPageContent() {
customerQuery.attach({ customerQuery.attach({
planId: AUTUMN_PAID_PLAN_ID, planId: AUTUMN_PAID_PLAN_ID,
redirectMode: "always", redirectMode: "always",
successUrl: window.location.href, successUrl: `${window.location.origin}${window.location.pathname}?checkout=success`,
}), }),
"We couldn't start the checkout. Please try again.", "We couldn't start the checkout. Please try again.",
) )

View File

@ -4,11 +4,13 @@ import { useEffect, useState } from "react";
import { ArrowRight, Settings, User } from "lucide-react"; import { ArrowRight, Settings, User } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
import { signOutAndRedirect, useSession } from "@/lib/auth-client"; import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { getSubscribeRouteState } from "@/client/features/billing/route-state";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing"; import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
export const Route = createFileRoute("/_authenticated/subscribe")({ export const Route = createFileRoute("/_authenticated/subscribe")({
validateSearch: (search: Record<string, unknown>) => ({ validateSearch: (search: Record<string, unknown>) => ({
@ -54,6 +56,15 @@ function SubscribePageContent() {
if (subscribeRouteState === "redirectToApp") { if (subscribeRouteState === "redirectToApp") {
if (checkoutCompleted) { if (checkoutCompleted) {
captureClientEvent("billing:checkout_success"); captureClientEvent("billing:checkout_success");
const attribution = getStoredRedditAttribution();
if (attribution) {
void captureRedditConversionEvent({
data: { attribution, eventType: "Purchase" },
}).finally(() => {
void navigate({ to: "/", replace: true });
});
return;
}
} }
void navigate({ to: "/", replace: true }); void navigate({ to: "/", replace: true });
} }

View File

@ -0,0 +1,172 @@
import { env } from "cloudflare:workers";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { redditAttributions } from "@/db/schema";
import {
hasRedditAttribution,
type RedditAttributionInput,
} from "@/shared/reddit-attribution";
type RedditConversionType = "SignUp" | "Purchase";
type CaptureRedditConversionArgs = {
attribution: RedditAttributionInput;
conversionId: string;
email: string;
eventType: RedditConversionType;
organizationId: string;
userId: string;
valueDecimal?: number;
currency?: string;
};
function getEnv(name: string) {
const value: unknown = Reflect.get(env, name);
return typeof value === "string" ? value.trim() : "";
}
async function sha256(value: string) {
const bytes = new TextEncoder().encode(value.trim().toLowerCase());
const hash = await crypto.subtle.digest("SHA-256", bytes);
return [...new Uint8Array(hash)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
function getRedditConfig() {
const accountId = getEnv("REDDIT_AD_ACCOUNT_ID") || getEnv("REDDIT_PIXEL_ID");
const accessToken = getEnv("REDDIT_CONVERSIONS_ACCESS_TOKEN");
if (!accountId || !accessToken) return null;
return { accountId, accessToken };
}
async function upsertAttribution(args: CaptureRedditConversionArgs) {
const existing = await db.query.redditAttributions.findFirst({
where: eq(redditAttributions.userId, args.userId),
});
const now = new Date().toISOString();
if (existing) {
await db
.update(redditAttributions)
.set({
clickId: existing.clickId ?? args.attribution.clickId,
uuid: existing.uuid ?? args.attribution.uuid,
landingPage: existing.landingPage ?? args.attribution.landingPage,
referrer: existing.referrer ?? args.attribution.referrer,
utmSource: existing.utmSource ?? args.attribution.utmSource,
utmMedium: existing.utmMedium ?? args.attribution.utmMedium,
utmCampaign: existing.utmCampaign ?? args.attribution.utmCampaign,
utmTerm: existing.utmTerm ?? args.attribution.utmTerm,
utmContent: existing.utmContent ?? args.attribution.utmContent,
updatedAt: now,
})
.where(eq(redditAttributions.userId, args.userId));
} else {
await db.insert(redditAttributions).values({
id: crypto.randomUUID(),
userId: args.userId,
organizationId: args.organizationId,
clickId: args.attribution.clickId,
uuid: args.attribution.uuid,
landingPage: args.attribution.landingPage,
referrer: args.attribution.referrer,
utmSource: args.attribution.utmSource,
utmMedium: args.attribution.utmMedium,
utmCampaign: args.attribution.utmCampaign,
utmTerm: args.attribution.utmTerm,
utmContent: args.attribution.utmContent,
createdAt: now,
updatedAt: now,
});
}
}
async function hasSentConversion(args: CaptureRedditConversionArgs) {
const existing = await db.query.redditAttributions.findFirst({
where: eq(redditAttributions.userId, args.userId),
});
return args.eventType === "SignUp"
? Boolean(existing?.signupSentAt)
: Boolean(existing?.purchaseSentAt);
}
async function markConversionSent(args: CaptureRedditConversionArgs) {
const now = new Date().toISOString();
const sentColumn =
args.eventType === "SignUp" ? "signupSentAt" : "purchaseSentAt";
await db
.update(redditAttributions)
.set({
[sentColumn]: now,
updatedAt: now,
})
.where(eq(redditAttributions.userId, args.userId));
}
export async function captureRedditConversion(
args: CaptureRedditConversionArgs,
) {
if (!hasRedditAttribution(args.attribution)) return "skipped" as const;
await upsertAttribution(args);
if (await hasSentConversion(args)) return "already_sent" as const;
const config = getRedditConfig();
if (!config) return "stored" as const;
const eventMetadata: Record<string, unknown> = {
conversion_id: args.conversionId,
transaction_id: args.conversionId,
};
if (args.valueDecimal !== undefined) {
eventMetadata.value_decimal = args.valueDecimal;
eventMetadata.currency = args.currency ?? "USD";
eventMetadata.item_count = 1;
}
const payload = {
events: [
{
click_id: args.attribution.clickId,
event_at: new Date().toISOString(),
event_type: {
tracking_type: args.eventType,
},
event_metadata: eventMetadata,
user: {
email: await sha256(args.email),
external_id: await sha256(args.userId),
uuid: args.attribution.uuid,
},
},
],
};
const response = await fetch(
`https://ads-api.reddit.com/api/v2.0/conversions/events/${config.accountId}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${config.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
);
if (!response.ok) {
console.error("reddit conversion capture failed", {
status: response.status,
eventType: args.eventType,
userId: args.userId,
});
return "failed" as const;
}
await markConversionSent(args);
return "sent" as const;
}

View File

@ -0,0 +1,28 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { captureRedditConversion } from "@/server/lib/reddit-conversions";
import { redditAttributionSchema } from "@/shared/reddit-attribution";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
const conversionInputSchema = z.object({
attribution: redditAttributionSchema,
eventType: z.enum(["SignUp", "Purchase"]),
});
export const captureRedditConversionEvent = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => conversionInputSchema.parse(data))
.handler(async ({ data, context }) => {
const status = await captureRedditConversion({
attribution: data.attribution,
conversionId: `${data.eventType.toLowerCase()}:${context.userId}`,
email: context.userEmail,
eventType: data.eventType,
organizationId: context.organizationId,
userId: context.userId,
valueDecimal: data.eventType === "Purchase" ? 10 : undefined,
currency: data.eventType === "Purchase" ? "USD" : undefined,
});
return { status };
});

View File

@ -0,0 +1,24 @@
import { z } from "zod";
export const redditAttributionSchema = z.object({
clickId: z.string().trim().min(1).max(256).optional(),
uuid: z.string().trim().min(1).max(256).optional(),
landingPage: z.string().trim().min(1).max(2048).optional(),
referrer: z.string().trim().max(2048).optional(),
utmSource: z.string().trim().min(1).max(256).optional(),
utmMedium: z.string().trim().min(1).max(256).optional(),
utmCampaign: z.string().trim().min(1).max(256).optional(),
utmTerm: z.string().trim().min(1).max(256).optional(),
utmContent: z.string().trim().min(1).max(256).optional(),
});
export type RedditAttributionInput = z.infer<typeof redditAttributionSchema>;
export function hasRedditAttribution(input: RedditAttributionInput) {
return Boolean(
input.clickId ||
input.uuid ||
input.utmSource?.toLowerCase() === "reddit" ||
input.referrer?.toLowerCase().includes("reddit."),
);
}

View File

@ -33,7 +33,7 @@ The Site and the Service are not directed to anyone under the age of 13\. As sta
II. HOW WE USE AND SHARE INFORMATION II. HOW WE USE AND SHARE INFORMATION
_Personal Information:_ _Personal Information:_
Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics, session replay, and error monitoring, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, and DataForSEO for data requested through the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy. Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, measure advertising conversions, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics, session replay, and error monitoring, Reddit Ads for advertising conversion measurement, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, and DataForSEO for data requested through the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy.
In general, the Personal Information you provide to us is used to help us communicate with you and operate the Service. For example, we use Personal Information to create and secure accounts, authenticate users, provide technical support, process billing, send administrative or transactional emails, contact users in response to questions, solicit feedback from users, and inform users about product updates and promotional offers. In general, the Personal Information you provide to us is used to help us communicate with you and operate the Service. For example, we use Personal Information to create and secure accounts, authenticate users, provide technical support, process billing, send administrative or transactional emails, contact users in response to questions, solicit feedback from users, and inform users about product updates and promotional offers.
We may share Personal Information with outside parties if we have a good-faith belief that access, use, preservation or disclosure of the information is reasonably necessary to meet any applicable legal process or enforceable governmental request; to enforce applicable Terms of Service, including investigation of potential violations; address fraud, security or technical concerns; or to protect against harm to the rights, property, or safety of our users or the public as required or permitted by law. We may share Personal Information with outside parties if we have a good-faith belief that access, use, preservation or disclosure of the information is reasonably necessary to meet any applicable legal process or enforceable governmental request; to enforce applicable Terms of Service, including investigation of potential violations; address fraud, security or technical concerns; or to protect against harm to the rights, property, or safety of our users or the public as required or permitted by law.