-
Notifications
You must be signed in to change notification settings - Fork 14
Consent-governed product analytics: events, offer profile, consent sync (Spec 1) #143
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tracking202
wants to merge
20
commits into
master
Choose a base branch
from
feat/consent-analytics
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
5578350
feat(analytics): add consent + event-tier columns (additive migration)
tracking202 e4a5841
feat(analytics): add ConsentPolicy single-chokepoint gate
tracking202 cf10b48
feat(analytics): add UrlScrubber PII safety net for offer URLs
tracking202 5098430
feat(analytics): add gated Analytics facade over MessagingService
tracking202 3a53b04
fix(analytics): close unknown-tier consent bypass in Analytics facade
tracking202 da84472
fix(analytics): gate client track.php through ConsentPolicy, reject b…
tracking202 52b71e8
feat(analytics): server-side page_viewed + persist account EU geo at …
tracking202 c278a31
fix(analytics): compute EU geo from client IP at login instead of dea…
tracking202 5774cbd
feat(analytics): emit milestone events incl. LTV/LPO activation
tracking202 c25aab5
feat(analytics): add OfferProfile volume + offer-matching + LTV/LPO s…
tracking202 36a5f5e
feat(analytics): compute + sync offer profile in cron, consent-gated
tracking202 42e65d3
fix(analytics): persist nested offer-profile attributes, reject non-J…
tracking202 c772a4c
feat(analytics): consent settings toggles, recording endpoint, one-ti…
tracking202 9317397
fix(analytics): enforce consent at the sync boundary, fail closed on …
tracking202 7bcc1b5
fix(analytics): ship a real disclosure page; fix the dead Learn-more …
tracking202 06f5201
feat(analytics): implement the essential tier + missing spec-8 lifecy…
tracking202 f6cf651
test(analytics): DB-gated behavioral verification of the consent subs…
tracking202 a01e1f6
feat(analytics): transmit consent block to central server (receiver s…
tracking202 259c996
docs(messaging): sync CENTRAL-API contract with tier, consent block, …
tracking202 0165543
docs(messaging): relocate central API contract to internal docs repo
tracking202 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| include_once(str_repeat('../', 3) . '202-config/connect.php'); | ||
|
|
||
| require __DIR__ . '/_auth.php'; | ||
| require_once dirname(__DIR__, 3) . '/202-config/Messaging/ConsentPolicy.class.php'; | ||
|
|
||
| header('Content-Type: application/json'); | ||
|
|
||
| // Shared guarded helper: fails closed when either token side is empty. | ||
| if (!AUTH::check_csrf_token()) { | ||
| http_response_code(403); | ||
| echo json_encode(['ok' => false, 'error' => 'invalid token']); | ||
| exit; | ||
| } | ||
|
|
||
| $flag = isset($_POST['flag']) ? (string) $_POST['flag'] : ''; | ||
| $state = isset($_POST['state']) ? (string) $_POST['state'] : ''; | ||
| // Column is varchar(32) — clamp instead of relying on silent truncation. | ||
| $source = substr(isset($_POST['source']) ? (string) $_POST['source'] : 'settings', 0, 32); | ||
|
|
||
| if (!in_array($flag, ['analytics', 'email_marketing'], true) | ||
| || !in_array($state, ['granted', 'denied'], true)) { | ||
| http_response_code(400); | ||
| echo json_encode(['ok' => false, 'error' => 'bad_args']); | ||
| exit; | ||
| } | ||
|
|
||
| // Single chokepoint — ConsentPolicy owns every raw consent-column write. | ||
| $ok = ConsentPolicy::record($db, (int) $messagingUserId, $flag, $state, $source); | ||
| echo json_encode(['ok' => (bool) $ok]); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * Product analytics & communication disclosure. | ||
| * | ||
| * The "Learn more" target for the consent surfaces (account settings toggles, | ||
| * EU consent prompt). Copy must stay truthful to the spec's D6/D7 bounds: | ||
| * analytics sends usage including traffic stats, revenue numbers, and campaign | ||
| * names + destination template URLs; marketing is money/workflow/offer-matching | ||
| * only; visitors' PII and per-customer LTV data never leave the install. | ||
| * | ||
| * Login is required, but NOT a valid license (like the messaging endpoints): | ||
| * a privacy disclosure must stay reachable even when the license check fails. | ||
| */ | ||
|
|
||
| declare(strict_types=1); | ||
| include_once(str_repeat("../", 1) . '202-config/connect.php'); | ||
|
|
||
| AUTH::require_user('', false); | ||
|
|
||
| template_top('Product Analytics Disclosure'); | ||
|
|
||
| ?> | ||
|
|
||
| <div style="max-width: 860px; margin: 24px auto 48px; padding: 0 16px;"> | ||
| <div class="page-header"> | ||
| <h4>Product analytics & personalized help</h4> | ||
| <p>What leaves your install, what never does, and how to switch it off.</p> | ||
| </div> | ||
|
|
||
| <div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;"> | ||
| <h5>Why we collect this</h5> | ||
| <p>When product analytics is on, we use your Prosper202 usage to help you <strong>earn more</strong> — | ||
| surfacing workflow tips and matching you with specially-sourced, higher-paying offers relevant to | ||
| what you actually promote. Everything we send you based on this data is bounded to | ||
| money-making, workflow, and offer-matching. Never generic promotion.</p> | ||
| </div> | ||
|
|
||
| <div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;"> | ||
| <h5>What is sent to Prosper202</h5> | ||
| <ul> | ||
| <li><strong>Traffic and results:</strong> clicks, conversions, income, cost, and net over the last | ||
| 30 days; top countries and device mix.</li> | ||
| <li><strong>Your campaign setup:</strong> affiliate network names, traffic source names, campaign | ||
| names, and the campaign destination (template) URLs you entered at setup — with any email or | ||
| phone values redacted before sending.</li> | ||
| <li><strong>Offer performance:</strong> per-campaign payout, currency, EPC, conversion rate, and | ||
| clicks.</li> | ||
| <li><strong>Account-level revenue aggregates:</strong> if you use Customer LTV, only account totals | ||
| (total revenue, MRR/ARR, customer count, average LTV, active subscriptions).</li> | ||
| <li><strong>Product usage:</strong> pages viewed inside Prosper202 and setup milestones (e.g. | ||
| campaign created, tracking link generated, integration connected).</li> | ||
| </ul> | ||
| <p>We never share this data with third parties.</p> | ||
| </div> | ||
|
|
||
| <div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;"> | ||
| <h5>What never leaves your install</h5> | ||
| <ul> | ||
| <li><strong>Your visitors' data.</strong> We track your product usage as the account holder — never | ||
| your traffic's personal data. (Your visitors' click-data privacy is governed separately by the | ||
| Privacy Option above it in Settings.)</li> | ||
| <li><strong>Your customers' data.</strong> Customer LTV records — names, aliases, emails, | ||
| per-customer revenue, custom fields — never sync. Only the account-level aggregates listed | ||
| above do.</li> | ||
| </ul> | ||
| </div> | ||
|
|
||
| <div class="card-modern" style="padding: 24px 28px; margin-bottom: 16px;"> | ||
| <h5>Consent and the off switch</h5> | ||
| <ul> | ||
| <li>Product analytics is on by default outside the EU/UK, and held behind a one-time consent | ||
| prompt for EU/UK account holders. You can turn it off (or back on) anytime in | ||
| <a href="<?php echo get_absolute_url(); ?>202-account/account.php">Account Settings</a>.</li> | ||
| <li>Switching it off stops collection and syncing immediately, and deletes the analytics data on | ||
| your install that has not yet been delivered (queued events and the computed usage profile).</li> | ||
| <li>A small <strong>essential</strong> tier stays on for operational use (account lifecycle, login, | ||
| and support messaging delivery) — it carries no campaign or revenue analytics.</li> | ||
| <li><strong>Money-making offers & tips by email</strong> is a separate, opt-in consent. It is | ||
| never inferred from the analytics setting, and you can unsubscribe anytime.</li> | ||
| </ul> | ||
| </div> | ||
| </div> | ||
|
|
||
| <?php template_bottom(); ?> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| <?php | ||
| // 202-config/Messaging/Analytics.class.php | ||
| require_once __DIR__ . '/ConsentPolicy.class.php'; | ||
| require_once __DIR__ . '/MessagingService.class.php'; | ||
|
|
||
| final class Analytics | ||
| { | ||
| /** Pure gate, delegates to ConsentPolicy. Exposed for testing. */ | ||
| public static function gate(string $stored, bool $isEu, string $tier): bool | ||
| { | ||
| return ConsentPolicy::decide($stored, $isEu, $tier); | ||
| } | ||
|
|
||
| /** | ||
| * Normalize a tier with the same semantics as ConsentPolicy::decide: | ||
| * anything that is not exactly 'essential' is treated as 'analytics' | ||
| * and therefore consent-gated. Never fail open on a typo or a future | ||
| * tier name. Exposed for testing. | ||
| */ | ||
| public static function normalizeTier(string $tier): string | ||
| { | ||
| return $tier === 'essential' ? 'essential' : 'analytics'; | ||
| } | ||
|
|
||
| public static function event(string $name, array $meta = [], string $tier = 'analytics'): void | ||
| { | ||
| self::guarded(function (mysqli $db, int $uid) use ($name, $meta, $tier) { | ||
| $tier = self::normalizeTier($tier); | ||
| if (!self::wouldRecord($db, $uid, $tier)) { | ||
| return; | ||
| } | ||
| $service = new MessagingService($db, $uid, []); | ||
| // MessagingService::recordEvent persists to 202_messaging_events; pass tier through. | ||
| $service->recordEvent($name, $meta, $tier); | ||
| }); | ||
| } | ||
|
|
||
| public static function attr(array $attributes, string $tier = 'analytics'): void | ||
| { | ||
| self::guarded(function (mysqli $db, int $uid) use ($attributes, $tier) { | ||
| if (!self::wouldRecord($db, $uid, self::normalizeTier($tier))) { | ||
| return; | ||
| } | ||
| $service = new MessagingService($db, $uid, []); | ||
| $service->updateAttributes($attributes); | ||
| }); | ||
| } | ||
|
|
||
| /** Thin testable wrapper: would a write of this tier be recorded for this user? */ | ||
| public static function wouldRecord(mysqli $db, int $userId, string $tier): bool | ||
| { | ||
| if (self::normalizeTier($tier) === 'essential') { | ||
| return true; | ||
| } | ||
| return ConsentPolicy::analyticsAllowed($db, $userId); | ||
| } | ||
|
|
||
| private static function guarded(callable $fn): void | ||
| { | ||
| try { | ||
| $db = $GLOBALS['db'] ?? null; | ||
| $uid = (int) ($_SESSION['user_id'] ?? 0); | ||
| if (!($db instanceof mysqli) || $uid <= 0) { return; } | ||
| $fn($db, $uid); | ||
| } catch (\Throwable $e) { | ||
| // Never let tracking break a host page. Log if a logger exists; otherwise swallow. | ||
| if (function_exists('error_log')) { | ||
| error_log('[Analytics] ' . $e->getMessage()); | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an integration is created through
api/v3(whoseLtvController::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 enqueueltv_integration_connected; pass the repository's$userIdand connection through a context-independent recording path instead.Useful? React with 👍 / 👎.