Files
desk-moloni/modules/desk_moloni/libraries/ErrorHandler.php
Emanuel Almeida c19f6fd9ee fix(perfexcrm module): align version to 3.0.1, unify entrypoint, and harden routes/views
- Bump DESK_MOLONI version to 3.0.1 across module
- Normalize hooks to after_client_* and instantiate PerfexHooks safely
- Fix OAuthController view path and API client class name
- Add missing admin views for webhook config/logs; adjust view loading
- Harden client portal routes and admin routes mapping
- Make Dashboard/Logs/Queue tolerant to optional model methods
- Align log details query with existing schema; avoid broken joins

This makes the module operational in Perfex (admin + client), reduces 404s,
and avoids fatal errors due to inconsistent tables/methods.
2025-09-11 17:38:45 +01:00

653 lines
20 KiB
PHP

<?php
defined('BASEPATH') or exit('No direct script access allowed');
/**
* Error Handler
* Comprehensive error handling and logging system for sync operations
*
* @package DeskMoloni
* @subpackage Libraries
* @category ErrorHandling
* @author Descomplicar® - PHP Fullstack Engineer
* @version 1.0.0
*/
namespace DeskMoloni\Libraries;
class ErrorHandler
{
protected $CI;
protected $model;
// Error severity levels
const SEVERITY_LOW = 'low';
const SEVERITY_MEDIUM = 'medium';
const SEVERITY_HIGH = 'high';
const SEVERITY_CRITICAL = 'critical';
// Error categories
const CATEGORY_SYNC = 'sync';
const CATEGORY_API = 'api';
const CATEGORY_QUEUE = 'queue';
const CATEGORY_MAPPING = 'mapping';
const CATEGORY_VALIDATION = 'validation';
const CATEGORY_AUTHENTICATION = 'authentication';
const CATEGORY_SYSTEM = 'system';
// Error codes
const ERROR_API_CONNECTION = 'API_CONNECTION_FAILED';
const ERROR_API_TIMEOUT = 'API_TIMEOUT';
const ERROR_API_AUTHENTICATION = 'API_AUTHENTICATION_FAILED';
const ERROR_API_RATE_LIMIT = 'API_RATE_LIMIT_EXCEEDED';
const ERROR_API_INVALID_RESPONSE = 'API_INVALID_RESPONSE';
const ERROR_SYNC_CONFLICT = 'SYNC_CONFLICT';
const ERROR_SYNC_VALIDATION = 'SYNC_VALIDATION_FAILED';
const ERROR_MAPPING_NOT_FOUND = 'MAPPING_NOT_FOUND';
const ERROR_QUEUE_PROCESSING = 'QUEUE_PROCESSING_FAILED';
const ERROR_DATA_CORRUPTION = 'DATA_CORRUPTION';
const ERROR_SYSTEM_RESOURCE = 'SYSTEM_RESOURCE_EXHAUSTED';
// Notification settings
protected $notification_thresholds = [
self::SEVERITY_CRITICAL => 1,
self::SEVERITY_HIGH => 3,
self::SEVERITY_MEDIUM => 10,
self::SEVERITY_LOW => 50
];
public function __construct()
{
$this->CI = &get_instance();
$this->CI->load->model('desk_moloni_model');
$this->model = $this->CI->desk_moloni_model;
log_activity('ErrorHandler initialized');
}
/**
* Log error with context and severity
*
* @param string $category
* @param string $error_code
* @param string $message
* @param array $context
* @param string $severity
* @return int Error log ID
*/
public function log_error($category, $error_code, $message, $context = [], $severity = self::SEVERITY_MEDIUM)
{
try {
// Validate inputs
if (!$this->is_valid_category($category)) {
$category = self::CATEGORY_SYSTEM;
}
if (!$this->is_valid_severity($severity)) {
$severity = self::SEVERITY_MEDIUM;
}
// Prepare error data
$error_data = [
'category' => $category,
'error_code' => $error_code,
'severity' => $severity,
'message' => $this->sanitize_message($message),
'context' => json_encode($this->sanitize_context($context)),
'stack_trace' => $this->get_sanitized_stack_trace(),
'occurred_at' => date('Y-m-d H:i:s'),
'user_id' => get_staff_user_id() ?: null,
'ip_address' => $this->CI->input->ip_address(),
'user_agent' => $this->CI->input->user_agent(),
'request_uri' => $this->CI->uri->uri_string(),
'memory_usage' => memory_get_usage(true),
'peak_memory' => memory_get_peak_usage(true),
'processing_time' => $this->get_processing_time()
];
// Store error in database
$error_id = $this->model->log_error($error_data);
// Log to file system as backup
$this->log_to_file($error_data);
// Check if notification is needed
$this->check_notification_threshold($category, $severity, $error_code);
// Trigger hooks for error handling
hooks()->do_action('desk_moloni_error_logged', $error_id, $error_data);
return $error_id;
} catch (\Exception $e) {
// Fallback error logging
log_message('error', 'ErrorHandler failed: ' . $e->getMessage());
error_log("DeskMoloni Error Handler Failure: {$e->getMessage()}");
return false;
}
}
/**
* Log API error with specific handling
*
* @param string $endpoint
* @param int $status_code
* @param string $response_body
* @param array $request_data
* @param string $error_message
* @return int
*/
public function log_api_error($endpoint, $status_code, $response_body, $request_data = [], $error_message = '')
{
$error_code = $this->determine_api_error_code($status_code, $response_body);
$severity = $this->determine_api_error_severity($status_code, $error_code);
$context = [
'endpoint' => $endpoint,
'status_code' => $status_code,
'response_body' => $this->truncate_response_body($response_body),
'request_data' => $this->sanitize_request_data($request_data),
'response_headers' => $this->get_last_response_headers()
];
$message = $error_message ?: "API request failed: {$endpoint} returned {$status_code}";
return $this->log_error(self::CATEGORY_API, $error_code, $message, $context, $severity);
}
/**
* Log sync error with entity context
*
* @param string $entity_type
* @param int $entity_id
* @param string $direction
* @param string $error_message
* @param array $additional_context
* @return int
*/
public function log_sync_error($entity_type, $entity_id, $direction, $error_message, $additional_context = [])
{
$error_code = $this->determine_sync_error_code($error_message);
$severity = $this->determine_sync_error_severity($error_code, $entity_type);
$context = array_merge([
'entity_type' => $entity_type,
'entity_id' => $entity_id,
'sync_direction' => $direction,
'sync_attempt' => $additional_context['attempt'] ?? 1
], $additional_context);
return $this->log_error(self::CATEGORY_SYNC, $error_code, $error_message, $context, $severity);
}
/**
* Log validation error
*
* @param string $field_name
* @param mixed $field_value
* @param string $validation_rule
* @param string $entity_type
* @return int
*/
public function log_validation_error($field_name, $field_value, $validation_rule, $entity_type = null)
{
$context = [
'field_name' => $field_name,
'field_value' => $this->sanitize_field_value($field_value),
'validation_rule' => $validation_rule,
'entity_type' => $entity_type
];
$message = "Validation failed for field '{$field_name}' with rule '{$validation_rule}'";
return $this->log_error(
self::CATEGORY_VALIDATION,
self::ERROR_SYNC_VALIDATION,
$message,
$context,
self::SEVERITY_LOW
);
}
/**
* Get error statistics
*
* @param array $filters
* @return array
*/
public function get_error_statistics($filters = [])
{
return [
'total_errors' => $this->model->count_errors($filters),
'by_category' => $this->model->count_errors_by_category($filters),
'by_severity' => $this->model->count_errors_by_severity($filters),
'by_error_code' => $this->model->count_errors_by_code($filters),
'recent_errors' => $this->model->get_recent_errors(10, $filters),
'error_trends' => $this->model->get_error_trends($filters),
'top_error_codes' => $this->model->get_top_error_codes(10, $filters)
];
}
/**
* Get errors by criteria
*
* @param array $criteria
* @param int $limit
* @param int $offset
* @return array
*/
public function get_errors($criteria = [], $limit = 50, $offset = 0)
{
return $this->model->get_errors($criteria, $limit, $offset);
}
/**
* Mark error as resolved
*
* @param int $error_id
* @param string $resolution_notes
* @param int $resolved_by
* @return bool
*/
public function mark_error_resolved($error_id, $resolution_notes = '', $resolved_by = null)
{
$resolution_data = [
'resolved' => 1,
'resolved_at' => date('Y-m-d H:i:s'),
'resolved_by' => $resolved_by ?: get_staff_user_id(),
'resolution_notes' => $resolution_notes
];
$result = $this->model->update_error($error_id, $resolution_data);
if ($result) {
log_activity("Error #{$error_id} marked as resolved");
hooks()->do_action('desk_moloni_error_resolved', $error_id, $resolution_data);
}
return $result;
}
/**
* Bulk mark errors as resolved
*
* @param array $error_ids
* @param string $resolution_notes
* @return array
*/
public function bulk_mark_resolved($error_ids, $resolution_notes = '')
{
$results = [
'total' => count($error_ids),
'success' => 0,
'errors' => 0
];
foreach ($error_ids as $error_id) {
if ($this->mark_error_resolved($error_id, $resolution_notes)) {
$results['success']++;
} else {
$results['errors']++;
}
}
return $results;
}
/**
* Clean up old errors
*
* @param int $retention_days
* @param bool $keep_critical
* @return int
*/
public function cleanup_old_errors($retention_days = 90, $keep_critical = true)
{
$cutoff_date = date('Y-m-d H:i:s', strtotime("-{$retention_days} days"));
$criteria = [
'occurred_before' => $cutoff_date,
'resolved' => 1
];
if ($keep_critical) {
$criteria['exclude_severity'] = self::SEVERITY_CRITICAL;
}
$deleted = $this->model->delete_errors($criteria);
if ($deleted > 0) {
log_activity("Cleaned up {$deleted} old error logs older than {$retention_days} days");
}
return $deleted;
}
/**
* Export errors to CSV
*
* @param array $filters
* @param int $limit
* @return string
*/
public function export_errors_csv($filters = [], $limit = 1000)
{
$errors = $this->model->get_errors($filters, $limit);
$output = fopen('php://temp', 'r+');
// CSV Header
fputcsv($output, [
'ID',
'Category',
'Error Code',
'Severity',
'Message',
'Occurred At',
'Resolved',
'User ID',
'IP Address',
'Request URI',
'Memory Usage',
'Context'
]);
foreach ($errors as $error) {
fputcsv($output, [
$error->id,
$error->category,
$error->error_code,
$error->severity,
$error->message,
$error->occurred_at,
$error->resolved ? 'Yes' : 'No',
$error->user_id,
$error->ip_address,
$error->request_uri,
$this->format_memory_usage($error->memory_usage),
$this->sanitize_context_for_export($error->context)
]);
}
rewind($output);
$csv_content = stream_get_contents($output);
fclose($output);
return $csv_content;
}
/**
* Check if notification threshold is reached
*
* @param string $category
* @param string $severity
* @param string $error_code
*/
protected function check_notification_threshold($category, $severity, $error_code)
{
$threshold = $this->notification_thresholds[$severity] ?? 10;
// Count recent errors of same type
$recent_count = $this->model->count_recent_errors($category, $error_code, 3600); // Last hour
if ($recent_count >= $threshold) {
$this->trigger_error_notification($category, $severity, $error_code, $recent_count);
}
}
/**
* Trigger error notification
*
* @param string $category
* @param string $severity
* @param string $error_code
* @param int $error_count
*/
protected function trigger_error_notification($category, $severity, $error_code, $error_count)
{
$notification_data = [
'category' => $category,
'severity' => $severity,
'error_code' => $error_code,
'error_count' => $error_count,
'time_period' => '1 hour'
];
// Send email notification if configured
if (get_option('desk_moloni_error_notifications') == '1') {
$this->send_error_notification_email($notification_data);
}
// Trigger webhook if configured
if (get_option('desk_moloni_error_webhooks') == '1') {
$this->trigger_error_webhook($notification_data);
}
hooks()->do_action('desk_moloni_error_threshold_reached', $notification_data);
}
/**
* Send error notification email
*
* @param array $notification_data
*/
protected function send_error_notification_email($notification_data)
{
$admin_emails = explode(',', get_option('desk_moloni_admin_emails', ''));
if (empty($admin_emails)) {
return;
}
$subject = "Desk-Moloni Error Threshold Reached: {$notification_data['error_code']}";
$message = $this->build_error_notification_message($notification_data);
foreach ($admin_emails as $email) {
$email = trim($email);
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
send_mail_template('desk_moloni_error_notification', $email, [
'subject' => $subject,
'message' => $message,
'notification_data' => $notification_data
]);
}
}
}
/**
* Determine API error code from response
*
* @param int $status_code
* @param string $response_body
* @return string
*/
protected function determine_api_error_code($status_code, $response_body)
{
switch ($status_code) {
case 401:
case 403:
return self::ERROR_API_AUTHENTICATION;
case 429:
return self::ERROR_API_RATE_LIMIT;
case 408:
case 504:
return self::ERROR_API_TIMEOUT;
case 0:
return self::ERROR_API_CONNECTION;
default:
if ($status_code >= 500) {
return self::ERROR_API_CONNECTION;
} elseif ($status_code >= 400) {
return self::ERROR_API_INVALID_RESPONSE;
}
return 'API_UNKNOWN_ERROR';
}
}
/**
* Determine API error severity
*
* @param int $status_code
* @param string $error_code
* @return string
*/
protected function determine_api_error_severity($status_code, $error_code)
{
if (in_array($error_code, [self::ERROR_API_AUTHENTICATION, self::ERROR_API_CONNECTION])) {
return self::SEVERITY_CRITICAL;
}
if ($error_code === self::ERROR_API_RATE_LIMIT) {
return self::SEVERITY_HIGH;
}
if ($status_code >= 500) {
return self::SEVERITY_HIGH;
}
return self::SEVERITY_MEDIUM;
}
/**
* Sanitize error message
*
* @param string $message
* @return string
*/
protected function sanitize_message($message)
{
// Remove sensitive information patterns
$patterns = [
'/password[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i',
'/token[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i',
'/key[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i',
'/secret[\'"\s]*[:=][\'"\s]*[^\s\'",}]+/i'
];
$message = preg_replace($patterns, '[REDACTED]', $message);
return substr(trim($message), 0, 1000);
}
/**
* Sanitize context data
*
* @param array $context
* @return array
*/
protected function sanitize_context($context)
{
$sensitive_keys = ['password', 'token', 'key', 'secret', 'auth', 'credential'];
array_walk_recursive($context, function(&$value, $key) use ($sensitive_keys) {
if (is_string($key) && in_array(strtolower($key), $sensitive_keys)) {
$value = '[REDACTED]';
}
});
return $context;
}
/**
* Get sanitized stack trace
*
* @return string
*/
protected function get_sanitized_stack_trace()
{
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
$clean_trace = [];
foreach ($trace as $frame) {
$clean_frame = [
'file' => basename($frame['file'] ?? 'unknown'),
'line' => $frame['line'] ?? 0,
'function' => $frame['function'] ?? 'unknown'
];
if (isset($frame['class'])) {
$clean_frame['class'] = $frame['class'];
}
$clean_trace[] = $clean_frame;
}
return json_encode($clean_trace);
}
/**
* Validate error category
*
* @param string $category
* @return bool
*/
protected function is_valid_category($category)
{
return in_array($category, [
self::CATEGORY_SYNC,
self::CATEGORY_API,
self::CATEGORY_QUEUE,
self::CATEGORY_MAPPING,
self::CATEGORY_VALIDATION,
self::CATEGORY_AUTHENTICATION,
self::CATEGORY_SYSTEM
]);
}
/**
* Validate error severity
*
* @param string $severity
* @return bool
*/
protected function is_valid_severity($severity)
{
return in_array($severity, [
self::SEVERITY_LOW,
self::SEVERITY_MEDIUM,
self::SEVERITY_HIGH,
self::SEVERITY_CRITICAL
]);
}
/**
* Log error to file as backup
*
* @param array $error_data
*/
protected function log_to_file($error_data)
{
$log_file = FCPATH . 'uploads/desk_moloni/logs/errors_' . date('Y-m-d') . '.log';
$log_entry = sprintf(
"[%s] %s/%s: %s\n",
$error_data['occurred_at'],
$error_data['category'],
$error_data['severity'],
$error_data['message']
);
if (!empty($error_data['context'])) {
$log_entry .= "Context: " . $error_data['context'] . "\n";
}
$log_entry .= "---\n";
file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
}
/**
* Get current processing time
*
* @return float
*/
protected function get_processing_time()
{
if (defined('APP_START_TIME')) {
return microtime(true) - APP_START_TIME;
}
return 0;
}
}