window.onerror + 20 Lines of PHP = Your Own Error Tracker
The uncomfortable truth about frontend errors: for every user who emails you "the button doesn't work", dozens hit the same exception and just leave. For years my sites failed silently until I added a twenty-minute DIY tracker, and the first day of logs was humbling.
The client side: two listeners
function report(payload) {
const body = JSON.stringify({
...payload,
url: location.href,
ua: navigator.userAgent,
});
navigator.sendBeacon('/js-error.php', new Blob([body], { type: 'application/json' }));
}
window.addEventListener('error', (e) => report({
type: 'error',
message: e.message,
source: `${e.filename}:${e.lineno}:${e.colno}`,
stack: e.error?.stack?.slice(0, 2000),
}));
window.addEventListener('unhandledrejection', (e) => report({
type: 'promise',
message: String(e.reason?.message ?? e.reason).slice(0, 500),
stack: e.reason?.stack?.slice(0, 2000),
}));
The second listener is the one people forget: rejected promises never hit window.onerror, and in async-heavy code they're the majority of failures. sendBeacon (covered in detail here) makes delivery survive tab closes - errors right before a rage-quit are precisely the interesting ones.
The server side
<?php
$raw = file_get_contents('php://input');
if (strlen($raw) > 10000) { http_response_code(413); exit; }
$data = json_decode($raw, true);
if (!is_array($data) || empty($data['message'])) { http_response_code(400); exit; }
$line = json_encode([
'ts' => date('c'),
'ip' => $_SERVER['REMOTE_ADDR'],
'type' => substr($data['type'] ?? '?', 0, 20),
'message' => substr($data['message'], 0, 500),
'source' => substr($data['source'] ?? '', 0, 300),
'url' => substr($data['url'] ?? '', 0, 300),
'stack' => substr($data['stack'] ?? '', 0, 2000),
]);
file_put_contents(__DIR__ . '/../logs/js-errors.log', $line . PHP_EOL, FILE_APPEND | LOCK_EX);
http_response_code(204);
One JSON object per line means grep, tail and jq all work on it. LOCK_EX keeps concurrent writes intact.
Hard-won advice
- Dedupe client-side. A error inside a scroll handler can fire hundreds of times. Keep a Set of seen messages and report each once per page load.
- Expect noise. "Script error." with no details is a cross-origin script erroring (add crossorigin="anonymous" + CORS headers to your own CDN scripts to unmask them). Extensions inject errors too; filter messages from chrome-extension:// sources.
- Rotate the log. A cron gzip-and-truncate keeps it from eating the disk.
Is this Sentry? No - no sourcemaps, no grouping UI. But it costs nothing, has no third-party data sharing to explain in a privacy policy, and going from zero visibility to "any error, any user, in a log I can tail" is the single biggest jump in debugging capability I know.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.