| Code Coverage
| Lines | Functions and Methods | Classes and Traits
Total | 95.31% covered (success) | 95.31% | 61 / 64 | 71.43% covered (warning) | 71.43% | 5 / 7 | CRAP | 0.00% covered (danger) | 0.00% | 0 / 1
StatisticsQueue | 95.24% covered (success) | 95.24% | 60 / 63 | 71.43% covered (warning) | 71.43% | 5 / 7 | 25 | 0.00% covered (danger) | 0.00% | 0 / 1
enqueue | 97.56% covered (success) | 97.56% | 40 / 41 | 0.00% covered (danger) | 0.00% | 0 / 1 | 15 |
drain | 100.00% covered (success) | 100.00% | 3 / 3 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
drainOlderThan | 100.00% covered (success) | 100.00% | 9 / 9 | 100.00% covered (success) | 100.00% | 1 / 1 | 3 |
count | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
load | 71.43% covered (warning) | 71.43% | 5 / 7 | 0.00% covered (danger) | 0.00% | 0 / 1 | 3.21 |
save | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |
mintVisitorCookie | 100.00% covered (success) | 100.00% | 1 / 1 | 100.00% covered (success) | 100.00% | 1 / 1 | 1 |

1 | <?php
2 | /**
3 | * SwapAds Client — StatisticsQueue (F238).
4 | *
5 | * Per F238 design (locked 2026-07-31):
6 | * - Operator-B's plugin enqueues backlink view/click events locally
7 | * (no HTTP call to our server at render time)
8 | * - Our server's AdaptivePollCron pulls events on an adaptive schedule
9 | * (default 15 min, backoff to 2 hr under load)
10 | * - HMAC-signed flush endpoint validates server identity
11 | *
12 | * Storage: wp_options('swapads_pending_statistics') JSON array.
13 | * Max 1000 entries per license (oldest pruned if exceeded).
14 | *
15 | * Naming-convention-locked (avoid tracker/ad/beacon trigger words).
16 | *
17 | * @since 1.5.3
18 | */
19 |
20 | declare(strict_types=1);
21 |
22 | namespace SwapAds\Client\Statistics;
23 |
24 | if (!defined('ABSPATH')) { /* test mode: skip WordPress bootstrap guard */ }
25 |
26 | final class StatisticsQueue
27 | {
28 | /** Max queue size. Oldest pruned when exceeded. */
29 | public const MAX_QUEUE_SIZE = 1000;
30 |
31 | /** Option key for the queue. */
32 | public const OPTION_KEY = 'swapads_pending_statistics';
33 |
34 | /**
35 | * Enqueue a backlink view or click event.
36 | *
37 | * TODO-CLIENT-API-002 (2026-08-01): visitor_ip + visitor_ua are now
38 | * HASHES (32-char SHA-256 prefix), never raw. Caller (StatisticsFlushEndpoint)
39 | * is responsible for hashing the real values before calling enqueue().
40 | * Backwards-compat: any legacy 'visitor_ip' / 'visitor_ua' string fields
41 | * in the input are dropped silently.
42 | *
43 | * @param array<string, mixed> $event Must contain backlink_id, partner_id, event_type ('view' or 'click'), ts_ms.
44 | * Optional: page_url, visitor_ip_hash, visitor_ua_hash, swapads_sid.
45 | */
46 | public static function enqueue(array $event): bool
47 | {
48 | $backlinkId = (int) ($event['backlink_id'] ?? 0);
49 | $partnerId = (int) ($event['partner_id'] ?? 0);
50 | $eventType = (string) ($event['event_type'] ?? '');
51 | $tsMs = (int) ($event['ts_ms'] ?? 0);
52 | if ($backlinkId <= 0 || $partnerId <= 0 || $tsMs <= 0) {
53 | return false;
54 | }
55 | if (!in_array($eventType, ['view', 'click'], true)) {
56 | return false;
57 | }
58 |
59 | $queue = self::load();
60 | // Prune oldest if at cap (LIFO drop).
61 | if (count($queue) >= self::MAX_QUEUE_SIZE) {
62 | $queue = array_slice($queue, -(self::MAX_QUEUE_SIZE - 1));
63 | }
64 | // TODO-CLIENT-API-002 (2026-08-01): strip any legacy raw IP/UA fields.
65 | // Accept ONLY the hash variants (visitor_ip_hash, visitor_ua_hash).
66 | $visitorIpHash = isset($event['visitor_ip_hash'])
67 | ? (string) $event['visitor_ip_hash']
68 | : '';
69 | $visitorUaHash = isset($event['visitor_ua_hash'])
70 | ? (string) $event['visitor_ua_hash']
71 | : '';
72 | // Defensive: if a caller still sends raw IP (e.g., legacy code
73 | // path), hash it here too. New callers MUST pre-hash.
74 | if ($visitorIpHash === '' && isset($event['visitor_ip']) && is_string($event['visitor_ip'])) {
75 | $visitorIpHash = substr(
76 | hash('sha256', $event['visitor_ip'] . wp_salt('auth')),
77 | 0,
78 | 32
79 | );
80 | }
81 | if ($visitorUaHash === '' && isset($event['visitor_ua']) && is_string($event['visitor_ua'])) {
82 | $visitorUaHash = substr(
83 | hash('sha256', $event['visitor_ua'] . wp_salt('auth')),
84 | 0,
85 | 32
86 | );
87 | }
88 |
89 | $queue[] = [
90 | 'backlink_id' => $backlinkId,
91 | 'partner_id' => $partnerId,
92 | 'event_type' => $eventType,
93 | 'ts_ms' => $tsMs,
94 | 'page_url' => (string) ($event['page_url'] ?? ''),
95 | 'visitor_ip_hash' => $visitorIpHash,
96 | 'visitor_ua_hash' => $visitorUaHash,
97 | 'swapads_sid' => isset($event['swapads_sid']) ? (string) $event['swapads_sid'] : null,
98 | 'enqueued_at' => time(),
99 | ];
100 | return self::save($queue);
101 | }
102 |
103 | /**
104 | * Drain all queued events.
105 | *
106 | * @return array<int, array<string, mixed>>
107 | */
108 | public static function drain(): array
109 | {
110 | $queue = self::load();
111 | self::save([]);
112 | return $queue;
113 | }
114 |
115 | /**
116 | * Drain events older than a given timestamp (used by server ACK).
117 | *
118 | * @return array<int, array<string, mixed>>
119 | */
120 | public static function drainOlderThan(int $maxEnqueuedAt): array
121 | {
122 | $queue = self::load();
123 | $kept = [];
124 | $drained = [];
125 | foreach ($queue as $item) {
126 | if ((int) ($item['enqueued_at'] ?? 0) <= $maxEnqueuedAt) {
127 | $drained[] = $item;
128 | } else {
129 | $kept[] = $item;
130 | }
131 | }
132 | self::save($kept);
133 | return $drained;
134 | }
135 |
136 | /**
137 | * Count events in queue.
138 | */
139 | public static function count(): int
140 | {
141 | return count(self::load());
142 | }
143 |
144 | /**
145 | * Load the queue from wp_options.
146 | *
147 | * @return array<int, array<string, mixed>>
148 | */
149 | private static function load(): array
150 | {
151 | $raw = get_option(self::OPTION_KEY, '[]');
152 | if (!is_string($raw)) {
153 | return [];
154 | }
155 | $decoded = json_decode($raw, true);
156 | if (!is_array($decoded)) {
157 | return [];
158 | }
159 | return $decoded;
160 | }
161 |
162 | /**
163 | * Save the queue to wp_options.
164 | *
165 | * @param array<int, array<string, mixed>> $queue
166 | */
167 | private static function save(array $queue): bool
168 | {
169 | return (bool) update_option(self::OPTION_KEY, (string) wp_json_encode($queue), 'no');
170 | }
171 |
172 | /**
173 | * Generate a stable, anonymous visitor cookie token.
174 | *
175 | * Called by JS to set the swapads_sid cookie on first page-load.
176 | * Server uses this for cross-page dedup within operator-B's domain.
177 | */
178 | public static function mintVisitorCookie(): string
179 | {
180 | return bin2hex(random_bytes(16));
181 | }
182 | }