PluginProbe
Visualizer – Tables & Charts Manager with Built-in AI Generator / 4.0.8
Visualizer – Tables & Charts Manager with Built-in AI Generator v4.0.8
4.0.8 4.0.7 4.0.6 4.0.5 4.0.4 4.0.3 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.10.1 3.10.10 3.10.11 3.10.12 3.10.13 3.10.14 3.10.15 3.10.2 3.10.3 All 149 releases
visualizer / vendor / codeinwp / themeisle-sdk / src / Modules / Crash_reporter.php

Crash_reporter.php in Visualizer – Tables & Charts Manager with Built-in AI Generator 4.0.8, at vendor/codeinwp/themeisle-sdk/src/Modules/Crash_reporter.php

964 lines 27.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * The crash reporter model class for ThemeIsle SDK.
4 *
5 * Captures fatal errors and uncaught exceptions originating from registered
6 * ThemeIsle products, stores sanitized aggregates locally and sends them to
7 * the tracking endpoint when the logging consent is granted.
8 *
9 * @package ThemeIsleSDK
10 * @subpackage Modules
11 * @copyright Copyright (c) 2026, Marius Cristea
12 * @license http://opensource.org/licenses/gpl-3.0.php GNU Public License
13 * @since 3.4.0
14 */
15
16 namespace ThemeisleSDK\Modules;
17
18 use ThemeisleSDK\Common\Abstract_Module;
19 use ThemeisleSDK\Product;
20
21 // Exit if accessed directly.
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 /**
27 * Crash reporter module for ThemeIsle SDK.
28 *
29 * A single set of PHP handlers serves every registered product: crashes are
30 * attributed to a product by path-prefix matching the crashing file against
31 * the registered product directories. Crashes that do not originate from a
32 * registered product directory are dropped and never stored.
33 */
34 class Crash_Reporter extends Abstract_Module {
35
36 /**
37 * Endpoint where crash reports are sent.
38 */
39 const CRASH_ENDPOINT = 'https://api.themeisle.com/tracking/crashes';
40
41 /**
42 * Maximum distinct crash fingerprints stored per product.
43 */
44 const MAX_FINGERPRINTS = 15;
45
46 /**
47 * Maximum stored message length. Large enough to preserve the stack trace
48 * text PHP embeds inside uncaught-exception fatal messages.
49 */
50 const MAX_MESSAGE_LENGTH = 2000;
51
52 /**
53 * Maximum serialized size of the stored reports, in bytes.
54 */
55 const MAX_STORED_BYTES = 16000;
56
57 /**
58 * Message length used for the compact uninstall summary.
59 */
60 const SUMMARY_MESSAGE_LENGTH = 200;
61
62 /**
63 * Maximum number of reports included in the compact uninstall summary.
64 */
65 const SUMMARY_MAX_REPORTS = 5;
66
67 /**
68 * Send backoff window after a failed delivery, in seconds (12 hours).
69 */
70 const BACKOFF_SECONDS = 43200;
71
72 /**
73 * Registered product directories, normalized dir => Product.
74 *
75 * @var array<string, Product>
76 */
77 private static $registry = [];
78
79 /**
80 * Whether the PHP handlers have been installed for this request.
81 *
82 * @var bool
83 */
84 private static $handlers_registered = false;
85
86 /**
87 * Memory buffer released at shutdown so the handler can run on OOM fatals.
88 *
89 * @var string|null
90 */
91 private static $reserved_memory = null;
92
93 /**
94 * Previously registered exception handler, chained after ours.
95 *
96 * @var callable|null
97 */
98 private static $previous_exception_handler = null;
99
100 /**
101 * Whether an uncaught exception was already captured by the exception
102 * handler, so the shutdown handler does not record it twice.
103 *
104 * @var bool
105 */
106 private static $exception_captured = false;
107
108 /**
109 * Fatal error types captured at shutdown.
110 *
111 * @var int[]
112 */
113 private static $fatal_types = [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR ];
114
115 /**
116 * Should we load this module for the product?
117 *
118 * @param Product $product Product to check.
119 *
120 * @return bool Should load?
121 */
122 public function can_load( $product ) {
123 return apply_filters( $product->get_slug() . '_sdk_enable_crash_reporter', true );
124 }
125
126 /**
127 * Bootstrap the module for a product.
128 *
129 * @param Product $product Product to load the module for.
130 *
131 * @return Crash_Reporter Module instance.
132 */
133 public function load( $product ) {
134 $this->product = $product;
135
136 self::register_product( $product );
137 self::register_handlers();
138
139 $key = $product->get_key();
140
141 add_action( $key . '_crash_flush', [ $this, 'send_reports' ] );
142
143 $update_action = 'themeisle_sdk_update_' . $product->get_slug();
144 add_action( $update_action, [ $this, 'on_product_update' ] );
145 if ( did_action( $update_action ) ) {
146 $this->on_product_update();
147 }
148
149 $this->adopt_sentinel_records();
150 $this->maybe_schedule_flush();
151
152 return $this;
153 }
154
155 /**
156 * Register a product directory for crash attribution.
157 *
158 * @param Product $product Product to register.
159 */
160 private static function register_product( $product ) {
161 $dir = self::normalize_path( dirname( $product->get_basefile() ) );
162 if ( '' === $dir ) {
163 return;
164 }
165 self::$registry[ rtrim( $dir, '/' ) . '/' ] = $product;
166 }
167
168 /**
169 * Install the PHP handlers, once per request, regardless of how many
170 * products are registered.
171 */
172 private static function register_handlers() {
173 if ( self::$handlers_registered ) {
174 return;
175 }
176 self::$handlers_registered = true;
177
178 if ( ! defined( 'THEMEISLE_SDK_CRASH_HANDLER' ) ) {
179 define( 'THEMEISLE_SDK_CRASH_HANDLER', true );
180 }
181
182 self::$reserved_memory = str_repeat( ' ', 16384 );
183 self::$previous_exception_handler = set_exception_handler( [ __CLASS__, 'handle_exception' ] );
184 register_shutdown_function( [ __CLASS__, 'handle_shutdown' ] );
185
186 add_filter( 'debug_information', [ __CLASS__, 'add_debug_information' ] );
187 }
188
189 /**
190 * Global handler for uncaught exceptions. Captures the crash and chains to
191 * the previously registered handler, if any.
192 *
193 * @param \Throwable $throwable Uncaught exception.
194 */
195 public static function handle_exception( $throwable ) {
196 try {
197 self::$exception_captured = true;
198 self::capture( self::normalize_throwable( $throwable ) );
199 } catch ( \Exception $e ) {
200 self::obs( 'exception_handler_failed' );
201 } catch ( \Throwable $e ) {
202 self::obs( 'exception_handler_failed' );
203 }
204
205 if ( null !== self::$previous_exception_handler && is_callable( self::$previous_exception_handler ) ) {
206 call_user_func( self::$previous_exception_handler, $throwable );
207 }
208 }
209
210 /**
211 * Shutdown handler. Records supported fatal errors originating from a
212 * registered product directory. Never outputs and never exits.
213 */
214 public static function handle_shutdown() {
215 self::$reserved_memory = null;
216
217 $error = error_get_last();
218 if ( empty( $error ) || ! isset( $error['type'] ) || ! in_array( (int) $error['type'], self::$fatal_types, true ) ) {
219 return;
220 }
221 if ( self::$exception_captured ) {
222 return;
223 }
224
225 try {
226 self::capture(
227 [
228 'type' => (int) $error['type'],
229 'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
230 'file' => isset( $error['file'] ) ? (string) $error['file'] : '',
231 'line' => isset( $error['line'] ) ? (int) $error['line'] : 0,
232 'trace' => [],
233 ]
234 );
235 } catch ( \Exception $e ) {
236 self::obs( 'shutdown_handler_failed' );
237 } catch ( \Throwable $e ) {
238 self::obs( 'shutdown_handler_failed' );
239 }
240 }
241
242 /**
243 * Capture pipeline: attribute, sanitize, fingerprint and store one error.
244 *
245 * Public and parameter-driven so tests can inject synthetic errors.
246 *
247 * @param array $error Error data: type, message, file, line, trace.
248 *
249 * @return bool Whether the error was stored.
250 */
251 public static function capture( $error ) {
252 if ( defined( 'WP_SANDBOX_SCRAPING' ) && WP_SANDBOX_SCRAPING ) {
253 return false;
254 }
255
256 $file = self::normalize_path( isset( $error['file'] ) ? $error['file'] : '' );
257 $owner = self::find_owner( $file );
258 if ( null === $owner ) {
259 self::obs( 'no_attribution' );
260
261 return false;
262 }
263
264 $message = self::sanitize_text( isset( $error['message'] ) ? $error['message'] : '', self::MAX_MESSAGE_LENGTH );
265 $line = isset( $error['line'] ) ? (int) $error['line'] : 0;
266 $type = isset( $error['type'] ) ? (int) $error['type'] : E_ERROR;
267 $trace = [];
268 if ( ! empty( $error['trace'] ) && is_array( $error['trace'] ) ) {
269 foreach ( $error['trace'] as $frame ) {
270 $trace[] = self::sanitize_frame( $frame );
271 }
272 }
273
274 $sanitized_file = self::classify_path( $file );
275 $report = [
276 'type' => $type,
277 'event_type' => empty( $error['is_exception'] ) ? 'fatal_error' : 'uncaught_exception',
278 'message' => $message,
279 'file' => $sanitized_file,
280 'line' => $line,
281 'trace' => $trace,
282 'in_sdk' => 0 === strpos( $sanitized_file, 'sdk:' ),
283 'request_context' => self::request_context(),
284 'product_version' => $owner->get_version(),
285 'sdk_version' => self::get_sdk_version(),
286 ];
287
288 return self::store_report( $owner, $report );
289 }
290
291 /**
292 * Store a sanitized report into the product crash option, aggregating by
293 * fingerprint.
294 *
295 * @param Product $product Owning product.
296 * @param array $report Sanitized report.
297 *
298 * @return bool Whether the report was persisted.
299 */
300 private static function store_report( $product, $report ) {
301 $key = $product->get_key();
302 $data = self::read_data( $key );
303
304 $fingerprint = self::fingerprint( $report );
305 $now = time();
306
307 if ( isset( $data['reports'][ $fingerprint ] ) ) {
308 $data['reports'][ $fingerprint ]['count'] = (int) $data['reports'][ $fingerprint ]['count'] + 1;
309 $data['reports'][ $fingerprint ]['last_seen'] = $now;
310 } else {
311 if ( count( $data['reports'] ) >= self::MAX_FINGERPRINTS ) {
312 $data['reports'] = self::evict_lowest( $data['reports'] );
313 }
314 $report['fingerprint'] = $fingerprint;
315 $report['count'] = 1;
316 $report['first_seen'] = $now;
317 $report['last_seen'] = $now;
318 if ( ! empty( $data['meta']['last_update'] ) ) {
319 $report['time_since_update'] = $now - (int) $data['meta']['last_update'];
320 }
321 $data['reports'][ $fingerprint ] = $report;
322 }
323
324 $data['reports'] = self::enforce_size_cap( $data['reports'] );
325
326 $stored = self::write_data( $key, $data );
327 if ( $stored ) {
328 self::schedule_flush( $product );
329 }
330
331 return $stored;
332 }
333
334 /**
335 * Evict the report with the lowest count, oldest last-seen on ties.
336 *
337 * @param array $reports Stored reports keyed by fingerprint.
338 *
339 * @return array Reports with one entry removed.
340 */
341 private static function evict_lowest( $reports ) {
342 $evict_key = null;
343 foreach ( $reports as $fingerprint => $report ) {
344 if ( null === $evict_key ) {
345 $evict_key = $fingerprint;
346 continue;
347 }
348 $candidate = $reports[ $evict_key ];
349 if ( $report['count'] < $candidate['count'] || ( $report['count'] === $candidate['count'] && $report['last_seen'] < $candidate['last_seen'] ) ) {
350 $evict_key = $fingerprint;
351 }
352 }
353 if ( null !== $evict_key ) {
354 unset( $reports[ $evict_key ] );
355 self::obs( 'cap_evicted' );
356 }
357
358 return $reports;
359 }
360
361 /**
362 * Keep the serialized reports under the size cap: first trim traces and
363 * messages, then drop the lowest-value reports.
364 *
365 * @param array $reports Stored reports keyed by fingerprint.
366 *
367 * @return array Reports fitting the size cap.
368 */
369 private static function enforce_size_cap( $reports ) {
370 if ( strlen( (string) wp_json_encode( $reports ) ) <= self::MAX_STORED_BYTES ) {
371 return $reports;
372 }
373
374 foreach ( $reports as $fingerprint => $report ) {
375 if ( ! empty( $report['trace'] ) && count( $report['trace'] ) > 5 ) {
376 $trimmed_marker = [
377 'file' => '[trimmed]',
378 'line' => 0,
379 'function' => '',
380 ];
381 $reports[ $fingerprint ]['trace'] = array_merge(
382 array_slice( $report['trace'], 0, 3 ),
383 [ $trimmed_marker ],
384 array_slice( $report['trace'], - 2 )
385 );
386 }
387 $reports[ $fingerprint ]['message'] = substr( (string) $report['message'], 0, 500 );
388 }
389 self::obs( 'payload_trimmed' );
390
391 $total = count( $reports );
392 $size = strlen( (string) wp_json_encode( $reports ) );
393 while ( $total > 1 && $size > self::MAX_STORED_BYTES ) {
394 $reports = self::evict_lowest( $reports );
395 $total = count( $reports );
396 $size = strlen( (string) wp_json_encode( $reports ) );
397 }
398
399 return $reports;
400 }
401
402 /**
403 * Build the dedup fingerprint of a report.
404 *
405 * Numbers are normalized out of the message component so variable parts
406 * (memory sizes, ids) do not break aggregation.
407 *
408 * @param array $report Sanitized report.
409 *
410 * @return string Fingerprint hash.
411 */
412 private static function fingerprint( $report ) {
413 $message_part = preg_replace( '/\d+/', 'N', substr( (string) $report['message'], 0, 200 ) );
414
415 return md5(
416 implode(
417 '|',
418 [
419 $report['type'],
420 $message_part,
421 $report['file'],
422 $report['line'],
423 $report['product_version'],
424 ]
425 )
426 );
427 }
428
429 /**
430 * Find the registered product owning a file path, longest prefix wins.
431 *
432 * @param string $file Normalized absolute file path.
433 *
434 * @return Product|null Owning product or null when the file is not ours.
435 */
436 private static function find_owner( $file ) {
437 if ( '' === $file ) {
438 return null;
439 }
440 $owner = null;
441 $owner_size = 0;
442 foreach ( self::$registry as $dir => $product ) {
443 if ( 0 === strpos( $file, $dir ) && strlen( $dir ) > $owner_size ) {
444 $owner = $product;
445 $owner_size = strlen( $dir );
446 }
447 }
448
449 return $owner;
450 }
451
452 /**
453 * Rewrite an absolute path into a privacy-safe classified path.
454 *
455 * @param string $path Absolute path.
456 *
457 * @return string Classified path, e.g. `product:inc/file.php`.
458 */
459 private static function classify_path( $path ) {
460 $path = self::normalize_path( $path );
461 if ( '' === $path ) {
462 return '';
463 }
464
465 // The longest matching root wins, so the most specific classification
466 // applies regardless of how the roots are nested into each other
467 // (in production the SDK lives inside a product directory).
468 $roots = [
469 [ rtrim( self::normalize_path( dirname( dirname( __DIR__ ) ) ), '/' ) . '/', 'sdk:' ],
470 ];
471 foreach ( self::$registry as $dir => $product ) {
472 $roots[] = [ $dir, 'product:' ];
473 }
474 if ( defined( 'WP_PLUGIN_DIR' ) ) {
475 $roots[] = [ rtrim( self::normalize_path( WP_PLUGIN_DIR ), '/' ) . '/', 'plugin:' ];
476 }
477 if ( function_exists( 'get_theme_root' ) ) {
478 $roots[] = [ rtrim( self::normalize_path( get_theme_root() ), '/' ) . '/', 'theme:' ];
479 }
480 if ( defined( 'ABSPATH' ) ) {
481 $roots[] = [ rtrim( self::normalize_path( ABSPATH ), '/' ) . '/', 'wp:' ];
482 }
483
484 $best_root = '';
485 $best_label = null;
486 foreach ( $roots as $root ) {
487 if ( strlen( $root[0] ) > strlen( $best_root ) && 0 === strpos( $path, $root[0] ) ) {
488 $best_root = $root[0];
489 $best_label = $root[1];
490 }
491 }
492 if ( null !== $best_label ) {
493 return $best_label . substr( $path, strlen( $best_root ) );
494 }
495
496 return basename( $path );
497 }
498
499 /**
500 * Sanitize a single trace frame: classified path, line and callable name,
501 * arguments always dropped.
502 *
503 * @param array $frame Raw trace frame.
504 *
505 * @return array Sanitized frame.
506 */
507 private static function sanitize_frame( $frame ) {
508 $function = isset( $frame['function'] ) ? (string) $frame['function'] : '';
509 if ( isset( $frame['class'] ) ) {
510 $function = $frame['class'] . ( isset( $frame['type'] ) ? $frame['type'] : '::' ) . $function;
511 }
512
513 return [
514 'file' => isset( $frame['file'] ) ? self::classify_path( $frame['file'] ) : '[internal]',
515 'line' => isset( $frame['line'] ) ? (int) $frame['line'] : 0,
516 'function' => $function,
517 ];
518 }
519
520 /**
521 * Redact secrets, personal data and server paths from free text.
522 *
523 * @param string $text Text to sanitize.
524 * @param int $max_length Maximum length to keep.
525 *
526 * @return string Sanitized text.
527 */
528 private static function sanitize_text( $text, $max_length ) {
529 $text = (string) $text;
530
531 // Rewrite known roots first so embedded stack traces stay readable.
532 $sdk_root = rtrim( self::normalize_path( dirname( dirname( __DIR__ ) ) ), '/' ) . '/';
533 $text = str_replace( [ $sdk_root, str_replace( '/', '\\', $sdk_root ) ], 'sdk:/', $text );
534 foreach ( self::$registry as $dir => $product ) {
535 $text = str_replace( [ $dir, str_replace( '/', '\\', $dir ) ], 'product:/', $text );
536 }
537 if ( defined( 'ABSPATH' ) ) {
538 $abspath = rtrim( self::normalize_path( ABSPATH ), '/' ) . '/';
539 $text = str_replace( [ $abspath, str_replace( '/', '\\', $abspath ) ], 'wp:/', $text );
540 }
541
542 $replacements = [
543 // Emails.
544 '/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/' => '[email]',
545 // Bearer tokens.
546 '/Bearer\s+[A-Za-z0-9\-._~+\/=]{8,}/i' => 'Bearer [token]',
547 // JWT-looking tokens.
548 '/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}/' => '[token]',
549 // Long hex strings (hashes, keys).
550 '/\b[a-fA-F0-9]{32,}\b/' => '[token]',
551 // Long base64-looking strings.
552 '/\b[A-Za-z0-9+\/=]{40,}\b/' => '[token]',
553 // Remaining absolute paths, keep the basename for readability.
554 '~(?:[A-Za-z]:)?[/\\\\](?:[^\s\'"():*?]+[/\\\\])+([^\s\'"():*?/\\\\]+)~' => '.../$1',
555 ];
556 $text = (string) preg_replace( array_keys( $replacements ), array_values( $replacements ), $text );
557
558 return substr( $text, 0, $max_length );
559 }
560
561 /**
562 * Normalize a path to forward slashes.
563 *
564 * @param string $path Path to normalize.
565 *
566 * @return string Normalized path.
567 */
568 private static function normalize_path( $path ) {
569 $path = (string) $path;
570 if ( function_exists( 'wp_normalize_path' ) ) {
571 return wp_normalize_path( $path );
572 }
573
574 return str_replace( '\\', '/', $path );
575 }
576
577 /**
578 * Normalize a throwable into the capture error shape.
579 *
580 * @param \Throwable $throwable Uncaught throwable.
581 *
582 * @return array Error data.
583 */
584 private static function normalize_throwable( $throwable ) {
585 $trace = $throwable->getTrace();
586
587 $previous = $throwable->getPrevious();
588 $depth = 0;
589 while ( null !== $previous && $depth < 2 ) {
590 $trace[] = [
591 'file' => $previous->getFile(),
592 'line' => $previous->getLine(),
593 'function' => '[caused by] ' . get_class( $previous ),
594 ];
595 $previous = $previous->getPrevious();
596 $depth ++;
597 }
598
599 return [
600 'type' => ( $throwable instanceof \ParseError ) ? E_PARSE : E_ERROR,
601 'message' => get_class( $throwable ) . ': ' . $throwable->getMessage(),
602 'file' => $throwable->getFile(),
603 'line' => $throwable->getLine(),
604 'trace' => $trace,
605 'is_exception' => true,
606 ];
607 }
608
609 /**
610 * Detect the request context the crash happened in.
611 *
612 * @return string One of cli, cron, ajax, rest, admin, frontend.
613 */
614 private static function request_context() {
615 if ( defined( 'WP_CLI' ) && WP_CLI ) {
616 return 'cli';
617 }
618 if ( function_exists( 'wp_doing_cron' ) ? wp_doing_cron() : ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
619 return 'cron';
620 }
621 if ( function_exists( 'wp_doing_ajax' ) ? wp_doing_ajax() : ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) {
622 return 'ajax';
623 }
624 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
625 return 'rest';
626 }
627 if ( function_exists( 'is_admin' ) && is_admin() ) {
628 return 'admin';
629 }
630
631 return 'frontend';
632 }
633
634 /**
635 * Read the crash data option of a product.
636 *
637 * @param string $key Product key.
638 *
639 * @return array Crash data with reports and meta keys.
640 */
641 private static function read_data( $key ) {
642 $data = get_option( $key . '_crash_data', [] );
643 if ( ! is_array( $data ) ) {
644 $data = [];
645 }
646 if ( ! isset( $data['reports'] ) || ! is_array( $data['reports'] ) ) {
647 $data['reports'] = [];
648 }
649 if ( ! isset( $data['meta'] ) || ! is_array( $data['meta'] ) ) {
650 $data['meta'] = [];
651 }
652
653 return $data;
654 }
655
656 /**
657 * Persist the crash data option of a product, never autoloaded.
658 *
659 * @param string $key Product key.
660 * @param array $data Crash data.
661 *
662 * @return bool Whether the write did not fail.
663 */
664 private static function write_data( $key, $data ) {
665 try {
666 $option = $key . '_crash_data';
667 if ( false === get_option( $option, false ) ) {
668 return add_option( $option, $data, '', 'no' );
669 }
670 update_option( $option, $data );
671
672 return true;
673 } catch ( \Exception $e ) {
674 self::obs( 'store_failed' );
675 } catch ( \Throwable $e ) {
676 self::obs( 'store_failed' );
677 }
678
679 return false;
680 }
681
682 /**
683 * Schedule the flush event for a product when consent is granted and no
684 * event or backoff is pending. Randomized 1-6h jitter avoids synchronized
685 * fleet-wide bursts.
686 *
687 * @param Product $product Product to schedule for.
688 */
689 private static function schedule_flush( $product ) {
690 if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) {
691 return;
692 }
693 if ( ! self::is_consent_given( $product ) ) {
694 return;
695 }
696 $key = $product->get_key();
697 if ( false !== get_transient( $key . '_crash_backoff' ) ) {
698 self::obs( 'backoff' );
699
700 return;
701 }
702 $action_key = $key . '_crash_flush';
703 if ( ! wp_next_scheduled( $action_key ) ) {
704 wp_schedule_single_event( time() + ( wp_rand( 1, 6 ) * HOUR_IN_SECONDS ), $action_key );
705 }
706 }
707
708 /**
709 * Check the logging consent for a product, using the Logger semantics.
710 *
711 * @param Product $product Product to check.
712 *
713 * @return bool Consent granted?
714 */
715 private static function is_consent_given( $product ) {
716 if ( ! class_exists( 'ThemeisleSDK\Modules\Logger' ) ) {
717 return false;
718 }
719
720 return Logger::is_logging_active( $product );
721 }
722
723 /**
724 * Re-arm the flush schedule on normal loads when reports are pending.
725 */
726 private function maybe_schedule_flush() {
727 $data = self::read_data( $this->product->get_key() );
728 if ( empty( $data['reports'] ) ) {
729 return;
730 }
731 self::schedule_flush( $this->product );
732 }
733
734 /**
735 * Send the stored reports of the product to the crash endpoint. Clears the
736 * store on success, sets a backoff window on failure.
737 */
738 public function send_reports() {
739 $key = $this->product->get_key();
740 $data = self::read_data( $key );
741 if ( empty( $data['reports'] ) ) {
742 return;
743 }
744 if ( ! self::is_consent_given( $this->product ) ) {
745 return;
746 }
747 if ( false !== get_transient( $key . '_crash_backoff' ) ) {
748 return;
749 }
750
751 global $wp_version;
752 $body = apply_filters(
753 'themeisle_sdk_crash_report_data',
754 [
755 'site' => get_site_url(),
756 'slug' => $this->product->get_slug(),
757 'version' => $this->product->get_version(),
758 'wp_version' => $wp_version,
759 'php_version' => PHP_VERSION,
760 'sdk_version' => self::get_sdk_version(),
761 'locale' => get_locale(),
762 'license' => apply_filters( $key . '_license_status', '' ),
763 'reports' => wp_json_encode( array_values( $data['reports'] ) ),
764 ],
765 $this->product
766 );
767
768 $response = wp_remote_post(
769 self::CRASH_ENDPOINT,
770 [
771 'timeout' => 3, //phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
772 'body' => $body,
773 ]
774 );
775
776 $code = (int) wp_remote_retrieve_response_code( $response );
777 if ( ! is_wp_error( $response ) && $code >= 200 && $code < 300 ) {
778 $data['reports'] = [];
779 self::write_data( $key, $data );
780
781 return;
782 }
783
784 set_transient( $key . '_crash_backoff', true, self::BACKOFF_SECONDS );
785 self::obs( 'send_failed' );
786 }
787
788 /**
789 * Product version change handler: record the update time and lift the send
790 * backoff so a fixed release reports its health immediately.
791 */
792 public function on_product_update() {
793 $key = $this->product->get_key();
794 delete_transient( $key . '_crash_backoff' );
795
796 $data = self::read_data( $key );
797 $data['meta']['last_update'] = time();
798 self::write_data( $key, $data );
799 }
800
801 /**
802 * Adopt raw records left by the load.php early sentinel: run them through
803 * the full sanitize/fingerprint pipeline and drop the raw entries.
804 */
805 private function adopt_sentinel_records() {
806 $key = $this->product->get_key();
807 $data = self::read_data( $key );
808 if ( empty( $data['raw'] ) || ! is_array( $data['raw'] ) ) {
809 return;
810 }
811
812 $raw = $data['raw'];
813 unset( $data['raw'] );
814 self::write_data( $key, $data );
815
816 foreach ( $raw as $record ) {
817 if ( ! is_array( $record ) ) {
818 continue;
819 }
820 self::capture(
821 [
822 'type' => isset( $record['type'] ) ? (int) $record['type'] : E_ERROR,
823 'message' => isset( $record['message'] ) ? (string) $record['message'] : '',
824 'file' => isset( $record['file'] ) ? (string) $record['file'] : '',
825 'line' => isset( $record['line'] ) ? (int) $record['line'] : 0,
826 'trace' => [],
827 ]
828 );
829 }
830 }
831
832 /**
833 * Compact crash summary attached to the uninstall feedback call:
834 * top reports by count, short messages, product-only frames.
835 *
836 * @param Product $product Product to summarize.
837 *
838 * @return array Compact summary, empty when there are no reports.
839 */
840 public static function get_uninstall_summary( $product ) {
841 $data = self::read_data( $product->get_key() );
842 if ( empty( $data['reports'] ) ) {
843 return [];
844 }
845
846 $reports = array_values( $data['reports'] );
847 usort(
848 $reports,
849 function ( $a, $b ) {
850 return (int) $b['count'] - (int) $a['count'];
851 }
852 );
853 $reports = array_slice( $reports, 0, self::SUMMARY_MAX_REPORTS );
854
855 $summary = [];
856 foreach ( $reports as $report ) {
857 $frames = [];
858 foreach ( (array) $report['trace'] as $frame ) {
859 if ( isset( $frame['file'] ) && ( 0 === strpos( (string) $frame['file'], 'product:' ) || 0 === strpos( (string) $frame['file'], 'sdk:' ) ) ) {
860 $frames[] = $frame;
861 }
862 }
863 $summary[] = [
864 'fingerprint' => isset( $report['fingerprint'] ) ? $report['fingerprint'] : '',
865 'type' => $report['type'],
866 'event_type' => isset( $report['event_type'] ) ? $report['event_type'] : 'fatal_error',
867 'message' => substr( (string) $report['message'], 0, self::SUMMARY_MESSAGE_LENGTH ),
868 'file' => $report['file'],
869 'line' => $report['line'],
870 'trace' => $frames,
871 'count' => $report['count'],
872 'first_seen' => $report['first_seen'],
873 'last_seen' => $report['last_seen'],
874 'product_version' => $report['product_version'],
875 ];
876 }
877
878 return $summary;
879 }
880
881 /**
882 * Short-form Site Health section: one row per product having stored
883 * crashes, no messages and no traces to keep the page lean.
884 *
885 * @param array $info Debug information sections.
886 *
887 * @return array Debug information sections.
888 */
889 public static function add_debug_information( $info ) {
890 $fields = [];
891 foreach ( self::$registry as $product ) {
892 $data = self::read_data( $product->get_key() );
893 if ( empty( $data['reports'] ) ) {
894 continue;
895 }
896 $total = 0;
897 $top = null;
898 $last = 0;
899 foreach ( $data['reports'] as $report ) {
900 $total += (int) $report['count'];
901 $last = max( $last, (int) $report['last_seen'] );
902 if ( null === $top || $report['count'] > $top['count'] ) {
903 $top = $report;
904 }
905 }
906 $fields[ $product->get_key() ] = [
907 'label' => $product->get_friendly_name(),
908 'value' => sprintf(
909 '%d distinct / %d total, last on %s, top: type %d @ %s:%d ×%d',
910 count( $data['reports'] ),
911 $total,
912 gmdate( 'Y-m-d H:i', $last ),
913 $top['type'],
914 $top['file'],
915 $top['line'],
916 $top['count']
917 ),
918 ];
919 }
920
921 if ( empty( $fields ) ) {
922 return $info;
923 }
924
925 $info['themeisle-sdk-crash-reports'] = [
926 'label' => 'ThemeIsle SDK Crash Reports',
927 'description' => 'Locally stored crash summaries for ThemeIsle products. Full detail lives in the per-product crash data option.',
928 'fields' => $fields,
929 ];
930
931 return $info;
932 }
933
934 /**
935 * Current SDK version, from the loader globals.
936 *
937 * @return string SDK version.
938 */
939 private static function get_sdk_version() {
940 global $themeisle_sdk_max_version;
941
942 return empty( $themeisle_sdk_max_version ) ? '' : (string) $themeisle_sdk_max_version;
943 }
944
945 /**
946 * Observability breadcrumb for debugging the reporter itself in the field.
947 *
948 * @param string $reason Drop or failure reason.
949 */
950 private static function obs( $reason ) {
951 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
952 error_log( '[TISDK_CRASH] ' . $reason ); //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
953 }
954 }
955
956 /**
957 * Reset the static state. Test helper only.
958 */
959 public static function reset() {
960 self::$registry = [];
961 self::$exception_captured = false;
962 }
963 }
964