aboutsummaryrefslogtreecommitdiff
path: root/api/debug/ingest.php
blob: 7388bce1c2c912a7a7d1f6560cb8340dd3356d0c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
<?php
require_once __DIR__ . '/config.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    error_response('Method not allowed', 405);
}

$raw = file_get_contents('php://input');
if (!$raw) error_response('Empty request body');

$payload = json_decode($raw, true);
if (!$payload || !isset($payload['errors']) || !is_array($payload['errors'])) {
    error_response('Invalid payload: expected { errors: [...], session: {...} }');
}

$errors = $payload['errors'];
$session = $payload['session'] ?? [];
$clientVersion = $payload['version'] ?? 'unknown';

// Client-supplied counts are untrusted: clamp so a bad client cannot store 38,495× rows.
function clamp_count($v) {
    $n = (int)$v;
    return $n < 1 ? 1 : ($n > 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),
]);