@shipeasy/sdk 6.3.1 → 7.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.
@@ -20,6 +20,10 @@ interface ExperimentResult<P> {
20
20
  inExperiment: boolean;
21
21
  group: string;
22
22
  params: P;
23
+ /** The universe this experiment belongs to. Carried in the SSR bootstrap +
24
+ * `/sdk/evaluate` so the client can resolve `universe(name).assign()` by
25
+ * finding the enrolled experiment in a universe. */
26
+ universe?: string;
23
27
  }
24
28
  /**
25
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
@@ -65,13 +69,46 @@ interface ExperimentGroup {
65
69
  interface Experiment {
66
70
  universe: string;
67
71
  targetingGate?: string | null;
72
+ /**
73
+ * Per-experiment holdout flag (a `type='holdout'` gate). When set and the flag
74
+ * passes for a unit, that unit is *held out* of THIS experiment — never
75
+ * assigned, sees the universe defaults. Checked after the universe holdout,
76
+ * before allocation. Mirrors @shipeasy/core §B3.
77
+ */
78
+ holdoutGate?: string | null;
68
79
  allocationPct: number;
80
+ /**
81
+ * Contiguous half-open slice `[poolOffsetBp, poolOffsetBp + poolSizeBp)` of the
82
+ * *universe* hash space this experiment claims. Used only when `hashVersion >= 2`
83
+ * (pooled assignment / real mutual exclusion, §B4): the unit's universe segment
84
+ * must fall in this range to be in the experiment. Null ⇒ fall back to the
85
+ * legacy independent-salt `allocationPct` gate.
86
+ */
87
+ poolOffsetBp?: number | null;
88
+ poolSizeBp?: number | null;
89
+ /**
90
+ * A tail of the group split kept empty (basis points) so a new variant can be
91
+ * appended into it while running without reshuffling enrolled units. Group
92
+ * weights sum to `10000 − reservedHeadroomBp`; a unit hashing into the reserved
93
+ * tail is treated as not-assigned (sees universe defaults). §B5.
94
+ */
95
+ reservedHeadroomBp?: number | null;
69
96
  salt: string;
70
97
  groups: ExperimentGroup[];
71
98
  status: "draft" | "running" | "stopped" | "archived";
99
+ /** Bucketing scheme version. `>= 2` unlocks pooled (mutually-exclusive) assignment. */
100
+ hashVersion?: number;
72
101
  /** Attribute to bucket on (e.g. company_id); defaults to user_id/anonymous_id. */
73
102
  bucketBy?: string | null;
74
103
  }
104
+ /** One param the universe owns: a name, a type, and the default a variant
105
+ * inherits when it doesn't override the value (§A/§B2). */
106
+ interface UniverseParam {
107
+ name: string;
108
+ type: "bool" | "int" | "number" | "string";
109
+ default: unknown;
110
+ }
111
+ type UniverseParamSchema = UniverseParam[];
75
112
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
76
113
  interface StickyEntry {
77
114
  g: string;
@@ -90,6 +127,13 @@ interface StickyBucketStore {
90
127
  }
91
128
  interface Universe {
92
129
  holdout_range: [number, number] | null;
130
+ /**
131
+ * The universe's config schema — the single source of truth for param names,
132
+ * types, and defaults (§A). The `assign()` merge layers these defaults *under*
133
+ * an assigned variant's overrides so an unset param still returns the universe
134
+ * default. Null/absent ⇒ no defaults (legacy: variant params returned verbatim).
135
+ */
136
+ param_schema?: UniverseParamSchema | null;
93
137
  }
94
138
  interface Killswitch {
95
139
  killed: 0 | 1 | boolean;
@@ -116,6 +160,34 @@ interface BootstrapPayload {
116
160
  configs: Record<string, unknown>;
117
161
  experiments: Record<string, ExperimentResult<Record<string, unknown>>>;
118
162
  killswitches: Record<string, boolean | Record<string, boolean>>;
163
+ /** Per-universe param defaults (name → default map) so the client can resolve
164
+ * `universe(name).get(field)` to the universe default even when the unit is
165
+ * not enrolled in any experiment there. Only universes with running
166
+ * experiments are included. */
167
+ universes?: Record<string, {
168
+ defaults: Record<string, unknown>;
169
+ }>;
170
+ }
171
+ /**
172
+ * The result of `universe(name).assign(user)` — a user's standing in a universe.
173
+ * A universe is a mutual-exclusion pool, so a unit lands in **at most one**
174
+ * experiment. Never throws: an un-enrolled unit still resolves `get()` to the
175
+ * universe defaults (or your fallback). Reading is side-effect free — the single
176
+ * exposure is logged once by `assign()` when the unit is enrolled.
177
+ */
178
+ interface Assignment {
179
+ /** The experiment the unit landed in, or `null` when not enrolled. */
180
+ readonly name: string | null;
181
+ /** The assigned variant/group name, or `null` when not enrolled. */
182
+ readonly group: string | null;
183
+ /** True iff the unit is enrolled in an experiment in this universe. */
184
+ readonly enrolled: boolean;
185
+ /**
186
+ * Read a resolved param: the assigned variant's override, else the universe
187
+ * default, else `fallback`. Works even when not enrolled (variant layer is
188
+ * absent, so you get `universeDefault ?? fallback`).
189
+ */
190
+ get<T = unknown>(field: string, fallback?: T): T | undefined;
119
191
  }
120
192
  type EngineEnv = "dev" | "staging" | "prod";
121
193
  interface EngineOptions {
@@ -129,12 +201,31 @@ interface EngineOptions {
129
201
  */
130
202
  initialBlob?: FlagsBlob;
131
203
  /**
132
- * Per-evaluation usage telemetry. ON by default each getFlag/getConfig/
133
- * getExperiment/getKillswitch (and the per-key evaluate() loop) fires one
134
- * fire-and-forget beacon counted by Cloudflare's native per-path analytics.
135
- * Pass `true` to disable. NOTE: on Cloudflare Workers each beacon is an
136
- * outbound subrequest (cap 50 free / 1000 paid per invocation), so disable
137
- * this on hot request paths that evaluate many flags per request.
204
+ * Master network switch when `false`, the SDK makes NO outbound requests at
205
+ * all: init()/initOnce() never fetch (getters read code defaults / overrides),
206
+ * track() and exposure logging are no-ops, usage telemetry is off, and
207
+ * SDK-internal error self-monitoring is off. Think of it as a production-safe
208
+ * offline mode.
209
+ *
210
+ * DEFAULT is environment-derived (see {@link isProductionEnv}): ON in
211
+ * production, OFF everywhere else — so a local/dev/CI run never phones home
212
+ * unless you opt in. Production is inferred from `SHIPEASY_ENV`/`NODE_ENV`, or
213
+ * (when neither is set, e.g. on Cloudflare Workers) from the `env` option
214
+ * above, which defaults to `"prod"`. Pass an explicit value to override.
215
+ */
216
+ isNetworkEnabled?: boolean;
217
+ /**
218
+ * Per-evaluation usage telemetry ("tracking"/outside logging). Each
219
+ * getFlag/getConfig/getExperiment/getKillswitch (and the per-key evaluate()
220
+ * loop) fires one fire-and-forget beacon counted by Cloudflare's native
221
+ * per-path analytics.
222
+ *
223
+ * DEFAULT is environment-derived: ON in production, OFF everywhere else (same
224
+ * inference as {@link isNetworkEnabled}). Pass `true` to force it off, `false`
225
+ * to force it on. NOTE: on Cloudflare Workers each beacon is an outbound
226
+ * subrequest (cap 50 free / 1000 paid per invocation), so disable this on hot
227
+ * request paths that evaluate many flags per request. Forced off whenever
228
+ * `isNetworkEnabled` is false.
138
229
  */
139
230
  disableTelemetry?: boolean;
140
231
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
@@ -149,6 +240,14 @@ interface EngineOptions {
149
240
  * SDK entirely. See {@link LogLevel}.
150
241
  */
151
242
  logLevel?: LogLevel;
243
+ /**
244
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
245
+ * last-resort guards catches an internal failure (an "on our end" bug), the
246
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
247
+ * apps — never to your project. ON by default; forced off in test mode. Pass
248
+ * `true` to disable.
249
+ */
250
+ disableInternalErrorReporting?: boolean;
152
251
  /**
153
252
  * Attribute names usable for targeting but never persisted in analytics
154
253
  * (LD/Statsig `privateAttributes`). The server evaluates locally so private
@@ -178,6 +277,8 @@ declare class Engine {
178
277
  private readonly env;
179
278
  private readonly privateAttributes;
180
279
  private readonly stickyStore;
280
+ private readonly networkEnabled;
281
+ private readonly offline;
181
282
  private readonly telemetry;
182
283
  private readonly seeLimiter;
183
284
  private flagsBlob;
@@ -191,6 +292,7 @@ declare class Engine {
191
292
  private readonly flagOverrides;
192
293
  private readonly configOverrides;
193
294
  private readonly experimentOverrides;
295
+ private readonly exposureSeen;
194
296
  private readonly changeListeners;
195
297
  constructor(opts: EngineOptions);
196
298
  /**
@@ -270,19 +372,47 @@ declare class Engine {
270
372
  getFlag(name: string, user: User, defaultValue?: boolean): boolean;
271
373
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
272
374
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
273
- getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
375
+ /**
376
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
377
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
378
+ */
379
+ private bindSticky;
380
+ /**
381
+ * Evaluate one experiment by name for `user` — override → full classify
382
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
383
+ * → group), merging the universe defaults under the assigned variant (§B2).
384
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
385
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
386
+ */
387
+ private evalExperiment;
388
+ /**
389
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
390
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
391
+ * exposes the variant + resolved params and auto-logs a single exposure when
392
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
393
+ * Never throws. This is the sole experiment read path (there is no
394
+ * `getExperiment` — a caller asks a universe, not an experiment).
395
+ */
396
+ assignUniverse(universeName: string, user: User): Assignment;
397
+ /**
398
+ * The universe-first experiment read entry point:
399
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
400
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
401
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
402
+ */
403
+ universe(name: string): {
404
+ assign(user: User): Assignment;
405
+ };
274
406
  /** Drop caller-marked private attributes from an outbound props bag. */
275
407
  private stripPrivate;
276
408
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
277
409
  /**
278
- * Emit an exposure event for an experiment at the server-side decision point
279
- * (parity with the browser's auto-exposure). The server is stateless and
280
- * never auto-logs, so call this when you actually present the treatment.
281
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
282
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
283
- * `/collect`. No-op in test mode or when the user isn't enrolled.
410
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
411
+ * per process (bounded set) so repeated `assign()` calls in one server don't
412
+ * spam `/collect`. Fire-and-forget; no-op when the network is disabled. This
413
+ * is how `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
284
414
  */
285
- logExposure(user: string | User, name: string): void;
415
+ private postExposure;
286
416
  /**
287
417
  * Report a structured error into the errors primitive. Fire-and-forget —
288
418
  * never blocks or throws into the request path. Spam-guarded by a 30s
@@ -20,6 +20,10 @@ interface ExperimentResult<P> {
20
20
  inExperiment: boolean;
21
21
  group: string;
22
22
  params: P;
23
+ /** The universe this experiment belongs to. Carried in the SSR bootstrap +
24
+ * `/sdk/evaluate` so the client can resolve `universe(name).assign()` by
25
+ * finding the enrolled experiment in a universe. */
26
+ universe?: string;
23
27
  }
24
28
  /**
25
29
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
@@ -65,13 +69,46 @@ interface ExperimentGroup {
65
69
  interface Experiment {
66
70
  universe: string;
67
71
  targetingGate?: string | null;
72
+ /**
73
+ * Per-experiment holdout flag (a `type='holdout'` gate). When set and the flag
74
+ * passes for a unit, that unit is *held out* of THIS experiment — never
75
+ * assigned, sees the universe defaults. Checked after the universe holdout,
76
+ * before allocation. Mirrors @shipeasy/core §B3.
77
+ */
78
+ holdoutGate?: string | null;
68
79
  allocationPct: number;
80
+ /**
81
+ * Contiguous half-open slice `[poolOffsetBp, poolOffsetBp + poolSizeBp)` of the
82
+ * *universe* hash space this experiment claims. Used only when `hashVersion >= 2`
83
+ * (pooled assignment / real mutual exclusion, §B4): the unit's universe segment
84
+ * must fall in this range to be in the experiment. Null ⇒ fall back to the
85
+ * legacy independent-salt `allocationPct` gate.
86
+ */
87
+ poolOffsetBp?: number | null;
88
+ poolSizeBp?: number | null;
89
+ /**
90
+ * A tail of the group split kept empty (basis points) so a new variant can be
91
+ * appended into it while running without reshuffling enrolled units. Group
92
+ * weights sum to `10000 − reservedHeadroomBp`; a unit hashing into the reserved
93
+ * tail is treated as not-assigned (sees universe defaults). §B5.
94
+ */
95
+ reservedHeadroomBp?: number | null;
69
96
  salt: string;
70
97
  groups: ExperimentGroup[];
71
98
  status: "draft" | "running" | "stopped" | "archived";
99
+ /** Bucketing scheme version. `>= 2` unlocks pooled (mutually-exclusive) assignment. */
100
+ hashVersion?: number;
72
101
  /** Attribute to bucket on (e.g. company_id); defaults to user_id/anonymous_id. */
73
102
  bucketBy?: string | null;
74
103
  }
104
+ /** One param the universe owns: a name, a type, and the default a variant
105
+ * inherits when it doesn't override the value (§A/§B2). */
106
+ interface UniverseParam {
107
+ name: string;
108
+ type: "bool" | "int" | "number" | "string";
109
+ default: unknown;
110
+ }
111
+ type UniverseParamSchema = UniverseParam[];
75
112
  /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
76
113
  interface StickyEntry {
77
114
  g: string;
@@ -90,6 +127,13 @@ interface StickyBucketStore {
90
127
  }
91
128
  interface Universe {
92
129
  holdout_range: [number, number] | null;
130
+ /**
131
+ * The universe's config schema — the single source of truth for param names,
132
+ * types, and defaults (§A). The `assign()` merge layers these defaults *under*
133
+ * an assigned variant's overrides so an unset param still returns the universe
134
+ * default. Null/absent ⇒ no defaults (legacy: variant params returned verbatim).
135
+ */
136
+ param_schema?: UniverseParamSchema | null;
93
137
  }
94
138
  interface Killswitch {
95
139
  killed: 0 | 1 | boolean;
@@ -116,6 +160,34 @@ interface BootstrapPayload {
116
160
  configs: Record<string, unknown>;
117
161
  experiments: Record<string, ExperimentResult<Record<string, unknown>>>;
118
162
  killswitches: Record<string, boolean | Record<string, boolean>>;
163
+ /** Per-universe param defaults (name → default map) so the client can resolve
164
+ * `universe(name).get(field)` to the universe default even when the unit is
165
+ * not enrolled in any experiment there. Only universes with running
166
+ * experiments are included. */
167
+ universes?: Record<string, {
168
+ defaults: Record<string, unknown>;
169
+ }>;
170
+ }
171
+ /**
172
+ * The result of `universe(name).assign(user)` — a user's standing in a universe.
173
+ * A universe is a mutual-exclusion pool, so a unit lands in **at most one**
174
+ * experiment. Never throws: an un-enrolled unit still resolves `get()` to the
175
+ * universe defaults (or your fallback). Reading is side-effect free — the single
176
+ * exposure is logged once by `assign()` when the unit is enrolled.
177
+ */
178
+ interface Assignment {
179
+ /** The experiment the unit landed in, or `null` when not enrolled. */
180
+ readonly name: string | null;
181
+ /** The assigned variant/group name, or `null` when not enrolled. */
182
+ readonly group: string | null;
183
+ /** True iff the unit is enrolled in an experiment in this universe. */
184
+ readonly enrolled: boolean;
185
+ /**
186
+ * Read a resolved param: the assigned variant's override, else the universe
187
+ * default, else `fallback`. Works even when not enrolled (variant layer is
188
+ * absent, so you get `universeDefault ?? fallback`).
189
+ */
190
+ get<T = unknown>(field: string, fallback?: T): T | undefined;
119
191
  }
120
192
  type EngineEnv = "dev" | "staging" | "prod";
121
193
  interface EngineOptions {
@@ -129,12 +201,31 @@ interface EngineOptions {
129
201
  */
130
202
  initialBlob?: FlagsBlob;
131
203
  /**
132
- * Per-evaluation usage telemetry. ON by default each getFlag/getConfig/
133
- * getExperiment/getKillswitch (and the per-key evaluate() loop) fires one
134
- * fire-and-forget beacon counted by Cloudflare's native per-path analytics.
135
- * Pass `true` to disable. NOTE: on Cloudflare Workers each beacon is an
136
- * outbound subrequest (cap 50 free / 1000 paid per invocation), so disable
137
- * this on hot request paths that evaluate many flags per request.
204
+ * Master network switch when `false`, the SDK makes NO outbound requests at
205
+ * all: init()/initOnce() never fetch (getters read code defaults / overrides),
206
+ * track() and exposure logging are no-ops, usage telemetry is off, and
207
+ * SDK-internal error self-monitoring is off. Think of it as a production-safe
208
+ * offline mode.
209
+ *
210
+ * DEFAULT is environment-derived (see {@link isProductionEnv}): ON in
211
+ * production, OFF everywhere else — so a local/dev/CI run never phones home
212
+ * unless you opt in. Production is inferred from `SHIPEASY_ENV`/`NODE_ENV`, or
213
+ * (when neither is set, e.g. on Cloudflare Workers) from the `env` option
214
+ * above, which defaults to `"prod"`. Pass an explicit value to override.
215
+ */
216
+ isNetworkEnabled?: boolean;
217
+ /**
218
+ * Per-evaluation usage telemetry ("tracking"/outside logging). Each
219
+ * getFlag/getConfig/getExperiment/getKillswitch (and the per-key evaluate()
220
+ * loop) fires one fire-and-forget beacon counted by Cloudflare's native
221
+ * per-path analytics.
222
+ *
223
+ * DEFAULT is environment-derived: ON in production, OFF everywhere else (same
224
+ * inference as {@link isNetworkEnabled}). Pass `true` to force it off, `false`
225
+ * to force it on. NOTE: on Cloudflare Workers each beacon is an outbound
226
+ * subrequest (cap 50 free / 1000 paid per invocation), so disable this on hot
227
+ * request paths that evaluate many flags per request. Forced off whenever
228
+ * `isNetworkEnabled` is false.
138
229
  */
139
230
  disableTelemetry?: boolean;
140
231
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
@@ -149,6 +240,14 @@ interface EngineOptions {
149
240
  * SDK entirely. See {@link LogLevel}.
150
241
  */
151
242
  logLevel?: LogLevel;
243
+ /**
244
+ * Opt out of SDK-internal error self-monitoring. When one of the SDK's own
245
+ * last-resort guards catches an internal failure (an "on our end" bug), the
246
+ * SDK reports it to Shipeasy's own project so we can track SDK bugs across
247
+ * apps — never to your project. ON by default; forced off in test mode. Pass
248
+ * `true` to disable.
249
+ */
250
+ disableInternalErrorReporting?: boolean;
152
251
  /**
153
252
  * Attribute names usable for targeting but never persisted in analytics
154
253
  * (LD/Statsig `privateAttributes`). The server evaluates locally so private
@@ -178,6 +277,8 @@ declare class Engine {
178
277
  private readonly env;
179
278
  private readonly privateAttributes;
180
279
  private readonly stickyStore;
280
+ private readonly networkEnabled;
281
+ private readonly offline;
181
282
  private readonly telemetry;
182
283
  private readonly seeLimiter;
183
284
  private flagsBlob;
@@ -191,6 +292,7 @@ declare class Engine {
191
292
  private readonly flagOverrides;
192
293
  private readonly configOverrides;
193
294
  private readonly experimentOverrides;
295
+ private readonly exposureSeen;
194
296
  private readonly changeListeners;
195
297
  constructor(opts: EngineOptions);
196
298
  /**
@@ -270,19 +372,47 @@ declare class Engine {
270
372
  getFlag(name: string, user: User, defaultValue?: boolean): boolean;
271
373
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
272
374
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
273
- getExperiment<P extends Record<string, unknown>>(name: string, user: User, defaultParams: P, decode?: (raw: unknown) => P): ExperimentResult<P>;
375
+ /**
376
+ * Bind the per-experiment sticky seam over the configured {@link StickyBucketStore}
377
+ * for one (unit, experiment). Absent store ⇒ deterministic (no sticky).
378
+ */
379
+ private bindSticky;
380
+ /**
381
+ * Evaluate one experiment by name for `user` — override → full classify
382
+ * pipeline (targeting → universe holdout → holdout gate → sticky → allocation
383
+ * → group), merging the universe defaults under the assigned variant (§B2).
384
+ * Internal: the public surface is `universe(name).assign(user)`. Reused by the
385
+ * SSR `evaluate()` bootstrap (keyed by experiment name) and by `assignUniverse`.
386
+ */
387
+ private evalExperiment;
388
+ /**
389
+ * Assign `user` within `universeName`. A universe is a mutual-exclusion pool,
390
+ * so a unit lands in **at most one** experiment; the returned {@link Assignment}
391
+ * exposes the variant + resolved params and auto-logs a single exposure when
392
+ * enrolled. An un-enrolled unit still resolves `get()` to the universe defaults.
393
+ * Never throws. This is the sole experiment read path (there is no
394
+ * `getExperiment` — a caller asks a universe, not an experiment).
395
+ */
396
+ assignUniverse(universeName: string, user: User): Assignment;
397
+ /**
398
+ * The universe-first experiment read entry point:
399
+ * `engine.universe("checkout").assign(user)`. Returns a reusable handle bound
400
+ * to one universe; `assign(user)` picks the ≤1 experiment the unit is pooled
401
+ * into and auto-logs a single exposure. See {@link assignUniverse}.
402
+ */
403
+ universe(name: string): {
404
+ assign(user: User): Assignment;
405
+ };
274
406
  /** Drop caller-marked private attributes from an outbound props bag. */
275
407
  private stripPrivate;
276
408
  track(userId: string, eventName: string, props?: Record<string, unknown>): void;
277
409
  /**
278
- * Emit an exposure event for an experiment at the server-side decision point
279
- * (parity with the browser's auto-exposure). The server is stateless and
280
- * never auto-logs, so call this when you actually present the treatment.
281
- * Re-evaluates the experiment for `user` (a bare `user_id` string is wrapped
282
- * as `{ user_id }`); if the user is enrolled, POSTs a single exposure to
283
- * `/collect`. No-op in test mode or when the user isn't enrolled.
410
+ * POST a single exposure for an enrolled `(user, experiment, group)`. Deduped
411
+ * per process (bounded set) so repeated `assign()` calls in one server don't
412
+ * spam `/collect`. Fire-and-forget; no-op when the network is disabled. This
413
+ * is how `assignUniverse` auto-logs the browser's auto-exposure parity for SSR.
284
414
  */
285
- logExposure(user: string | User, name: string): void;
415
+ private postExposure;
286
416
  /**
287
417
  * Report a structured error into the errors primitive. Fire-and-forget —
288
418
  * never blocks or throws into the request path. Spam-guarded by a 30s
@@ -28,43 +28,12 @@ var import_server_sdk = require("@openfeature/server-sdk");
28
28
  // src/server/index.ts
29
29
  var import_node_async_hooks = require("async_hooks");
30
30
 
31
- // src/logger.ts
32
- var RANK = {
33
- silent: 0,
34
- error: 1,
35
- warn: 2,
36
- info: 3,
37
- debug: 4
38
- };
39
- var currentLevel = "warn";
40
- function write(method, args) {
41
- try {
42
- const c = globalThis.console;
43
- if (!c) return;
44
- const fn = c[method] ?? c.log;
45
- if (typeof fn === "function") fn.call(c, ...args);
46
- } catch {
47
- }
48
- }
49
- var logger = {
50
- error(...args) {
51
- if (RANK[currentLevel] >= RANK.error) write("error", args);
52
- },
53
- warn(...args) {
54
- if (RANK[currentLevel] >= RANK.warn) write("warn", args);
55
- },
56
- info(...args) {
57
- if (RANK[currentLevel] >= RANK.info) write("info", args);
58
- },
59
- debug(...args) {
60
- if (RANK[currentLevel] >= RANK.debug) write("debug", args);
61
- }
62
- };
63
-
64
31
  // src/see/core.ts
65
32
  var SEE_MAX_SUBJECT = 200;
66
33
  var SEE_MAX_EXTRA_VALUE = 200;
67
34
  var SEE_MAX_EXTRA_KEYS = 20;
35
+ var SEE_DEDUP_WINDOW_MS = 3e4;
36
+ var SEE_MAX_PER_SESSION = 25;
68
37
  function causesThe(subject) {
69
38
  return {
70
39
  to(outcome) {
@@ -175,6 +144,69 @@ function startControlFlowChain(err) {
175
144
  }
176
145
  };
177
146
  }
147
+ function topStackLine(stack) {
148
+ if (!stack) return "";
149
+ for (const line of stack.split("\n")) {
150
+ if (/^\s*at |@|:\d+:\d+/.test(line)) return line.trim().slice(0, 200);
151
+ }
152
+ return "";
153
+ }
154
+ var SeeLimiter = class {
155
+ constructor(maxPerSession = SEE_MAX_PER_SESSION, dedupWindowMs = SEE_DEDUP_WINDOW_MS) {
156
+ this.maxPerSession = maxPerSession;
157
+ this.dedupWindowMs = dedupWindowMs;
158
+ }
159
+ maxPerSession;
160
+ dedupWindowMs;
161
+ lastSent = /* @__PURE__ */ new Map();
162
+ sent = 0;
163
+ shouldSend(ev) {
164
+ if (this.sent >= this.maxPerSession) return false;
165
+ const key = `${ev.kind}|${ev.error_type}|${ev.message.slice(0, 200)}|${topStackLine(ev.stack)}`;
166
+ const now = Date.now();
167
+ const prev = this.lastSent.get(key);
168
+ if (prev !== void 0 && now - prev < this.dedupWindowMs) return false;
169
+ this.lastSent.set(key, now);
170
+ this.sent += 1;
171
+ return true;
172
+ }
173
+ };
174
+
175
+ // src/internal-report.ts
176
+ var limiter = new SeeLimiter();
177
+
178
+ // src/logger.ts
179
+ var RANK = {
180
+ silent: 0,
181
+ error: 1,
182
+ warn: 2,
183
+ info: 3,
184
+ debug: 4
185
+ };
186
+ var currentLevel = "warn";
187
+ function write(method, args) {
188
+ try {
189
+ const c = globalThis.console;
190
+ if (!c) return;
191
+ const fn = c[method] ?? c.log;
192
+ if (typeof fn === "function") fn.call(c, ...args);
193
+ } catch {
194
+ }
195
+ }
196
+ var logger = {
197
+ error(...args) {
198
+ if (RANK[currentLevel] >= RANK.error) write("error", args);
199
+ },
200
+ warn(...args) {
201
+ if (RANK[currentLevel] >= RANK.warn) write("warn", args);
202
+ },
203
+ info(...args) {
204
+ if (RANK[currentLevel] >= RANK.info) write("info", args);
205
+ },
206
+ debug(...args) {
207
+ if (RANK[currentLevel] >= RANK.debug) write("debug", args);
208
+ }
209
+ };
178
210
 
179
211
  // src/server/index.ts
180
212
  var _I18N_SSR_SYM = /* @__PURE__ */ Symbol.for("@shipeasy/sdk:ssr-i18n");