@statoss/sdk
Error tracking and logs 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.
Your own levels, categories and fields
Sentry has five levels: fatal, error, warning, info and debug.
StatOSS adds levels of your own, categories and fields. They travel as tags
next to the nearest Sentry level, so Sentry and any Sentry-based tool still
read the event normally.
Declare them with defineSchema and give the schema to init. The capture
functions init returns take only the schema's names, so a wrong one fails
to compile.
import * as StatOSS from "@statoss/sdk";
export const schema = StatOSS.defineSchema({
levels: {
"payment-failed": { like: "error", label: "Payment failed" },
"slow-query": "warning",
},
categories: {
billing: { label: "Billing", color: "plum" },
auth: {},
},
fields: {
route: { label: "Route", filterable: true },
duration_ms: { type: "number", unit: "ms", label: "Duration" },
},
});
const statoss = StatOSS.init({ dsn: process.env.STATOSS_DSN, schema });
statoss.captureException(error, {
level: "payment-failed",
category: "billing",
fields: { route: "/pay" },
});
statoss.captureMessage("Search took 2.4 s", {
level: "slow-query",
fields: { duration_ms: 2400 },
});
statoss.captureException(error, { level: "payment-faild" });
- A level ranks like
fatal, error, warning, info, debug or
trace: give that alone, or like with an optional severity (1 to 24,
inside its range), label and color. It is sent as the nearest Sentry
level (trace as debug; error when none is given).
- A category takes an optional
label, color, owner (a team member's
email) and hidden.
- A field is a tag key, with an optional
label, type (text or
number), unit and filterable. Field values are strings, numbers or
booleans.
- Categories and fields can also be lists of names.
rules set a level or a category for events that match, such as
{ subject: "logger", value: "payments", category: "billing" }. They name
the schema's own levels and categories.
- Colours:
crimson, red, orange, amber, yellow, green, aqua,
teal, blue, violet, indigo, magenta, plum and grey.
- Names: a level is 1 to 32 characters and a category 1 to 64, of lowercase
letters, digits, dots, dashes and underscores. A field is any tag key not
starting
statoss..
defineSchema refuses a wrong name, range, severity, colour or setting at
compile time (TypeScript 5.0 or later). It returns the schema as given:
plain data in the shape StatOSS's schema API takes. A function of yours
that hands a schema to init types it as
<const S extends SchemaSpec & CheckSchema<S>>(schema: S).
Without a schema
The module-level functions take any level or category name, and levels in
init maps your names to Sentry levels:
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",
});
- A name in neither
levels nor the schema is sent as error. Given both,
levels wins.
- Names the SDK cannot send are ignored; with
debug: true it logs each
one.
- StatOSS filters and groups on these tags like any other.
Logs
With logs: true, logger's lines are sent as Sentry's log items, which
StatOSS's Logs area searches. Without it they are dropped.
import * as StatOSS from "@statoss/sdk";
StatOSS.init({ dsn: "...", logs: true });
StatOSS.logger.info("Order paid", { orderId: 7, amount: 12.5 });
StatOSS.logger.warn(StatOSS.logger.fmt`Search for ${term} took ${ms} ms`);
StatOSS.logger.log("error", "Card declined", { orderId: 7 });
trace, debug, info, warn, error and fatal each take a message
and attributes. Strings, numbers and booleans are sent typed; anything
else as JSON.
logger.fmt fills the values in and sends the template and each value
as sentry.message.template and sentry.message.parameter.<n>.
logger.log(level, message, attributes) takes a level by name: one of the
six, a Sentry level (warning), or one of your own. Your own level is
sent at its range's level (trace stays trace), with its severity if
the schema gives one, and its name as the attribute statoss.level. A
name in neither the schema nor levels is sent as error.
- Each line carries
sentry.environment, sentry.release,
sentry.sdk.name, sentry.sdk.version and server.address, and with
sendDefaultPii: true the scope's user.id, user.email and
user.name. A line's own attributes win.
- Each line carries a
trace_id of 32 hex digits, one for the life of the
client, since Sentry's Relay drops a line without one.
beforeSendLog(log) changes a line or drops it (return null).
- A body past 64 KiB is cut there, with its size in bytes as
statoss.truncated, and an attribute's value at 8 KiB, in UTF-8 bytes,
as StatOSS's intake cuts and records them.
- Lines go out 100 at a time, or 5 seconds after the first waits;
flush
and close send what waits, and in Node, Bun and Deno so does the
process running out of work.
With a schema, the logger init returns takes only its levels:
const statoss = StatOSS.init({ dsn: "...", schema, logs: true });
statoss.logger.log("payment-failed", "Card declined", { orderId: 7 });
statoss.logger.log("payment-faild", "Card declined");
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): handle
defineSchema(schema): schema
logger.trace, debug, info, warn, error, fatal(message, attributes?)
logger.log(level, message, attributes?)
logger.fmt`text ${value}`
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 },
and on the handle init returns also fields. 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; the user on log lines |
tags | | Tags on every event |
initialScope | | { tags, extra, contexts, user, level, fingerprint } |
schema | | From defineSchema: your levels, categories, fields |
levels | | Your level names and the Sentry level each is sent as |
logs | false | Send logger's lines |
beforeSendLog | | (log) => log | null |
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, log lines, 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, on events and log lines alike.
Network behaviour
Envelopes go to https://<host>/api/<app number>/envelope/, at most 5 at a
time and 30 queued or in flight, of them at most 10 of log lines; 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