@shipeasy/sdk 5.1.0 → 5.3.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.
@@ -120,6 +120,20 @@ interface ExperimentResult<P> {
120
120
  group: string;
121
121
  params: P;
122
122
  }
123
+ /** Options object form of `getExperiment` — the legacy `decode`/`variants`
124
+ * positional args plus per-call exposure control. */
125
+ interface GetExperimentOptions<P> {
126
+ /** Decode the raw stored params into the typed shape callers want. */
127
+ decode?: (raw: unknown) => P;
128
+ /** Variant-specific param overrides merged on top of group params. */
129
+ variants?: Record<string, Partial<P>>;
130
+ /**
131
+ * Override automatic exposure logging for this read. Defaults to the client's
132
+ * setting (`disableAutoExposure` flips it). `false` reads the variant without
133
+ * logging an exposure — pair with `logExposure(name)` at render time.
134
+ */
135
+ logExposure?: boolean;
136
+ }
123
137
  /**
124
138
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
125
139
  * Computed at the client boundary:
@@ -162,6 +176,17 @@ interface EvalResponse {
162
176
  * switch booleans.
163
177
  */
164
178
  killswitches?: Record<string, boolean | Record<string, boolean>>;
179
+ /**
180
+ * Newly-assigned sticky entries (doc 20 §2). The worker returns these so the
181
+ * browser can merge them into the `__se_sticky` cookie. Present only when
182
+ * sticky bucketing is on and at least one assignment was made/refreshed.
183
+ */
184
+ sticky?: Record<string, StickyCookieEntry>;
185
+ }
186
+ /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
187
+ interface StickyCookieEntry {
188
+ g: string;
189
+ s: string;
165
190
  }
166
191
  interface AutoCollectGroups {
167
192
  vitals: boolean;
@@ -207,6 +232,29 @@ interface FlagsClientBrowserOptions {
207
232
  disableTelemetry?: boolean;
208
233
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
209
234
  telemetryUrl?: string;
235
+ /**
236
+ * Suppress automatic exposure logging in `getExperiment` (Statsig's
237
+ * `disableExposureLogging`). Default false — reading an enrolled variant
238
+ * auto-logs a deduped exposure. When true, no exposure fires unless you call
239
+ * `logExposure(name)` yourself, or pass `{ logExposure: true }` per call.
240
+ */
241
+ disableAutoExposure?: boolean;
242
+ /**
243
+ * Attribute names usable for targeting but never persisted in analytics
244
+ * (LD/Statsig `privateAttributes`). They are sent to `/sdk/evaluate` under
245
+ * `private_attributes` so the edge can evaluate with them (unavoidable —
246
+ * the edge evaluates), but the worker never stores them, and the listed keys
247
+ * are stripped from any `track(props)` payload.
248
+ */
249
+ privateAttributes?: string[];
250
+ /**
251
+ * Sticky bucketing (doc 20 §2). ON by default in the browser: a unit's
252
+ * first-assigned variant is locked in the `__se_sticky` cookie so changing an
253
+ * experiment's allocation % or group weights never silently re-buckets
254
+ * enrolled users. Changing the experiment salt is the deliberate reshuffle
255
+ * lever. Pass `false` to opt out (pure deterministic eval).
256
+ */
257
+ stickyBucketing?: boolean;
210
258
  /**
211
259
  * Test mode — no network at all. identify()/init are no-ops (never call
212
260
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -221,6 +269,9 @@ declare class FlagsClientBrowser {
221
269
  private readonly autoGuardrails;
222
270
  private readonly autoGuardrailGroups;
223
271
  private readonly autoCollectAlways;
272
+ private readonly disableAutoExposure;
273
+ private readonly privateAttributes;
274
+ private readonly stickyBucketing;
224
275
  private readonly env;
225
276
  private evalResult;
226
277
  private anonId;
@@ -292,6 +343,15 @@ declare class FlagsClientBrowser {
292
343
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
293
344
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
294
345
  getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
346
+ getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
347
+ /**
348
+ * Manually log an exposure for an enrolled experiment (Statsig's
349
+ * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
350
+ * the experiment, pushes the session-deduped exposure. Pair this with the
351
+ * render of the treatment when reading with `{ logExposure: false }` (or
352
+ * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
353
+ */
354
+ logExposure(name: string): void;
295
355
  /**
296
356
  * Subscribe to state changes — fires after identify() completes and on
297
357
  * `se:override:change` events from the devtools overlay. Returns an
@@ -304,6 +364,8 @@ declare class FlagsClientBrowser {
304
364
  */
305
365
  installBridge(): ShipeasySdkBridge | null;
306
366
  track(eventName: string, props?: Record<string, unknown>): void;
367
+ /** Drop caller-marked private attributes from an outbound props bag. */
368
+ private stripPrivate;
307
369
  /**
308
370
  * Read a killswitch from the server's evaluated state. Without `switchKey`,
309
371
  * returns true when the killswitch is whole-killed. With `switchKey`, returns
@@ -412,6 +474,25 @@ interface ShipeasyClientConfig {
412
474
  * counted by Cloudflare's native per-path analytics. Pass `true` to opt out.
413
475
  */
414
476
  disableTelemetry?: boolean;
477
+ /**
478
+ * Suppress automatic exposure logging in `flags.getExperiment` (Statsig's
479
+ * `disableExposureLogging`). Default false. When true, call
480
+ * `flags.logExposure(name)` at the treatment's render to log the exposure.
481
+ */
482
+ disableAutoExposure?: boolean;
483
+ /**
484
+ * Attribute names usable for targeting but never persisted in analytics
485
+ * (LD/Statsig `privateAttributes`). Sent to the edge for evaluation, never
486
+ * stored, and stripped from `flags.track(props)`. See
487
+ * {@link FlagsClientBrowserOptions.privateAttributes}.
488
+ */
489
+ privateAttributes?: string[];
490
+ /**
491
+ * Sticky bucketing (doc 20 §2). ON by default — locks each enrolled unit to
492
+ * its first-assigned variant via the `__se_sticky` cookie. Pass `false` to
493
+ * opt out. See {@link FlagsClientBrowserOptions.stickyBucketing}.
494
+ */
495
+ stickyBucketing?: boolean;
415
496
  }
416
497
  /**
417
498
  * Initialise the ShipEasy client SDK and wire up lazy devtools.
@@ -471,7 +552,10 @@ declare const flags: {
471
552
  /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
472
553
  getDetail(name: string): FlagDetail;
473
554
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
474
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
555
+ getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decodeOrOpts?: ((raw: unknown) => P) | GetExperimentOptions<P>, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
556
+ /** Manually log an exposure for an enrolled experiment. See
557
+ * {@link FlagsClientBrowser.logExposure}. No-op before configure(). */
558
+ logExposure(name: string): void;
475
559
  track(eventName: string, props?: Record<string, unknown>): void;
476
560
  /**
477
561
  * Read a killswitch. Without `switchKey`, returns true when the killswitch is
@@ -590,4 +674,4 @@ interface I18nFacade {
590
674
  }
591
675
  declare const i18n: I18nFacade;
592
676
 
593
- export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
677
+ export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type GetExperimentOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
@@ -120,6 +120,20 @@ interface ExperimentResult<P> {
120
120
  group: string;
121
121
  params: P;
122
122
  }
123
+ /** Options object form of `getExperiment` — the legacy `decode`/`variants`
124
+ * positional args plus per-call exposure control. */
125
+ interface GetExperimentOptions<P> {
126
+ /** Decode the raw stored params into the typed shape callers want. */
127
+ decode?: (raw: unknown) => P;
128
+ /** Variant-specific param overrides merged on top of group params. */
129
+ variants?: Record<string, Partial<P>>;
130
+ /**
131
+ * Override automatic exposure logging for this read. Defaults to the client's
132
+ * setting (`disableAutoExposure` flips it). `false` reads the variant without
133
+ * logging an exposure — pair with `logExposure(name)` at render time.
134
+ */
135
+ logExposure?: boolean;
136
+ }
123
137
  /**
124
138
  * Why a flag evaluated the way it did (LaunchDarkly variationDetail parity).
125
139
  * Computed at the client boundary:
@@ -162,6 +176,17 @@ interface EvalResponse {
162
176
  * switch booleans.
163
177
  */
164
178
  killswitches?: Record<string, boolean | Record<string, boolean>>;
179
+ /**
180
+ * Newly-assigned sticky entries (doc 20 §2). The worker returns these so the
181
+ * browser can merge them into the `__se_sticky` cookie. Present only when
182
+ * sticky bucketing is on and at least one assignment was made/refreshed.
183
+ */
184
+ sticky?: Record<string, StickyCookieEntry>;
185
+ }
186
+ /** One persisted sticky assignment: group + 8-char salt prefix (reshuffle key). */
187
+ interface StickyCookieEntry {
188
+ g: string;
189
+ s: string;
165
190
  }
166
191
  interface AutoCollectGroups {
167
192
  vitals: boolean;
@@ -207,6 +232,29 @@ interface FlagsClientBrowserOptions {
207
232
  disableTelemetry?: boolean;
208
233
  /** Override the telemetry beacon host. Defaults to {@link DEFAULT_TELEMETRY_URL}. */
209
234
  telemetryUrl?: string;
235
+ /**
236
+ * Suppress automatic exposure logging in `getExperiment` (Statsig's
237
+ * `disableExposureLogging`). Default false — reading an enrolled variant
238
+ * auto-logs a deduped exposure. When true, no exposure fires unless you call
239
+ * `logExposure(name)` yourself, or pass `{ logExposure: true }` per call.
240
+ */
241
+ disableAutoExposure?: boolean;
242
+ /**
243
+ * Attribute names usable for targeting but never persisted in analytics
244
+ * (LD/Statsig `privateAttributes`). They are sent to `/sdk/evaluate` under
245
+ * `private_attributes` so the edge can evaluate with them (unavoidable —
246
+ * the edge evaluates), but the worker never stores them, and the listed keys
247
+ * are stripped from any `track(props)` payload.
248
+ */
249
+ privateAttributes?: string[];
250
+ /**
251
+ * Sticky bucketing (doc 20 §2). ON by default in the browser: a unit's
252
+ * first-assigned variant is locked in the `__se_sticky` cookie so changing an
253
+ * experiment's allocation % or group weights never silently re-buckets
254
+ * enrolled users. Changing the experiment salt is the deliberate reshuffle
255
+ * lever. Pass `false` to opt out (pure deterministic eval).
256
+ */
257
+ stickyBucketing?: boolean;
210
258
  /**
211
259
  * Test mode — no network at all. identify()/init are no-ops (never call
212
260
  * /sdk/evaluate), track() is a no-op, telemetry is forced off, and the client
@@ -221,6 +269,9 @@ declare class FlagsClientBrowser {
221
269
  private readonly autoGuardrails;
222
270
  private readonly autoGuardrailGroups;
223
271
  private readonly autoCollectAlways;
272
+ private readonly disableAutoExposure;
273
+ private readonly privateAttributes;
274
+ private readonly stickyBucketing;
224
275
  private readonly env;
225
276
  private evalResult;
226
277
  private anonId;
@@ -292,6 +343,15 @@ declare class FlagsClientBrowser {
292
343
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
293
344
  getConfig<T = unknown>(name: string, opts: GetConfigOptions<T>): T;
294
345
  getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
346
+ getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, opts: GetExperimentOptions<P>): ExperimentResult<P>;
347
+ /**
348
+ * Manually log an exposure for an enrolled experiment (Statsig's
349
+ * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
350
+ * the experiment, pushes the session-deduped exposure. Pair this with the
351
+ * render of the treatment when reading with `{ logExposure: false }` (or
352
+ * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
353
+ */
354
+ logExposure(name: string): void;
295
355
  /**
296
356
  * Subscribe to state changes — fires after identify() completes and on
297
357
  * `se:override:change` events from the devtools overlay. Returns an
@@ -304,6 +364,8 @@ declare class FlagsClientBrowser {
304
364
  */
305
365
  installBridge(): ShipeasySdkBridge | null;
306
366
  track(eventName: string, props?: Record<string, unknown>): void;
367
+ /** Drop caller-marked private attributes from an outbound props bag. */
368
+ private stripPrivate;
307
369
  /**
308
370
  * Read a killswitch from the server's evaluated state. Without `switchKey`,
309
371
  * returns true when the killswitch is whole-killed. With `switchKey`, returns
@@ -412,6 +474,25 @@ interface ShipeasyClientConfig {
412
474
  * counted by Cloudflare's native per-path analytics. Pass `true` to opt out.
413
475
  */
414
476
  disableTelemetry?: boolean;
477
+ /**
478
+ * Suppress automatic exposure logging in `flags.getExperiment` (Statsig's
479
+ * `disableExposureLogging`). Default false. When true, call
480
+ * `flags.logExposure(name)` at the treatment's render to log the exposure.
481
+ */
482
+ disableAutoExposure?: boolean;
483
+ /**
484
+ * Attribute names usable for targeting but never persisted in analytics
485
+ * (LD/Statsig `privateAttributes`). Sent to the edge for evaluation, never
486
+ * stored, and stripped from `flags.track(props)`. See
487
+ * {@link FlagsClientBrowserOptions.privateAttributes}.
488
+ */
489
+ privateAttributes?: string[];
490
+ /**
491
+ * Sticky bucketing (doc 20 §2). ON by default — locks each enrolled unit to
492
+ * its first-assigned variant via the `__se_sticky` cookie. Pass `false` to
493
+ * opt out. See {@link FlagsClientBrowserOptions.stickyBucketing}.
494
+ */
495
+ stickyBucketing?: boolean;
415
496
  }
416
497
  /**
417
498
  * Initialise the ShipEasy client SDK and wire up lazy devtools.
@@ -471,7 +552,10 @@ declare const flags: {
471
552
  /** Evaluate a gate and report why (value + reason). See {@link FlagDetail}. */
472
553
  getDetail(name: string): FlagDetail;
473
554
  getConfig<T = unknown>(name: string, decode?: (raw: unknown) => T): T | undefined;
474
- getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decode?: (raw: unknown) => P, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
555
+ getExperiment<P extends Record<string, unknown>>(name: string, defaultParams: P, decodeOrOpts?: ((raw: unknown) => P) | GetExperimentOptions<P>, variants?: Record<string, Partial<P>>): ExperimentResult<P>;
556
+ /** Manually log an exposure for an enrolled experiment. See
557
+ * {@link FlagsClientBrowser.logExposure}. No-op before configure(). */
558
+ logExposure(name: string): void;
475
559
  track(eventName: string, props?: Record<string, unknown>): void;
476
560
  /**
477
561
  * Read a killswitch. Without `switchKey`, returns true when the killswitch is
@@ -590,4 +674,4 @@ interface I18nFacade {
590
674
  }
591
675
  declare const i18n: I18nFacade;
592
676
 
593
- export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
677
+ export { type AutoCollectGroups, type BootstrapPayload, type Consequence, type ExperimentResult, FLAG_REASONS, type FlagDetail, type FlagReason, FlagsClientBrowser, type FlagsClientBrowserEnv, type FlagsClientBrowserOptions, type GetConfigOptions, type GetExperimentOptions, type I18nFacade, type I18nKey, type I18nRichComponents, type I18nString, type I18nTagRenderer, type I18nVariables, LABEL_MARKER_END, LABEL_MARKER_RE, LABEL_MARKER_SEP, LABEL_MARKER_START, type LabelAttrs, type SeeApi, type SeeChain, type SeeControlFlowChain, type SeeErrorEvent, type SeeExtras, type SeeKind, type SeeViolationChain, type ShipeasyClientConfig, type ShipeasySdkBridge, type User, type Violation, _resetShipeasyForTests, attachDevtools, configureShipeasy, encodeLabelMarker, flags, getShipeasyClient, i18n, injectCorrelationHeader, isDevtoolsRequested, labelAttrs, loadDevtools, readConfigOverride, readExpOverride, readGateOverride, sameOrigin, see, shipeasy, version };
@@ -731,6 +731,40 @@ function writeAnonCookie(id) {
731
731
  } catch {
732
732
  }
733
733
  }
734
+ var STICKY_COOKIE = "__se_sticky";
735
+ var STICKY_MAX_BYTES = 3800;
736
+ function readStickyCookie() {
737
+ try {
738
+ const m = ("; " + document.cookie).match(/; __se_sticky=([^;]+)/);
739
+ if (!m) return {};
740
+ const parsed = JSON.parse(decodeURIComponent(m[1]));
741
+ if (!parsed || typeof parsed !== "object") return {};
742
+ const out = {};
743
+ for (const [k, v] of Object.entries(parsed)) {
744
+ if (v && typeof v === "object" && typeof v.g === "string") {
745
+ out[k] = { g: v.g, s: String(v.s ?? "") };
746
+ }
747
+ }
748
+ return out;
749
+ } catch {
750
+ return {};
751
+ }
752
+ }
753
+ function writeStickyCookie(map) {
754
+ try {
755
+ let json = JSON.stringify(map);
756
+ if (json.length > STICKY_MAX_BYTES) {
757
+ const entries = Object.entries(map);
758
+ while (entries.length > 0 && JSON.stringify(Object.fromEntries(entries)).length > STICKY_MAX_BYTES) {
759
+ entries.shift();
760
+ }
761
+ json = JSON.stringify(Object.fromEntries(entries));
762
+ }
763
+ const secure = location.protocol === "https:" ? ";secure" : "";
764
+ document.cookie = `${STICKY_COOKIE}=${encodeURIComponent(json)};path=/;max-age=31536000;samesite=lax${secure}`;
765
+ } catch {
766
+ }
767
+ }
734
768
  function getOrCreateAnonId() {
735
769
  let id = readAnonCookie();
736
770
  if (!id && typeof window !== "undefined") {
@@ -884,6 +918,9 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
884
918
  autoGuardrails;
885
919
  autoGuardrailGroups;
886
920
  autoCollectAlways;
921
+ disableAutoExposure;
922
+ privateAttributes;
923
+ stickyBucketing;
887
924
  env;
888
925
  evalResult = null;
889
926
  anonId;
@@ -918,6 +955,9 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
918
955
  this.testMode = opts.testMode === true;
919
956
  this.autoGuardrails = opts.autoGuardrails !== false;
920
957
  this.autoCollectAlways = opts.autoCollectAlways === true;
958
+ this.disableAutoExposure = opts.disableAutoExposure === true;
959
+ this.privateAttributes = opts.privateAttributes ?? [];
960
+ this.stickyBucketing = opts.stickyBucketing !== false;
921
961
  const g = opts.autoGuardrailGroups ?? {};
922
962
  this.autoGuardrailGroups = {
923
963
  vitals: g.vitals ?? this.autoGuardrails,
@@ -983,13 +1023,22 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
983
1023
  headers: { "X-SDK-Key": this.sdkKey, "Content-Type": "application/json" },
984
1024
  body: JSON.stringify({
985
1025
  user: userPayload,
986
- experiment_overrides: readExperimentOverridesFromUrl()
1026
+ experiment_overrides: readExperimentOverridesFromUrl(),
1027
+ // Private attributes still reach the edge for evaluation (unavoidable —
1028
+ // the edge evaluates), but the worker never persists them. See doc 20 §5.
1029
+ ...this.privateAttributes.length > 0 ? { private_attributes: [...this.privateAttributes] } : {},
1030
+ // Sticky state round-trip: send the current cookie map; the worker
1031
+ // applies the sticky short-circuit and returns any new assignments.
1032
+ ...this.stickyBucketing ? { sticky: readStickyCookie() } : {}
987
1033
  })
988
1034
  });
989
1035
  if (!res.ok) throw new Error(`/sdk/evaluate returned ${res.status}`);
990
1036
  const data = await res.json();
991
1037
  if (seq !== this.identifySeq) return;
992
1038
  this.evalResult = data;
1039
+ if (this.stickyBucketing && data.sticky && Object.keys(data.sticky).length > 0) {
1040
+ writeStickyCookie({ ...readStickyCookie(), ...data.sticky });
1041
+ }
993
1042
  const anyGroupOn = this.autoGuardrailGroups.vitals || this.autoGuardrailGroups.errors || this.autoGuardrailGroups.engagement;
994
1043
  if (anyGroupOn && !this.guardrailsInstalled) {
995
1044
  this.guardrailsInstalled = true;
@@ -1115,7 +1164,9 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1115
1164
  return void 0;
1116
1165
  }
1117
1166
  }
1118
- getExperiment(name, defaultParams, decode, variants) {
1167
+ getExperiment(name, defaultParams, decodeOrOpts, variantsArg) {
1168
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts, variants: variantsArg } : decodeOrOpts ?? {};
1169
+ const { decode, variants } = opts;
1119
1170
  this.telemetry.emit("experiment", name);
1120
1171
  const notIn = {
1121
1172
  inExperiment: false,
@@ -1136,7 +1187,8 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1136
1187
  }
1137
1188
  const entry = this.evalResult?.experiments[name];
1138
1189
  if (!entry || !entry.inExperiment) return notIn;
1139
- this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1190
+ const shouldLog = opts.logExposure ?? !this.disableAutoExposure;
1191
+ if (shouldLog) this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1140
1192
  if (!decode) return { inExperiment: true, group: entry.group, params: entry.params };
1141
1193
  try {
1142
1194
  return { inExperiment: true, group: entry.group, params: decode(entry.params) };
@@ -1145,6 +1197,18 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1145
1197
  return notIn;
1146
1198
  }
1147
1199
  }
1200
+ /**
1201
+ * Manually log an exposure for an enrolled experiment (Statsig's
1202
+ * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
1203
+ * the experiment, pushes the session-deduped exposure. Pair this with the
1204
+ * render of the treatment when reading with `{ logExposure: false }` (or
1205
+ * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
1206
+ */
1207
+ logExposure(name) {
1208
+ const entry = this.evalResult?.experiments[name];
1209
+ if (!entry || !entry.inExperiment) return;
1210
+ this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1211
+ }
1148
1212
  /**
1149
1213
  * Subscribe to state changes — fires after identify() completes and on
1150
1214
  * `se:override:change` events from the devtools overlay. Returns an
@@ -1180,7 +1244,16 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1180
1244
  }
1181
1245
  track(eventName, props) {
1182
1246
  if (this.testMode) return;
1183
- this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1247
+ this.buffer.pushMetric(eventName, this.userId, this.anonId, this.stripPrivate(props));
1248
+ }
1249
+ /** Drop caller-marked private attributes from an outbound props bag. */
1250
+ stripPrivate(props) {
1251
+ if (!props || this.privateAttributes.length === 0) return props;
1252
+ const out = {};
1253
+ for (const [k, v] of Object.entries(props)) {
1254
+ if (!this.privateAttributes.includes(k)) out[k] = v;
1255
+ }
1256
+ return out;
1184
1257
  }
1185
1258
  /**
1186
1259
  * Read a killswitch from the server's evaluated state. Without `switchKey`,
@@ -1326,7 +1399,10 @@ function shipeasy(opts) {
1326
1399
  autoGuardrails: blanket,
1327
1400
  autoGuardrailGroups: groups,
1328
1401
  autoCollectAlways: acObj?.always === true,
1329
- disableTelemetry: opts.disableTelemetry
1402
+ disableTelemetry: opts.disableTelemetry,
1403
+ disableAutoExposure: opts.disableAutoExposure,
1404
+ privateAttributes: opts.privateAttributes,
1405
+ stickyBucketing: opts.stickyBucketing
1330
1406
  });
1331
1407
  injectI18nLoader(opts.clientKey, baseUrl, opts.i18nProfile);
1332
1408
  flags.notifyMounted();
@@ -1434,12 +1510,19 @@ var flags = {
1434
1510
  return void 0;
1435
1511
  }
1436
1512
  },
1437
- getExperiment(name, defaultParams, decode, variants) {
1438
- return _client?.getExperiment(name, defaultParams, decode, variants) ?? {
1513
+ getExperiment(name, defaultParams, decodeOrOpts, variants) {
1514
+ const fallback = {
1439
1515
  inExperiment: false,
1440
1516
  group: "control",
1441
1517
  params: defaultParams
1442
1518
  };
1519
+ if (!_client) return fallback;
1520
+ return typeof decodeOrOpts === "function" ? _client.getExperiment(name, defaultParams, decodeOrOpts, variants) : _client.getExperiment(name, defaultParams, decodeOrOpts ?? {});
1521
+ },
1522
+ /** Manually log an exposure for an enrolled experiment. See
1523
+ * {@link FlagsClientBrowser.logExposure}. No-op before configure(). */
1524
+ logExposure(name) {
1525
+ _client?.logExposure(name);
1443
1526
  },
1444
1527
  track(eventName, props) {
1445
1528
  _client?.track(eventName, props);
@@ -682,6 +682,40 @@ function writeAnonCookie(id) {
682
682
  } catch {
683
683
  }
684
684
  }
685
+ var STICKY_COOKIE = "__se_sticky";
686
+ var STICKY_MAX_BYTES = 3800;
687
+ function readStickyCookie() {
688
+ try {
689
+ const m = ("; " + document.cookie).match(/; __se_sticky=([^;]+)/);
690
+ if (!m) return {};
691
+ const parsed = JSON.parse(decodeURIComponent(m[1]));
692
+ if (!parsed || typeof parsed !== "object") return {};
693
+ const out = {};
694
+ for (const [k, v] of Object.entries(parsed)) {
695
+ if (v && typeof v === "object" && typeof v.g === "string") {
696
+ out[k] = { g: v.g, s: String(v.s ?? "") };
697
+ }
698
+ }
699
+ return out;
700
+ } catch {
701
+ return {};
702
+ }
703
+ }
704
+ function writeStickyCookie(map) {
705
+ try {
706
+ let json = JSON.stringify(map);
707
+ if (json.length > STICKY_MAX_BYTES) {
708
+ const entries = Object.entries(map);
709
+ while (entries.length > 0 && JSON.stringify(Object.fromEntries(entries)).length > STICKY_MAX_BYTES) {
710
+ entries.shift();
711
+ }
712
+ json = JSON.stringify(Object.fromEntries(entries));
713
+ }
714
+ const secure = location.protocol === "https:" ? ";secure" : "";
715
+ document.cookie = `${STICKY_COOKIE}=${encodeURIComponent(json)};path=/;max-age=31536000;samesite=lax${secure}`;
716
+ } catch {
717
+ }
718
+ }
685
719
  function getOrCreateAnonId() {
686
720
  let id = readAnonCookie();
687
721
  if (!id && typeof window !== "undefined") {
@@ -835,6 +869,9 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
835
869
  autoGuardrails;
836
870
  autoGuardrailGroups;
837
871
  autoCollectAlways;
872
+ disableAutoExposure;
873
+ privateAttributes;
874
+ stickyBucketing;
838
875
  env;
839
876
  evalResult = null;
840
877
  anonId;
@@ -869,6 +906,9 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
869
906
  this.testMode = opts.testMode === true;
870
907
  this.autoGuardrails = opts.autoGuardrails !== false;
871
908
  this.autoCollectAlways = opts.autoCollectAlways === true;
909
+ this.disableAutoExposure = opts.disableAutoExposure === true;
910
+ this.privateAttributes = opts.privateAttributes ?? [];
911
+ this.stickyBucketing = opts.stickyBucketing !== false;
872
912
  const g = opts.autoGuardrailGroups ?? {};
873
913
  this.autoGuardrailGroups = {
874
914
  vitals: g.vitals ?? this.autoGuardrails,
@@ -934,13 +974,22 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
934
974
  headers: { "X-SDK-Key": this.sdkKey, "Content-Type": "application/json" },
935
975
  body: JSON.stringify({
936
976
  user: userPayload,
937
- experiment_overrides: readExperimentOverridesFromUrl()
977
+ experiment_overrides: readExperimentOverridesFromUrl(),
978
+ // Private attributes still reach the edge for evaluation (unavoidable —
979
+ // the edge evaluates), but the worker never persists them. See doc 20 §5.
980
+ ...this.privateAttributes.length > 0 ? { private_attributes: [...this.privateAttributes] } : {},
981
+ // Sticky state round-trip: send the current cookie map; the worker
982
+ // applies the sticky short-circuit and returns any new assignments.
983
+ ...this.stickyBucketing ? { sticky: readStickyCookie() } : {}
938
984
  })
939
985
  });
940
986
  if (!res.ok) throw new Error(`/sdk/evaluate returned ${res.status}`);
941
987
  const data = await res.json();
942
988
  if (seq !== this.identifySeq) return;
943
989
  this.evalResult = data;
990
+ if (this.stickyBucketing && data.sticky && Object.keys(data.sticky).length > 0) {
991
+ writeStickyCookie({ ...readStickyCookie(), ...data.sticky });
992
+ }
944
993
  const anyGroupOn = this.autoGuardrailGroups.vitals || this.autoGuardrailGroups.errors || this.autoGuardrailGroups.engagement;
945
994
  if (anyGroupOn && !this.guardrailsInstalled) {
946
995
  this.guardrailsInstalled = true;
@@ -1066,7 +1115,9 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1066
1115
  return void 0;
1067
1116
  }
1068
1117
  }
1069
- getExperiment(name, defaultParams, decode, variants) {
1118
+ getExperiment(name, defaultParams, decodeOrOpts, variantsArg) {
1119
+ const opts = typeof decodeOrOpts === "function" ? { decode: decodeOrOpts, variants: variantsArg } : decodeOrOpts ?? {};
1120
+ const { decode, variants } = opts;
1070
1121
  this.telemetry.emit("experiment", name);
1071
1122
  const notIn = {
1072
1123
  inExperiment: false,
@@ -1087,7 +1138,8 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1087
1138
  }
1088
1139
  const entry = this.evalResult?.experiments[name];
1089
1140
  if (!entry || !entry.inExperiment) return notIn;
1090
- this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1141
+ const shouldLog = opts.logExposure ?? !this.disableAutoExposure;
1142
+ if (shouldLog) this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1091
1143
  if (!decode) return { inExperiment: true, group: entry.group, params: entry.params };
1092
1144
  try {
1093
1145
  return { inExperiment: true, group: entry.group, params: decode(entry.params) };
@@ -1096,6 +1148,18 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1096
1148
  return notIn;
1097
1149
  }
1098
1150
  }
1151
+ /**
1152
+ * Manually log an exposure for an enrolled experiment (Statsig's
1153
+ * `manuallyLogExposure`). Reads the cached eval result; if the visitor is in
1154
+ * the experiment, pushes the session-deduped exposure. Pair this with the
1155
+ * render of the treatment when reading with `{ logExposure: false }` (or
1156
+ * `disableAutoExposure: true`). No-op if the visitor isn't enrolled.
1157
+ */
1158
+ logExposure(name) {
1159
+ const entry = this.evalResult?.experiments[name];
1160
+ if (!entry || !entry.inExperiment) return;
1161
+ this.buffer.pushExposure(name, entry.group, this.userId, this.anonId);
1162
+ }
1099
1163
  /**
1100
1164
  * Subscribe to state changes — fires after identify() completes and on
1101
1165
  * `se:override:change` events from the devtools overlay. Returns an
@@ -1131,7 +1195,16 @@ var FlagsClientBrowser = class _FlagsClientBrowser {
1131
1195
  }
1132
1196
  track(eventName, props) {
1133
1197
  if (this.testMode) return;
1134
- this.buffer.pushMetric(eventName, this.userId, this.anonId, props);
1198
+ this.buffer.pushMetric(eventName, this.userId, this.anonId, this.stripPrivate(props));
1199
+ }
1200
+ /** Drop caller-marked private attributes from an outbound props bag. */
1201
+ stripPrivate(props) {
1202
+ if (!props || this.privateAttributes.length === 0) return props;
1203
+ const out = {};
1204
+ for (const [k, v] of Object.entries(props)) {
1205
+ if (!this.privateAttributes.includes(k)) out[k] = v;
1206
+ }
1207
+ return out;
1135
1208
  }
1136
1209
  /**
1137
1210
  * Read a killswitch from the server's evaluated state. Without `switchKey`,
@@ -1277,7 +1350,10 @@ function shipeasy(opts) {
1277
1350
  autoGuardrails: blanket,
1278
1351
  autoGuardrailGroups: groups,
1279
1352
  autoCollectAlways: acObj?.always === true,
1280
- disableTelemetry: opts.disableTelemetry
1353
+ disableTelemetry: opts.disableTelemetry,
1354
+ disableAutoExposure: opts.disableAutoExposure,
1355
+ privateAttributes: opts.privateAttributes,
1356
+ stickyBucketing: opts.stickyBucketing
1281
1357
  });
1282
1358
  injectI18nLoader(opts.clientKey, baseUrl, opts.i18nProfile);
1283
1359
  flags.notifyMounted();
@@ -1385,12 +1461,19 @@ var flags = {
1385
1461
  return void 0;
1386
1462
  }
1387
1463
  },
1388
- getExperiment(name, defaultParams, decode, variants) {
1389
- return _client?.getExperiment(name, defaultParams, decode, variants) ?? {
1464
+ getExperiment(name, defaultParams, decodeOrOpts, variants) {
1465
+ const fallback = {
1390
1466
  inExperiment: false,
1391
1467
  group: "control",
1392
1468
  params: defaultParams
1393
1469
  };
1470
+ if (!_client) return fallback;
1471
+ return typeof decodeOrOpts === "function" ? _client.getExperiment(name, defaultParams, decodeOrOpts, variants) : _client.getExperiment(name, defaultParams, decodeOrOpts ?? {});
1472
+ },
1473
+ /** Manually log an exposure for an enrolled experiment. See
1474
+ * {@link FlagsClientBrowser.logExposure}. No-op before configure(). */
1475
+ logExposure(name) {
1476
+ _client?.logExposure(name);
1394
1477
  },
1395
1478
  track(eventName, props) {
1396
1479
  _client?.track(eventName, props);