npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@scoperat/synapse

v1.1.0

Published

SDK for ScopeRat: analytics, feature flags, error capture for browsers and Node.js, session replay, support widget, and release announcements

Downloads

1,582

Readme

@scoperat/synapse

Browser SDK for ScopeRat — analytics, feature flags, error capture, session replay, the support widget, and release announcements — with error reporting for Node servers in @scoperat/synapse/node.

Formerly published as @scoperat/tracker, which is deprecated. Upgrading is a rename: the API, the write key, and the localStorage keys are unchanged, so swap the dependency and the import specifier and nothing else moves.

Install

npm install @scoperat/synapse

Quick Start

import scoperat from "@scoperat/synapse";

// Initialize once at app startup (endpoint defaults to window.location.origin)
scoperat.init("tk_live_your_write_key", {
  release: "1.4.2", // or your git SHA
  captureErrors: true,
});

// Identify the current user after login
scoperat.identify("user_123", { email: "[email protected]", orgId: "org_456" });

// Track events
scoperat.track("button_clicked", { buttonId: "signup-cta", page: "/pricing" });

// Reset on logout
scoperat.reset();

API

scoperat.init(writeKey, options?)

Initialize the SDK. Must be called before any other method.

| Option | Type | Default | Description | | ------------------------- | ------------------------- | ------------------------ | ----------------------------------------------------------------------- | | endpoint | string | window.location.origin | API base URL | | release | string | the bundle's | Your app's version or git SHA, attached to every event | | environment | string | — | Where the app runs (production, staging), on every event | | captureErrors | boolean | false | Report uncaught errors and unhandled rejections automatically | | sendDefaultPii | boolean | false | Record the text of clicked elements in error breadcrumbs | | maxBreadcrumbs | number | 100 | Most breadcrumbs sent with an error, newest kept (100 at most) | | beforeBreadcrumb | (breadcrumb) => … | — | Change a breadcrumb before it's kept, or return null to drop | | ignoreErrors | (string \| RegExp)[] | — | Errors not to send, matched against the message | | denyUrls | (string \| RegExp)[] | — | Don't send errors thrown from scripts at these URLs | | allowUrls | (string \| RegExp)[] | — | Only send errors thrown from scripts at these URLs | | sampleRate | number | 1 | Share of errors sent, from 0 to 1 | | beforeSend | (event) => … | — | Change an error event before it's sent, or return null to drop | | tracePropagationTargets | (string \| RegExp)[] | — | Requests that carry the page's trace, linking server errors (see below) | | offlineQueue | OfflineQueue | — | Keep unsent events for the next visit (see below) | | flushAt | number | 10 | Flush after this many events | | flushInterval | number | 3000 | Auto-flush interval in ms | | timeout | number | 10000 | Request timeout in ms | | maxQueueSize | number | 1000 | Max buffered events (oldest dropped, errors last) | | flagPollInterval | number | 60000 | How often feature flags are fetched again, in ms | | onError | (error, events) => void | — | Called with events that failed to send or were dropped |

Without a release, the SDK reports the one @scoperat/vite-plugin or scoperat sourcemaps inject --release stamped into the bundle. Errors also carry the debug IDs those tools stamp into each script, which find a script's source map whatever its URL. Scripts stamped by Sentry's tooling work too.

scoperat.identify(userId, traits?)

Associate events with a user. Traits are merged into every event's context.

scoperat.track(event, properties?)

Enqueue a tracking event. Events are batched and flushed automatically.

scoperat.getFlag(key, defaultValue)

The flag's value for the current user, or defaultValue when it's off for them or the flags haven't loaded yet. Flags load at init, again after identify, and every flagPollInterval. onFlagsReady(callback) runs once they have, and onFlagChange(listener) each time they're fetched.

scoperat.onFlagsReady(() => {
  if (scoperat.getFlag("new-checkout", false)) showNewCheckout();
});

The flags are the write key's project's: those set for every project, and those for that project alone. A flag's targeting rules compare the traits passed to identify, such as plan or email, so a rule like "plan is pro" turns a flag on for the users identified with { plan: "pro" }. Only traits that are text, numbers or booleans are compared.

Once the flags have loaded, the first time in a session a user is given a flag's value, on or off, the SDK sends a $flag_exposure event with the flag, the value and whether it was on, so funnels can be broken down by what each user saw. Errors are sent with the flags on for the user when they happened: an issue shows what share of its events had each flag on, issue search takes flags.<key>:<value>, and alert rules can filter by the tag flags.<key>.

scoperat.captureException(error, context?)

Report an error you caught yourself, and get back the event's ID. error can be anything that was thrown: strings and plain objects are described rather than dropped, and get a stack from where you called captureException. An error's cause, and each error in an AggregateError, are sent with it, so the issue shows the whole chain.

With captureErrors: true, uncaught errors and unhandled rejections are reported the same way, with breadcrumbs of what led up to them:

  • clicks, naming the element, such as form.checkout > button#pay, but not its text, which can name a person, unless you pass sendDefaultPii: true
  • fetch and XMLHttpRequest requests, with the method, the URL without its query string, the status and how long they took
  • navigation, including history.pushState and hash changes
  • console output at every level

The SDK's own requests to ScopeRat are left out. ScopeRat also scrubs errors before storing them: emails, card numbers, secrets and URL query strings are masked, and fields named like passwords or tokens are dropped. Each project can change this under its error settings.

try {
  await checkout();
} catch (error) {
  const eventId = scoperat.captureException(error, { step: "payment" });
}

Pass fingerprint to choose how the error is grouped. Errors with the same fingerprint share one issue, whatever their stack. Include "{{ default }}" to split ScopeRat's usual grouping further instead of replacing it:

scoperat.captureException(error, {
  fingerprint: ["payment-provider", provider, "{{ default }}"],
});

Pass level ("fatal", "error", "warning", "info" or "debug") to say how serious the error is, and tags to filter and break issues down by short values. Everything else in the context is kept with the event as extra data.

scoperat.captureException(error, {
  level: "warning",
  tags: { provider: "stripe", plan: "pro" },
});

scoperat.captureMessage(message, context?)

Report something that went wrong without an error to throw, such as a payment provider answering in a way you didn't expect. It's grouped and alerted on like an error, at info level unless you pass another. Takes the same context as captureException, or just a level, and returns the event's ID.

scoperat.captureMessage("Checkout fell back to the legacy flow", "warning");

scoperat.lastEventId()

The ID of the last error or message sent, which is how the crash report dialog knows what a report is about.

Filtering errors

Errors you don't want are dropped in the browser, before they're sent:

scoperat.init("tk_live_your_write_key", {
  captureErrors: true,
  ignoreErrors: ["ResizeObserver loop", /^NetworkError/],
  allowUrls: [/https:\/\/app\.example\.com/],
  denyUrls: [/extensions\//, /^chrome:\/\//],
  sampleRate: 0.5,
  beforeSend(event) {
    if (event.tags?.area === "internal-tools") return null;
    return event;
  },
});

ignoreErrors matches the message, or Type: message: a string anywhere in it, a RegExp by testing it. denyUrls and allowUrls match the script the error was thrown from, and an error whose script is unknown is sent. sampleRate keeps that share of errors at random. beforeSend runs last, and can change the event or return null to drop it.

Linking browser and server errors

A page that fails because its API call failed reports one error, and the API reports another. List your API in tracePropagationTargets, and the issue page shows each of them beside the other:

scoperat.init("tk_live_your_write_key", {
  captureErrors: true,
  tracePropagationTargets: ["/", "https://api.example.com"],
});

fetch and XMLHttpRequest calls to those URLs carry a W3C traceparent header, and errors captured on the page carry its trace ID. Each page gets a trace, and a route change starts the next one. A string matches a URL or origin the request's URL starts with, and "/" stands for the page's own origin; a RegExp is tested against the full URL. Nothing is sent by default: a server on another origin has to allow the header in its CORS Access-Control-Allow-Headers, or the browser fails the request at its preflight.

The header is marked sampled, so an OpenTelemetry server whose sampler follows its caller records these requests. Exceptions that reach ScopeRat from OpenTelemetry (/api/ingest/otlp) or a Sentry SDK keep the trace ID they carry, so they link too when it's the page's. @scoperat/synapse/node reads the header itself (see Requests and other work).

scoperat.reset()

Clear user identity and generate a new anonymous ID. Call on logout.

scoperat.flush()

Manually flush the event buffer. Returns a Promise<FlushResult>.

scoperat.shutdown()

Flush remaining events and stop the auto-flush timer. Call before app teardown.

React (@scoperat/synapse/react)

ErrorBoundary reports what a component tree throws while rendering, with the React component stack, and shows a fallback instead. scoperat.init() has to run first.

import { ErrorBoundary } from "@scoperat/synapse/react";

<ErrorBoundary
  fallback={({ error, eventId, resetError }) => (
    <CrashScreen eventId={eventId} onRetry={resetError} />
  )}
  tags={{ area: "checkout" }}
>
  <Checkout />
</ErrorBoundary>;

fallback can also be plain JSX. onError(error, componentStack, eventId) runs after the error is reported, and onReset when resetError is called.

With React 19, reactErrorHandler() reports errors React handles at the root, including those no boundary catches. An error a boundary also reports is sent once.

import { reactErrorHandler } from "@scoperat/synapse/react";

createRoot(container, {
  onUncaughtError: reactErrorHandler(),
  onCaughtError: reactErrorHandler(),
  onRecoverableError: reactErrorHandler(),
}).render(<App />);

Pass a callback, as in reactErrorHandler((error, info) => …), to keep React's own handling, such as logging to the console. React is an optional peer dependency, needed only for this entry point.

Support widget (@scoperat/synapse/widget)

initWidget() adds a launcher that opens your users' conversations with your team: they start one, and read and answer your replies, which arrive in your inbox as tickets.

import { initWidget } from "@scoperat/synapse/widget";

initWidget({ position: "bottom-right" });

When your team has a feedback board, the widget gets an Ideas tab beside the conversations. It lists the board's requests, most wanted first, with their status. Users vote with the email identify() gave, or one they type, and can ask to be emailed once an idea they voted for ships. A suggested idea reaches your inbox as a feature request ticket rather than going on the board by itself. A request ships when a release that names it is published.

Pass labels to change the text (title, newTicket, placeholder, messages, ideas, newIdea), and theme or darkMode to match your app. destroyWidget() removes it.

Crash report dialog

showFeedbackDialog(), from @scoperat/synapse/widget, asks the user what happened after an error. What they write arrives in your inbox as a ticket, and shows on the error's User reports tab. It asks for a name and email only when identify() hasn't provided them.

import { showFeedbackDialog } from "@scoperat/synapse/widget";

const eventId = scoperat.captureException(error);
showFeedbackDialog({ eventId });

Without an eventId, the report is about the last error sent. Pass labels to change the text, subject to change the ticket's subject, theme or darkMode to match your app, and onSubmit or onClose to follow along. closeFeedbackDialog() closes it.

Session replay (@scoperat/synapse/replay)

startReplay() records the page with rrweb into memory, keeping about the last minute. Nothing leaves the browser unless an error is captured: a few seconds after it, the recording is uploaded and attached to that error, and to any other caught in the meantime. It plays on the error's event in ScopeRat.

import scoperat from "@scoperat/synapse";

scoperat.init("tk_live_your_write_key", { captureErrors: true });

// Loaded on its own, so pages that don't record don't download rrweb.
void import("@scoperat/synapse/replay").then(({ startReplay }) =>
  startReplay({ sampleRate: 0.5 }),
);

What's recorded is masked by default:

  • Text — every character of the page's text is replaced with *. Mark elements whose text is safe to show with data-scoperat-unmask, or pass maskAllText: false with a mask selector to mask only some.
  • Inputs — what's typed into inputs is always masked.
  • Media — images, video, audio, canvases, iframes and embeds are kept out of the recording, as empty boxes of their size. blockAllMedia: false records images and video; block takes a selector of more elements to leave out.
  • URLs — query strings and fragments are cut from the page's address before upload, and from links and sources in the page when ScopeRat stores the recording.

sampleRate is the share of page loads that record (default 1). A page uploads at most ten replays, ten seconds apart or more, each up to 2 MB compressed; older parts of a bigger recording are dropped. stopReplay() stops recording and drops what was kept.

Offline queue (@scoperat/synapse/offline)

When the page closes, events still waiting are sent with a keepalive request, which browsers cap at 64 KB. Anything that doesn't fit, or can't go because the user is offline, is lost unless you give the SDK somewhere to keep it:

import { indexedDbQueue } from "@scoperat/synapse/offline";

scoperat.init("tk_live_your_write_key", {
  captureErrors: true,
  offlineQueue: indexedDbQueue(),
});

Kept events are sent on the next visit, or when the browser comes back online. indexedDbQueue({ maxEvents, maxAge }) keeps up to 200 events for up to 3 days by default. An event is stored once, however often it's sent.

Node (@scoperat/synapse/node)

Error reporting for servers on Node 18 or later. It's a client of its own, with no analytics, flags or widget, and it uses the same write key. Call init once, before the app starts serving, so crashes during startup are reported too. Audit events still go through @scoperat/audit, whose captureException reports errors through this SDK once it's initialized.

import scoperat from "@scoperat/synapse/node";

scoperat.init("tk_live_your_write_key", {
  endpoint: "https://api.scoperat.ai",
  release: process.env.GIT_SHA,
  environment: "production",
});

try {
  await chargeCard(order);
} catch (error) {
  scoperat.captureException(error, { tags: { area: "billing" } });
  throw error;
}

Each error is sent with its cause chain (or an AggregateError's errors), the fields Node and database drivers such as pg set on errors (code, detail, constraint, …), and its stack, with frame paths relative to root and the source lines around each of your app's frames. The source is read on your server; nothing is fetched. An error reported twice is sent once. captureMessage(message, level?) reports something that isn't an error.

An error you capture is marked handled. One that ended what it was doing, such as a request your own error handler answers with a 500, can say so: captureException(error, { handled: false }).

Uncaught exceptions and unhandled rejections are reported too, unless captureUnhandled is false. The process then does what Node would have done without the SDK: it exits with code 1 once the report is sent, or after shutdownTimeout. Your own uncaughtException and unhandledRejection listeners stay in charge, and --unhandled-rejections=warn still only warns.

| Option | Type | Default | Description | | ------------------ | ------------------------- | --------------- | ------------------------------------------------------------------- | | endpoint | string | — | ScopeRat's API address (required) | | release | string | the bundle's | The deployed version or git SHA, for releases and source maps | | environment | string | — | Where the app runs (production, staging) | | captureUnhandled | boolean | true | Report uncaught exceptions and unhandled rejections | | sourceContext | boolean | true | Send the source lines around each of your app's frames | | sourceMaps | boolean | true | Resolve compiled frames through the source maps deployed with them | | root | string | process.cwd() | Your app's root; frame paths are sent relative to it | | sampleRate | number | 1 | Share of errors sent, from 0 to 1 | | ignoreErrors | (string \| RegExp)[] | — | Errors not to send, matched against the message or Type: message | | beforeSend | (event) => … | — | Change an event before it's sent, or return null to drop it | | flushAt | number | 20 | Send once this many events are queued | | flushInterval | number | 5000 | Milliseconds between sends | | timeout | number | 10000 | Request timeout in ms | | maxQueueSize | number | 1000 | Most events queued while ScopeRat can't be reached (oldest dropped) | | shutdownTimeout | number | 2000 | Milliseconds a crash waits for its report before the process exits | | onError | (error, events) => void | — | Called with events that failed to send or were dropped |

Requests and other work

The Hono and Express middleware give each request a scope of its own, so the user and tags set while handling it stay with its errors. They report errors that end in a 5xx response, with the request's method, path and route. Query strings are never sent, since they often hold tokens.

import { Hono } from "hono";
import scoperat, { honoMiddleware } from "@scoperat/synapse/node";

const app = new Hono<{ Variables: { userId: string } }>();
app.use(honoMiddleware()); // first, so it wraps every route
app.use(auth); // your auth middleware, which sets userId
app.use(async (c, next) => {
  scoperat.setUser({ id: c.get("userId") });
  await next();
});
import express from "express";
import { expressErrorHandler, expressMiddleware } from "@scoperat/synapse/node";

const app = express();
app.use(expressMiddleware()); // before your routes
// …your routes…
app.use(expressErrorHandler()); // after them, before your own error handler

Each request's errors also join the trace its traceparent header names, so they link to the errors of the page that sent it (see Linking browser and server errors). With another framework, call scoperat.setTraceparent(header) inside the request's scope.

withScope(fn) does the same for work outside a request, such as a queue job. Outside any scope, setUser, setTag and setTags apply to the whole process. Tags show as facets on the error's page, where they filter its events.

async function runJob(job: Job) {
  await scoperat.withScope(async () => {
    scoperat.setTag("queue", job.queueName);
    await handle(job);
  });
}

Cron monitors

A cron monitor expects a scheduled job to check in. withMonitor checks in as the job starts and again as it ends, with ok, or error when it throws or its promise rejects. A run that misses its check-in, ends in an error or runs past maxRuntime opens an issue, which your alert rules for new issues and regressions tell you about, and which resolves itself once the job runs well again. ScopeRat starts expecting runs after a monitor's first check-in.

await scoperat.withMonitor("nightly-backup", () => runBackup(), {
  schedule: { type: "crontab", value: "0 3 * * *" },
  timezone: "Europe/Berlin",
  checkinMargin: 5, // minutes a run may be late
  maxRuntime: 30, // minutes a run may take
});

The settings add the monitor the first time it checks in, then keep it up to date, overwriting changes made to it in ScopeRat; leave them out to manage the monitor in ScopeRat instead. An interval schedule reads { type: "interval", value: 2, unit: "hour" }. A job that can't be wrapped checks in itself:

const checkInId = scoperat.captureCheckIn({
  monitorSlug: "import",
  status: "in_progress",
});
// …the job…
scoperat.captureCheckIn({ monitorSlug: "import", status: "ok", checkInId });

Check-ins are sent in the order they're made, each with the client's release and environment, and flush() and close() wait for them. A check-in that fails is passed to onError with no events. The settings use Sentry's names, so code written for Sentry.withMonitor moves over as it is, and a Sentry SDK pointed at the project's DSN sends its check-ins here too.

Source maps

Compiled code, such as TypeScript or a bundle, reports frames in your source when its source maps are deployed with it. The SDK reads them on your server: a file's sourceMappingURL comment, an inline map, or a .map file beside it with no comment. The source lines come from the map's sourcesContent, or from the source files when they're deployed too.

When the maps aren't deployed, stamp and upload them from CI with @scoperat/cli. Stamped files carry debug IDs, which the SDK reports with each frame so ScopeRat finds its map wherever the code runs from, and the release, which the SDK reports when init() is given none:

export SCOPERAT_API_KEY=... SCOPERAT_PROJECT_ID=your-project-id SCOPERAT_RELEASE="$GIT_SHA"
npx @scoperat/cli sourcemaps inject dist
npx @scoperat/cli sourcemaps upload dist --url-prefix /app/dist

Frames without a debug ID are matched by the release and their absolute path, which is why --url-prefix is the directory the compiled code runs from on the server. Matching by path doesn't work for Windows paths.

Shutting down

The SDK's timers never keep the process alive, and what's queued is sent when the event loop empties. It doesn't handle SIGTERM itself, so send what's left from your own handler:

process.on("SIGTERM", async () => {
  await scoperat.close(); // sends what's queued, then stops
  process.exit(0);
});

Anywhere else the app calls process.exit(), await scoperat.flush() first, or events still queued are lost.

Release announcements (@scoperat/synapse/announce)

An optional subpath export that polls for the newest published release and renders a dismissible popup in its own Shadow DOM. It reads the endpoint and write key from the core client, so scoperat.init() has to run first.

import scoperat from "@scoperat/synapse";
import { initAnnounce } from "@scoperat/synapse/announce";

scoperat.init("tk_live_your_write_key");
initAnnounce({ environment: "production" });

initAnnounce(options?)

| Option | Type | Default | Description | | -------------- | --------------------------------- | ---------------- | --------------------------------------------- | | position | "bottom-right" \| "bottom-left" | "bottom-right" | Corner the popup docks to | | environment | string | — | Only show releases targeting this environment | | pollInterval | number | 60000 | How often to re-check, in ms | | timeout | number | 10000 | Per-request timeout in ms | | darkMode | boolean | false | Use the dark palette | | theme | WidgetTheme | — | Same theme shape as the support widget | | labels | { learnMore?, dismiss? } | — | Override the two built-in strings |

onActiveChange(active) additionally fires whenever the popup appears or disappears. A normal web page can ignore it — the popup is position: fixed over content that is already on screen. It is there for hosts that have a surface of their own to reveal, such as an Electron shell that shows a transparent always-on-top window only while there is something in it:

initAnnounce({
  onActiveChange: (active) => window.myShell.setOverlayVisible(active),
});

destroyAnnounce() / refreshAnnounce()

Stop polling and unmount, or force an immediate re-check (e.g. when the host app regains focus).

Release highlights carry their icon as geometry resolved by ScopeRat, so the popup draws any Lucide icon without this SDK bundling an icon set. A highlight whose icon is unset — or was written before ScopeRat's icon picker existed — renders a bullet instead.

The popup's header panel layers whatever the release supplies: the background image fills the panel, with the icon image and the product wordmark centred on top of it. Any of the three can be left unset.

Dismissals are stored per release id in localStorage under __scoperat_announce_dismissed, so a closed popup stays closed across reloads. The entry emits $release_shown, $release_dismissed, and $release_cta_clicked through scoperat.track().

resetAnnounceDismissals()

Forget every dismissal on this browser. Re-publishing a release keeps its id, so without this a second look at the popup means clearing localStorage by hand — useful while building an announcement, and a bug in production, where it resurfaces popups the user has already closed. Applies on the next poll, or immediately if followed by refreshAnnounce().

Features

  • Auto-batching — events are buffered and sent in batches for efficiency
  • Anonymous IDs — persistent anonymous ID via localStorage, auto-generated on first visit
  • Delivery on page close — what's queued when the page hides is sent with a keepalive request, and the rest kept by the offline queue if you use one
  • Retry on failure — transient failures (network errors, 5xx, 408/429) are re-queued and retried with exponential backoff, waiting as long as a Retry-After header asks; non-retryable 4xx batches are dropped
  • Stored once — every event carries an id, so a batch resent after a lost response isn't counted twice
  • Small core — the core has no dependencies; the widget, announcements and session replay bring their own, in their own entry points
  • Tree-shakeable — marked as side-effect-free for optimal bundling
  • ESM-only — ships as pure ES modules

License

MIT