Skip to content
Carlo Fontanos
Carlo Fontanos

Carlo Fontanos

Software Engineer

I build web applications and share what I learn along the way.

© 2026

window.onerror + 20 Lines of PHP = Your Own Error Tracker

C
Carlo Fontanos
· 2 min read

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.

C
Written by Carlo Fontanos

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.

Your details are only used to reply to you.

Keep reading