@shipeasy/sdk 3.1.0 → 4.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.
@@ -38,6 +38,7 @@ __export(client_exports, {
38
38
  readConfigOverride: () => readConfigOverride,
39
39
  readExpOverride: () => readExpOverride,
40
40
  readGateOverride: () => readGateOverride,
41
+ see: () => see,
41
42
  shipeasy: () => shipeasy,
42
43
  version: () => version
43
44
  });
@@ -102,8 +103,208 @@ function send(url) {
102
103
  }
103
104
  }
104
105
 
106
+ // src/see/core.ts
107
+ var SEE_MAX_MESSAGE = 500;
108
+ var SEE_MAX_STACK = 8e3;
109
+ var SEE_MAX_SUBJECT = 200;
110
+ var SEE_MAX_EXTRA_VALUE = 200;
111
+ var SEE_MAX_EXTRA_KEYS = 20;
112
+ var SEE_DEDUP_WINDOW_MS = 3e4;
113
+ var SEE_MAX_PER_SESSION = 25;
114
+ function causesThe(subject) {
115
+ return {
116
+ to(outcome) {
117
+ return {
118
+ __seConsequence: true,
119
+ subject: truncate(String(subject), SEE_MAX_SUBJECT),
120
+ outcome: truncate(String(outcome), SEE_MAX_SUBJECT)
121
+ };
122
+ }
123
+ };
124
+ }
125
+ function violation(name) {
126
+ const make = (msg) => ({
127
+ __seViolation: true,
128
+ violationName: String(name),
129
+ ...msg !== void 0 ? { violationMessage: msg } : {},
130
+ message(m) {
131
+ return make(String(m));
132
+ }
133
+ });
134
+ return make();
135
+ }
136
+ function isViolation(p) {
137
+ return typeof p === "object" && p !== null && p.__seViolation === true;
138
+ }
139
+ var EXPECTED_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:see-expected");
140
+ function markExpected(err, because) {
141
+ if (typeof err !== "object" || err === null) return;
142
+ try {
143
+ Object.defineProperty(err, EXPECTED_SYM, {
144
+ value: String(because),
145
+ enumerable: false,
146
+ configurable: true
147
+ });
148
+ } catch {
149
+ }
150
+ }
151
+ function isExpected(err) {
152
+ if (typeof err !== "object" || err === null) return false;
153
+ return err[EXPECTED_SYM] !== void 0;
154
+ }
155
+ function truncate(s, max) {
156
+ return s.length > max ? s.slice(0, max) : s;
157
+ }
158
+ function sanitizeExtras(extras) {
159
+ if (!extras || typeof extras !== "object") return void 0;
160
+ const out = {};
161
+ let n = 0;
162
+ for (const [k, v] of Object.entries(extras)) {
163
+ if (v === null || v === void 0) continue;
164
+ if (n >= SEE_MAX_EXTRA_KEYS) break;
165
+ if (typeof v === "string") out[k] = truncate(v, SEE_MAX_EXTRA_VALUE);
166
+ else if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
167
+ else if (typeof v === "boolean") out[k] = v;
168
+ else continue;
169
+ n += 1;
170
+ }
171
+ return n > 0 ? out : void 0;
172
+ }
173
+ function captureCallsiteStack() {
174
+ const raw = new Error().stack;
175
+ if (!raw) return void 0;
176
+ const lines = raw.split("\n");
177
+ const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
178
+ return kept.length ? kept.join("\n") : void 0;
179
+ }
180
+ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride) {
181
+ let errorType;
182
+ let message;
183
+ let stack;
184
+ let kind;
185
+ if (isViolation(problem)) {
186
+ errorType = problem.violationName;
187
+ message = problem.violationMessage ?? problem.violationName;
188
+ stack = captureCallsiteStack();
189
+ kind = kindOverride ?? "violation";
190
+ } else if (problem instanceof Error) {
191
+ errorType = problem.name || "Error";
192
+ message = problem.message || String(problem);
193
+ stack = problem.stack ?? void 0;
194
+ kind = kindOverride ?? "caught";
195
+ } else {
196
+ errorType = "Error";
197
+ message = typeof problem === "string" ? problem : safeString(problem);
198
+ stack = captureCallsiteStack();
199
+ kind = kindOverride ?? "caught";
200
+ }
201
+ const ev = {
202
+ type: "error",
203
+ kind,
204
+ error_type: truncate(errorType, SEE_MAX_SUBJECT),
205
+ message: truncate(message, SEE_MAX_MESSAGE),
206
+ subject: consequence.subject,
207
+ outcome: consequence.outcome,
208
+ side: ctx.side,
209
+ sdk_version: ctx.sdkVersion,
210
+ ts: Date.now()
211
+ };
212
+ if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
213
+ const cleanExtras = sanitizeExtras(extras);
214
+ if (cleanExtras) ev.extras = cleanExtras;
215
+ if (ctx.url) ev.url = truncate(ctx.url, SEE_MAX_SUBJECT);
216
+ if (ctx.userId) ev.user_id = ctx.userId;
217
+ if (ctx.anonId) ev.anonymous_id = ctx.anonId;
218
+ if (ctx.env) ev.env = ctx.env;
219
+ return ev;
220
+ }
221
+ function safeString(v) {
222
+ try {
223
+ return typeof v === "object" ? JSON.stringify(v) : String(v);
224
+ } catch {
225
+ return String(v);
226
+ }
227
+ }
228
+ var scheduleMicrotask = typeof queueMicrotask === "function" ? queueMicrotask : (cb) => {
229
+ void Promise.resolve().then(cb);
230
+ };
231
+ function startSeeChain(getProblem, dispatch) {
232
+ let subject;
233
+ let outcome;
234
+ let collected;
235
+ let flushed = false;
236
+ scheduleMicrotask(() => {
237
+ if (flushed) return;
238
+ flushed = true;
239
+ dispatch(
240
+ getProblem(),
241
+ causesThe(subject ?? "the app").to(outcome ?? "hit an error"),
242
+ collected
243
+ );
244
+ });
245
+ const tail = {
246
+ extras(x) {
247
+ if (x && typeof x === "object") collected = { ...collected, ...x };
248
+ return tail;
249
+ }
250
+ };
251
+ const step = {
252
+ to(o) {
253
+ outcome = String(o);
254
+ return tail;
255
+ }
256
+ };
257
+ const start = (s) => {
258
+ subject = String(s);
259
+ return step;
260
+ };
261
+ return { causes_the: start, causesThe: start };
262
+ }
263
+ function startSeeViolationChain(name, dispatch) {
264
+ let msg;
265
+ const base = startSeeChain(
266
+ () => msg !== void 0 ? violation(name).message(msg) : violation(name),
267
+ dispatch
268
+ );
269
+ const chain = {
270
+ ...base,
271
+ message(m) {
272
+ msg = String(m);
273
+ return chain;
274
+ }
275
+ };
276
+ return chain;
277
+ }
278
+ function topStackLine(stack) {
279
+ if (!stack) return "";
280
+ for (const line of stack.split("\n")) {
281
+ if (/^\s*at |@|:\d+:\d+/.test(line)) return line.trim().slice(0, 200);
282
+ }
283
+ return "";
284
+ }
285
+ var SeeLimiter = class {
286
+ constructor(maxPerSession = SEE_MAX_PER_SESSION, dedupWindowMs = SEE_DEDUP_WINDOW_MS) {
287
+ this.maxPerSession = maxPerSession;
288
+ this.dedupWindowMs = dedupWindowMs;
289
+ }
290
+ maxPerSession;
291
+ dedupWindowMs;
292
+ lastSent = /* @__PURE__ */ new Map();
293
+ sent = 0;
294
+ shouldSend(ev) {
295
+ if (this.sent >= this.maxPerSession) return false;
296
+ const key = `${ev.kind}|${ev.error_type}|${ev.message.slice(0, 200)}|${topStackLine(ev.stack)}`;
297
+ const now = Date.now();
298
+ const prev = this.lastSent.get(key);
299
+ if (prev !== void 0 && now - prev < this.dedupWindowMs) return false;
300
+ this.lastSent.set(key, now);
301
+ this.sent += 1;
302
+ return true;
303
+ }
304
+ };
305
+
105
306
  // src/client/index.ts
106
- var version = "1.0.0";
307
+ var version = "4.0.0";
107
308
  var FLUSH_INTERVAL_MS = 5e3;
108
309
  var MAX_BUFFER = 100;
109
310
  var ANON_ID_KEY = "__se_anon_id";
@@ -137,6 +338,13 @@ var EventBuffer = class {
137
338
  this.timer = null;
138
339
  }
139
340
  }
341
+ /** True once this visitor has been exposed to ≥1 experiment (this tab or a
342
+ * prior page in the session — the dedup set persists in sessionStorage).
343
+ * Gates auto-metric emission: vitals from non-participants are never read
344
+ * by the analysis pipeline and would be pure AE write cost (see cost.md). */
345
+ hasExposures() {
346
+ return this.exposureSeen.size > 0;
347
+ }
140
348
  pushExposure(experiment, group, userId, anonId) {
141
349
  const key = `${userId || anonId}:${experiment}`;
142
350
  if (this.exposureSeen.has(key)) return;
@@ -202,16 +410,29 @@ var EventBuffer = class {
202
410
  flush(useBeacon = false) {
203
411
  if (!this.queue.length) return;
204
412
  const batch = this.queue.splice(0);
205
- const body = JSON.stringify({ events: batch });
413
+ this.send(batch, useBeacon);
414
+ }
415
+ /**
416
+ * Bypass the 5s queue and ship events immediately — used by see() error
417
+ * reporting so occurrences land near-real-time and survive page unload.
418
+ * Beacon-first (fire-and-forget, unload-safe), keepalive fetch fallback.
419
+ */
420
+ sendNow(events) {
421
+ this.send(events, true);
422
+ }
423
+ send(batch, useBeacon) {
206
424
  if (useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
207
425
  const beaconBody = JSON.stringify({ k: this.sdkKey, events: batch });
208
- navigator.sendBeacon(this.collectUrl, new Blob([beaconBody], { type: "text/plain" }));
209
- return;
426
+ try {
427
+ if (navigator.sendBeacon(this.collectUrl, new Blob([beaconBody], { type: "text/plain" })))
428
+ return;
429
+ } catch {
430
+ }
210
431
  }
211
432
  fetch(this.collectUrl, {
212
433
  method: "POST",
213
434
  headers: { "X-SDK-Key": this.sdkKey, "Content-Type": "application/json" },
214
- body,
435
+ body: JSON.stringify({ events: batch }),
215
436
  keepalive: true
216
437
  }).catch(() => {
217
438
  });
@@ -228,14 +449,12 @@ var EventBuffer = class {
228
449
  });
229
450
  }
230
451
  };
231
- var MAX_ERRORS_PER_SESSION = 5;
232
- function installAutoGuardrails(buffer, userId, anonId, groups) {
452
+ function installAutoGuardrails(buffer, userId, anonId, groups, reportSee, ignoreUrlPrefixes, always = false) {
233
453
  if (typeof window === "undefined" || typeof PerformanceObserver === "undefined") return;
454
+ const shouldEmit = () => always || buffer.hasExposures();
234
455
  let lcp = null;
235
456
  let inp = null;
236
457
  let clsBad = false;
237
- let jsErrorCount = 0;
238
- let netErrorCount = 0;
239
458
  let navTimingFlushed = false;
240
459
  if (groups.vitals) {
241
460
  try {
@@ -274,68 +493,71 @@ function installAutoGuardrails(buffer, userId, anonId, groups) {
274
493
  if (groups.errors) {
275
494
  const origOnError = window.onerror;
276
495
  window.onerror = (msg, source, lineno, _colno, err) => {
277
- if (jsErrorCount < MAX_ERRORS_PER_SESSION) {
278
- jsErrorCount += 1;
279
- buffer.pushMetric("__auto_js_error", userId, anonId, {
280
- value: 1,
281
- kind: "exception",
282
- message: typeof msg === "string" ? msg.slice(0, 200) : String(err ?? "").slice(0, 200),
283
- source: typeof source === "string" ? source.slice(0, 200) : "",
284
- line: lineno ?? 0
285
- });
496
+ if (!isExpected(err)) {
497
+ const problem = err ?? (typeof msg === "string" && msg ? msg : "Unknown error");
498
+ reportSee(
499
+ problem,
500
+ causesThe("the page").to("hit an unhandled error"),
501
+ {
502
+ source: typeof source === "string" ? source : void 0,
503
+ line: lineno ?? void 0
504
+ },
505
+ "uncaught"
506
+ );
286
507
  }
287
508
  if (typeof origOnError === "function") return origOnError(msg, source, lineno, _colno, err);
288
509
  return false;
289
510
  };
290
511
  window.addEventListener("unhandledrejection", (e) => {
291
- if (jsErrorCount < MAX_ERRORS_PER_SESSION) {
292
- jsErrorCount += 1;
293
- const reason = e.reason;
294
- const message = reason instanceof Error ? reason.message : typeof reason === "string" ? reason : String(reason);
295
- buffer.pushMetric("__auto_js_error", userId, anonId, {
296
- value: 1,
297
- kind: "unhandled_rejection",
298
- message: message.slice(0, 200)
299
- });
300
- }
512
+ const reason = e.reason;
513
+ if (isExpected(reason)) return;
514
+ reportSee(
515
+ reason ?? "Unhandled promise rejection",
516
+ causesThe("the page").to("hit an unhandled promise rejection"),
517
+ void 0,
518
+ "unhandled_rejection"
519
+ );
301
520
  });
302
521
  const origFetch = window.fetch;
303
522
  window.fetch = async function(...args) {
304
523
  const startedAt = typeof performance !== "undefined" ? performance.now() : 0;
305
524
  const url = typeof args[0] === "string" ? args[0] : args[0].toString();
525
+ const ignored = ignoreUrlPrefixes.some((p) => p && url.startsWith(p));
526
+ const bareUrl = url.split("?")[0].slice(0, 200);
306
527
  let res;
307
528
  try {
308
529
  res = await origFetch.apply(this, args);
309
530
  } catch (err) {
310
- if (netErrorCount < MAX_ERRORS_PER_SESSION) {
311
- netErrorCount += 1;
312
- buffer.pushMetric("__auto_network_error", userId, anonId, {
313
- value: 1,
314
- kind: "network",
315
- status: 0,
316
- url: url.slice(0, 200)
317
- });
531
+ if (!ignored && !isExpected(err)) {
532
+ reportSee(
533
+ violation("NetworkError").message(`request to ${bareUrl} failed`),
534
+ causesThe("a network request").to("fail without a response"),
535
+ { status: 0, url: url.slice(0, 200) },
536
+ "network"
537
+ );
318
538
  }
319
539
  throw err;
320
540
  }
321
- if (res.status >= 500 && netErrorCount < MAX_ERRORS_PER_SESSION) {
322
- netErrorCount += 1;
541
+ if (!ignored && res.status >= 500) {
323
542
  const elapsed = typeof performance !== "undefined" ? performance.now() - startedAt : 0;
324
- buffer.pushMetric("__auto_network_error", userId, anonId, {
325
- value: 1,
326
- kind: "5xx",
327
- status: res.status,
328
- url: url.slice(0, 200),
329
- duration_ms: Math.round(elapsed)
330
- });
543
+ reportSee(
544
+ violation("Http5xx").message(`request to ${bareUrl} returned ${res.status}`),
545
+ causesThe("a network request").to(`fail with HTTP ${res.status}`),
546
+ { status: res.status, url: url.slice(0, 200), duration_ms: Math.round(elapsed) },
547
+ "network"
548
+ );
331
549
  }
332
550
  return res;
333
551
  };
334
552
  }
335
553
  const flushNavTiming = () => {
336
554
  if (navTimingFlushed) return;
555
+ if (!groups.vitals) {
556
+ navTimingFlushed = true;
557
+ return;
558
+ }
559
+ if (!shouldEmit()) return;
337
560
  navTimingFlushed = true;
338
- if (!groups.vitals) return;
339
561
  try {
340
562
  const navList = performance.getEntriesByType("navigation");
341
563
  const nav = navList[0];
@@ -370,7 +592,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups) {
370
592
  };
371
593
  if (groups.engagement) {
372
594
  try {
373
- buffer.pushMetric("__auto_session_active", userId, anonId, { value: 1 });
595
+ if (shouldEmit()) buffer.pushMetric("__auto_session_active", userId, anonId, { value: 1 });
374
596
  } catch {
375
597
  }
376
598
  let lastEmit = Date.now();
@@ -378,6 +600,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups) {
378
600
  document.addEventListener("visibilitychange", () => {
379
601
  if (document.visibilityState !== "visible") return;
380
602
  if (Date.now() - lastEmit < SESSION_GAP_MS) return;
603
+ if (!shouldEmit()) return;
381
604
  try {
382
605
  buffer.pushMetric("__auto_session_active", userId, anonId, { value: 1 });
383
606
  lastEmit = Date.now();
@@ -400,7 +623,7 @@ function installAutoGuardrails(buffer, userId, anonId, groups) {
400
623
  }
401
624
  const flushOnHide = () => {
402
625
  flushNavTiming();
403
- if (groups.vitals) {
626
+ if (groups.vitals && shouldEmit()) {
404
627
  if (lcp !== null) buffer.pushMetric("__auto_lcp", userId, anonId, { value: lcp });
405
628
  if (inp !== null) buffer.pushMetric("__auto_inp", userId, anonId, { value: inp });
406
629
  if (clsBad) buffer.pushMetric("__auto_cls_binary", userId, anonId, { value: 1 });
@@ -483,12 +706,14 @@ var FlagsClientBrowser = class {
483
706
  baseUrl;
484
707
  autoGuardrails;
485
708
  autoGuardrailGroups;
709
+ autoCollectAlways;
486
710
  env;
487
711
  evalResult = null;
488
712
  anonId;
489
713
  userId = "";
490
714
  buffer;
491
715
  telemetry;
716
+ seeLimiter = new SeeLimiter();
492
717
  guardrailsInstalled = false;
493
718
  listeners = /* @__PURE__ */ new Set();
494
719
  overrideListenerInstalled = false;
@@ -504,6 +729,7 @@ var FlagsClientBrowser = class {
504
729
  this.baseUrl = (opts.baseUrl ?? "https://edge.shipeasy.dev").replace(/\/$/, "");
505
730
  this.env = opts.env ?? "prod";
506
731
  this.autoGuardrails = opts.autoGuardrails !== false;
732
+ this.autoCollectAlways = opts.autoCollectAlways === true;
507
733
  const g = opts.autoGuardrailGroups ?? {};
508
734
  this.autoGuardrailGroups = {
509
735
  vitals: g.vitals ?? this.autoGuardrails,
@@ -548,10 +774,38 @@ var FlagsClientBrowser = class {
548
774
  const anyGroupOn = this.autoGuardrailGroups.vitals || this.autoGuardrailGroups.errors || this.autoGuardrailGroups.engagement;
549
775
  if (anyGroupOn && !this.guardrailsInstalled) {
550
776
  this.guardrailsInstalled = true;
551
- installAutoGuardrails(this.buffer, this.userId, this.anonId, this.autoGuardrailGroups);
777
+ installAutoGuardrails(
778
+ this.buffer,
779
+ this.userId,
780
+ this.anonId,
781
+ this.autoGuardrailGroups,
782
+ (problem, consequence, extras, kind) => this.reportError(problem, consequence, extras, kind),
783
+ [`${this.baseUrl}/`, DEFAULT_TELEMETRY_URL],
784
+ this.autoCollectAlways
785
+ );
552
786
  }
553
787
  this.notify();
554
788
  }
789
+ /**
790
+ * Report a structured error into the errors primitive. Flushes immediately
791
+ * (beacon-first) — error occurrences are near-real-time, never queued behind
792
+ * the 5s metric batch. Spam-guarded by a 30s dedup window + per-session cap.
793
+ */
794
+ reportError(problem, consequence, extras, kind) {
795
+ try {
796
+ const ev = buildSeeEvent(problem, consequence, extras, {
797
+ side: "client",
798
+ sdkVersion: version,
799
+ env: this.env,
800
+ url: typeof window !== "undefined" && window.location ? window.location.href : void 0,
801
+ userId: this.userId || void 0,
802
+ anonId: this.anonId
803
+ }, kind);
804
+ if (!this.seeLimiter.shouldSend(ev)) return;
805
+ this.buffer.sendNow([ev]);
806
+ } catch {
807
+ }
808
+ }
555
809
  get ready() {
556
810
  return this.evalResult !== null;
557
811
  }
@@ -783,13 +1037,15 @@ var _client = null;
783
1037
  function shipeasy(opts) {
784
1038
  const ac = opts.autoCollect;
785
1039
  const blanket = ac === false ? false : true;
786
- const groups = ac && typeof ac === "object" ? ac : void 0;
1040
+ const acObj = ac && typeof ac === "object" ? ac : void 0;
1041
+ const groups = acObj ? { vitals: acObj.vitals, errors: acObj.errors, engagement: acObj.engagement } : void 0;
787
1042
  const baseUrl = opts.baseUrl ?? "https://cdn.shipeasy.ai";
788
1043
  const client = configureShipeasy({
789
1044
  sdkKey: opts.clientKey,
790
1045
  baseUrl,
791
1046
  autoGuardrails: blanket,
792
1047
  autoGuardrailGroups: groups,
1048
+ autoCollectAlways: acObj?.always === true,
793
1049
  disableTelemetry: opts.disableTelemetry
794
1050
  });
795
1051
  injectI18nLoader(opts.clientKey, baseUrl, opts.i18nProfile);
@@ -951,6 +1207,20 @@ var flags = {
951
1207
  return _client?.ready ?? false;
952
1208
  }
953
1209
  };
1210
+ function dispatchSee(problem, consequence, extras, kind) {
1211
+ if (!_client) {
1212
+ console.warn("[shipeasy] see() called before shipeasy({ clientKey }) \u2014 error dropped");
1213
+ return;
1214
+ }
1215
+ _client.reportError(problem, consequence, extras, kind);
1216
+ }
1217
+ var see = Object.assign(
1218
+ (problem) => startSeeChain(() => problem, dispatchSee),
1219
+ {
1220
+ Violation: (name) => startSeeViolationChain(name, dispatchSee),
1221
+ ControlFlowException: markExpected
1222
+ }
1223
+ );
954
1224
  var LABEL_MARKER_START = "\uFFF9";
955
1225
  var LABEL_MARKER_SEP = "\uFFFA";
956
1226
  var LABEL_MARKER_END = "\uFFFB";
@@ -1203,6 +1473,7 @@ var i18n = {
1203
1473
  readConfigOverride,
1204
1474
  readExpOverride,
1205
1475
  readGateOverride,
1476
+ see,
1206
1477
  shipeasy,
1207
1478
  version
1208
1479
  });