| Server IP : 52.25.153.185 / Your IP : 216.73.217.131 Web Server : Apache System : Linux ip-172-26-6-158 5.10.0-35-cloud-amd64 #1 SMP Debian 5.10.237-1 (2025-05-19) x86_64 User : daemon ( 1) PHP Version : 8.1.10 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /bitnami/wordpress/wp-content/plugins/allaccessible/inc/ |
Upload File : |
<?php
/**
* AllAccessible — minimal Sentry client.
*
* What it captures:
* - Uncaught exceptions (set_exception_handler chain)
* - Fatal errors (register_shutdown_function)
* - Catchable errors above warning severity (set_error_handler chain)
* - Explicit AllAccessible_Sentry::capture_exception() calls inside
* AJAX handlers + ApiClient HTTP failures
*
* What it does NOT do:
* - Performance / tracing — fire-and-forget single events only
* - Release health sessions — start/exit signals not modeled
* - Source-context attachments — pre-/post-context lines around the
* throw site (Sentry shows the function + line which is enough)
*
* Opt-out: aacb_options.sentry_disabled === true → init() no-ops.
* Toggle wired on Settings → Account → Advanced.
*
*/
if (!defined('ABSPATH')) { exit; }
final class AllAccessible_Sentry {
/**
* Hardcoded DSN. Project-level credential, intentionally not secret
* — Sentry DSN keys are designed to be embedded in client code (web
* browsers, mobile apps) and rely on origin + project-level rate
* limits, not key secrecy. Filter `aacb_sentry_dsn` available for
* white-label installs that want to point at their own Sentry.
*/
const DEFAULT_DSN = 'https://09483018ddcc1c2ce3afa01acd5f0318@o4509626671759361.ingest.us.sentry.io/4511461234704384';
/**
* SDK identifier sent in the X-Sentry-Auth header. Lets us split
* issues by plugin-source in the Sentry UI when other AllAccessible
* services start reporting (widget, lambda) — they each set their
* own sentry_client value.
*/
const CLIENT_NAME = 'allaccessible-wp';
/**
* Per-request throttle — same exception fingerprint shouldn't send
* more than once per pageload (a cascading failure can throw 100
* times in one request). Keyed by message+file+line hash.
*/
private static $seen_fingerprints = array();
/**
* Per-hour throttle — even across requests, cap total events from
* one install at this number to avoid runaway sites flooding the
* project quota. Transient-backed.
*/
const HOURLY_CAP = 50;
const HOURLY_CAP_TRANSIENT = 'aacb_sentry_hourly_count';
/**
* Initialized flag + parsed DSN parts. Set in init().
*/
private static $initialized = false;
private static $dsn_parsed = null;
private static $public_key = '';
private static $project_id = '';
private static $store_url = '';
/**
* Breadcrumbs buffer — last 20 events captured before a real error.
* Symmetric ring buffer; oldest dropped first when full.
*/
private static $breadcrumbs = array();
const MAX_BREADCRUMBS = 20;
/**
* Bootstrap. Called once from allaccessible.php after Debug.php
* loads but before any AJAX handler is registered. Returns early
* when the user has opted out OR has no AllAccessible account.
*/
public static function init() {
if (self::$initialized) return;
// CONSENT GATE: only report errors when an AllAccessible account
// is linked. The consent chain for error/diagnostic collection
// runs through account signup → Terms → Privacy Policy (which
// discloses Sentry). A free-widget-only user who never created
// an account has agreed to nothing, so we send NOTHING — this
// keeps the README's "no data without an account" promise true
// and satisfies WP.org Guideline #7 (no external data
// transmission without authenticated consent).
$account_id = (string) get_option('aacb_accountID', '');
if ($account_id === '') {
self::$initialized = true; // mark so we don't re-check every call
return;
}
$opts = get_option('aacb_options', array());
if (!empty($opts['sentry_disabled'])) {
self::$initialized = true; // mark so we don't re-check on every call
return;
}
$dsn = (string) apply_filters('aacb_sentry_dsn', self::DEFAULT_DSN);
if (!self::parse_dsn($dsn)) {
self::$initialized = true;
return;
}
// Chain error handlers — never clobber existing ones. WordPress
// core, other plugins, and Query Monitor all set their own. We
// call through to the previous handler so they keep working.
$prev_exception = set_exception_handler(array(__CLASS__, 'handle_uncaught_exception'));
$prev_error = set_error_handler(array(__CLASS__, 'handle_php_error'));
register_shutdown_function(array(__CLASS__, 'handle_shutdown'));
// Stash prior handlers so our chained handlers can pass through.
self::$prev_exception_handler = $prev_exception;
self::$prev_error_handler = $prev_error;
self::$initialized = true;
}
private static $prev_exception_handler = null;
private static $prev_error_handler = null;
/**
* Sentry-able DSN format:
* https://<public_key>@<host>/<project_id>
* 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' => '<throw>',
);
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);
}
}