@brunyee-studio/onus-sdk
Official Onus browser SDK for errors, session replay, custom events, and web analytics — it talks to the Onus envelope endpoint (/api/{ingest_project_id}/envelope/) and capture API (/api/{projectId}/analytics/batch/). The envelope endpoint also accepts an existing Sentry SDK unchanged, and the analytics calls mirror PostHog's core calls, so an existing PostHog integration ports over without rewriting call sites.
Install
pnpm add @brunyee-studio/onus-sdk
Analytics ships inside this package — the standalone
@brunyee-studio/onus-analytics package was removed. Import analytics from
the @brunyee-studio/onus-sdk/analytics subpath (see the
Web analytics section).
import { init, captureException, setReplayCaptureAllowed } from '@brunyee-studio/onus-sdk';
init({
dsn: 'https://<public-key>@<ingest-host>/<numeric-project-id>',
release: '1.0.0',
environment: 'production',
// Configure replay; recording waits for explicit host authorization:
replays: { sampleRate: 1 },
});
// Only after the host has determined capture is authorized:
await setReplayCaptureAllowed(true);
try {
risky();
} catch (e) {
captureException(e);
}
The DSN is public by design; abuse is handled server-side via rate limits and the optional per-key origin allowlist. A missing or malformed DSN never throws: every entry point (init, analytics init, startSessionReplay, ./node, ./next, ./pino) logs a console.warn, disables network sends and keeps the app running, and every capture, logger and analytics call becomes a no-op.
In-app frame marking (allowUrls / denyUrls)
By default, captureException marks stack frames in_app using the same-origin
heuristic: relative/bare paths and same-origin URLs are application code, other
absolute origins are third-party. Two init options narrow or extend this:
init({
dsn,
allowUrls: ['app.example.com', /\/static\/chunks/], // strings match by substring, RegExp by test
denyUrls: ['/vendor/'], // always excludes, even when allowUrls matches
});
allowUrls(non-empty) marks only matching frames in_app;denyUrlsalways marks matching frames out-of-app, taking precedence overallowUrls.- An empty array behaves as "not set" (falls back to the same-origin heuristic); non-array values are ignored.
- Applies to
captureExceptionpaths only — payloads passed tocaptureEventare not auto-marked.
Error grouping on the server hashes only in-app frames, so cross-origin vendor
noise cannot fragment groups. Bundled webpack:// frames (including
node_modules paths) are still marked in-app by default — use denyUrls to
exclude them. Stacks with no in-app frame fall back to hashing all frames.
Session replay
Two ways to record. Auto-start (recommended) — pass a replays block to
init(); recording is deferred until the host calls setReplayCaptureAllowed(true)
after its own legal/CMP decision. The SDK's in-memory authorization starts
closed on every page session and is never persisted. After authorization,
every session records by default and sampling is an opt-down:
init({
dsn,
replays: {
sampleRate: 1, // fraction of sessions recorded (default 1 = all users)
// sessionId: () => analytics.get_session_id(), // link playback to analytics sessions
},
});
await setReplayCaptureAllowed(true); // Only after host authorization
// On withdrawal: stop without flushing unsent events, abort in-flight sends,
// and remove the replay id from future errors.
await setReplayCaptureAllowed(false);
// Re-grant re-runs the configured auto-start (including sampling) and, if
// selected, starts a fresh recording with a new replay id.
await setReplayCaptureAllowed(true);
Errors captured afterwards carry contexts.replay.replay_id automatically —
no extra wiring. The link starts once Onus has accepted the recording's first
segment (the page snapshot playback starts from), so errors never point at a
replay that cannot play; errors captured before then carry no replay id. A grant is idempotent while open; after withdrawal, a new grant
re-runs the latest init({ replays }) configuration, whereas re-initializing
without replays removes the parked auto-start. enabled: false (or omitting
the block) opts out entirely.
Behavior change for existing manual callers: a direct
startSessionReplay() now resolves null until the host explicitly calls
setReplayCaptureAllowed(true). Withdrawal stops recording immediately without
a tail flush, aborts in-flight requests, and clears the error↔replay link.
Re-grant permits a fresh manual recording; it never resumes the withdrawn one.
Privacy defaults (always on)
Capture is privacy-safe by default, and masking happens before transport (the SDK resolves masks before rrweb serializes anything and before any segment leaves the page):
- Non-password, text-like input values are masked via the forced mask map
(
maskAllInputs); password values are always masked and cannot be unmasked. - Ordinary DOM text nodes are masked by default (
maskTextSelector: '*'). input[type="hidden"],input[type="radio"], andinput[type="checkbox"]elements are blocked entirely by default: rrweb's masking path skips these types (hidden values and radio/checkboxvalueattributes would otherwise record verbatim), and their checked state is not replayed.- Subtrees marked
data-onus-privateare blocked entirely. - Canvas recording, cross-origin iframe recording, and inline images stay off.
- Recorded page URLs omit query strings, fragments, and credentials.
What is NOT scrubbed: masking covers input values, ordinary DOM text
nodes, and the blocked subtree only. DOM attributes (for example element
title or the href attribute of an anchor), image URLs, <style> /
stylesheet text (serialized by rrweb as _cssText, outside the
maskTextSelector masking path), and non-replay payloads are recorded
verbatim. Hosts must block sensitive subtrees with data-onus-private (or
privacy.blockSelector) and review sensitive routes and every data path
before enabling replay in production.
Opting down per integration
Pass a typed privacy object (manual startSessionReplay or the replays
block) to deliberately relax non-password masking for that one integration.
privacy flags always win over the bare recordOptions passthrough, and a
hostile recordOptions can never defeat masking, enable canvas/iframe
capture, replace the SDK's emit, or pre-pack events through rrweb
packFn/plugins (those are stripped: they run before the SDK's emit and
could bypass Meta href sanitization). Empty, whitespace, or CSS-invalid
selectors fall back to the safe defaults instead of disabling masking (rrweb
2.1.1 silently records unmasked on an invalid selector). privacy.blockSelector
is additive: it EXTENDS the built-in block list — it can never remove the
default blocks (data-onus-private, hidden, radio, and checkbox inputs).
There is no
privacy: false convenience switch. URL minimization means sensitive data must still never
live in the path — hosts should also block sensitive subtrees:
import { init, setReplayCaptureAllowed } from '@brunyee-studio/onus-sdk';
import { startSessionReplay } from '@brunyee-studio/onus-sdk/replay';
// Strict default — everything masked (after authorization):
await setReplayCaptureAllowed(true);
await startSessionReplay({ dsn });
// Selective text opt-down — only mask text inside a known-sensitive area:
init({
dsn,
replays: {
privacy: { maskTextSelector: '.sensitive-area' },
},
});
// Extra blocked subtree — privacy.blockSelector ADDS to the built-in block
// list; [data-onus-private], input[type=hidden], input[type=radio] and
// input[type=checkbox] stay blocked:
init({
dsn,
replays: {
privacy: {
blockSelector: '.payment-form',
ignoreSelector: '.skip-recording',
},
},
});
Manual control still works via the separate entrypoint:
import { setReplayCaptureAllowed } from '@brunyee-studio/onus-sdk';
import { startSessionReplay, stopSessionReplay } from '@brunyee-studio/onus-sdk/replay';
await setReplayCaptureAllowed(true); // Manual starts also require authorization.
const replayId = await startSessionReplay({ dsn, environment: 'production' });
// Errors carry contexts.replay.replay_id once the first segment is accepted.
rrweb is an optional peer dependency — the recorder bundle is lazy-loaded only when recording actually starts, so apps that disable replay never download it. Replay segments are emitted as paired replay_event + replay_recording envelope items (gzipped) exactly as the ingest routes and the private Storage bucket expect. The first segment (rrweb Meta + FullSnapshot) is sent as soon as the snapshot is taken; later segments batch every segmentIntervalMs (default 5000) or maxEventsPerSegment events (default 100). Segment sends retry network errors, 408, 429 and 5xx up to 3 attempts with capped backoff; other 4xx responses are not retried. Each recording body is gzipped before upload (where CompressionStream exists), so it is stored compressed. A replay ends after idleTimeoutMs without user input (default 5 minutes) or once it is maxReplayDurationMs long (default and maximum 60 minutes; ingest drops later segments); the next user input starts a fresh replay, so an unattended tab stops recording. Replays expire per the team's retention setting (90 days by default).
Logs
Structured logs go to Onus's log viewer as log envelope items. Opt in
with a logs block; without one, logger calls do nothing.
import { init, logger } from '@brunyee-studio/onus-sdk';
init({
dsn: 'https://<key>@onus.example/<projectId>',
release: '[email protected]',
logs: {
minLevel: 'info', // default 'trace'
captureConsole: ['warn', 'error'], // also send these console calls
sessionId: () => analytics.get_session_id(),
},
});
logger.info('checkout started', { cartItems: 3, tier: 'pro' });
logger.error(logger.fmt`payment ${orderId} failed after ${attempts} tries`);
- Levels:
trace,debug,info,warn,errorandfatal. - Attributes: strings, numbers and booleans, typed on the wire. Other values are JSON-stringified.
logger.fmt: keeps the message template and its parameters as attributes, so similar lines group together.- Automatic context: environment, release, SDK name and version, the active replay id,
session.id, and the page URL without its query string or fragment. - Delivery:
- Up to 100 records (and about 512 KiB) per envelope, sent every 5 s by default or as soon as a batch fills.
- When the page is hidden or unloads, buffered records are sent with
sendBeacon.
- Limits and failures:
- At most 1000 unsent records are kept, and the oldest are dropped first.
- A
429pauses sending forRetry-After. Other failures back off exponentially up to 60 s. - Dropped records are counted and reported to Onus in a client report.
- The logger never throws.
- Bundle size: the logger ships in the core entry. Importing it never pulls in replay or analytics code.
Tracing (@brunyee-studio/onus-sdk/tracing)
Browser traces: a pageload root, a navigation root per SPA route change,
and an http.client child span per fetch/XMLHttpRequest. Each root is sent
as one transaction envelope item and shows up on the Traces page.
import { init } from '@brunyee-studio/onus-sdk';
import { startBrowserTracing } from '@brunyee-studio/onus-sdk/tracing';
init({ dsn: 'https://<key>@onus.example/<projectId>', environment: 'production' });
const tracing = startBrowserTracing({
tracesSampleRate: 0.1, // default 1
service: 'web', // default 'browser'
tracePropagationTargets: ['https://api.example.com', /^https:\/\/[a-z]+\.example\.com\//],
});
// tracing?.stop() removes every patch.
- Needs
init()first.startBrowserTracingreturnsnullwhen the SDK has no valid DSN or tracing already started. - Roots: a root ends 1 s after its last request finishes (
idleTimeoutMs), after 30 s at most (finalTimeoutMs), on the next navigation, or onpagehide. Requests still open then are sent with statuscancelled. Pageload starts atperformance.timeOrigin. - Navigation:
history.pushState/replaceStateandpopstate; only a pathname change starts a new root. Routers that fetch the next page before changing the URL should callstartNavigation(url)when the navigation starts, so those requests (and the server spans they cause) land in the new page's trace; the history change that follows doesn't start a second root for the same path.startNavigationis a no-op when tracing isn't running. - Requests between roots: a request made while no root is open (after the last one ended) gets a fresh trace id of its own with the last root's sampling decision, so an ended trace never keeps growing.
Next.js App Router fetches the next page's payload before pushState, so call startNavigation from instrumentation-client.ts:
// instrumentation-client.ts
import { startNavigation } from '@brunyee-studio/onus-sdk/tracing';
export function onRouterTransitionStart(url: string) {
startNavigation(url);
}
- Request spans: named
METHOD /path, withhttp.request.method,url.full(without query or fragment) andhttp.response.status_code. A status ≥ 500 or a network error isinternal_error. Requests to the SDK's own ingest routes are never traced. traceparentpropagation: by default only same-origin requests get the header, so CDNs and third parties never see your trace ids.tracePropagationTargetsreplaces the default: a RegExp is tested against the absolute URL; a string that is an absolute URL (https://api.example.com) matches only as a prefix on that exact origin; a path string (/api/) matches only same-origin requests whose path starts with it; any other string is a host (api.example.com,localhost:3000) that matches that host or its subdomains, never a lookalike such asapi.example.com.evil.net. Strings never match on a substring of the URL, so a host with a path (api.example.com/v1) or a protocol-relative string (//cdn.example.com) never matches anything: use a full URL (https://api.example.com/v1) or a RegExp.tracePropagationTargets: []turns propagation off entirely. An existingtraceparentis never overwritten.- Sampling: decided once per root. An unsampled root sends nothing but still propagates
traceparentwith flags00, so servers can respect the decision. - Correlation: while tracing runs,
loggerrecords carry the activetrace_idand captured errors carrycontexts.trace. The core entry only holds a tiny trace registry, not the tracer. Each replay segment lists the traces started or propagated since the previous one astrace_ids, so the replay's log sidebar also shows your backend's lines for those traces. - Bundle size: its own subpath with no OpenTelemetry dependency. Apps that never import it download none of it.
Node (@brunyee-studio/onus-sdk/node)
pnpm add @brunyee-studio/onus-sdk @opentelemetry/api @opentelemetry/api-logs @opentelemetry/core \
@opentelemetry/otlp-transformer @opentelemetry/resources @opentelemetry/sdk-logs
import { init, logger, captureException, flush } from '@brunyee-studio/onus-sdk/node';
init({
dsn: process.env.ONUS_DSN,
service: 'api',
environment: 'production',
release: '1.4.0',
});
logger.info('order placed', { orderId });
captureException(err); // error event with contexts.trace from the active span
await flush(); // before a serverless function returns
-
Logs go over OTLP/JSON to
/api/{projectId}/otlp/v1/logs, batched (≤ 512 records, 1 s), gzipped, and split to stay under the 1 MiB request cap. The DSN's key must have no allowed-origins list, because server senders have no Origin header. -
logLevel(defaultinfo) filterslogger.*and console capture.captureConsole: ['warn', 'error']mirrors those console methods. -
Records carry the active OpenTelemetry span.
-
Tracing is its own subpath,
@brunyee-studio/onus-sdk/node/tracing, so apps that only send logs and errors never need its two extra packages. Add@opentelemetry/sdk-trace-base @opentelemetry/context-async-hooksto the install line and passonusTracingtoinit:import { init } from '@brunyee-studio/onus-sdk/node'; import { onusTracing } from '@brunyee-studio/onus-sdk/node/tracing'; init({ dsn: process.env.ONUS_DSN, service: 'api', tracing: onusTracing({ tracesSampleRate: 0.1 }), });initthen also registers a global tracer provider, an async context manager and the W3CtraceContextpropagator, and exports spans over OTLP/JSON to/api/{projectId}/otlp/v1/traces(same batching, gzip and backoff as logs). Withouttracingno tracer is registered; bring your own (@opentelemetry/sdk-node). Sampling is parent-based: an incomingtraceparentdecides (so a browser's sampled-out trace stays out), and only root spans usetracesSampleRate(0..1).ignoreSpanPaths: /\/health/never samples roots whose name orhttp.target/url.path/http.route/next.routematches. The exporter's own requests run with tracing suppressed, so they never become spans. -
Servers without HTTP auto-instrumentation join the caller's trace with
continueTrace(headers, name, fn):fnruns in an activeSERVERspan parented by the request'straceparent, and a throw is recorded on the span and rethrown.import { continueTrace } from '@brunyee-studio/onus-sdk/node'; app.get('/orders', (req, res) => continueTrace(req.headers, 'GET /orders', async () => res.json(await listOrders())), ); -
initdoes not replace a global OTel logs provider that is already registered: the OTel API keeps the first one and ignores later registrations, sologgerrecords would go to that provider instead of Onus (error capture is unaffected). If your app already registers one (e.g.@opentelemetry/sdk-nodewith log processors), forward pino logs through the./pinotransport below, which exports through its own provider rather than the global one; on Next.js, letregisterOnusfrom./nextbe what registers OTel. -
On
429/503the exporter pauses forRetry-Afterand drops batches until then. A rejected key or project (401/403/404, e.g. a key with an allowed-origins list) pauses forwarding for 10 minutes. Nothing throws into your app.
pino (@brunyee-studio/onus-sdk/pino)
Add pino-abstract-transport to the Node install line.
const log = pino({
transport: {
targets: [
{ target: 'pino/file', options: { destination: 1 } }, // keep stdout
{
target: '@brunyee-studio/onus-sdk/pino',
options: { dsn: process.env.ONUS_DSN, service: 'api' },
},
],
},
});
The transport runs in pino's worker thread and exports on its own. pino rejects a custom formatters.level together with transport.targets, and the transport accepts both numeric levels and level labels. Lines keep trace_id/span_id (e.g. from @opentelemetry/instrumentation-pino) as the record's trace context.
If ./node init (or ./next registerOnus) already runs in-process, use createOnusPinoStream({ exclude? }) as a pino.multistream destination instead. It emits through that provider.
winston (@brunyee-studio/onus-sdk/winston)
Add winston-transport to the Node install line, and call init from ./node first.
import { OnusWinstonTransport } from '@brunyee-studio/onus-sdk/winston';
const log = winston.createLogger({
transports: [new winston.transports.Console(), new OnusWinstonTransport()],
});
npm and syslog levels both map to OTel severities, metadata becomes attributes, and records carry the active span.
Next.js (@brunyee-studio/onus-sdk/next)
Add @vercel/otel to the Node install line, and @opentelemetry/sdk-trace-base if you set tracesSampleRate.
// instrumentation.ts
import { registerOnus } from '@brunyee-studio/onus-sdk/next';
export const register = () =>
registerOnus({ dsn: process.env.ONUS_DSN, service: 'web', environment: process.env.VERCEL_ENV });
export { onRequestError } from '@brunyee-studio/onus-sdk/next';
On the Node runtime this registers @vercel/otel (request spans) with the Onus log processor (and, with tracesSampleRate, the Onus sampler and span processor alongside @vercel/otel's own 'auto' processors), so logger, createOnusPinoStream and OnusWinstonTransport export through it. Once registerOnus has run, captureException and logger from ./node work from any module in the app (route handlers, server actions, server components); you don't call init. onRequestError captures unhandled errors with the route and active trace. On the edge runtime only tracing is registered, and request errors are written as one JSON line with trace_id/span_id.
Transport semantics
- POSTs envelopes with
X-Sentry-Auth(sentry_key= your public key). - Gzips bodies via
CompressionStreamwhen available; the ingest route sniffs magic bytes and also accepts identity bodies. - Honors
429+retry-afterwith capped backoff (per-key rate limits). - Envelopes over the server's caps (1 MiB on the wire after gzip, 10 MiB inflated) are dropped client-side.
navigator.sendBeaconis available for unload-time delivery (?sentry_key=query fallback, since beacons cannot set headers).
Tree-shaking & debug flag
- Import only what you use; the replay recorder is isolated in
@brunyee-studio/onus-sdk/replay. - Known limitation: do not mix CommonJS
require('@brunyee-studio/onus-sdk')with ESM imports of the/replaysubpath in one page. The two builds can create separate module instances with separate in-memory authorization gate state. Use one module system consistently for both SDK entrypoints until dual-instance compatibility has been resolved. - Debug logging is gated behind the compile-time flag
__ONUS_DEBUG__. Strip it in production bundlers:
// webpack
new webpack.DefinePlugin({ __ONUS_DEBUG__: false });
// esbuild / tsup
define: {
__ONUS_DEBUG__: 'false';
}
Browser support
| Browser | Minimum |
|---|---|
| Chrome | 90 |
| Edge | 90 |
| Firefox | 90 |
| Safari | 15.4 |
| iOS Safari | 15.4 |
Requires crypto.randomUUID or crypto.getRandomValues (event ids), fetch, and optionally CompressionStream (gzip) / navigator.sendBeacon (unload delivery). Older browsers degrade gracefully: identity bodies instead of gzip, fetch instead of beacon.
Version policy: we support the current major line with fixes.
Publishing
@brunyee-studio/onus-sdk releases through the same semantic-release pipeline as packages/cli. Only dist/ and README.md are published (files in package.json).
Web analytics (@brunyee-studio/onus-sdk/analytics)
Product-analytics surface (init / capture / identify / alias / register / reset /
get_distinct_id / get_session_id) on top of the Onus capture API. The core calls match
an existing PostHog integration, so call sites port over without rewriting.
Import it from its own subpath so apps that only need error tracking never download
analytics code. The subpath is ESM-only (import condition, like ./replay), and the
package is sideEffects: false, so bundlers drop any feature set you don't import.
import { init } from '@brunyee-studio/onus-sdk/analytics';
const onus = init({
// Flat ingest DSN from your team's keys panel (public by design).
dsn: 'https://<token>@<ingest-host>/<ingest-project-id>',
});
onus.capture('cta_click', { button: 'hero' });
Or with explicit parts:
init({ apiKey: '<token>', apiHost: 'https://app.onus.dev', projectId: 42 });
API
| Method | Behavior |
|---|---|
init(config) | Parse DSN, restore persisted identity, wire pageleave flush. |
capture(name, props?) | Queue a custom event. |
identify(id, $set?, $set_once?) | Emit $identify; adopt the id; keep the anon id for server-side merge. |
alias(id) | Emit $create_alias linking the current distinct id to id. |
register(props) / register_once(props, def?) / unregister(name) | Persisted super properties attached to every event. |
reset() | Fresh anonymous id + session; clears super properties. |
get_distinct_id() / get_session_id() | Read current ids (session: uuidv7, 30-min idle / 24-h max). |
flush() | Await a transport flush (tests, SPAs before route teardown). |
Behavior notes
- Sessions are client-side uuidv7 ids shared across tabs via localStorage, rotated after 30 minutes idle or 24 hours total.
- Persistence prefers localStorage and degrades to in-memory when blocked (Safari private mode). Cookie persistence is intentionally not supported in v1 (consent surface + 4KB budget).
- Pageleave: buffered events flush via
sendBeacononpagehide, with a keepalivefetchfallback. - Retries: transient failures (5xx/network) retry with exponential backoff and drop after 4 attempts; 4xx responses are acknowledged, never retried — the server's rate-limit decision is authoritative.
- Rate limiting: client-side token bucket (default 10 events/s, burst 10x); excess events are dropped locally rather than 429-hammering the endpoint.
- DNT:
captureis a no-op whennavigator.doNotTrackis set andrespectDntis true (default). before_sendreceives each event before it leaves the browser; returnnullto drop it.- Autocapture:
$pageviewon init + SPA history changes,$pageleave, and$autocaptureclicks/submits with an elements chain anddata-onus-capture-attribute-*property passthrough.