Skip to content

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

  1. 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.

  2. 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.

  3. 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));

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 ARG if you build from your own Dockerfile — Docker drops a build argument the Dockerfile does not declare. HostStack says so in the deploy log when it spots one.
javascript
// 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.

bash
# 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"
done

The 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.

bash
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

events
arrayrequired

1 to 100 events. Batch them — a hot loop throwing thousands of times a second should send tens of requests, not thousands.

release
string

The 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.

environment
string

Up to 100 characters.

Default: the service's environment name

platform
string

What sent it — node, python, browser. Up to 50 characters.

Each event

type
stringrequired

The exception class — TypeError, ValueError, PDOException. 1–200 characters.

value
string

The message. At most 2,000 characters.

Default: ""

stack
string

The 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 warning is tracked and counted but never raises an alert.

Default: error

context
object

Any 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, session or cookie are replaced with <redacted> before anything is stored (so sessionId is too). Don't rely on that — keep secrets out of it.

requestId
string

A correlation ID, up to 200 characters, so you can find the request in your logs.

user.id
string

Who 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.

fingerprint
string[]

Up to 10 strings. Overrides the grouping below entirely — useful when a generic wrapper error collapses several distinct bugs into one issue.

timestamp
ISO 8601 string

Accepted, but currently ignored: the time HostStack received the event is what is stored.

Responses and limits

StatusWhen
202Accepted. 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.
400The body failed validation — a missing type, an over-long field, more than 100 events. The whole envelope is refused.
401Unknown or revoked ingest key.
413The body is larger than 512 KiB (by its Content-Length header). Send fewer events per request.
429More 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 TypeError and a RangeError from the same line are different bugs.
  • The message with its variables removed. user 41 not found and user 9002 not found are 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 droppedCount says 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:

EventWhenDefault
error.issue_newAn 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_regressedAn 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.

Ignore

Keeps 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.

Reopen

Puts 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 12

About 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

Was this page helpful?

Essential cookies only — for login sessions. No tracking. Details