← All changes
|
jetpack_vendor/automattic/jetpack-connection/src/class-external-storage.php
+320
-0
16.2-beta
→
16.3-beta
View file →
| @@ -1,0 +1,320 @@ | ||
| 1 | +<?php | |
| 2 | +/** | |
| 3 | + * External Storage utilities for Jetpack Connection. | |
| 4 | + * | |
| 5 | + * Provides centralized logic for external storage implementations | |
| 6 | + * across different environments (WoA, VIP, other). | |
| 7 | + * | |
| 8 | + * Usage Example: | |
| 9 | + * | |
| 10 | + * // 1. Create a storage provider class implementing the interface: | |
| 11 | + * class My_Storage_Provider implements Storage_Provider_Interface { | |
| 12 | + * public function is_available() { return true; } | |
| 13 | + * public function should_handle( $option_name ) { | |
| 14 | + * return in_array( $option_name, array( 'blog_token', 'id' ), true ); | |
| 15 | + * } | |
| 16 | + * public function get( $option_name ) { | |
| 17 | + * // Return value from your external storage or null | |
| 18 | + * } | |
| 19 | + * public function get_environment_id() { return 'my_env'; } | |
| 20 | + * } | |
| 21 | + * | |
| 22 | + * // 2. Register the provider: | |
| 23 | + * if ( class_exists( 'Automattic\Jetpack\Connection\External_Storage' ) ) { | |
| 24 | + * \Automattic\Jetpack\Connection\External_Storage::register_provider( new My_Storage_Provider() ); | |
| 25 | + * } | |
| 26 | + * | |
| 27 | + * // 3. External storage is now automatically used by Jetpack_Options::get_option() | |
| 28 | + * | |
| 29 | + * @package automattic/jetpack-connection | |
| 30 | + */ | |
| 31 | + | |
| 32 | +namespace Automattic\Jetpack\Connection; | |
| 33 | + | |
| 34 | +/** | |
| 35 | + * External Storage utilities class. | |
| 36 | + * | |
| 37 | + * @since 6.18.0 | |
| 38 | + */ | |
| 39 | +class External_Storage { | |
| 40 | + | |
| 41 | + /** | |
| 42 | + * Registered storage provider. | |
| 43 | + * | |
| 44 | + * @since 6.18.0 | |
| 45 | + * | |
| 46 | + * @var Storage_Provider_Interface|null | |
| 47 | + */ | |
| 48 | + private static $provider = null; | |
| 49 | + | |
| 50 | + /** | |
| 51 | + * Whether the init action has already fired. | |
| 52 | + * | |
| 53 | + * @since 8.3.0 | |
| 54 | + * | |
| 55 | + * @var bool | |
| 56 | + */ | |
| 57 | + private static $init_fired = false; | |
| 58 | + | |
| 59 | + /** | |
| 60 | + * Static cache to prevent logging same event multiple times in single request. | |
| 61 | + * | |
| 62 | + * @since 7.0.0 | |
| 63 | + * | |
| 64 | + * @var array | |
| 65 | + */ | |
| 66 | + private static $logged_events = array(); | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * Maximum delay threshold for empty state reporting (in seconds). | |
| 70 | + * This also determines the transient expiry for tracking first empty state. | |
| 71 | + * Provider custom thresholds must not exceed this value. | |
| 72 | + * | |
| 73 | + * @since 7.0.0 | |
| 74 | + */ | |
| 75 | + private const EMPTY_STATE_TRANSIENT_EXPIRY = 15 * MINUTE_IN_SECONDS; | |
| 76 | + | |
| 77 | + /** | |
| 78 | + * Register a storage provider for external storage. | |
| 79 | + * | |
| 80 | + * @since 6.18.0 | |
| 81 | + * | |
| 82 | + * @param Storage_Provider_Interface $provider Storage provider implementing the interface. | |
| 83 | + * @return bool True if provider was registered successfully, false otherwise. | |
| 84 | + */ | |
| 85 | + public static function register_provider( Storage_Provider_Interface $provider ) { | |
| 86 | + self::$provider = $provider; | |
| 87 | + | |
| 88 | + /** | |
| 89 | + * Fires after an external storage provider is registered. | |
| 90 | + * | |
| 91 | + * This allows dependent systems (like the connection status cache in Manager) | |
| 92 | + * to invalidate state that may have been computed before the provider was available. | |
| 93 | + * | |
| 94 | + * @since 8.3.0 | |
| 95 | + * | |
| 96 | + * @param Storage_Provider_Interface $provider The registered storage provider. | |
| 97 | + */ | |
| 98 | + do_action( 'jetpack_external_storage_provider_registered', $provider ); | |
| 99 | + | |
| 100 | + return true; | |
| 101 | + } | |
| 102 | + | |
| 103 | + /** | |
| 104 | + * Get value from external storage provider. | |
| 105 | + * | |
| 106 | + * Returns null if no provider is registered or if the provider can't provide the value (triggers database fallback). | |
| 107 | + * | |
| 108 | + * @since 6.18.0 | |
| 109 | + * | |
| 110 | + * @param string $key The key to retrieve. | |
| 111 | + * @return mixed The value from external storage, or null for database fallback. | |
| 112 | + */ | |
| 113 | + public static function get_value( $key ) { | |
| 114 | + if ( ! self::$init_fired ) { | |
| 115 | + self::$init_fired = true; | |
| 116 | + | |
| 117 | + /** | |
| 118 | + * Fires before the first external storage read. | |
| 119 | + * | |
| 120 | + * Use this hook to register your storage provider via | |
| 121 | + * External_Storage::register_provider(). This fires after the connection | |
| 122 | + * package classes are loaded but before any connection status checks read | |
| 123 | + * from external storage. | |
| 124 | + * | |
| 125 | + * Useful for mu-plugins that load before the plugin providing External_Storage, | |
| 126 | + * since add_action() does not require the action or any classes to exist at | |
| 127 | + * hook-registration time. | |
| 128 | + * | |
| 129 | + * @since 8.3.0 | |
| 130 | + */ | |
| 131 | + do_action( 'jetpack_external_storage_init' ); | |
| 132 | + } | |
| 133 | + | |
| 134 | + $provider = self::$provider; | |
| 135 | + | |
| 136 | + // Check if we have a registered provider | |
| 137 | + if ( null === $provider ) { | |
| 138 | + return null; // No provider registered, use database | |
| 139 | + } | |
| 140 | + | |
| 141 | + $environment = $provider->get_environment_id(); | |
| 142 | + | |
| 143 | + // Check if provider is available in current environment | |
| 144 | + if ( ! $provider->is_available() ) { | |
| 145 | + self::log_event( 'unavailable', $key, 'External storage not available', $environment ); | |
| 146 | + return null; | |
| 147 | + } | |
| 148 | + | |
| 149 | + // Check if provider should handle this option | |
| 150 | + if ( ! $provider->should_handle( $key ) ) { | |
| 151 | + return null; | |
| 152 | + } | |
| 153 | + | |
| 154 | + // Try to get value from the provider | |
| 155 | + try { | |
| 156 | + $value = $provider->get( $key ); | |
| 157 | + | |
| 158 | + // Check if we got a valid value | |
| 159 | + if ( null !== $value && false !== $value && '' !== $value && 0 !== $value ) { | |
| 160 | + return $value; | |
| 161 | + } | |
| 162 | + | |
| 163 | + // Empty value - log it | |
| 164 | + self::log_event( 'empty', $key, '', $environment ); | |
| 165 | + | |
| 166 | + } catch ( \Exception $e ) { | |
| 167 | + // Provider threw an exception | |
| 168 | + self::log_event( 'error', $key, $e->getMessage(), $environment ); | |
| 169 | + } | |
| 170 | + | |
| 171 | + // Provider couldn't provide value, return null for database fallback | |
| 172 | + return null; | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** | |
| 176 | + * Log events if WP_DEBUG is enabled and delegate to provider for error reporting. | |
| 177 | + * Includes rate limiting to prevent log spam from noisy events. | |
| 178 | + * | |
| 179 | + * Storage providers can optionally implement handle_error_event() method to receive | |
| 180 | + * notifications about storage errors and empty states for their own error reporting. | |
| 181 | + * | |
| 182 | + * @since 6.18.0 | |
| 183 | + * | |
| 184 | + * @param string $event_type The event type (error, empty, unavailable). | |
| 185 | + * @param string $key The key that triggered the event. | |
| 186 | + * @param string $details Additional details about the event. | |
| 187 | + * @param string $environment The environment identifier (atomic, vip, etc.). | |
| 188 | + */ | |
| 189 | + public static function log_event( $event_type, $key, $details = '', $environment = 'unknown' ) { | |
| 190 | + // Only process 'error' and 'empty' events for provider error reporting | |
| 191 | + if ( 'error' !== $event_type && 'empty' !== $event_type ) { | |
| 192 | + // For non-reportable events, just do debug logging with rate limiting | |
| 193 | + if ( self::should_log_event( $key, $event_type ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 194 | + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 195 | + sprintf( | |
| 196 | + 'Jetpack External Storage %s: %s in %s%s', | |
| 197 | + $event_type, | |
| 198 | + $key, | |
| 199 | + $environment, | |
| 200 | + $details ? ' - ' . $details : '' | |
| 201 | + ) | |
| 202 | + ); | |
| 203 | + } | |
| 204 | + return; | |
| 205 | + } | |
| 206 | + | |
| 207 | + // For 'empty' events, check delay mechanism first to avoid false positives | |
| 208 | + // during sync between external storage and the database. | |
| 209 | + // This is checked BEFORE rate limiting so we don't block legitimate reports. | |
| 210 | + if ( 'empty' === $event_type && ! self::should_report_empty_state( $key ) ) { | |
| 211 | + return; | |
| 212 | + } | |
| 213 | + | |
| 214 | + // Apply rate limiting only for events that will trigger provider notification | |
| 215 | + if ( ! self::should_log_event( $key, $event_type ) ) { | |
| 216 | + return; | |
| 217 | + } | |
| 218 | + | |
| 219 | + // Local debug logging (only when WP_DEBUG is enabled) | |
| 220 | + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 221 | + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log | |
| 222 | + sprintf( | |
| 223 | + 'Jetpack External Storage %s: %s in %s%s', | |
| 224 | + $event_type, | |
| 225 | + $key, | |
| 226 | + $environment, | |
| 227 | + $details ? ' - ' . $details : '' | |
| 228 | + ) | |
| 229 | + ); | |
| 230 | + } | |
| 231 | + | |
| 232 | + // Delegate to provider if it implements error handling | |
| 233 | + if ( null !== self::$provider && method_exists( self::$provider, 'handle_error_event' ) ) { | |
| 234 | + // @phan-suppress-next-line PhanUndeclaredMethod -- Optional method, checked via method_exists() | |
| 235 | + self::$provider->handle_error_event( $event_type, $key, $details, $environment ); | |
| 236 | + } | |
| 237 | + } | |
| 238 | + | |
| 239 | + /** | |
| 240 | + * Determine if we should report an empty state based on delay mechanism. | |
| 241 | + * | |
| 242 | + * This prevents false positives during storage sync delays. On first encounter | |
| 243 | + * of empty state, sets a transient. On subsequent encounters after the delay | |
| 244 | + * threshold, allows reporting (indicating likely disconnection, not sync delay). | |
| 245 | + * | |
| 246 | + * Providers can customize the delay threshold by implementing get_empty_state_delay_threshold(). | |
| 247 | + * | |
| 248 | + * @since 6.18.0 | |
| 249 | + * | |
| 250 | + * @param string $key The key that was empty. | |
| 251 | + * @return bool True if we should report this empty state, false otherwise. | |
| 252 | + */ | |
| 253 | + private static function should_report_empty_state( $key ) { | |
| 254 | + $delay_key = 'jetpack_external_storage_empty_delay_' . $key; | |
| 255 | + $first_empty_time = get_transient( $delay_key ); | |
| 256 | + | |
| 257 | + if ( false === $first_empty_time ) { | |
| 258 | + // First time encountering empty state - set delay transient and don't report yet | |
| 259 | + set_transient( $delay_key, time(), self::EMPTY_STATE_TRANSIENT_EXPIRY ); | |
| 260 | + return false; | |
| 261 | + } | |
| 262 | + | |
| 263 | + // Default delay threshold (5 minutes) | |
| 264 | + $delay_threshold = 5 * MINUTE_IN_SECONDS; | |
| 265 | + | |
| 266 | + // Allow provider to customize delay threshold | |
| 267 | + // A threshold of 0 is valid for providers where external storage is written first | |
| 268 | + if ( null !== self::$provider && method_exists( self::$provider, 'get_empty_state_delay_threshold' ) ) { | |
| 269 | + // @phan-suppress-next-line PhanUndeclaredMethod -- Optional method, checked via method_exists() | |
| 270 | + $custom_threshold = self::$provider->get_empty_state_delay_threshold(); | |
| 271 | + if ( is_int( $custom_threshold ) && $custom_threshold >= 0 && $custom_threshold <= self::EMPTY_STATE_TRANSIENT_EXPIRY ) { | |
| 272 | + $delay_threshold = $custom_threshold; | |
| 273 | + } | |
| 274 | + } | |
| 275 | + | |
| 276 | + if ( ( time() - $first_empty_time ) >= $delay_threshold ) { | |
| 277 | + // Delay threshold passed - likely disconnection, report it | |
| 278 | + delete_transient( $delay_key ); | |
| 279 | + return true; | |
| 280 | + } | |
| 281 | + | |
| 282 | + return false; | |
| 283 | + } | |
| 284 | + | |
| 285 | + /** | |
| 286 | + * Determine if an event should be logged based on rate limiting rules. | |
| 287 | + * | |
| 288 | + * This prevents log spam from noisy events by applying a simple one-hour | |
| 289 | + * rate limit per key and event type combination. Also uses a static cache | |
| 290 | + * to prevent duplicate logs within the same request. | |
| 291 | + * | |
| 292 | + * @since 6.18.0 | |
| 293 | + * | |
| 294 | + * @param string $key The key that triggered the event. | |
| 295 | + * @param string $event_type The event type (error, empty, unavailable). | |
| 296 | + * @return bool True if the event should be logged, false if rate limited. | |
| 297 | + */ | |
| 298 | + private static function should_log_event( $key, $event_type = '' ) { | |
| 299 | + // Combine event type and key for unique tracking | |
| 300 | + $event_cache_key = $event_type . '_' . $key; | |
| 301 | + | |
| 302 | + // Check static cache first (prevents multiple logs in same request) | |
| 303 | + if ( isset( self::$logged_events[ $event_cache_key ] ) ) { | |
| 304 | + return false; | |
| 305 | + } | |
| 306 | + | |
| 307 | + $rate_limit_key = 'jetpack_ext_storage_rate_limit_' . $event_cache_key; | |
| 308 | + | |
| 309 | + // Check if we're still within the rate limit period | |
| 310 | + if ( get_transient( $rate_limit_key ) ) { | |
| 311 | + return false; | |
| 312 | + } | |
| 313 | + | |
| 314 | + // Mark as logged in both caches | |
| 315 | + self::$logged_events[ $event_cache_key ] = true; | |
| 316 | + set_transient( $rate_limit_key, true, HOUR_IN_SECONDS ); | |
| 317 | + | |
| 318 | + return true; | |
| 319 | + } | |
| 320 | +} | |