@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 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
+ };