Errors are events too
Before you need a dedicated error tracker, you need to know something broke. Here is how to send every unhandled error to Butters with one small function, what goes in tags versus metadata, and where this approach stops being enough.
Most small apps don't have error tracking. They have logs nobody reads, and a customer who emails three days later to say checkout is broken.
A dedicated error tracker is the right answer eventually. Before that, there is a cheaper step that catches most of the same problems: send every unhandled error to Butters as an event, and let the feed tell you what broke, when, and for whom.
This post walks through the setup for a Node app, what to put where, and the point at which you should outgrow it.
The shape of an error event
An error in Butters is an ordinary event with the category errors. The fields map like this:
- title is the error name and message. It's what you scan in the feed, so keep it specific:
TypeError: Cannot read properties of undefined (reading 'id'). - tags are a few short labels you'll want to search on: environment, service, route. Each pair renders as a badge on the event.
- metadata is everything else. The stack trace, the request details, the job payload. It's stored as JSON with its nesting intact, and the event grows a button that opens it in a dialog.
- url links somewhere useful, like the admin page for the order that failed.
- user_id records whose request it was, if you know.
The split between tags and metadata matters more than it looks. Tags are part of the search index and render inline, so a nested object in tags shows up as [object Object]. Metadata is deliberately not searched: if stack traces were in the index, searching for any common word would match nearly every error you've ever logged. Short labels go in tags. The evidence goes in metadata.
A reporter in thirty lines
// butters.js
const ENDPOINT = 'https://app.getbutters.com/api/events'
export async function reportError(err, { tags = {}, context = {}, userId, url, notify = false } = {}) {
const error = err instanceof Error ? err : new Error(String(err))
try {
await fetch(ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.BUTTERS_API_KEY}`,
},
body: JSON.stringify({
project: process.env.BUTTERS_PROJECT,
category: 'errors',
title: `${error.name}: ${error.message}`.slice(0, 200),
icon: '🔥',
tags: { env: process.env.NODE_ENV ?? 'development', ...tags },
metadata: { stack: (error.stack ?? '').split('\n').slice(0, 30), ...context },
user_id: userId,
url,
notify,
}),
signal: AbortSignal.timeout(2000),
})
} catch {
// Reporting must never throw. Losing one report beats failing the request.
}
} A few details are deliberate.
The stack is trimmed to 30 lines. Metadata has a hard cap of 64 KB once serialized, and anything over it is rejected with a 400. Thirty frames is plenty to find the problem and nowhere near the limit. If you attach request bodies or job payloads, trim those too.
The request times out after two seconds. If Butters is slow to answer, your app shouldn't wait on it.
The catch is empty. Error reporting that throws is worse than none. If a report fails you lose one event, not the user's request.
The key stays on the server. An API key belongs to your whole organization, not a single project, and it can read events as well as write them. Never ship it to a browser. If you want frontend errors, post them to your own backend and forward them from there.
Wiring it in
In an Express app, the error middleware catches anything a route throws, and two process handlers catch what escapes everything else:
import { reportError } from './butters.js'
app.use((err, req, res, next) => {
reportError(err, {
tags: { route: req.route?.path ?? req.path, method: req.method },
context: { query: req.query, requestId: req.get('x-request-id') },
userId: req.user?.id,
})
res.status(500).json({ error: 'Something went wrong' })
})
process.on('unhandledRejection', (reason) => {
reportError(reason, { tags: { source: 'unhandledRejection' }, notify: true })
})
process.on('uncaughtException', async (err) => {
await reportError(err, { tags: { source: 'uncaughtException' }, notify: true })
process.exit(1)
}) The middleware doesn't wait for the report, so the user gets their 500 straight away. The crash handler does wait, because the process is about to exit and the report would be lost otherwise.
notify: true only goes on the process-level handlers. An unhandled rejection usually means something is about to fall over, which is worth a notification on your phone. A single 500 from one route usually isn't. Notifications only mean something if they're rare.
Don't let one bug send ten thousand events
Butters records every event you send. It doesn't group duplicates the way a dedicated error tracker does. If a bad deploy makes every request throw, you get one event per request, and the feed turns into a wall of the same line.
Put a small throttle in front of the reporter:
const lastSent = new Map()
export function reportErrorThrottled(err, options) {
const key = `${err?.name}:${err?.message}`
const now = Date.now()
if (now - (lastSent.get(key) ?? 0) < 60_000) return
lastSent.set(key, now)
return reportError(err, options)
} That caps each distinct error at one event per minute per process. It's crude, and crude is fine here: you still see the error within a minute of it starting, and you can still see that it's ongoing. If your error messages contain ids, key on the name plus the route instead, or the map will keep every unique message it has ever seen.
Reading the feed
Once errors are flowing, three features earn their keep.
Filter to the category. The feed's filters live in the URL, so a view filtered to errors can be bookmarked and opens already filtered.
Search the tags. Search covers titles, descriptions and tags, so searching checkout finds every error tagged with that route. It won't look inside metadata, which is exactly why the route belongs in tags.
Star what you're working on. Favorite the event you're chasing and switch on the favorites-only toggle. It works as a small to-do list that clears itself when you unstar.
The chart for the errors category is a quick health check. A flat line with a spike on Tuesday tells you when something went in. If you're also sending deploy events to the same project, the feed tells you what went in.
Where this stops being enough
Butters doesn't group errors by fingerprint, resolve source maps, track which release introduced a bug, or mark an issue resolved. Those are the things a dedicated error tracker is built around, and once you have more than a handful of distinct errors a day, you'll want them.
What Butters gives you is the step before that. Errors land in the same feed as your signups, orders and deploys, seconds after they happen, for the cost of one HTTP call. For a lot of small apps, that's the difference between finding out from the logs and finding out from an angry customer.