@brunyee-studio/onus-sdk 0.1.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 +101 -0
- package/dist/chunk-SLWBPQ6C.js +326 -0
- package/dist/index.cjs +588 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.js +40 -0
- package/dist/replay-D7ejwI0s.d.ts +126 -0
- package/dist/replay.d.ts +1 -0
- package/dist/replay.js +174 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @brunyee-studio/onus-sdk
|
|
2
|
+
|
|
3
|
+
Official Onus browser SDK for **errors, session replay, and custom events** — speaks Onus' Sentry-compatible envelope protocol (`/api/{ingest_project_id}/envelope/`).
|
|
4
|
+
|
|
5
|
+
For the PostHog-style product-analytics pipeline (pageviews, autocapture, identity, trends/funnels), use [`@brunyee-studio/onus-analytics`](../analytics) instead.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @brunyee-studio/onus-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
import { init, captureException } from '@brunyee-studio/onus-sdk';
|
|
15
|
+
|
|
16
|
+
init({
|
|
17
|
+
dsn: 'https://<public-key>@<ingest-host>/<numeric-project-id>',
|
|
18
|
+
release: '1.0.0',
|
|
19
|
+
environment: 'production',
|
|
20
|
+
// Record every user's session automatically (see "Session replay"):
|
|
21
|
+
replays: { sampleRate: 1 },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
risky();
|
|
26
|
+
} catch (e) {
|
|
27
|
+
captureException(e);
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The DSN is public by design (Sentry doctrine); abuse is handled server-side via rate limits and the optional per-key origin allowlist. A missing or malformed DSN disables all network sends — the SDK fails safe.
|
|
32
|
+
|
|
33
|
+
## Session replay
|
|
34
|
+
|
|
35
|
+
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:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
init({
|
|
40
|
+
dsn,
|
|
41
|
+
replays: {
|
|
42
|
+
sampleRate: 1, // fraction of sessions recorded (default 1 = all users)
|
|
43
|
+
// sessionId: () => analytics.get_session_id(), // link playback to analytics sessions
|
|
44
|
+
// recordOptions: { maskAllInputs: true }, // rrweb privacy passthrough
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Errors captured afterwards carry `contexts.replay.replay_id` automatically —
|
|
50
|
+
no extra wiring. `enabled: false` (or omitting the block) opts out entirely.
|
|
51
|
+
|
|
52
|
+
Manual control still works via the separate entrypoint:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { startSessionReplay, stopSessionReplay } from '@brunyee-studio/onus-sdk/replay';
|
|
56
|
+
|
|
57
|
+
const replayId = await startSessionReplay({ dsn, environment: 'production' });
|
|
58
|
+
// Errors captured afterwards carry contexts.replay.replay_id automatically.
|
|
59
|
+
```
|
|
60
|
+
|
|
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).
|
|
62
|
+
|
|
63
|
+
## Transport semantics
|
|
64
|
+
|
|
65
|
+
- POSTs envelopes with `X-Sentry-Auth` (`sentry_key` = your public key).
|
|
66
|
+
- Gzips bodies via `CompressionStream` when available; the ingest route sniffs magic bytes and also accepts identity bodies.
|
|
67
|
+
- Honors `429` + `retry-after` with capped backoff (per-key rate limits).
|
|
68
|
+
- Envelopes over the server's 1 MiB wire cap are dropped client-side.
|
|
69
|
+
- `navigator.sendBeacon` is available for unload-time delivery (`?sentry_key=` query fallback, since beacons cannot set headers).
|
|
70
|
+
|
|
71
|
+
## Tree-shaking & debug flag
|
|
72
|
+
|
|
73
|
+
- Import only what you use; the replay recorder is isolated in `@brunyee-studio/onus-sdk/replay`.
|
|
74
|
+
- Debug logging is gated behind the compile-time flag `__ONUS_DEBUG__`. Strip it in production bundlers:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
// webpack
|
|
78
|
+
new webpack.DefinePlugin({ __ONUS_DEBUG__: false });
|
|
79
|
+
// esbuild / tsup
|
|
80
|
+
define: {
|
|
81
|
+
__ONUS_DEBUG__: 'false';
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Browser support
|
|
86
|
+
|
|
87
|
+
| Browser | Minimum |
|
|
88
|
+
| ---------- | ------- |
|
|
89
|
+
| Chrome | 90 |
|
|
90
|
+
| Edge | 90 |
|
|
91
|
+
| Firefox | 90 |
|
|
92
|
+
| Safari | 15.4 |
|
|
93
|
+
| iOS Safari | 15.4 |
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
**Version policy:** we support the current major line with fixes.
|
|
98
|
+
|
|
99
|
+
## Publishing
|
|
100
|
+
|
|
101
|
+
`@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`).
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// src/dsn.ts
|
|
2
|
+
function parseDsn(raw) {
|
|
3
|
+
if (!raw) return null;
|
|
4
|
+
let url;
|
|
5
|
+
try {
|
|
6
|
+
url = new URL(raw);
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
11
|
+
const key = url.username.split(":")[0] ?? "";
|
|
12
|
+
if (!key) return null;
|
|
13
|
+
const segments = url.pathname.split("/").filter((s) => s !== "");
|
|
14
|
+
if (segments.length !== 1) return null;
|
|
15
|
+
const projectId = segments[0] ?? "";
|
|
16
|
+
if (!/^\d+$/.test(projectId)) return null;
|
|
17
|
+
return {
|
|
18
|
+
key,
|
|
19
|
+
host: url.host,
|
|
20
|
+
projectId,
|
|
21
|
+
envelopeUrl: `${url.protocol}//${url.host}/api/${projectId}/envelope/`,
|
|
22
|
+
origin: url.origin
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function buildSentryAuthHeader(dsn, clientVersion) {
|
|
26
|
+
return `Sentry sentry_key=${dsn.key}, sentry_version=7, sentry_client=onus.javascript/${clientVersion}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/envelope.ts
|
|
30
|
+
var MAX_ENVELOPE_BYTES = 1024 * 1024;
|
|
31
|
+
var REPLAY_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
32
|
+
function eventId() {
|
|
33
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
34
|
+
return crypto.randomUUID();
|
|
35
|
+
}
|
|
36
|
+
const bytes = new Uint8Array(16);
|
|
37
|
+
crypto.getRandomValues(bytes);
|
|
38
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
39
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
40
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
41
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
42
|
+
}
|
|
43
|
+
function itemHeader(type, length) {
|
|
44
|
+
const header = { type, length };
|
|
45
|
+
return JSON.stringify(header);
|
|
46
|
+
}
|
|
47
|
+
function buildEnvelope(header, items) {
|
|
48
|
+
const parts = [JSON.stringify(header)];
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
parts.push(itemHeader(item.type, item.type === "replay_recording" ? item.length : void 0));
|
|
51
|
+
if (item.type === "replay_recording") {
|
|
52
|
+
parts.push(item.payload);
|
|
53
|
+
} else {
|
|
54
|
+
parts.push(JSON.stringify(item.payload));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const serialized = parts.join("\n");
|
|
58
|
+
if (new TextEncoder().encode(serialized).byteLength > MAX_ENVELOPE_BYTES) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return serialized;
|
|
62
|
+
}
|
|
63
|
+
function withReplayContext(event, replayId) {
|
|
64
|
+
if (!replayId || !REPLAY_ID_RE.test(replayId)) return event;
|
|
65
|
+
const contexts = {
|
|
66
|
+
...event["contexts"],
|
|
67
|
+
replay: { replay_id: replayId }
|
|
68
|
+
};
|
|
69
|
+
return { ...event, contexts };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/transport.ts
|
|
73
|
+
async function gzipText(text) {
|
|
74
|
+
const CS = globalThis.CompressionStream;
|
|
75
|
+
if (!CS || typeof Response === "undefined") return null;
|
|
76
|
+
try {
|
|
77
|
+
const body = new Response(text).body;
|
|
78
|
+
if (!body) return null;
|
|
79
|
+
const buf = await new Response(body.pipeThrough(new CS("gzip"))).arrayBuffer();
|
|
80
|
+
return new Uint8Array(buf);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function delay(ms) {
|
|
86
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
87
|
+
}
|
|
88
|
+
async function sendEnvelope(opts) {
|
|
89
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
90
|
+
const maxRetries = opts.maxRetries ?? 1;
|
|
91
|
+
const backoffMs = opts.backoffMs ?? 500;
|
|
92
|
+
const sleep = opts.delayImpl ?? delay;
|
|
93
|
+
const gz = await gzipText(opts.body);
|
|
94
|
+
const headers = {
|
|
95
|
+
"Content-Type": "application/x-sentry-envelope",
|
|
96
|
+
"X-Sentry-Auth": opts.authHeader,
|
|
97
|
+
...gz ? { "Content-Encoding": "gzip" } : {}
|
|
98
|
+
};
|
|
99
|
+
for (let attempt = 0; ; attempt++) {
|
|
100
|
+
let res;
|
|
101
|
+
try {
|
|
102
|
+
res = await fetchImpl(opts.url, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers,
|
|
105
|
+
body: gz ?? opts.body,
|
|
106
|
+
// Analytics-style telemetry: never block page unload on the response.
|
|
107
|
+
keepalive: true
|
|
108
|
+
});
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { ok: false, error: e instanceof Error ? e.message : "network error" };
|
|
111
|
+
}
|
|
112
|
+
if (res.ok) return { ok: true, status: res.status };
|
|
113
|
+
if (res.status === 429 && attempt < maxRetries) {
|
|
114
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "");
|
|
115
|
+
const wait = Math.min(
|
|
116
|
+
Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : backoffMs * (attempt + 1),
|
|
117
|
+
1e4
|
|
118
|
+
);
|
|
119
|
+
await sleep(wait);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (res.status === 429) {
|
|
123
|
+
const retryAfter = Number(res.headers.get("retry-after") ?? "");
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
status: 429,
|
|
127
|
+
retryAfterSeconds: Number.isFinite(retryAfter) ? retryAfter : void 0,
|
|
128
|
+
error: "rate limited"
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { ok: false, status: res.status, error: `ingest responded ${res.status}` };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/client.ts
|
|
136
|
+
var SDK_NAME = "onus.javascript";
|
|
137
|
+
var SDK_VERSION = "0.1.0";
|
|
138
|
+
var autoReplayStart = null;
|
|
139
|
+
var state = null;
|
|
140
|
+
var pending = [];
|
|
141
|
+
var MAX_PENDING = 100;
|
|
142
|
+
function debugLog(...args) {
|
|
143
|
+
if (false) {
|
|
144
|
+
console.debug("[onus]", ...args);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function dispatch(items, eventIdValue) {
|
|
148
|
+
if (!state?.dsn) return Promise.resolve(false);
|
|
149
|
+
const serialized = buildEnvelope(
|
|
150
|
+
{
|
|
151
|
+
event_id: eventIdValue,
|
|
152
|
+
sent_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
153
|
+
sdk: { name: SDK_NAME, version: SDK_VERSION }
|
|
154
|
+
},
|
|
155
|
+
items
|
|
156
|
+
);
|
|
157
|
+
if (!serialized) {
|
|
158
|
+
debugLog("envelope exceeded 1 MiB cap; dropped", eventIdValue);
|
|
159
|
+
return Promise.resolve(false);
|
|
160
|
+
}
|
|
161
|
+
const send = sendEnvelope({
|
|
162
|
+
url: state.dsn.envelopeUrl,
|
|
163
|
+
authHeader: state.authHeader,
|
|
164
|
+
body: serialized,
|
|
165
|
+
fetchImpl: state.options.fetchImpl
|
|
166
|
+
});
|
|
167
|
+
const tracked = send.then((r) => {
|
|
168
|
+
if (!r.ok) debugLog("send failed", r.status, r.error);
|
|
169
|
+
return r.ok;
|
|
170
|
+
});
|
|
171
|
+
pending.push(tracked);
|
|
172
|
+
if (pending.length > MAX_PENDING) pending.shift();
|
|
173
|
+
return tracked;
|
|
174
|
+
}
|
|
175
|
+
function init(options) {
|
|
176
|
+
const dsn = parseDsn(options.dsn);
|
|
177
|
+
if (!dsn) {
|
|
178
|
+
debugLog("no valid dsn; SDK is disabled (no network sends)");
|
|
179
|
+
}
|
|
180
|
+
state = {
|
|
181
|
+
dsn,
|
|
182
|
+
options,
|
|
183
|
+
authHeader: dsn ? buildSentryAuthHeader(dsn, SDK_VERSION) : "",
|
|
184
|
+
replayId: null
|
|
185
|
+
};
|
|
186
|
+
autoReplayStart = null;
|
|
187
|
+
if (options.replays && options.replays.enabled !== false && dsn) {
|
|
188
|
+
autoReplayStart = startAutoReplay(options.replays, options);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function startAutoReplay(replays, options) {
|
|
192
|
+
try {
|
|
193
|
+
const rate = typeof replays.sampleRate === "number" && replays.sampleRate >= 0 && replays.sampleRate <= 1 ? replays.sampleRate : 1;
|
|
194
|
+
if (Math.random() >= rate) return;
|
|
195
|
+
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);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
debugLog("replay auto-start failed", e);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function isEnabled() {
|
|
213
|
+
return state?.dsn !== null && state?.dsn !== void 0;
|
|
214
|
+
}
|
|
215
|
+
function resetForTest() {
|
|
216
|
+
state = null;
|
|
217
|
+
pending.length = 0;
|
|
218
|
+
autoReplayStart = null;
|
|
219
|
+
}
|
|
220
|
+
function baseEvent(error) {
|
|
221
|
+
return {
|
|
222
|
+
platform: "javascript",
|
|
223
|
+
environment: state?.options.environment ?? "production",
|
|
224
|
+
...state?.options.release ? { release: state.options.release } : {},
|
|
225
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
226
|
+
...error
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function captureException(exception, hint) {
|
|
230
|
+
const id = eventId();
|
|
231
|
+
const event = withReplayContext(baseEvent({ level: "error", ...hint }), state?.replayId ?? null);
|
|
232
|
+
event["event_id"] = id;
|
|
233
|
+
event["exception"] = exceptionPayload(exception);
|
|
234
|
+
void dispatch([{ type: "event", payload: event }], id);
|
|
235
|
+
return id;
|
|
236
|
+
}
|
|
237
|
+
function exceptionPayload(exception) {
|
|
238
|
+
if (exception instanceof Error) {
|
|
239
|
+
return {
|
|
240
|
+
values: [
|
|
241
|
+
{
|
|
242
|
+
type: exception.name,
|
|
243
|
+
value: exception.message,
|
|
244
|
+
stacktrace: exception.stack ? { frames: parseStackFrames(exception.stack) } : void 0
|
|
245
|
+
}
|
|
246
|
+
]
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return { values: [{ value: String(exception) }] };
|
|
250
|
+
}
|
|
251
|
+
function parseStackFrames(stack) {
|
|
252
|
+
const frames = [];
|
|
253
|
+
for (const line of stack.split("\n")) {
|
|
254
|
+
const m = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/.exec(line);
|
|
255
|
+
if (!m) continue;
|
|
256
|
+
frames.push({
|
|
257
|
+
function: m[1] ?? void 0,
|
|
258
|
+
filename: m[2],
|
|
259
|
+
lineno: Number(m[3]),
|
|
260
|
+
colno: Number(m[4])
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return frames.reverse();
|
|
264
|
+
}
|
|
265
|
+
function captureMessage(message, hint) {
|
|
266
|
+
const id = eventId();
|
|
267
|
+
const event = withReplayContext(
|
|
268
|
+
baseEvent({ message, level: "info", ...hint }),
|
|
269
|
+
state?.replayId ?? null
|
|
270
|
+
);
|
|
271
|
+
event["event_id"] = id;
|
|
272
|
+
void dispatch([{ type: "event", payload: event }], id);
|
|
273
|
+
return id;
|
|
274
|
+
}
|
|
275
|
+
function captureEvent(event) {
|
|
276
|
+
const id = eventId();
|
|
277
|
+
const payload = withReplayContext(baseEvent(event), state?.replayId ?? null);
|
|
278
|
+
payload["event_id"] = id;
|
|
279
|
+
void dispatch([{ type: "event", payload }], id);
|
|
280
|
+
return id;
|
|
281
|
+
}
|
|
282
|
+
function captureCustomEvent(name, props) {
|
|
283
|
+
const id = eventId();
|
|
284
|
+
const event = baseEvent({
|
|
285
|
+
message: name,
|
|
286
|
+
level: "info",
|
|
287
|
+
tags: { __onus_custom: name },
|
|
288
|
+
extra: props ?? {}
|
|
289
|
+
});
|
|
290
|
+
event["event_id"] = id;
|
|
291
|
+
void dispatch([{ type: "event", payload: event }], id);
|
|
292
|
+
return id;
|
|
293
|
+
}
|
|
294
|
+
function setActiveReplayId(replayId) {
|
|
295
|
+
if (state) state.replayId = replayId && /^[a-zA-Z0-9_-]{1,64}$/.test(replayId) ? replayId : null;
|
|
296
|
+
}
|
|
297
|
+
function getActiveReplayId() {
|
|
298
|
+
return state?.replayId ?? null;
|
|
299
|
+
}
|
|
300
|
+
async function flush() {
|
|
301
|
+
if (autoReplayStart) await autoReplayStart;
|
|
302
|
+
await Promise.allSettled(pending.splice(0, pending.length));
|
|
303
|
+
return isEnabled();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export {
|
|
307
|
+
parseDsn,
|
|
308
|
+
buildSentryAuthHeader,
|
|
309
|
+
MAX_ENVELOPE_BYTES,
|
|
310
|
+
REPLAY_ID_RE,
|
|
311
|
+
eventId,
|
|
312
|
+
buildEnvelope,
|
|
313
|
+
sendEnvelope,
|
|
314
|
+
SDK_NAME,
|
|
315
|
+
SDK_VERSION,
|
|
316
|
+
init,
|
|
317
|
+
isEnabled,
|
|
318
|
+
resetForTest,
|
|
319
|
+
captureException,
|
|
320
|
+
captureMessage,
|
|
321
|
+
captureEvent,
|
|
322
|
+
captureCustomEvent,
|
|
323
|
+
setActiveReplayId,
|
|
324
|
+
getActiveReplayId,
|
|
325
|
+
flush
|
|
326
|
+
};
|