Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
52.05% covered (warning)
52.05%
38 / 73
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
External_Storage
52.05% covered (warning)
52.05%
38 / 73
40.00% covered (danger)
40.00%
2 / 5
187.88
0.00% covered (danger)
0.00%
0 / 1
 register_provider
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 get_value
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
13.15
 log_event
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
240
 should_report_empty_state
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
8
 should_log_event
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
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
32namespace Automattic\Jetpack\Connection;
33
34/**
35 * External Storage utilities class.
36 *
37 * @since 6.18.0
38 */
39class 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}