@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
Maintainers
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 thelocalStoragekeys are unchanged, so swap the dependency and the import specifier and nothing else moves.
Install
npm install @scoperat/synapseQuick 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 passsendDefaultPii: true fetchandXMLHttpRequestrequests, with the method, the URL without its query string, the status and how long they took- navigation, including
history.pushStateand 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 withdata-scoperat-unmask, or passmaskAllText: falsewith amaskselector 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: falserecords images and video;blocktakes 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 handlerEach 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/distFrames 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-Afterheader 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
