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

333 lines 8.8 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 * Record a set of font observations for one page in a single query each.
41 *
42 * @param string $page_url Page URL.
43 * @param int $page_id Post ID (0 if unknown).
44 * @param array<int,array<string,mixed>> $records Each: family, weight, style, origin, rendered, above_fold.
45 */
46 public function record( string $page_url, int $page_id, array $records ): void {
47 global $wpdb;
48
49 $now = current_time( 'mysql' );
50 $table = $this->usage_table();
51
52 foreach ( $records as $rec ) {
53 $family = sanitize_text_field( $rec['family'] ?? '' );
54
55 if ( '' === $family ) {
56 continue;
57 }
58
59 $weight = sanitize_text_field( $rec['weight'] ?? '400' );
60 $style = sanitize_text_field( $rec['style'] ?? 'normal' );
61 $origin = sanitize_text_field( $rec['origin'] ?? 'buffer' );
62 $rendered = ! empty( $rec['rendered'] ) ? 1 : 0;
63 $above_fold = ! empty( $rec['above_fold'] ) ? 1 : 0;
64 $key = sha1( $page_url . '|' . strtolower( $family ) . '|' . $weight . '|' . $style );
65
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 )
89 );
90 }
91 }
92
93 /**
94 * Sets of what the beacon observed actually rendering, for used/unused
95 * classification on the Fonts screen.
96 *
97 * @return array{variants:array<string,bool>,families:array<string,bool>}
98 */
99 public function rendered_set(): array {
100 global $wpdb;
101
102 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
103 $rows = $wpdb->get_results(
104 "SELECT DISTINCT family, weight, style FROM {$this->usage_table()} WHERE rendered = 1",
105 ARRAY_A
106 );
107
108 $variants = array();
109 $families = array();
110
111 if ( $rows ) {
112 foreach ( $rows as $r ) {
113 $lc = strtolower( $r['family'] );
114 $families[ $lc ] = true;
115 $variants[ $lc . '|' . $r['weight'] . '|' . $r['style'] ] = true;
116 }
117 }
118
119 return array(
120 'variants' => $variants,
121 'families' => $families,
122 );
123 }
124
125 /**
126 * Store beacon decisions (preload / unload) for a route + device.
127 *
128 * @param string $route Path (e.g. /pricing).
129 * @param string $device 'mobile'|'desktop'|'any'.
130 * @param array<int,mixed> $preload Above-the-fold variants to preload.
131 * @param array<int,mixed> $unload Loaded-but-unrendered variants to unload.
132 */
133 public function store_decision( string $route, string $device, array $preload, array $unload ): void {
134 global $wpdb;
135
136 $key = sha1( $route . '|' . $device );
137
138 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
139 $wpdb->query(
140 $wpdb->prepare(
141 "INSERT INTO {$this->decisions_table()}
142 (route_key, route, device, preload, unload, updated_at)
143 VALUES (%s,%s,%s,%s,%s,%s)
144 ON DUPLICATE KEY UPDATE
145 preload = VALUES(preload),
146 unload = VALUES(unload),
147 updated_at = VALUES(updated_at)",
148 $key,
149 $route,
150 $device,
151 wp_json_encode( array_values( $preload ) ),
152 wp_json_encode( array_values( $unload ) ),
153 current_time( 'mysql' )
154 )
155 );
156 }
157
158 /**
159 * Get the decision for a route + device (falls back to 'any').
160 *
161 * @param string $route Path.
162 * @param string $device Device.
163 * @return array{preload:array,unload:array}
164 */
165 public function get_decision( string $route, string $device ): array {
166 global $wpdb;
167
168 $empty = array(
169 'preload' => array(),
170 'unload' => array(),
171 );
172
173 foreach ( array( $device, 'any' ) as $dev ) {
174 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
175 $row = $wpdb->get_row(
176 $wpdb->prepare(
177 "SELECT preload, unload FROM {$this->decisions_table()} WHERE route_key = %s",
178 sha1( $route . '|' . $dev )
179 ),
180 ARRAY_A
181 );
182
183 if ( $row ) {
184 return array(
185 'preload' => json_decode( (string) $row['preload'], true ) ?: array(),
186 'unload' => json_decode( (string) $row['unload'], true ) ?: array(),
187 );
188 }
189 }
190
191 return $empty;
192 }
193
194 /**
195 * Aggregated usage summary (one row per family) for the dashboard.
196 *
197 * @return array<int,array<string,mixed>>
198 */
199 public function summary(): array {
200 global $wpdb;
201
202 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
203 $rows = $wpdb->get_results(
204 "SELECT
205 family,
206 GROUP_CONCAT(DISTINCT weight ORDER BY weight SEPARATOR ',') AS weights,
207 SUM(hits) AS hits,
208 COUNT(DISTINCT page_url) AS pages,
209 MAX(rendered) AS rendered,
210 MAX(above_fold) AS above_fold,
211 MAX(last_seen) AS last_seen
212 FROM {$this->usage_table()}
213 GROUP BY family
214 ORDER BY hits DESC",
215 ARRAY_A
216 );
217
218 return $rows ?: array();
219 }
220
221 /**
222 * Families that were loaded but never observed rendering (cleanup candidates).
223 *
224 * @return string[]
225 */
226 public function never_rendered_families(): array {
227 global $wpdb;
228
229 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
230 $rows = $wpdb->get_col(
231 "SELECT family FROM {$this->usage_table()}
232 GROUP BY family
233 HAVING MAX(rendered) = 0"
234 );
235
236 return $rows ?: array();
237 }
238
239 /**
240 * Garbage-collect stale rows. Keeps the usage/decisions tables bounded on
241 * long-running / high-traffic sites: anything not seen within the retention
242 * window is dropped, and a hard row cap trims the oldest beyond it. Called
243 * from the weekly cleanup event.
244 *
245 * @param int $days Retention window in days.
246 * @param int $max_rows Hard cap on usage rows after time-pruning.
247 * @return void
248 */
249 public function gc( int $days = 30, int $max_rows = 5000 ): void {
250 global $wpdb;
251
252 $days = max( 1, $days );
253 $usage = $this->usage_table();
254 $dec = $this->decisions_table();
255
256 // 1. Time-based prune (usage + decisions).
257 // phpcs:disable WordPress.DB.DirectDatabaseQuery
258 $wpdb->query(
259 $wpdb->prepare(
260 "DELETE FROM {$usage} WHERE last_seen IS NOT NULL AND last_seen < ( NOW() - INTERVAL %d DAY )",
261 $days
262 )
263 );
264 $wpdb->query(
265 $wpdb->prepare(
266 "DELETE FROM {$dec} WHERE updated_at IS NOT NULL AND updated_at < ( NOW() - INTERVAL %d DAY )",
267 $days
268 )
269 );
270 // phpcs:enable
271
272 // 2. Hard cap: if usage still exceeds the cap, drop the oldest surplus.
273 // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
274 $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$usage}" );
275
276 if ( $total > $max_rows ) {
277 $surplus = $total - $max_rows;
278
279 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
280 $wpdb->query(
281 $wpdb->prepare(
282 "DELETE FROM {$usage} ORDER BY last_seen ASC LIMIT %d",
283 $surplus
284 )
285 );
286 }
287 }
288
289 /**
290 * Has a fresh decision already been recorded for this route + device?
291 *
292 * Used to make public beacon ingestion effectively write-once per
293 * route/device until the retention window elapses — capping DB writes and
294 * neutralising repeated-request abuse without losing any functionality.
295 *
296 * @param string $route Path.
297 * @param string $device Device.
298 * @param int $fresh_hours Consider a decision "fresh" for this many hours.
299 * @return bool
300 */
301 public function has_fresh_decision( string $route, string $device, int $fresh_hours = 168 ): bool {
302 global $wpdb;
303
304 $key = sha1( $route . '|' . $device );
305
306 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
307 $updated = $wpdb->get_var(
308 $wpdb->prepare(
309 "SELECT updated_at FROM {$this->decisions_table()} WHERE route_key = %s",
310 $key
311 )
312 );
313
314 if ( ! $updated ) {
315 return false;
316 }
317
318 return ( time() - strtotime( (string) $updated ) ) < ( max( 1, $fresh_hours ) * HOUR_IN_SECONDS );
319 }
320
321 /**
322 * Empty both tables.
323 */
324 public function truncate(): void {
325 global $wpdb;
326
327 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
328 $wpdb->query( "TRUNCATE TABLE {$this->usage_table()}" );
329 $wpdb->query( "TRUNCATE TABLE {$this->decisions_table()}" );
330 // phpcs:enable
331 }
332 }
333