PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
1.3.2 1.3.1 1.3.0 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 All 28 releases
xspeed / includes / class-activity-log.php

class-activity-log.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.2, 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