diff --git a/.env.example b/.env.example index 454928e8..e4b8e7aa 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/CHANGELOG.md b/CHANGELOG.md index a92ba565..aea70f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index fda48191..5c5b8dce 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -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 @@ -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 + ) + 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 diff --git a/apps/core/openai_ads.py b/apps/core/openai_ads.py new file mode 100644 index 00000000..fa4b62bb --- /dev/null +++ b/apps/core/openai_ads.py @@ -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 diff --git a/apps/core/signals.py b/apps/core/signals.py index 0644de7c..ff591bf1 100644 --- a/apps/core/signals.py +++ b/apps/core/signals.py @@ -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 @@ -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: diff --git a/apps/pages/test_openai_ads.py b/apps/pages/test_openai_ads.py new file mode 100644 index 00000000..c2df510f --- /dev/null +++ b/apps/pages/test_openai_ads.py @@ -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 diff --git a/frontend/src/js/openai-ads.js b/frontend/src/js/openai-ads.js new file mode 100644 index 00000000..2d952dae --- /dev/null +++ b/frontend/src/js/openai-ads.js @@ -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(); + } +})(); diff --git a/frontend/src/js/openai-ads.test.mjs b/frontend/src/js/openai-ads.test.mjs new file mode 100644 index 00000000..8d5fdc59 --- /dev/null +++ b/frontend/src/js/openai-ads.test.mjs @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const source = fs.readFileSync(new URL("./openai-ads.js", import.meta.url), "utf8"); + +function loadOpenAiAds({ + consent = "", + registrationCompleted = true, + sharedConsentController = false, + sessionStorage = new Map(), +} = {}) { + const listeners = new Map(); + const calls = []; + const banner = { hidden: true }; + const cookies = new Map(consent ? [["rowset_analytics_consent", consent]] : []); + const buttons = new Map(); + const document = { + readyState: "complete", + querySelector(selector) { + if (selector === "[data-analytics-consent]") return banner; + if (!buttons.has(selector)) { + buttons.set(selector, { + addEventListener: (_name, callback) => listeners.set(selector, callback), + }); + } + return buttons.get(selector); + }, + }; + Object.defineProperty(document, "cookie", { + get: () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; "), + set: (value) => { + const [pair] = value.split(";"); + const separator = pair.indexOf("="); + cookies.set(pair.slice(0, separator), pair.slice(separator + 1)); + }, + }); + const Rowset = { openAiAds: { registrationCompleted } }; + if (sharedConsentController) { + Rowset.hasAnalyticsConsent = () => consent === "granted"; + } + const window = { + Rowset, + addEventListener: (name, callback) => listeners.set(name, callback), + location: { protocol: "https:" }, + oaiq: (...args) => calls.push(args), + sessionStorage: { + getItem: (name) => sessionStorage.get(name) || null, + removeItem: (name) => sessionStorage.delete(name), + setItem: (name, value) => sessionStorage.set(name, value), + }, + }; + + vm.runInContext(source, vm.createContext({ document, window })); + return { + banner, + calls, + click: (selector) => listeners.get(selector)(), + emit: (name) => listeners.get(name)(), + getCookie: () => document.cookie, + hasListener: (name) => listeners.has(name), + sessionStorage, + }; +} + +test("shows the shared consent choice when no preference exists", () => { + const { banner } = loadOpenAiAds({ registrationCompleted: false }); + + assert.equal(banner.hidden, false); +}); + +test("measures a completed registration when analytics consent is already granted", () => { + const { calls } = loadOpenAiAds({ consent: "granted" }); + + assert.deepEqual(JSON.parse(JSON.stringify(calls)), [ + ["measure", "registration_completed", { type: "customer_action" }], + ]); +}); + +test("waits for consent before measuring a completed registration", () => { + const integration = loadOpenAiAds(); + + assert.deepEqual(integration.calls, []); + integration.click("[data-analytics-consent-accept]"); + + assert.deepEqual(JSON.parse(JSON.stringify(integration.calls)), [ + ["consent", true], + ["measure", "registration_completed", { type: "customer_action" }], + ]); + assert.match(integration.getCookie(), /rowset_analytics_consent=granted/); +}); + +test("reuses the existing consent controller when PostHog owns the choice", () => { + const integration = loadOpenAiAds({ sharedConsentController: true }); + + assert.equal(integration.hasListener("[data-analytics-consent-accept]"), false); + integration.emit("rowset:analytics-consent-granted"); + + assert.deepEqual(JSON.parse(JSON.stringify(integration.calls)), [ + ["consent", true], + ["measure", "registration_completed", { type: "customer_action" }], + ]); + assert.equal(integration.getCookie(), ""); +}); + +test("never measures the same registration twice", () => { + const integration = loadOpenAiAds({ consent: "granted" }); + + integration.click("[data-analytics-consent-accept]"); + + assert.equal( + integration.calls.filter((call) => call[0] === "measure").length, + 1, + ); +}); + +test("declining consent keeps conversion measurement disabled", () => { + const integration = loadOpenAiAds(); + + integration.click("[data-analytics-consent-decline]"); + + assert.deepEqual(integration.calls, [["consent", false]]); + assert.match(integration.getCookie(), /rowset_analytics_consent=denied/); +}); + +test("keeps an unconsented registration pending across navigation", () => { + const sessionStorage = new Map(); + loadOpenAiAds({ sessionStorage }); + + const nextPage = loadOpenAiAds({ + registrationCompleted: false, + sessionStorage, + }); + nextPage.click("[data-analytics-consent-accept]"); + + assert.deepEqual(JSON.parse(JSON.stringify(nextPage.calls)), [ + ["consent", true], + ["measure", "registration_completed", { type: "customer_action" }], + ]); + assert.equal(sessionStorage.size, 0); +}); + +test("declining consent clears the pending registration", () => { + const integration = loadOpenAiAds(); + + integration.click("[data-analytics-consent-decline]"); + + assert.equal(integration.sessionStorage.size, 0); +}); diff --git a/frontend/templates/base_app.html b/frontend/templates/base_app.html index 68ee9c03..2fa67924 100644 --- a/frontend/templates/base_app.html +++ b/frontend/templates/base_app.html @@ -21,6 +21,7 @@ {% endblock social_image_meta %} {% include 'components/plausible.html' %} {% include 'components/posthog.html' %} + {% include 'components/openai_ads.html' %} + +{% endif %} diff --git a/frontend/templates/pages/privacy-policy.html b/frontend/templates/pages/privacy-policy.html index 6f224cb6..d73076fa 100644 --- a/frontend/templates/pages/privacy-policy.html +++ b/frontend/templates/pages/privacy-policy.html @@ -106,11 +106,14 @@

5. Cookies and Tracking Technologies

  • Stripe (payment processing)
  • Plausible or PostHog (usage analytics)
  • +
  • OpenAI Ads Measurement Pixel (ad conversion measurement)
  • - PostHog analytics is disabled until you choose Allow analytics in Rowset; declining does not - affect service functionality. You can also control cookies through your browser settings. - Note that disabling essential cookies may affect service functionality. + PostHog analytics and OpenAI Ads conversion measurement are disabled until you choose Allow + analytics in Rowset; declining does not affect service functionality. The OpenAI Ads pixel + records completed registrations without sending dataset contents. You can also control cookies + through your browser settings. Note that disabling essential cookies may affect service + functionality.

    6. Your Rights

    diff --git a/rowset/adapters.py b/rowset/adapters.py index 6541020f..04b36f3d 100644 --- a/rowset/adapters.py +++ b/rowset/adapters.py @@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model from apps.core.choices import EmailType +from apps.core.openai_ads import mark_registration_completed from apps.core.utils import send_transactional_email User = get_user_model() @@ -21,6 +22,12 @@ def is_open_for_signup(self, request): """Allow operators to pause new registrations without affecting existing users.""" return getattr(settings, "ALLOW_SIGNUPS", True) and super().is_open_for_signup(request) + def post_login(self, request, user, **kwargs): + response = super().post_login(request, user, **kwargs) + if kwargs.get("signup"): + mark_registration_completed(request) + return response + def send_confirmation_mail(self, request, emailconfirmation, signup): """ Override to track email confirmation sends. diff --git a/rowset/settings.py b/rowset/settings.py index e68eae8b..c7079f89 100644 --- a/rowset/settings.py +++ b/rowset/settings.py @@ -152,6 +152,7 @@ def _validate_production_configuration() -> None: "POSTHOG_AI_OBSERVABILITY_ENABLED", default=False, ) +OPENAI_ADS_PIXEL_ID = env("OPENAI_ADS_PIXEL_ID", default="").strip() # Quick-start development settings - unsuitable for production @@ -283,6 +284,7 @@ def _validate_production_configuration() -> None: "apps.core.context_processors.app_navigation", "apps.core.context_processors.current_state", "apps.core.context_processors.posthog_api_key", + "apps.core.context_processors.openai_ads", "apps.core.context_processors.chatwoot_config", "apps.core.context_processors.mjml_url", "apps.core.context_processors.available_social_providers", diff --git a/rowset/tests/test_log_safety.py b/rowset/tests/test_log_safety.py index c6adc81a..bf8bcbce 100644 --- a/rowset/tests/test_log_safety.py +++ b/rowset/tests/test_log_safety.py @@ -76,7 +76,7 @@ def test_signup_signal_logs_safe_newsletter_job_context(captured_events, monkeyp sender=object(), email_address="private@example.com", ) - core_signals.email_confirmation_callback( + core_signals.handle_user_signed_up( sender=object(), request=object(), user=SimpleNamespace(id=7),