@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.
- package/README.md +52 -0
- package/dist/client/index.d.mts +165 -7
- package/dist/client/index.d.ts +165 -7
- package/dist/client/index.js +396 -51
- package/dist/client/index.mjs +393 -51
- package/dist/server/index.d.mts +130 -2
- package/dist/server/index.d.ts +130 -2
- package/dist/server/index.js +312 -2
- package/dist/server/index.mjs +311 -2
- package/package.json +1 -1
package/dist/server/index.mjs
CHANGED
|
@@ -1,6 +1,263 @@
|
|
|
1
1
|
// src/server/index.ts
|
|
2
2
|
import { AsyncLocalStorage } from "async_hooks";
|
|
3
|
-
|
|
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
|
+
"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",
|