Consent-governed product analytics: events, offer profile, consent sync (Spec 1) - #143
Consent-governed product analytics: events, offer profile, consent sync (Spec 1)#143tracking202 wants to merge 20 commits into
Conversation
Normalize tiers in Analytics with ConsentPolicy::decide semantics (anything not 'essential' is 'analytics' and consent-gated), route event()/attr() gating through wouldRecord(), and make MessagingService::recordEvent() reject unknown tiers with a log instead of silently coercing them to 'analytics'.
…d session flag $_SESSION['is_european_union'] is never assigned anywhere, so rememberGeo always persisted analytics_geo_is_eu = 0 and the EU consent prompt could never fire. Look the status up via the GeoIp2 reader on AUTH::client_ip() (getGeoData lives in connect2.php, which the login bootstrap never loads), and only persist a definitive result so an unknown lookup never flattens a previously known value.
…SON loudly
updateAttributes() kept only scalar values, silently dropping the offer
profile's core payload (networks[], traffic_source_types[], top_geos[],
device_mix{}, offers[], ltv{}) before it reached
202_messaging_attributes.data. Accept JSON-encodable arrays (the whole
snapshot is json_encode'd with the failure checked), and log any
still-rejected value (objects/resources) instead of dropping it
silently. Covered by a DB-less unit test on the extracted
isPersistableAttributeValue() helper, plus manual cron verification:
consented user's profile lands with nested keys and scrubbed offer
URLs; denied user's row is untouched on rerun.
…lookup failure
Spec $9 was unimplemented at the transport layer: flushEvents() ignored
the tier column entirely and the identity payload re-transmitted the full
attribute snapshot (offer profile: revenue, campaign names, destination
URLs) on every pull/send/track — indefinitely, even after revocation.
- flushEvents() now reads tier, restricts non-consented flushes to
essential rows, sends the tier in the payload so the server can keep
the tiers separable, and withholds the analytics snapshot (its dirty
flag survives so a later re-grant delivers a fresh one).
- Every client() call site transmits outboundIdentity(), which strips
the analytics-tier attribute snapshot for non-consented users.
- ConsentPolicy::record('analytics','denied') purges the undelivered
analytics events and the snapshot via MessagingService::purgeAnalyticsData,
so revocation stops previously collected data from ever leaving.
- ConsentPolicy now fails CLOSED on lookup failure (spec $6): loadPref
distinguishes a query failure (null -> deny) from a genuine missing row
(documented non-EU default), and every failure path logs.
…link The settings copy linked href="/disclosure", which 404s on every install (no such route exists) and escapes the install root on subdirectory installs. The disclosure is load-bearing for the on-by-default posture (spec D2/D3, $10.1), so it now ships as a local page: - 202-account/disclosure.php: what is sent, what never leaves the install (visitors' PII, per-customer LTV data), consent + off switch, the bounded marketing promise. Login required, license NOT (a privacy disclosure must stay reachable when the license check fails). - account.php links it via get_absolute_url() like every other page. - The EU consent banner gains the same Learn-more link — it previously had no disclosure surface at all.
…cle attributes The essential tier was entirely unimplemented (the enum value was dead outside the gate logic) and three spec-8 attributes were absent: - login (essential) recorded in begin_user_session — also gives login_cadence a data source, since no historical login log exists. - account_created (essential) recorded for the NEW user at the user-management create handler (via MessagingService directly; the Analytics facade would attach it to the creating admin). - OfferProfile::compute adds first_click_at (MIN click_time from 202_dataengine), days_since_signup (202_users.user_time_register) and login_cadence (bucketed 30d count of essential login events). - first_click_received (analytics) derived one-shot in the cron inside the existing consent gate, guarded by MessagingService::hasEvent. Still unwired, needs a plan-owner call (no handler exists / entry points too diffuse): upgraded/downgraded (no plan-change handler in the self-hosted codebase), report_viewed / feature_used call sites, account_created at the installer and V3 users API.
…trate The branch had no behavioral tests — every wiring test is a source-string assertion, and the plan's manual verification steps were unevidenced. This adds real-MySQL integration tests (skipped unless P202_TEST_DB_* is set) using the actual schema definitions and a captured fake transport: - EU geo persistence, one-time prompt flow, grant/decline (Tasks 6/10). - Consent-gated, tier'd event writes through the Analytics facade (5/7). - Snapshot write while consented; denial purges the snapshot and the undelivered analytics events, keeps essential rows (Task 9 + revocation). - Transport boundary: a held (EU+unset) user syncs ONLY essential rows, an empty attribute payload, and an identity without the snapshot; a granted user syncs both tiers with tier in the payload. Verified locally against mysql:8 (6 tests, 101 assertions).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 259c9963de
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| require_once dirname(__DIR__) . '/Messaging/Analytics.class.php'; | ||
| \Analytics::event('ltv_integration_connected', ['provider' => (string) $provider], 'analytics'); |
There was a problem hiding this comment.
Record the LTV milestone for API-created integrations
When an integration is created through api/v3 (whose LtvController::createIntegration() calls this repository with the API-authenticated user ID), this call silently does nothing: Analytics::event() derives its user solely from $_SESSION['user_id'], and API requests do not establish that dashboard session. Consequently, consented users creating LTV integrations through the public API never enqueue ltv_integration_connected; pass the repository's $userId and connection through a context-independent recording path instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0165543e22
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| $stmt->close(); | ||
| } | ||
|
|
||
| // plan_limit_pct / near_plan_limit: this self-hosted schema has no |
There was a problem hiding this comment.
Avoid logging the unavailable plan limit for every user
When sync-messaging.php runs, it calls OfferProfile::compute() once for every active user, so this unconditional error_log() writes one warning per user on every cron cycle even though the condition is static and expected. On a non-trivial installation this rapidly floods the PHP/error log and can consume disk or bury actual sync failures; emit it once per run/install or omit it.
Useful? React with 👍 / 👎.
What this is
A consent-governed product-analytics substrate for Prosper202: behavioural events, an offer/volume targeting profile, and consent bookkeeping — all synced to the central server through the existing Messenger transport. Collection side only; server-side targeting/marketing is a separate effort.
Consent model (the keystone)
202_users_pref:analytics_consentandemail_marketing_consent— never conflated, both distinct from the existing visitor-facinguser_pref_privacy.ConsentPolicyis the single chokepoint — the only code that reads raw consent columns or EU status. Every capture path (Analytics facade,track.php, template page-views), the sync flush, and the cron consult it.202-account/disclosure.php). EU/UK: held behind a one-time consent prompt; essential tier still flows. Unknown geo → non-EU default.What gets collected
login,account_created, plan changes, support delivery.page_viewed, milestone events (tracker_created,aff_network_added,traffic_source_added,aff_campaign_added,ltv_integration_connected,lpo_paired,first_click_received), client-JS custom events (now consent-gated intrack.php— previously ungated), and the cron-computed profile: 30-day volumes, per-offer detail (template URLs PII-scrubbed), account-level-only LTV aggregates (per-customer data never syncs — enforced by a boundary test),lpo_active.identity.consent+ top-level on/track) so the server can honoremail_marketing_consent.Verification
phpunit.ci.xml); DB-gated integration tests for the consent boundary.tier, nested profile accepted, consent block accepted on all endpoints, denied-user sync delivered essential-only while analytics stayed local, revocation purge + re-grant recovery verified.DlIntegrationTestfailures reproduce identically at the branch-base commit (environment-dependent, not introduced here).Notes for review
CENTRAL-API.mdupdated to match the new wire format (tier, consent, nested attributes).