PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 1.2.1 1.2.2 1.2.3
xspeed / includes / class-activity-log.php

class-activity-log.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, at includes/class-activity-log.php

175 lines 5.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Activity_Log — capped, append-only log of cache lifecycle events.
4 *
5 * Used by the Health module's dashboard panel + (later phases) by audit
6 * surfaces. Storage: one transient `xspeed_activity_log` containing at
7 * most MAX_ENTRIES events, ordered newest-first. Transient TTL is long
8 * (30 days) so we don't lose history during quiet periods, but the size
9 * cap keeps it bounded.
10 *
11 * Event shape:
12 * [ ts => int, type => string, message => string, severity => 'info' |
13 * 'warn' | 'error' | 'success' ]
14 *
15 * Callers (from Cache.php and elsewhere):
16 * Activity_Log::record('cache_purged', 'Cache purged (settings change)');
17 * Activity_Log::record('cache_enabled_event', 'Cache enabled', 'success');
18 * Activity_Log::record('conflict_detected', 'WP Rocket activated', 'warn');
19 *
20 * Event type ids are snake_case — sanitize_key (called on the way in)
21 * strips dots, so dotted ids would collapse silently.
22 *
23 * @package XSpeed
24 */
25
26 declare(strict_types=1);
27
28 namespace XSpeed;
29
30 defined( 'ABSPATH' ) || exit;
31
32 final class Activity_Log {
33
34 /**
35 * Storage key. An OPTION, not a transient — with a persistent object
36 * cache a transient lives in Redis/Memcached, and `Cache::purge_all()`
37 * calls `wp_cache_flush()` before it records the purge. The log was
38 * therefore erased by the very event it exists to record: on any
39 * Redis/Memcached site "Last purge" could never show more than the one
40 * row written after the flush. A history a flush can evaporate is not a
41 * history. Autoload is off — the log is read in admin contexts only.
42 */
43 public const OPTION_KEY = 'xspeed_activity_log';
44
45 /** Legacy transient the log used to live in; drained once on read. */
46 public const TRANSIENT_KEY = 'xspeed_activity_log';
47
48 public const TTL = 2592000; // 30 days — legacy transient only.
49 public const MAX_ENTRIES = 50;
50
51 public const INFO = 'info';
52 public const WARN = 'warn';
53 public const ERROR = 'error';
54 public const SUCCESS = 'success';
55
56 public static function record( string $type, string $message, string $severity = self::INFO ): void {
57 $entries = self::entries();
58 array_unshift(
59 $entries,
60 array(
61 'ts' => time(),
62 'type' => sanitize_key( $type ),
63 'message' => $message, // caller must produce already-safe text.
64 'severity' => in_array( $severity, array( self::INFO, self::WARN, self::ERROR, self::SUCCESS ), true ) ? $severity : self::INFO,
65 )
66 );
67 if ( count( $entries ) > self::MAX_ENTRIES ) {
68 $entries = array_slice( $entries, 0, self::MAX_ENTRIES );
69 }
70 self::store( $entries );
71 }
72
73 /**
74 * Persist the log with autoload disabled, so it never joins
75 * `wp_load_alloptions()` on frontend requests.
76 *
77 * @param array<int,array<string,mixed>> $entries Newest-first entries.
78 */
79 private static function store( array $entries ): void {
80 if ( false === get_option( self::OPTION_KEY, false ) ) {
81 add_option( self::OPTION_KEY, $entries, '', 'no' );
82 return;
83 }
84 update_option( self::OPTION_KEY, $entries );
85 }
86
87 /**
88 * Newest-first entries (up to MAX_ENTRIES). Defensive shape coercion
89 * so a malformed transient never breaks the dashboard.
90 *
91 * @return array<int,array{ts:int,type:string,message:string,severity:string}>
92 */
93 public static function entries(): array {
94 $raw = get_option( self::OPTION_KEY, null );
95
96 // One-time migration off the old transient. Done lazily on read so
97 // no upgrade routine has to run first, and so history written by a
98 // previous version isn't thrown away.
99 if ( ! is_array( $raw ) ) {
100 $legacy = get_transient( self::TRANSIENT_KEY );
101 if ( is_array( $legacy ) ) {
102 self::store( $legacy );
103 delete_transient( self::TRANSIENT_KEY );
104 $raw = $legacy;
105 }
106 }
107
108 if ( ! is_array( $raw ) ) {
109 return array();
110 }
111 $out = array();
112 foreach ( $raw as $e ) {
113 if ( is_array( $e ) && isset( $e['ts'], $e['type'], $e['message'] ) ) {
114 $out[] = array(
115 'ts' => (int) $e['ts'],
116 'type' => (string) $e['type'],
117 'message' => (string) $e['message'],
118 'severity' => isset( $e['severity'] ) ? (string) $e['severity'] : self::INFO,
119 );
120 }
121 }
122 return $out;
123 }
124
125 public static function clear(): void {
126 delete_option( self::OPTION_KEY );
127 delete_transient( self::TRANSIENT_KEY );
128 }
129
130 /**
131 * One-time scrub of secret values recorded by earlier versions.
132 *
133 * Settings change annotations used to include the raw value of every
134 * changed field, secrets included, and the dashboard trend endpoints
135 * serve those annotations. Redacting new writes isn't enough — the log
136 * is a 30-day transient, so entries written before the upgrade would
137 * keep exposing credentials until they aged out.
138 *
139 * Rewrites any `<secret_key> old→new` fragment to `<secret_key> changed`,
140 * preserving the rest of the entry so the causal history survives.
141 */
142 public static function redact_legacy_secrets(): void {
143 $entries = self::entries();
144 if ( empty( $entries ) ) {
145 return;
146 }
147
148 $changed = false;
149 foreach ( $entries as $i => $entry ) {
150 if ( ! is_array( $entry ) || ! isset( $entry['message'] ) ) {
151 continue;
152 }
153 $message = (string) $entry['message'];
154 // `key value→value` where key is secret-ish. Values never contain
155 // a comma (describe_value truncates at 40 chars), so the fragment
156 // ends at the next comma or the trailing " (via <channel>)".
157 $scrubbed = preg_replace_callback(
158 '/([a-z0-9_]*(?:token|password|secret|api_key|passwd|private_key|credential)[a-z0-9_]*)\s+[^,]*?→[^,]*?(?=,|\s+\(via|$)/i',
159 static function ( $m ) {
160 return $m[1] . ' changed';
161 },
162 $message
163 );
164 if ( null !== $scrubbed && $scrubbed !== $message ) {
165 $entries[ $i ]['message'] = $scrubbed;
166 $changed = true;
167 }
168 }
169
170 if ( $changed ) {
171 self::store( $entries );
172 }
173 }
174 }
175