Skip to content

Commit 68f8085

Browse files
committed
feat(dashboard): improve OpenAI Ads signup attribution
1 parent 76ffe29 commit 68f8085

14 files changed

Lines changed: 412 additions & 19 deletions

File tree

apps/dashboard/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,6 @@ RESEND_API_KEY=your_resend_api_key
1919
NEXT_PUBLIC_API_URL=http://localhost:3001
2020
# OpenAI Ads browser pixel_id from Ads Manager, not the API data source id.
2121
NEXT_PUBLIC_OPENAI_ADS_PIXEL_ID=
22+
# Server-side OpenAI Ads Conversions API key from Ads Manager.
23+
OPENAI_ADS_CONVERSIONS_API_KEY=
2224
AUTUMN_SECRET_KEY=your_autumn_secret_key

apps/dashboard/app/(auth)/register/page.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import Link from "next/link";
55
import { parseAsString, useQueryState } from "nuqs";
66
import { Suspense, useState } from "react";
77
import { toast } from "sonner";
8-
import { measureOpenAiRegistrationCompleted } from "@/components/openai-ads-pixel";
8+
import { trackOpenAiRegistrationCompleted } from "@/components/openai-ads-pixel";
99
import { GithubMark, GoogleMark } from "@/components/ui/brand-icons";
1010
import VisuallyHidden from "@/components/ui/visuallyhidden";
1111
import {
1212
APP_EVENTS,
13-
readUtmProperties,
13+
readMarketingProperties,
1414
storePendingSocialSignup,
1515
type SignupEventProperties,
1616
type SignupMethod,
@@ -60,7 +60,7 @@ function RegisterPageContent() {
6060
const getSignupProperties = (
6161
method: SignupMethod
6262
): SignupEventProperties => ({
63-
...readUtmProperties(new URLSearchParams(window.location.search)),
63+
...readMarketingProperties(new URLSearchParams(window.location.search)),
6464
method,
6565
plan: selectedPlan || undefined,
6666
});
@@ -110,7 +110,10 @@ function RegisterPageContent() {
110110
fetchOptions: {
111111
onSuccess: () => {
112112
trackSignup(APP_EVENTS.signupCompleted, signupProperties);
113-
measureOpenAiRegistrationCompleted();
113+
trackOpenAiRegistrationCompleted({
114+
email: formData.email,
115+
properties: signupProperties,
116+
});
114117
toast.success(
115118
"Account created! Please check your email to verify your account."
116119
);

apps/dashboard/app/(main)/onboarding/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useRouter } from "next/navigation";
44
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5-
import { measureOpenAiRegistrationCompleted } from "@/components/openai-ads-pixel";
5+
import { trackOpenAiRegistrationCompleted } from "@/components/openai-ads-pixel";
66
import { useWebsitesLight } from "@/hooks/use-websites";
77
import {
88
APP_EVENTS,
@@ -84,7 +84,7 @@ export default function OnboardingPage() {
8484
trackAppEvent(APP_EVENTS.signupCompleted, signupProperties, {
8585
flush: true,
8686
});
87-
measureOpenAiRegistrationCompleted();
87+
trackOpenAiRegistrationCompleted({ properties: signupProperties });
8888
}
8989
trackAppEvent(APP_EVENTS.onboardingStarted);
9090
}, []);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { config } from "@databuddy/env/app";
2+
import { type NextRequest, NextResponse } from "next/server";
3+
import { z } from "zod";
4+
import { buildOpenAiRegistrationCompletedEvent } from "@/lib/openai-ads-conversions";
5+
6+
const ConversionRequestSchema = z.object({
7+
email: z.string().email().max(320).optional(),
8+
eventId: z.string().min(1).max(160),
9+
oppref: z.string().min(1).max(512).optional(),
10+
sourceUrl: z.string().url().max(2048),
11+
});
12+
13+
function readClientIp(headers: Headers): string | undefined {
14+
const forwardedFor = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
15+
return forwardedFor || headers.get("x-real-ip")?.trim() || undefined;
16+
}
17+
18+
async function readJson(request: NextRequest): Promise<unknown> {
19+
try {
20+
return await request.json();
21+
} catch {
22+
return null;
23+
}
24+
}
25+
26+
export async function POST(request: NextRequest) {
27+
const pixelId = config.integrations.openAiAdsPixelId;
28+
const apiKey = config.integrations.openAiAdsConversionsApiKey;
29+
30+
if (!(pixelId && apiKey)) {
31+
return NextResponse.json({ skipped: true });
32+
}
33+
34+
const parsed = ConversionRequestSchema.safeParse(await readJson(request));
35+
if (!parsed.success) {
36+
return NextResponse.json(
37+
{ error: "Invalid conversion payload" },
38+
{ status: 400 }
39+
);
40+
}
41+
42+
const event = buildOpenAiRegistrationCompletedEvent({
43+
...parsed.data,
44+
ipAddress: readClientIp(request.headers),
45+
timestampMs: Date.now(),
46+
userAgent: request.headers.get("user-agent") ?? undefined,
47+
});
48+
49+
const response = await fetch(
50+
`https://bzr.openai.com/v1/events?pid=${encodeURIComponent(pixelId)}`,
51+
{
52+
body: JSON.stringify({ events: [event], validate_only: false }),
53+
headers: {
54+
Authorization: `Bearer ${apiKey}`,
55+
"Content-Type": "application/json",
56+
},
57+
method: "POST",
58+
}
59+
);
60+
61+
if (!response.ok) {
62+
return NextResponse.json({ ok: false }, { status: 502 });
63+
}
64+
65+
return NextResponse.json({ ok: true });
66+
}

apps/dashboard/components/openai-ads-pixel.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "bun:test";
2-
import { isOpenAiAdsPixelHostAllowed } from "./openai-ads-pixel";
2+
import {
3+
buildOpenAiRegistrationConversionPayload,
4+
buildOpenAiRegistrationMeasureArgs,
5+
isOpenAiAdsPixelHostAllowed,
6+
} from "./openai-ads-pixel";
37

48
describe("isOpenAiAdsPixelHostAllowed", () => {
59
it("blocks local development hosts", () => {
@@ -14,4 +18,33 @@ describe("isOpenAiAdsPixelHostAllowed", () => {
1418
expect(isOpenAiAdsPixelHostAllowed("app.databuddy.cc")).toBe(true);
1519
expect(isOpenAiAdsPixelHostAllowed("staging.databuddy.cc")).toBe(true);
1620
});
21+
22+
it("builds a registration payload with the OpenAI reference", () => {
23+
expect(
24+
buildOpenAiRegistrationConversionPayload({
25+
email: "founder@example.com",
26+
eventId: "conversion-1",
27+
properties: {
28+
method: "email",
29+
oppref: "openai-reference",
30+
utm_source: "openai_ads",
31+
},
32+
sourceUrl: "https://app.databuddy.cc/register?utm_source=openai_ads",
33+
})
34+
).toEqual({
35+
email: "founder@example.com",
36+
eventId: "conversion-1",
37+
oppref: "openai-reference",
38+
sourceUrl: "https://app.databuddy.cc/register?utm_source=openai_ads",
39+
});
40+
});
41+
42+
it("passes event_id as the pixel options argument for dedupe", () => {
43+
expect(buildOpenAiRegistrationMeasureArgs("conversion-1")).toEqual([
44+
"measure",
45+
"registration_completed",
46+
{ type: "customer_action" },
47+
{ event_id: "conversion-1" },
48+
]);
49+
});
1750
});

apps/dashboard/components/openai-ads-pixel.tsx

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,25 @@
22

33
import { useEffect } from "react";
44
import { publicConfig } from "@databuddy/env/public";
5+
import type { SignupEventProperties } from "@databuddy/shared/custom-events";
56

67
const PIXEL_ID = publicConfig.integrations.openAiAdsPixelId;
78
const SCRIPT_SRC = "https://bzrcdn.openai.com/sdk/oaiq.min.js";
89

10+
interface RegistrationConversionInput {
11+
email?: string;
12+
eventId?: string;
13+
properties?: SignupEventProperties;
14+
sourceUrl?: string;
15+
}
16+
17+
interface RegistrationConversionPayload {
18+
email?: string;
19+
eventId: string;
20+
oppref?: string;
21+
sourceUrl: string;
22+
}
23+
924
type OpenAiAdsQueue = ((...args: unknown[]) => void) & {
1025
q?: unknown[][];
1126
};
@@ -60,6 +75,38 @@ function initOpenAiQueue(): boolean {
6075
return true;
6176
}
6277

78+
function createRegistrationEventId(): string {
79+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
80+
return crypto.randomUUID();
81+
}
82+
83+
return `registration_completed_${Date.now()}_${Math.random()
84+
.toString(36)
85+
.slice(2)}`;
86+
}
87+
88+
export function buildOpenAiRegistrationConversionPayload(
89+
input: RegistrationConversionInput
90+
): RegistrationConversionPayload {
91+
return {
92+
...(input.email ? { email: input.email } : {}),
93+
eventId: input.eventId ?? createRegistrationEventId(),
94+
...(input.properties?.oppref ? { oppref: input.properties.oppref } : {}),
95+
sourceUrl: input.sourceUrl ?? window.location.href,
96+
};
97+
}
98+
99+
export function buildOpenAiRegistrationMeasureArgs(
100+
eventId?: string
101+
): unknown[] {
102+
return [
103+
"measure",
104+
"registration_completed",
105+
{ type: "customer_action" },
106+
...(eventId ? [{ event_id: eventId }] : []),
107+
];
108+
}
109+
63110
export function OpenAiAdsPixel() {
64111
useEffect(() => {
65112
initOpenAiQueue();
@@ -68,12 +115,39 @@ export function OpenAiAdsPixel() {
68115
return null;
69116
}
70117

71-
export function measureOpenAiRegistrationCompleted() {
118+
export function measureOpenAiRegistrationCompleted(eventId?: string) {
72119
if (!initOpenAiQueue()) {
73120
return;
74121
}
75122

76-
window.oaiq?.("measure", "registration_completed", {
77-
type: "customer_action",
123+
window.oaiq?.(...buildOpenAiRegistrationMeasureArgs(eventId));
124+
}
125+
126+
export function sendOpenAiRegistrationCompletedConversion(
127+
payload: RegistrationConversionPayload
128+
) {
129+
if (
130+
typeof window === "undefined" ||
131+
!PIXEL_ID ||
132+
!isOpenAiAdsPixelHostAllowed(window.location.hostname)
133+
) {
134+
return;
135+
}
136+
137+
fetch("/api/openai-ads/conversions", {
138+
body: JSON.stringify(payload),
139+
headers: { "Content-Type": "application/json" },
140+
keepalive: true,
141+
method: "POST",
142+
}).catch(() => {
143+
// Signup should not depend on ad-platform reporting.
78144
});
79145
}
146+
147+
export function trackOpenAiRegistrationCompleted(
148+
input: RegistrationConversionInput = {}
149+
) {
150+
const payload = buildOpenAiRegistrationConversionPayload(input);
151+
measureOpenAiRegistrationCompleted(payload.eventId);
152+
sendOpenAiRegistrationCompletedConversion(payload);
153+
}
Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
1-
import { describe, expect, it } from "bun:test";
1+
import { afterEach, describe, expect, it } from "bun:test";
22
import { SIGNUP_METHODS } from "@databuddy/shared/custom-events";
3-
import { isSocialSignupMethod } from "./app-events";
3+
import {
4+
consumePendingSocialSignup,
5+
isSocialSignupMethod,
6+
storePendingSocialSignup,
7+
} from "./app-events";
8+
9+
const originalSessionStorage = globalThis.sessionStorage;
10+
11+
function installSessionStorageMock() {
12+
const storage = new Map<string, string>();
13+
14+
Object.defineProperty(globalThis, "sessionStorage", {
15+
configurable: true,
16+
value: {
17+
getItem: (key: string) => storage.get(key) ?? null,
18+
removeItem: (key: string) => {
19+
storage.delete(key);
20+
},
21+
setItem: (key: string, value: string) => {
22+
storage.set(key, value);
23+
},
24+
},
25+
});
26+
}
27+
28+
afterEach(() => {
29+
Object.defineProperty(globalThis, "sessionStorage", {
30+
configurable: true,
31+
value: originalSessionStorage,
32+
});
33+
});
434

535
describe("isSocialSignupMethod", () => {
636
it("derives social methods from the shared signup method list", () => {
@@ -9,4 +39,29 @@ describe("isSocialSignupMethod", () => {
939
"social_google",
1040
]);
1141
});
42+
43+
it("preserves marketing attribution through pending social signup storage", () => {
44+
installSessionStorageMock();
45+
46+
storePendingSocialSignup({
47+
method: "social_google",
48+
oppref: "openai-reference",
49+
utm_campaign: "competitors",
50+
utm_content: "ask_why_signups_dropped",
51+
utm_medium: "paid",
52+
utm_source: "openai_ads",
53+
wolref: "web-origin-reference",
54+
});
55+
56+
expect(consumePendingSocialSignup()).toEqual({
57+
method: "social_google",
58+
oppref: "openai-reference",
59+
utm_campaign: "competitors",
60+
utm_content: "ask_why_signups_dropped",
61+
utm_medium: "paid",
62+
utm_source: "openai_ads",
63+
wolref: "web-origin-reference",
64+
});
65+
expect(consumePendingSocialSignup()).toBeNull();
66+
});
1267
});

apps/dashboard/lib/app-events.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ import type {
1010
SignupMethod,
1111
} from "@databuddy/shared/custom-events";
1212
import {
13+
MARKETING_PARAM_KEYS,
1314
SIGNUP_METHODS,
14-
UTM_PARAM_KEYS,
1515
isSignupMethod,
1616
} from "@databuddy/shared/custom-events";
1717

18-
export { APP_EVENTS, readUtmProperties } from "@databuddy/shared/custom-events";
18+
export {
19+
APP_EVENTS,
20+
readMarketingProperties,
21+
readUtmProperties,
22+
} from "@databuddy/shared/custom-events";
1923
export type {
2024
SignupEventProperties,
2125
SignupMethod,
@@ -94,7 +98,7 @@ function readStoredSignupProperties(
9498
properties.plan = plan;
9599
}
96100

97-
for (const key of UTM_PARAM_KEYS) {
101+
for (const key of MARKETING_PARAM_KEYS) {
98102
const param = trimStoredString(source[key]);
99103
if (param) {
100104
properties[key] = param;

0 commit comments

Comments
 (0)