PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.2
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.2
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.2, at src/Fonts/UsageTracker.php

544 lines 15.6 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 return '' === $path ? '/' : $path;
54 }
55
56 /**
57 * Canonical route key for the current request.
58 *
59 * @return string
60 */
61 public static function current_route(): string {
62 return self::route_for( home_url( add_query_arg( array() ) ) );
63 }
64
65 /**
66 * Record a set of font observations for one page in a single query each.
67 *
68 * @param string $page_url Page URL.
69 * @param int $page_id Post ID (0 if unknown).
70 * @param array<int,array<string,mixed>> $records Each: family, weight, style, origin, rendered, above_fold.
71 */
72 public function record( string $page_url, int $page_id, array $records ): void {
73 global $wpdb;
74
75 if ( empty( $records ) ) {
76 return;
77 }
78
79 $now = current_time( 'mysql' );
80 $table = $this->usage_table();
81
82 // Collapse to one row per (page, family, weight, style); merge any
83 // duplicates within this batch (strongest rendered/above_fold wins,
84 // beacon origin beats buffer) so a single multi-row upsert replaces what
85 // used to be one query per variant on every render.
86 $by_key = array();
87
88 foreach ( $records as $rec ) {
89 $family = sanitize_text_field( $rec['family'] ?? '' );
90
91 if ( '' === $family ) {
92 continue;
93 }
94
95 $weight = sanitize_text_field( $rec['weight'] ?? '400' );
96 $style = sanitize_text_field( $rec['style'] ?? 'normal' );
97 $origin = sanitize_text_field( $rec['origin'] ?? 'buffer' );
98 $key = sha1( $page_url . '|' . strtolower( $family ) . '|' . $weight . '|' . $style );
99
100 $rendered = ! empty( $rec['rendered'] ) ? 1 : 0;
101 $above_fold = ! empty( $rec['above_fold'] ) ? 1 : 0;
102
103 if ( isset( $by_key[ $key ] ) ) {
104 $by_key[ $key ]['rendered'] = max( $by_key[ $key ]['rendered'], $rendered );
105 $by_key[ $key ]['above_fold'] = max( $by_key[ $key ]['above_fold'], $above_fold );
106
107 if ( 'beacon' === $origin ) {
108 $by_key[ $key ]['origin'] = 'beacon';
109 }
110
111 continue;
112 }
113
114 $by_key[ $key ] = array(
115 'key' => $key,
116 'family' => $family,
117 'weight' => $weight,
118 'style' => $style,
119 'origin' => $origin,
120 'rendered' => $rendered,
121 'above_fold' => $above_fold,
122 );
123 }
124
125 if ( empty( $by_key ) ) {
126 return;
127 }
128
129 $placeholders = array();
130 $args = array();
131
132 foreach ( $by_key as $row ) {
133 $placeholders[] = '(%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%s)';
134 array_push(
135 $args,
136 $row['key'],
137 $page_url,
138 $page_id,
139 $row['family'],
140 $row['weight'],
141 $row['style'],
142 $row['origin'],
143 $row['rendered'],
144 $row['above_fold'],
145 $now
146 );
147 }
148
149 $sql = "INSERT INTO {$table}
150 (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, last_seen)
151 VALUES " . implode( ',', $placeholders ) . "
152 ON DUPLICATE KEY UPDATE
153 hits = hits + 1,
154 rendered = GREATEST(rendered, VALUES(rendered)),
155 above_fold = GREATEST(above_fold, VALUES(above_fold)),
156 origin = VALUES(origin),
157 last_seen = VALUES(last_seen)";
158
159 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
160 $wpdb->query( $wpdb->prepare( $sql, $args ) );
161 }
162
163 /**
164 * Sets of what the beacon observed actually rendering, for used/unused
165 * classification on the Fonts screen.
166 *
167 * @return array{variants:array<string,bool>,families:array<string,bool>}
168 */
169 public function rendered_set(): array {
170 global $wpdb;
171
172 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
173 $rows = $wpdb->get_results(
174 "SELECT DISTINCT family, weight, style FROM {$this->usage_table()} WHERE rendered = 1",
175 ARRAY_A
176 );
177
178 $variants = array();
179 $families = array();
180
181 if ( $rows ) {
182 foreach ( $rows as $r ) {
183 $lc = strtolower( $r['family'] );
184 $families[ $lc ] = true;
185 $variants[ $lc . '|' . $r['weight'] . '|' . $r['style'] ] = true;
186 }
187 }
188
189 return array(
190 'variants' => $variants,
191 'families' => $families,
192 );
193 }
194
195 /**
196 * Per-route render profile in a SINGLE query: which families the beacon has
197 * seen rendering on the route, and which of those are above the fold. Both
198 * are family-level and based on the page's authorial font-family, so they're
199 * robust to weight/style differences and self-correcting.
200 *
201 * - `rendered` drives page scoping (which fonts load on this page).
202 * - `above_fold` drives preload (which of them to fetch early).
203 *
204 * Empty `rendered` means the route hasn't been measured yet — callers must
205 * neither scope nor preload from no data.
206 *
207 * @param string $route Canonical route (path).
208 * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,scope_out:array<string,bool>,measured:bool}
209 */
210 public function route_render_profile( string $route ): array {
211 global $wpdb;
212
213 $out = array(
214 'rendered' => array(),
215 'above_fold' => array(),
216 'scope_out' => array(),
217 'measured' => false,
218 );
219
220 if ( '' === $route ) {
221 return $out;
222 }
223
224 // Per family on this route: was it ever rendered (used), was it above
225 // the fold, and has a real-browser beacon ever reported on it. The
226 // beacon reports both what rendered AND what loaded-but-never-rendered
227 // (recorded as rendered = 0, origin = 'beacon'), so a family is a
228 // confirmed scope-out ONLY when a beacon has actually measured it and
229 // never saw it render. A family that hasn't been beacon-measured yet
230 // (e.g. a font just added/replaced in the Customizer) is NEVER scoped
231 // out — it loads normally until a beacon has had a chance to judge it.
232 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
233 $rows = $wpdb->get_results(
234 $wpdb->prepare(
235 "SELECT family,
236 MAX(rendered) AS rnd,
237 MAX(CASE WHEN rendered = 1 THEN above_fold ELSE 0 END) AS af,
238 MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed
239 FROM {$this->usage_table()}
240 WHERE page_url = %s
241 GROUP BY family",
242 $route
243 ),
244 ARRAY_A
245 );
246
247 if ( $rows ) {
248 foreach ( $rows as $r ) {
249 $fam = strtolower( (string) $r['family'] );
250 $rendered = ! empty( $r['rnd'] );
251 $beaconed = ! empty( $r['beaconed'] );
252
253 if ( $beaconed ) {
254 $out['measured'] = true;
255 }
256
257 if ( $rendered ) {
258 $out['rendered'][ $fam ] = true;
259
260 if ( ! empty( $r['af'] ) ) {
261 $out['above_fold'][ $fam ] = true;
262 }
263 } elseif ( $beaconed ) {
264 // Beacon measured it and never saw it render → scope out.
265 $out['scope_out'][ $fam ] = true;
266 }
267 }
268 }
269
270 return $out;
271 }
272
273 /**
274 * Cached set of families that are above the fold on EVERY route where they
275 * render — i.e. uniformly above-the-fold site-wide. These can be preloaded
276 * wherever they appear without consulting per-route data (and even before a
277 * given route has been measured), which is the cheap path: no per-route
278 * check needed for the fonts whose answer is the same everywhere.
279 *
280 * @return array<string,bool> Lowercased family => true.
281 */
282 public function global_preload_families(): array {
283 $list = get_option( 'easyfonts_global_preload', array() );
284
285 return is_array( $list ) ? array_fill_keys( array_map( 'strval', $list ), true ) : array();
286 }
287
288 /**
289 * Recompute and cache the uniformly-above-the-fold family set. A family
290 * qualifies when every measured row for it (across all routes/variants where
291 * it renders) is above the fold — so its preload answer doesn't vary by page
292 * and we can skip per-route checks for it. Cheap: one grouped scan of the
293 * (capped) usage table; called when a beacon is accepted and from GC.
294 *
295 * @return void
296 */
297 public function recompute_global_preload(): void {
298 global $wpdb;
299
300 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
301 $families = $wpdb->get_col(
302 "SELECT family FROM {$this->usage_table()}
303 WHERE rendered = 1
304 GROUP BY family
305 HAVING MIN(above_fold) = 1"
306 );
307
308 $families = $families ? array_values( array_unique( array_map( 'strtolower', $families ) ) ) : array();
309
310 update_option( 'easyfonts_global_preload', $families, true );
311 }
312
313 /**
314 * Forget the stored decision for a route (all devices), so the next beacon
315 * for it is accepted immediately instead of being throttled. Used by the
316 * "Optimize" action to force a fresh measurement on demand.
317 *
318 * @param string $route Canonical route (path).
319 * @return void
320 */
321 public function forget_decision( string $route ): void {
322 global $wpdb;
323
324 foreach ( array( 'mobile', 'desktop', 'any' ) as $dev ) {
325 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
326 $wpdb->delete( $this->decisions_table(), array( 'route_key' => sha1( $route . '|' . $dev ) ) );
327 }
328 }
329
330 /**
331 * Store beacon decisions (preload / unload) for a route + device.
332 *
333 * @param string $route Path (e.g. /pricing).
334 * @param string $device 'mobile'|'desktop'|'any'.
335 * @param array<int,mixed> $preload Above-the-fold variants to preload.
336 * @param array<int,mixed> $unload Loaded-but-unrendered variants to unload.
337 */
338 public function store_decision( string $route, string $device, array $preload, array $unload ): void {
339 global $wpdb;
340
341 $key = sha1( $route . '|' . $device );
342
343 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
344 $wpdb->query(
345 $wpdb->prepare(
346 "INSERT INTO {$this->decisions_table()}
347 (route_key, route, device, preload, unload, updated_at)
348 VALUES (%s,%s,%s,%s,%s,%s)
349 ON DUPLICATE KEY UPDATE
350 preload = VALUES(preload),
351 unload = VALUES(unload),
352 updated_at = VALUES(updated_at)",
353 $key,
354 $route,
355 $device,
356 wp_json_encode( array_values( $preload ) ),
357 wp_json_encode( array_values( $unload ) ),
358 current_time( 'mysql' )
359 )
360 );
361 }
362
363 /**
364 * Get the decision for a route + device (falls back to 'any').
365 *
366 * @param string $route Path.
367 * @param string $device Device.
368 * @return array{preload:array,unload:array}
369 */
370 public function get_decision( string $route, string $device ): array {
371 global $wpdb;
372
373 $empty = array(
374 'preload' => array(),
375 'unload' => array(),
376 );
377
378 foreach ( array( $device, 'any' ) as $dev ) {
379 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
380 $row = $wpdb->get_row(
381 $wpdb->prepare(
382 "SELECT preload, unload FROM {$this->decisions_table()} WHERE route_key = %s",
383 sha1( $route . '|' . $dev )
384 ),
385 ARRAY_A
386 );
387
388 if ( $row ) {
389 return array(
390 'preload' => json_decode( (string) $row['preload'], true ) ?: array(),
391 'unload' => json_decode( (string) $row['unload'], true ) ?: array(),
392 );
393 }
394 }
395
396 return $empty;
397 }
398
399 /**
400 * Aggregated usage summary (one row per family) for the dashboard.
401 *
402 * @return array<int,array<string,mixed>>
403 */
404 public function summary(): array {
405 global $wpdb;
406
407 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
408 $rows = $wpdb->get_results(
409 "SELECT
410 family,
411 GROUP_CONCAT(DISTINCT weight ORDER BY weight SEPARATOR ',') AS weights,
412 SUM(hits) AS hits,
413 COUNT(DISTINCT page_url) AS pages,
414 MAX(rendered) AS rendered,
415 MAX(above_fold) AS above_fold,
416 MAX(last_seen) AS last_seen
417 FROM {$this->usage_table()}
418 GROUP BY family
419 ORDER BY hits DESC",
420 ARRAY_A
421 );
422
423 return $rows ?: array();
424 }
425
426 /**
427 * Families that were loaded but never observed rendering (cleanup candidates).
428 *
429 * @return string[]
430 */
431 public function never_rendered_families(): array {
432 global $wpdb;
433
434 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
435 $rows = $wpdb->get_col(
436 "SELECT family FROM {$this->usage_table()}
437 GROUP BY family
438 HAVING MAX(rendered) = 0"
439 );
440
441 return $rows ?: array();
442 }
443
444 /**
445 * Garbage-collect stale rows. Keeps the usage/decisions tables bounded on
446 * long-running / high-traffic sites: anything not seen within the retention
447 * window is dropped, and a hard row cap trims the oldest beyond it. Called
448 * from the weekly cleanup event.
449 *
450 * @param int $days Retention window in days.
451 * @param int $max_rows Hard cap on usage rows after time-pruning.
452 * @return void
453 */
454 public function gc( int $days = 30, int $max_rows = 5000 ): void {
455 global $wpdb;
456
457 $days = max( 1, $days );
458 $usage = $this->usage_table();
459 $dec = $this->decisions_table();
460
461 // 1. Time-based prune (usage + decisions).
462 // phpcs:disable WordPress.DB.DirectDatabaseQuery
463 $wpdb->query(
464 $wpdb->prepare(
465 "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( NOW() - INTERVAL %d DAY )",
466 $days
467 )
468 );
469 $wpdb->query(
470 $wpdb->prepare(
471 "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( NOW() - INTERVAL %d DAY )",
472 $days
473 )
474 );
475 // phpcs:enable
476
477 // 2. Hard cap: if usage still exceeds the cap, drop the oldest surplus.
478 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
479 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" );
480
481 if ( $total > $max_rows ) {
482 $surplus = $total - $max_rows;
483
484 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
485 $wpdb->query(
486 $wpdb->prepare(
487 "DELETE FROM {$usage} ORDER BY last_seen ASC LIMIT %d",
488 $surplus
489 )
490 );
491 }
492
493 // Pruning may have changed which families are uniformly above the fold.
494 $this->recompute_global_preload();
495 }
496
497 /**
498 * Has a fresh decision already been recorded for this route + device?
499 *
500 * Used to make public beacon ingestion effectively write-once per
501 * route/device until the retention window elapses — capping DB writes and
502 * neutralising repeated-request abuse without losing any functionality.
503 *
504 * @param string $route Path.
505 * @param string $device Device.
506 * @param int $fresh_hours Consider a decision "fresh" for this many hours.
507 * @return bool
508 */
509 public function has_fresh_decision( string $route, string $device, int $fresh_hours = 168 ): bool {
510 global $wpdb;
511
512 $key = sha1( $route . '|' . $device );
513
514 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
515 $updated = $wpdb->get_var(
516 $wpdb->prepare(
517 "SELECT updated_at FROM {$this->decisions_table()} WHERE route_key = %s",
518 $key
519 )
520 );
521
522 if ( ! $updated ) {
523 return false;
524 }
525
526 return ( time() - strtotime( (string) $updated ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS );
527 }
528
529 /**
530 * Empty both tables.
531 */
532 public function truncate(): void {
533 global $wpdb;
534
535 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
536 $wpdb->query( "TRUNCATE TABLE {$this->usage_table()}" );
537 $wpdb->query( "TRUNCATE TABLE {$this->decisions_table()}" );
538 // phpcs:enable
539
540 // No usage data left → no uniformly-above-the-fold fonts.
541 delete_option( 'easyfonts_global_preload' );
542 }
543 }
544