Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ SENTRY_MAX_BREADCRUMBS=100

# PostHog analytics and batched OpenTelemetry logs. POSTHOG_API_KEY must be a phc_ project token.
POSTHOG_API_KEY=
# Public browser Pixel ID for OpenAI Ads conversion measurement.
OPENAI_ADS_PIXEL_ID=
POSTHOG_HOST=https://us.i.posthog.com
# Prefer a first-party reverse proxy URL in production to improve delivery through blockers.
POSTHOG_BROWSER_HOST=
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ deployment or release cut date.
## 2026-08-04

### Added
- Added consent-aware OpenAI Ads conversion measurement for completed account registrations.
- Added a guide for migrating agent-managed datasets from generated row identity to a stable
business key with mapping, mirrored writes, verification, cutover, and rollback.

Expand Down
14 changes: 14 additions & 0 deletions apps/core/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.db.models import Prefetch

from apps.core.choices import ProfileStates
from apps.core.openai_ads import REGISTRATION_COMPLETED_SESSION_KEY
from apps.datasets.models import Dataset, Project, ProjectSection
from rowset.traffic import classify_traffic
from rowset.utils import get_rowset_logger
Expand Down Expand Up @@ -163,6 +164,19 @@ def posthog_api_key(request):
return context


def openai_ads(request):
session = getattr(request, "session", None)
registration_completed = bool(
session.pop(REGISTRATION_COMPLETED_SESSION_KEY, False) if session is not None else False
Comment thread
rasulkireev marked this conversation as resolved.
)
pixel_id = settings.OPENAI_ADS_PIXEL_ID
return {
"openai_ads_debug": bool(pixel_id and settings.ENVIRONMENT != "prod"),
"openai_ads_pixel_id": pixel_id,
"openai_ads_registration_completed": bool(pixel_id and registration_completed),
}


def chatwoot_config(request):
base_url = settings.CHATWOOT_BASE_URL.rstrip("/")
website_token = settings.CHATWOOT_WEBSITE_TOKEN
Expand Down
7 changes: 7 additions & 0 deletions apps/core/openai_ads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
REGISTRATION_COMPLETED_SESSION_KEY = "openai_ads_registration_completed"


def mark_registration_completed(request) -> None:
session = getattr(request, "session", None)
if session is not None:
session[REGISTRATION_COMPLETED_SESSION_KEY] = True
4 changes: 3 additions & 1 deletion apps/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from apps.core.analytics import track_user_logged_in_event
from apps.core.choices import TrialReward
from apps.core.models import Profile, ProfileStates
from apps.core.openai_ads import mark_registration_completed
from apps.core.tasks import add_email_to_buttondown
from apps.core.trials import claim_trial_reward
from rowset.utils import get_rowset_logger
Expand Down Expand Up @@ -64,7 +65,8 @@ def track_user_logged_in(sender, request, user, **kwargs):


@receiver(user_signed_up)
def email_confirmation_callback(sender, request, user, **kwargs):
def handle_user_signed_up(sender, request, user, **kwargs):
mark_registration_completed(request)
if "sociallogin" in kwargs:
email = kwargs["sociallogin"].user.email
if email:
Expand Down
113 changes: 113 additions & 0 deletions apps/pages/test_openai_ads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import pytest
from allauth.account.adapter import DefaultAccountAdapter
from django.contrib.auth import get_user_model
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponseRedirect
from django.test import RequestFactory
from django.urls import reverse

from apps.core.openai_ads import REGISTRATION_COMPLETED_SESSION_KEY
from rowset.adapters import CustomAccountAdapter

PIXEL_ID = "8dYXMBukBoC779PidZY6ZX"
pytestmark = pytest.mark.django_db


def test_openai_ads_pixel_is_installed_once_on_public_pages(client, settings):
settings.OPENAI_ADS_PIXEL_ID = PIXEL_ID
settings.ENVIRONMENT = "prod"

response = client.get(reverse("landing"))

content = response.content.decode()
assert content.count("https://bzrcdn.openai.com/sdk/oaiq.min.js") == 1
assert content.count(f'pixelId: "{PIXEL_ID}"') == 1
assert "debug: true" not in content


def test_openai_ads_pixel_is_disabled_without_configuration(client, settings):
settings.OPENAI_ADS_PIXEL_ID = ""

response = client.get(reverse("landing"))

assert "https://bzrcdn.openai.com/sdk/oaiq.min.js" not in response.content.decode()


def test_successful_signup_measures_registration_once(client, monkeypatch, settings):
settings.OPENAI_ADS_PIXEL_ID = PIXEL_ID
settings.ENVIRONMENT = "prod"
client.cookies["rowset_analytics_consent"] = "granted"

monkeypatch.setattr(
"rowset.adapters.CustomAccountAdapter.send_confirmation_mail",
lambda *_args, **_kwargs: None,
)

response = client.post(
reverse("account_signup"),
data={
"email": "openai-pixel-user@example.com",
"password1": "strong-test-pass-123",
},
follow=True,
)

assert response.status_code == 200
assert get_user_model().objects.filter(email="openai-pixel-user@example.com").exists()
content = response.content.decode()
assert "registrationCompleted: true" in content
assert 'oaiq("measure", "registration_completed", {' not in content

next_response = client.get(reverse("home"))
assert "registrationCompleted: true" not in next_response.content.decode()


def test_failed_signup_does_not_mark_registration_completed(client, settings):
settings.OPENAI_ADS_PIXEL_ID = PIXEL_ID

response = client.post(
reverse("account_signup"),
data={"email": "invalid", "password1": "short"},
)

assert response.status_code == 200
assert "registrationCompleted: true" not in response.content.decode()


def test_social_signup_marks_post_login_session(monkeypatch):
request = RequestFactory().get("/")
SessionMiddleware(lambda _request: None).process_request(request)
response = HttpResponseRedirect("/")
monkeypatch.setattr(DefaultAccountAdapter, "post_login", lambda *_args, **_kwargs: response)

result = CustomAccountAdapter().post_login(
request,
object(),
email_verification="none",
signal_kwargs={"sociallogin": object()},
email="social@example.com",
signup=True,
redirect_url="/",
)

assert result is response
assert request.session[REGISTRATION_COMPLETED_SESSION_KEY] is True


def test_existing_user_login_does_not_mark_registration(monkeypatch):
request = RequestFactory().get("/")
SessionMiddleware(lambda _request: None).process_request(request)
response = HttpResponseRedirect("/")
monkeypatch.setattr(DefaultAccountAdapter, "post_login", lambda *_args, **_kwargs: response)

CustomAccountAdapter().post_login(
request,
object(),
email_verification="none",
signal_kwargs=None,
email="existing@example.com",
signup=False,
redirect_url="/",
)

assert REGISTRATION_COMPLETED_SESSION_KEY not in request.session
112 changes: 112 additions & 0 deletions frontend/src/js/openai-ads.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
(function () {
const Rowset = (window.Rowset = window.Rowset || {});
const config = Rowset.openAiAds || {};
const consentCookie = "rowset_analytics_consent";
const pendingRegistrationKey = "rowset_openai_ads_registration_pending";
const hasSharedConsentController = typeof Rowset.hasAnalyticsConsent === "function";
let measurementAllowed = hasSharedConsentController
? Rowset.hasAnalyticsConsent()
: cookieValue(consentCookie) === "granted";
let registrationMeasured = false;

function pendingRegistration() {
try {
if (config.registrationCompleted) {
window.sessionStorage?.setItem(pendingRegistrationKey, "true");
}
return window.sessionStorage?.getItem(pendingRegistrationKey) === "true" ||
Boolean(config.registrationCompleted);
} catch (_error) {
return Boolean(config.registrationCompleted);
}
}

function clearPendingRegistration() {
try {
window.sessionStorage?.removeItem(pendingRegistrationKey);
} catch (_error) {
// Storage can be unavailable in privacy-restricted browser contexts.
}
}

function cookieValue(name) {
const prefix = `${name}=`;
return (document.cookie || "")
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(prefix))
?.slice(prefix.length) || "";
}

function setCookie(name, value) {
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${name}=${value}; Path=/; Max-Age=${60 * 60 * 24 * 365}; SameSite=Lax${secure}`;
}

function showBanner(show) {
const banner = document.querySelector("[data-analytics-consent]");
if (banner) banner.hidden = !show;
}

function measureRegistration() {
const hasPendingRegistration = pendingRegistration();
if (!measurementAllowed || !hasPendingRegistration || registrationMeasured) return;
try {
window.oaiq?.("measure", "registration_completed", {
type: "customer_action",
});
registrationMeasured = true;
clearPendingRegistration();
} catch (_error) {
// Conversion measurement must never block or break signup.
}
}

function grantConsent() {
setCookie(consentCookie, "granted");
if (!measurementAllowed) {
measurementAllowed = true;
window.oaiq?.("consent", true);
}
showBanner(false);
measureRegistration();
}

function handleSharedConsentGranted() {
measurementAllowed = true;
window.oaiq?.("consent", true);
measureRegistration();
}

function declineConsent() {
setCookie(consentCookie, "denied");
measurementAllowed = false;
window.oaiq?.("consent", false);
clearPendingRegistration();
showBanner(false);
}

function initialize() {
if (hasSharedConsentController) {
window.addEventListener?.(
"rowset:analytics-consent-granted",
handleSharedConsentGranted,
);
} else {
document
.querySelector("[data-analytics-consent-accept]")
?.addEventListener("click", grantConsent);
document
.querySelector("[data-analytics-consent-decline]")
?.addEventListener("click", declineConsent);
showBanner(!cookieValue(consentCookie));
}
measureRegistration();
}

if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initialize, { once: true });
} else {
initialize();
}
})();
Loading
Loading