Skip to content

Consent-governed product analytics: events, offer profile, consent sync (Spec 1) - #143

Open
tracking202 wants to merge 20 commits into
masterfrom
feat/consent-analytics
Open

Consent-governed product analytics: events, offer profile, consent sync (Spec 1)#143
tracking202 wants to merge 20 commits into
masterfrom
feat/consent-analytics

Conversation

@tracking202

Copy link
Copy Markdown
Owner

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)

  • Two independent flags in 202_users_pref: analytics_consent and email_marketing_consent — never conflated, both distinct from the existing visitor-facing user_pref_privacy.
  • ConsentPolicy is 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.
  • Non-EU: on by default with settings toggles + a real disclosure page (202-account/disclosure.php). EU/UK: held behind a one-time consent prompt; essential tier still flows. Unknown geo → non-EU default.
  • Fails closed where it matters: consent lookup failure at the sync boundary blocks analytics; consent revocation purges undelivered analytics locally.

What gets collected

  • Essential tier (always, operational): login, account_created, plan changes, support delivery.
  • Analytics tier (consent-gated): server-side 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 in track.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.
  • Consent block transmitted on every request (identity.consent + top-level on /track) so the server can honor email_marketing_consent.

Verification

  • 50+ messaging tests green (phpunit.ci.xml); DB-gated integration tests for the consent boundary.
  • Tested end-to-end against the live central receiver: 21 events flushed with 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.
  • Full-suite run: 4 pre-existing DlIntegrationTest failures reproduce identically at the branch-base commit (environment-dependent, not introduced here).

Notes for review

  • Migration is additive/idempotent (1.9.75), guarded column-adds, mirrored into fresh-install schema.
  • CENTRAL-API.md updated to match the new wire format (tier, consent, nested attributes).
  • Built task-by-task with per-task adversarial review; notable catches folded in: fail-closed consent on DB error, unknown-tier bypass closed, EU geo from client IP, nested-attribute persistence.

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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +88 to +89
require_once dirname(__DIR__) . '/Messaging/Analytics.class.php';
\Analytics::event('ltv_integration_connected', ['provider' => (string) $provider], 'analytics');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant