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