PluginProbe
ElasticPress / 4.5.2
ElasticPress v4.5.2
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.5.2, at includes/classes/QueryLogger.php

418 lines 12.1 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 \ElasticPress\Utils\delete_option( 'ep_hide_has_failed_queries_notice' );
182
183 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
184 set_site_transient( self::CACHE_KEY, $logs_json_str, DAY_IN_SECONDS );
185 } else {
186 set_transient( self::CACHE_KEY, $logs_json_str, DAY_IN_SECONDS );
187 }
188
189 return $logs_json_str;
190 }
191
192 /**
193 * Clear the stored logs
194 */
195 public function clear_logs() {
196 if ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) {
197 delete_site_transient( self::CACHE_KEY );
198 } else {
199 delete_transient( self::CACHE_KEY );
200 }
201
202 /**
203 * Perform actions after clearing the logs
204 *
205 * @hook ep_query_logger_cleared_logs
206 * @since 4.4.0
207 */
208 do_action( 'ep_query_logger_cleared_logs' );
209 }
210
211 /**
212 * Conditionally display a notice in the admin
213 *
214 * @param array $notices Current EP notices
215 * @return array
216 */
217 public function maybe_add_notice( array $notices ) : array {
218 if ( ! current_user_can( Utils\get_capability() ) ) {
219 return $notices;
220 }
221
222 $current_ep_screen = \ElasticPress\Screen::factory()->get_current_screen();
223 if ( 'status-report' === $current_ep_screen ) {
224 return $notices;
225 }
226
227 if ( \ElasticPress\Utils\get_option( 'ep_hide_has_failed_queries_notice' ) ) {
228 return $notices;
229 }
230
231 $logs = $this->get_logs();
232 if ( empty( $logs ) ) {
233 return $notices;
234 }
235
236 $page = 'admin.php?page=elasticpress-status-report';
237
238 $status_report_url = ( defined( 'EP_IS_NETWORK' ) && EP_IS_NETWORK ) ?
239 network_admin_url( $page ) :
240 admin_url( $page );
241
242 $notices['has_failed_queries'] = [
243 'html' => sprintf(
244 /* translators: Status Report URL */
245 __( 'Some ElasticPress queries failed in the last 24 hours. Please visit the <a href="%s">Status Report page</a> for more details.', 'elasticpress' ),
246 $status_report_url . '#failed-queries'
247 ),
248 'type' => 'warning',
249 'dismiss' => true,
250 ];
251
252 return $notices;
253 }
254
255 /**
256 * Given a query, return a formatted log entry
257 *
258 * @param array $query The failed query
259 * @param string $type The query type
260 * @return array
261 */
262 protected function format_log_entry( array $query, string $type ) : array {
263 global $wp;
264
265 $query_time = ( ! empty( $query['time_start'] ) && ! empty( $query['time_finish'] ) ) ?
266 ( $query['time_finish'] - $query['time_start'] ) * 1000 :
267 false;
268
269 // If the body is too big, trim it down to avoid storing a too big log entry
270 $body = ! empty( $query['args']['body'] ) ? $query['args']['body'] : '';
271 if ( strlen( $body ) > 200 * KB_IN_BYTES ) {
272 $body = substr( $body, 0, 1000 ) . ' (trimmed)';
273 } else {
274 $json_body = json_decode( $body, true );
275 // Bulk indexes are not "valid" JSON, for example.
276 if ( json_last_error() === JSON_ERROR_NONE ) {
277 $body = wp_json_encode( $json_body );
278 }
279 }
280
281 $request_id = ( ! empty( $query['args']['headers'] ) && ! empty( $query['args']['headers']['X-ElasticPress-Request-ID'] ) ) ?
282 $query['args']['headers']['X-ElasticPress-Request-ID'] :
283 null;
284
285 $status = wp_remote_retrieve_response_code( $query['request'] );
286 $result = json_decode( wp_remote_retrieve_body( $query['request'] ), true );
287
288 $formatted_log = [
289 'wp_url' => home_url( add_query_arg( [ $_GET ], $wp->request ) ), // phpcs:ignore WordPress.Security.NonceVerification
290 'es_req' => $query['args']['method'] . ' ' . $query['url'],
291 'request_id' => $request_id ?? '',
292 'timestamp' => current_time( 'timestamp' ),
293 'query_time' => $query_time,
294 'wp_args' => $query['query_args'] ?? [],
295 'status_code' => $status,
296 'body' => $body,
297 'result' => $result,
298 ];
299
300 /**
301 * Filter the formatted query log
302 *
303 * @since 4.4.0
304 * @hook ep_query_logger_formatted_query
305 * @param {array} $formatted_log The log entry
306 * @param {array} $query The failed query
307 * @param {string} $type The query type
308 * @return {array} Changed log entry
309 */
310 return apply_filters( 'ep_query_logger_formatted_query', $formatted_log, $query, $type );
311 }
312
313 /**
314 * Given a query and its type, check if it should be logged
315 *
316 * @param array $query The failed query
317 * @param string $type The query type
318 * @return boolean
319 */
320 protected function should_log_query_type( array $query, string $type ) : bool {
321 /**
322 * Filter the array with a map from query types to callables. If the callable returns true,
323 * the query will be logged.
324 *
325 * @since 4.4.0
326 * @hook ep_query_logger_allowed_log_types
327 * @param {array} $callable_map Array indexed by type and valued by a callable that returns a boolean
328 * @param {array} $query Remote request arguments
329 * @param {string} $type Request type
330 * @return {array} New array
331 */
332 $allowed_log_types = apply_filters(
333 'ep_query_logger_allowed_log_types',
334 array(
335 'put_mapping' => array( $this, 'is_query_error' ),
336 'delete_network_alias' => array( $this, 'is_query_error' ),
337 'create_network_alias' => array( $this, 'is_query_error' ),
338 'bulk_index' => array( $this, 'is_bulk_index_error' ),
339 'delete_index' => array( $this, 'maybe_log_delete_index' ),
340 'create_pipeline' => array( $this, 'is_query_error' ),
341 'get_pipeline' => array( $this, 'is_query_error' ),
342 'query' => array( $this, 'is_query_error' ),
343 ),
344 $query,
345 $type
346 );
347
348 $should_log = isset( $allowed_log_types[ $type ] ) ?
349 call_user_func( $allowed_log_types[ $type ], $query ) :
350 false;
351
352 /**
353 * Filter the formatted query log
354 *
355 * @since 4.4.0
356 * @hook ep_query_logger_should_log_query
357 * @param {bool} $should_log Whether the query should be logged or not
358 * @param {array} $query The failed query
359 * @param {string} $type The query type
360 * @return {bool} New value of $should_log
361 */
362 return apply_filters( 'ep_query_logger_should_log_query', $should_log, $query, $type );
363 }
364
365 /**
366 * Check the request body, as usually bulk indexing does not return a status error.
367 *
368 * @param array $query Remote request arguments
369 * @return boolean
370 */
371 protected function is_bulk_index_error( $query ) {
372 if ( is_wp_error( $query['request'] ) ) {
373 return true;
374 }
375
376 $response_code = wp_remote_retrieve_response_code( $query['request'] );
377 // Bulk index dynamically will eventually fire a 413 (too big) request but will recover from it
378 if ( 413 === $response_code && false !== strpos( wp_debug_backtrace_summary(), 'bulk_index_dynamically' ) ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_wp_debug_backtrace_summary
379 return false;
380 }
381
382 if ( $response_code < 200 || $response_code > 299 ) {
383 return true;
384 }
385
386 $request_body = json_decode( wp_remote_retrieve_body( $query['request'] ), true );
387 return ! empty( $request_body['errors'] );
388 }
389
390 /**
391 * Only log delete index error if not 2xx AND not 404
392 *
393 * @param array $query Remote request arguments
394 * @return bool
395 */
396 protected function maybe_log_delete_index( $query ) {
397 $response_code = wp_remote_retrieve_response_code( $query['request'] );
398
399 return ( ( $response_code < 200 || $response_code > 299 ) && 404 !== $response_code );
400 }
401
402 /**
403 * Log all non-200 requests
404 *
405 * @param array $query Remote request arguments
406 * @return bool
407 */
408 protected function is_query_error( $query ) {
409 if ( is_wp_error( $query['request'] ) ) {
410 return true;
411 }
412
413 $response_code = wp_remote_retrieve_response_code( $query['request'] );
414
415 return ( $response_code < 200 || $response_code > 299 );
416 }
417 }
418