@brunyee-studio/onus-sdk 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,16 +11,19 @@ pnpm add @brunyee-studio/onus-sdk
11
11
  ```
12
12
 
13
13
  ```js
14
- import { init, captureException } from '@brunyee-studio/onus-sdk';
14
+ import { init, captureException, setReplayCaptureAllowed } from '@brunyee-studio/onus-sdk';
15
15
 
16
16
  init({
17
17
  dsn: 'https://<public-key>@<ingest-host>/<numeric-project-id>',
18
18
  release: '1.0.0',
19
19
  environment: 'production',
20
- // Record every user's session automatically (see "Session replay"):
20
+ // Configure replay; recording waits for explicit host authorization:
21
21
  replays: { sampleRate: 1 },
22
22
  });
23
23
 
24
+ // Only after the host has determined capture is authorized:
25
+ await setReplayCaptureAllowed(true);
26
+
24
27
  try {
25
28
  risky();
26
29
  } catch (e) {
@@ -33,7 +36,10 @@ The DSN is public by design (Sentry doctrine); abuse is handled server-side via
33
36
  ## Session replay
34
37
 
35
38
  Two ways to record. **Auto-start (recommended)** — pass a `replays` block to
36
- `init()`; every session records by default and sampling is an opt-down:
39
+ `init()`; recording is deferred until the host calls `setReplayCaptureAllowed(true)`
40
+ after its own legal/CMP decision. The SDK's in-memory authorization starts
41
+ closed on every page session and is never persisted. After authorization,
42
+ every session records by default and sampling is an opt-down:
37
43
 
38
44
  ```js
39
45
  init({
@@ -41,24 +47,115 @@ init({
41
47
  replays: {
42
48
  sampleRate: 1, // fraction of sessions recorded (default 1 = all users)
43
49
  // sessionId: () => analytics.get_session_id(), // link playback to analytics sessions
44
- // recordOptions: { maskAllInputs: true }, // rrweb privacy passthrough
45
50
  },
46
51
  });
52
+ await setReplayCaptureAllowed(true); // Only after host authorization
53
+ // On withdrawal: stop without flushing unsent events, abort in-flight sends,
54
+ // and remove the replay id from future errors.
55
+ await setReplayCaptureAllowed(false);
56
+ // Re-grant re-runs the configured auto-start (including sampling) and, if
57
+ // selected, starts a fresh recording with a new replay id.
58
+ await setReplayCaptureAllowed(true);
47
59
  ```
48
60
 
49
61
  Errors captured afterwards carry `contexts.replay.replay_id` automatically —
50
- no extra wiring. `enabled: false` (or omitting the block) opts out entirely.
62
+ no extra wiring. A grant is idempotent while open; after withdrawal, a new grant
63
+ re-runs the latest `init({ replays })` configuration, whereas re-initializing
64
+ without `replays` removes the parked auto-start. `enabled: false` (or omitting
65
+ the block) opts out entirely.
66
+
67
+ **Behavior change for existing manual callers:** a direct
68
+ `startSessionReplay()` now resolves `null` until the host explicitly calls
69
+ `setReplayCaptureAllowed(true)`. Withdrawal stops recording immediately without
70
+ a tail flush, aborts in-flight requests, and clears the error↔replay link.
71
+ Re-grant permits a fresh manual recording; it never resumes the withdrawn one.
72
+
73
+ ### Privacy defaults (always on)
74
+
75
+ Capture is privacy-safe by default, and masking happens **before transport**
76
+ (the SDK resolves masks before rrweb serializes anything and before any
77
+ segment leaves the page):
78
+
79
+ - Non-password, text-like input values are masked via the forced mask map
80
+ (`maskAllInputs`); password values are **always** masked and cannot be
81
+ unmasked.
82
+ - Ordinary DOM text nodes are masked by default (`maskTextSelector: '*'`).
83
+ - `input[type="hidden"]`, `input[type="radio"]`, and `input[type="checkbox"]`
84
+ elements are **blocked entirely** by default: rrweb's masking path skips
85
+ these types (hidden values and radio/checkbox `value` attributes would
86
+ otherwise record verbatim), and their checked state is not replayed.
87
+ - Subtrees marked `data-onus-private` are blocked entirely.
88
+ - Canvas recording, cross-origin iframe recording, and inline images stay off.
89
+ - Recorded page URLs omit query strings, fragments, and credentials.
90
+
91
+ **What is NOT scrubbed:** masking covers input values, ordinary DOM text
92
+ nodes, and the blocked subtree only. DOM **attributes** (for example element
93
+ `title` or the `href` attribute of an anchor), image URLs, **`<style>` /
94
+ stylesheet text** (serialized by rrweb as `_cssText`, outside the
95
+ `maskTextSelector` masking path), and non-replay payloads are recorded
96
+ verbatim. Hosts must block sensitive subtrees with `data-onus-private` (or
97
+ `privacy.blockSelector`) and review sensitive routes and every data path
98
+ before enabling replay in production.
99
+
100
+ ### Opting down per integration
101
+
102
+ Pass a typed `privacy` object (manual `startSessionReplay` or the `replays`
103
+ block) to deliberately relax non-password masking for that one integration.
104
+ `privacy` flags always win over the bare `recordOptions` passthrough, and a
105
+ hostile `recordOptions` can never defeat masking, enable canvas/iframe
106
+ capture, replace the SDK's `emit`, or pre-pack events through rrweb
107
+ `packFn`/`plugins` (those are stripped: they run before the SDK's emit and
108
+ could bypass Meta href sanitization). Empty, whitespace, or CSS-invalid
109
+ selectors fall back to the safe defaults instead of disabling masking (rrweb
110
+ 2.1.1 silently records unmasked on an invalid selector). `privacy.blockSelector`
111
+ is **additive**: it EXTENDS the built-in block list — it can never remove the
112
+ default blocks (`data-onus-private`, hidden, radio, and checkbox inputs).
113
+ There is no
114
+ `privacy: false` convenience switch. URL minimization means sensitive data must still never
115
+ live in the path — hosts should also block sensitive subtrees:
116
+
117
+ ```js
118
+ import { init, setReplayCaptureAllowed } from '@brunyee-studio/onus-sdk';
119
+ import { startSessionReplay } from '@brunyee-studio/onus-sdk/replay';
120
+
121
+ // Strict default — everything masked (after authorization):
122
+ await setReplayCaptureAllowed(true);
123
+ await startSessionReplay({ dsn });
124
+
125
+ // Selective text opt-down — only mask text inside a known-sensitive area:
126
+ init({
127
+ dsn,
128
+ replays: {
129
+ privacy: { maskTextSelector: '.sensitive-area' },
130
+ },
131
+ });
132
+
133
+ // Extra blocked subtree — privacy.blockSelector ADDS to the built-in block
134
+ // list; [data-onus-private], input[type=hidden], input[type=radio] and
135
+ // input[type=checkbox] stay blocked:
136
+ init({
137
+ dsn,
138
+ replays: {
139
+ privacy: {
140
+ blockSelector: '.payment-form',
141
+ ignoreSelector: '.skip-recording',
142
+ },
143
+ },
144
+ });
145
+ ```
51
146
 
52
147
  Manual control still works via the separate entrypoint:
53
148
 
54
149
  ```js
150
+ import { setReplayCaptureAllowed } from '@brunyee-studio/onus-sdk';
55
151
  import { startSessionReplay, stopSessionReplay } from '@brunyee-studio/onus-sdk/replay';
56
152
 
153
+ await setReplayCaptureAllowed(true); // Manual starts also require authorization.
57
154
  const replayId = await startSessionReplay({ dsn, environment: 'production' });
58
155
  // Errors captured afterwards carry contexts.replay.replay_id automatically.
59
156
  ```
60
157
 
61
- `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. When capturing all users, review privacy masking (`recordOptions`, e.g. `maskAllInputs`) before enabling in production; replays expire per the team's retention setting (90 days by default).
158
+ `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. Replays expire per the team's retention setting (90 days by default).
62
159
 
63
160
  ## Transport semantics
64
161
 
@@ -71,6 +168,11 @@ const replayId = await startSessionReplay({ dsn, environment: 'production' });
71
168
  ## Tree-shaking & debug flag
72
169
 
73
170
  - Import only what you use; the replay recorder is isolated in `@brunyee-studio/onus-sdk/replay`.
171
+ - **Known limitation:** do not mix CommonJS `require('@brunyee-studio/onus-sdk')`
172
+ with ESM imports of the `/replay` subpath in one page. The two builds can
173
+ create separate module instances with separate in-memory authorization gate
174
+ state. Use one module system consistently for both SDK entrypoints until
175
+ dual-instance compatibility has been resolved.
74
176
  - Debug logging is gated behind the compile-time flag `__ONUS_DEBUG__`. Strip it in production bundlers:
75
177
 
76
178
  ```js
@@ -85,18 +85,39 @@ async function gzipText(text) {
85
85
  function delay(ms) {
86
86
  return new Promise((resolve) => setTimeout(resolve, ms));
87
87
  }
88
+ function untilAborted(operation, signal) {
89
+ if (!signal) return operation;
90
+ if (signal.aborted) return Promise.resolve(void 0);
91
+ return new Promise((resolve, reject) => {
92
+ const onAbort = () => resolve(void 0);
93
+ signal.addEventListener("abort", onAbort, { once: true });
94
+ operation.then(
95
+ (value) => {
96
+ signal.removeEventListener("abort", onAbort);
97
+ resolve(value);
98
+ },
99
+ (error) => {
100
+ signal.removeEventListener("abort", onAbort);
101
+ reject(error);
102
+ }
103
+ );
104
+ });
105
+ }
88
106
  async function sendEnvelope(opts) {
89
107
  const fetchImpl = opts.fetchImpl ?? fetch;
90
108
  const maxRetries = opts.maxRetries ?? 1;
91
109
  const backoffMs = opts.backoffMs ?? 500;
92
110
  const sleep = opts.delayImpl ?? delay;
93
- const gz = await gzipText(opts.body);
111
+ if (opts.signal?.aborted) return { ok: false, error: "aborted" };
112
+ const gz = await untilAborted(gzipText(opts.body), opts.signal);
113
+ if (opts.signal?.aborted) return { ok: false, error: "aborted" };
94
114
  const headers = {
95
115
  "Content-Type": "application/x-sentry-envelope",
96
116
  "X-Sentry-Auth": opts.authHeader,
97
117
  ...gz ? { "Content-Encoding": "gzip" } : {}
98
118
  };
99
119
  for (let attempt = 0; ; attempt++) {
120
+ if (opts.signal?.aborted) return { ok: false, error: "aborted" };
100
121
  let res;
101
122
  try {
102
123
  res = await fetchImpl(opts.url, {
@@ -104,11 +125,13 @@ async function sendEnvelope(opts) {
104
125
  headers,
105
126
  body: gz ?? opts.body,
106
127
  // Analytics-style telemetry: never block page unload on the response.
107
- keepalive: true
128
+ keepalive: true,
129
+ signal: opts.signal
108
130
  });
109
131
  } catch (e) {
110
132
  return { ok: false, error: e instanceof Error ? e.message : "network error" };
111
133
  }
134
+ if (opts.signal?.aborted) return { ok: false, error: "aborted" };
112
135
  if (res.ok) return { ok: true, status: res.status };
113
136
  if (res.status === 429 && attempt < maxRetries) {
114
137
  const retryAfter = Number(res.headers.get("retry-after") ?? "");
@@ -116,7 +139,8 @@ async function sendEnvelope(opts) {
116
139
  Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : backoffMs * (attempt + 1),
117
140
  1e4
118
141
  );
119
- await sleep(wait);
142
+ await untilAborted(sleep(wait), opts.signal);
143
+ if (opts.signal?.aborted) return { ok: false, error: "aborted" };
120
144
  continue;
121
145
  }
122
146
  if (res.status === 429) {
@@ -132,6 +156,68 @@ async function sendEnvelope(opts) {
132
156
  }
133
157
  }
134
158
 
159
+ // src/capture-gate.ts
160
+ var allowed = false;
161
+ var pendingAutoStart = null;
162
+ var autoStartGeneration = 0;
163
+ var grantEpoch = 0;
164
+ var stopReplay = null;
165
+ var clearReplayLink = null;
166
+ var releaseStaleAutoReplay = null;
167
+ function isReplayCaptureAllowed() {
168
+ return allowed;
169
+ }
170
+ function deferAutoStart(start) {
171
+ pendingAutoStart = start;
172
+ const generation = ++autoStartGeneration;
173
+ releaseStaleAutoReplay?.(generation);
174
+ return generation;
175
+ }
176
+ function clearAutoStart() {
177
+ pendingAutoStart = null;
178
+ releaseStaleAutoReplay?.(++autoStartGeneration);
179
+ }
180
+ function isAutoStartGenerationCurrent(generation) {
181
+ return generation === autoStartGeneration && pendingAutoStart !== null;
182
+ }
183
+ function getReplayCaptureGrantEpoch() {
184
+ return grantEpoch;
185
+ }
186
+ function registerReplayLinkClear(clear) {
187
+ clearReplayLink = clear;
188
+ }
189
+ function registerStaleAutoReplayRelease(release) {
190
+ releaseStaleAutoReplay = release;
191
+ }
192
+ function registerReplayStop(stop) {
193
+ stopReplay = stop;
194
+ }
195
+ async function setReplayCaptureAllowed(next) {
196
+ if (next === allowed) return;
197
+ allowed = next;
198
+ grantEpoch++;
199
+ if (!next) {
200
+ const stop = stopReplay;
201
+ stopReplay = null;
202
+ stop?.();
203
+ clearReplayLink?.();
204
+ return;
205
+ }
206
+ const start = pendingAutoStart;
207
+ if (start) await start();
208
+ }
209
+ function resetCaptureGateForTest() {
210
+ allowed = false;
211
+ grantEpoch++;
212
+ clearAutoStart();
213
+ releaseStaleAutoReplay = null;
214
+ const stop = stopReplay;
215
+ stopReplay = null;
216
+ stop?.();
217
+ clearReplayLink?.();
218
+ clearReplayLink = null;
219
+ }
220
+
135
221
  // src/client.ts
136
222
  var SDK_NAME = "onus.javascript";
137
223
  var SDK_VERSION = "0.1.0";
@@ -183,28 +269,50 @@ function init(options) {
183
269
  authHeader: dsn ? buildSentryAuthHeader(dsn, SDK_VERSION) : "",
184
270
  replayId: null
185
271
  };
272
+ registerReplayLinkClear(() => setActiveReplayId(null));
186
273
  autoReplayStart = null;
187
- if (options.replays && options.replays.enabled !== false && dsn) {
188
- autoReplayStart = startAutoReplay(options.replays, options);
274
+ const replays = options.replays;
275
+ if (replays && replays.enabled !== false && dsn) {
276
+ let generation;
277
+ const start = () => {
278
+ if (!isAutoStartGenerationCurrent(generation) || !isReplayCaptureAllowed()) {
279
+ return Promise.resolve();
280
+ }
281
+ autoReplayStart = startAutoReplay(replays, options, generation, getReplayCaptureGrantEpoch());
282
+ return autoReplayStart;
283
+ };
284
+ generation = deferAutoStart(start);
285
+ if (isReplayCaptureAllowed()) void start();
286
+ } else {
287
+ clearAutoStart();
189
288
  }
190
289
  }
191
- async function startAutoReplay(replays, options) {
290
+ async function startAutoReplay(replays, options, generation, grantEpoch2) {
291
+ const shouldProceed = () => isAutoStartGenerationCurrent(generation) && isReplayCaptureAllowed() && getReplayCaptureGrantEpoch() === grantEpoch2;
192
292
  try {
193
293
  const rate = typeof replays.sampleRate === "number" && replays.sampleRate >= 0 && replays.sampleRate <= 1 ? replays.sampleRate : 1;
194
294
  if (Math.random() >= rate) return;
195
295
  const replay = await import("./replay.js");
196
- const replayId = await replay.startSessionReplay({
197
- dsn: options.dsn,
198
- environment: replays.environment ?? options.environment,
199
- release: replays.release ?? options.release,
200
- sessionId: replays.sessionId,
201
- segmentIntervalMs: replays.segmentIntervalMs,
202
- maxEventsPerSegment: replays.maxEventsPerSegment,
203
- recordOptions: replays.recordOptions,
204
- recorderFactory: replays.recorderFactory,
205
- fetchImpl: replays.fetchImpl ?? options.fetchImpl
206
- });
207
- if (replayId) setActiveReplayId(replayId);
296
+ if (!shouldProceed()) return;
297
+ const replayId = await replay.startAutoSessionReplay(
298
+ {
299
+ shouldProceed,
300
+ dsn: options.dsn,
301
+ environment: replays.environment ?? options.environment,
302
+ release: replays.release ?? options.release,
303
+ sessionId: replays.sessionId,
304
+ segmentIntervalMs: replays.segmentIntervalMs,
305
+ maxEventsPerSegment: replays.maxEventsPerSegment,
306
+ recordOptions: replays.recordOptions,
307
+ privacy: replays.privacy,
308
+ recorderFactory: replays.recorderFactory,
309
+ fetchImpl: replays.fetchImpl ?? options.fetchImpl
310
+ },
311
+ generation
312
+ );
313
+ if (replayId && shouldProceed() && replay.activeReplayId() === replayId) {
314
+ setActiveReplayId(replayId);
315
+ }
208
316
  } catch (e) {
209
317
  debugLog("replay auto-start failed", e);
210
318
  }
@@ -213,6 +321,7 @@ function isEnabled() {
213
321
  return state?.dsn !== null && state?.dsn !== void 0;
214
322
  }
215
323
  function resetForTest() {
324
+ resetCaptureGateForTest();
216
325
  state = null;
217
326
  pending.length = 0;
218
327
  autoReplayStart = null;
@@ -292,7 +401,8 @@ function captureCustomEvent(name, props) {
292
401
  return id;
293
402
  }
294
403
  function setActiveReplayId(replayId) {
295
- if (state) state.replayId = replayId && /^[a-zA-Z0-9_-]{1,64}$/.test(replayId) ? replayId : null;
404
+ if (!state || replayId && !isReplayCaptureAllowed()) return;
405
+ state.replayId = replayId && /^[a-zA-Z0-9_-]{1,64}$/.test(replayId) ? replayId : null;
296
406
  }
297
407
  function getActiveReplayId() {
298
408
  return state?.replayId ?? null;
@@ -311,6 +421,11 @@ export {
311
421
  eventId,
312
422
  buildEnvelope,
313
423
  sendEnvelope,
424
+ isReplayCaptureAllowed,
425
+ registerStaleAutoReplayRelease,
426
+ registerReplayStop,
427
+ setReplayCaptureAllowed,
428
+ resetCaptureGateForTest,
314
429
  SDK_NAME,
315
430
  SDK_VERSION,
316
431
  init,