@statoss/sdk
Error tracking for StatOSS. One package for browsers,
Node, Bun, Deno, Cloudflare Workers and Vercel Edge, with TypeScript types
included and no dependencies.
The SDK speaks Sentry's protocol (envelopes, event schema, rate limits,
client reports), so it can also send to Sentry, and Sentry's own SDKs can
send to StatOSS.
Install
pnpm add @statoss/sdk
deno add npm:@statoss/sdk
Node 20.16 or later on 20, or 22.3 or later. Older Node lacks
process.getBuiltinModule, which the SDK uses to reach AsyncLocalStorage
without a static import that would break browser and edge bundles; without it,
withScope could not keep concurrent requests apart (see
Scopes).
Start
Call init once, as early as you can, with your app's DSN. The DSN is on the
app's setup page in StatOSS and looks like
https://<key>@ingest.statoss.com/<app number>.
import * as StatOSS from "@statoss/sdk";
StatOSS.init({
dsn: "https://0123456789abcdef0123456789abcdef@ingest.statoss.com/42",
environment: "production",
release: "shop@1.4.0",
});
try {
await chargeCard(order);
} catch (error) {
StatOSS.captureException(error, { tags: { order: order.id } });
}
StatOSS.captureMessage("Nightly export finished late", "warning");
Without a DSN, with an invalid one, or with enabled: false, the SDK stays
off: nothing is sent and nothing is patched. The SDK never throws into your
app and never logs, unless you pass debug: true.
Browser
import * as StatOSS from "@statoss/sdk";
StatOSS.init({
dsn: "https://<key>@ingest.statoss.com/<app number>",
release: "web@5.0.0",
});
Uncaught errors and unhandled rejections are captured. Breadcrumbs record
console calls, fetch requests, history navigation and clicks. Each event
carries the page URL, the user agent, and the browser and OS read from it.
The key travels in the URL query and the body as text/plain, so sending
needs no CORS preflight. Small envelopes use keepalive, so an error
captured just before the page unloads still arrives.
Script tags from other origins only report "Script error." unless they are
served with CORS headers and loaded with crossorigin="anonymous".
Node
import * as StatOSS from "@statoss/sdk";
StatOSS.init({
dsn: process.env.STATOSS_DSN,
environment: process.env.NODE_ENV,
release: process.env.RELEASE,
});
-
An uncaught exception is captured at level fatal. If your app has no
uncaughtException listener of its own, the SDK prints the error, sets the
exit code to 1, sends the event (2 seconds at most) and exits with 1, as Node
would have. If it has one, your listener decides.
-
An unhandled rejection is captured and then handled as Node's
--unhandled-rejections mode says, which the SDK reads from the command
line and NODE_OPTIONS:
throw (the default) | Raised as an uncaught exception: your uncaughtException listener gets it, or the process exits 1 |
strict | The same; Node raises it itself |
warn-with-error-code | Printed; the process keeps running and later exits with 1 |
warn, none | The process keeps running |
An unhandledRejection listener of your own decides instead. To keep
running after unhandled rejections, as Sentry's default does, start Node
with --unhandled-rejections=warn.
-
server_name is the host name. Events also carry the Node version, the OS
and the CPU architecture.
-
withScope uses AsyncLocalStorage, so what you set inside one stays with
that request across awaits.
app.use((req, res, next) => {
StatOSS.withScope((scope) => {
scope.setUser({ id: req.user?.id });
scope.setTag("route", req.route?.path);
next();
});
});
Next.js
Server side, in instrumentation.ts at the project root (or in src/). This
runs in both the Node.js and the Edge runtime.
import * as StatOSS from "@statoss/sdk";
import type { Instrumentation } from "next";
export function register() {
StatOSS.init({
dsn: process.env.STATOSS_DSN,
environment: process.env.NODE_ENV,
});
}
export const onRequestError: Instrumentation.onRequestError = async (
error,
request,
context,
) => {
StatOSS.captureException(error, {
tags: {
route: context.routePath,
routeType: context.routeType,
method: request.method,
},
});
await StatOSS.flush(2000);
};
Browser side, in instrumentation-client.ts:
import * as StatOSS from "@statoss/sdk";
StatOSS.init({ dsn: process.env.NEXT_PUBLIC_STATOSS_DSN });
Errors that an error boundary catches never reach the browser's global
handler, so report them from app/error.tsx and app/global-error.tsx:
"use client";
import * as StatOSS from "@statoss/sdk";
import { useEffect } from "react";
export default function Error({
error,
}: {
error: Error & { digest?: string };
}) {
useEffect(() => {
StatOSS.captureException(error, { tags: { digest: error.digest } });
}, [error]);
return <p>Something went wrong.</p>;
}
Bun
import * as StatOSS from "@statoss/sdk";
StatOSS.init({ dsn: Bun.env.STATOSS_DSN });
Bun behaves as Node does above, including uncaught exceptions and unhandled
rejections.
Deno
import * as StatOSS from "npm:@statoss/sdk";
StatOSS.init({ dsn: Deno.env.get("STATOSS_DSN") });
Deno.serve((request) => {
return StatOSS.withScope((scope) => {
scope.setTag("path", new URL(request.url).pathname);
return handle(request);
});
});
Run with --allow-net=ingest.statoss.com (and --allow-env for the DSN).
An uncaught error or unhandled rejection is captured at level fatal and
sent, then Deno prints it and exits as it would have. A listener of yours that
calls event.preventDefault() first keeps the process running. server_name
is set only when the sys permission is already granted; the SDK never asks.
Cloudflare Workers
Workers have no global error hook, so capture in your handler and hand the
sending to waitUntil, which keeps the Worker alive until it is done.
import * as StatOSS from "@statoss/sdk";
let started = false;
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
if (!started) {
StatOSS.init({ dsn: env.STATOSS_DSN, release: env.RELEASE });
started = true;
}
try {
return await handle(request, env);
} catch (error) {
StatOSS.captureException(error, {
tags: { path: new URL(request.url).pathname },
});
return new Response("Something went wrong", { status: 500 });
} finally {
ctx.waitUntil(StatOSS.flush(2000));
}
},
};
init does no I/O, timers or random numbers, so it may also run at the top of
the module, where Workers forbid those. Turn on the nodejs_compat
compatibility flag for withScope to keep requests apart across awaits
(see Scopes).
Vercel Edge
The Edge runtime has no global error hook either: capture where you handle
errors and await StatOSS.flush(2000) (or pass it to waitUntil) before
returning. In Next.js, the instrumentation.ts above covers it.
Source maps
After the build, give the bundles and their maps debug ids, then upload the
maps with Sentry's CLI:
npx @sentry/cli sourcemaps inject ./dist
SENTRY_URL=https://statoss.com \
SENTRY_AUTH_TOKEN=<a StatOSS API key with write access> \
npx @sentry/cli sourcemaps upload --org statoss --project <app slug> ./dist
Adding --release <release> to the upload is optional. Deploy the bundles as
inject left them: the SDK reads their debug ids and sends them with each
event. Frames are then shown as source and grouped by their source names.
Custom levels and categories
Sentry has five levels: fatal, error, warning, info and debug.
StatOSS lets you use your own words as well, and adds a category. Both are
sent as tags next to the nearest Sentry level, so Sentry and any Sentry-based
tool still read the event normally.
StatOSS.init({
dsn: "...",
levels: {
"payment-failed": "error",
"slow-query": "warning",
},
});
StatOSS.captureMessage("Card declined", "payment-failed");
StatOSS.captureException(error, {
level: "slow-query",
category: "db.postgres",
});
level takes a Sentry level or any name of your own. A name that is not in
levels is sent as error.
category sets the statoss.category tag.
- Names are 1 to 64 characters of lowercase letters, digits, dots, dashes and
underscores. Others are ignored; with
debug: true the SDK logs each one.
- StatOSS filters and groups on these tags like any other.
What is captured
globalHandlers | browser, Node, Bun, Deno | Uncaught errors and unhandled rejections |
console | all | A breadcrumb per console call |
fetch | all | A breadcrumb per request: method, URL, status |
navigation | browser | A breadcrumb per pushState, replaceState and popstate |
clicks | browser | A breadcrumb per click, with a short selector |
dedupe | all | Drops an event identical to the one just before it |
All are on by default. Turn one off with
integrations: { console: false }.
Breadcrumbs keep the last 100. URLs in breadcrumbs and in the page request
lose their credentials, fragment, and the values of query parameters whose
names look secret (token, key, session, password and similar). The sender's
IP address is recorded only with sendDefaultPii: true (browsers send
{{auto}}, and the server reads the address).
Values in breadcrumbs, extra and contexts are copied only so far: 5 levels
deep, 1,000 entries per array or object, 8 KiB per string (1 KiB for a
console breadcrumb). Buffers, typed arrays, ArrayBuffers, Maps and Sets are
written as their type and size, such as [Uint8Array(30000000)], without
reading them, so logging a large value costs the app next to nothing.
Scopes
setUser, setTag and the other setters change the current scope, which
every later event carries. withScope(callback) gives the callback its own
copy, so what it sets stays with the events captured inside it.
Where the runtime has AsyncLocalStorage (Node 20.16+ and 22.3+, Bun, Deno,
Vercel Edge, Next.js, Workers with nodejs_compat), the copy follows the
callback through every await, and concurrent requests never see each other's
user or tags.
Elsewhere (browsers, Workers without nodejs_compat) the copy is current only
until the callback first awaits. It is never left current for other work to
pick up, so one request's user cannot end up on another's error, but after an
await the module-level functions see the outer scope. Capture through the
scope the callback gets instead; with debug: true the SDK warns once.
await StatOSS.withScope(async (scope) => {
scope.setUser({ id: user.id });
await loadCart();
scope.captureMessage("Cart was empty", "warning");
});
API
init(options): void
captureException(error, context?): string
captureMessage(message, levelOrContext?): string
captureEvent(event, context?): string
setUser(user | null), setTag(key, value), setTags(tags)
setContext(name, context | null), setExtra(key, value)
addBreadcrumb(breadcrumb)
withScope(callback): result
flush(timeoutMs = 2000): Promise<boolean>
close(timeoutMs = 2000): Promise<boolean>
lastEventId(): string | undefined
scope.setUser, setTag, setTags, setExtra, setExtras, setContext, setLevel,
setFingerprint, addBreadcrumb, clearBreadcrumbs, clear
scope.captureException, captureMessage, captureEvent
context is { level, tags, extra, contexts, user, fingerprint, category }.
It wins over the scope for that one event.
Options
dsn | | Where to send. Without it the SDK stays off. |
environment | "production" | |
release, dist | | |
serverName | host name | Node and Bun only, unless set |
sampleRate | 1 | Share of events sent, 0 to 1 |
beforeSend | | (event, hint) => event | null, may be async |
beforeBreadcrumb | | (breadcrumb, hint) => breadcrumb | null |
maxBreadcrumbs | 100 | 100 at most |
sendDefaultPii | false | Record the sender's IP address |
tags | | Tags on every event |
initialScope | | { tags, extra, contexts, user, level, fingerprint } |
levels | | Your level names and the Sentry level each is sent as |
integrations | all on | See above |
enabled | true | |
debug | false | Log what the SDK does |
transport | fetch | (request) => Promise<{ status, headers }>, for tests |
Sending to Sentry
Use a Sentry DSN instead. Events, client reports, rate limits (including a
used-up quota) and the X-Sentry-Auth header all follow Sentry's protocol.
Custom levels arrive as Sentry levels with the statoss.level tag.
Network behaviour
Envelopes go to https://<host>/api/<app number>/envelope/, at most 5 at a
time and 30 queued or in flight; more are dropped and counted. When the server answers 429, the SDK holds back
the limited categories for as long as Retry-After or X-Sentry-Rate-Limits
says. Events it could not send (queue full, rate limited, network error,
sampled out, dropped by beforeSend) are counted and reported as Sentry
client reports, on flush and otherwise at most every 30 seconds.
Building
From the repository root:
pnpm --dir sdk build
pnpm test
The build uses the repository's TypeScript; the package has no dependencies.
License
MIT