aboutsummaryrefslogtreecommitdiff
path: root/api/debug
diff options
context:
space:
mode:
Diffstat (limited to 'api/debug')
-rw-r--r--api/debug/dashboard.php10
-rw-r--r--api/debug/health.php17
-rw-r--r--api/debug/ingest.php91
-rw-r--r--api/debug/stats.php9
4 files changed, 106 insertions, 21 deletions
diff --git a/api/debug/dashboard.php b/api/debug/dashboard.php
index c235ec0..74ba3af 100644
--- a/api/debug/dashboard.php
+++ b/api/debug/dashboard.php
@@ -5,7 +5,9 @@ header('Content-Type: text/html; charset=utf-8');
$errors = load_json(ERRORS_FILE);
$sessions = load_json(SESSIONS_FILE);
-$totalErrors = count($errors);
+$uniqueErrors = count($errors);
+$totalOccurrences = array_sum(array_map(fn($e) => (int)($e['count'] ?? 1), $errors));
+$lastIngest = $errors ? max(array_column($errors, 'ingested_at')) : null;
$totalSessions = count($sessions);
$byType = [];
$bySeverity = [];
@@ -91,8 +93,10 @@ if ($lastSync): ?>
<div class="grid">
<div class="card">
- <h3>Total Errors</h3>
- <div class="stat-value <?= $totalErrors > 0 ? 'error' : 'ok' ?>"><?= $totalErrors ?></div>
+ <h3>Total Occurrences</h3>
+ <div class="stat-value <?= $totalOccurrences > 0 ? 'error' : 'ok' ?>"><?= $totalOccurrences ?></div>
+ <div class="muted" style="font-size:.75em;color:var(--muted)"><?= $uniqueErrors ?> unique signatures</div>
+ <div class="muted" style="font-size:.75em;color:var(--muted)">last ingest: <?= $lastIngest ? htmlspecialchars($lastIngest) : '—' ?></div>
</div>
<div class="card">
<h3>Sessions</h3>
diff --git a/api/debug/health.php b/api/debug/health.php
index 5a0048f..883588f 100644
--- a/api/debug/health.php
+++ b/api/debug/health.php
@@ -6,11 +6,24 @@ $sessions = load_json(SESSIONS_FILE);
$lastError = !empty($errors) ? end($errors) : null;
+// Self-referential rows mean the debug pipeline is counting itself: the failure mode that
+// turned 33 real errors into 116,696 "occurrences" before 2026-10-23. Surface it instead of
+// reporting a poisoned dataset as healthy.
+$selfRef = 0;
+$occurrences = 0;
+foreach ($errors as $e) {
+ if (stripos($e['message'] ?? '', '/api/debug/') !== false) $selfRef++;
+ $occurrences += (int)($e['count'] ?? 1);
+}
+
json_response([
'success' => true,
- 'status' => 'ok',
+ 'status' => $selfRef > 0 ? 'degraded' : 'ok',
'uptime' => filemtime(__FILE__) ? (time() - filemtime(__FILE__)) : 0,
- 'total_errors' => count($errors),
+ 'total_errors' => $occurrences,
+ 'total_occurrences' => $occurrences,
+ 'unique_errors' => count($errors),
+ 'self_referential' => $selfRef,
'total_sessions' => count($sessions),
'errors_file_size_kb' => file_exists(ERRORS_FILE) ? round(filesize(ERRORS_FILE) / 1024, 1) : 0,
'sessions_file_size_kb' => file_exists(SESSIONS_FILE) ? round(filesize(SESSIONS_FILE) / 1024, 1) : 0,
diff --git a/api/debug/ingest.php b/api/debug/ingest.php
index b513593..7388bce 100644
--- a/api/debug/ingest.php
+++ b/api/debug/ingest.php
@@ -64,23 +64,51 @@ foreach ($allErrors as $idx => $existing) {
}
}
-// Ingest each error (dedup by hash)
+// Ingest each error (dedup by hash).
+// The client re-sends its whole error DB every 5 min with a CUMULATIVE count per hash.
+// Store what the client already reported for this session/hash and only add the delta,
+// otherwise every sync multiplies the occurrence count (measured 2026-10-23:
+// 33 real records had accumulated 116,696 "occurrences").
+if (!isset($sessions[$sessionId]['synced']) || !is_array($sessions[$sessionId]['synced'])) {
+ $sessions[$sessionId]['synced'] = [];
+}
+
+function norm_ts($s, $fallback) {
+ if (!is_string($s) || $s === '') return $fallback;
+ $t = strtotime($s);
+ if ($t === false) return $fallback;
+ return gmdate('c', $t); // always +00:00, never Z
+}
+
+$processed = 0; // entries handled (NOT occurrences) — the client's truncation warning uses this
foreach ($errors as $e) {
+ $processed++;
$hash = $e['hash'] ?? md5(($e['message'] ?? '') . '|' . ($e['source'] ?? '') . '|' . ($e['type'] ?? ''));
-
+ $clientCount = clamp_count($e['count'] ?? 1);
+ $prevCount = (int)($sessions[$sessionId]['synced'][$hash] ?? 0);
+ // A client whose localStorage was cleared (or a replayed payload) can present a huge
+ // cumulative count against prev=0. Cap the accepted delta per sync so one replay cannot
+ // reintroduce the 46k-style inflation.
+ if ($prevCount > $clientCount) $prevCount = 0;
+ $delta = min(5000, max(0, $clientCount - $prevCount));
+ if ($clientCount > $prevCount) {
+ $sessions[$sessionId]['synced'][$hash] = $clientCount;
+ }
+
if (isset($existingHashes[$hash])) {
- // Update existing entry
$idx = $existingHashes[$hash];
- $allErrors[$idx]['count'] += clamp_count($e['count'] ?? 1);
- $allErrors[$idx]['last_seen'] = $e['lastSeen'] ?? $now;
- $allErrors[$idx]['ingested_at'] = $now;
- if (!empty($e['stack'])) {
- $allErrors[$idx]['stack'] = substr($e['stack'], 0, 2000);
+ if ($delta > 0) {
+ $allErrors[$idx]['count'] += $delta;
+ $allErrors[$idx]['last_seen'] = norm_ts($e['lastSeen'] ?? null, $now);
+ $allErrors[$idx]['ingested_at'] = $now;
+ if (!empty($e['stack'])) {
+ $allErrors[$idx]['stack'] = substr($e['stack'], 0, 2000);
+ }
+ $ingested += $delta;
}
- $ingested++;
continue;
}
-
+
$entry = [
'hash' => $hash,
'message' => substr($e['message'] ?? '', 0, 500),
@@ -91,15 +119,38 @@ foreach ($errors as $e) {
'module' => $e['module'] ?? null,
'stack' => isset($e['stack']) ? substr($e['stack'], 0, 2000) : null,
'session_id' => $sessionId,
- 'count' => clamp_count($e['count'] ?? 1),
- 'first_seen' => $e['firstSeen'] ?? $now,
- 'last_seen' => $e['lastSeen'] ?? $now,
+ 'count' => $delta,
+ 'first_seen' => norm_ts($e['firstSeen'] ?? null, $now),
+ 'last_seen' => norm_ts($e['lastSeen'] ?? null, $now),
'ingested_at' => $now,
];
-
+
$allErrors[] = $entry;
$existingHashes[$hash] = count($allErrors) - 1;
- $ingested++;
+ $ingested += $delta;
+}
+
+// Bound the per-session counter map so sessions.json cannot grow without limit.
+if (count($sessions[$sessionId]['synced']) > 500) {
+ $sessions[$sessionId]['synced'] = array_slice($sessions[$sessionId]['synced'], -500, 500, true);
+}
+
+// Retention: drop signatures unseen for 30 days (occurrence history is in the archive).
+// A dropped hash MUST also leave every sessions[*].synced map, otherwise the next client
+// sync recreates the record with delta=0 and the retention pass never converges.
+$retentionCut = time() - 30 * 86400;
+$survivors = [];
+$allErrors = array_values(array_filter($allErrors, function ($e) use ($retentionCut, &$survivors) {
+ $t = strtotime($e['last_seen'] ?? '');
+ if ($t === false) { $survivors[$e['hash'] ?? ''] = true; return true; }
+ if ($t > $retentionCut) { $survivors[$e['hash'] ?? ''] = true; return true; }
+ return false;
+}));
+foreach ($sessions as $sid => $sinfo) {
+ if (empty($sinfo['synced'])) continue;
+ foreach (array_keys($sinfo['synced']) as $h) {
+ if (!isset($survivors[$h])) unset($sessions[$sid]['synced'][$h]);
+ }
}
// Trim to last 10,000 errors (ring buffer)
@@ -107,6 +158,15 @@ if (count($allErrors) > 10000) {
$allErrors = array_slice($allErrors, -10000);
}
+// Bound the session map itself: keep the 200 most recently seen sessions.
+if (count($sessions) > 200) {
+ uasort($sessions, fn($a, $b) => strcmp($a['last_seen'] ?? '', $b['last_seen'] ?? ''));
+ $sessions = array_slice($sessions, -200, 200, true);
+}
+
+// Write order matters: errors first, then sessions. `synced` is what suppresses a re-add, so
+// recording it before the count lands would permanently lose occurrences; the reverse loses at
+// most one batch of duplicates (harmless). Both files use flock inside save_json().
save_json(ERRORS_FILE, $allErrors);
save_json(SESSIONS_FILE, $sessions);
@@ -117,6 +177,7 @@ file_put_contents($rebuildFlag, time());
json_response([
'success' => true,
'ingested' => $ingested,
+ 'accepted' => $processed,
'session_id' => $sessionId,
'total_errors' => count($allErrors),
'total_sessions' => count($sessions),
diff --git a/api/debug/stats.php b/api/debug/stats.php
index dd0b0db..6e4feb0 100644
--- a/api/debug/stats.php
+++ b/api/debug/stats.php
@@ -37,13 +37,20 @@ foreach ($errors as $e) {
}
}
+$totalOccurrences = 0;
+foreach ($errors as $e) { $totalOccurrences += (int)($e['count'] ?? 1); }
+
usort($topErrors, fn($a, $b) => $b['count'] - $a['count']);
$topErrors = array_slice($topErrors, 0, 20);
ksort($byDay);
json_response([
'success' => true,
- 'total_errors' => $total,
+ // total_errors = OCCURRENCES (sum of counts); unique_errors = distinct signatures.
+ // Before 2026-10-23 the key held the record count while by_type held occurrences,
+ // so two numbers on the same screen disagreed by ~3,500x.
+ 'total_errors' => $totalOccurrences,
+ 'unique_errors' => $total,
'total_sessions' => count($sessions),
'by_type' => $byType,
'by_severity' => $bySeverity,