New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

@statoss/sdk

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@statoss/sdk

Error tracking and logs for StatOSS: browsers, Node, Bun, Deno, Cloudflare Workers and Vercel Edge. Speaks Sentry's protocol.

latest
Source
npmnpm
Version
0.4.0
Version published
Weekly downloads
230
-66.67%
Maintainers
1
Weekly downloads
 
Created
Source

@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     # or: npm install, yarn add, bun add
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:

    ModeAfter capturing
    throw (the default)Raised as an uncaught exception: your uncaughtException listener gets it, or the process exits 1
    strictThe same; Node raises it itself
    warn-with-error-codePrinted; the process keeps running and later exits with 1
    warn, noneThe 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,
    },
  });
  // The Edge runtime may stop once the response is sent.
  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" },
});
// level "error", tags { "statoss.level": "payment-failed",
//   "statoss.category": "billing", route: "/pay" }

statoss.captureMessage("Search took 2.4 s", {
  level: "slow-query",
  fields: { duration_ms: 2400 },
});

// @ts-expect-error: not a level of this schema
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");
// level "error", tags { "statoss.level": "payment-failed" }

StatOSS.captureException(error, {
  level: "slow-query",
  category: "db.postgres",
});
// level "warning", tags { "statoss.level": "slow-query", "statoss.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 });
// level "error", severity 17, attributes { "statoss.level": "payment-failed", orderId: 7 }

// @ts-expect-error: not a level of the schema
statoss.logger.log("payment-faild", "Card declined");

What is captured

IntegrationRuntimesWhat it does
globalHandlersbrowser, Node, Bun, DenoUncaught errors and unhandled rejections
consoleallA breadcrumb per console call
fetchallA breadcrumb per request: method, URL, status
navigationbrowserA breadcrumb per pushState, replaceState and popstate
clicksbrowserA breadcrumb per click, with a short selector
dedupeallDrops 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"); // carries the user anywhere
});

API

init(options): handle                                // handle.captureException, captureMessage, captureEvent, logger
defineSchema(schema): schema                         // the schema as given, its names as types
logger.trace, debug, info, warn, error, fatal(message, attributes?)
logger.log(level, message, attributes?)              // a level by name, your own too
logger.fmt`text ${value}`                            // a message with its template and values
captureException(error, context?): string            // returns the event id
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                          // callback gets an isolated scope
flush(timeoutMs = 2000): Promise<boolean>            // true once nothing is left to send
close(timeoutMs = 2000): Promise<boolean>            // flush, then stop and unpatch
lastEventId(): string | undefined

// on the scope withScope passes
scope.setUser, setTag, setTags, setExtra, setExtras, setContext, setLevel,
  setFingerprint, addBreadcrumb, clearBreadcrumbs, clear
scope.captureException, captureMessage, captureEvent // with this scope

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

OptionDefaultMeaning
dsnWhere to send. Without it the SDK stays off.
environment"production"
release, dist
serverNamehost nameNode and Bun only, unless set
sampleRate1Share of events sent, 0 to 1
beforeSend(event, hint) => event | null, may be async
beforeBreadcrumb(breadcrumb, hint) => breadcrumb | null
maxBreadcrumbs100100 at most
sendDefaultPiifalseRecord the sender's IP address; the user on log lines
tagsTags on every event
initialScope{ tags, extra, contexts, user, level, fingerprint }
schemaFrom defineSchema: your levels, categories, fields
levelsYour level names and the Sentry level each is sent as
logsfalseSend logger's lines
beforeSendLog(log) => log | null
integrationsall onSee above
enabledtrue
debugfalseLog what the SDK does
transportfetch(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    # ESM in sdk/dist/esm, CommonJS in sdk/dist/cjs, .d.ts in both
pnpm test               # runs sdk/test with the rest of the repository's tests

The build uses the repository's TypeScript; the package has no dependencies.

License

MIT

Keywords

statoss

FAQs

Package last updated on 11 Oct 2026

Related posts