@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 +108 -6
- package/dist/{chunk-SLWBPQ6C.js → chunk-YPQXIZ4X.js} +134 -19
- package/dist/index.cjs +364 -43
- package/dist/index.d.ts +16 -9
- package/dist/index.js +7 -3
- package/dist/{replay-D7ejwI0s.d.ts → replay-BElhLiw8.d.ts} +52 -14
- package/dist/replay.d.ts +1 -1
- package/dist/replay.js +221 -23
- package/package.json +1 -1
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
|
-
//
|
|
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()`;
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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,
|