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