PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
1.3.3 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 All 29 releases
xspeed / includes / class-activity-log.php

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

134 lines 4.3 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 public const TRANSIENT_KEY = 'xspeed_activity_log';
35 public const TTL = 2592000; // 30 days
36 public const MAX_ENTRIES = 50;
37
38 public const INFO = 'info';
39 public const WARN = 'warn';
40 public const ERROR = 'error';
41 public const SUCCESS = 'success';
42
43 public static function record( string $type, string $message, string $severity = self::INFO ): void {
44 $entries = self::entries();
45 array_unshift(
46 $entries,
47 array(
48 'ts' => time(),
49 'type' => sanitize_key( $type ),
50 'message' => $message, // caller must produce already-safe text.
51 'severity' => in_array( $severity, array( self::INFO, self::WARN, self::ERROR, self::SUCCESS ), true ) ? $severity : self::INFO,
52 )
53 );
54 if ( count( $entries ) > self::MAX_ENTRIES ) {
55 $entries = array_slice( $entries, 0, self::MAX_ENTRIES );
56 }
57 set_transient( self::TRANSIENT_KEY, $entries, self::TTL );
58 }
59
60 /**
61 * Newest-first entries (up to MAX_ENTRIES). Defensive shape coercion
62 * so a malformed transient never breaks the dashboard.
63 *
64 * @return array<int,array{ts:int,type:string,message:string,severity:string}>
65 */
66 public static function entries(): array {
67 $raw = get_transient( self::TRANSIENT_KEY );
68 if ( ! is_array( $raw ) ) {
69 return array();
70 }
71 $out = array();
72 foreach ( $raw as $e ) {
73 if ( is_array( $e ) && isset( $e['ts'], $e['type'], $e['message'] ) ) {
74 $out[] = array(
75 'ts' => (int) $e['ts'],
76 'type' => (string) $e['type'],
77 'message' => (string) $e['message'],
78 'severity' => isset( $e['severity'] ) ? (string) $e['severity'] : self::INFO,
79 );
80 }
81 }
82 return $out;
83 }
84
85 public static function clear(): void {
86 delete_transient( self::TRANSIENT_KEY );
87 }
88
89 /**
90 * One-time scrub of secret values recorded by earlier versions.
91 *
92 * Settings change annotations used to include the raw value of every
93 * changed field, secrets included, and the dashboard trend endpoints
94 * serve those annotations. Redacting new writes isn't enough — the log
95 * is a 30-day transient, so entries written before the upgrade would
96 * keep exposing credentials until they aged out.
97 *
98 * Rewrites any `<secret_key> old→new` fragment to `<secret_key> changed`,
99 * preserving the rest of the entry so the causal history survives.
100 */
101 public static function redact_legacy_secrets(): void {
102 $entries = get_transient( self::TRANSIENT_KEY );
103 if ( ! is_array( $entries ) || empty( $entries ) ) {
104 return;
105 }
106
107 $changed = false;
108 foreach ( $entries as $i => $entry ) {
109 if ( ! is_array( $entry ) || ! isset( $entry['message'] ) ) {
110 continue;
111 }
112 $message = (string) $entry['message'];
113 // `key value→value` where key is secret-ish. Values never contain
114 // a comma (describe_value truncates at 40 chars), so the fragment
115 // ends at the next comma or the trailing " (via <channel>)".
116 $scrubbed = preg_replace_callback(
117 '/([a-z0-9_]*(?:token|password|secret|api_key|passwd|private_key|credential)[a-z0-9_]*)\s+[^,]*?→[^,]*?(?=,|\s+\(via|$)/i',
118 static function ( $m ) {
119 return $m[1] . ' changed';
120 },
121 $message
122 );
123 if ( null !== $scrubbed && $scrubbed !== $message ) {
124 $entries[ $i ]['message'] = $scrubbed;
125 $changed = true;
126 }
127 }
128
129 if ( $changed ) {
130 set_transient( self::TRANSIENT_KEY, $entries, self::TTL );
131 }
132 }
133 }
134