Error tracking
Post exceptions from your app to an ingest endpoint. HostStack groups them into issues, alerts you when one is new or comes back, and can turn any issue into a briefed task for a coding agent in your dev box.
How it works
Uptime checks tell you a service stopped answering. Error tracking tells you what threw, where, on which release, and how many people hit it — something only your own process knows. So your app reports it: one HTTP POST per batch of errors, to an endpoint that carries a write-only key for one service.
- There is no SDK to install and nothing to keep up to date. A handler is about twenty lines, and you can read exactly what leaves your process.
- Reports become issues: one row per distinct problem, with an exact count, the releases it was first and last seen on, and a sample of full occurrences.
- Issues live on the service's Errors tab, and in the CLI, SDK and MCP server.
Set it up
Mint an ingest key
Open the service, go to the Errors tab and click New key — or run
hoststack errors keys new <service-id>. Copy the key: it is shown once and stored only as a SHA-256 hash.Put the endpoint in the environment
Add it to the service as
HOSTSTACK_ERRORS_URL(the name is yours to choose; the snippets below use this one):https://hoststack.dev/api/ingest/errors/ing_...A browser bundle needs the value at build time instead — read Report from the browser first.
Paste a handler and deploy
Pick the snippet for your runtime below. Once the new deploy is live, the first exception shows up on the Errors tab.
Report from a server
Each handler catches what would otherwise kill the process, posts it, and never throws itself. A server reads its environment when it runs, so HOSTSTACK_ERRORS_URL can be a runtime variable and changes take effect on the next restart.
const HOSTSTACK_ERRORS = process.env.HOSTSTACK_ERRORS_URL;
async function report(err, context = {}) {
try {
await fetch(HOSTSTACK_ERRORS, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
// Injected by HostStack. Sending it labels the error with the build
// that threw, even mid-deploy when two containers are alive.
release: process.env.HOSTSTACK_RELEASE,
events: [{
type: err?.name ?? 'Error',
// Longer fields fail validation and the whole request is refused.
value: String(err?.message ?? err).slice(0, 2000),
stack: err?.stack?.slice(0, 16384),
level: 'error',
context,
}],
}),
});
} catch {
// Never let the reporter take down the process it is reporting for.
}
}
process.on('uncaughtException', (err) => void report(err));
process.on('unhandledRejection', (err) => void report(err));import json, os, sys, traceback, urllib.request
HOSTSTACK_ERRORS = os.environ["HOSTSTACK_ERRORS_URL"]
def report(exc_type, exc, tb, context=None):
payload = {"release": os.environ.get("HOSTSTACK_RELEASE"), "events": [{
"type": exc_type.__name__,
"value": str(exc)[:2000],
"stack": "".join(traceback.format_exception(exc_type, exc, tb))[-16384:],
"level": "error",
"context": context or {},
}]}
req = urllib.request.Request(
HOSTSTACK_ERRORS,
data=json.dumps(payload).encode(),
headers={"content-type": "application/json"},
)
try:
urllib.request.urlopen(req, timeout=3).read()
except Exception:
pass # never let the reporter raise
sys.excepthook = report<?php
function hoststack_report(Throwable $e): void {
$payload = json_encode([
'release' => getenv('HOSTSTACK_RELEASE') ?: null,
'events' => [[
'type' => get_class($e),
'value' => mb_substr($e->getMessage(), 0, 2000),
'stack' => mb_substr($e->getTraceAsString(), 0, 16384),
'level' => 'error',
]],
]);
$ch = curl_init(getenv('HOSTSTACK_ERRORS_URL'));
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
]);
curl_exec($ch); // failures are ignored on purpose
curl_close($ch);
}
set_exception_handler('hoststack_report');To report errors your framework catches — which never reach uncaughtException — call report(err, context) from its error handler as well, with the request path and method as context.
Report from the browser
This is the step people lose an afternoon to. A browser bundle has no environment: the bundler substitutes the value in while it builds, and whatever was there is frozen into the JavaScript you ship. So the variable has to:
- carry the prefix your bundler exposes to client code —
VITE_,NEXT_PUBLIC_and so on; - reach the build: set it to Build + Runtime or Build only under Inject into, and leave Secret off. A secret set to Build + Runtime is withheld from the build, because build arguments are baked into the image's history;
- be declared with
ARGif you build from your ownDockerfile— Docker drops a build argument the Dockerfile does not declare. HostStack says so in the deploy log when it spots one.
// A bundler INLINES this at build time. The value has to exist when the
// bundle is built — not when the page loads.
const HOSTSTACK_ERRORS = import.meta.env.VITE_ERRORS_URL; // Vite
// const HOSTSTACK_ERRORS = process.env.NEXT_PUBLIC_ERRORS_URL; // Next.js
if (!HOSTSTACK_ERRORS) {
// Keep this line. Without it a missing value is completely silent: the
// bundler inlines `undefined`, the guard below is provably always true, and
// the whole reporter is tree-shaken out of the bundle you ship.
console.warn('[HostStack] build-time errors URL was not set — reporting is off.');
}
function report(event) {
if (!HOSTSTACK_ERRORS) return;
// keepalive, so the request survives the unload that often follows a crash.
// credentials: 'omit', because the ingest key IS the auth, and a credentialed
// cross-origin request is refused by the endpoint's wildcard CORS policy.
// (That is also why this is fetch and not navigator.sendBeacon, which
// always sends credentials.)
fetch(HOSTSTACK_ERRORS, {
method: 'POST',
keepalive: true,
credentials: 'omit',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ platform: 'browser', events: [event] }),
}).catch(() => {});
}
addEventListener('error', (e) =>
report({
type: e.error?.name ?? 'Error',
value: String(e.message).slice(0, 2000),
stack: e.error?.stack?.slice(0, 16384),
level: 'error',
context: { url: location.pathname },
}),
);
addEventListener('unhandledrejection', (e) =>
report({
type: 'UnhandledRejection',
value: String(e.reason?.message ?? e.reason).slice(0, 2000),
stack: e.reason?.stack?.slice(0, 16384),
level: 'error',
context: { url: location.pathname },
}),
);Then check the shipped assets once. It is the only way to tell an inlined value from an absent one after the fact. This works for a Vite build, whose scripts live under /assets/; adjust the pattern for other bundlers.
# Count the shipped scripts that contain the ingest URL.
# 0 means it was compiled out — the variable never reached the build.
site=https://your-app.example
curl -s "$site/" | grep -o '/assets/[^"]*\.js' | sort -u | while read -r asset; do
curl -s "$site$asset" | grep -l 'ingest/errors' >/dev/null && echo "found in $asset"
doneThe ingest key in the bundle is world-readable, and that is expected — see About the ingest key.
The ingest API
POST https://hoststack.dev/api/ingest/errors/<ingest-key> with a JSON body. The key in the path is the only credential: no API key, no session. The endpoint answers CORS preflights with Access-Control-Allow-Origin: *, so a browser can post to it from any origin as long as it does not send credentials.
curl -X POST https://hoststack.dev/api/ingest/errors/ing_... \
-H 'content-type: application/json' \
-d '{
"release": "9f3c1ab",
"environment": "production",
"platform": "node",
"events": [{
"type": "TypeError",
"value": "cart.total is not a function",
"stack": " at checkout (/app/src/checkout.ts:12:9)",
"level": "error",
"requestId": "req_9f3c",
"context": { "url": "/checkout", "method": "POST" },
"user": { "id": "customer-777" }
}]
}'The envelope
eventsarrayrequired1 to 100 events. Batch them — a hot loop throwing thousands of times a second should send tens of requests, not thousands.
releasestringThe commit the code was built from, up to 200 characters. Send
HOSTSTACK_RELEASE, which HostStack injects into every container. Omitted, it defaults to the commit of the service's current production deploy — including one whose container has started but not yet taken traffic. That guess is right almost always; during a deploy, while two containers are alive, only the container itself knows which one it is.environmentstringUp to 100 characters.
Default:
the service's environment nameplatformstringWhat sent it —
node,python,browser. Up to 50 characters.
Each event
typestringrequiredThe exception class —
TypeError,ValueError,PDOException. 1–200 characters.valuestringThe message. At most 2,000 characters.
Default:
""stackstringThe raw stack, exactly as the runtime printed it. At most 16,384 characters. Without it there is no culprit line and no fix-in-dev-box button.
level"error" | "warning" | "fatal"A
warningis tracked and counted but never raises an alert.Default:
errorcontextobjectAny JSON — URL, method, feature flag. Kept up to 8 KB serialised; a context cut at that limit is dropped. Top-level keys whose name contains
password,secret,token,api_key,authorization,sessionorcookieare replaced with<redacted>before anything is stored (sosessionIdis too). Don't rely on that — keep secrets out of it.requestIdstringA correlation ID, up to 200 characters, so you can find the request in your logs.
user.idstringWho hit it, up to 200 characters. Hashed (HMAC-SHA-256, keyed per team) before it is stored, so "how many people hit this" is answered without keeping who they are. Distinct users are counted up to 500 per issue.
fingerprintstring[]Up to 10 strings. Overrides the grouping below entirely — useful when a generic wrapper error collapses several distinct bugs into one issue.
timestampISO 8601 stringAccepted, but currently ignored: the time HostStack received the event is what is stored.
Responses and limits
| Status | When |
|---|---|
| 202 | Accepted. The body is { accepted, dropped, newIssues }; dropped counts events over the hourly quota (still counted on their issue, not stored) and any event that failed to save. |
| 400 | The body failed validation — a missing type, an over-long field, more than 100 events. The whole envelope is refused. |
| 401 | Unknown or revoked ingest key. |
| 413 | The body is larger than 512 KiB (by its Content-Length header). Send fewer events per request. |
| 429 | More than 120 requests a second from one IP address. |
Unlike analytics ingest, this endpoint answers honestly: a mistyped key gets a 401, not a silent success, so "nothing has thrown yet" and "nothing has ever arrived" look different.
How errors are grouped
An issue is one distinct problem, not one event. Its fingerprint is computed once, at ingest, from three things:
- The exception class. A
TypeErrorand aRangeErrorfrom the same line are different bugs. - The message with its variables removed.
user 41 not foundanduser 9002 not foundare one issue, not two hundred. Numbers, UUIDs, hex values and hashes, timestamps and dates, quoted strings, URLs, absolute paths, email addresses and IP addresses are all normalised. - The topmost stack frame in your own code — not the framework's. Grouping on the top frame overall would file every error in your router; grouping on yours files it at the line that needs editing. That frame is the issue's culprit.
Frames count as someone else's code when they are in node_modules, Node internals, site-packages, vendor, gems, Cargo, or a browser extension. Frames in hashed bundle chunks (/assets/index-….js,/_next/static/, webpack-internal:) don't count as yours either, because their line numbers do not exist in your repository. Node, Python, PHP, Ruby and browser stack formats are parsed; a stack that can't be parsed groups on class and message alone.
A fingerprint is never recomputed. If grouping changes, it applies to new events — existing issues are not rewritten underneath you.
Counts, samples and retention
- Counts are exact. An issue that fired 40,000 times says 40,000.
- Samples are not. About the 50 most recent full occurrences — stack, context, request ID, release — are kept per issue. A table that keeps every occurrence is a table that eventually takes the platform down with it.
- 10,000 events per service per hour. Over that, events are still counted on their issue but not stored, and the issue's
droppedCountsays how many. You are never quietly shown a smaller number than the truth. - Occurrences are deleted after 30 days. The issue and its counts stay.
- Resolved and ignored issues are deleted once they have not been seen for 90 days. Unresolved issues are never aged out.
- Deleting a service deletes its issues and occurrences with it.
Alerts
Two events go to your Slack, Discord and email notification channels, and to the dashboard:
| Event | When | Default |
|---|---|---|
| error.issue_new | An error this service has never reported before. At most five of these per service per hour — a deploy that breaks a shared module creates fifty new issues in a minute, and fifty messages is how a channel gets muted. The issues are all still created. | Not critical |
| error.issue_regressed | An issue you marked resolved has happened again. Never rate-limited: it is a statement about a shipped fix, not a new bug. | Critical — emails team owners, and is pre-selected on new channels |
Neither fires for an event with level: "warning", or for a bare Script error. — the placeholder a browser sends for an exception in a cross-origin script, which carries no message, file or line. Both are still tracked.
An issue's alert closes by itself once the issue has not been seen for 72 hours, and reopens if it happens again. The issue itself stays unresolved until you triage it.
Triage an issue
- Mark resolved
Records the release it was resolved in — the service's current deploy. If the issue happens again it reopens itself and fires
error.issue_regressed, naming the fix that did not hold. Don't resolve what you have not actually fixed.IgnoreKeeps counting and stops telling you. An ignored issue never reopens itself, so use it for noise you have decided to live with, not for something you intend to fix.
ReopenPuts a resolved or ignored issue back in the unresolved list.
Resolving or ignoring an issue also closes its open alert. The issue list shows unresolved issues by default.
Fix it in a dev box
Because HostStack also runs your dev box, an issue can become a briefed task for a coding agent there. Fix in a dev box on the issue (or hoststack errors fix <issue-id>) writes a task with:
- the exception, the stack with your own frames marked
>>, the release it happened on, how often and to how many users, and a real request context from a stored occurrence; - the repository and branch, and instructions to reproduce it in a failing test first;
- hard boundaries: commit to a new
fix/issue-…branch, don't deploy, don't disable or delete tests, don't silence the error instead of fixing it, and stop and say so if the cause is not in this repository.
It writes the task; it does not start the agent. Running it spends your own agent tokens, and that is a separate decision made in the box. Pressing it twice for the same issue returns the task that is already open.
From the CLI, SDK or MCP
Everything on the Errors tab is available from the CLI, the SDK and the MCP server, so an agent can triage production errors without a human relaying them.
# Mint the write-only key your app reports with (shown once).
# Service and issue IDs here are the numeric ones.
hoststack errors keys new 48
# What is broken right now (unresolved issues)
hoststack errors list --service 48 --sort count
# Read one issue, with the stack traces behind it
hoststack errors show 12
# Hand it to a coding agent in this project's dev box
hoststack errors fix 12
# Fixed it? Resolve it — you are told if it comes back
hoststack errors resolve 12
# Or: keep counting, stop telling me / undo either
hoststack errors ignore 12
hoststack errors reopen 12import { HostStack } from '@hoststack.dev/sdk';
const hs = new HostStack({ apiKey: process.env.HOSTSTACK_API_KEY! });
const teamId = 1; // "Team ID" in the output of: hoststack whoami
const { key } = await hs.errors.createIngestKey(teamId, 48);
console.log(key.key); // ing_... — the only time the plaintext exists
const { issues } = await hs.errors.listIssues(teamId, { serviceId: 48, sort: 'count' });
const { occurrences } = await hs.errors.listOccurrences(teamId, issues[0].id, 5);
await hs.errors.fixInDevBox(teamId, issues[0].id);
await hs.errors.updateIssue(teamId, issues[0].id, 'resolved');What is throwing in production on the "web" service? Show me the
top issue's stack trace, and if it's in our own code, open a fix task
for it in the dev box.
# Tools: list_error_issues, get_error_issue, update_error_issue,
# fix_error_in_dev_box, list_ingest_keys, create_ingest_key, delete_ingest_keyAbout the ingest key
An ingest key (ing_…) is not an API key. It can create error events for exactly one service and nothing else — it cannot read the issues it created, list your services, or touch anything else on your team. That is why shipping it inside your application, including a browser bundle where anyone can read it, is expected rather than a mistake. The worst a leaked key allows is junk on one service's issue list, bounded by the hourly quota.
- It is stored as a SHA-256 hash, so it is shown exactly once.
- A service can hold up to five keys.
- Rotate without downtime: create a second key, deploy with it, then revoke the first. A revoked key gets a
401. hoststack errors keys list <service-id>shows each key's prefix and when it was last used (updated at most once a minute), so you can tell whether an old key is still in use before revoking it.
Next steps
- Cron & uptimeUptime checks that alert when a service stops answering at all.
- AI dev environmentsWhere fix tasks land, with the repository and a coding agent ready.
- Environment variablesBuild-time versus runtime variables, and why secrets skip the build.
- Site analyticsCookieless pageviews and events for the same sites.
Was this page helpful?