@shipeasy/sdk 3.0.0 → 3.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.
@@ -53,6 +53,15 @@ interface FlagsClientBrowserOptions {
53
53
  autoGuardrailGroups?: Partial<AutoCollectGroups>;
54
54
  /** Which published env to read values from. Defaults to "prod". */
55
55
  env?: FlagsClientBrowserEnv;
56
+ /**
57
+ * Per-evaluation usage telemetry. ON by default — each getFlag/getConfig/
58
+ * getExperiment/getKillswitch call fires one fire-and-forget sendBeacon so
59
+ * usage is counted by Cloudflare's native per-path analytics. Pass `true` to
60
+ * disable entirely.
61
+ */
62
+ disableTelemetry?: boolean;
63
+ /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
64
+ telemetryUrl?: string;
56
65
  }
57
66
  declare class FlagsClientBrowser {
58
67
  private readonly sdkKey;
@@ -64,6 +73,7 @@ declare class FlagsClientBrowser {
64
73
  private anonId;
65
74
  private userId;
66
75
  private buffer;
76
+ private telemetry;
67
77
  private guardrailsInstalled;
68
78
  private listeners;
69
79
  private overrideListenerInstalled;
@@ -175,6 +185,12 @@ interface ShipeasyClientConfig {
175
185
  * ```
176
186
  */
177
187
  autoCollect?: boolean | Partial<AutoCollectGroups>;
188
+ /**
189
+ * Disable per-evaluation usage telemetry. Telemetry is ON by default — every
190
+ * flag/config/experiment/killswitch read fires one fire-and-forget beacon
191
+ * counted by Cloudflare's native per-path analytics. Pass `true` to opt out.
192
+ */
193
+ disableTelemetry?: boolean;
178
194
  }
179
195
  /**
180
196
  * Initialise the ShipEasy client SDK and wire up lazy devtools.
@@ -53,6 +53,15 @@ interface FlagsClientBrowserOptions {
53
53
  autoGuardrailGroups?: Partial<AutoCollectGroups>;
54
54
  /** Which published env to read values from. Defaults to "prod". */
55
55
  env?: FlagsClientBrowserEnv;
56
+ /**
57
+ * Per-evaluation usage telemetry. ON by default — each getFlag/getConfig/
58
+ * getExperiment/getKillswitch call fires one fire-and-forget sendBeacon so
59
+ * usage is counted by Cloudflare's native per-path analytics. Pass `true` to
60
+ * disable entirely.
61
+ */
62
+ disableTelemetry?: boolean;
63
+ /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
64
+ telemetryUrl?: string;
56
65
  }
57
66
  declare class FlagsClientBrowser {
58
67
  private readonly sdkKey;
@@ -64,6 +73,7 @@ declare class FlagsClientBrowser {
64
73
  private anonId;
65
74
  private userId;
66
75
  private buffer;
76
+ private telemetry;
67
77
  private guardrailsInstalled;
68
78
  private listeners;
69
79
  private overrideListenerInstalled;
@@ -175,6 +185,12 @@ interface ShipeasyClientConfig {
175
185
  * ```
176
186
  */
177
187
  autoCollect?: boolean | Partial<AutoCollectGroups>;
188
+ /**
189
+ * Disable per-evaluation usage telemetry. Telemetry is ON by default — every
190
+ * flag/config/experiment/killswitch read fires one fire-and-forget beacon
191
+ * counted by Cloudflare's native per-path analytics. Pass `true` to opt out.
192
+ */
193
+ disableTelemetry?: boolean;
178
194
  }
179
195
  /**
180
196
  * Initialise the ShipEasy client SDK and wire up lazy devtools.
@@ -42,6 +42,67 @@ __export(client_exports, {
42
42
  version: () => version
43
43
  });
44
44
  module.exports = __toCommonJS(client_exports);
45
+
46
+ // src/telemetry.ts
47
+ async function sha256Hex(input) {
48
+ const buf = new TextEncoder().encode(input);
49
+ const digest = await crypto.subtle.digest("SHA-256", buf);
50
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
51
+ }
52
+ var DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai";
53
+ var Telemetry = class {
54
+ prefix;
55
+ disabled;
56
+ dedupeMs;
57
+ // Last-emit timestamp per `feature/resource`, for the dedup window. Bounded by
58
+ // the number of distinct keys the app reads.
59
+ lastEmit = /* @__PURE__ */ new Map();
60
+ // Resolved once at construction and reused by every emit(), so the per-eval
61
+ // cost is a Map-free microtask, not a hash.
62
+ keyHash;
63
+ constructor(opts) {
64
+ const endpoint = (opts.endpoint ?? "").replace(/\/$/, "");
65
+ this.disabled = opts.disabled === true || !opts.sdkKey || !endpoint;
66
+ this.dedupeMs = opts.dedupeMs ?? 2e3;
67
+ this.prefix = `${endpoint}/t`;
68
+ this.keyHash = this.disabled ? null : sha256Hex(opts.sdkKey).then((h) => `${h}/${opts.side}/${encodeURIComponent(opts.env)}`).catch(() => "");
69
+ }
70
+ /**
71
+ * Emit a single best-effort usage beacon for one evaluation. Never blocks the
72
+ * caller (the hash is already resolved) and never throws — a failed beacon
73
+ * must never affect the evaluation it measures.
74
+ */
75
+ emit(feature, resource) {
76
+ if (this.disabled || !this.keyHash) return;
77
+ if (this.dedupeMs > 0) {
78
+ const dedupeKey = `${feature}/${resource}`;
79
+ const now = Date.now();
80
+ const last = this.lastEmit.get(dedupeKey);
81
+ if (last !== void 0 && now - last < this.dedupeMs) return;
82
+ this.lastEmit.set(dedupeKey, now);
83
+ }
84
+ void this.keyHash.then((suffix) => {
85
+ if (!suffix) return;
86
+ send(`${this.prefix}/${suffix}/${feature}/${encodeURIComponent(resource)}`);
87
+ });
88
+ }
89
+ };
90
+ function send(url) {
91
+ try {
92
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
93
+ navigator.sendBeacon(url);
94
+ return;
95
+ }
96
+ const f = globalThis.fetch;
97
+ if (typeof f === "function") {
98
+ void f(url, { method: "GET", keepalive: true }).catch(() => {
99
+ });
100
+ }
101
+ } catch {
102
+ }
103
+ }
104
+
105
+ // src/client/index.ts
45
106
  var version = "1.0.0";
46
107
  var FLUSH_INTERVAL_MS = 5e3;
47
108
  var MAX_BUFFER = 100;
@@ -427,6 +488,7 @@ var FlagsClientBrowser = class {
427
488
  anonId;
428
489
  userId = "";
429
490
  buffer;
491
+ telemetry;
430
492
  guardrailsInstalled = false;
431
493
  listeners = /* @__PURE__ */ new Set();
432
494
  overrideListenerInstalled = false;
@@ -450,6 +512,13 @@ var FlagsClientBrowser = class {
450
512
  };
451
513
  this.anonId = getOrCreateAnonId();
452
514
  this.buffer = new EventBuffer(`${this.baseUrl}/collect`, this.sdkKey);
515
+ this.telemetry = new Telemetry({
516
+ endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
517
+ sdkKey: this.sdkKey,
518
+ side: "client",
519
+ env: this.env,
520
+ disabled: opts.disableTelemetry
521
+ });
453
522
  void this.buffer.flushPendingAlias();
454
523
  }
455
524
  async identify(user) {
@@ -499,12 +568,14 @@ var FlagsClientBrowser = class {
499
568
  this.evalResult = data;
500
569
  }
501
570
  getFlag(name) {
571
+ this.telemetry.emit("gate", name);
502
572
  if (this.evalResult === null) return false;
503
573
  const ov = readGateOverride(name);
504
574
  if (ov !== null) return ov;
505
575
  return this.evalResult.flags[name] ?? false;
506
576
  }
507
577
  getConfig(name, decode) {
578
+ this.telemetry.emit("config", name);
508
579
  if (this.evalResult === null) return void 0;
509
580
  const ov = readConfigOverride(name);
510
581
  const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
@@ -518,6 +589,7 @@ var FlagsClientBrowser = class {
518
589
  }
519
590
  }
520
591
  getExperiment(name, defaultParams, decode, variants) {
592
+ this.telemetry.emit("experiment", name);
521
593
  const notIn = {
522
594
  inExperiment: false,
523
595
  group: "control",
@@ -582,6 +654,7 @@ var FlagsClientBrowser = class {
582
654
  * the per-switch state. Returns false for unknown killswitches / switches.
583
655
  */
584
656
  getKillswitch(name, switchKey) {
657
+ this.telemetry.emit("ks", name);
585
658
  if (this.evalResult === null) return false;
586
659
  const ks = this.evalResult.killswitches?.[name];
587
660
  if (ks === void 0) return false;
@@ -716,7 +789,8 @@ function shipeasy(opts) {
716
789
  sdkKey: opts.clientKey,
717
790
  baseUrl,
718
791
  autoGuardrails: blanket,
719
- autoGuardrailGroups: groups
792
+ autoGuardrailGroups: groups,
793
+ disableTelemetry: opts.disableTelemetry
720
794
  });
721
795
  injectI18nLoader(opts.clientKey, baseUrl, opts.i18nProfile);
722
796
  flags.notifyMounted();
@@ -1,3 +1,62 @@
1
+ // src/telemetry.ts
2
+ async function sha256Hex(input) {
3
+ const buf = new TextEncoder().encode(input);
4
+ const digest = await crypto.subtle.digest("SHA-256", buf);
5
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
6
+ }
7
+ var DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai";
8
+ var Telemetry = class {
9
+ prefix;
10
+ disabled;
11
+ dedupeMs;
12
+ // Last-emit timestamp per `feature/resource`, for the dedup window. Bounded by
13
+ // the number of distinct keys the app reads.
14
+ lastEmit = /* @__PURE__ */ new Map();
15
+ // Resolved once at construction and reused by every emit(), so the per-eval
16
+ // cost is a Map-free microtask, not a hash.
17
+ keyHash;
18
+ constructor(opts) {
19
+ const endpoint = (opts.endpoint ?? "").replace(/\/$/, "");
20
+ this.disabled = opts.disabled === true || !opts.sdkKey || !endpoint;
21
+ this.dedupeMs = opts.dedupeMs ?? 2e3;
22
+ this.prefix = `${endpoint}/t`;
23
+ this.keyHash = this.disabled ? null : sha256Hex(opts.sdkKey).then((h) => `${h}/${opts.side}/${encodeURIComponent(opts.env)}`).catch(() => "");
24
+ }
25
+ /**
26
+ * Emit a single best-effort usage beacon for one evaluation. Never blocks the
27
+ * caller (the hash is already resolved) and never throws — a failed beacon
28
+ * must never affect the evaluation it measures.
29
+ */
30
+ emit(feature, resource) {
31
+ if (this.disabled || !this.keyHash) return;
32
+ if (this.dedupeMs > 0) {
33
+ const dedupeKey = `${feature}/${resource}`;
34
+ const now = Date.now();
35
+ const last = this.lastEmit.get(dedupeKey);
36
+ if (last !== void 0 && now - last < this.dedupeMs) return;
37
+ this.lastEmit.set(dedupeKey, now);
38
+ }
39
+ void this.keyHash.then((suffix) => {
40
+ if (!suffix) return;
41
+ send(`${this.prefix}/${suffix}/${feature}/${encodeURIComponent(resource)}`);
42
+ });
43
+ }
44
+ };
45
+ function send(url) {
46
+ try {
47
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
48
+ navigator.sendBeacon(url);
49
+ return;
50
+ }
51
+ const f = globalThis.fetch;
52
+ if (typeof f === "function") {
53
+ void f(url, { method: "GET", keepalive: true }).catch(() => {
54
+ });
55
+ }
56
+ } catch {
57
+ }
58
+ }
59
+
1
60
  // src/client/index.ts
2
61
  var version = "1.0.0";
3
62
  var FLUSH_INTERVAL_MS = 5e3;
@@ -384,6 +443,7 @@ var FlagsClientBrowser = class {
384
443
  anonId;
385
444
  userId = "";
386
445
  buffer;
446
+ telemetry;
387
447
  guardrailsInstalled = false;
388
448
  listeners = /* @__PURE__ */ new Set();
389
449
  overrideListenerInstalled = false;
@@ -407,6 +467,13 @@ var FlagsClientBrowser = class {
407
467
  };
408
468
  this.anonId = getOrCreateAnonId();
409
469
  this.buffer = new EventBuffer(`${this.baseUrl}/collect`, this.sdkKey);
470
+ this.telemetry = new Telemetry({
471
+ endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
472
+ sdkKey: this.sdkKey,
473
+ side: "client",
474
+ env: this.env,
475
+ disabled: opts.disableTelemetry
476
+ });
410
477
  void this.buffer.flushPendingAlias();
411
478
  }
412
479
  async identify(user) {
@@ -456,12 +523,14 @@ var FlagsClientBrowser = class {
456
523
  this.evalResult = data;
457
524
  }
458
525
  getFlag(name) {
526
+ this.telemetry.emit("gate", name);
459
527
  if (this.evalResult === null) return false;
460
528
  const ov = readGateOverride(name);
461
529
  if (ov !== null) return ov;
462
530
  return this.evalResult.flags[name] ?? false;
463
531
  }
464
532
  getConfig(name, decode) {
533
+ this.telemetry.emit("config", name);
465
534
  if (this.evalResult === null) return void 0;
466
535
  const ov = readConfigOverride(name);
467
536
  const raw = ov !== void 0 ? ov : this.evalResult.configs?.[name];
@@ -475,6 +544,7 @@ var FlagsClientBrowser = class {
475
544
  }
476
545
  }
477
546
  getExperiment(name, defaultParams, decode, variants) {
547
+ this.telemetry.emit("experiment", name);
478
548
  const notIn = {
479
549
  inExperiment: false,
480
550
  group: "control",
@@ -539,6 +609,7 @@ var FlagsClientBrowser = class {
539
609
  * the per-switch state. Returns false for unknown killswitches / switches.
540
610
  */
541
611
  getKillswitch(name, switchKey) {
612
+ this.telemetry.emit("ks", name);
542
613
  if (this.evalResult === null) return false;
543
614
  const ks = this.evalResult.killswitches?.[name];
544
615
  if (ks === void 0) return false;
@@ -673,7 +744,8 @@ function shipeasy(opts) {
673
744
  sdkKey: opts.clientKey,
674
745
  baseUrl,
675
746
  autoGuardrails: blanket,
676
- autoGuardrailGroups: groups
747
+ autoGuardrailGroups: groups,
748
+ disableTelemetry: opts.disableTelemetry
677
749
  });
678
750
  injectI18nLoader(opts.clientKey, baseUrl, opts.i18nProfile);
679
751
  flags.notifyMounted();
@@ -51,11 +51,23 @@ interface FlagsClientOptions {
51
51
  * for tests; production callers should rely on init()/initOnce().
52
52
  */
53
53
  initialBlob?: FlagsBlob;
54
+ /**
55
+ * Per-evaluation usage telemetry. ON by default — each getFlag/getConfig/
56
+ * getExperiment/getKillswitch (and the per-key evaluate() loop) fires one
57
+ * fire-and-forget beacon counted by Cloudflare's native per-path analytics.
58
+ * Pass `true` to disable. NOTE: on Cloudflare Workers each beacon is an
59
+ * outbound subrequest (cap 50 free / 1000 paid per invocation), so disable
60
+ * this on hot request paths that evaluate many flags per request.
61
+ */
62
+ disableTelemetry?: boolean;
63
+ /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
64
+ telemetryUrl?: string;
54
65
  }
55
66
  declare class FlagsClient {
56
67
  private readonly apiKey;
57
68
  private readonly baseUrl;
58
69
  private readonly env;
70
+ private readonly telemetry;
59
71
  private flagsBlob;
60
72
  private expsBlob;
61
73
  private flagsEtag;
@@ -147,6 +159,12 @@ interface ShipeasyServerConfig {
147
159
  user?: User;
148
160
  /** i18n profile to load for SSR translations, e.g. "en:prod". Defaults to "en:prod". */
149
161
  i18nDefaultProfile?: string;
162
+ /**
163
+ * Disable per-evaluation usage telemetry. ON by default. On Cloudflare
164
+ * Workers each beacon is an outbound subrequest, so disable on hot SSR paths
165
+ * that evaluate many flags per request. See {@link FlagsClientOptions.disableTelemetry}.
166
+ */
167
+ disableTelemetry?: boolean;
150
168
  }
151
169
  interface ShipeasyServerHandle {
152
170
  flags: Record<string, boolean>;
@@ -51,11 +51,23 @@ interface FlagsClientOptions {
51
51
  * for tests; production callers should rely on init()/initOnce().
52
52
  */
53
53
  initialBlob?: FlagsBlob;
54
+ /**
55
+ * Per-evaluation usage telemetry. ON by default — each getFlag/getConfig/
56
+ * getExperiment/getKillswitch (and the per-key evaluate() loop) fires one
57
+ * fire-and-forget beacon counted by Cloudflare's native per-path analytics.
58
+ * Pass `true` to disable. NOTE: on Cloudflare Workers each beacon is an
59
+ * outbound subrequest (cap 50 free / 1000 paid per invocation), so disable
60
+ * this on hot request paths that evaluate many flags per request.
61
+ */
62
+ disableTelemetry?: boolean;
63
+ /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
64
+ telemetryUrl?: string;
54
65
  }
55
66
  declare class FlagsClient {
56
67
  private readonly apiKey;
57
68
  private readonly baseUrl;
58
69
  private readonly env;
70
+ private readonly telemetry;
59
71
  private flagsBlob;
60
72
  private expsBlob;
61
73
  private flagsEtag;
@@ -147,6 +159,12 @@ interface ShipeasyServerConfig {
147
159
  user?: User;
148
160
  /** i18n profile to load for SSR translations, e.g. "en:prod". Defaults to "en:prod". */
149
161
  i18nDefaultProfile?: string;
162
+ /**
163
+ * Disable per-evaluation usage telemetry. ON by default. On Cloudflare
164
+ * Workers each beacon is an outbound subrequest, so disable on hot SSR paths
165
+ * that evaluate many flags per request. See {@link FlagsClientOptions.disableTelemetry}.
166
+ */
167
+ disableTelemetry?: boolean;
150
168
  }
151
169
  interface ShipeasyServerHandle {
152
170
  flags: Record<string, boolean>;
@@ -43,6 +43,67 @@ __export(server_exports, {
43
43
  });
44
44
  module.exports = __toCommonJS(server_exports);
45
45
  var import_node_async_hooks = require("async_hooks");
46
+
47
+ // src/telemetry.ts
48
+ async function sha256Hex(input) {
49
+ const buf = new TextEncoder().encode(input);
50
+ const digest = await crypto.subtle.digest("SHA-256", buf);
51
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
52
+ }
53
+ var DEFAULT_TELEMETRY_URL = "https://t.shipeasy.ai";
54
+ var Telemetry = class {
55
+ prefix;
56
+ disabled;
57
+ dedupeMs;
58
+ // Last-emit timestamp per `feature/resource`, for the dedup window. Bounded by
59
+ // the number of distinct keys the app reads.
60
+ lastEmit = /* @__PURE__ */ new Map();
61
+ // Resolved once at construction and reused by every emit(), so the per-eval
62
+ // cost is a Map-free microtask, not a hash.
63
+ keyHash;
64
+ constructor(opts) {
65
+ const endpoint = (opts.endpoint ?? "").replace(/\/$/, "");
66
+ this.disabled = opts.disabled === true || !opts.sdkKey || !endpoint;
67
+ this.dedupeMs = opts.dedupeMs ?? 2e3;
68
+ this.prefix = `${endpoint}/t`;
69
+ this.keyHash = this.disabled ? null : sha256Hex(opts.sdkKey).then((h) => `${h}/${opts.side}/${encodeURIComponent(opts.env)}`).catch(() => "");
70
+ }
71
+ /**
72
+ * Emit a single best-effort usage beacon for one evaluation. Never blocks the
73
+ * caller (the hash is already resolved) and never throws — a failed beacon
74
+ * must never affect the evaluation it measures.
75
+ */
76
+ emit(feature, resource) {
77
+ if (this.disabled || !this.keyHash) return;
78
+ if (this.dedupeMs > 0) {
79
+ const dedupeKey = `${feature}/${resource}`;
80
+ const now = Date.now();
81
+ const last = this.lastEmit.get(dedupeKey);
82
+ if (last !== void 0 && now - last < this.dedupeMs) return;
83
+ this.lastEmit.set(dedupeKey, now);
84
+ }
85
+ void this.keyHash.then((suffix) => {
86
+ if (!suffix) return;
87
+ send(`${this.prefix}/${suffix}/${feature}/${encodeURIComponent(resource)}`);
88
+ });
89
+ }
90
+ };
91
+ function send(url) {
92
+ try {
93
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
94
+ navigator.sendBeacon(url);
95
+ return;
96
+ }
97
+ const f = globalThis.fetch;
98
+ if (typeof f === "function") {
99
+ void f(url, { method: "GET", keepalive: true }).catch(() => {
100
+ });
101
+ }
102
+ } catch {
103
+ }
104
+ }
105
+
106
+ // src/server/index.ts
46
107
  var version = "1.0.0";
47
108
  var C1 = 3432918353;
48
109
  var C2 = 461845907;
@@ -192,6 +253,7 @@ var FlagsClient = class {
192
253
  apiKey;
193
254
  baseUrl;
194
255
  env;
256
+ telemetry;
195
257
  flagsBlob = null;
196
258
  expsBlob = null;
197
259
  flagsEtag = null;
@@ -203,6 +265,13 @@ var FlagsClient = class {
203
265
  this.apiKey = opts.apiKey;
204
266
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
205
267
  this.env = opts.env ?? "prod";
268
+ this.telemetry = new Telemetry({
269
+ endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
270
+ sdkKey: this.apiKey,
271
+ side: "server",
272
+ env: this.env,
273
+ disabled: opts.disableTelemetry
274
+ });
206
275
  if (opts.initialBlob) {
207
276
  this.flagsBlob = opts.initialBlob;
208
277
  this.initialized = true;
@@ -264,17 +333,20 @@ var FlagsClient = class {
264
333
  this.expsBlob = await res.json();
265
334
  }
266
335
  getFlag(name, user) {
336
+ this.telemetry.emit("gate", name);
267
337
  const gate = this.flagsBlob?.gates[name];
268
338
  if (!gate) return false;
269
339
  return evalGateInternal(gate, user);
270
340
  }
271
341
  getConfig(name, decode) {
342
+ this.telemetry.emit("config", name);
272
343
  const entry = this.flagsBlob?.configs[name];
273
344
  if (!entry) return void 0;
274
345
  if (!decode) return entry.value;
275
346
  return decode(entry.value);
276
347
  }
277
348
  getExperiment(name, user, defaultParams, decode) {
349
+ this.telemetry.emit("experiment", name);
278
350
  const notIn = {
279
351
  inExperiment: false,
280
352
  group: "control",
@@ -349,15 +421,18 @@ var FlagsClient = class {
349
421
  const experiments = {};
350
422
  const killswitches = {};
351
423
  for (const [name, gate] of Object.entries(this.flagsBlob?.gates ?? {})) {
424
+ this.telemetry.emit("gate", name);
352
425
  flags2[name] = evalGateInternal(gate, user);
353
426
  }
354
427
  for (const [name, entry] of Object.entries(this.flagsBlob?.configs ?? {})) {
428
+ this.telemetry.emit("config", name);
355
429
  configs[name] = entry.value;
356
430
  }
357
431
  for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
358
432
  experiments[name] = this.getExperiment(name, user, {});
359
433
  }
360
434
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
435
+ this.telemetry.emit("ks", name);
361
436
  if (ks.switches && Object.keys(ks.switches).length > 0) {
362
437
  const out = {};
363
438
  for (const [k, v] of Object.entries(ks.switches)) out[k] = isEnabled(v);
@@ -377,6 +452,7 @@ var FlagsClient = class {
377
452
  return { flags: flags2, configs, experiments, killswitches };
378
453
  }
379
454
  getKillswitch(name, switchKey) {
455
+ this.telemetry.emit("ks", name);
380
456
  const ks = this.flagsBlob?.killswitches?.[name];
381
457
  if (!ks) return false;
382
458
  if (switchKey === void 0) return isEnabled(ks.killed);
@@ -387,6 +463,7 @@ var _I18N_SSR_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-i18n");
387
463
  var _EDIT_MODE_SSR_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-edit-mode");
388
464
  var _i18nALS = new import_node_async_hooks.AsyncLocalStorage();
389
465
  var _I18N_CACHE_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-i18n-cache");
466
+ var I18N_CACHE_TTL_MS = 6e4;
390
467
  var _i18nCache = globalThis[_I18N_CACHE_SYM] ?? (globalThis[_I18N_CACHE_SYM] = /* @__PURE__ */ new Map());
391
468
  globalThis[_I18N_SSR_SYM] = () => {
392
469
  const fromALS = _i18nALS.getStore();
@@ -432,14 +509,19 @@ var i18n = {
432
509
  const existingALS = _i18nALS.getStore();
433
510
  if (existingALS && Object.keys(existingALS.strings).length > 0) return;
434
511
  const cached = _i18nCache.get(profile);
435
- if (cached && Object.keys(cached.strings).length > 0) {
512
+ if (cached && Object.keys(cached.strings).length > 0 && Date.now() - cached.fetchedAt < I18N_CACHE_TTL_MS) {
436
513
  _i18nALS.enterWith(cached);
437
514
  return;
438
515
  }
439
516
  const labels = await fetchLabelsForSSR({ key, profile, cdnBaseUrl }).catch(() => null);
440
517
  const locale = profile.split(":")[0] || "en";
441
518
  const store = { strings: labels?.strings ?? {}, locale };
442
- if (Object.keys(store.strings).length > 0) _i18nCache.set(profile, store);
519
+ if (Object.keys(store.strings).length > 0) {
520
+ _i18nCache.set(profile, { ...store, fetchedAt: Date.now() });
521
+ } else if (cached && Object.keys(cached.strings).length > 0) {
522
+ _i18nALS.enterWith(cached);
523
+ return;
524
+ }
443
525
  _i18nALS.enterWith(store);
444
526
  },
445
527
  /**
@@ -506,7 +588,7 @@ async function shipeasy(opts) {
506
588
  );
507
589
  }
508
590
  const profile = opts.i18nDefaultProfile ?? "en:prod";
509
- flags.configure({ apiKey: serverKey });
591
+ flags.configure({ apiKey: serverKey, disableTelemetry: opts.disableTelemetry });
510
592
  let resolvedUrlOverrides = opts.urlOverrides;
511
593
  if (!resolvedUrlOverrides) {
512
594
  try {
@@ -1,5 +1,66 @@
1
1
  // src/server/index.ts
2
2
  import { AsyncLocalStorage } from "async_hooks";
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/server/index.ts
3
64
  var version = "1.0.0";
4
65
  var C1 = 3432918353;
5
66
  var C2 = 461845907;
@@ -149,6 +210,7 @@ var FlagsClient = class {
149
210
  apiKey;
150
211
  baseUrl;
151
212
  env;
213
+ telemetry;
152
214
  flagsBlob = null;
153
215
  expsBlob = null;
154
216
  flagsEtag = null;
@@ -160,6 +222,13 @@ var FlagsClient = class {
160
222
  this.apiKey = opts.apiKey;
161
223
  this.baseUrl = (opts.baseUrl ?? "https://cdn.shipeasy.ai").replace(/\/$/, "");
162
224
  this.env = opts.env ?? "prod";
225
+ this.telemetry = new Telemetry({
226
+ endpoint: opts.telemetryUrl ?? DEFAULT_TELEMETRY_URL,
227
+ sdkKey: this.apiKey,
228
+ side: "server",
229
+ env: this.env,
230
+ disabled: opts.disableTelemetry
231
+ });
163
232
  if (opts.initialBlob) {
164
233
  this.flagsBlob = opts.initialBlob;
165
234
  this.initialized = true;
@@ -221,17 +290,20 @@ var FlagsClient = class {
221
290
  this.expsBlob = await res.json();
222
291
  }
223
292
  getFlag(name, user) {
293
+ this.telemetry.emit("gate", name);
224
294
  const gate = this.flagsBlob?.gates[name];
225
295
  if (!gate) return false;
226
296
  return evalGateInternal(gate, user);
227
297
  }
228
298
  getConfig(name, decode) {
299
+ this.telemetry.emit("config", name);
229
300
  const entry = this.flagsBlob?.configs[name];
230
301
  if (!entry) return void 0;
231
302
  if (!decode) return entry.value;
232
303
  return decode(entry.value);
233
304
  }
234
305
  getExperiment(name, user, defaultParams, decode) {
306
+ this.telemetry.emit("experiment", name);
235
307
  const notIn = {
236
308
  inExperiment: false,
237
309
  group: "control",
@@ -306,15 +378,18 @@ var FlagsClient = class {
306
378
  const experiments = {};
307
379
  const killswitches = {};
308
380
  for (const [name, gate] of Object.entries(this.flagsBlob?.gates ?? {})) {
381
+ this.telemetry.emit("gate", name);
309
382
  flags2[name] = evalGateInternal(gate, user);
310
383
  }
311
384
  for (const [name, entry] of Object.entries(this.flagsBlob?.configs ?? {})) {
385
+ this.telemetry.emit("config", name);
312
386
  configs[name] = entry.value;
313
387
  }
314
388
  for (const [name] of Object.entries(this.expsBlob?.experiments ?? {})) {
315
389
  experiments[name] = this.getExperiment(name, user, {});
316
390
  }
317
391
  for (const [name, ks] of Object.entries(this.flagsBlob?.killswitches ?? {})) {
392
+ this.telemetry.emit("ks", name);
318
393
  if (ks.switches && Object.keys(ks.switches).length > 0) {
319
394
  const out = {};
320
395
  for (const [k, v] of Object.entries(ks.switches)) out[k] = isEnabled(v);
@@ -334,6 +409,7 @@ var FlagsClient = class {
334
409
  return { flags: flags2, configs, experiments, killswitches };
335
410
  }
336
411
  getKillswitch(name, switchKey) {
412
+ this.telemetry.emit("ks", name);
337
413
  const ks = this.flagsBlob?.killswitches?.[name];
338
414
  if (!ks) return false;
339
415
  if (switchKey === void 0) return isEnabled(ks.killed);
@@ -344,6 +420,7 @@ var _I18N_SSR_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-i18n");
344
420
  var _EDIT_MODE_SSR_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-edit-mode");
345
421
  var _i18nALS = new AsyncLocalStorage();
346
422
  var _I18N_CACHE_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-i18n-cache");
423
+ var I18N_CACHE_TTL_MS = 6e4;
347
424
  var _i18nCache = globalThis[_I18N_CACHE_SYM] ?? (globalThis[_I18N_CACHE_SYM] = /* @__PURE__ */ new Map());
348
425
  globalThis[_I18N_SSR_SYM] = () => {
349
426
  const fromALS = _i18nALS.getStore();
@@ -389,14 +466,19 @@ var i18n = {
389
466
  const existingALS = _i18nALS.getStore();
390
467
  if (existingALS && Object.keys(existingALS.strings).length > 0) return;
391
468
  const cached = _i18nCache.get(profile);
392
- if (cached && Object.keys(cached.strings).length > 0) {
469
+ if (cached && Object.keys(cached.strings).length > 0 && Date.now() - cached.fetchedAt < I18N_CACHE_TTL_MS) {
393
470
  _i18nALS.enterWith(cached);
394
471
  return;
395
472
  }
396
473
  const labels = await fetchLabelsForSSR({ key, profile, cdnBaseUrl }).catch(() => null);
397
474
  const locale = profile.split(":")[0] || "en";
398
475
  const store = { strings: labels?.strings ?? {}, locale };
399
- if (Object.keys(store.strings).length > 0) _i18nCache.set(profile, store);
476
+ if (Object.keys(store.strings).length > 0) {
477
+ _i18nCache.set(profile, { ...store, fetchedAt: Date.now() });
478
+ } else if (cached && Object.keys(cached.strings).length > 0) {
479
+ _i18nALS.enterWith(cached);
480
+ return;
481
+ }
400
482
  _i18nALS.enterWith(store);
401
483
  },
402
484
  /**
@@ -463,7 +545,7 @@ async function shipeasy(opts) {
463
545
  );
464
546
  }
465
547
  const profile = opts.i18nDefaultProfile ?? "en:prod";
466
- flags.configure({ apiKey: serverKey });
548
+ flags.configure({ apiKey: serverKey, disableTelemetry: opts.disableTelemetry });
467
549
  let resolvedUrlOverrides = opts.urlOverrides;
468
550
  if (!resolvedUrlOverrides) {
469
551
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipeasy/sdk",
3
- "version": "3.0.0",
3
+ "version": "3.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",