TypeScript error tracking
Know when your TypeScript app breaks, before your users do
ForgeOps connects the errors your TypeScript app reports to the deploys, traces, and changes around them, so when production breaks, the incident already says who's affected, what changed, and where to look.
Catches window errors and unhandled promise rejections automatically in any browser app. Running on ForgeOps' own dedicated infrastructure, so nothing about a TypeScript app's errors or its users' data gets routed through a third-party error-tracking vendor. See an incident investigated →
Install
npm install @forge-ops/tracker-web
import * as forgeOpsTracker from "@forge-ops/tracker-web";
forgeOpsTracker.init({ dsn: "https://<api_key>@getforgeops.net/api/v1/events" });
Catches window errors and unhandled promise rejections automatically in any browser app.
Every client reports the same shape
Wherever it comes from, an event arrives with an exception class, a message, and a backtrace, plus whatever environment/release/server context that client can gather on its own. ForgeOps fingerprints and groups on the first three, so a TypeScript app's issues list works exactly the same way every other language's does: report the same bug a thousand times and it's still one row, not a thousand.
No agent, no sidecar: it's a plain HTTPS POST to ForgeOps' own ingestion API, so nothing about a TypeScript app's traffic gets routed through a third-party pipeline first.
Works with your framework
The TypeScript client hooks in at the framework level, not by wrapping individual calls, so nothing about how you already write TypeScript code has to change.
Plain pages
init() installs window error and unhandledrejection listeners by default, covering anything that escapes with no framework in the way.
React
ForgeOpsErrorBoundary reports via componentDidCatch, after React has already committed the fallback UI.
Next.js
A single onRequestError export covers App Router and Pages Router server-side errors alike, no further wiring.
What it looks like
The issue list
Every TypeScript issue reported into a project shows up here: title, status, assignee, event count, and when it was last seen, the same columns as any other language's project.
The issue detail
Every occurrence keeps its own timestamp, environment, release, and server, plus the backtrace exactly as TypeScript reported it, expandable per occurrence rather than flattened into one generic stack.
Alerting
A new TypeScript issue, a regression, or a spike past a threshold you set can reach Slack, Microsoft Teams, email, PagerDuty, Opsgenie, or a generic webhook, configured per project, per trigger.
Heartbeats
For the failure mode error tracking can't see on its own: a TypeScript cron job or recurring task that's supposed to run but silently stopped. Each heartbeat gets its own ping URL and its own grace period before it alerts.
Breadcrumbs
Every reported error carries a trail of whatever just happened in the page: console output, navigation (including a client-side router's own pushState/replaceState calls), clicks, and outgoing fetch/XHR requests, all captured automatically with no wiring needed. On by default; a bounded, in-order trail, not an unbounded log, so a long-lived single-page app session never grows this without bound.
forgeOpsTracker.init({
dsn: "...",
breadcrumbs: false, // opt out of the automatic sources entirely
maxBreadcrumbs: 50, // default 30
});
Add your own alongside the automatic ones for anything specific to this app that none of them would know to record:
forgeOpsTracker.addBreadcrumb({
category: "auth",
message: "user logged in",
level: "info",
});
Web Vitals
Once your app is reporting into ForgeOps, init() also captures Core Web Vitals: LCP, CLS, INP, FCP, and TTFB, sent as one measurement once the page becomes hidden, with whatever subset actually finished measuring by then. On by default, no wiring needed beyond init() itself; see p75 for each metric, and a breakdown by page, on a project's own Web Vitals page.
forgeOpsTracker.init({
dsn: "...",
webVitals: false, // opt out entirely
});
Performance monitoring
Times work and reports one small aggregate per transaction, flushed on a timer, never one network call per timed call. Every fetch and XMLHttpRequest is timed automatically under METHOD host (for example GET api.example.com), never the full URL, so a path with an id in it doesn't give every distinct id its own row; this client's own delivery requests are never timed. For anything else, such as a route change or a heavy render, wrap it yourself with a low-cardinality name. timeTransaction returns whatever the function returned, waits for a returned promise to settle, and records even if it throws. This is separate from Web Vitals above, which measures how one page load felt. Turn it all off with trackPerformance: false, or change the interval with performanceFlushIntervalMs. A hidden or closing tab is flushed for you. Each report also carries a small latency histogram, so ForgeOps shows an approximate p50/p95/p99 per transaction, not just an average, accurate to the width of the latency bucket a duration falls into. Requires a plan that includes performance monitoring; on a plan that doesn't, the periodic reports are accepted but not recorded (the response says why), so a working setup never looks broken.
const results = await forgeOpsTracker.timeTransaction("search", () => api.search(query));
forgeOpsTracker.recordPerformance("render:dashboard", elapsedMs); // or a duration you measured yourself
Distributed tracing
One flow's own call tree, such as a checkout, a page's data fetching, or a click handler and what it triggered. Any fetch or XMLHttpRequest made while exactly one trace is open is recorded into it as an http span automatically. Only a slow flow's trace is ever sent: whether it crossed the threshold (one second by default, configurable) is decided entirely inside your app, so a fast flow costs nothing over the wire. Requires a plan that includes distributed tracing.
import { trace, startTrace } from "@forge-ops/tracker-web";
const order = await trace("checkout", async (t) => {
const cart = await t.span("load cart", () => api.cart(), { kind: "http" });
return t.span("charge", () => api.charge(cart), { data: { items: cart.length } });
});
// Or hold the trace across the flow and finish it when it ends:
const t = startTrace("checkout");
t.recordSpan("render", { kind: "service", startedAt: started, durationMs: elapsed });
t.finish();
Fetch through the trace with t.fetch (or add t.httpSpan's headers to any other client) and the request goes out with the standard traceparent header, recorded as its own http span; fetch and XMLHttpRequest calls made while exactly one trace is open get it on their own. By default only same-origin requests get the header: list your API hosts in tracePropagationTargets to include them, and allow traceparent in those hosts' CORS headers, or the browser blocks the request. An error captured inside the trace carries its trace id, so with your backend on the current ForgeOps server SDK and the projects linked in ForgeOps, the failed checkout shows the API error from the very same request. Needs @forge-ops/tracker-web 0.9.0.
forgeOpsTracker.init({ dsn: "...", tracePropagationTargets: ["api.example.com"] });
const t = startTrace("Checkout");
try {
const response = await t.fetch("https://api.example.com/orders", { method: "POST", body: JSON.stringify(cart) });
if (!response.ok) throw new Error(`Checkout failed with ${response.status}`);
} catch (error) {
captureException(error as Error, { cartId: cart.id }); // sent with t.traceId
} finally {
t.finish();
}
SQL in a request
When an error is captured inside a trace that ran database queries, its issue opens with the slowest of them: how long it took, its share of the trace's time, how many queries ran, and a likely N+1 warning when the same statement ran five or more times. Pass a query's SQL as statement on a database span (an in-browser SQLite query, say). The statement is masked before it leaves your page, every string and number replaced with a question mark, and the same SQL shows under that span's bar in the trace's waterfall. Needs @forge-ops/tracker-web 0.11.0, on a plan that includes distributed tracing.
import { trace } from "@forge-ops/tracker-web";
const sql = "SELECT * FROM orders WHERE customer_id = 42 AND status = 'open'";
const orders = await trace("orders page", (t) =>
t.span("Load orders", () => db.exec(sql), { kind: "database", statement: sql, dbSystem: "sqlite" }),
);
// Sent as "SELECT * FROM orders WHERE customer_id = ? AND status = ?"
What changed
When a feature flag flips or a remote config value changes, record it from your flag client's callback, and it shows under What changed on any issue that starts in the next two hours, and on the project's Changes page beside your deploys, labeled potentially relevant, never as the cause. "Errors started right after new_checkout turned on" becomes one glance. recordChange sends in the background, returns immediately, and never throws. A browser has nothing of its own to compare between deploys, so every change is one you record. Needs @forge-ops/tracker-web 0.10.0, on a plan that includes change tracking.
import { recordChange } from "@forge-ops/tracker-web";
// Your flag client's change listener, with the key and the old and new values
flagClient.on("change", (key: string, value: boolean, previous: boolean) => {
recordChange("feature_flag", `${key} turned ${value ? "on" : "off"}`, {
details: { key, from: previous, to: value },
actor: "flag-service",
});
});
Custom metrics and infrastructure monitoring
Track a business event you name yourself (a signup, a payment) or a reading from one of your own hosts, with one explicit call each; nothing is automatic. The value defaults to 1, so a bare call is a counter; pass a real amount for anything else (it can be negative, for a refund). A captured value is stored as its own row, so counts and sums you compute later are exact. Calls are buffered and flushed as one batch in the background, so they are safe to make inside a request, and flushMetrics sends whatever is buffered right now. An infrastructure reading needs a hostname, and this client has no server name by default, so pass one or set the server name; a reading with none is dropped. Requires a plan that includes custom metrics and infrastructure monitoring.
import { captureMetric, captureInfrastructureMetric, flushMetrics } from "@forge-ops/tracker-web";
captureMetric("signup"); // value defaults to 1: a bare counter
captureMetric("payment", 49); // a real magnitude; it may be negative (a refund)
captureInfrastructureMetric("queue_depth", 12, { hostname: "worker-1" });
await flushMetrics(); // send right now
Database errors
When an error comes from a database call, the issue shows which stored procedure, table or view its SQL touched, so you know where to start looking. A browser rarely runs SQL, but an error from server-rendered code or an in-browser SQLite build can carry the statement as a .sql or .query string, which is read automatically. Otherwise attach it where you ran the query with withSql. The names are sent by default and are identifiers, never values. The SQL statement itself is opt-in, with every string and number replaced by a question mark before it leaves your app, and a project setting can stop ForgeOps storing the statement at all.
import { withSql } from "@forge-ops/tracker-web";
try {
db.exec(sql);
} catch (error) {
throw withSql(error as Error, sql);
}
// Opt in to also sending the masked statement (default false).
forgeOpsTracker.init({ dsn: "...", captureSqlStatement: true });
Questions
Do I need to change how I already handle errors?
Almost never. Every client hooks the framework's own exception handling directly, the same way the install snippet above shows, so your own logs, error pages, and rescue blocks keep working exactly as they did before, and ForgeOps just also finds out about it. The one common exception is a background-job runner that doesn't forward through that same mechanism on its own; that needs one small hook added instead, not a change to anything already there.
Where does the data actually go?
Straight to ForgeOps' own ingestion API over plain HTTPS. No agent, no sidecar, and no third-party ingestion service in between, so nothing about your app's traffic or its users' data gets routed through anyone else's pipeline first.
What happens if the same bug fires a thousand times?
It's fingerprinted from its exception class, message, and backtrace, so a thousand reports of the same bug still show up as one issue with an event count of 1,000, not a thousand separate rows to dig through. And a runaway loop can't use up your monthly event quota: once one issue repeats faster than your plan's hourly limit (50 an hour on Free), further repeats are still counted, and still count toward spike alerts, but they aren't stored or charged to your quota.
Is anything scrubbed before it's stored?
Yes. Emails, credit card numbers, and known API key/token formats are redacted out of every event before it's even written, on every plan, with room for per-project custom field names on top of the defaults.
Try it with your own TypeScript app
Free plan included, no credit card required. New organizations start with 14 days of Business.
Get started freeGet