PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / stats / StatsReadRepository.php

StatsReadRepository.php in 404 Solution 4.3.0, at includes/stats/StatsReadRepository.php

432 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Reads aggregate statistics used by admin dashboards and periodic summaries.
9 */
10 class ABJ_404_Solution_StatsReadRepository {
11
12 /** @var int Max age for cached stats-periodic aggregates. */
13 const PERIODIC_STATS_CACHE_TTL_SECONDS = 300;
14 /** @var int Minimum interval before recalculating expensive stats aggregates. */
15 const PERIODIC_STATS_REFRESH_COOLDOWN_SECONDS = 30;
16
17 /** @var ABJ_404_Solution_DatabaseQueryInterface */
18 private $dbCore;
19 /** @var ABJ_404_Solution_LogsRepositoryInterface */
20 private $logsRepo;
21 /** @var ABJ_404_Solution_Logging */
22 private $logger;
23 /** @var ABJ_404_Solution_StatsRefreshLock */
24 private $refreshLock;
25
26 /**
27 * @param ABJ_404_Solution_DatabaseQueryInterface $dbCore
28 * @param ABJ_404_Solution_LogsRepositoryInterface $logsRepo
29 * @param ABJ_404_Solution_Logging $logging
30 * @param ABJ_404_Solution_StatsRefreshLock $refreshLock
31 */
32 public function __construct(
33 ABJ_404_Solution_DatabaseQueryInterface $dbCore,
34 ABJ_404_Solution_LogsRepositoryInterface $logsRepo,
35 $logging,
36 ABJ_404_Solution_StatsRefreshLock $refreshLock
37 ) {
38 $this->dbCore = $dbCore;
39 $this->logsRepo = $logsRepo;
40 $this->logger = $logging;
41 $this->refreshLock = $refreshLock;
42 }
43
44 /**
45 * @param string $query
46 * @param array<int|string, mixed> $valueParams
47 * @return int
48 */
49 public function getStatsCount($query, array $valueParams) {
50 if ($query == '') {
51 return 0;
52 }
53
54 $result = $this->dbCore->queryAndGetResults($query, array('query_params' => $valueParams));
55
56 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
57 return 0;
58 }
59
60 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
61 if (empty($rows)) {
62 $this->logger->debugMessage("getStatsCount returned no results for query: " . esc_html($query));
63 return 0;
64 }
65
66 $first = $rows[0];
67 if (is_array($first)) {
68 $value = reset($first);
69 } else {
70 $value = $first;
71 }
72 return $this->toInt($value, 0);
73 }
74
75 /**
76 * @param int $sinceTimestamp
77 * @param string $notFoundDest
78 * @return array{disp404:int,distinct404:int,visitors404:int,refer404:int,redirected:int,distinctredirected:int,distinctvisitors:int,distinctrefer:int}
79 */
80 public function getPeriodicStatsSummary($sinceTimestamp, $notFoundDest = '404') {
81 $sinceTimestamp = absint($sinceTimestamp);
82 $notFoundDest = sanitize_text_field((string)$notFoundDest);
83 if ($notFoundDest === '') {
84 $notFoundDest = '404';
85 }
86
87 $zero = $this->zeroPeriodicStats();
88
89 $logsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}');
90 $sql = "SELECT
91 COUNT(CASE WHEN dest_url = %s THEN 1 END) AS disp404,
92 COUNT(DISTINCT CASE WHEN dest_url = %s THEN requested_url END) AS distinct404,
93 COUNT(DISTINCT CASE WHEN dest_url = %s THEN user_ip END) AS visitors404,
94 COUNT(DISTINCT CASE WHEN dest_url = %s THEN referrer END) AS refer404,
95 COUNT(CASE WHEN dest_url <> %s THEN 1 END) AS redirected,
96 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN requested_url END) AS distinctredirected,
97 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN user_ip END) AS distinctvisitors,
98 COUNT(DISTINCT CASE WHEN dest_url <> %s THEN referrer END) AS distinctrefer
99 FROM {$logsTable}
100 WHERE timestamp >= %d";
101
102 $result = $this->dbCore->queryAndGetResults($sql, array(
103 'query_params' => array(
104 $notFoundDest, $notFoundDest, $notFoundDest, $notFoundDest,
105 $notFoundDest, $notFoundDest, $notFoundDest, $notFoundDest,
106 $sinceTimestamp,
107 ),
108 ));
109
110 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
111 return $zero;
112 }
113
114 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
115 if (empty($rows) || !is_array($rows[0] ?? null)) {
116 return $zero;
117 }
118 $row = $rows[0];
119
120 foreach ($zero as $key => $unused) {
121 $zero[$key] = array_key_exists($key, $row) ? $this->toInt($row[$key], 0) : 0;
122 }
123
124 return $zero;
125 }
126
127 /**
128 * @param string $notFoundDest
129 * @return array{today:array<string,int>,month:array<string,int>,year:array<string,int>,all:array<string,int>}
130 */
131 public function getPeriodicStatsSummariesCached($notFoundDest = '404') {
132 $now = abj_clock()->now();
133 $today = mktime(0, 0, 0, abs(intval(date('m', $now))), abs(intval(date('d', $now))), abs(intval(date('Y', $now))));
134 $firstm = mktime(0, 0, 0, abs(intval(date('m', $now))), 1, abs(intval(date('Y', $now))));
135 $firsty = mktime(0, 0, 0, 1, 1, abs(intval(date('Y', $now))));
136
137 $thresholds = array(
138 'today' => intval($today),
139 'month' => intval($firstm),
140 'year' => intval($firsty),
141 'all' => 0,
142 );
143
144 $emptyPayload = $this->emptyPeriodicPayload();
145 $cacheKey = 'abj404_stats_periodic_v1_' . $this->currentBlogId() . '_' . md5(
146 $notFoundDest . '|' . $thresholds['today'] . '|' . $thresholds['month'] . '|' . $thresholds['year']
147 );
148 $cached = function_exists('get_transient') ? get_transient($cacheKey) : null;
149
150 $isCachedValid = (is_array($cached) && isset($cached['periods']) && is_array($cached['periods']));
151 $cachedPeriods = $isCachedValid ? $this->normalizePeriodicPayload($cached['periods']) : $emptyPayload;
152 $currentMaxLogId = -1;
153 try {
154 $currentMaxLogId = $this->toInt($this->logsRepo->getMaxLogId(), -1);
155 } catch (Throwable $unused) { // allow-silent-catch: cache-key derivation; -1 means "no cached entry, recompute" which is the correct degraded behavior
156 $currentMaxLogId = -1;
157 }
158
159 if ($isCachedValid) {
160 $refreshedAt = $this->toInt($cached['refreshed_at'] ?? 0, 0);
161 $ageSeconds = max(0, abj_clock()->now() - $refreshedAt);
162 $cachedMaxLogId = $this->toInt($cached['max_log_id'] ?? -1, -1);
163 if ($currentMaxLogId >= 0 && $cachedMaxLogId === $currentMaxLogId) {
164 return $cachedPeriods;
165 }
166 if ($ageSeconds < self::PERIODIC_STATS_REFRESH_COOLDOWN_SECONDS) {
167 return $cachedPeriods;
168 }
169 }
170
171 $lockKey = 'stats-periodic:' . $cacheKey;
172 $lockAcquired = $this->refreshLock->acquire($lockKey);
173 if (!$lockAcquired && $isCachedValid) {
174 return $cachedPeriods;
175 }
176
177 try {
178 $result = array(
179 'today' => $this->getPeriodicStatsSummary($thresholds['today'], $notFoundDest),
180 'month' => $this->getPeriodicStatsSummary($thresholds['month'], $notFoundDest),
181 'year' => $this->getPeriodicStatsSummary($thresholds['year'], $notFoundDest),
182 'all' => $this->getPeriodicStatsSummary($thresholds['all'], $notFoundDest),
183 );
184
185 if (function_exists('set_transient')) {
186 set_transient(
187 $cacheKey,
188 array(
189 'refreshed_at' => abj_clock()->now(),
190 'max_log_id' => $currentMaxLogId,
191 'periods' => $result,
192 ),
193 self::PERIODIC_STATS_CACHE_TTL_SECONDS
194 );
195 }
196
197 return $result;
198 } finally {
199 if ($lockAcquired) {
200 $this->refreshLock->release($lockKey);
201 }
202 }
203 }
204
205 /** @return int */
206 public function getEarliestLogTimestamp() {
207 $query = 'SELECT min(timestamp) as timestamp FROM {wp_abj404_logsv2}';
208
209 $result = $this->dbCore->queryAndGetResults($query);
210
211 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
212 return -1;
213 }
214
215 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
216 if (empty($rows)) {
217 return -1;
218 }
219
220 $first = $rows[0];
221 $value = is_array($first) ? reset($first) : $first;
222 if ($value === null || $value === false || $value === '') {
223 return -1;
224 }
225 return $this->toInt($value, -1);
226 }
227
228 /**
229 * Count redirect rows grouped into match-confidence bands for the stats
230 * page Match Confidence card.
231 *
232 * A NULL score is the "manual" band (no automated scoring took place);
233 * scored rows fall into high/medium/low per
234 * {@see ABJ_404_Solution_ScoreThresholds}. Disabled rows and rows with
235 * status 0 are excluded. Routed through queryAndGetResults() so the
236 * 5x SUM(CASE...) aggregate inherits the centralized 60s SELECT timeout
237 * (the redirects table can be very large on busy sites).
238 *
239 * This is the single owner of the confidence-band SQL and thresholds:
240 * the view layer asks for the counts and only formats them.
241 *
242 * @return array{high:int,medium:int,low:int,manual:int,avg:float|null,total:int}|null
243 * Band counts plus the rounded average score (avg is null when no scored
244 * rows exist), or null when the query timed out, errored, or returned no
245 * aggregate row so the caller can skip rendering the card.
246 */
247 public function getConfidenceBandCounts() {
248 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
249
250 $high = ABJ_404_Solution_ScoreThresholds::HIGH;
251 $medium = ABJ_404_Solution_ScoreThresholds::MEDIUM;
252 $sql = "SELECT
253 SUM(CASE WHEN score IS NULL THEN 1 ELSE 0 END) AS manual_count,
254 SUM(CASE WHEN score >= {$high} THEN 1 ELSE 0 END) AS high_count,
255 SUM(CASE WHEN score >= {$medium} AND score < {$high} THEN 1 ELSE 0 END) AS medium_count,
256 SUM(CASE WHEN score IS NOT NULL AND score < {$medium} THEN 1 ELSE 0 END) AS low_count,
257 AVG(score) AS avg_score
258 FROM `{$redirectsTable}`
259 WHERE disabled = %d AND status != %d";
260
261 $result = $this->dbCore->queryAndGetResults($sql, array('query_params' => array(0, 0)));
262 if (!empty($result['timed_out']) || (isset($result['last_error']) && $result['last_error'] != '')) {
263 return null;
264 }
265 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
266 if (empty($rows) || !is_array($rows[0] ?? null)) {
267 return null;
268 }
269 $row = $rows[0];
270
271 $highCount = $this->toInt($row['high_count'] ?? 0, 0);
272 $mediumCount = $this->toInt($row['medium_count'] ?? 0, 0);
273 $lowCount = $this->toInt($row['low_count'] ?? 0, 0);
274 $manualCount = $this->toInt($row['manual_count'] ?? 0, 0);
275 $avgRaw = $row['avg_score'] ?? null;
276 $avgScore = is_numeric($avgRaw) ? round((float)$avgRaw, 1) : null;
277
278 return array(
279 'high' => $highCount,
280 'medium' => $mediumCount,
281 'low' => $lowCount,
282 'manual' => $manualCount,
283 'avg' => $avgScore,
284 'total' => $highCount + $mediumCount + $lowCount + $manualCount,
285 );
286 }
287
288 /** @return array<string, mixed> */
289 public function buildStatsDashboardSnapshotData() {
290 $redirectsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_redirects}");
291
292 $auto301 = $this->getStatsCount(
293 "select count(id) from $redirectsTable where disabled = 0 and code = 301 and status = %d",
294 array(ABJ404_STATUS_AUTO)
295 );
296 $auto302 = $this->getStatsCount(
297 "select count(id) from $redirectsTable where disabled = 0 and code = 302 and status = %d",
298 array(ABJ404_STATUS_AUTO)
299 );
300 $manual301 = $this->getStatsCount(
301 "select count(id) from $redirectsTable where disabled = 0 and code = 301 and status = %d",
302 array(ABJ404_STATUS_MANUAL)
303 );
304 $manual302 = $this->getStatsCount(
305 "select count(id) from $redirectsTable where disabled = 0 and code = 302 and status = %d",
306 array(ABJ404_STATUS_MANUAL)
307 );
308 $trashedRedirects = $this->getStatsCount(
309 "select count(id) from $redirectsTable where disabled = 1 and (status = %d or status = %d)",
310 array(ABJ404_STATUS_AUTO, ABJ404_STATUS_MANUAL)
311 );
312
313 $captured = $this->getStatsCount(
314 "select count(id) from $redirectsTable where disabled = 0 and status = %d",
315 array(ABJ404_STATUS_CAPTURED)
316 );
317 $ignored = $this->getStatsCount(
318 "select count(id) from $redirectsTable where disabled = 0 and status in (%d, %d)",
319 array(ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)
320 );
321 $trashedCaptured = $this->getStatsCount(
322 "select count(id) from $redirectsTable where disabled = 1 and (status in (%d, %d, %d) )",
323 array(ABJ404_STATUS_CAPTURED, ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER)
324 );
325
326 $now = abj_clock()->now();
327 $thresholds = array(
328 'today' => (int)mktime(0, 0, 0, abs(intval(date('m', $now))), abs(intval(date('d', $now))), abs(intval(date('Y', $now)))),
329 'month' => (int)mktime(0, 0, 0, abs(intval(date('m', $now))), 1, abs(intval(date('Y', $now)))),
330 'year' => (int)mktime(0, 0, 0, 1, 1, abs(intval(date('Y', $now)))),
331 'all' => 0,
332 );
333 $periods = array();
334 foreach ($thresholds as $periodKey => $ts) {
335 $periods[$periodKey] = $this->getPeriodicStatsSummary($ts, '404');
336 }
337
338 return array(
339 'redirects' => array(
340 'auto301' => intval($auto301),
341 'auto302' => intval($auto302),
342 'manual301' => intval($manual301),
343 'manual302' => intval($manual302),
344 'trashed' => intval($trashedRedirects),
345 ),
346 'captured' => array(
347 'captured' => intval($captured),
348 'ignored' => intval($ignored),
349 'trashed' => intval($trashedCaptured),
350 ),
351 'periods' => $periods,
352 );
353 }
354
355 /** @return array{disp404:int,distinct404:int,visitors404:int,refer404:int,redirected:int,distinctredirected:int,distinctvisitors:int,distinctrefer:int} */
356 private function zeroPeriodicStats(): array {
357 return array(
358 'disp404' => 0,
359 'distinct404' => 0,
360 'visitors404' => 0,
361 'refer404' => 0,
362 'redirected' => 0,
363 'distinctredirected' => 0,
364 'distinctvisitors' => 0,
365 'distinctrefer' => 0,
366 );
367 }
368
369 /** @return array{today:array<string,int>,month:array<string,int>,year:array<string,int>,all:array<string,int>} */
370 private function emptyPeriodicPayload(): array {
371 $zero = $this->zeroPeriodicStats();
372 return array(
373 'today' => $zero,
374 'month' => $zero,
375 'year' => $zero,
376 'all' => $zero,
377 );
378 }
379
380 /**
381 * @param mixed $periods
382 * @return array{today:array<string,int>,month:array<string,int>,year:array<string,int>,all:array<string,int>}
383 */
384 private function normalizePeriodicPayload($periods): array {
385 $payload = $this->emptyPeriodicPayload();
386 if (!is_array($periods)) {
387 return $payload;
388 }
389 foreach (array('today', 'month', 'year', 'all') as $periodKey) {
390 if (isset($periods[$periodKey]) && is_array($periods[$periodKey])) {
391 $payload[$periodKey] = $this->normalizePeriodicStats($periods[$periodKey]);
392 }
393 }
394 return $payload;
395 }
396
397 /**
398 * @param array<int|string, mixed> $stats
399 * @return array{disp404:int,distinct404:int,visitors404:int,refer404:int,redirected:int,distinctredirected:int,distinctvisitors:int,distinctrefer:int}
400 */
401 private function normalizePeriodicStats(array $stats): array {
402 $zero = $this->zeroPeriodicStats();
403 foreach ($zero as $key => $unused) {
404 $zero[$key] = array_key_exists($key, $stats) ? $this->toInt($stats[$key], 0) : 0;
405 }
406 return $zero;
407 }
408
409 /** @return int */
410 private function currentBlogId(): int {
411 $blogId = 1;
412 if (function_exists('get_current_blog_id')) {
413 $blogId = absint(get_current_blog_id());
414 if ($blogId <= 0) {
415 $blogId = 1;
416 }
417 }
418 return $blogId;
419 }
420
421 /** @param mixed $value @param int $default @return int */
422 private function toInt($value, int $default): int {
423 if ($value === null) {
424 return $default;
425 }
426 if (is_scalar($value)) {
427 return intval($value);
428 }
429 return $default;
430 }
431 }
432