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