yf_core_errors classCustom error handler.
| File | plugins/sys/classes/yf_core_errors.class.php |
|---|---|
| Use | _class('core_errors') |
A site sets them in its config with $PROJECT_CONF['core_errors']['NAME'] = ...;
| Property | Default | Description |
|---|---|---|
$CLEAN_OUTPUT_BUFFERSbool |
true |
Drop partial output before the 500 page while headers are not sent; tests capture output with their own buffer and turn it off |
$ENV_ARRAYSstring |
'GPF' |
Could be any sequence from GPFCS |
$ERROR_LEVELint |
0 |
Error reporting level |
$ERROR_LEVEL_DEBUGint |
30711 |
Error reporting level for DEBUG_MODE enabled |
$ERROR_LEVEL_PRODint |
0 |
Error reporting level for production/non-debug mode (int from built-in constants) |
$ERROR_LOG_PATHstring |
'{LOGS_PATH}yf_core_errors.log' |
Log errors switcher, keep empty to disable logging |
$ERROR_MODE |
false |
|
$ERROR_PAGESbool |
false |
Off by default until 4.0: 500 page for uncaught exceptions and fatals |
$HTML_ERRORS |
true |
|
$LOG_CONTEXT_FIELDSarray |
|
Request sections an error log row keeps: get, post, files, cookie, session, qs (qs, url) and server (ip, ref, us); the fields of a section left out are written empty. Error log rows only, not the logger() db handler columns |
$LOG_DEPRECATED_TO_FILEbool |
true |
Log deprecated to the error file? |
$LOG_ERRORS_TO_FILEbool |
true |
Log errors to the error file? |
$LOG_NOTICES_TO_FILEbool |
false |
Log notices to the error file? |
$LOG_REDACTbool |
false |
SECURITY: mask the values of LOG_REDACT_KEYS in the request data of error log rows (get, post, files, cookie, session; query string pairs and user:pass@ of qs, url, ref) and drop call arguments from the trace (src); also the request columns and exception traces written by the logger() db / file handlers. Default false keeps the rows as before; 4.0 flips. The debug console (DEBUG_MODE) shows the request unmasked, as before: it is the developer's own request. |
$LOG_REDACT_KEYSarray |
|
Case-insensitive parts of a key whose value LOG_REDACT masks, at any depth; the current session_name() is added |
$LOG_WARNINGS_TO_FILEbool |
true |
Log warnings to the error file? |
$NO_NOTICESbool |
true |
Quickly turn off notices |
$SHOW_DETAILSnull|bool |
null |
Show class, message, file and trace on the 500 page; null follows DEBUG_MODE |
$USE_COMPACT_FORMATUse |
true |
compact format |
$USE_LOGGERbool |
false |
Also hand every handled error and exception to logger() (off by default until 4.0: the default error_log handler would add a line per warning to the PHP error log of every site) |
$_LOG_STARTEDbool |
false |
@conf_skip Started log output or not |
$_SHOW_BORDERSbool |
false |
Show start and end log headers or not |
$_in_loggerbool |
false |
@conf_skip Re-entrancy guard of the logger calls |
$error_log_filenamestring |
'yf_core_errors{suffix}.log' |
The filename of the log file. NOTE: $error_log_filename will only be used if you have log_errors Off and ;error_log filename in php.ini if log_errors is On, and error_log is set, the filename in error_log will be used. |
$error_typesarray |
|
@conf_skip Standard error types |
$logger_levelsarray |
|
@conf_skip PHP error type => PSR-3 level for logger() |
$pattern_ignore |
'~^(Undefined array key|Undefined variable)~' |
public function error_handler($error_type, $error_msg, $error_file, $error_line)
The error handling routine set by set_error_handler(). return true = Don't execute PHP internal error handler.
public function error_handler($error_type, $error_msg, $error_file, $error_line)
{
if ($this->pattern_ignore && preg_match($this->pattern_ignore, $error_msg)) {
return true;
}
// quickly turn off notices logging
if ($this->NO_NOTICES && ($error_type == E_NOTICE || $error_type == E_USER_NOTICE)) {
return true;
}
// PHP 8 still calls the handler for @-silenced errors; those stay out of the log like they stay off the screen
defined('DEBUG_MODE') && DEBUG_MODE && (error_reporting() & $error_type) && $this->_debug_record($error_type, $error_msg, $error_file, $error_line);
(error_reporting() & $error_type) && $this->_to_logger($this->logger_levels[$error_type] ?? 'warning', $error_msg, [
'file' => $error_file,
'line' => $error_line,
'type' => $error_type,
]);
$msg = '';
$save_log = false;
// Process critical errors
if ($error_type == E_ERROR || $error_type == E_USER_ERROR) {
if ($this->LOG_ERRORS_TO_FILE) {
$save_log = true;
}
// Process warnings errors
} elseif ($error_type == E_WARNING || $error_type == E_USER_WARNING) {
if ($this->LOG_WARNINGS_TO_FILE) {
$save_log = true;
}
// Process notices
} elseif ($error_type == E_NOTICE || $error_type == E_USER_NOTICE) {
if ($this->LOG_NOTICES_TO_FILE) {
$save_log = true;
}
} elseif ($error_type == E_DEPRECATED) {
if ($this->LOG_DEPRECATED_TO_FILE) {
$save_log = true;
}
}
$IP = is_object(common()) ? common()->get_ip() : false;
if ( ! $IP) {
$IP = ($_SERVER['REMOTE_ADDR'] ?? null);
}
$trace = array_slice($this->LOG_REDACT ? $this->_log_trace_lines() : explode(PHP_EOL, main()->trace_string()), 0, 5);
if ($save_log) {
$msg = json_encode($this->_log_row_filter([
'time' => date('Y-m-d H:i:s'),
'type' => $this->error_types[$error_type],
'msg' => str_replace(["\r", PHP_EOL], '', $error_msg) . ';',
'src' => implode(';', $trace),
'site' => conf('SITE_ID'),
'ip' => $IP,
'qs' => WEB_PATH . (strlen($_SERVER['QUERY_STRING'] ?? '') ? '?' . ($_SERVER['QUERY_STRING'] ?? null) : ''),
'url' => 'http://' . ($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['REQUEST_URI'] ?? ''),
'ref' => ($_SERVER['HTTP_REFERER'] ?? null),
'get' => $this->_log_display_array('GET'),
'post' => $this->_log_display_array('POST'),
'files' => $this->_log_display_array('FILES'),
'cookie' => $this->_log_display_array('COOKIE'),
'session' => $this->_log_display_array('SESSION'),
'us' => ($_SERVER['HTTP_USER_AGENT'] ?? ''),
])) . PHP_EOL;
if ( ! $this->_LOG_STARTED) {
if ($this->_SHOW_BORDERS) {
$this->_do_save_log_info('START EXECUTION' . PHP_EOL, 1);
}
$this->_LOG_STARTED = true;
}
$this->_do_save_log_info($msg);
}
if (($this->ERROR_LEVEL & $error_type) && strlen($msg)) {
if ($this->HTML_ERRORS) {
$tpl = '<b>%s</b>: <pre>%s</pre> (<i>%s on line %d</i>)<pre>%s</pre><br>' . PHP_EOL;
$msg = _prepare_html($error_msg);
$trace = _prepare_html(main()->trace_string());
} else {
$tpl = '%s: %s (%s on line %d)' . PHP_EOL . '%s' . PHP_EOL;
$msg = $error_msg;
$trace = main()->trace_string();
}
printf(
$tpl,
$this->error_types[$error_type],
$msg,
$error_file,
$error_line,
$trace
);
}
return true;
}
public function error_handler_destructor()
Destructor.
public function error_handler_destructor()
{
// Restore startup working directory
chdir(main()->_CWD);
// Send the endian log text if errors exists
if ($this->_LOG_STARTED && $this->_SHOW_BORDERS) {
$this->_do_save_log_info('END EXECUTION' . PHP_EOL, 1);
}
}
public function exception_handler($exception)
public function exception_handler($exception)
{
$traceline = '#%s %s(%s): %s(%s)';
$msg = "YF core errors: Uncaught exception '%s' with message '%s' in %s:%s\nStack trace:\n%s\n thrown in %s on line %s";
$trace = $exception->getTrace();
foreach ($trace as $key => $stackPoint) {
$trace[$key]['args'] = array_map('gettype', $trace[$key]['args'] ?? []);
}
$result = [];
$key = null;
foreach ($trace as $key => $stackPoint) {
$result[] = sprintf(
$traceline,
$key,
isset($stackPoint['file']) ? $stackPoint['file'] : '',
isset($stackPoint['line']) ? $stackPoint['line'] : '',
isset($stackPoint['function']) ? $stackPoint['function'] : '',
implode(', ', isset($stackPoint['args']) ? $stackPoint['args'] : [])
);
}
$result[] = '#' . ++$key . ' {main}';
$msg = sprintf(
$msg,
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine(),
implode(PHP_EOL, $result),
$exception->getFile(),
$exception->getLine()
);
$error_id = $this->ERROR_PAGES ? $this->_error_id() : '';
error_log($error_id ? $error_id . ' ' . $msg : $msg);
if ($error_id) {
$this->_to_logger('critical', 'Uncaught exception', ['exception' => $exception, 'error_id' => $error_id]);
} else {
$this->_to_logger('error', 'Uncaught exception', ['exception' => $exception]);
}
if ($error_id && ! $this->_is_cli()) {
$this->_clean_output_buffers();
echo $this->_error_500_page($error_id, $this->_show_details() ? $this->_exception_details($exception) : '');
} elseif ($this->ERROR_MODE) {
if ($this->HTML_ERRORS) {
echo '<pre>' . _prepare_html($msg) . '</pre>';
} else {
echo $msg;
}
}
}
public function init_reporting($force_error_mode = null)
Method that changes the error reporting level.
public function init_reporting($force_error_mode = null)
{
// New mode designed to show errors without enabling DEBUG_MODE
if (defined('ERROR_MODE')) {
$this->ERROR_MODE = constant('ERROR_MODE');
}
$conf_error_mode = conf('ERROR_MODE');
if ($conf_error_mode) {
$this->ERROR_MODE = $conf_error_mode;
}
if (DEBUG_MODE) {
$this->ERROR_MODE = true;
}
if (isset($force_error_mode)) {
$this->ERROR_MODE = $force_error_mode;
}
$this->ERROR_LEVEL = $this->ERROR_LEVEL_PROD;
if ($this->ERROR_MODE) {
$this->ERROR_LEVEL = $this->ERROR_LEVEL_DEBUG;
}
if (defined('ERROR_LEVEL')) {
$this->ERROR_LEVEL = constant('ERROR_LEVEL');
}
// Set value to E_ERROR is you want to turn off error level to minimum
$conf_error_level = conf('ERROR_LEVEL');
if ($conf_error_level) {
$this->ERROR_LEVEL = $conf_error_level;
}
error_reporting($this->ERROR_LEVEL);
ini_set('ignore_repeated_errors', 1);
ini_set('ignore_repeated_source', 1);
if (php_sapi_name() == 'cli') {
$this->HTML_ERRORS = false;
ini_set('html_errors', false);
}
set_error_handler([$this, 'error_handler'], E_ALL);
set_exception_handler([$this, 'exception_handler']);
register_shutdown_function([$this, 'error_handler_destructor']);
}
public function restore_handler()
Method that restores the error handler to the default error handler.
public function restore_handler()
{
restore_error_handler();
}
public function return_handler()
Method that returns the error handler to error_handler().
public function return_handler()
{
set_error_handler([$this, 'error_handler']);
}
public function set_log_file_name($filename)
Method that changes the filename of the generated log file.
public function set_log_file_name($filename)
{
$dir = dirname($filename);
if ( ! file_exists($dir)) {
mkdir($dir, 0755, true);
}
$this->error_log_filename = $filename;
}
_ hooks and helpers; not routable as page actionspublic function __call($name, $args)
Catch missing method call.
public function __call($name, $args)
{
return main()->extend_call($this, $name, $args);
}
public function __construct()
Constructor.
public function __construct()
{
if ($this->ERROR_LOG_PATH) {
ini_set('error_log', main()->_replace_core_paths($this->ERROR_LOG_PATH));
}
$this->set_log_file_name($this->_get_file_path());
$this->init_reporting();
}
public function _clean_output_buffers()
Partial page output must not precede the 500 page; after headers are sent the page is appended.
public function _clean_output_buffers()
{
if ( ! $this->CLEAN_OUTPUT_BUFFERS || headers_sent()) {
return false;
}
while (ob_get_level() > 0) {
ob_end_clean();
}
return true;
}
public function _debug_record($error_type, $error_msg, $error_file, $error_line)
The message for the debug console's errors tab (DEBUG_MODE only; debug() keeps at most 500).
public function _debug_record($error_type, $error_msg, $error_file, $error_line)
{
// full: no trace to build for a record debug() would drop
if ( ! function_exists('debug') || count((array) debug('php_errors')) >= 500) {
return false;
}
return debug('php_errors[]', [
'type' => $this->error_types[$error_type] ?? (string) $error_type,
'message' => (string) $error_msg,
'file' => (string) $error_file,
'line' => (int) $error_line,
'time' => microtime(true),
'trace' => function_exists('main') && is_object(main()) ? main()->trace_string() : '',
]);
}
public function _do_save_log_info($msg, $add_time = false)
Save log info to file or stdout.
public function _do_save_log_info($msg, $add_time = false)
{
if ($add_time) {
$msg = date('Y-m-d H:i:s') . ' - ' . $msg;
}
// Save log to file
if ($this->error_log_filename == '') {
error_log($msg, 0);
} else {
$log_dir = dirname($this->error_log_filename);
if ( ! file_exists($log_dir)) {
mkdir($log_dir, 0755, true);
}
error_log($msg, 3, $this->error_log_filename);
}
}
public function _error_500_page($error_id, $details = '')
500 page; falls back to inline HTML when the template engine is not available (bootstrap errors).
$details mixed Raw text, shown only when not empty public function _error_500_page($error_id, $details = '')
{
$this->_send_status(500);
try {
$vars = [
'error_id' => _prepare_html($error_id),
'details' => $details === '' ? '' : _prepare_html($details),
];
$html = $this->_render_500_template($vars);
if (trim($html) !== '') {
return $html;
}
} catch (Throwable $e) {
}
return '<h1>Internal Server Error</h1><p>Error id: ' . htmlspecialchars((string) $error_id, ENT_QUOTES) . '</p>';
}
public function _error_id()
public function _error_id()
{
return uniqid('err_');
}
public function _exception_details($exception)
Text for the developer part of the 500 page, not escaped.
public function _exception_details($exception)
{
return get_class($exception) . ': ' . $exception->getMessage() . PHP_EOL
. $exception->getFile() . ':' . $exception->getLine() . PHP_EOL
. $exception->getTraceAsString();
}
public function _get_file_path()
public function _get_file_path()
{
$main = main();
$is = [
'unit' => $main->is_unit_test(),
'admin' => MAIN_TYPE == 'admin',
'user' => MAIN_TYPE == 'user',
'console' => $main->is_console(),
'ajax' => $main->is_ajax(),
'debug' => $main->is_debug(),
];
$suffix = '';
foreach ($is as $name => $enabled) {
if ($enabled) {
$suffix .= '_' . $name;
}
}
$file_path = defined('ERROR_LOGS_FILE') ? constant('ERROR_LOGS_FILE') : APP_PATH . 'logs/' . $this->error_log_filename;
$file_path = str_replace('{suffix}', $suffix, $file_path);
$this->error_log_filename = $file_path;
return $this->error_log_filename;
}
public function _is_cli()
public function _is_cli()
{
return PHP_SAPI === 'cli';
}
public function _log_display_array($array_name = '')
Display array.
public function _log_display_array($array_name = '')
{
if (empty($array_name)) {
return '';
}
$A = match ($array_name) {
'GET' => $_GET,
'POST' => $_POST,
'FILES' => $_FILES,
'COOKIE' => $_COOKIE,
'SESSION' => $_SESSION ?? null,
'SERVER' => $_SERVER,
'REQUEST' => $_REQUEST,
'ENV' => $_ENV,
default => null,
};
if (empty($A)) {
return '';
}
if ($this->LOG_REDACT && is_array($A)) {
$A = $this->_log_redact_array($A, $this->_log_redact_pattern());
}
$output = str_replace(["\r", PHP_EOL], '', var_export($A, 1));
$output = preg_replace('/^array \((.*?)[\,]{0,1}\)$/i', '$1', $output);
return '_' . $array_name . '=' . $output;
}
public function _log_exception_trace(Throwable $e)
getTraceAsString() of a throwable without its call arguments.
$e Throwable public function _log_exception_trace(Throwable $e)
{
$lines = $this->_log_format_frames($e->getTrace());
$lines[] = '#' . count($lines) . ' {main}';
return implode("\n", $lines);
}
public function _log_format_frames(array $frames)
Lines in the getTraceAsString() format, "***" in place of the arguments of a call that had any.
$frames array public function _log_format_frames(array $frames)
{
$lines = [];
foreach (array_values($frames) as $i => $frame) {
$where = isset($frame['file']) ? $frame['file'] . '(' . ($frame['line'] ?? 0) . ')' : '[internal function]';
$call = ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? '');
$lines[] = '#' . $i . ' ' . str_replace(["\r", "\n"], ' ', $where) . ': ' . $call . '(' . (empty($frame['args']) ? '' : '***') . ')';
}
return $lines;
}
public function _log_redact_array(array $data, $pattern, $depth = 0)
Values under a matching key are masked whole, arrays included.
$data array public function _log_redact_array(array $data, $pattern, $depth = 0)
{
if ($pattern === '') {
return $data;
}
foreach ($data as $key => $value) {
if (preg_match($pattern, (string) $key)) {
$data[$key] = '***';
} elseif (is_array($value)) {
$data[$key] = $depth < 20 ? $this->_log_redact_array($value, $pattern, $depth + 1) : '***';
}
}
return $data;
}
public function _log_redact_pattern()
Regex matching any of LOG_REDACT_KEYS or the session name, '' when there is nothing to match.
public function _log_redact_pattern()
{
$keys = (array) $this->LOG_REDACT_KEYS;
$keys[] = session_name();
$quoted = [];
foreach ($keys as $key) {
if (is_scalar($key) && (string) $key !== '') {
$quoted[] = preg_quote((string) $key, '~');
}
}
return $quoted ? '~' . implode('|', array_unique($quoted)) . '~i' : '';
}
public function _log_redact_query($query, $pattern)
Masks the value of each name=value pair (separated by & or ;) whose decoded name matches, in a raw query string.
public function _log_redact_query($query, $pattern)
{
$query = (string) $query;
if ($pattern === '' || strpos($query, '=') === false) {
return $query;
}
return preg_replace_callback('~(^|[&;])([^=&;]+)=([^&;]*)~', function ($m) use ($pattern) {
return preg_match($pattern, urldecode($m[2])) ? $m[1] . $m[2] . '=***' : $m[0];
}, $query);
}
public function _log_redact_url($url, $pattern)
URL with its userinfo (user:pass@) masked and the pairs of its query part (after ?, before #) redacted; the path is kept.
public function _log_redact_url($url, $pattern)
{
$url = (string) preg_replace('~^((?:[a-z][a-z0-9+.\-]*:)?//)[^/?#]*@~i', '$1***@', (string) $url);
$fragment = '';
if (($pos = strpos($url, '#')) !== false) {
$fragment = substr($url, $pos);
$url = substr($url, 0, $pos);
}
if (($pos = strpos($url, '?')) !== false) {
$url = substr($url, 0, $pos + 1) . $this->_log_redact_query(substr($url, $pos + 1), $pattern);
}
return $url . $fragment;
}
public function _log_row_filter(array $row)
Section allow-list and redaction of the query strings and the trace of an error log row; the identity with the defaults.
$row array public function _log_row_filter(array $row)
{
$sections = [
'get' => ['get'],
'post' => ['post'],
'files' => ['files'],
'cookie' => ['cookie'],
'session' => ['session'],
'qs' => ['qs', 'url'],
'server' => ['ip', 'ref', 'us'],
];
foreach (array_diff(array_keys($sections), (array) $this->LOG_CONTEXT_FIELDS) as $section) {
foreach ($sections[$section] as $field) {
array_key_exists($field, $row) && $row[$field] = '';
}
}
if ( ! $this->LOG_REDACT) {
return $row;
}
$pattern = $this->_log_redact_pattern();
foreach (['qs', 'url', 'ref'] as $field) {
is_string($row[$field] ?? null) && $row[$field] = $this->_log_redact_url($row[$field], $pattern);
}
return $row;
}
public function _log_trace_lines()
The caller's trace as main()->trace_string() lists it (from #1, without {main}), built from the frames so that no call argument reaches the log.
public function _log_trace_lines()
{
$lines = $this->_log_format_frames((new Exception())->getTrace());
array_shift($lines);
return $lines;
}
public function _render_500_template($vars)
Seam for the template engine, returns '' when it is not available (bootstrap errors).
public function _render_500_template($vars)
{
if ( ! function_exists('tpl') || ! function_exists('main') || ! is_object(main())) {
return '';
}
return (string) tpl()->parse('system/error_500', $vars);
}
public function _render_error_page($code, $tpl_name, array $vars = [])
Status header and the template of an error page; $vars must be escaped by the caller.
$vars array public function _render_error_page($code, $tpl_name, array $vars = [])
{
$this->_send_status($code);
if ($code == 404) {
main()->IS_404 = true;
} elseif ($code == 403) {
main()->IS_403 = true;
}
return tpl()->parse($tpl_name, $vars);
}
public function _send_status($code)
Send a status line, unless the response has already started.
public function _send_status($code)
{
$texts = [403 => 'Forbidden', 404 => 'Not Found', 500 => 'Internal Server Error'];
if ($this->_is_cli() || headers_sent()) {
return false;
}
header((($_SERVER['SERVER_PROTOCOL'] ?? '') ?: 'HTTP/1.1') . ' ' . $code . ' ' . ($texts[$code] ?? ''));
return true;
}
public function _show_details()
public function _show_details()
{
return (bool) ($this->SHOW_DETAILS ?? DEBUG_MODE);
}
public function _to_logger($level, $message, array $context = [])
Pass a record to logger(); it can run during bootstrap and must never raise an error of its own.
$context array public function _to_logger($level, $message, array $context = [])
{
if ( ! $this->USE_LOGGER || $this->_in_logger || ! function_exists('logger') || ! function_exists('main') || ! is_object(main())) {
return false;
}
$this->_in_logger = true;
try {
return (bool) logger()->log($level, $message, $context);
} catch (Throwable $e) {
return false;
} finally {
$this->_in_logger = false;
}
}