Improve error log deduplication and occurrence tracking

Enhanced error deduplication by matching on type, file, line, and message. Updated error occurrence counting and admin stats to reflect deduplicated errors. Refactored error resolution and deletion to operate on all matching errors. Improved error occurrence display in the admin detail view for clarity and accuracy.
This commit is contained in:
Hosteroid
2026-01-08 14:19:09 +02:00
parent 24d5479dcf
commit 686f6f7528
3 changed files with 143 additions and 51 deletions

View File

@@ -229,9 +229,20 @@ class DebugController extends Controller
if (isset($entity['vcardArray'][1])) {
foreach ($entity['vcardArray'][1] as $field) {
if (is_array($field) && count($field) >= 4) {
// Handle nested arrays by flattening them recursively
$value = $field[3];
if (is_array($value)) {
$flattened = [];
array_walk_recursive($value, function($item) use (&$flattened) {
if (!is_array($item)) {
$flattened[] = $item;
}
});
$value = !empty($flattened) ? implode(', ', $flattened) : json_encode($value);
}
$parsedData[] = [
'key' => $field[0],
'value' => is_array($field[3]) ? implode(', ', $field[3]) : $field[3]
'value' => $value
];
}
}

View File

@@ -15,51 +15,52 @@ class ErrorLog extends Model
/**
* Log an error to database
* If the same error exists (same file + line + type), increment occurrence count
* If the same error exists (same type + file + line + message), increment occurrence count
* Otherwise, create a new record with unique error_id
*/
public function logError(array $errorData): ?int
{
// Generate unique error signature for deduplication
$signature = md5($errorData['error_type'] . $errorData['error_file'] . $errorData['error_line']);
// Check if this error already exists
// Check if this error already exists (same type, file, line, and message)
$existing = $this->findBySimilar(
$errorData['error_type'],
$errorData['error_file'],
$errorData['error_line']
$errorData['error_line'],
$errorData['error_message']
);
if ($existing) {
// Update existing error
// Update existing error: increment occurrence and update timestamp
// Keep the original error_id (don't use the new one from errorData)
$this->incrementOccurrence($existing['id']);
return $existing['id'];
}
// Create new error log
// Create new error log with the unique error_id
return $this->create($errorData);
}
/**
* Find similar error (same type, file, line)
* Find similar error (same type, file, line, and message)
*/
private function findBySimilar(string $type, string $file, int $line): ?array
private function findBySimilar(string $type, string $file, int $line, string $message): ?array
{
$sql = "SELECT * FROM error_logs
WHERE error_type = ?
AND error_file = ?
AND error_line = ?
AND error_message = ?
AND is_resolved = FALSE
LIMIT 1";
$stmt = $this->db->prepare($sql);
$stmt->execute([$type, $file, $line]);
$stmt->execute([$type, $file, $line, $message]);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Increment occurrence counter
* Increment occurrence counter and update last occurrence timestamp
*/
private function incrementOccurrence(int $id): void
{
@@ -72,6 +73,7 @@ class ErrorLog extends Model
$stmt->execute([$id]);
}
/**
* Find error by error_id (unique reference)
*/
@@ -256,12 +258,11 @@ class ErrorLog extends Model
error_file,
error_line,
is_resolved,
MIN(occurred_at) as occurred_at,
MAX(occurred_at) as last_occurred_at,
COUNT(*) as occurrences
occurred_at,
last_occurred_at,
occurrences
FROM error_logs
$whereClause
GROUP BY error_id
ORDER BY $sortColumn $sortOrder
LIMIT ? OFFSET ?
";
@@ -291,7 +292,7 @@ class ErrorLog extends Model
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
$query = "SELECT COUNT(DISTINCT error_id) as total FROM error_logs $whereClause";
$query = "SELECT COUNT(*) as total FROM error_logs $whereClause";
$stmt = $this->db->prepare($query);
$stmt->execute($params);
return (int)$stmt->fetch()['total'];
@@ -299,16 +300,19 @@ class ErrorLog extends Model
/**
* Get all occurrences of a specific error
* Since we deduplicate errors, this returns a single record (or empty if not found)
* The occurrences count shows how many times this error happened
*/
public function getOccurrencesByErrorId(string $errorId): array
{
$stmt = $this->db->prepare("
SELECT * FROM error_logs
WHERE error_id = ?
ORDER BY occurred_at DESC
LIMIT 1
");
$stmt->execute([$errorId]);
return $stmt->fetchAll();
$result = $stmt->fetch();
return $result ? [$result] : [];
}
/**
@@ -316,16 +320,16 @@ class ErrorLog extends Model
*/
public function getAdminStats(): array
{
// Total unique errors
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs");
// Total unique errors (one record per unique error signature)
$stmt = $this->db->query("SELECT COUNT(*) as total FROM error_logs");
$totalErrors = $stmt->fetch()['total'];
// Unresolved errors
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE is_resolved = 0");
$stmt = $this->db->query("SELECT COUNT(*) as total FROM error_logs WHERE is_resolved = 0");
$unresolved = $stmt->fetch()['total'];
// Errors in last 24h
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)");
// Errors in last 24h (errors that occurred or were last seen in last 24h)
$stmt = $this->db->query("SELECT COUNT(*) as total FROM error_logs WHERE last_occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)");
$last24h = $stmt->fetch()['total'];
// Total occurrences
@@ -342,45 +346,99 @@ class ErrorLog extends Model
/**
* Mark all occurrences of an error as resolved
* Resolves all errors with the same type, file, line, and message as the given error_id
*/
public function markErrorResolved(string $errorId, int $userId, ?string $notes): bool
{
// First get the error details to find all similar errors
$error = $this->findByErrorId($errorId);
if (!$error) {
return false;
}
// Mark all errors with the same signature as resolved
$stmt = $this->db->prepare("
UPDATE error_logs
SET is_resolved = 1,
resolved_at = NOW(),
resolved_by = ?,
notes = ?
WHERE error_id = ?
WHERE error_type = ?
AND error_file = ?
AND error_line = ?
AND error_message = ?
");
return $stmt->execute([$userId, $notes, $errorId]);
return $stmt->execute([
$userId,
$notes,
$error['error_type'],
$error['error_file'],
$error['error_line'],
$error['error_message']
]);
}
/**
* Mark all occurrences of an error as unresolved
* Unresolves all errors with the same type, file, line, and message as the given error_id
*/
public function markErrorUnresolved(string $errorId): bool
{
// First get the error details to find all similar errors
$error = $this->findByErrorId($errorId);
if (!$error) {
return false;
}
// Mark all errors with the same signature as unresolved
$stmt = $this->db->prepare("
UPDATE error_logs
SET is_resolved = 0,
resolved_at = NULL,
resolved_by = NULL,
notes = NULL
WHERE error_id = ?
WHERE error_type = ?
AND error_file = ?
AND error_line = ?
AND error_message = ?
");
return $stmt->execute([$errorId]);
return $stmt->execute([
$error['error_type'],
$error['error_file'],
$error['error_line'],
$error['error_message']
]);
}
/**
* Delete all occurrences of an error
* Deletes all errors with the same type, file, line, and message as the given error_id
*/
public function deleteByErrorId(string $errorId): bool
{
$stmt = $this->db->prepare("DELETE FROM error_logs WHERE error_id = ?");
return $stmt->execute([$errorId]);
// First get the error details to find all similar errors
$error = $this->findByErrorId($errorId);
if (!$error) {
return false;
}
// Delete all errors with the same signature
$stmt = $this->db->prepare("
DELETE FROM error_logs
WHERE error_type = ?
AND error_file = ?
AND error_line = ?
AND error_message = ?
");
return $stmt->execute([
$error['error_type'],
$error['error_file'],
$error['error_line'],
$error['error_message']
]);
}
/**

View File

@@ -72,11 +72,11 @@ $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['erro
</div>
<div class="flex items-center">
<i class="fas fa-redo mr-1.5"></i>
<span><?= count($errorOccurrences) ?> occurrence<?= count($errorOccurrences) != 1 ? 's' : '' ?></span>
<span><?= $error['occurrences'] ?? 1 ?> occurrence<?= ($error['occurrences'] ?? 1) != 1 ? 's' : '' ?></span>
</div>
<div class="flex items-center">
<i class="far fa-clock mr-1.5"></i>
<span>Last: <?= date('M d, Y H:i:s', strtotime($error['occurred_at'])) ?></span>
<span>Last: <?= date('M d, Y H:i:s', strtotime($error['last_occurred_at'] ?? $error['occurred_at'])) ?></span>
</div>
</div>
</div>
@@ -134,7 +134,7 @@ $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['erro
</button>
<button onclick="switchTab('occurrences')" id="tab-occurrences" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
<i class="fas fa-history mr-2"></i>
All Occurrences (<?= count($errorOccurrences) ?>)
Occurrence Details (<?= $error['occurrences'] ?? 1 ?>)
</button>
</nav>
</div>
@@ -212,24 +212,47 @@ $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['erro
<!-- Occurrences Tab -->
<div id="content-occurrences" class="tab-content hidden">
<div class="space-y-2">
<?php foreach ($errorOccurrences as $occurrence): ?>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="space-y-4">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<i class="fas fa-info-circle text-blue-600 mt-0.5 mr-3"></i>
<div>
<p class="text-sm font-semibold text-gray-900"><?= date('M d, Y H:i:s', strtotime($occurrence['occurred_at'])) ?></p>
<p class="text-xs text-gray-500 mt-1">
<?= htmlspecialchars($occurrence['request_method']) ?>
<?= htmlspecialchars($occurrence['request_uri']) ?>
from <?= htmlspecialchars($occurrence['ip_address']) ?>
<p class="text-sm font-semibold text-blue-900 mb-1">Error Occurrence Tracking</p>
<p class="text-sm text-blue-800">
This error has occurred <strong><?= $error['occurrences'] ?? 1 ?> time<?= ($error['occurrences'] ?? 1) != 1 ? 's' : '' ?></strong>.
Similar errors are automatically grouped together and the occurrence count is incremented.
</p>
</div>
<div class="text-xs text-gray-500">
ID: <span class="font-mono"><?= $occurrence['id'] ?></span>
</div>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 class="text-sm font-semibold text-gray-900 mb-3">Occurrence Information</h3>
<div class="space-y-3">
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">First Occurred</p>
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($error['occurred_at'])) ?></p>
</div>
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Last Occurred</p>
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($error['last_occurred_at'] ?? $error['occurred_at'])) ?></p>
</div>
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Total Occurrences</p>
<p class="text-sm text-gray-900"><?= $error['occurrences'] ?? 1 ?></p>
</div>
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Request Details</p>
<p class="text-xs text-gray-600">
<?= htmlspecialchars($error['request_method']) ?>
<?= htmlspecialchars($error['request_uri']) ?>
</p>
<p class="text-xs text-gray-600 mt-1">
IP: <?= htmlspecialchars($error['ip_address']) ?>
</p>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
@@ -249,7 +272,7 @@ $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['erro
</div>
<div>
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">First Occurred</p>
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($errorOccurrences[count($errorOccurrences)-1]['occurred_at'])) ?></p>
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($error['occurred_at'])) ?></p>
</div>
</div>
</div>