PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Internal / Logging / RemoteLogger.php
woocommerce / src / Internal / Logging Last commit date
OrderLogsCleanupHelper.php 2 months ago OrderLogsDeletionProcessor.php 2 months ago RemoteLogger.php 9 months ago SafeGlobalFunctionProxy.php 1 year ago
RemoteLogger.php
613 lines
1 <?php
2 declare( strict_types = 1 );
3
4 namespace Automattic\WooCommerce\Internal\Logging;
5
6 use Automattic\WooCommerce\Utilities\FeaturesUtil;
7 use Automattic\WooCommerce\Utilities\StringUtil;
8 use Automattic\WooCommerce\Internal\McStats;
9 use Jetpack_Options;
10 use WC_Rate_Limiter;
11 use WC_Log_Levels;
12 use WC_Site_Tracking;
13
14 /**
15 * WooCommerce Remote Logger
16 *
17 * The WooCommerce remote logger class adds functionality to log WooCommerce errors remotely based on if the customer opted in and several other conditions.
18 *
19 * No personal information is logged, only error information and relevant context.
20 *
21 * @class RemoteLogger
22 * @since 9.2.0
23 * @package WooCommerce\Classes
24 */
25 class RemoteLogger extends \WC_Log_Handler {
26
27 const LOG_ENDPOINT = 'https://public-api.wordpress.com/rest/v1.1/logstash';
28 const RATE_LIMIT_ID = 'woocommerce_remote_logging';
29 const RATE_LIMIT_DELAY = 60; // 1 minute.
30 const WC_NEW_VERSION_TRANSIENT = 'woocommerce_new_version';
31
32 /**
33 * Handle a log entry.
34 *
35 * @param int $timestamp Log timestamp.
36 * @param string $level emergency|alert|critical|error|warning|notice|info|debug.
37 * @param string $message Log message.
38 * @param array $context Additional information for log handlers.
39 *
40 * @throws \Exception If the remote logging fails. The error is caught and logged locally.
41 *
42 * @return bool False if value was not handled and true if value was handled.
43 */
44 public function handle( $timestamp, $level, $message, $context ) {
45 try {
46 if ( ! $this->should_handle( $level, $message, $context ) ) {
47 return false;
48 }
49
50 return $this->log( $level, $message, $context );
51 } catch ( \Throwable $e ) {
52 // Log the error to the local logger so we can investigate.
53 SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to handle the log: ' . $e->getMessage(), array( 'source' => 'remote-logging' ) );
54 return false;
55 }
56 }
57
58 /**
59 * Get formatted log data to be sent to the remote logging service.
60 *
61 * This method formats the log data by sanitizing the message, adding default fields, and including additional context
62 * such as backtrace, tags, and extra attributes. It also integrates with WC_Tracks to include blog and store details.
63 * The formatted log data is then filtered before being sent to the remote logging service.
64 *
65 * @param string $level Log level (e.g., 'error', 'warning', 'info').
66 * @param string $message Log message to be recorded.
67 * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'.
68 *
69 * @return array Formatted log data ready to be sent to the remote logging service.
70 */
71 public function get_formatted_log( $level, $message, $context = array() ) {
72 $log_data = array(
73 // Default fields.
74 'feature' => 'woocommerce_core',
75 'severity' => $level,
76 'message' => $this->sanitize( $message ),
77 'host' => SafeGlobalFunctionProxy::wp_parse_url( SafeGlobalFunctionProxy::home_url(), PHP_URL_HOST ) ?? 'Unable to retrieve host',
78 'tags' => array( 'woocommerce', 'php' ),
79 'properties' => array(
80 'wc_version' => $this->get_wc_version(),
81 'php_version' => phpversion(),
82 'wp_version' => SafeGlobalFunctionProxy::get_bloginfo( 'version' ) ?? 'Unable to retrieve wp version',
83 'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ),
84 'store_id' => SafeGlobalFunctionProxy::get_option( \WC_Install::STORE_ID_OPTION, null ) ?? 'Unable to retrieve store id',
85 ),
86 );
87
88 $blog_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null;
89
90 if ( ! empty( $blog_id ) && is_int( $blog_id ) ) {
91 $log_data['blog_id'] = $blog_id;
92 }
93
94 if ( isset( $context['backtrace'] ) ) {
95 if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) {
96 $log_data['trace'] = $this->sanitize_trace( $context['backtrace'] );
97 } elseif ( true === $context['backtrace'] ) {
98 $log_data['trace'] = $this->sanitize_trace( self::get_backtrace() );
99 }
100 unset( $context['backtrace'] );
101 }
102
103 if ( isset( $context['tags'] ) && is_array( $context['tags'] ) ) {
104 $log_data['tags'] = array_merge( $log_data['tags'], $context['tags'] );
105 unset( $context['tags'] );
106 }
107
108 if ( isset( $context['error']['file'] ) && is_string( $context['error']['file'] ) && '' !== $context['error']['file'] ) {
109 $log_data['file'] = $this->normalize_paths( $context['error']['file'] );
110 unset( $context['error']['file'] );
111 }
112
113 $extra_attrs = $context['extra'] ?? array();
114 unset( $context['extra'] );
115 unset( $context['remote-logging'] );
116
117 // Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash.
118 $log_data['extra'] = array_merge( $extra_attrs, $context );
119
120 /**
121 * Filters the formatted log data before sending it to the remote logging service.
122 * Returning a non-array value will prevent the log from being sent.
123 *
124 * @since 9.2.0
125 *
126 * @param array $log_data The formatted log data.
127 * @param string $level The log level (e.g., 'error', 'warning').
128 * @param string $message The log message.
129 * @param array $context The original context array.
130 *
131 * @return array The filtered log data.
132 */
133 return apply_filters( 'woocommerce_remote_logger_formatted_log_data', $log_data, $level, $message, $context );
134 }
135
136 /**
137 * Determines if remote logging is allowed based on the following conditions:
138 *
139 * 1. The feature flag for remote error logging is enabled.
140 * 2. The user has opted into tracking/logging.
141 * 3. The store is allowed to log based on the variant assignment percentage.
142 * 4. The current WooCommerce version is the latest so we don't log errors that might have been fixed in a newer version.
143 *
144 * @return bool
145 */
146 public function is_remote_logging_allowed() {
147 if ( ! FeaturesUtil::feature_is_enabled( 'remote_logging' ) ) {
148 return false;
149 }
150
151 if ( ! WC_Site_Tracking::is_tracking_enabled() ) {
152 return false;
153 }
154
155 if ( ! $this->should_current_version_be_logged() ) {
156 return false;
157 }
158
159 return true;
160 }
161
162 /**
163 * Determine whether to handle or ignore log.
164 *
165 * @param string $level emergency|alert|critical|error|warning|notice|info|debug.
166 * @param string $message Log message to be recorded.
167 * @param array $context Additional information for log handlers.
168 *
169 * @return bool True if the log should be handled.
170 */
171 protected function should_handle( $level, $message, $context ) {
172 // Ignore logs that are not opted in for remote logging.
173 if ( ! isset( $context['remote-logging'] ) || false === $context['remote-logging'] ) {
174 return false;
175 }
176
177 if ( ! $this->is_remote_logging_allowed() ) {
178 return false;
179 }
180
181 if ( $this->is_third_party_error( (string) $message, (array) $context ) ) {
182 return false;
183 }
184
185 // Record fatal error stats.
186 if ( WC_Log_Levels::get_level_severity( $level ) >= WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) {
187 try {
188 $mc_stats = wc_get_container()->get( McStats::class );
189 $mc_stats->add( 'error', 'critical-errors' );
190 $mc_stats->do_server_side_stats();
191 } catch ( \Throwable $e ) {
192 error_log( 'Warning: Failed to record fatal error stats: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
193 }
194 }
195
196 if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) {
197 // Log locally that the remote logging is throttled.
198 SafeGlobalFunctionProxy::wc_get_logger()->warning( 'Remote logging throttled.', array( 'source' => 'remote-logging' ) );
199 return false;
200 }
201
202 return true;
203 }
204
205
206 /**
207 * Send the log to the remote logging service.
208 *
209 * @param string $level Log level (e.g., 'error', 'warning', 'info').
210 * @param string $message Log message to be recorded.
211 * @param array $context Optional. Additional information for log handlers, such as 'backtrace', 'tags', 'extra', and 'error'.
212 *
213 * @throws \Exception|\Error If the remote logging fails. The error is caught and logged locally.
214 * @return bool
215 */
216 private function log( $level, $message, $context ) {
217 $log_data = $this->get_formatted_log( $level, $message, $context );
218
219 // Ensure the log data is valid.
220 if ( ! is_array( $log_data ) || empty( $log_data['message'] ) || empty( $log_data['feature'] ) ) {
221 return false;
222 }
223
224 $body = SafeGlobalFunctionProxy::wp_json_encode( array( 'params' => SafeGlobalFunctionProxy::wp_json_encode( $log_data ) ) );
225 if ( is_null( $body ) ) { // if the json encoding fails the API will reject the API call so let's not bother.
226 throw new \Error( 'Remote Logger encountered error while attempting to JSON encode $log_data' );
227 }
228
229 WC_Rate_Limiter::set_rate_limit( self::RATE_LIMIT_ID, self::RATE_LIMIT_DELAY );
230
231 if ( $this->is_dev_or_local_environment() ) {
232 return false;
233 }
234
235 $response = SafeGlobalFunctionProxy::wp_safe_remote_post(
236 self::LOG_ENDPOINT,
237 array(
238 'body' => $body,
239 'timeout' => 3,
240 'headers' => array(
241 'Content-Type' => 'application/json',
242 ),
243 'blocking' => false,
244 )
245 );
246
247 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.
248 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' ) );
249 return false;
250 }
251
252 $is_api_call_error = SafeGlobalFunctionProxy::is_wp_error( $response );
253
254 if ( $is_api_call_error ) {
255 SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to send the log to the remote logging service: ' . $response->get_error_message(), array( 'source' => 'remote-logging' ) );
256 return false;
257 } elseif ( is_null( $is_api_call_error ) ) {
258 SafeGlobalFunctionProxy::wc_get_logger()->error( 'Failed to parse the response after sending log to the remote logging service. ', array( 'source' => 'remote-logging' ) );
259 return false;
260 }
261 return true;
262 }
263
264 /**
265 * Check if the current WooCommerce version is the latest.
266 *
267 * @return bool
268 */
269 private function should_current_version_be_logged() {
270 $new_version = SafeGlobalFunctionProxy::get_site_transient( self::WC_NEW_VERSION_TRANSIENT ) ?? '';
271
272 if ( false === $new_version ) {
273 $new_version = $this->fetch_new_woocommerce_version();
274 // 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.
275 SafeGlobalFunctionProxy::set_site_transient( self::WC_NEW_VERSION_TRANSIENT, $new_version, WEEK_IN_SECONDS );
276 }
277
278 if ( ! is_string( $new_version ) || '' === $new_version ) {
279 // If the new version is not available, we consider the current version to be the latest.
280 return true;
281 }
282
283 // If the current version is the latest, we don't want to log errors.
284 return version_compare( $this->get_wc_version(), $new_version, '>=' );
285 }
286
287 /**
288 * Get the current WooCommerce version reliably through a series of fallbacks
289 *
290 * @return string The current WooCommerce version.
291 */
292 private function get_wc_version() {
293 if ( class_exists( '\Automattic\Jetpack\Constants' ) && method_exists( '\Automattic\Jetpack\Constants', 'get_constant' ) ) {
294 $wc_version = \Automattic\Jetpack\Constants::get_constant( 'WC_VERSION' );
295 if ( $wc_version ) {
296 return $wc_version;
297 }
298 }
299
300 if ( defined( 'WC_VERSION' ) ) {
301 return WC_VERSION;
302 }
303
304 if ( function_exists( 'WC' ) ) {
305 return WC()->version;
306 }
307
308 // Return null since none of the above worked.
309 return null;
310 }
311
312 /**
313 * Check if the error exclusively contains third-party stack frames for fatal-errors source context.
314 *
315 * @param string $message The error message.
316 * @param array $context The error context.
317 *
318 * @return bool
319 */
320 protected function is_third_party_error( string $message, array $context ): bool {
321 // Only check for fatal-errors source context.
322 if ( ! isset( $context['source'] ) || 'fatal-errors' !== $context['source'] ) {
323 return false;
324 }
325
326 $wc_plugin_dir = StringUtil::normalize_local_path_slashes( WC_ABSPATH );
327
328 // Check if the error message contains the WooCommerce plugin directory.
329 if ( str_contains( $message, $wc_plugin_dir ) ) {
330 return false;
331 }
332
333 // Without a backtrace, it's impossible to ascertain if the error is third-party. To avoid logging numerous irrelevant errors, we'll consider it a third-party error and ignore it.
334 if ( isset( $context['backtrace'] ) && is_array( $context['backtrace'] ) ) {
335 $wp_includes_dir = StringUtil::normalize_local_path_slashes( ABSPATH . WPINC );
336 $wp_admin_dir = StringUtil::normalize_local_path_slashes( ABSPATH . 'wp-admin' );
337
338 // Find the first relevant frame that is not from WordPress core and not empty.
339 $relevant_frame = null;
340 foreach ( $context['backtrace'] as $frame ) {
341 if ( empty( $frame ) || ! is_string( $frame ) ) {
342 continue;
343 }
344
345 // Skip frames from WordPress core.
346 if ( strpos( $frame, $wp_includes_dir ) !== false || strpos( $frame, $wp_admin_dir ) !== false ) {
347 continue;
348 }
349
350 $relevant_frame = $frame;
351 break;
352 }
353
354 // Check if the relevant frame is from WooCommerce.
355 if ( $relevant_frame && strpos( $relevant_frame, $wc_plugin_dir ) !== false ) {
356 return false;
357 }
358 }
359
360 if ( ! function_exists( 'apply_filters' ) ) {
361 require_once ABSPATH . WPINC . '/plugin.php';
362 }
363 /**
364 * Filter to allow other plugins to overwrite the result of the third-party error check for remote logging.
365 *
366 * @since 9.2.0
367 *
368 * @param bool $is_third_party_error The result of the third-party error check.
369 * @param string $message The error message.
370 * @param array $context The error context.
371 */
372 return apply_filters( 'woocommerce_remote_logging_is_third_party_error', true, $message, $context );
373 }
374
375 /**
376 * Fetch the new version of WooCommerce from the WordPress API.
377 *
378 * @return string|null New version if an update is available, null otherwise.
379 */
380 private function fetch_new_woocommerce_version() {
381 $plugin_updates = SafeGlobalFunctionProxy::get_plugin_updates();
382
383 // Check if WooCommerce plugin update information is available.
384 if ( ! is_array( $plugin_updates ) || ! isset( $plugin_updates[ WC_PLUGIN_BASENAME ] ) ) {
385 return null;
386 }
387
388 $wc_plugin_update = $plugin_updates[ WC_PLUGIN_BASENAME ];
389
390 // Ensure the update object exists and has the required information.
391 if ( ! $wc_plugin_update || ! isset( $wc_plugin_update->update->new_version ) ) {
392 return null;
393 }
394
395 $new_version = $wc_plugin_update->update->new_version;
396 return is_string( $new_version ) ? $new_version : null;
397 }
398
399 /**
400 * Sanitize the content to exclude sensitive data.
401 *
402 * The trace is sanitized by:
403 *
404 * 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.
405 * 2. Remove the absolute path to the WordPress root directory.
406 * 3. Redact potential user data such as email addresses and phone numbers.
407 *
408 * For example, the trace:
409 *
410 * /var/www/html/wp-content/plugins/woocommerce/includes/class-wc-remote-logger.php on line 123
411 * will be sanitized to: **\/woocommerce/includes/class-wc-remote-logger.php on line 123
412 *
413 * Additionally, any user data like email addresses or phone numbers will be redacted.
414 *
415 * @param string $content The content to sanitize.
416 *
417 * @return string The sanitized content.
418 */
419 private function sanitize( $content ) {
420 if ( ! is_string( $content ) ) {
421 return $content;
422 }
423
424 $sanitized = $this->normalize_paths( $content );
425 $sanitized = $this->redact_user_data( $sanitized );
426
427 if ( ! function_exists( 'apply_filters' ) ) {
428 require_once ABSPATH . WPINC . '/plugin.php';
429 }
430
431 /**
432 * Filter the sanitized log content before it's sent to the remote logging service.
433 *
434 * @since 9.5.0
435 *
436 * @param string $sanitized The sanitized content.
437 * @param string $content The original content.
438 */
439 return apply_filters( 'woocommerce_remote_logger_sanitized_content', $sanitized, $content );
440 }
441
442 /**
443 * Normalize file paths by replacing absolute paths with relative ones.
444 *
445 * @param string $content The content containing paths to normalize.
446 *
447 * @return string The content with normalized paths.
448 */
449 private function normalize_paths( string $content ): string {
450 $plugin_path = StringUtil::normalize_local_path_slashes( trailingslashit( dirname( WC_ABSPATH ) ) );
451 $wp_path = StringUtil::normalize_local_path_slashes( trailingslashit( ABSPATH ) );
452
453 return str_replace(
454 array( $plugin_path, $wp_path ),
455 array( './', './' ),
456 $content
457 );
458 }
459
460 /**
461 * Sanitize the error trace to exclude sensitive data.
462 *
463 * @param array|string $trace The error trace.
464 * @return string The sanitized trace.
465 */
466 private function sanitize_trace( $trace ): string {
467 if ( is_string( $trace ) ) {
468 return $this->sanitize( $trace );
469 }
470
471 if ( ! is_array( $trace ) ) {
472 return '';
473 }
474
475 $sanitized_trace = array_map(
476 function ( $trace_item ) {
477 if ( is_array( $trace_item ) && isset( $trace_item['file'] ) ) {
478 $trace_item['file'] = $this->sanitize( $trace_item['file'] );
479 return $trace_item;
480 }
481
482 return $this->sanitize( $trace_item );
483 },
484 $trace
485 );
486
487 $is_array_by_file = isset( $sanitized_trace[0]['file'] );
488 if ( $is_array_by_file ) {
489 return SafeGlobalFunctionProxy::wc_print_r( $sanitized_trace, true );
490 }
491
492 return implode( "\n", $sanitized_trace );
493 }
494
495
496 /**
497 * Redact potential user data from the content.
498 *
499 * @param string $content The content to redact.
500 * @return string The redacted message.
501 */
502 private function redact_user_data( $content ) {
503 // Redact email addresses.
504 $content = preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $content );
505
506 // Redact potential IP addresses.
507 $content = preg_replace( '/\b(?:\d{1,3}\.){3}\d{1,3}\b/', '[redacted_ip]', $content );
508
509 // Redact potential credit card numbers.
510 $content = preg_replace( '/(\d{4}[- ]?){3}\d{4}/', '[redacted_credit_card]', $content );
511
512 // API key redaction patterns.
513 $api_patterns = array(
514 '/\b[A-Za-z0-9]{32,40}\b/', // Generic API key.
515 '/\b[0-9a-f]{32}\b/i', // 32 hex characters.
516 '/\b(?:[A-Z0-9]{4}-){3,7}[A-Z0-9]{4}\b/i', // Segmented API key (e.g., XXXX-XXXX-XXXX-XXXX).
517 '/\bsk_[A-Za-z0-9]{24,}\b/i', // Stripe keys (starts with sk_).
518 );
519
520 foreach ( $api_patterns as $pattern ) {
521 $content = preg_replace( $pattern, '[redacted_api_key]', $content );
522 }
523
524 /**
525 * Redact potential phone numbers.
526 *
527 * This will match patterns like:
528 * +1 (123) 456 7890 (with parentheses around area code)
529 * +44-123-4567-890 (with area code, no parentheses)
530 * 1234567890 (10 consecutive digits, no area code)
531 * (123) 456-7890 (area code in parentheses, groups)
532 * +91 12345 67890 (international format with space)
533 */
534 $content = preg_replace(
535 '/(?:(?:\+?\d{1,3}[-\s]?)?\(?\d{3}\)?[-\s]?\d{3}[-\s]?\d{4}|\b\d{10,11}\b)/',
536 '[redacted_phone]',
537 $content
538 );
539
540 return $content;
541 }
542
543 /**
544 * Check if the current environment is development or local.
545 *
546 * Creates a helper method so we can easily mock this in tests.
547 *
548 * @return bool
549 */
550 protected function is_dev_or_local_environment() {
551 return in_array( SafeGlobalFunctionProxy::wp_get_environment_type() ?? 'production', array( 'development', 'local' ), true );
552 }
553 /**
554 * Sanitize the request URI to only allow certain query parameters.
555 *
556 * @param string $request_uri The request URI to sanitize.
557 * @return string The sanitized request URI.
558 */
559 private function sanitize_request_uri( $request_uri ) {
560 $default_whitelist = array(
561 'path',
562 'page',
563 'step',
564 'task',
565 'tab',
566 'section',
567 'status',
568 'post_type',
569 'taxonomy',
570 'action',
571 );
572
573 /**
574 * Filter to allow other plugins to whitelist request_uri query parameter values for unmasked remote logging.
575 *
576 * @since 9.4.0
577 *
578 * @param string $default_whitelist The default whitelist of query parameters.
579 */
580 $whitelist = apply_filters( 'woocommerce_remote_logger_request_uri_whitelist', $default_whitelist );
581
582 $parsed_url = SafeGlobalFunctionProxy::wp_parse_url( $request_uri );
583 if ( ! is_array( $parsed_url ) || ! isset( $parsed_url['query'] ) ) {
584 return $request_uri;
585 }
586
587 parse_str( $parsed_url['query'], $query_params );
588
589 foreach ( $query_params as $key => &$value ) {
590 if ( ! in_array( $key, $whitelist, true ) ) {
591 $value = 'xxxxxx';
592 }
593 }
594
595 $parsed_url['query'] = http_build_query( $query_params );
596 return $this->build_url( $parsed_url );
597 }
598
599 /**
600 * Build a URL from its parsed components.
601 *
602 * @param array $parsed_url The parsed URL components.
603 * @return string The built URL.
604 */
605 private function build_url( $parsed_url ) {
606 $path = $parsed_url['path'] ?? '';
607 $query = isset( $parsed_url['query'] ) ? "?{$parsed_url['query']}" : '';
608 $fragment = isset( $parsed_url['fragment'] ) ? "#{$parsed_url['fragment']}" : '';
609
610 return "$path$query$fragment";
611 }
612 }
613