PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.4
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.4
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
← All changes | src/Fonts/UsageTracker.php +411 -38 2.0.0 → 2.0.4 View file →
@@ -36,8 +36,40 @@
36 36 return $wpdb->prefix . 'easyfonts_decisions';
37 37 }
38 38
39 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 + /**
40 72 * Record a set of font observations for one page in a single query each.
41 73 *
42 74 * @param string $page_url Page URL.
43 75 * @param int $page_id Post ID (0 if unknown).
@@ -45,11 +77,21 @@
45 77 */
46 78 public function record( string $page_url, int $page_id, array $records ): void {
47 79 global $wpdb;
48 80
49 - $now = current_time( 'mysql' );
81 + if ( empty( $records ) ) {
82 + return;
83 + }
84 +
85 + $now = gmdate( 'Y-m-d H:i:s' ); // UTC — compared against UTC_TIMESTAMP() in gc().
50 86 $table = $this->usage_table();
51 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 +
52 94 foreach ( $records as $rec ) {
53 95 $family = sanitize_text_field( $rec['family'] ?? '' );
54 96
55 97 if ( '' === $family ) {
@@ -55,40 +97,86 @@
55 97 if ( '' === $family ) {
56 98 continue;
57 99 }
58 100
59 - $weight = sanitize_text_field( $rec['weight'] ?? '400' );
60 - $style = sanitize_text_field( $rec['style'] ?? 'normal' );
61 - $origin = sanitize_text_field( $rec['origin'] ?? 'buffer' );
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 +
62 106 $rendered = ! empty( $rec['rendered'] ) ? 1 : 0;
63 107 $above_fold = ! empty( $rec['above_fold'] ) ? 1 : 0;
64 - $key = sha1( $page_url . '|' . strtolower( $family ) . '|' . $weight . '|' . $style );
65 108
66 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery
67 - $wpdb->query(
68 - $wpdb->prepare(
69 - "INSERT INTO {$table}
70 - (usage_key, page_url, page_id, family, weight, style, origin, rendered, above_fold, hits, last_seen)
71 - VALUES (%s,%s,%d,%s,%s,%s,%s,%d,%d,1,%s)
72 - ON DUPLICATE KEY UPDATE
73 - hits = hits + 1,
74 - rendered = GREATEST(rendered, VALUES(rendered)),
75 - above_fold = GREATEST(above_fold, VALUES(above_fold)),
76 - origin = VALUES(origin),
77 - last_seen = VALUES(last_seen)",
78 - $key,
79 - $page_url,
80 - $page_id,
81 - $family,
82 - $weight,
83 - $style,
84 - $origin,
85 - $rendered,
86 - $above_fold,
87 - $now
88 - )
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,
89 128 );
90 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 ) );
91 179 }
92 180
93 181 /**
94 182 * Sets of what the beacon observed actually rendering, for used/unused
@@ -122,8 +210,211 @@
122 210 );
123 211 }
124 212
125 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` the families above the fold.
221 + * - `fold_faces` drives preload: for every family rendered here, the faces
222 + * ('weight|style') the beacon saw above the fold — empty when the family
223 + * renders only further down. (2.0.4) Preload used to take the whole
224 + * family, so a family with many weights filled every slot with weights
225 + * the page never shows.
226 + *
227 + * Empty `rendered` means the route hasn't been measured yet — callers must
228 + * neither scope nor preload from no data.
229 + *
230 + * @param string $route Canonical route (path).
231 + * @return array{rendered:array<string,bool>,above_fold:array<string,bool>,fold_faces:array<string,array<string,bool>>,scope_out:array<string,bool>,measured:bool}
232 + */
233 + public function route_render_profile( string $route ): array {
234 + global $wpdb;
235 +
236 + $out = array(
237 + 'rendered' => array(),
238 + 'above_fold' => array(),
239 + 'fold_faces' => array(),
240 + 'scope_out' => array(),
241 + 'measured' => false,
242 + );
243 +
244 + if ( '' === $route ) {
245 + return $out;
246 + }
247 +
248 + // Per family on this route: was it ever rendered (used), was it above
249 + // the fold, and has a real-browser beacon ever reported on it. The
250 + // beacon reports both what rendered AND what loaded-but-never-rendered
251 + // (recorded as rendered = 0, origin = 'beacon'), so a family is a
252 + // confirmed scope-out ONLY when a beacon has actually measured it and
253 + // never saw it render. A family that hasn't been beacon-measured yet
254 + // (e.g. a font just added/replaced in the Customizer) is NEVER scoped
255 + // out — it loads normally until a beacon has had a chance to judge it.
256 + // Grouped per face (still one query); folded per family below.
257 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery
258 + $rows = $wpdb->get_results(
259 + $wpdb->prepare(
260 + "SELECT family, weight, style,
261 + MAX(rendered) AS rnd,
262 + MAX(CASE WHEN rendered = 1 THEN above_fold ELSE 0 END) AS af,
263 + MAX(CASE WHEN origin = 'beacon' THEN 1 ELSE 0 END) AS beaconed,
264 + MAX(beacon_misses) AS misses
265 + FROM {$this->usage_table()}
266 + WHERE page_url = %s
267 + GROUP BY family, weight, style",
268 + $route
269 + ),
270 + ARRAY_A
271 + );
272 +
273 + $families = array();
274 +
275 + foreach ( (array) $rows as $r ) {
276 + $fam = strtolower( (string) $r['family'] );
277 + $agg = $families[ $fam ] ?? array( 'rnd' => 0, 'af' => 0, 'beaconed' => 0, 'misses' => 0, 'faces' => array() );
278 +
279 + $agg['rnd'] = max( $agg['rnd'], (int) $r['rnd'] );
280 + $agg['af'] = max( $agg['af'], (int) $r['af'] );
281 + $agg['beaconed'] = max( $agg['beaconed'], (int) $r['beaconed'] );
282 + $agg['misses'] = max( $agg['misses'], (int) $r['misses'] );
283 +
284 + if ( ! empty( $r['af'] ) ) {
285 + $agg['faces'][ $r['weight'] . '|' . $r['style'] ] = true;
286 + }
287 +
288 + $families[ $fam ] = $agg;
289 + }
290 +
291 + /**
292 + * Consistent beacon measurements required before a never-rendered
293 + * family is scoped off a page. Corroboration means one bad or forged
294 + * report can't remove a font (beacons are throttled hourly, so this is
295 + * also a minimum observation window).
296 + *
297 + * @param int $min
298 + */
299 + $min_misses = max( 1, (int) apply_filters( 'easyfonts_scope_out_min_measurements', 2 ) );
300 +
301 + foreach ( $families as $fam => $r ) {
302 + $rendered = ! empty( $r['rnd'] );
303 + $beaconed = ! empty( $r['beaconed'] );
304 +
305 + if ( $beaconed ) {
306 + $out['measured'] = true;
307 + }
308 +
309 + if ( $rendered ) {
310 + $out['rendered'][ $fam ] = true;
311 + $out['fold_faces'][ $fam ] = $r['faces'];
312 +
313 + if ( ! empty( $r['af'] ) ) {
314 + $out['above_fold'][ $fam ] = true;
315 + }
316 + } elseif ( $beaconed && (int) $r['misses'] >= $min_misses ) {
317 + // Multiple beacons measured it and never saw it render →
318 + // confirmed scope-out.
319 + $out['scope_out'][ $fam ] = true;
320 + }
321 + }
322 +
323 + return $out;
324 + }
325 +
326 + /**
327 + * Cached set of families that are above the fold on EVERY route where they
328 + * render — i.e. uniformly above-the-fold site-wide. These can be preloaded
329 + * wherever they appear without consulting per-route data (and even before a
330 + * given route has been measured), which is the cheap path: no per-route
331 + * check needed for the fonts whose answer is the same everywhere.
332 + *
333 + * (2.0.4) With the faces ('weight|style') those families render in. A value
334 + * of `true` is a family stored by 2.0.3 without its faces — it stands for
335 + * the whole family until the next recompute (next beacon, or weekly GC).
336 + *
337 + * @return array<string,array<string,bool>|true> Lowercased family => faces.
338 + */
339 + public function global_preload_faces(): array {
340 + $list = get_option( 'easyfonts_global_preload', array() );
341 + $out = array();
342 +
343 + foreach ( is_array( $list ) ? $list : array() as $key => $value ) {
344 + if ( is_array( $value ) ) {
345 + $out[ (string) $key ] = array_fill_keys( array_map( 'strval', $value ), true );
346 + } elseif ( is_int( $key ) ) {
347 + $out[ strtolower( (string) $value ) ] = true;
348 + }
349 + }
350 +
351 + return $out;
352 + }
353 +
354 + /**
355 + * Recompute and cache the uniformly-above-the-fold family set. A family
356 + * qualifies when every measured row for it (across all routes/variants where
357 + * it renders) is above the fold — so its preload answer doesn't vary by page
358 + * and we can skip per-route checks for it. Stored with the faces it renders
359 + * in, so preload stays weight-level. Cheap: one grouped scan of the (capped)
360 + * usage table; called when a beacon is accepted and from GC.
361 + *
362 + * @return void
363 + */
364 + public function recompute_global_preload(): void {
365 + global $wpdb;
366 +
367 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
368 + $rows = $wpdb->get_results(
369 + "SELECT family, weight, style, MIN(above_fold) AS af FROM {$this->usage_table()}
370 + WHERE rendered = 1
371 + GROUP BY family, weight, style",
372 + ARRAY_A
373 + );
374 +
375 + $faces = array();
376 + $below = array();
377 +
378 + foreach ( (array) $rows as $r ) {
379 + $fam = strtolower( (string) $r['family'] );
380 +
381 + if ( empty( $r['af'] ) ) {
382 + $below[ $fam ] = true;
383 + }
384 +
385 + $faces[ $fam ][ $r['weight'] . '|' . $r['style'] ] = true;
386 + }
387 +
388 + $global = array();
389 +
390 + foreach ( $faces as $fam => $set ) {
391 + if ( ! isset( $below[ $fam ] ) ) {
392 + $global[ $fam ] = array_keys( $set );
393 + }
394 + }
395 +
396 + update_option( 'easyfonts_global_preload', $global, true );
397 + }
398 +
399 + /**
400 + * Forget the stored decision for a route (all devices), so the next beacon
401 + * for it is accepted immediately instead of being throttled. Used by the
402 + * "Optimize" action to force a fresh measurement on demand.
403 + *
404 + * @param string $route Canonical route (path).
405 + * @return void
406 + */
407 + public function forget_decision( string $route ): void {
408 + global $wpdb;
409 +
410 + foreach ( array( 'mobile', 'desktop', 'any' ) as $dev ) {
411 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery
412 + $wpdb->delete( $this->decisions_table(), array( 'route_key' => sha1( $route . '|' . $dev ) ) );
413 + }
414 + }
415 +
416 + /**
126 417 * Store beacon decisions (preload / unload) for a route + device.
127 418 *
128 419 * @param string $route Path (e.g. /pricing).
129 420 * @param string $device 'mobile'|'desktop'|'any'.
@@ -134,24 +425,26 @@
134 425 global $wpdb;
135 426
136 427 $key = sha1( $route . '|' . $device );
137 428
429 + $alias = $this->dup_alias();
430 +
138 431 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
139 432 $wpdb->query(
140 433 $wpdb->prepare(
141 434 "INSERT INTO {$this->decisions_table()}
142 435 (route_key, route, device, preload, unload, updated_at)
143 - VALUES (%s,%s,%s,%s,%s,%s)
436 + VALUES (%s,%s,%s,%s,%s,%s){$alias}
144 437 ON DUPLICATE KEY UPDATE
145 - preload = VALUES(preload),
146 - unload = VALUES(unload),
147 - updated_at = VALUES(updated_at)",
438 + preload = {$this->dup( 'preload' )},
439 + unload = {$this->dup( 'unload' )},
440 + updated_at = {$this->dup( 'updated_at' )}",
148 441 $key,
149 442 $route,
150 443 $device,
151 444 wp_json_encode( array_values( $preload ) ),
152 445 wp_json_encode( array_values( $unload ) ),
153 - current_time( 'mysql' )
446 + gmdate( 'Y-m-d H:i:s' ) // UTC — parsed as UTC in has_fresh_decision().
154 447 )
155 448 );
156 449 }
157 450
@@ -252,19 +545,20 @@
252 545 $days = max( 1, $days );
253 546 $usage = $this->usage_table();
254 547 $dec = $this->decisions_table();
255 548
256 - // 1. Time-based prune (usage + decisions).
549 + // 1. Time-based prune (usage + decisions). Timestamps are stored in
550 + // UTC, so compare against UTC_TIMESTAMP() (not session-TZ NOW()).
257 551 // phpcs:disable WordPress.DB.DirectDatabaseQuery
258 552 $wpdb->query(
259 553 $wpdb->prepare(
260 - "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( NOW() - INTERVAL %d DAY )",
554 + "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
261 555 $days
262 556 )
263 557 );
264 558 $wpdb->query(
265 559 $wpdb->prepare(
266 - "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( NOW() - INTERVAL %d DAY )",
560 + "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( UTC_TIMESTAMP() - INTERVAL %d DAY )",
267 561 $days
268 562 )
269 563 );
270 564 // phpcs:enable
@@ -269,8 +563,10 @@
269 563 );
270 564 // phpcs:enable
271 565
272 566 // 2. Hard cap: if usage still exceeds the cap, drop the oldest surplus.
567 + // The id-bounded form is deterministic (replication-safe), unlike a bare
568 + // DELETE ... ORDER BY ... LIMIT.
273 569 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
274 570 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" );
275 571
276 572 if ( $total > $max_rows ) {
@@ -278,13 +574,42 @@
278 574
279 575 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
280 576 $wpdb->query(
281 577 $wpdb->prepare(
282 - "DELETE FROM {$usage} ORDER BY last_seen ASC LIMIT %d",
578 + "DELETE FROM {$usage} WHERE id IN (
579 + SELECT id FROM ( SELECT id FROM {$usage} ORDER BY last_seen ASC, id ASC LIMIT %d ) AS ef_tmp
580 + )",
283 581 $surplus
284 582 )
285 583 );
286 584 }
585 +
586 + // 3. Hard cap on decisions rows too — beacon routes are public input, so
587 + // without a cap the table could be grown without bound between prunes.
588 + /**
589 + * Hard cap on decision rows after time-based pruning.
590 + *
591 + * @param int $max
592 + */
593 + $max_dec = max( 100, (int) apply_filters( 'easyfonts_gc_max_decision_rows', 2000 ) );
594 +
595 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
596 + $total_dec = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$dec}" );
597 +
598 + if ( $total_dec > $max_dec ) {
599 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery
600 + $wpdb->query(
601 + $wpdb->prepare(
602 + "DELETE FROM {$dec} WHERE id IN (
603 + SELECT id FROM ( SELECT id FROM {$dec} ORDER BY updated_at ASC, id ASC LIMIT %d ) AS ef_tmp
604 + )",
605 + $total_dec - $max_dec
606 + )
607 + );
608 + }
609 +
610 + // Pruning may have changed which families are uniformly above the fold.
611 + $this->recompute_global_preload();
287 612 }
288 613
289 614 /**
290 615 * Has a fresh decision already been recorded for this route + device?
@@ -314,12 +639,57 @@
314 639 if ( ! $updated ) {
315 640 return false;
316 641 }
317 642
318 - return ( time() - strtotime( (string) $updated ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS );
643 + // Stored in UTC — parse it as UTC regardless of PHP's default timezone.
644 + return ( time() - strtotime( (string) $updated . ' UTC' ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS );
319 645 }
320 646
321 647 /**
648 + * ON DUPLICATE KEY UPDATE row reference, portable across servers.
649 + *
650 + * MySQL 8.0.20 deprecated VALUES() in ON DUPLICATE KEY UPDATE in favour of
651 + * a row alias — which MariaDB does not support. Detect the server once and
652 + * emit whichever syntax is native, so neither engine logs deprecation
653 + * warnings nor breaks when VALUES() is eventually removed.
654 + *
655 + * @param string $column Column name.
656 + * @return string SQL fragment referencing the incoming row's value.
657 + */
658 + private function dup( string $column ): string {
659 + return $this->use_row_alias() ? "ef_new.{$column}" : "VALUES({$column})";
660 + }
661 +
662 + /**
663 + * Row-alias suffix for the INSERT (empty when using VALUES()).
664 + *
665 + * @return string
666 + */
667 + private function dup_alias(): string {
668 + return $this->use_row_alias() ? ' AS ef_new' : '';
669 + }
670 +
671 + /**
672 + * Should upserts use the MySQL 8.0.19+ row-alias syntax?
673 + *
674 + * @return bool
675 + */
676 + private function use_row_alias(): bool {
677 + static $use = null;
678 +
679 + if ( null === $use ) {
680 + global $wpdb;
681 +
682 + $info = method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : '';
683 + $is_mariadb = false !== stripos( $info, 'mariadb' );
684 +
685 + $use = ! $is_mariadb && '' !== $info && version_compare( (string) $wpdb->db_version(), '8.0.19', '>=' );
686 + }
687 +
688 + return $use;
689 + }
690 +
691 + /**
322 692 * Empty both tables.
323 693 */
324 694 public function truncate(): void {
325 695 global $wpdb;
@@ -327,6 +697,9 @@
327 697 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
328 698 $wpdb->query( "TRUNCATE TABLE {$this->usage_table()}" );
329 699 $wpdb->query( "TRUNCATE TABLE {$this->decisions_table()}" );
330 700 // phpcs:enable
701 +
702 + // No usage data left → no uniformly-above-the-fold fonts.
703 + delete_option( 'easyfonts_global_preload' );
331 704 }
332 705 }