PluginProbe
ElasticPress / 4.4.0
ElasticPress v4.4.0
5.3.5 5.3.4 3.6.5 3.6.6 4.0.0 4.0.1 4.1.0 4.2.0 4.2.1 4.2.2 4.3.0 4.3.1 4.4.0 4.4.1 4.5.0 4.5.1 4.5.2 4.6.0 4.6.1 4.7.0 4.7.1 4.7.2 5.0.0 5.0.1 5.0.2 All 108 releases
elasticpress / includes / classes / QueryLogger.php

QueryLogger.php in ElasticPress 4.4.0, at includes/classes/QueryLogger.php

403 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Query Logger class
4 *
5 * phpcs:disable WordPress.DateTime.CurrentTimeTimestamp.Requested
6 *
7 * @since 4.4.0
8 * @package elasticpress
9 */
10
11 namespace ElasticPress;
12
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Query Logger class
17 *
18 * @package ElasticPress
19 */
20 class QueryLogger {
21 /**
22 * String used to get and update the transient.
23 */
24 const CACHE_KEY = 'ep_query_log';
25
26 /**
27 * Setup the logging functionality
28 */
29 public function setup() {
30 add_action( 'ep_remote_request', [ $this, 'log_query' ], 10, 2 );
31 add_filter( 'ep_admin_notices', [ $this, 'maybe_add_notice' ] );
32
33 add_action( 'ep_sync_start_index', [ $this, 'clear_logs' ] );
34 }
35
36 /**
37 * Conditionally save a query to the log which is stored in options. This is a big performance hit so be careful.
38 *
39 * @param array $query Remote request arguments
40 * @param string $type Request type
41 */
42 public function log_query( $query, $type ) {
43 $last_sync = Utils\get_option( 'ep_last_sync', false );
44 if ( empty( $last_sync ) ) {
45 return;
46 }
47
48 $logs = $this->get_logs();
49
50 /**
51 * Filter the number of queries to keep in the log
52 *
53 * @since 4.4.0
54 * @hook ep_query_logger_queries_to_keep
55 * @param {int} $keep Number of queries to keep in the log
56 * @param {array} $query Remote request arguments
57 * @param {string} $type Request type
58 * @return {int} New number
59 */
60 $keep = apply_filters( 'ep_query_logger_queries_to_keep', 5, $query, $type );
61
62 if ( $keep > 0 && count( $logs ) >= $keep ) {
63 return;
64 }
65
66 if ( ! $this->should_log_query_type( $query, (string) $type ) ) {
67 return;
68 }
69
70 array_unshift( $logs, $this->format_log_entry( $query, $type ) );
71
72 $logs_json_str = $this->update_logs( $logs );
73
74 /**
75 * Perform actions after a new query is logged
76 *
77 * @hook ep_query_logger_logged_query
78 * @since 4.4.0
79 * @param {string} $logs_json_str The JSON string as stored in the transient
80 * @param {array} $query Remote request arguments
81 * @param {string} $type Request type
82 */
83 do_action( 'ep_query_logger_logged_query', $logs_json_str, $query, $type );
84 }
85
86 /**
87 * Return logged failed queries.
88 *
89 * @param bool $should_filter_old Whether it should filter out old entries or not. Default to true, only return entries newer than the limit
90 * @return array
91 */
92 public function get_logs( bool $should_filter_old = true ) : array {
93 $logs = ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) ?
94 get_site_transient( self::CACHE_KEY, [] ) :
95 get_transient( self::CACHE_KEY, [] );
96
97 $logs = (array) json_decode( (string) $logs, true );
98
99 if ( $should_filter_old ) {
100 $current_time = current_time( 'timestamp' );
101
102 /**
103 * Filter the period to keep queried logs. Defaults to DAY_IN_SECONDS
104 *
105 * @since 4.4.0
106 * @hook ep_query_logger_time_to_keep
107 * @param {int} $period_to_keep The period to keep queried logs, in seconds
108 * @return {int} New period
109 */
110 $period_to_keep = apply_filters( 'ep_query_logger_time_to_keep', DAY_IN_SECONDS );
111
112 $time_limit = $current_time - $period_to_keep;
113
114 $logs = array_filter(
115 (array) $logs,
116 function ( $log ) use ( $time_limit ) {
117 return ! empty( $log['timestamp'] ) && $log['timestamp'] > $time_limit;
118 }
119 );
120 }
121
122 /**
123 * Filter the logs
124 *
125 * @since 4.4.0
126 * @hook ep_query_logger_logs
127 * @param {int} $logs The logs array
128 * @return {int} New array
129 */
130 $logs = apply_filters( 'ep_query_logger_logs', $logs );
131
132 return $logs;
133 }
134
135 /**
136 * Update the logs array in the transient
137 *
138 * @param array $logs New logs array
139 */
140 public function update_logs( array $logs ) {
141 /**
142 * Filter the max cache size. Defaults to MB_IN_BYTES
143 *
144 * @since 4.4.0
145 * @hook ep_query_logger_max_cache_size
146 * @param {int} $max_cache_size The max cache size in bytes
147 * @return {int} New size
148 */
149 $max_cache_size = apply_filters( 'ep_query_logger_max_cache_size', MB_IN_BYTES );
150
151 $logs_json_str = wp_json_encode( $logs );
152 $logs_json_str_size = strlen( $logs_json_str );
153
154 // If the logs size is too big, remove older entries (except the newest one)
155 if ( $logs_json_str_size >= $max_cache_size ) {
156 $logs_count = count( $logs );
157 for ( $i = 0; $i < ( $logs_count - 1 ); $i++ ) {
158 array_pop( $logs );
159
160 $logs_json_str = wp_json_encode( $logs );
161 $logs_json_str_size = strlen( $logs_json_str );
162
163 if ( $logs_json_str_size < $max_cache_size ) {
164 break;
165 }
166 }
167 }
168
169 // If even removing older entries, it is still too big, try to limit some of its info
170 if ( $logs_json_str_size >= $max_cache_size ) {
171 $logs[0]['body'] = '(removed due to its size)';
172
173 $logs_json_str = wp_json_encode( $logs );
174 $logs_json_str_size = strlen( $logs_json_str );
175
176 if ( $logs_json_str_size >= $max_cache_size ) {
177 $logs[0]['result'] = '(removed due to its size)';
178 }
179 }
180
181 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
182 set_site_transient( self::CACHE_KEY, $logs_json_str, DAY_IN_SECONDS );
183 } else {
184 set_transient( self::CACHE_KEY, $logs_json_str, DAY_IN_SECONDS );
185 }
186
187 return $logs_json_str;
188 }
189
190 /**
191 * Clear the stored logs
192 */
193 public function clear_logs() {
194 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
195 delete_site_transient( self::CACHE_KEY );
196 } else {
197 delete_transient( self::CACHE_KEY );
198 }
199
200 /**
201 * Perform actions after clearing the logs
202 *
203 * @hook ep_query_logger_cleared_logs
204 * @since 4.4.0
205 */
206 do_action( 'ep_query_logger_cleared_logs' );
207 }
208
209 /**
210 * Conditionally display a notice in the admin
211 *
212 * @param array $notices Current EP notices
213 * @return array
214 */
215 public function maybe_add_notice( array $notices ) : array {
216 $current_ep_screen = \ElasticPress\Screen::factory()->get_current_screen();
217 if ( 'status-report' === $current_ep_screen ) {
218 return $notices;
219 }
220
221 $logs = $this->get_logs();
222 if ( empty( $logs ) ) {
223 return $notices;
224 }
225
226 $page = 'admin.php?page=elasticpress-status-report';
227
228 $status_report_url = ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) ?
229 network_admin_url( $page ) :
230 admin_url( $page );
231
232 $notices['has_failed_queries'] = [
233 'html' => sprintf(
234 /* translators: Status Report URL */
235 __( 'Some ElasticPress queries failed in the last 24 hours. Please visit the <a href="%s">Status Report page</a> for more details.', 'elasticpress' ),
236 $status_report_url . '#failed-queries'
237 ),
238 'type' => 'warning',
239 'dismiss' => true,
240 ];
241
242 return $notices;
243 }
244
245 /**
246 * Given a query, return a formatted log entry
247 *
248 * @param array $query The failed query
249 * @param string $type The query type
250 * @return array
251 */
252 protected function format_log_entry( array $query, string $type ) : array {
253 global $wp;
254
255 $query_time = ( ! empty( $query['time_start'] ) && ! empty( $query['time_finish'] ) ) ?
256 ( $query['time_finish'] - $query['time_start'] ) * 1000 :
257 false;
258
259 // If the body is too big, trim it down to avoid storing a too big log entry
260 $body = ! empty( $query['args']['body'] ) ? $query['args']['body'] : '';
261 if ( strlen( $body ) > 200 * KB_IN_BYTES ) {
262 $body = substr( $body, 0, 1000 ) . ' (trimmed)';
263 } else {
264 $json_body = json_decode( $body, true );
265 // Bulk indexes are not "valid" JSON, for example.
266 if ( json_last_error() === JSON_ERROR_NONE ) {
267 $body = wp_json_encode( $json_body );
268 }
269 }
270
271 $status = wp_remote_retrieve_response_code( $query['request'] );
272 $result = json_decode( wp_remote_retrieve_body( $query['request'] ), true );
273
274 $formatted_log = [
275 'wp_url' => home_url( add_query_arg( [ $_GET ], $wp->request ) ), // phpcs:ignore WordPress.Security.NonceVerification
276 'es_req' => $query['args']['method'] . ' ' . $query['url'],
277 'timestamp' => current_time( 'timestamp' ),
278 'query_time' => $query_time,
279 'wp_args' => $query['query_args'] ?? [],
280 'status_code' => $status,
281 'body' => $body,
282 'result' => $result,
283 ];
284
285 /**
286 * Filter the formatted query log
287 *
288 * @since 4.4.0
289 * @hook ep_query_logger_formatted_query
290 * @param {array} $formatted_log The log entry
291 * @param {array} $query The failed query
292 * @param {string} $type The query type
293 * @return {array} Changed log entry
294 */
295 return apply_filters( 'ep_query_logger_formatted_query', $formatted_log, $query, $type );
296 }
297
298 /**
299 * Given a query and its type, check if it should be logged
300 *
301 * @param array $query The failed query
302 * @param string $type The query type
303 * @return boolean
304 */
305 protected function should_log_query_type( array $query, string $type ) : bool {
306 /**
307 * Filter the array with a map from query types to callables. If the callable returns true,
308 * the query will be logged.
309 *
310 * @since 4.4.0
311 * @hook ep_query_logger_allowed_log_types
312 * @param {array} $callable_map Array indexed by type and valued by a callable that returns a boolean
313 * @param {array} $query Remote request arguments
314 * @param {string} $type Request type
315 * @return {array} New array
316 */
317 $allowed_log_types = apply_filters(
318 'ep_query_logger_allowed_log_types',
319 array(
320 'put_mapping' => array( $this, 'is_query_error' ),
321 'delete_network_alias' => array( $this, 'is_query_error' ),
322 'create_network_alias' => array( $this, 'is_query_error' ),
323 'bulk_index' => array( $this, 'is_bulk_index_error' ),
324 'delete_index' => array( $this, 'maybe_log_delete_index' ),
325 'create_pipeline' => array( $this, 'is_query_error' ),
326 'get_pipeline' => array( $this, 'is_query_error' ),
327 'query' => array( $this, 'is_query_error' ),
328 ),
329 $query,
330 $type
331 );
332
333 $should_log = isset( $allowed_log_types[ $type ] ) ?
334 call_user_func( $allowed_log_types[ $type ], $query ) :
335 false;
336
337 /**
338 * Filter the formatted query log
339 *
340 * @since 4.4.0
341 * @hook ep_query_logger_should_log_query
342 * @param {bool} $should_log Whether the query should be logged or not
343 * @param {array} $query The failed query
344 * @param {string} $type The query type
345 * @return {bool} New value of $should_log
346 */
347 return apply_filters( 'ep_query_logger_should_log_query', $should_log, $query, $type );
348 }
349
350 /**
351 * Check the request body, as usually bulk indexing does not return a status error.
352 *
353 * @param array $query Remote request arguments
354 * @return boolean
355 */
356 protected function is_bulk_index_error( $query ) {
357 if ( is_wp_error( $query['request'] ) ) {
358 return true;
359 }
360
361 $response_code = wp_remote_retrieve_response_code( $query['request'] );
362 // Bulk index dynamically will eventually fire a 413 (too big) request but will recover from it
363 if ( 413 === $response_code && false !== strpos( wp_debug_backtrace_summary(), 'bulk_index_dynamically' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_wp_debug_backtrace_summary
364 return false;
365 }
366
367 if ( $response_code < 200 || $response_code > 299 ) {
368 return true;
369 }
370
371 $request_body = json_decode( wp_remote_retrieve_body( $query['request'] ), true );
372 return ! empty( $request_body['errors'] );
373 }
374
375 /**
376 * Only log delete index error if not 2xx AND not 404
377 *
378 * @param array $query Remote request arguments
379 * @return bool
380 */
381 protected function maybe_log_delete_index( $query ) {
382 $response_code = wp_remote_retrieve_response_code( $query['request'] );
383
384 return ( ( $response_code < 200 || $response_code > 299 ) && 404 !== $response_code );
385 }
386
387 /**
388 * Log all non-200 requests
389 *
390 * @param array $query Remote request arguments
391 * @return bool
392 */
393 protected function is_query_error( $query ) {
394 if ( is_wp_error( $query['request'] ) ) {
395 return true;
396 }
397
398 $response_code = wp_remote_retrieve_response_code( $query['request'] );
399
400 return ( $response_code < 200 || $response_code > 299 );
401 }
402 }
403