PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 1.9.12 1.9.11 1.9.10 All 159 releases
woocommerce-pos / includes / Services / Error_Reporter.php

Error_Reporter.php in WCPOS – Point of Sale (POS) plugin for WooCommerce trunk, at includes/Services/Error_Reporter.php

648 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Consent-gated Sentry error reporting.
4 *
5 * @package WCPOS\WooCommercePOS\Services
6 */
7
8 namespace WCPOS\WooCommercePOS\Services;
9
10 use Throwable;
11 use WCPOS\Vendor\Sentry\ClientBuilder;
12 use WCPOS\Vendor\Sentry\ClientInterface;
13 use WCPOS\Vendor\Sentry\Event;
14 use WCPOS\Vendor\Sentry\Severity;
15 use WCPOS\Vendor\Sentry\UserDataBag;
16 use WP_REST_Request;
17 use WP_REST_Response;
18 use const WCPOS\WooCommercePOS\PLUGIN_PATH;
19 use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION;
20
21 /**
22 * Reports WCPOS errors without ever disrupting the request being reported.
23 */
24 class Error_Reporter {
25 /**
26 * Sentry ingest DSN.
27 *
28 * Client-side DSNs are designed to be public: they authorize event
29 * ingestion into one project only. This is the same Sentry project as the
30 * app-side reporters (release namespaces WCPOS@ and wcpos-app@); this
31 * reporter tags release wcpos-php@<VERSION>.
32 *
33 * Override with the WCPOS_SENTRY_DSN constant.
34 *
35 * @var string
36 */
37 const DEFAULT_DSN = 'https://39233e9d1e5046cbb67dae52f807de5f@o159038.ingest.sentry.io/1220733';
38
39 /**
40 * Transient latched after a failed send.
41 *
42 * @var string
43 */
44 const BACKOFF_TRANSIENT = 'wcpos_sentry_backoff';
45
46 /**
47 * How long a failed send suppresses delivery, in seconds.
48 *
49 * Long enough that a Sentry outage costs a store one slow request per
50 * window rather than one per error; short enough that a transient blip
51 * does not hide a real incident for the rest of the day.
52 *
53 * @var int
54 */
55 const BACKOFF_TTL = 15 * MINUTE_IN_SECONDS;
56
57 /**
58 * Singleton instance.
59 *
60 * @var null|self
61 */
62 private static $instance = null;
63
64 /**
65 * Factory for an injected transport. Test seam only.
66 *
67 * @var null|callable
68 */
69 private static $transport_factory = null;
70
71 /**
72 * Forced development-environment answer. Test seam only.
73 *
74 * @var null|bool
75 */
76 private static ?bool $dev_override = null;
77
78 /**
79 * Cached client, built only after the consent gate passes.
80 *
81 * @var null|ClientInterface
82 */
83 private ?ClientInterface $client = null; // @phpstan-ignore-line -- Scoped SDK is excluded from analysis.
84
85 /**
86 * Number of non-fatal events captured during this request.
87 *
88 * @var int
89 */
90 private int $events_sent = 0;
91
92 /**
93 * Whether a fatal has already been captured during this request.
94 *
95 * Fatals get their own slot. They arrive last (shutdown), so sharing one
96 * counter with the other two paths meant any earlier logged error or REST
97 * 500 permanently consumed the budget and silently dropped the fatal —
98 * the highest-value of the three signals.
99 *
100 * @var bool
101 */
102 private bool $fatal_sent = false;
103
104 /**
105 * Cached consent + environment answer for this request.
106 *
107 * Memoised because Logger forwards every error-level write, and Logger's
108 * class contract forbids repeated option lookups from that path.
109 *
110 * @var null|bool
111 */
112 private ?bool $enabled_cache = null;
113
114 /**
115 * Whether this reporter is already handling an event.
116 *
117 * @var bool
118 */
119 private bool $reporting = false;
120
121 /** Get the singleton instance. */
122 public static function instance(): self {
123 if ( null === self::$instance ) {
124 self::$instance = new self();
125 }
126
127 return self::$instance;
128 }
129
130 /** Reset the singleton. Intended for tests only. */
131 public static function reset_instance(): void {
132 self::$instance = null;
133 }
134
135 /**
136 * Install a transport factory. Test seam only.
137 *
138 * Resetting the singleton deliberately does not clear this factory.
139 * No-op outside the PHPUnit environment so shipped code cannot use it
140 * to intercept event payloads.
141 *
142 * @param null|callable $factory Factory returning a Sentry transport.
143 */
144 public static function set_transport_factory_for_testing( ?callable $factory ): void {
145 if ( ! \defined( 'WP_TESTS_DOMAIN' ) ) {
146 return;
147 }
148 self::$transport_factory = $factory;
149 }
150
151 /**
152 * Override development-environment detection. Test seam only.
153 *
154 * No-op outside the PHPUnit environment: a production site must not be
155 * able to lift the WP_DEBUG / WCPOS_DEV gate through this seam.
156 *
157 * @param null|bool $is_dev Forced answer, or null to use constants.
158 */
159 public static function set_dev_override_for_testing( ?bool $is_dev ): void {
160 if ( ! \defined( 'WP_TESTS_DOMAIN' ) ) {
161 return;
162 }
163 self::$dev_override = $is_dev;
164 }
165
166 /** Whether the Sentry client has been built. */
167 public function is_initialized(): bool {
168 return null !== $this->client;
169 }
170
171 /** Register error-reporting hooks. */
172 public function register_hooks(): void {
173 try {
174 add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'filter_rest_request_after_callbacks' ), 999, 3 );
175 register_shutdown_function( array( __CLASS__, 'handle_shutdown' ) );
176 } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
177 // Reporting must never break plugin initialization.
178 }
179 }
180
181 /** Whether reporting is allowed by consent and environment. */
182 public function is_enabled(): bool {
183 if ( null !== $this->enabled_cache ) {
184 return $this->enabled_cache;
185 }
186
187 $this->enabled_cache = $this->compute_enabled();
188
189 return $this->enabled_cache;
190 }
191
192 /** Resolve consent, environment and the host kill switch. */
193 private function compute_enabled(): bool {
194 try {
195 // The PERSISTED value, not the filtered read view: a settings filter
196 // must not be able to manufacture consent the merchant never gave.
197 $consent_allowed = 'allowed' === Settings::instance()->raw_tracking_consent();
198 $enabled = $consent_allowed && ! $this->is_dev_environment();
199
200 /**
201 * Filters whether WCPOS error reporting is active. The host's kill
202 * switch. Default: tracking consent, minus development sites.
203 *
204 * @since 1.10.6
205 *
206 * @param bool $enabled Computed default.
207 * @param bool $consent_allowed Raw consent check.
208 */
209 return $consent_allowed && (bool) apply_filters( 'woocommerce_pos_error_reporting_enabled', $enabled, $consent_allowed );
210 } catch ( Throwable $throwable ) {
211 return false;
212 }
213 }
214
215 /**
216 * Remove request and user data that could identify the merchant.
217 *
218 * @param Event $event Event about to be sent.
219 *
220 * @return Event Scrubbed event.
221 */
222 public static function scrub_event( $event ) { // phpcs:ignore Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace -- Scoped SDK is excluded from analysis. @phpstan-ignore-line
223 try {
224 $request = $event->getRequest();
225 if ( ! empty( $request ) ) {
226 $url = isset( $request['url'] ) && \is_string( $request['url'] ) ? $request['url'] : '';
227 $path = wp_parse_url( $url, PHP_URL_PATH );
228 $event->setRequest(
229 array(
230 'method' => isset( $request['method'] ) ? (string) $request['method'] : '',
231 'url' => \is_string( $path ) ? $path : '',
232 )
233 );
234 }
235
236 // Message text is the one place merchant identity still leaks: a PHP
237 // fatal embeds absolute paths and a textual stack trace, and shared
238 // hosting bakes the store's domain into the docroot
239 // (/home/<domain>/public_html, /var/www/vhosts/<domain>/httpdocs).
240 // The `prefixes` option only rewrites stacktrace FRAMES, which are
241 // switched off here, so it never sees these strings.
242 $message = $event->getMessage();
243 if ( \is_string( $message ) && '' !== $message ) {
244 $event->setMessage( self::redact_paths( $message ) );
245 }
246
247 $event->setServerName( '' );
248 $event->setUser( UserDataBag::createFromUserIdentifier( Analytics::instance()->get_site_id() ) );
249 } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
250 // Return the best-effort scrubbed event; never disrupt Sentry internals.
251 }
252
253 return $event;
254 }
255
256 /**
257 * Replace local filesystem paths in free text with stable placeholders.
258 *
259 * Longest path first: WP_CONTENT_DIR usually sits inside ABSPATH, and the
260 * parent of ABSPATH is the segment that carries the account or domain name
261 * on cPanel-style hosting. A root-level path ('/' or '') is skipped — it
262 * would match every slash in the string.
263 *
264 * @param string $text Text to redact.
265 *
266 * @return string Redacted text.
267 */
268 private static function redact_paths( string $text ): string {
269 $normalized = str_replace( '\\', '/', $text );
270
271 $replacements = array();
272 if ( \defined( 'WP_CONTENT_DIR' ) ) {
273 $replacements[ untrailingslashit( str_replace( '\\', '/', WP_CONTENT_DIR ) ) ] = '<wp-content>';
274 }
275 if ( \defined( 'ABSPATH' ) ) {
276 $abspath = untrailingslashit( str_replace( '\\', '/', ABSPATH ) );
277 $replacements[ $abspath ] = '<abspath>';
278 $replacements[ \dirname( $abspath ) ] = '<root>';
279 }
280
281 // Longest needle first so a nested path is not partially replaced by its
282 // own parent, which would leave the identifying segment behind.
283 uksort(
284 $replacements,
285 static function ( string $a, string $b ): int {
286 return \strlen( $b ) <=> \strlen( $a );
287 }
288 );
289
290 foreach ( $replacements as $path => $placeholder ) {
291 if ( \strlen( $path ) > 1 && '/' !== $path ) {
292 $normalized = str_replace( $path, $placeholder, $normalized );
293 }
294 }
295
296 return $normalized;
297 }
298
299 /**
300 * Forward an error-level Logger write.
301 *
302 * @param string $level Logger level.
303 * @param string $message Log message.
304 * @param string $context Formatted log context.
305 */
306 public static function report_log_error( string $level, string $message, string $context ): void {
307 try {
308 if ( 'error' !== $level && 'critical' !== $level ) {
309 return;
310 }
311
312 // Group on the bare message: the context routinely carries order ids,
313 // timestamps and paths, so folding it into the grouping key would make
314 // every occurrence its own Sentry issue.
315 $fingerprint = array( 'wcpos-log', $level, self::redact_paths( $message ) );
316
317 if ( '' !== $context ) {
318 $message .= ' | Context: ' . $context;
319 }
320
321 self::instance()->capture( $level, $message, array( 'source' => 'logger' ), $fingerprint );
322 } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
323 // Reporting must never break logging.
324 }
325 }
326
327 /**
328 * Report server errors returned by WCPOS REST routes.
329 *
330 * @param mixed $response REST response.
331 * @param mixed $handler Matched REST handler.
332 * @param mixed $request REST request.
333 *
334 * @return mixed The original response, always unchanged.
335 */
336 public static function filter_rest_request_after_callbacks( $response, $handler, $request ) {
337 try {
338 if ( $request instanceof WP_REST_Request ) {
339 $route = $request->get_route();
340 $lower_route = strtolower( $route );
341 $is_wcpos = 0 === strpos( $lower_route, '/wcpos/v1/' ) || 0 === strpos( $lower_route, '/wcpos/v2/' );
342 if ( $is_wcpos ) {
343 $status = 0;
344 $code = '';
345 $message = '';
346 if ( is_wp_error( $response ) ) {
347 $statuses = array();
348 foreach ( $response->get_error_codes() as $error_code ) {
349 $data = $response->get_error_data( $error_code );
350 if ( \is_array( $data ) && isset( $data['status'] ) && \is_numeric( $data['status'] ) ) {
351 $statuses[] = (int) $data['status'];
352 }
353 }
354 $status = empty( $statuses ) ? 500 : max( $statuses );
355 $code = (string) $response->get_error_code();
356 $message = $code . ': ' . $response->get_error_message();
357 } elseif ( $response instanceof WP_REST_Response ) {
358 $status = $response->get_status();
359 $data = $response->get_data();
360 $code = \is_array( $data ) && isset( $data['code'] ) && \is_string( $data['code'] )
361 ? $data['code']
362 : 'http_' . $status;
363 $message = $code;
364 }
365
366 if ( 500 <= $status ) {
367 // $code can originate in a third-party response body (a
368 // relay or upstream error forwarded by a controller), so it
369 // is allow-listed before it becomes a grouping key or tag.
370 $code = self::safe_code( $code );
371
372 self::instance()->capture(
373 'error',
374 $message,
375 array(
376 'route' => self::generalize_route( (string) $route, $request->get_url_params() ),
377 'method' => (string) $request->get_method(),
378 'status' => (string) $status,
379 'source' => 'rest',
380 ),
381 array( 'wcpos-rest', $code )
382 );
383 }
384 }
385 }
386 } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
387 // REST responses must pass through even if reporting fails.
388 }
389
390 return $response;
391 }
392
393 /**
394 * Reduce an error code to a safe, low-cardinality identifier.
395 *
396 * @param string $code Raw error code.
397 *
398 * @return string Allow-listed code.
399 */
400 private static function safe_code( string $code ): string {
401 return preg_match( '/^[A-Za-z0-9_.-]{1,64}$/', $code ) ? $code : 'unrecognized_code';
402 }
403
404 /**
405 * Collapse resource ids in a route so the tag stays low-cardinality.
406 *
407 * Matched route parameters are removed first because printer-token routes
408 * contain non-numeric credentials. Remaining numeric ids are then collapsed.
409 * Without this, each resource id opens its own tag value and exposes store data.
410 *
411 * @param string $route Concrete requested route.
412 * @param array $parameters Matched URL parameters.
413 *
414 * @return string Generalized route.
415 */
416 private static function generalize_route( string $route, array $parameters ): string {
417 foreach ( $parameters as $value ) {
418 if ( ! \is_scalar( $value ) || '' === (string) $value ) {
419 continue;
420 }
421
422 $redacted = preg_replace( '#/' . preg_quote( (string) $value, '#' ) . '(?=/|$)#', '/{param}', $route );
423 $route = \is_string( $redacted ) ? $redacted : $route;
424 }
425
426 $generalized = preg_replace( '#/\d+#', '/{id}', $route );
427
428 return \is_string( $generalized ) ? $generalized : $route;
429 }
430
431 /** Handle the last PHP error at shutdown. */
432 public static function handle_shutdown(): void {
433 try {
434 $error = error_get_last();
435 if ( null !== $error ) {
436 self::instance()->report_fatal( $error );
437 }
438 } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
439 // Shutdown reporting is best-effort only.
440 }
441 }
442
443 /**
444 * Report a crafted fatal error array. Public so tests can drive this path.
445 *
446 * @param array $error PHP error details.
447 */
448 public function report_fatal( array $error ): void {
449 try {
450 if ( ! isset( $error['type'], $error['message'], $error['file'] ) ) {
451 return;
452 }
453
454 $fatal_types = E_ERROR | E_PARSE | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR;
455 if ( 0 === ( (int) $error['type'] & $fatal_types ) ) {
456 return;
457 }
458
459 $file = str_replace( '\\', '/', (string) $error['file'] );
460 $plugin_path = str_replace( '\\', '/', PLUGIN_PATH );
461 $is_ours = 0 === strpos( $file, $plugin_path );
462 if ( ! $is_ours && \defined( '\WCPOS\WooCommercePOSPro\PLUGIN_PATH' ) ) {
463 $pro_path = str_replace( '\\', '/', (string) \constant( '\WCPOS\WooCommercePOSPro\PLUGIN_PATH' ) );
464 $is_ours = 0 === strpos( $file, $pro_path );
465 }
466 if ( ! $is_ours ) {
467 return;
468 }
469
470 $content_path = rtrim( str_replace( '\\', '/', WP_CONTENT_DIR ), '/' ) . '/';
471 $relative = 0 === strpos( $file, $content_path ) ? substr( $file, strlen( $content_path ) ) : basename( $file );
472 $line = isset( $error['line'] ) ? (string) $error['line'] : '0';
473
474 $this->capture(
475 'critical',
476 substr( (string) $error['message'], 0, 8 * 1024 ),
477 array(
478 'source' => 'fatal',
479 'fatal_file' => $relative,
480 'fatal_line' => $line,
481 ),
482 array( 'wcpos-fatal', $relative, $line ),
483 true
484 );
485 } catch ( Throwable $throwable ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
486 // Fatal reporting must not create another fatal.
487 }
488 }
489
490 /**
491 * Build and send one event, subject to consent and the request cap.
492 *
493 * @param string $level Error level.
494 * @param string $message Event message.
495 * @param array $tags Event tags.
496 * @param array $fingerprint Event fingerprint.
497 * @param bool $from_fatal_path Whether this came from the shutdown fatal
498 * handler, which owns the reserved slot.
499 *
500 * @return bool Whether an event was handed to the client.
501 */
502 private function capture( string $level, string $message, array $tags, array $fingerprint, bool $from_fatal_path = false ): bool {
503 if ( $this->reporting ) {
504 return false;
505 }
506
507 // The reserved slot belongs to the shutdown fatal PATH, not to a
508 // severity. `Logger::$log_level` is public, so anything may log at
509 // `critical`; letting that consume the fatal budget would silently drop
510 // the real fatal arriving later in the same request — the exact
511 // starvation the second slot exists to prevent.
512 $is_fatal = $from_fatal_path;
513
514 $this->reporting = true;
515 try {
516 // One non-fatal event per request, plus at most one fatal.
517 if ( $is_fatal ? $this->fatal_sent : 1 <= $this->events_sent ) {
518 return false;
519 }
520
521 if ( ! $this->is_enabled() || $this->is_backing_off() ) {
522 return false;
523 }
524
525 $client = $this->get_client();
526 if ( null === $client ) {
527 return false;
528 }
529
530 $event = Event::createEvent();
531 $event->setLevel( 'critical' === $level ? Severity::fatal() : Severity::error() );
532 $event->setMessage( $message );
533 if ( ! empty( $fingerprint ) ) {
534 $event->setFingerprint( $fingerprint );
535 }
536 $event->setTags( array_merge( $this->get_default_tags(), $tags ) );
537 $event->setUser( UserDataBag::createFromUserIdentifier( Analytics::instance()->get_site_id() ) );
538
539 $sent = $client->captureEvent( $event );
540
541 if ( $is_fatal ) {
542 $this->fatal_sent = true;
543 } else {
544 ++$this->events_sent;
545 }
546
547 // captureEvent() returns null when the transport failed. The SDK's own
548 // rate limiter is built per transport, i.e. per request under PHP-FPM,
549 // so it forgets a 429 immediately and every erroring request would keep
550 // paying the full timeout. Latch a short backoff across requests
551 // instead: an outage costs one slow request per window, not all of them.
552 if ( null === $sent ) {
553 $this->start_backoff();
554
555 return false;
556 }
557
558 return true;
559 } catch ( Throwable $throwable ) {
560 return false;
561 } finally {
562 $this->reporting = false;
563 }
564 }
565
566 /** Lazily build the scoped Sentry client. */
567 private function get_client(): ?ClientInterface { // phpcs:ignore Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace -- Scoped SDK is excluded from analysis. @phpstan-ignore-line
568 if ( null !== $this->client ) {
569 return $this->client;
570 }
571
572 try {
573 if ( ! \class_exists( '\WCPOS\Vendor\Sentry\ClientBuilder' ) ) {
574 return null;
575 }
576 foreach ( array( 'mb_detect_encoding', 'mb_convert_encoding', 'mb_strlen', 'mb_substr' ) as $function ) {
577 if ( ! \function_exists( $function ) ) {
578 return null;
579 }
580 }
581 if ( null === self::$transport_factory && ! \extension_loaded( 'curl' ) ) {
582 return null;
583 }
584
585 $dsn = \defined( 'WCPOS_SENTRY_DSN' ) ? (string) \WCPOS_SENTRY_DSN : self::DEFAULT_DSN;
586 $options = array(
587 'dsn' => $dsn,
588 'release' => 'wcpos-php@' . PLUGIN_VERSION,
589 'environment' => 'production',
590 'default_integrations' => false,
591 'integrations' => array(),
592 'send_default_pii' => false,
593 'attach_stacktrace' => false,
594 'max_breadcrumbs' => 0,
595 // The SDK stamps gethostname() onto every event before before_send
596 // runs. Blanking it here means the hostname is never placed on the
597 // event at all, so a throw inside scrub_event cannot ship it.
598 'server_name' => '',
599 'http_connect_timeout' => 1,
600 'http_timeout' => 2,
601 'prefixes' => array( ABSPATH ),
602 'before_send' => array( __CLASS__, 'scrub_event' ),
603 );
604
605 $builder = ClientBuilder::create( $options );
606 if ( null !== self::$transport_factory ) {
607 $builder->setTransport( \call_user_func( self::$transport_factory ) );
608 }
609
610 $this->client = $builder->getClient();
611
612 return $this->client;
613 } catch ( Throwable $throwable ) {
614 return null;
615 }
616 }
617
618 /** Whether a recent send failure is still suppressing delivery. */
619 private function is_backing_off(): bool {
620 return false !== get_transient( self::BACKOFF_TRANSIENT );
621 }
622
623 /** Suppress delivery for a short window after a failed send. */
624 private function start_backoff(): void {
625 set_transient( self::BACKOFF_TRANSIENT, 1, self::BACKOFF_TTL );
626 }
627
628 /** Get tags attached to every event. */
629 private function get_default_tags(): array {
630 return array(
631 'wp_version' => (string) get_bloginfo( 'version' ),
632 'wc_version' => \defined( 'WC_VERSION' ) ? (string) WC_VERSION : 'unknown',
633 'php_version' => (string) PHP_VERSION,
634 'pro_version' => \defined( '\WCPOS\WooCommercePOSPro\VERSION' ) ? (string) \constant( '\WCPOS\WooCommercePOSPro\VERSION' ) : 'none',
635 'multisite' => is_multisite() ? 'yes' : 'no',
636 );
637 }
638
639 /** Whether this is a development site where reporting stays disabled. */
640 private function is_dev_environment(): bool {
641 if ( null !== self::$dev_override ) {
642 return self::$dev_override;
643 }
644
645 return ( \defined( 'WP_DEBUG' ) && WP_DEBUG ) || \defined( 'WCPOS_DEV' );
646 }
647 }
648