1000 ? 1000 : $n); } // Validate batch size if (count($errors) > MAX_ERRORS_PER_BATCH) { $errors = array_slice($errors, 0, MAX_ERRORS_PER_BATCH); } if (strlen($raw) > MAX_BATCH_SIZE_KB * 1024) { error_response('Payload too large (max ' . MAX_BATCH_SIZE_KB . 'KB)', 413); } // Load existing data $allErrors = load_json(ERRORS_FILE); $sessions = load_json(SESSIONS_FILE); $sessionId = $session['id'] ?? ('anon_' . bin2hex(random_bytes(4))); $ingested = 0; $now = date('c'); // Update session metadata if (!isset($sessions[$sessionId])) { $sessions[$sessionId] = [ 'id' => $sessionId, 'first_seen' => $now, 'version' => $clientVersion, 'modules' => $session['modules'] ?? [], 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '', 'ip' => $_SERVER['REMOTE_ADDR'] ?? '', 'error_count' => 0, ]; } $sessions[$sessionId]['last_seen'] = $now; $sessions[$sessionId]['error_count'] += count($errors); $sessions[$sessionId]['version'] = $clientVersion; // Build a lookup of existing hashes for dedup $existingHashes = []; foreach ($allErrors as $idx => $existing) { $h = $existing['hash'] ?? ''; if ($h) { $existingHashes[$h] = $idx; } } // 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])) { $idx = $existingHashes[$hash]; 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; } continue; } $entry = [ 'hash' => $hash, 'message' => substr($e['message'] ?? '', 0, 500), 'type' => $e['type'] ?? 'unknown', 'severity' => $e['severity'] ?? 'error', 'category' => $e['category'] ?? 'runtime', 'source' => $e['source'] ?? '', 'module' => $e['module'] ?? null, 'stack' => isset($e['stack']) ? substr($e['stack'], 0, 2000) : null, 'session_id' => $sessionId, '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 += $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) 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); // Rebuild clusters asynchronously (flag file) $rebuildFlag = DEBUG_DATA_DIR . '/.rebuild_clusters'; file_put_contents($rebuildFlag, time()); json_response([ 'success' => true, 'ingested' => $ingested, 'accepted' => $processed, 'session_id' => $sessionId, 'total_errors' => count($allErrors), 'total_sessions' => count($sessions), ]);