PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.3
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.3
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Fonts / UsageTracker.php

UsageTracker.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.3, at src/Fonts/UsageTracker.php

650 lines 19.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Font usage + beacon decisions.
4 *
5 * @package EasyFonts
6 */
7
8 namespace EasyFonts\Fonts;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Records real-render font usage and stores beacon-derived decisions.
14 */
15 class UsageTracker {
16
17 /**
18 * Usage table.
19 *
20 * @return string
21 */
22 private function usage_table(): string {
23 global $wpdb;
24
25 return $wpdb->prefix . 'easyfonts_usage';
26 }
27
28 /**
29 * Decisions table.
30 *
31 * @return string
32 */
33 private function decisions_table(): string {
34 global $wpdb;
35
36 return $wpdb->prefix . 'easyfonts_decisions';
37 }
38
39 /**
40 * Canonical route key for a URL: the path only, with the query string
41 * dropped. This is the single normalization used everywhere a page is
42 * identified (usage rows, beacon decisions, warm locks), so the server
43 * buffer and the browser beacon always agree on one key per page — and
44 * volatile params (cache-busters like efbust, the easyfonts_probe key,
45 * builder flags, tracking params) can never mint duplicate rows.
46 *
47 * @param string $url Absolute or relative URL.
48 * @return string Path beginning with '/', never empty.
49 */
50 public static function route_for( string $url ): string {
51 $path = (string) wp_parse_url( $url, PHP_URL_PATH );
52
53 if ( '' === $path ) {
54 return '/';
55 }
56
57 // Guarantee a canonical leading slash — malformed input (e.g. a bare
58 // scheme string) must never mint a route outside the path namespace.
59 return '/' === $path[0] ? $path : '/' . $path;
60 }
61
62 /**
63 * Canonical route key for the current request.
64 *
65 * @return string
66 */
67 public static function current_route(): string {
68 return self::route_for( home_url( add_query_arg( array() ) ) );
69 }
70
71 /**
72 * Record a set of font observations for one page in a single query each.
73 *
74 * @param string $page_url Page URL.
75 * @param int $page_id Post ID (0 if unknown).
76 * @param array<int,array<string,mixed>> $records Each: family, weight, style, origin, rendered, above_fold.
77 */
78 public function record( string $page_url, int $page_id, array $records ): void {
79 global $wpdb;
80
81 if ( empty( $records ) ) {
82 return;
83 }
84
85 $now = gmdate( 'Y-m-d H:i:s' ); // UTC — compared against UTC_TIMESTAMP() in gc().
86 $table = $this->usage_table();
87
88 // Collapse to one row per (page, family, weight, style); merge any
89 // duplicates within this batch (strongest rendered/above_fold wins,
90 // beacon origin beats buffer) so a single multi-row upsert replaces what
91 // used to be one query per variant on every render.
92 $by_key = array();
93
94 foreach ( $records as $rec ) {
95 $family = sanitize_text_field( $rec['family'] ?? '' );
96
97 if ( '' === $family ) {
98 continue;
99 }
100
101 $weight = sanitize_text_field( $rec['weight'] ?? '400' );
102 $style = sanitize_text_field( $rec['style'] ?? 'normal' );
103 $origin = sanitize_text_field( $rec['origin'] ?? 'buffer' );
104 $key = sha1( $page_url . '|' . strtolower( $family ) . '|' . $weight . '|' . $style );
105
106 $rendered = ! empty( $rec['rendered'] ) ? 1 : 0;
107 $above_fold = ! empty( $rec['above_fold'] ) ? 1 : 0;
108
109 if ( isset( $by_key[ $key ] ) ) {
110 $by_key[ $key ]['rendered'] = max( $by_key[ $key ]['rendered'], $rendered );
111 $by_key[ $key ]['above_fold'] = max( $by_key[ $key ]['above_fold'], $above_fold );
112
113 if ( 'beacon' === $origin ) {
114 $by_key[ $key ]['origin'] = 'beacon';
115 }
116
117 continue;
118 }
119
120 $by_key[ $key ] = array(
121 'key' => $key,
122 'family' => $family,
123 'weight' => $weight,
124 'style' => $style,
125 'origin' => $origin,
126 'rendered' => $rendered,
127 'above_fold' => $above_fold,
128 );
129 }
130
131 if ( empty( $by_key ) ) {
132 return;
133 }
134
135 $placeholders = array();
136 $args = array();
137
138 foreach ( $by_key as $row ) {
139 // A beacon measurement that saw the variant loaded but not rendered
140 // counts one "miss" toward the scope-out corroboration threshold.
141 $miss = ( 'beacon' === $row['origin'] && 0 === $row['rendered'] ) ? 1 : 0;
142
143 $placeholders[] = '(%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%d,%s)';
144 array_push(
145 $args,
146 $row['key'],
147 $page_url,
148 $page_id,
149 $row['family'],
150 $row['weight'],
151 $row['style'],
152 $row['origin'],
153 $row['rendered'],
154 $row['above_fold'],
155 $miss,
156 $now
157 );
158 }
159
160 $alias = $this->dup_alias();
161
162 $sql = "INSERT INTO {$table}
163 (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, beacon_misses, last_seen)
164 VALUES " . implode( ',', $placeholders ) . "{$alias}
165 ON DUPLICATE KEY UPDATE
166 hits = hits + 1,
167 rendered = GREATEST(rendered, {$this->dup( 'rendered' )}),
168 above_fold = GREATEST(above_fold, {$this->dup( 'above_fold' )}),
169 origin = {$this->dup( 'origin' )},
170 beacon_misses = CASE
171 WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 1 THEN 0
172 WHEN {$this->dup( 'origin' )} = 'beacon' AND {$this->dup( 'rendered' )} = 0 THEN beacon_misses + 1
173 ELSE beacon_misses
174 END,
175 last_seen = {$this->dup( 'last_seen' )}";
176
177 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
178 $wpdb->query( $wpdb->prepare( $sql, $args ) );
179 }
180
181 /**
182 * Sets of what the beacon observed actually rendering, for used/unused
183 * classification on the Fonts screen.
184 *
185 * @return array{variants:array<string,bool>,families:array<string,bool>}
186 */
187 public function rendered_set(): array {
188 global $wpdb;
189
190 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
191 $rows = $wpdb->get_results(
192 "SELECT DISTINCT family, weight, style FROM {$this->usage_table()} WHERE rendered = 1",
193 ARRAY_A
194 );
195
196 $variants = array();
197 $families = array();
198
199 if ( $rows ) {
200 foreach ( $rows as $r ) {
201 $lc = strtolower( $r['family'] );
202 $families[ $lc ] = true;
203 $variants[ $lc . '|' . $r['weight'] . '|' . $r['style'] ] = true;
204 }
205 }
206
207 return array(
208 'variants' => $variants,
209 'families' => $families,
210 );
211 }
212
213 /**
214 * Per-route render profile in a SINGLE query: which families the beacon has
215 * seen rendering on the route, and which of those are above the fold. Both
216 * are family-level and based on the page's authorial font-family, so they're
217 * robust to weight/style differences and self-correcting.
218 *
219 * - `rendered` drives page scoping (which fonts load on this page).
220 * - `above_fold` drives preload (which of them to fetch early).
221 *
222 * Empty `rendered` means the route hasn't been measured yet — callers must
223 * neither scope nor preload from no data.
224 *
225 * @param string $route Canonical route (path).
226 * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,scope_out:array<string,bool>,measured:bool}
227 */
228 public function route_render_profile( string $route ): array {
229 global $wpdb;
230
231 $out = array(
232 'rendered' => array(),
233 'above_fold' => array(),
234 'scope_out' => array(),
235 'measured' => false,
236 );
237
238 if ( '' === $route ) {
239 return $out;
240 }
241
242 // Per family on this route: was it ever rendered (used), was it above
243 // the fold, and has a real-browser beacon ever reported on it. The
244 // beacon reports both what rendered AND what loaded-but-never-rendered
245 // (recorded as rendered = 0, origin = 'beacon'), so a family is a
246 // confirmed scope-out ONLY when a beacon has actually measured it and
247 // never saw it render. A family that hasn't been beacon-measured yet
248 // (e.g. a font just added/replaced in the Customizer) is NEVER scoped
249 // out — it loads normally until a beacon has had a chance to judge it.
250 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
251 $rows = $wpdb->get_results(
252 $wpdb->prepare(
253 "SELECT family,
254 MAX(rendered) AS rnd,
255 MAX(CASE WHEN rendered = 1 THEN above_fold ELSE 0 END) AS af,
256 MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed,
257 MAX(beacon_misses) AS misses
258 FROM {$this->usage_table()}
259 WHERE page_url = %s
260 GROUP BY family",
261 $route
262 ),
263 ARRAY_A
264 );
265
266 /**
267 * Consistent beacon measurements required before a never-rendered
268 * family is scoped off a page. Corroboration means one bad or forged
269 * report can't remove a font (beacons are throttled hourly, so this is
270 * also a minimum observation window).
271 *
272 * @param int $min
273 */
274 $min_misses = max( 1, (int) apply_filters( 'easyfonts_scope_out_min_measurements', 2 ) );
275
276 if ( $rows ) {
277 foreach ( $rows as $r ) {
278 $fam = strtolower( (string) $r['family'] );
279 $rendered = ! empty( $r['rnd'] );
280 $beaconed = ! empty( $r['beaconed'] );
281
282 if ( $beaconed ) {
283 $out['measured'] = true;
284 }
285
286 if ( $rendered ) {
287 $out['rendered'][ $fam ] = true;
288
289 if ( ! empty( $r['af'] ) ) {
290 $out['above_fold'][ $fam ] = true;
291 }
292 } elseif ( $beaconed && (int) $r['misses'] >= $min_misses ) {
293 // Multiple beacons measured it and never saw it render →
294 // confirmed scope-out.
295 $out['scope_out'][ $fam ] = true;
296 }
297 }
298 }
299
300 return $out;
301 }
302
303 /**
304 * Cached set of families that are above the fold on EVERY route where they
305 * render — i.e. uniformly above-the-fold site-wide. These can be preloaded
306 * wherever they appear without consulting per-route data (and even before a
307 * given route has been measured), which is the cheap path: no per-route
308 * check needed for the fonts whose answer is the same everywhere.
309 *
310 * @return array<string,bool> Lowercased family => true.
311 */
312 public function global_preload_families(): array {
313 $list = get_option( 'easyfonts_global_preload', array() );
314
315 return is_array( $list ) ? array_fill_keys( array_map( 'strval', $list ), true ) : array();
316 }
317
318 /**
319 * Recompute and cache the uniformly-above-the-fold family set. A family
320 * qualifies when every measured row for it (across all routes/variants where
321 * it renders) is above the fold — so its preload answer doesn't vary by page
322 * and we can skip per-route checks for it. Cheap: one grouped scan of the
323 * (capped) usage table; called when a beacon is accepted and from GC.
324 *
325 * @return void
326 */
327 public function recompute_global_preload(): void {
328 global $wpdb;
329
330 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
331 $families = $wpdb->get_col(
332 "SELECT family FROM {$this->usage_table()}
333 WHERE rendered = 1
334 GROUP BY family
335 HAVING MIN(above_fold) = 1"
336 );
337
338 $families = $families ? array_values( array_unique( array_map( 'strtolower', $families ) ) ) : array();
339
340 update_option( 'easyfonts_global_preload', $families, true );
341 }
342
343 /**
344 * Forget the stored decision for a route (all devices), so the next beacon
345 * for it is accepted immediately instead of being throttled. Used by the
346 * "Optimize" action to force a fresh measurement on demand.
347 *
348 * @param string $route Canonical route (path).
349 * @return void
350 */
351 public function forget_decision( string $route ): void {
352 global $wpdb;
353
354 foreach ( array( 'mobile', 'desktop', 'any' ) as $dev ) {
355 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
356 $wpdb->delete( $this->decisions_table(), array( 'route_key' => sha1( $route . '|' . $dev ) ) );
357 }
358 }
359
360 /**
361 * Store beacon decisions (preload / unload) for a route + device.
362 *
363 * @param string $route Path (e.g. /pricing).
364 * @param string $device 'mobile'|'desktop'|'any'.
365 * @param array<int,mixed> $preload Above-the-fold variants to preload.
366 * @param array<int,mixed> $unload Loaded-but-unrendered variants to unload.
367 */
368 public function store_decision( string $route, string $device, array $preload, array $unload ): void {
369 global $wpdb;
370
371 $key = sha1( $route . '|' . $device );
372
373 $alias = $this->dup_alias();
374
375 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
376 $wpdb->query(
377 $wpdb->prepare(
378 "INSERT INTO {$this->decisions_table()}
379 (route_key, route, device, preload, unload, updated_at)
380 VALUES (%s,%s,%s,%s,%s,%s){$alias}
381 ON DUPLICATE KEY UPDATE
382 preload = {$this->dup( 'preload' )},
383 unload = {$this->dup( 'unload' )},
384 updated_at = {$this->dup( 'updated_at' )}",
385 $key,
386 $route,
387 $device,
388 wp_json_encode( array_values( $preload ) ),
389 wp_json_encode( array_values( $unload ) ),
390 gmdate( 'Y-m-d H:i:s' ) // UTC — parsed as UTC in has_fresh_decision().
391 )
392 );
393 }
394
395 /**
396 * Get the decision for a route + device (falls back to 'any').
397 *
398 * @param string $route Path.
399 * @param string $device Device.
400 * @return array{preload:array,unload:array}
401 */
402 public function get_decision( string $route, string $device ): array {
403 global $wpdb;
404
405 $empty = array(
406 'preload' => array(),
407 'unload' => array(),
408 );
409
410 foreach ( array( $device, 'any' ) as $dev ) {
411 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
412 $row = $wpdb->get_row(
413 $wpdb->prepare(
414 "SELECT preload, unload FROM {$this->decisions_table()} WHERE route_key = %s",
415 sha1( $route . '|' . $dev )
416 ),
417 ARRAY_A
418 );
419
420 if ( $row ) {
421 return array(
422 'preload' => json_decode( (string) $row['preload'], true ) ?: array(),
423 'unload' => json_decode( (string) $row['unload'], true ) ?: array(),
424 );
425 }
426 }
427
428 return $empty;
429 }
430
431 /**
432 * Aggregated usage summary (one row per family) for the dashboard.
433 *
434 * @return array<int,array<string,mixed>>
435 */
436 public function summary(): array {
437 global $wpdb;
438
439 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
440 $rows = $wpdb->get_results(
441 "SELECT
442 family,
443 GROUP_CONCAT(DISTINCT weight ORDER BY weight SEPARATOR ',') AS weights,
444 SUM(hits) AS hits,
445 COUNT(DISTINCT page_url) AS pages,
446 MAX(rendered) AS rendered,
447 MAX(above_fold) AS above_fold,
448 MAX(last_seen) AS last_seen
449 FROM {$this->usage_table()}
450 GROUP BY family
451 ORDER BY hits DESC",
452 ARRAY_A
453 );
454
455 return $rows ?: array();
456 }
457
458 /**
459 * Families that were loaded but never observed rendering (cleanup candidates).
460 *
461 * @return string[]
462 */
463 public function never_rendered_families(): array {
464 global $wpdb;
465
466 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
467 $rows = $wpdb->get_col(
468 "SELECT family FROM {$this->usage_table()}
469 GROUP BY family
470 HAVING MAX(rendered) = 0"
471 );
472
473 return $rows ?: array();
474 }
475
476 /**
477 * Garbage-collect stale rows. Keeps the usage/decisions tables bounded on
478 * long-running / high-traffic sites: anything not seen within the retention
479 * window is dropped, and a hard row cap trims the oldest beyond it. Called
480 * from the weekly cleanup event.
481 *
482 * @param int $days Retention window in days.
483 * @param int $max_rows Hard cap on usage rows after time-pruning.
484 * @return void
485 */
486 public function gc( int $days = 30, int $max_rows = 5000 ): void {
487 global $wpdb;
488
489 $days = max( 1, $days );
490 $usage = $this->usage_table();
491 $dec = $this->decisions_table();
492
493 // 1. Time-based prune (usage + decisions). Timestamps are stored in
494 // UTC, so compare against UTC_TIMESTAMP() (not session-TZ NOW()).
495 // phpcs:disable WordPress.DB.DirectDatabaseQuery
496 $wpdb->query(
497 $wpdb->prepare(
498 "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
499 $days
500 )
501 );
502 $wpdb->query(
503 $wpdb->prepare(
504 "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
505 $days
506 )
507 );
508 // phpcs:enable
509
510 // 2. Hard cap: if usage still exceeds the cap, drop the oldest surplus.
511 // The id-bounded form is deterministic (replication-safe), unlike a bare
512 // DELETE ... ORDER BY ... LIMIT.
513 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
514 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" );
515
516 if ( $total > $max_rows ) {
517 $surplus = $total - $max_rows;
518
519 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
520 $wpdb->query(
521 $wpdb->prepare(
522 "DELETE FROM {$usage} WHERE id IN (
523 SELECT id FROM ( SELECT id FROM {$usage} ORDER BY last_seen ASC, id ASC LIMIT %d ) AS ef_tmp
524 )",
525 $surplus
526 )
527 );
528 }
529
530 // 3. Hard cap on decisions rows too — beacon routes are public input, so
531 // without a cap the table could be grown without bound between prunes.
532 /**
533 * Hard cap on decision rows after time-based pruning.
534 *
535 * @param int $max
536 */
537 $max_dec = max( 100, (int) apply_filters( 'easyfonts_gc_max_decision_rows', 2000 ) );
538
539 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
540 $total_dec = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$dec}" );
541
542 if ( $total_dec > $max_dec ) {
543 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
544 $wpdb->query(
545 $wpdb->prepare(
546 "DELETE FROM {$dec} WHERE id IN (
547 SELECT id FROM ( SELECT id FROM {$dec} ORDER BY updated_at ASC, id ASC LIMIT %d ) AS ef_tmp
548 )",
549 $total_dec - $max_dec
550 )
551 );
552 }
553
554 // Pruning may have changed which families are uniformly above the fold.
555 $this->recompute_global_preload();
556 }
557
558 /**
559 * Has a fresh decision already been recorded for this route + device?
560 *
561 * Used to make public beacon ingestion effectively write-once per
562 * route/device until the retention window elapses — capping DB writes and
563 * neutralising repeated-request abuse without losing any functionality.
564 *
565 * @param string $route Path.
566 * @param string $device Device.
567 * @param int $fresh_hours Consider a decision "fresh" for this many hours.
568 * @return bool
569 */
570 public function has_fresh_decision( string $route, string $device, int $fresh_hours = 168 ): bool {
571 global $wpdb;
572
573 $key = sha1( $route . '|' . $device );
574
575 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
576 $updated = $wpdb->get_var(
577 $wpdb->prepare(
578 "SELECT updated_at FROM {$this->decisions_table()} WHERE route_key = %s",
579 $key
580 )
581 );
582
583 if ( ! $updated ) {
584 return false;
585 }
586
587 // Stored in UTC — parse it as UTC regardless of PHP's default timezone.
588 return ( time() - strtotime( (string) $updated . ' UTC' ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS );
589 }
590
591 /**
592 * ON DUPLICATE KEY UPDATE row reference, portable across servers.
593 *
594 * MySQL 8.0.20 deprecated VALUES() in ON DUPLICATE KEY UPDATE in favour of
595 * a row alias — which MariaDB does not support. Detect the server once and
596 * emit whichever syntax is native, so neither engine logs deprecation
597 * warnings nor breaks when VALUES() is eventually removed.
598 *
599 * @param string $column Column name.
600 * @return string SQL fragment referencing the incoming row's value.
601 */
602 private function dup( string $column ): string {
603 return $this->use_row_alias() ? "ef_new.{$column}" : "VALUES({$column})";
604 }
605
606 /**
607 * Row-alias suffix for the INSERT (empty when using VALUES()).
608 *
609 * @return string
610 */
611 private function dup_alias(): string {
612 return $this->use_row_alias() ? ' AS ef_new' : '';
613 }
614
615 /**
616 * Should upserts use the MySQL 8.0.19+ row-alias syntax?
617 *
618 * @return bool
619 */
620 private function use_row_alias(): bool {
621 static $use = null;
622
623 if ( null === $use ) {
624 global $wpdb;
625
626 $info = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : '';
627 $is_mariadb = false !== stripos( $info, 'mariadb' );
628
629 $use = ! $is_mariadb && '' !== $info && version_compare( (string) $wpdb->db_version(), '8.0.19', '>=' );
630 }
631
632 return $use;
633 }
634
635 /**
636 * Empty both tables.
637 */
638 public function truncate(): void {
639 global $wpdb;
640
641 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
642 $wpdb->query( "TRUNCATE TABLE {$this->usage_table()}" );
643 $wpdb->query( "TRUNCATE TABLE {$this->decisions_table()}" );
644 // phpcs:enable
645
646 // No usage data left → no uniformly-above-the-fold fonts.
647 delete_option( 'easyfonts_global_preload' );
648 }
649 }
650