@/ * Returns true on success, false if malformed (we silently no-op). */ private static function parse_dsn(string $dsn): bool { $parts = parse_url($dsn); if (!$parts || empty($parts['scheme']) || empty($parts['user']) || empty($parts['host']) || empty($parts['path'])) { return false; } self::$public_key = (string) $parts['user']; self::$project_id = ltrim((string) $parts['path'], '/'); if (self::$public_key === '' || self::$project_id === '') return false; self::$store_url = sprintf('%s://%s/api/%s/store/', $parts['scheme'], $parts['host'], self::$project_id); self::$dsn_parsed = $parts; return true; } /** * Public API — record a breadcrumb (something interesting that * happened but wasn't an error). Surfaces in Sentry's UI as the * trail leading up to a captured event. */ public static function add_breadcrumb(string $category, string $message, array $data = array()) { if (!self::$initialized) self::init(); if (!self::$dsn_parsed) return; self::$breadcrumbs[] = array( 'timestamp' => microtime(true), 'category' => $category, 'message' => $message, 'data' => $data, 'level' => 'info', ); if (count(self::$breadcrumbs) > self::MAX_BREADCRUMBS) { array_shift(self::$breadcrumbs); } } /** * Public API — send an exception. Use this in catch blocks where * the exception is being swallowed but represents a real failure * (e.g. ApiClient request errors that get returned as WP_Error). */ public static function capture_exception(\Throwable $e, array $extra = array()) { if (!self::$initialized) self::init(); if (!self::$dsn_parsed) return; $fingerprint = md5($e->getMessage() . '|' . $e->getFile() . '|' . $e->getLine()); if (isset(self::$seen_fingerprints[$fingerprint])) return; self::$seen_fingerprints[$fingerprint] = true; if (!self::within_hourly_cap()) return; $event = self::build_event($extra); $event['exception'] = array('values' => array(array( 'type' => get_class($e), 'value' => $e->getMessage(), 'stacktrace' => self::frames_to_stacktrace($e->getTrace(), $e->getFile(), $e->getLine()), ))); $event['level'] = 'error'; self::send_event($event); } /** * Public API — send a freeform message. Use for non-exception * conditions worth surfacing (e.g. AJAX nonce failures that the * caller wants to track without being an exception). */ public static function capture_message(string $message, string $level = 'error', array $extra = array()) { if (!self::$initialized) self::init(); if (!self::$dsn_parsed) return; $fingerprint = md5($message); if (isset(self::$seen_fingerprints[$fingerprint])) return; self::$seen_fingerprints[$fingerprint] = true; if (!self::within_hourly_cap()) return; $event = self::build_event($extra); $event['message'] = $message; $event['level'] = in_array($level, array('fatal','error','warning','info','debug'), true) ? $level : 'error'; self::send_event($event); } /** * Build the common event envelope — tags, contexts, breadcrumbs. */ private static function build_event(array $extra): array { $event = array( 'event_id' => self::uuid4(), 'timestamp' => gmdate('Y-m-d\TH:i:s\Z'), 'platform' => 'php', 'logger' => 'allaccessible.wp', 'environment' => (defined('WP_DEBUG') && WP_DEBUG) ? 'dev' : 'production', 'release' => defined('AACB_VERSION') ? ('aacb-wp@' . AACB_VERSION) : 'unknown', 'server_name' => function_exists('gethostname') ? (string) gethostname() : 'unknown', 'tags' => self::default_tags(), 'contexts' => self::default_contexts(), 'extra' => $extra, 'breadcrumbs' => array('values' => self::$breadcrumbs), ); return $event; } /** * Identity + environment tags Sentry uses for grouping / filtering. */ private static function default_tags(): array { $account_id = (string) get_option('aacb_accountID', ''); $site_url = function_exists('get_site_url') ? (string) get_site_url() : ''; $host = ''; if ($site_url !== '') { $p = parse_url($site_url); $host = isset($p['host']) ? strtolower($p['host']) : ''; } $tier = ''; if (class_exists('AllAccessible_ApiClient')) { try { $tier = (string) AllAccessible_ApiClient::get_instance()->get_subscription_tier(); } catch (\Throwable $e) { /* don't recurse */ } } $tags = array( 'account_id' => $account_id !== '' ? $account_id : 'unknown', 'site_host' => $host !== '' ? $host : 'unknown', 'tier' => $tier !== '' ? $tier : 'unknown', 'plugin_version' => defined('AACB_VERSION') ? AACB_VERSION : 'unknown', 'wp_version' => function_exists('get_bloginfo') ? (string) get_bloginfo('version') : 'unknown', 'php_version' => PHP_VERSION, 'multisite' => function_exists('is_multisite') && is_multisite() ? 'yes' : 'no', ); return $tags; } /** * Contexts (richer than tags — render as collapsible cards in * Sentry UI). user context lets the dashboard search by accountID. */ private static function default_contexts(): array { return array( 'runtime' => array( 'name' => 'php', 'version' => PHP_VERSION, ), 'app' => array( 'app_name' => 'AllAccessible WP Plugin', 'app_version' => defined('AACB_VERSION') ? AACB_VERSION : 'unknown', ), 'user' => array( 'id' => (string) get_option('aacb_accountID', 'anonymous'), ), ); } /** * Convert PHP stacktrace into Sentry's frames format. Innermost * frame goes last (Sentry convention). Includes the throw site * itself as the topmost (last) frame. */ private static function frames_to_stacktrace(array $trace, string $throw_file, int $throw_line): array { $frames = array(); foreach (array_reverse($trace) as $f) { $frames[] = array( 'filename' => $f['file'] ?? '?', 'lineno' => (int) ($f['line'] ?? 0), 'function' => self::format_function($f), ); } // Topmost frame = the actual throw location. $frames[] = array( 'filename' => $throw_file, 'lineno' => $throw_line, 'function' => '', ); return array('frames' => $frames); } private static function format_function(array $f): string { $fn = $f['function'] ?? '?'; if (!empty($f['class']) && !empty($f['type'])) { return $f['class'] . $f['type'] . $fn; } return $fn; } /** * POST to Sentry. Non-blocking — the user's request never waits. */ private static function send_event(array $event) { if (!function_exists('wp_remote_post')) return; $payload = wp_json_encode($event); if ($payload === false) return; $auth = sprintf( 'Sentry sentry_version=7, sentry_client=%s/%s, sentry_key=%s', self::CLIENT_NAME, defined('AACB_VERSION') ? AACB_VERSION : '0.0.0', self::$public_key ); wp_remote_post(self::$store_url, array( 'method' => 'POST', 'timeout' => 2, 'blocking' => false, // critical — fire-and-forget 'headers' => array( 'Content-Type' => 'application/json', 'X-Sentry-Auth' => $auth, 'User-Agent' => self::CLIENT_NAME . '/' . (defined('AACB_VERSION') ? AACB_VERSION : '0.0.0'), ), 'body' => $payload, )); // Increment hourly counter (cap-checked separately so a flood // never grows the counter past the cap). $count = (int) get_transient(self::HOURLY_CAP_TRANSIENT); set_transient(self::HOURLY_CAP_TRANSIENT, $count + 1, HOUR_IN_SECONDS); } /** * Cap how many events a single install can emit per hour. Protects * the Sentry project quota when one customer has a broken setup * generating errors on every pageload. Reads the counter only — * write happens after a successful send. */ private static function within_hourly_cap(): bool { $count = (int) get_transient(self::HOURLY_CAP_TRANSIENT); return $count < self::HOURLY_CAP; } /** * Handlers chained from init(). */ public static function handle_uncaught_exception(\Throwable $e) { // Plugin-dir scope gate — same rule as handle_php_error and // handle_shutdown. Without this, ANY uncaught exception from // any other WP plugin (Give, Yoast, WooCommerce, etc.) gets // captured to OUR Sentry project because we registered last // in the chain. Verified leak: Give plugin's "Target class // [...Harbor\Consent\Provider] does not exist" landed in our // project before this gate. // // Walks the throw location AND the stack to catch cases where // OUR code threw but the file path PHP reports is some wrapper // (rare, but defensive). If ANY frame is in our plugin dir we // own the throw and should capture. if (self::is_throwable_in_plugin_scope($e)) { self::capture_exception($e, array('handler' => 'uncaught_exception')); } if (self::$prev_exception_handler) { call_user_func(self::$prev_exception_handler, $e); } } /** * Returns true if the throw originated inside this plugin's dir, * either at the throw site or anywhere in the stack trace. */ private static function is_throwable_in_plugin_scope(\Throwable $e): bool { $plugin_root = defined('AACB_VERSION') ? dirname(__FILE__, 2) : null; if (!$plugin_root) return false; // Throw site itself. if (strpos((string) $e->getFile(), $plugin_root) === 0) { return true; } // Walk frames — catches cases where a third-party wrapper // re-threw OUR exception (rare, but possible). foreach ($e->getTrace() as $frame) { $file = isset($frame['file']) ? (string) $frame['file'] : ''; if ($file !== '' && strpos($file, $plugin_root) === 0) { return true; } } return false; } public static function handle_php_error($errno, $errstr, $errfile = '', $errline = 0) { // Only capture errors above warning. Notices/deprecations are // too noisy and almost always third-party plugin chatter. if (in_array($errno, array(E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, E_WARNING, E_USER_WARNING), true)) { // Only report errors that originate from our plugin dir to // avoid being the de-facto error monitor for every other // WP plugin on the install. $plugin_root = defined('AACB_VERSION') ? dirname(__FILE__, 2) : null; if ($plugin_root && strpos((string) $errfile, $plugin_root) === 0) { self::capture_message( sprintf('[php_error %d] %s', $errno, $errstr), in_array($errno, array(E_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR), true) ? 'error' : 'warning', array( 'errno' => $errno, 'errfile' => $errfile, 'errline' => $errline, ) ); } } if (self::$prev_error_handler) { return call_user_func(self::$prev_error_handler, $errno, $errstr, $errfile, $errline); } return false; // let PHP run its normal handler } public static function handle_shutdown() { $err = error_get_last(); if (!$err) return; if (!in_array($err['type'], array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR), true)) return; // Same scope gate as the error handler. $plugin_root = defined('AACB_VERSION') ? dirname(__FILE__, 2) : null; if (!$plugin_root || strpos((string) ($err['file'] ?? ''), $plugin_root) !== 0) return; self::capture_message( sprintf('[fatal %d] %s', $err['type'], $err['message']), 'fatal', array('errfile' => $err['file'], 'errline' => $err['line']) ); } /** * RFC 4122 v4 UUID — Sentry event_id must be 32 hex chars (no * hyphens). PHP doesn't have a built-in. */ private static function uuid4(): string { $data = random_bytes(16); $data[6] = chr((ord($data[6]) & 0x0f) | 0x40); $data[8] = chr((ord($data[8]) & 0x3f) | 0x80); return bin2hex($data); } }