@shipeasy/sdk 3.0.1 → 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.
@@ -1,6 +1,263 @@
1
1
  // src/server/index.ts
2
2
  import { AsyncLocalStorage } from "async_hooks";
3
- var version = "1.0.0";
3
+
4
+ // src/telemetry.ts
5
+ async function sha256Hex(input) {
6
+ const buf = new TextEncoder().encode(input);
7
+ const digest = await crypto.subtle.digest("SHA-256", buf);
8
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
9
+ }
10
+ var DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai";
11
+ var Telemetry = class {
12
+ prefix;
13
+ disabled;
14
+ dedupeMs;
15
+ // Last-emit timestamp per `feature/resource`, for the dedup window. Bounded by
16
+ // the number of distinct keys the app reads.
17
+ lastEmit = /* @__PURE__ */ new Map();
18
+ // Resolved once at construction and reused by every emit(), so the per-eval
19
+ // cost is a Map-free microtask, not a hash.
20
+ keyHash;
21
+ constructor(opts) {
22
+ const endpoint = (opts.endpoint ?? "").replace(/\/$/, "");
23
+ this.disabled = opts.disabled === true || !opts.sdkKey || !endpoint;
24
+ this.dedupeMs = opts.dedupeMs ?? 2e3;
25
+ this.prefix = `${endpoint}/t`;
26
+ this.keyHash = this.disabled ? null : sha256Hex(opts.sdkKey).then((h) => `${h}/${opts.side}/${encodeURIComponent(opts.env)}`).catch(() => "");
27
+ }
28
+ /**
29
+ * Emit a single best-effort usage beacon for one evaluation. Never blocks the
30
+ * caller (the hash is already resolved) and never throws — a failed beacon
31
+ * must never affect the evaluation it measures.
32
+ */
33
+ emit(feature, resource) {
34
+ if (this.disabled || !this.keyHash) return;
35
+ if (this.dedupeMs > 0) {
36
+ const dedupeKey = `${feature}/${resource}`;
37
+ const now = Date.now();
38
+ const last = this.lastEmit.get(dedupeKey);
39
+ if (last !== void 0 && now - last < this.dedupeMs) return;
40
+ this.lastEmit.set(dedupeKey, now);
41
+ }
42
+ void this.keyHash.then((suffix) => {
43
+ if (!suffix) return;
44
+ send(`${this.prefix}/${suffix}/${feature}/${encodeURIComponent(resource)}`);
45
+ });
46
+ }
47
+ };
48
+ function send(url) {
49
+ try {
50
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
51
+ navigator.sendBeacon(url);
52
+ return;
53
+ }
54
+ const f = globalThis.fetch;
55
+ if (typeof f === "function") {
56
+ void f(url, { method: "GET", keepalive: true }).catch(() => {
57
+ });
58
+ }
59
+ } catch {
60
+ }
61
+ }
62
+
63
+ // src/see/core.ts
64
+ var SEE_MAX_MESSAGE = 500;
65
+ var SEE_MAX_STACK = 8e3;
66
+ var SEE_MAX_SUBJECT = 200;
67
+ var SEE_MAX_EXTRA_VALUE = 200;
68
+ var SEE_MAX_EXTRA_KEYS = 20;
69
+ var SEE_DEDUP_WINDOW_MS = 3e4;
70
+ var SEE_MAX_PER_SESSION = 25;
71
+ function causesThe(subject) {
72
+ return {
73
+ to(outcome) {
74
+ return {
75
+ __seConsequence: true,
76
+ subject: truncate(String(subject), SEE_MAX_SUBJECT),
77
+ outcome: truncate(String(outcome), SEE_MAX_SUBJECT)
78
+ };
79
+ }
80
+ };
81
+ }
82
+ function violation(name) {
83
+ const make = (msg) => ({
84
+ __seViolation: true,
85
+ violationName: String(name),
86
+ ...msg !== void 0 ? { violationMessage: msg } : {},
87
+ message(m) {
88
+ return make(String(m));
89
+ }
90
+ });
91
+ return make();
92
+ }
93
+ function isViolation(p) {
94
+ return typeof p === "object" && p !== null && p.__seViolation === true;
95
+ }
96
+ var EXPECTED_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:see-expected");
97
+ function markExpected(err, because) {
98
+ if (typeof err !== "object" || err === null) return;
99
+ try {
100
+ Object.defineProperty(err, EXPECTED_SYM, {
101
+ value: String(because),
102
+ enumerable: false,
103
+ configurable: true
104
+ });
105
+ } catch {
106
+ }
107
+ }
108
+ function truncate(s, max) {
109
+ return s.length > max ? s.slice(0, max) : s;
110
+ }
111
+ function sanitizeExtras(extras) {
112
+ if (!extras || typeof extras !== "object") return void 0;
113
+ const out = {};
114
+ let n = 0;
115
+ for (const [k, v] of Object.entries(extras)) {
116
+ if (v === null || v === void 0) continue;
117
+ if (n >= SEE_MAX_EXTRA_KEYS) break;
118
+ if (typeof v === "string") out[k] = truncate(v, SEE_MAX_EXTRA_VALUE);
119
+ else if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
120
+ else if (typeof v === "boolean") out[k] = v;
121
+ else continue;
122
+ n += 1;
123
+ }
124
+ return n > 0 ? out : void 0;
125
+ }
126
+ function captureCallsiteStack() {
127
+ const raw = new Error().stack;
128
+ if (!raw) return void 0;
129
+ const lines = raw.split("\n");
130
+ const kept = lines.slice(1).filter((l) => !/@shipeasy[\\/]sdk|see[\\/]core|captureCallsiteStack|\bsee\b\s*\(/.test(l));
131
+ return kept.length ? kept.join("\n") : void 0;
132
+ }
133
+ function buildSeeEvent(problem, consequence, extras, ctx, kindOverride) {
134
+ let errorType;
135
+ let message;
136
+ let stack;
137
+ let kind;
138
+ if (isViolation(problem)) {
139
+ errorType = problem.violationName;
140
+ message = problem.violationMessage ?? problem.violationName;
141
+ stack = captureCallsiteStack();
142
+ kind = kindOverride ?? "violation";
143
+ } else if (problem instanceof Error) {
144
+ errorType = problem.name || "Error";
145
+ message = problem.message || String(problem);
146
+ stack = problem.stack ?? void 0;
147
+ kind = kindOverride ?? "caught";
148
+ } else {
149
+ errorType = "Error";
150
+ message = typeof problem === "string" ? problem : safeString(problem);
151
+ stack = captureCallsiteStack();
152
+ kind = kindOverride ?? "caught";
153
+ }
154
+ const ev = {
155
+ type: "error",
156
+ kind,
157
+ error_type: truncate(errorType, SEE_MAX_SUBJECT),
158
+ message: truncate(message, SEE_MAX_MESSAGE),
159
+ subject: consequence.subject,
160
+ outcome: consequence.outcome,
161
+ side: ctx.side,
162
+ sdk_version: ctx.sdkVersion,
163
+ ts: Date.now()
164
+ };
165
+ if (stack) ev.stack = truncate(stack, SEE_MAX_STACK);
166
+ const cleanExtras = sanitizeExtras(extras);
167
+ if (cleanExtras) ev.extras = cleanExtras;
168
+ if (ctx.url) ev.url = truncate(ctx.url, SEE_MAX_SUBJECT);
169
+ if (ctx.userId) ev.user_id = ctx.userId;
170
+ if (ctx.anonId) ev.anonymous_id = ctx.anonId;
171
+ if (ctx.env) ev.env = ctx.env;
172
+ return ev;
173
+ }
174
+ function safeString(v) {
175
+ try {
176
+ return typeof v === "object" ? JSON.stringify(v) : String(v);
177
+ } catch {
178
+ return String(v);
179
+ }
180
+ }
181
+ var scheduleMicrotask = typeof queueMicrotask === "function" ? queueMicrotask : (cb) => {
182
+ void Promise.resolve().then(cb);
183
+ };
184
+ function startSeeChain(getProblem, dispatch) {
185
+ let subject;
186
+ let outcome;
187
+ let collected;
188
+ let flushed = false;
189
+ scheduleMicrotask(() => {
190
+ if (flushed) return;
191
+ flushed = true;
192
+ dispatch(
193
+ getProblem(),
194
+ causesThe(subject ?? "the app").to(outcome ?? "hit an error"),
195
+ collected
196
+ );
197
+ });
198
+ const tail = {
199
+ extras(x) {
200
+ if (x && typeof x === "object") collected = { ...collected, ...x };
201
+ return tail;
202
+ }
203
+ };
204
+ const step = {
205
+ to(o) {
206
+ outcome = String(o);
207
+ return tail;
208
+ }
209
+ };
210
+ const start = (s) => {
211
+ subject = String(s);
212
+ return step;
213
+ };
214
+ return { causes_the: start, causesThe: start };
215
+ }
216
+ function startSeeViolationChain(name, dispatch) {
217
+ let msg;
218
+ const base = startSeeChain(
219
+ () => msg !== void 0 ? violation(name).message(msg) : violation(name),
220
+ dispatch
221
+ );
222
+ const chain = {
223
+ ...base,
224
+ message(m) {
225
+ msg = String(m);
226
+ return chain;
227
+ }
228
+ };
229
+ return chain;
230
+ }
231
+ function topStackLine(stack) {
232
+ if (!stack) return "";
233
+ for (const line of stack.split("\n")) {
234
+ if (/^\s*at |@|:\d+:\d+/.test(line)) return line.trim().slice(0, 200);
235
+ }
236
+ return "";
237
+ }
238
+ var SeeLimiter = class {
239
+ constructor(maxPerSession = SEE_MAX_PER_SESSION, dedupWindowMs = SEE_DEDUP_WINDOW_MS) {
240
+ this.maxPerSession = maxPerSession;
241
+ this.dedupWindowMs = dedupWindowMs;
242
+ }
243
+ maxPerSession;
244
+ dedupWindowMs;
245
+ lastSent = /* @__PURE__ */ new Map();
246
+ sent = 0;
247
+ shouldSend(ev) {
248
+ if (this.sent >= this.maxPerSession) return false;
249
+ const key = `${ev.kind}|${ev.error_type}|${ev.message.slice(0, 200)}|${topStackLine(ev.stack)}`;
250
+ const now = Date.now();
251
+ const prev = this.lastSent.get(key);
252
+ if (prev !== void 0 && now - prev < this.dedupWindowMs) return false;
253
+ this.lastSent.set(key, now);
254
+ this.sent += 1;
255
+ return true;
256
+ }
257
+ };
258
+
259
+ // src/server/index.ts
260
+ var version = "4.0.0";
4
261
  var C1 = 3432918353;
5
262
  var C2 = 461845907;
6
263
  function murmur3(key) {
@@ -149,6 +406,8 @@ var FlagsClient = class {
149
406
  apiKey;
150
407
  baseUrl;
151
408
  env;
409
+ telemetry;
410
+ seeLimiter = new SeeLimiter();
152
411
  flagsBlob = null;
153
412
  expsBlob = null;
154
413
  flagsEtag = null;
@@ -160,6 +419,13 @@ var FlagsClient = class {
160
419
  this.apiKey = opts.apiKey;
161
420
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
162
421
  this.env = opts.env ?? "prod";
422
+ this.telemetry = new Telemetry({
423
+ endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
424
+ sdkKey: this.apiKey,
425
+ side: "server",
426
+ env: this.env,
427
+ disabled: opts.disableTelemetry
428
+ });
163
429
  if (opts.initialBlob) {
164
430
  this.flagsBlob = opts.initialBlob;
165
431
  this.initialized = true;
@@ -221,17 +487,20 @@ var FlagsClient = class {
221
487
  this.expsBlob = await res.json();
222
488
  }
223
489
  getFlag(name, user) {
490
+ this.telemetry.emit("gate", name);
224
491
  const gate = this.flagsBlob?.gates[name];
225
492
  if (!gate) return false;
226
493
  return evalGateInternal(gate, user);
227
494
  }
228
495
  getConfig(name, decode) {
496
+ this.telemetry.emit("config", name);
229
497
  const entry = this.flagsBlob?.configs[name];
230
498
  if (!entry) return void 0;
231
499
  if (!decode) return entry.value;
232
500
  return decode(entry.value);
233
501
  }
234
502
  getExperiment(name, user, defaultParams, decode) {
503
+ this.telemetry.emit("experiment", name);
235
504
  const notIn = {
236
505
  inExperiment: false,
237
506
  group: "control",
@@ -291,6 +560,27 @@ var FlagsClient = class {
291
560
  body
292
561
  }).catch((err) => console.warn("[shipeasy] track failed:", String(err)));
293
562
  }
563
+ /**
564
+ * Report a structured error into the errors primitive. Fire-and-forget —
565
+ * never blocks or throws into the request path. Spam-guarded by a 30s
566
+ * dedup window + per-process cap.
567
+ */
568
+ reportError(problem, consequence, extras, kind) {
569
+ try {
570
+ const ev = buildSeeEvent(problem, consequence, extras, {
571
+ side: "server",
572
+ sdkVersion: version,
573
+ env: this.env
574
+ }, kind);
575
+ if (!this.seeLimiter.shouldSend(ev)) return;
576
+ globalThis.fetch(`${this.baseUrl}/collect`, {
577
+ method: "POST",
578
+ headers: { "X-SDK-Key": this.apiKey, "Content-Type": "text/plain" },
579
+ body: JSON.stringify({ events: [ev] })
580
+ }).catch((err) => console.warn("[shipeasy] see() send failed:", String(err)));
581
+ } catch {
582
+ }
583
+ }
294
584
  /**
295
585
  * Evaluate all flags, configs, and experiments for a user against the locally
296
586
  * cached blob (no network call). Applies ?se_ks_* / ?se_cf_* / ?se_exp_*
@@ -306,15 +596,18 @@ var FlagsClient = class {
306
596
  const experiments = {};
307
597
  const killswitches = {};
308
598
  for (const [name, gate] of Object.entries(this.flagsBlob?.gates ?? {})) {
599
+ this.telemetry.emit("gate", name);
309
600
  flags2[name] = evalGateInternal(gate, user);
310
601
  }
311
602
  for (const [name, entry] of Object.entries(this.flagsBlob?.configs ?? {})) {
603
+ this.telemetry.emit("config", name);
312
604
  configs[name] = entry.value;
313
605
  }
314
606
  for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
315
607
  experiments[name] = this.getExperiment(name, user, {});
316
608
  }
317
609
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
610
+ this.telemetry.emit("ks", name);
318
611
  if (ks.switches && Object.keys(ks.switches).length > 0) {
319
612
  const out = {};
320
613
  for (const [k, v] of Object.entries(ks.switches)) out[k] = isEnabled(v);
@@ -334,6 +627,7 @@ var FlagsClient = class {
334
627
  return { flags: flags2, configs, experiments, killswitches };
335
628
  }
336
629
  getKillswitch(name, switchKey) {
630
+ this.telemetry.emit("ks", name);
337
631
  const ks = this.flagsBlob?.killswitches?.[name];
338
632
  if (!ks) return false;
339
633
  if (switchKey === void 0) return isEnabled(ks.killed);
@@ -469,7 +763,7 @@ async function shipeasy(opts) {
469
763
  );
470
764
  }
471
765
  const profile = opts.i18nDefaultProfile ?? "en:prod";
472
- flags.configure({ apiKey: serverKey });
766
+ flags.configure({ apiKey: serverKey, disableTelemetry: opts.disableTelemetry });
473
767
  let resolvedUrlOverrides = opts.urlOverrides;
474
768
  if (!resolvedUrlOverrides) {
475
769
  try {
@@ -595,6 +889,20 @@ var flags = {
595
889
  };
596
890
  }
597
891
  };
892
+ function dispatchSee(problem, consequence, extras, kind) {
893
+ if (!_server) {
894
+ console.warn("[shipeasy] see() called before shipeasy({ serverKey }) \u2014 error dropped");
895
+ return;
896
+ }
897
+ _server.reportError(problem, consequence, extras, kind);
898
+ }
899
+ var see = Object.assign(
900
+ (problem) => startSeeChain(() => problem, dispatchSee),
901
+ {
902
+ Violation: (name) => startSeeViolationChain(name, dispatchSee),
903
+ ControlFlowException: markExpected
904
+ }
905
+ );
598
906
  export {
599
907
  FlagsClient,
600
908
  _resetShipeasyServerForTests,
@@ -604,6 +912,7 @@ export {
604
912
  getBootstrapHtml,
605
913
  getShipeasyServerClient,
606
914
  i18n,
915
+ see,
607
916
  shipeasy,
608
917
  version
609
918
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipeasy/sdk",
3
- "version": "3.0.1",
3
+ "version": "4.1.0",
4
4
  "description": "Shipeasy SDK — feature gates, runtime configs, experiments, and metrics for the Shipeasy hosted service.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://shipeasy.ai",