1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
|
<?php declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\Logging;
use Automattic\WooCommerce\Utilities\FeaturesUtil; use Automattic\WooCommerce\Utilities\StringUtil; use Automattic\WooCommerce\Internal\McStats; use Jetpack_Options; use WC_Rate_Limiter; use WC_Log_Levels; use WC_Site_Tracking;
/** * WooCommerce Remote Logger * * The WooCommerce remote logger class adds functionality to log WooCommerce errors remotely based on if the customer opted in and several other conditions. * * No personal information is logged, only error information and relevant context. * * @class RemoteLogger * @since 9.2.0 * @package WooCommerce\Classes */ class RemoteLogger extends \WC_Log_Handler {
const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash'; const RATE_LIMIT_ID = 'woocommerce_remote_logging'; const RATE_LIMIT_DELAY = 60; // 1 minute. const WC_NEW_VERSION_TRANSIENT = 'woocommerce_new_version';
/** * Handle a log entry. * * @param int $timestamp Log timestamp. * @param string $level emergency|alert|critical|error|warning|notice|info|debug. * @param string $message Log message. * @param array $context Additional information for log handlers. * * @throws \Exception If the remote logging fails. The error is caught and logged locally. * * @return bool False if value was not handled and true if value was handled. */ public function handle( $timestamp, $level, $message, $context ) { try { if ( ! $this->should_handle( $level, $message, $context ) ) { return false; }
return $this->log( $level, $message, $context ); } catch ( \Throwable $e ) { // Log the error to the local logger so we can investigate. SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to handle the log: ' . $e->getMessage(), array( 'source' => 'remote-logging' ) ); return false; } }
/** * Get formatted log data to be sent to the remote logging service. * * This method formats the log data by sanitizing the message, adding default fields, and including additional context * such as backtrace, tags, and extra attributes. It also integrates with WC_Tracks to include blog and store details. * The formatted log data is then filtered before being sent to the remote logging service. * * @param string $level Log level (e.g., 'error', 'warning', 'info'). * @param string $message Log message to be recorded. * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'. * * @return array Formatted log data ready to be sent to the remote logging service. */ public function get_formatted_log( $level, $message, $context = array() ) { $log_data = array( // Default fields. 'feature' => 'woocommerce_core', 'severity' => $level, 'message' => $this->sanitize( $message ), 'host' => SafeGlobalFunctionProxy::wp_parse_url( SafeGlobalFunctionProxy::home_url(), PHP_URL_HOST ) ?? 'Unable to retrieve host', 'tags' => array( 'woocommerce', 'php' ), 'properties' => array( 'wc_version' => $this->get_wc_version(), 'php_version' => phpversion(), 'wp_version' => SafeGlobalFunctionProxy::get_bloginfo( 'version' ) ?? 'Unable to retrieve wp version', 'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ), 'store_id' => SafeGlobalFunctionProxy::get_option( \WC_Install::STORE_ID_OPTION, null ) ?? 'Unable to retrieve store id', ), );
$blog_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null;
if ( ! empty( $blog_id ) && is_int( $blog_id ) ) { $log_data['blog_id'] = $blog_id; }
if ( isset( $context['backtrace'] ) ) { if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) { $log_data['trace'] = $this->sanitize_trace( $context['backtrace'] ); } elseif ( true === $context['backtrace'] ) { $log_data['trace'] = $this->sanitize_trace( self::get_backtrace() ); } unset( $context['backtrace'] ); }
if ( isset( $context['tags'] ) && is_array( $context['tags'] ) ) { $log_data['tags'] = array_merge( $log_data['tags'], $context['tags'] ); unset( $context['tags'] ); }
if ( isset( $context['error']['file'] ) && is_string( $context['error']['file'] ) && '' !== $context['error']['file'] ) { $log_data['file'] = $this->normalize_paths( $context['error']['file'] ); unset( $context['error']['file'] ); }
$extra_attrs = $context['extra'] ?? array(); unset( $context['extra'] ); unset( $context['remote-logging'] );
// Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash. $log_data['extra'] = array_merge( $extra_attrs, $context );
/** * Filters the formatted log data before sending it to the remote logging service. * Returning a non-array value will prevent the log from being sent. * * @since 9.2.0 * * @param array $log_data The formatted log data. * @param string $level The log level (e.g., 'error', 'warning'). * @param string $message The log message. * @param array $context The original context array. * * @return array The filtered log data. */ return apply_filters( 'woocommerce_remote_logger_formatted_log_data', $log_data, $level, $message, $context ); }
/** * Determines if remote logging is allowed based on the following conditions: * * 1. The feature flag for remote error logging is enabled. * 2. The user has opted into tracking/logging. * 3. The store is allowed to log based on the variant assignment percentage. * 4. The current WooCommerce version is the latest so we don't log errors that might have been fixed in a newer version. * * @return bool */ public function is_remote_logging_allowed() { if ( ! FeaturesUtil::feature_is_enabled( 'remote_logging' ) ) { return false; }
if ( ! WC_Site_Tracking::is_tracking_enabled() ) { return false; }
if ( ! $this->is_variant_assignment_allowed() ) { return false; }
if ( ! $this->should_current_version_be_logged() ) { return false; }
return true; }
/** * Determine whether to handle or ignore log. * * @param string $level emergency|alert|critical|error|warning|notice|info|debug. * @param string $message Log message to be recorded. * @param array $context Additional information for log handlers. * * @return bool True if the log should be handled. */ protected function should_handle( $level, $message, $context ) { // Ignore logs that are not opted in for remote logging. if ( ! isset( $context['remote-logging'] ) || false === $context['remote-logging'] ) { return false; }
if ( ! $this->is_remote_logging_allowed() ) { return false; }
// Ignore logs that are less severe than critical. This is temporary to prevent sending too many logs to the remote logging service. We can consider remove this if the remote logging service can handle more logs. if ( WC_Log_Levels::get_level_severity( $level ) < WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) { return false; }
if ( $this->is_third_party_error( (string) $message, (array) $context ) ) { return false; }
try { // Record fatal error stats. $mc_stats = wc_get_container()->get( McStats::class ); $mc_stats->add( 'error', 'critical-errors' ); $mc_stats->do_server_side_stats(); } catch ( \Throwable $e ) { error_log( 'Warning: Failed to record fatal error stats: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log }
if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) { // Log locally that the remote logging is throttled. SafeGlobalFunctionProxy::wc_get_logger()->warning( 'Remote logging throttled.', array( 'source' => 'remote-logging' ) ); return false; }
return true; }
/** * Send the log to the remote logging service. * * @param string $level Log level (e.g., 'error', 'warning', 'info'). * @param string $message Log message to be recorded. * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'. * * @throws \Exception|\Error If the remote logging fails. The error is caught and logged locally. * @return bool */ private function log( $level, $message, $context ) { $log_data = $this->get_formatted_log( $level, $message, $context );
// Ensure the log data is valid. if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) { return false; }
$body = SafeGlobalFunctionProxy::wp_json_encode( array( 'params' => SafeGlobalFunctionProxy::wp_json_encode( $log_data ) ) ); if ( is_null( $body ) ) { // if the json encoding fails the API will reject the API call so let's not bother. throw new \Error( 'Remote Logger encountered error while attempting to JSON encode $log_data' ); }
WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY );
if ( $this->is_dev_or_local_environment() ) { return false; }
$response = SafeGlobalFunctionProxy::wp_safe_remote_post( self::LOG_ENDPOINT, array( 'body' => $body, 'timeout' => 3, 'headers' => array( 'Content-Type' => 'application/json', ), 'blocking' => false, ) );
if ( is_null( $response ) ) { // SafeGlobalFunctionProxy will return a null if an error occurs within, so there will be a separate log entry with the details. SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to call wp_safe_remote_post while sending the log to the remote logging service.', array( 'source' => 'remote-logging' ) ); return false; }
$is_api_call_error = SafeGlobalFunctionProxy::is_wp_error( $response );
if ( $is_api_call_error ) { SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to send the log to the remote logging service: ' . $response->get_error_message(), array( 'source' => 'remote-logging' ) ); return false; } elseif ( is_null( $is_api_call_error ) ) { SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to parse the response after sending log to the remote logging service. ', array( 'source' => 'remote-logging' ) ); return false; } return true; }
/** * Check if the store is allowed to log based on the variant assignment percentage. * * @return bool */ private function is_variant_assignment_allowed() { $assignment = SafeGlobalFunctionProxy::get_option( 'woocommerce_remote_variant_assignment', 0 ) ?? 0; return ( $assignment <= 12 ); // Considering 10% of the 0-120 range. }
/** * Check if the current WooCommerce version is the latest. * * @return bool */ private function should_current_version_be_logged() { $new_version = SafeGlobalFunctionProxy::get_site_transient( self::WC_NEW_VERSION_TRANSIENT ) ?? '';
if ( false === $new_version ) { $new_version = $this->fetch_new_woocommerce_version(); // Cache the new version for a week since we want to keep logging in with the same version for a while even if the new version is available. SafeGlobalFunctionProxy::set_site_transient( self::WC_NEW_VERSION_TRANSIENT, $new_version, WEEK_IN_SECONDS ); }
if ( ! is_string( $new_version ) || '' === $new_version ) { // If the new version is not available, we consider the current version to be the latest. return true; }
// If the current version is the latest, we don't want to log errors. return version_compare( $this->get_wc_version(), $new_version, '>=' ); }
/** * Get the current WooCommerce version reliably through a series of fallbacks * * @return string The current WooCommerce version. */ private function get_wc_version() { if ( class_exists( '\Automattic\Jetpack\Constants' ) && method_exists( '\Automattic\Jetpack\Constants', 'get_constant' ) ) { $wc_version = \Automattic\Jetpack\Constants::get_constant( 'WC_VERSION' ); if ( $wc_version ) { return $wc_version; } }
if ( function_exists( 'WC' ) && method_exists( WC(), 'version' ) ) { return WC()->version(); }
if ( defined( 'WC_VERSION' ) ) { return WC_VERSION; }
// Return null since none of the above worked. return null; }
/** * Check if the error exclusively contains third-party stack frames for fatal-errors source context. * * @param string $message The error message. * @param array $context The error context. * * @return bool */ protected function is_third_party_error( string $message, array $context ): bool { // Only check for fatal-errors source context. if ( ! isset( $context['source'] ) || 'fatal-errors' !== $context['source'] ) { return false; }
// If backtrace is not available, we can't determine if the error is third-party. Log it for further investigation. if ( ! isset( $context['backtrace'] ) || ! is_array( $context['backtrace'] ) ) { return false; }
$wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH ); $wp_includes_dir = StringUtil::normalize_local_path_slashes( ABSPATH . WPINC ); $wp_admin_dir = StringUtil::normalize_local_path_slashes( ABSPATH . 'wp-admin' );
// Check if the error message contains the WooCommerce plugin directory. if ( str_contains( $message, $wc_plugin_dir ) ) { return false; }
// Find the first relevant frame that is not from WordPress core and not empty. $relevant_frame = null; foreach ( $context['backtrace'] as $frame ) { if ( empty( $frame ) || ! is_string( $frame ) ) { continue; }
// Skip frames from WordPress core. if ( strpos( $frame, $wp_includes_dir ) !== false || strpos( $frame, $wp_admin_dir ) !== false ) { continue; }
$relevant_frame = $frame; break; }
// Check if the relevant frame is from WooCommerce. if ( $relevant_frame && strpos( $relevant_frame, $wc_plugin_dir ) !== false ) { return false; }
if ( ! function_exists( 'apply_filters' ) ) { require_once ABSPATH . WPINC . '/plugin.php'; } /** * Filter to allow other plugins to overwrite the result of the third-party error check for remote logging. * * @since 9.2.0 * * @param bool $is_third_party_error The result of the third-party error check. * @param string $message The error message. * @param array $context The error context. */ return apply_filters( 'woocommerce_remote_logging_is_third_party_error', true, $message, $context ); }
/** * Fetch the new version of WooCommerce from the WordPress API. * * @return string|null New version if an update is available, null otherwise. */ private function fetch_new_woocommerce_version() { $plugin_updates = SafeGlobalFunctionProxy::get_plugin_updates();
// Check if WooCommerce plugin update information is available. if ( ! is_array( $plugin_updates ) || ! isset( $plugin_updates[ WC_PLUGIN_BASENAME ] ) ) { return null; }
$wc_plugin_update = $plugin_updates[ WC_PLUGIN_BASENAME ];
// Ensure the update object exists and has the required information. if ( ! $wc_plugin_update || ! isset( $wc_plugin_update->update->new_version ) ) { return null; }
$new_version = $wc_plugin_update->update->new_version; return is_string( $new_version ) ? $new_version : null; }
/** * Sanitize the content to exclude sensitive data. * * The trace is sanitized by: * * 1. Remove the absolute path to the plugin directory based on WC_ABSPATH. This is more accurate than using WP_PLUGIN_DIR when the plugin is symlinked. * 2. Remove the absolute path to the WordPress root directory. * 3. Redact potential user data such as email addresses and phone numbers. * * For example, the trace: * * /var/www/html/wp-content/plugins/woocommerce/includes/class-wc-remote-logger.php on line 123 * will be sanitized to: **\/woocommerce/includes/class-wc-remote-logger.php on line 123 * * Additionally, any user data like email addresses or phone numbers will be redacted. * * @param string $content The content to sanitize. * * @return string The sanitized content. */ private function sanitize( $content ) { if ( ! is_string( $content ) ) { return $content; }
$sanitized = $this->normalize_paths( $content ); $sanitized = $this->redact_user_data( $sanitized );
if ( ! function_exists( 'apply_filters' ) ) { require_once ABSPATH . WPINC . '/plugin.php'; }
/** * Filter the sanitized log content before it's sent to the remote logging service. * * @since 9.5.0 * * @param string $sanitized The sanitized content. * @param string $content The original content. */ return apply_filters( 'woocommerce_remote_logger_sanitized_content', $sanitized, $content ); }
/** * Normalize file paths by replacing absolute paths with relative ones. * * @param string $content The content containing paths to normalize. * * @return string The content with normalized paths. */ private function normalize_paths( string $content ): string { $plugin_path = StringUtil::normalize_local_path_slashes( trailingslashit( dirname( WC_ABSPATH ) ) ); $wp_path = StringUtil::normalize_local_path_slashes( trailingslashit( ABSPATH ) );
return str_replace( array( $plugin_path, $wp_path ), array( './', './' ), $content ); }
/** * Sanitize the error trace to exclude sensitive data. * * @param array|string $trace The error trace. * @return string The sanitized trace. */ private function sanitize_trace( $trace ): string { if ( is_string( $trace ) ) { return $this->sanitize( $trace ); }
if ( ! is_array( $trace ) ) { return ''; }
$sanitized_trace = array_map( function ( $trace_item ) { if ( is_array( $trace_item ) && isset( $trace_item['file'] ) ) { $trace_item['file'] = $this->sanitize( $trace_item['file'] ); return $trace_item; }
return $this->sanitize( $trace_item ); }, $trace );
$is_array_by_file = isset( $sanitized_trace[0]['file'] ); if ( $is_array_by_file ) { return SafeGlobalFunctionProxy::wc_print_r( $sanitized_trace, true ); }
return implode( "\n", $sanitized_trace ); }
/** * Redact potential user data from the content. * * @param string $content The content to redact. * @return string The redacted message. */ private function redact_user_data( $content ) { // Redact email addresses. $content = preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $content );
// Redact potential IP addresses. $content = preg_replace( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[redacted_ip]', $content );
// Redact potential credit card numbers. $content = preg_replace( '/(\d{4}[- ]?){3}\d{4}/', '[redacted_credit_card]', $content );
// API key redaction patterns. $api_patterns = array( '/\b[A-Za-z0-9]{32,40}\b/', // Generic API key. '/\b[0-9a-f]{32}\b/i', // 32 hex characters. '/\b(?:[A-Z0-9]{4}-){3,7}[A-Z0-9]{4}\b/i', // Segmented API key (e.g., XXXX-XXXX-XXXX-XXXX). '/\bsk_[A-Za-z0-9]{24,}\b/i', // Stripe keys (starts with sk_). );
foreach ( $api_patterns as $pattern ) { $content = preg_replace( $pattern, '[redacted_api_key]', $content ); }
/** * Redact potential phone numbers. * * This will match patterns like: * +1 (123) 456 7890 (with parentheses around area code) * +44-123-4567-890 (with area code, no parentheses) * 1234567890 (10 consecutive digits, no area code) * (123) 456-7890 (area code in parentheses, groups) * +91 12345 67890 (international format with space) */ $content = preg_replace( '/(?:(?:\+?\d{1,3}[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}|\b\d{10,11}\b)/', '[redacted_phone]', $content );
return $content; }
/** * Check if the current environment is development or local. * * Creates a helper method so we can easily mock this in tests. * * @return bool */ protected function is_dev_or_local_environment() { return in_array( SafeGlobalFunctionProxy::wp_get_environment_type() ?? 'production', array( 'development', 'local' ), true ); } /** * Sanitize the request URI to only allow certain query parameters. * * @param string $request_uri The request URI to sanitize. * @return string The sanitized request URI. */ private function sanitize_request_uri( $request_uri ) { $default_whitelist = array( 'path', 'page', 'step', 'task', 'tab', 'section', 'status', 'post_type', 'taxonomy', 'action', );
/** * Filter to allow other plugins to whitelist request_uri query parameter values for unmasked remote logging. * * @since 9.4.0 * * @param string $default_whitelist The default whitelist of query parameters. */ $whitelist = apply_filters( 'woocommerce_remote_logger_request_uri_whitelist', $default_whitelist );
$parsed_url = SafeGlobalFunctionProxy::wp_parse_url( $request_uri ); if ( ! is_array( $parsed_url ) || ! isset( $parsed_url['query'] ) ) { return $request_uri; }
parse_str( $parsed_url['query'], $query_params );
foreach ( $query_params as $key => &$value ) { if ( ! in_array( $key, $whitelist, true ) ) { $value = 'xxxxxx'; } }
$parsed_url['query'] = http_build_query( $query_params ); return $this->build_url( $parsed_url ); }
/** * Build a URL from its parsed components. * * @param array $parsed_url The parsed URL components. * @return string The built URL. */ private function build_url( $parsed_url ) { $path = $parsed_url['path'] ?? ''; $query = isset( $parsed_url['query'] ) ? "?{$parsed_url['query']}" : ''; $fragment = isset( $parsed_url['fragment'] ) ? "#{$parsed_url['fragment']}" : '';
return "$path$query$fragment"; } }
|