Product. * * @var array */ private static $registry = []; /** * Whether the PHP handlers have been installed for this request. * * @var bool */ private static $handlers_registered = false; /** * Memory buffer released at shutdown so the handler can run on OOM fatals. * * @var string|null */ private static $reserved_memory = null; /** * Previously registered exception handler, chained after ours. * * @var callable|null */ private static $previous_exception_handler = null; /** * Whether an uncaught exception was already captured by the exception * handler, so the shutdown handler does not record it twice. * * @var bool */ private static $exception_captured = false; /** * Fatal error types captured at shutdown. * * @var int[] */ private static $fatal_types = [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR ]; /** * Should we load this module for the product? * * @param Product $product Product to check. * * @return bool Should load? */ public function can_load( $product ) { return apply_filters( $product->get_slug() . '_sdk_enable_crash_reporter', true ); } /** * Bootstrap the module for a product. * * @param Product $product Product to load the module for. * * @return Crash_Reporter Module instance. */ public function load( $product ) { $this->product = $product; self::register_product( $product ); self::register_handlers(); $key = $product->get_key(); add_action( $key . '_crash_flush', [ $this, 'send_reports' ] ); $update_action = 'themeisle_sdk_update_' . $product->get_slug(); add_action( $update_action, [ $this, 'on_product_update' ] ); if ( did_action( $update_action ) ) { $this->on_product_update(); } $this->adopt_sentinel_records(); $this->maybe_schedule_flush(); return $this; } /** * Register a product directory for crash attribution. * * @param Product $product Product to register. */ private static function register_product( $product ) { $dir = self::normalize_path( dirname( $product->get_basefile() ) ); if ( '' === $dir ) { return; } self::$registry[ rtrim( $dir, '/' ) . '/' ] = $product; } /** * Install the PHP handlers, once per request, regardless of how many * products are registered. */ private static function register_handlers() { if ( self::$handlers_registered ) { return; } self::$handlers_registered = true; if ( ! defined( 'THEMEISLE_SDK_CRASH_HANDLER' ) ) { define( 'THEMEISLE_SDK_CRASH_HANDLER', true ); } self::$reserved_memory = str_repeat( ' ', 16384 ); self::$previous_exception_handler = set_exception_handler( [ __CLASS__, 'handle_exception' ] ); register_shutdown_function( [ __CLASS__, 'handle_shutdown' ] ); add_filter( 'debug_information', [ __CLASS__, 'add_debug_information' ] ); } /** * Global handler for uncaught exceptions. Captures the crash and chains to * the previously registered handler, if any. * * @param \Throwable $throwable Uncaught exception. */ public static function handle_exception( $throwable ) { try { self::$exception_captured = true; self::capture( self::normalize_throwable( $throwable ) ); } catch ( \Exception $e ) { self::obs( 'exception_handler_failed' ); } catch ( \Throwable $e ) { self::obs( 'exception_handler_failed' ); } if ( null !== self::$previous_exception_handler && is_callable( self::$previous_exception_handler ) ) { call_user_func( self::$previous_exception_handler, $throwable ); } } /** * Shutdown handler. Records supported fatal errors originating from a * registered product directory. Never outputs and never exits. */ public static function handle_shutdown() { self::$reserved_memory = null; $error = error_get_last(); if ( empty( $error ) || ! isset( $error['type'] ) || ! in_array( (int) $error['type'], self::$fatal_types, true ) ) { return; } if ( self::$exception_captured ) { return; } try { self::capture( [ 'type' => (int) $error['type'], 'message' => isset( $error['message'] ) ? (string) $error['message'] : '', 'file' => isset( $error['file'] ) ? (string) $error['file'] : '', 'line' => isset( $error['line'] ) ? (int) $error['line'] : 0, 'trace' => [], ] ); } catch ( \Exception $e ) { self::obs( 'shutdown_handler_failed' ); } catch ( \Throwable $e ) { self::obs( 'shutdown_handler_failed' ); } } /** * Capture pipeline: attribute, sanitize, fingerprint and store one error. * * Public and parameter-driven so tests can inject synthetic errors. * * @param array $error Error data: type, message, file, line, trace. * * @return bool Whether the error was stored. */ public static function capture( $error ) { if ( defined( 'WP_SANDBOX_SCRAPING' ) && WP_SANDBOX_SCRAPING ) { return false; } $file = self::normalize_path( isset( $error['file'] ) ? $error['file'] : '' ); $owner = self::find_owner( $file ); if ( null === $owner ) { self::obs( 'no_attribution' ); return false; } $message = self::sanitize_text( isset( $error['message'] ) ? $error['message'] : '', self::MAX_MESSAGE_LENGTH ); $line = isset( $error['line'] ) ? (int) $error['line'] : 0; $type = isset( $error['type'] ) ? (int) $error['type'] : E_ERROR; $trace = []; if ( ! empty( $error['trace'] ) && is_array( $error['trace'] ) ) { foreach ( $error['trace'] as $frame ) { $trace[] = self::sanitize_frame( $frame ); } } $sanitized_file = self::classify_path( $file ); $report = [ 'type' => $type, 'event_type' => empty( $error['is_exception'] ) ? 'fatal_error' : 'uncaught_exception', 'message' => $message, 'file' => $sanitized_file, 'line' => $line, 'trace' => $trace, 'in_sdk' => 0 === strpos( $sanitized_file, 'sdk:' ), 'request_context' => self::request_context(), 'product_version' => $owner->get_version(), 'sdk_version' => self::get_sdk_version(), ]; return self::store_report( $owner, $report ); } /** * Store a sanitized report into the product crash option, aggregating by * fingerprint. * * @param Product $product Owning product. * @param array $report Sanitized report. * * @return bool Whether the report was persisted. */ private static function store_report( $product, $report ) { $key = $product->get_key(); $data = self::read_data( $key ); $fingerprint = self::fingerprint( $report ); $now = time(); if ( isset( $data['reports'][ $fingerprint ] ) ) { $data['reports'][ $fingerprint ]['count'] = (int) $data['reports'][ $fingerprint ]['count'] + 1; $data['reports'][ $fingerprint ]['last_seen'] = $now; } else { if ( count( $data['reports'] ) >= self::MAX_FINGERPRINTS ) { $data['reports'] = self::evict_lowest( $data['reports'] ); } $report['fingerprint'] = $fingerprint; $report['count'] = 1; $report['first_seen'] = $now; $report['last_seen'] = $now; if ( ! empty( $data['meta']['last_update'] ) ) { $report['time_since_update'] = $now - (int) $data['meta']['last_update']; } $data['reports'][ $fingerprint ] = $report; } $data['reports'] = self::enforce_size_cap( $data['reports'] ); $stored = self::write_data( $key, $data ); if ( $stored ) { self::schedule_flush( $product ); } return $stored; } /** * Evict the report with the lowest count, oldest last-seen on ties. * * @param array $reports Stored reports keyed by fingerprint. * * @return array Reports with one entry removed. */ private static function evict_lowest( $reports ) { $evict_key = null; foreach ( $reports as $fingerprint => $report ) { if ( null === $evict_key ) { $evict_key = $fingerprint; continue; } $candidate = $reports[ $evict_key ]; if ( $report['count'] < $candidate['count'] || ( $report['count'] === $candidate['count'] && $report['last_seen'] < $candidate['last_seen'] ) ) { $evict_key = $fingerprint; } } if ( null !== $evict_key ) { unset( $reports[ $evict_key ] ); self::obs( 'cap_evicted' ); } return $reports; } /** * Keep the serialized reports under the size cap: first trim traces and * messages, then drop the lowest-value reports. * * @param array $reports Stored reports keyed by fingerprint. * * @return array Reports fitting the size cap. */ private static function enforce_size_cap( $reports ) { if ( strlen( (string) wp_json_encode( $reports ) ) <= self::MAX_STORED_BYTES ) { return $reports; } foreach ( $reports as $fingerprint => $report ) { if ( ! empty( $report['trace'] ) && count( $report['trace'] ) > 5 ) { $trimmed_marker = [ 'file' => '[trimmed]', 'line' => 0, 'function' => '', ]; $reports[ $fingerprint ]['trace'] = array_merge( array_slice( $report['trace'], 0, 3 ), [ $trimmed_marker ], array_slice( $report['trace'], - 2 ) ); } $reports[ $fingerprint ]['message'] = substr( (string) $report['message'], 0, 500 ); } self::obs( 'payload_trimmed' ); $total = count( $reports ); $size = strlen( (string) wp_json_encode( $reports ) ); while ( $total > 1 && $size > self::MAX_STORED_BYTES ) { $reports = self::evict_lowest( $reports ); $total = count( $reports ); $size = strlen( (string) wp_json_encode( $reports ) ); } return $reports; } /** * Build the dedup fingerprint of a report. * * Numbers are normalized out of the message component so variable parts * (memory sizes, ids) do not break aggregation. * * @param array $report Sanitized report. * * @return string Fingerprint hash. */ private static function fingerprint( $report ) { $message_part = preg_replace( '/\d+/', 'N', substr( (string) $report['message'], 0, 200 ) ); return md5( implode( '|', [ $report['type'], $message_part, $report['file'], $report['line'], $report['product_version'], ] ) ); } /** * Find the registered product owning a file path, longest prefix wins. * * @param string $file Normalized absolute file path. * * @return Product|null Owning product or null when the file is not ours. */ private static function find_owner( $file ) { if ( '' === $file ) { return null; } $owner = null; $owner_size = 0; foreach ( self::$registry as $dir => $product ) { if ( 0 === strpos( $file, $dir ) && strlen( $dir ) > $owner_size ) { $owner = $product; $owner_size = strlen( $dir ); } } return $owner; } /** * Rewrite an absolute path into a privacy-safe classified path. * * @param string $path Absolute path. * * @return string Classified path, e.g. `product:inc/file.php`. */ private static function classify_path( $path ) { $path = self::normalize_path( $path ); if ( '' === $path ) { return ''; } // The longest matching root wins, so the most specific classification // applies regardless of how the roots are nested into each other // (in production the SDK lives inside a product directory). $roots = [ [ rtrim( self::normalize_path( dirname( dirname( __DIR__ ) ) ), '/' ) . '/', 'sdk:' ], ]; foreach ( self::$registry as $dir => $product ) { $roots[] = [ $dir, 'product:' ]; } if ( defined( 'WP_PLUGIN_DIR' ) ) { $roots[] = [ rtrim( self::normalize_path( WP_PLUGIN_DIR ), '/' ) . '/', 'plugin:' ]; } if ( function_exists( 'get_theme_root' ) ) { $roots[] = [ rtrim( self::normalize_path( get_theme_root() ), '/' ) . '/', 'theme:' ]; } if ( defined( 'ABSPATH' ) ) { $roots[] = [ rtrim( self::normalize_path( ABSPATH ), '/' ) . '/', 'wp:' ]; } $best_root = ''; $best_label = null; foreach ( $roots as $root ) { if ( strlen( $root[0] ) > strlen( $best_root ) && 0 === strpos( $path, $root[0] ) ) { $best_root = $root[0]; $best_label = $root[1]; } } if ( null !== $best_label ) { return $best_label . substr( $path, strlen( $best_root ) ); } return basename( $path ); } /** * Sanitize a single trace frame: classified path, line and callable name, * arguments always dropped. * * @param array $frame Raw trace frame. * * @return array Sanitized frame. */ private static function sanitize_frame( $frame ) { $function = isset( $frame['function'] ) ? (string) $frame['function'] : ''; if ( isset( $frame['class'] ) ) { $function = $frame['class'] . ( isset( $frame['type'] ) ? $frame['type'] : '::' ) . $function; } return [ 'file' => isset( $frame['file'] ) ? self::classify_path( $frame['file'] ) : '[internal]', 'line' => isset( $frame['line'] ) ? (int) $frame['line'] : 0, 'function' => $function, ]; } /** * Redact secrets, personal data and server paths from free text. * * @param string $text Text to sanitize. * @param int $max_length Maximum length to keep. * * @return string Sanitized text. */ private static function sanitize_text( $text, $max_length ) { $text = (string) $text; // Rewrite known roots first so embedded stack traces stay readable. $sdk_root = rtrim( self::normalize_path( dirname( dirname( __DIR__ ) ) ), '/' ) . '/'; $text = str_replace( [ $sdk_root, str_replace( '/', '\\', $sdk_root ) ], 'sdk:/', $text ); foreach ( self::$registry as $dir => $product ) { $text = str_replace( [ $dir, str_replace( '/', '\\', $dir ) ], 'product:/', $text ); } if ( defined( 'ABSPATH' ) ) { $abspath = rtrim( self::normalize_path( ABSPATH ), '/' ) . '/'; $text = str_replace( [ $abspath, str_replace( '/', '\\', $abspath ) ], 'wp:/', $text ); } $replacements = [ // Emails. '/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/' => '[email]', // Bearer tokens. '/Bearer\s+[A-Za-z0-9\-._~+\/=]{8,}/i' => 'Bearer [token]', // JWT-looking tokens. '/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}/' => '[token]', // Long hex strings (hashes, keys). '/\b[a-fA-F0-9]{32,}\b/' => '[token]', // Long base64-looking strings. '/\b[A-Za-z0-9+\/=]{40,}\b/' => '[token]', // Remaining absolute paths, keep the basename for readability. '~(?:[A-Za-z]:)?[/\\\\](?:[^\s\'"():*?]+[/\\\\])+([^\s\'"():*?/\\\\]+)~' => '.../$1', ]; $text = (string) preg_replace( array_keys( $replacements ), array_values( $replacements ), $text ); return substr( $text, 0, $max_length ); } /** * Normalize a path to forward slashes. * * @param string $path Path to normalize. * * @return string Normalized path. */ private static function normalize_path( $path ) { $path = (string) $path; if ( function_exists( 'wp_normalize_path' ) ) { return wp_normalize_path( $path ); } return str_replace( '\\', '/', $path ); } /** * Normalize a throwable into the capture error shape. * * @param \Throwable $throwable Uncaught throwable. * * @return array Error data. */ private static function normalize_throwable( $throwable ) { $trace = $throwable->getTrace(); $previous = $throwable->getPrevious(); $depth = 0; while ( null !== $previous && $depth < 2 ) { $trace[] = [ 'file' => $previous->getFile(), 'line' => $previous->getLine(), 'function' => '[caused by] ' . get_class( $previous ), ]; $previous = $previous->getPrevious(); $depth ++; } return [ 'type' => ( $throwable instanceof \ParseError ) ? E_PARSE : E_ERROR, 'message' => get_class( $throwable ) . ': ' . $throwable->getMessage(), 'file' => $throwable->getFile(), 'line' => $throwable->getLine(), 'trace' => $trace, 'is_exception' => true, ]; } /** * Detect the request context the crash happened in. * * @return string One of cli, cron, ajax, rest, admin, frontend. */ private static function request_context() { if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'cli'; } if ( function_exists( 'wp_doing_cron' ) ? wp_doing_cron() : ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { return 'cron'; } if ( function_exists( 'wp_doing_ajax' ) ? wp_doing_ajax() : ( defined( 'DOING_AJAX' ) && DOING_AJAX ) ) { return 'ajax'; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return 'rest'; } if ( function_exists( 'is_admin' ) && is_admin() ) { return 'admin'; } return 'frontend'; } /** * Read the crash data option of a product. * * @param string $key Product key. * * @return array Crash data with reports and meta keys. */ private static function read_data( $key ) { $data = get_option( $key . '_crash_data', [] ); if ( ! is_array( $data ) ) { $data = []; } if ( ! isset( $data['reports'] ) || ! is_array( $data['reports'] ) ) { $data['reports'] = []; } if ( ! isset( $data['meta'] ) || ! is_array( $data['meta'] ) ) { $data['meta'] = []; } return $data; } /** * Persist the crash data option of a product, never autoloaded. * * @param string $key Product key. * @param array $data Crash data. * * @return bool Whether the write did not fail. */ private static function write_data( $key, $data ) { try { $option = $key . '_crash_data'; if ( false === get_option( $option, false ) ) { return add_option( $option, $data, '', 'no' ); } update_option( $option, $data ); return true; } catch ( \Exception $e ) { self::obs( 'store_failed' ); } catch ( \Throwable $e ) { self::obs( 'store_failed' ); } return false; } /** * Schedule the flush event for a product when consent is granted and no * event or backoff is pending. Randomized 1-6h jitter avoids synchronized * fleet-wide bursts. * * @param Product $product Product to schedule for. */ private static function schedule_flush( $product ) { if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) { return; } if ( ! self::is_consent_given( $product ) ) { return; } $key = $product->get_key(); if ( false !== get_transient( $key . '_crash_backoff' ) ) { self::obs( 'backoff' ); return; } $action_key = $key . '_crash_flush'; if ( ! wp_next_scheduled( $action_key ) ) { wp_schedule_single_event( time() + ( wp_rand( 1, 6 ) * HOUR_IN_SECONDS ), $action_key ); } } /** * Check the logging consent for a product, using the Logger semantics. * * @param Product $product Product to check. * * @return bool Consent granted? */ private static function is_consent_given( $product ) { if ( ! class_exists( 'ThemeisleSDK\Modules\Logger' ) ) { return false; } return Logger::is_logging_active( $product ); } /** * Re-arm the flush schedule on normal loads when reports are pending. */ private function maybe_schedule_flush() { $data = self::read_data( $this->product->get_key() ); if ( empty( $data['reports'] ) ) { return; } self::schedule_flush( $this->product ); } /** * Send the stored reports of the product to the crash endpoint. Clears the * store on success, sets a backoff window on failure. */ public function send_reports() { $key = $this->product->get_key(); $data = self::read_data( $key ); if ( empty( $data['reports'] ) ) { return; } if ( ! self::is_consent_given( $this->product ) ) { return; } if ( false !== get_transient( $key . '_crash_backoff' ) ) { return; } global $wp_version; $body = apply_filters( 'themeisle_sdk_crash_report_data', [ 'site' => get_site_url(), 'slug' => $this->product->get_slug(), 'version' => $this->product->get_version(), 'wp_version' => $wp_version, 'php_version' => PHP_VERSION, 'sdk_version' => self::get_sdk_version(), 'locale' => get_locale(), 'license' => apply_filters( $key . '_license_status', '' ), 'reports' => wp_json_encode( array_values( $data['reports'] ) ), ], $this->product ); $response = wp_remote_post( self::CRASH_ENDPOINT, [ 'timeout' => 3, //phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout 'body' => $body, ] ); $code = (int) wp_remote_retrieve_response_code( $response ); if ( ! is_wp_error( $response ) && $code >= 200 && $code < 300 ) { $data['reports'] = []; self::write_data( $key, $data ); return; } set_transient( $key . '_crash_backoff', true, self::BACKOFF_SECONDS ); self::obs( 'send_failed' ); } /** * Product version change handler: record the update time and lift the send * backoff so a fixed release reports its health immediately. */ public function on_product_update() { $key = $this->product->get_key(); delete_transient( $key . '_crash_backoff' ); $data = self::read_data( $key ); $data['meta']['last_update'] = time(); self::write_data( $key, $data ); } /** * Adopt raw records left by the load.php early sentinel: run them through * the full sanitize/fingerprint pipeline and drop the raw entries. */ private function adopt_sentinel_records() { $key = $this->product->get_key(); $data = self::read_data( $key ); if ( empty( $data['raw'] ) || ! is_array( $data['raw'] ) ) { return; } $raw = $data['raw']; unset( $data['raw'] ); self::write_data( $key, $data ); foreach ( $raw as $record ) { if ( ! is_array( $record ) ) { continue; } self::capture( [ 'type' => isset( $record['type'] ) ? (int) $record['type'] : E_ERROR, 'message' => isset( $record['message'] ) ? (string) $record['message'] : '', 'file' => isset( $record['file'] ) ? (string) $record['file'] : '', 'line' => isset( $record['line'] ) ? (int) $record['line'] : 0, 'trace' => [], ] ); } } /** * Compact crash summary attached to the uninstall feedback call: * top reports by count, short messages, product-only frames. * * @param Product $product Product to summarize. * * @return array Compact summary, empty when there are no reports. */ public static function get_uninstall_summary( $product ) { $data = self::read_data( $product->get_key() ); if ( empty( $data['reports'] ) ) { return []; } $reports = array_values( $data['reports'] ); usort( $reports, function ( $a, $b ) { return (int) $b['count'] - (int) $a['count']; } ); $reports = array_slice( $reports, 0, self::SUMMARY_MAX_REPORTS ); $summary = []; foreach ( $reports as $report ) { $frames = []; foreach ( (array) $report['trace'] as $frame ) { if ( isset( $frame['file'] ) && ( 0 === strpos( (string) $frame['file'], 'product:' ) || 0 === strpos( (string) $frame['file'], 'sdk:' ) ) ) { $frames[] = $frame; } } $summary[] = [ 'fingerprint' => isset( $report['fingerprint'] ) ? $report['fingerprint'] : '', 'type' => $report['type'], 'event_type' => isset( $report['event_type'] ) ? $report['event_type'] : 'fatal_error', 'message' => substr( (string) $report['message'], 0, self::SUMMARY_MESSAGE_LENGTH ), 'file' => $report['file'], 'line' => $report['line'], 'trace' => $frames, 'count' => $report['count'], 'first_seen' => $report['first_seen'], 'last_seen' => $report['last_seen'], 'product_version' => $report['product_version'], ]; } return $summary; } /** * Short-form Site Health section: one row per product having stored * crashes, no messages and no traces to keep the page lean. * * @param array $info Debug information sections. * * @return array Debug information sections. */ public static function add_debug_information( $info ) { $fields = []; foreach ( self::$registry as $product ) { $data = self::read_data( $product->get_key() ); if ( empty( $data['reports'] ) ) { continue; } $total = 0; $top = null; $last = 0; foreach ( $data['reports'] as $report ) { $total += (int) $report['count']; $last = max( $last, (int) $report['last_seen'] ); if ( null === $top || $report['count'] > $top['count'] ) { $top = $report; } } $fields[ $product->get_key() ] = [ 'label' => $product->get_friendly_name(), 'value' => sprintf( '%d distinct / %d total, last on %s, top: type %d @ %s:%d ×%d', count( $data['reports'] ), $total, gmdate( 'Y-m-d H:i', $last ), $top['type'], $top['file'], $top['line'], $top['count'] ), ]; } if ( empty( $fields ) ) { return $info; } $info['themeisle-sdk-crash-reports'] = [ 'label' => 'ThemeIsle SDK Crash Reports', 'description' => 'Locally stored crash summaries for ThemeIsle products. Full detail lives in the per-product crash data option.', 'fields' => $fields, ]; return $info; } /** * Current SDK version, from the loader globals. * * @return string SDK version. */ private static function get_sdk_version() { global $themeisle_sdk_max_version; return empty( $themeisle_sdk_max_version ) ? '' : (string) $themeisle_sdk_max_version; } /** * Observability breadcrumb for debugging the reporter itself in the field. * * @param string $reason Drop or failure reason. */ private static function obs( $reason ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( '[TISDK_CRASH] ' . $reason ); //phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } } /** * Reset the static state. Test helper only. */ public static function reset() { self::$registry = []; self::$exception_captured = false; } }