Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.00% covered (warning)
70.00%
49 / 70
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventManager
70.00% covered (warning)
70.00%
49 / 70
40.00% covered (danger)
40.00%
6 / 15
76.99
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 initialize_rest_endpoint
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_cron_schedule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initialize_cron
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 rest_api_init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 add_minutely_schedule
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 shutdown
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
7.60
 add_subscriber
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_subscribers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_listeners
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 initialize_listeners
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
7.46
 push
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 send_request_events
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 send_saved_events_batch
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
8.07
1<?php
2
3namespace NewfoldLabs\WP\Module\Data;
4
5use Exception;
6use NewfoldLabs\WP\Module\Data\EventQueue\EventQueue;
7use NewfoldLabs\WP\Module\Data\Listeners\Listener;
8use WP_Error;
9
10/**
11 * Class to manage event subscriptions
12 */
13class EventManager {
14
15    /**
16     * List of default listener category classes
17     *
18     * @var Listener[]
19     */
20    const LISTENERS = array(
21        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Admin',
22        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Content',
23        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Cron',
24        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Jetpack',
25        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Plugin',
26        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\BluehostPlugin',
27        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\SiteHealth',
28        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Theme',
29        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Commerce',
30        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\Yoast',
31        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\WonderCart',
32        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\WPMail',
33        '\\NewfoldLabs\\WP\\Module\\Data\\Listeners\\SalesPromotions',
34    );
35
36    /**
37     * Queue used to store events between requests.
38     *
39     * @var EventQueue
40     */
41    private $event_queue;
42
43    /**
44     * List of subscribers receiving event data
45     *
46     * @var array
47     */
48    private $subscribers = array();
49
50    /**
51     * The queue of events logged in the current request
52     *
53     * @var Event[]
54     */
55    private $queue = array();
56
57    /**
58     * The maximum number of attempts to send an event
59     *
60     * @var int
61     */
62    private $attempts_limit = 3;
63
64    /**
65     * Constructor
66     *
67     * Inject or instantiate required objects.
68     *
69     * @param ?EventQueue $event_queue Queue used to store events between requests.
70     */
71    public function __construct(
72        ?EventQueue $event_queue = null
73    ) {
74
75        $this->event_queue = $event_queue ?? EventQueue::getInstance();
76    }
77
78    /**
79     * Initialize the Event Manager
80     */
81    public function init(): void {
82        $this->initialize_listeners();
83        $this->initialize_cron();
84
85        // Register the shutdown hook which sends or saves all queued events
86        add_action( 'shutdown', array( $this, 'shutdown' ) );
87    }
88
89    /**
90     * Initialize the REST API endpoint.
91     *
92     * @see Data::init()
93     */
94    public function initialize_rest_endpoint() {
95        // Register REST endpoint.
96        add_action( 'rest_api_init', array( $this, 'rest_api_init' ) );
97    }
98
99    /**
100     * Register the minutely cron schedule.
101     *
102     * Deliberately separate from {@see self::initialize_cron()} so it can be registered on every
103     * request, regardless of Hiive connection state. The `nfd_data_sync_cron` event outlives the
104     * connection: once scheduled it stays in the cron array even after the site disconnects, and
105     * WP-Cron then fails to reschedule it with `invalid_schedule` because `minutely` is unknown.
106     *
107     * @see Data::start()
108     */
109    public function register_cron_schedule(): void {
110        // phpcs:disable WordPress.WP.CronInterval.CronSchedulesInterval
111        add_filter( 'cron_schedules', array( $this, 'add_minutely_schedule' ) );
112    }
113
114    /**
115     * Handle setting up the scheduled job for sending updates
116     */
117    protected function initialize_cron(): void {
118        // Ensure there is a minutely option in the cron schedules
119        $this->register_cron_schedule();
120
121        // Minutely cron hook
122        add_action( 'nfd_data_sync_cron', array( $this, 'send_saved_events_batch' ) );
123
124        // Register the cron task
125        if ( ! wp_next_scheduled( 'nfd_data_sync_cron' ) ) {
126            wp_schedule_event( time() + constant( 'MINUTE_IN_SECONDS' ), 'minutely', 'nfd_data_sync_cron' );
127        }
128    }
129
130    /**
131     * Register the event route.
132     */
133    public function rest_api_init() {
134        $controller = new API\Events( Data::$instance->hiive, $this );
135        $controller->register_routes();
136    }
137
138    /**
139     * Add the weekly option to cron schedules if it doesn't exist
140     *
141     * @hooked cron_schedules
142     *
143     * @param  array<string, array{interval:int, display:string}> $schedules  List of defined cron schedule options.
144     *
145     * @return array<string, array{interval:int, display:string}>
146     */
147    public function add_minutely_schedule( $schedules ) {
148        if ( ! array_key_exists( 'minutely', $schedules ) ||
149            MINUTE_IN_SECONDS !== $schedules['minutely']['interval']
150            ) {
151            $schedules['minutely'] = array(
152                'interval' => MINUTE_IN_SECONDS,
153                'display'  => __( 'Once Every Minute', 'wp-module-data' ),
154            );
155        }
156
157        return $schedules;
158    }
159
160    /**
161     * Sends or saves all queued events at the end of the request
162     *
163     * @hooked shutdown
164     */
165    public function shutdown(): void {
166
167        // Due to a bug sending too many events, we are temporarily disabling these.
168        $disabled_events = array( 'pageview', 'page_view', 'wp_mail', 'plugin_updated' );
169        foreach ( $this->queue as $index => $event ) {
170            if ( in_array( $event->key, $disabled_events, true ) ) {
171                unset( $this->queue[ $index ] );
172            }
173        }
174
175        // Separate out the async events
176        $async = array();
177        foreach ( $this->queue as $index => $event ) {
178            if ( 'pageview' === $event->key ) {
179                $async[] = $event;
180                unset( $this->queue[ $index ] );
181            }
182        }
183
184        // Save any async events for sending later
185        if ( ! empty( $async ) ) {
186            $this->event_queue->queue()->push( $async );
187        }
188
189        // Any remaining items in the queue should be sent now
190        if ( ! empty( $this->queue ) ) {
191            $this->send_request_events( $this->queue );
192        }
193    }
194
195    /**
196     * Register a new event subscriber
197     *
198     * @param  SubscriberInterface $subscriber  Class subscribing to event updates
199     */
200    public function add_subscriber( SubscriberInterface $subscriber ): void {
201        $this->subscribers[] = $subscriber;
202    }
203
204    /**
205     * Returns filtered list of registered event subscribers
206     *
207     * @return array<SubscriberInterface> List of subscriber classes
208     */
209    public function get_subscribers() {
210        return apply_filters( 'newfold_data_subscribers', $this->subscribers );
211    }
212
213    /**
214     * Return an array of listener classes
215     *
216     * @return Listener[] List of listener classes
217     */
218    public function get_listeners() {
219        return apply_filters( 'newfold_data_listeners', $this::LISTENERS );
220    }
221
222    /**
223     * Initialize event listener classes
224     */
225    protected function initialize_listeners(): void {
226        if ( defined( 'BURST_SAFETY_MODE' ) && constant( 'BURST_SAFETY_MODE' ) ) {
227            // Disable listeners when site is under heavy load
228            return;
229        }
230        foreach ( $this->get_listeners() as $listener ) {
231            $class = new $listener( $this );
232            $class->register_hooks();
233        }
234    }
235
236    /**
237     * Push event data onto the queue
238     *
239     * @param  Event $event  Details about the action taken
240     */
241    public function push( Event $event ): void {
242        /**
243         * The `nfd_event_log` action is handled in the notification module.
244         *
245         * @see wp-module-notifications/notifications.php
246         */
247        do_action( 'nfd_event_log', $event->key, $event );
248        $this->queue[] = $event;
249    }
250
251    /**
252     * Send queued events to all subscribers; store them if they fail
253     *
254     * @used-by EventManager::shutdown()
255     *
256     * @param  Event[] $events  A list of events
257     */
258    protected function send_request_events( array $events ): void {
259
260        foreach ( $this->get_subscribers() as $subscriber ) {
261            /**
262             * Response returned by the subscriber.
263             *
264             * @var array{succeededEvents:array,failedEvents:array}|WP_Error $response
265             */
266            $response = $subscriber->notify( $events );
267
268            if ( ! ( $subscriber instanceof HiiveConnection ) ) {
269                continue;
270            }
271
272            if ( is_wp_error( $response ) ) {
273                $this->event_queue->queue()->push( $events );
274                continue;
275            }
276
277            if ( ! empty( $response['failedEvents'] ) ) {
278                $this->event_queue->queue()->push( $response['failedEvents'] );
279            }
280        }
281    }
282
283    /**
284     * Send stored events to all subscribers; remove/release them from the store aftewards.
285     *
286     * @hooked nfd_data_sync_cron
287     */
288    public function send_saved_events_batch(): void {
289
290        $queue = $this->event_queue->queue();
291
292        $queue->remove_events_exceeding_attempts_limit( $this->attempts_limit );
293
294        /**
295         * Array indexed by the table row id.
296         *
297         * @var array<int,Event> $events
298         */
299        $events = $queue->pull( 50 );
300
301        // If queue is empty, do nothing.
302        if ( empty( $events ) ) {
303            return;
304        }
305
306        // Reserve the events in the queue so they are not processed by another instance.
307        if ( ! $queue->reserve( array_keys( $events ) ) ) {
308            // If the events fail to reserve, they will be repeatedly retried.
309            // It would be good to log this somewhere.
310            return;
311        }
312
313        $queue->increment_attempt( array_keys( $events ) );
314
315        foreach ( $this->get_subscribers() as $subscriber ) {
316            /**
317             * Response returned by the subscriber.
318             *
319             * @var array{succeededEvents:array,failedEvents:array}|WP_Error $response
320             */
321            $response = $subscriber->notify( $events );
322
323            if ( ! ( $subscriber instanceof HiiveConnection ) ) {
324                continue;
325            }
326
327            if ( is_wp_error( $response ) ) {
328                $queue->release( array_keys( $events ) );
329                continue;
330            }
331
332            // Remove from the queue.
333            if ( ! empty( $response['succeededEvents'] ) ) {
334                $queue->remove( array_keys( $response['succeededEvents'] ) );
335            }
336
337            // Release the 'reserve' we placed on the entry, so it will be tried again later.
338            if ( ! empty( $response['failedEvents'] ) ) {
339                $queue->release( array_keys( $response['failedEvents'] ) );
340            }
341        }
342    }
343}