@traffical/js-client 0.16.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ const traffical = await createTrafficalClient({
29
29
  orgId: 'org_xxx',
30
30
  projectId: 'proj_xxx',
31
31
  env: 'production',
32
- apiKey: 'pk_xxx',
32
+ apiKey: 'traffical_pk_…',
33
33
  });
34
34
 
35
35
  // Get parameters
@@ -67,7 +67,7 @@ traffical.track('checkout_complete', { value: 1 }, { decisionId: decision.decisi
67
67
  orgId: 'org_xxx',
68
68
  projectId: 'proj_xxx',
69
69
  env: 'production',
70
- apiKey: 'pk_xxx',
70
+ apiKey: 'traffical_pk_…',
71
71
  }).then(function(traffical) {
72
72
  var params = traffical.getParams({
73
73
  context: { userId: 'user_123' },
@@ -167,7 +167,7 @@ The Traffical DevTools debug plugin uses these methods to let developers force p
167
167
  ## Features
168
168
 
169
169
  - **Error Boundary** - SDK errors never crash your app
170
- - **Exposure Deduplication** - Same user/variant = 1 exposure per session
170
+ - **Exposure Deduplication** - Same user/allocation = 1 exposure event per session
171
171
  - **Smart Batching** - Events batched and flushed efficiently
172
172
  - **Beacon on Unload** - Events sent reliably on page close
173
173
  - **Auto Stable ID** - Anonymous user identification via localStorage/cookie
@@ -187,7 +187,7 @@ const traffical = await createTrafficalClient({
187
187
  orgId: 'org_xxx',
188
188
  projectId: 'proj_xxx',
189
189
  env: 'production',
190
- apiKey: 'pk_xxx',
190
+ apiKey: 'traffical_pk_…',
191
191
  plugins: [
192
192
  createDOMBindingPlugin({
193
193
  observeMutations: true, // Watch for DOM changes (SPA support)
@@ -218,7 +218,7 @@ The plugin:
218
218
 
219
219
  ### Redirect Plugin
220
220
 
221
- Run URL split tests (redirect experiments) where visitors are redirected to different landing page variants. The redirect plugin automatically triggers a decision on init, performs the redirect, and sets an attribution cookie. The attribution plugin ensures conversions on the variant page are attributed back to the experiment.
221
+ Run URL split tests (redirect policies) where visitors are redirected to different landing page allocations. The redirect plugin automatically triggers a decision on init, performs the redirect, and sets an attribution cookie. The attribution plugin ensures conversions on the destination page are attributed back to the policy.
222
222
 
223
223
  ```typescript
224
224
  import {
@@ -231,7 +231,7 @@ const traffical = await createTrafficalClient({
231
231
  orgId: 'org_xxx',
232
232
  projectId: 'proj_xxx',
233
233
  env: 'production',
234
- apiKey: 'pk_xxx',
234
+ apiKey: 'traffical_pk_…',
235
235
  plugins: [
236
236
  createRedirectPlugin(),
237
237
  createRedirectAttributionPlugin(),
@@ -242,7 +242,7 @@ const traffical = await createTrafficalClient({
242
242
  // On entry pages, it redirects. On other pages, it's a no-op.
243
243
 
244
244
  // Track goals as usual — the attribution plugin injects
245
- // redirect experiment metadata into every track() call.
245
+ // redirect policy metadata into every track() call.
246
246
  traffical.track('add_to_cart', { value: 29.99 });
247
247
  ```
248
248
 
@@ -281,9 +281,9 @@ Track goal events from a separate GTM tag (e.g., triggered on "Add to Cart" clic
281
281
 
282
282
  1. **Init** — The redirect plugin's `onInitialize` hook receives the client and calls `decide()` automatically.
283
283
 
284
- 2. **Entry page** — `onBeforeDecision` injects `url.pathname` into the context. The policy condition (e.g., `url.pathname startsWith /products/pillow`) matches, `redirect.url` resolves to the variant URL. `onDecision` writes an attribution cookie (`traffical_rdr`) and calls `window.location.replace()`.
284
+ 2. **Entry page** — `onBeforeDecision` injects `url.pathname` into the context. The policy condition (e.g., `url.pathname startsWith /products/pillow`) matches, `redirect.url` resolves to the allocation's URL. `onDecision` writes an attribution cookie (`traffical_rdr`) and calls `window.location.replace()`.
285
285
 
286
- 3. **Variant page** — The SDK loads again, `decide()` runs, but the policy condition doesn't match the new URL, so `redirect.url` stays empty and no redirect happens. The redirect-attribution plugin reads the `traffical_rdr` cookie and injects the experiment metadata into every `track()` call.
286
+ 3. **Destination page** — The SDK loads again, `decide()` runs, but the policy condition doesn't match the new URL, so `redirect.url` stays empty and no redirect happens. The redirect-attribution plugin reads the `traffical_rdr` cookie and injects the policy metadata into every `track()` call.
287
287
 
288
288
  #### Configuration
289
289
 
package/dist/client.d.ts CHANGED
@@ -9,12 +9,26 @@
9
9
  * - Plugin system (P2)
10
10
  * - Auto stable ID for anonymous users
11
11
  */
12
- import { type ConfigBundle, type Context, type DecisionResult, type ParameterValue, type DecideOptions, type GetParamsOptions, type TrackEventOptions, type AssignmentLogger, type TrackableEventLogger, type TrackEventMap, type OnSchemaWarnings } from "@traffical/core";
12
+ import { type ConfigBundle, type Context, type DecisionResult, type ParameterValue, type DecideOptions, type GetParamsOptions, type TrackEventOptions, type AssignmentLogger, type TrackableEventLogger, type TrackEventMap, type OnSchemaWarnings, type OnResolutionError, type SdkDiagnostics } from "@traffical/core";
13
13
  import { type ErrorBoundaryOptions } from "./error-boundary.js";
14
14
  import { type StorageProvider } from "./storage.js";
15
15
  import { type TrafficalPlugin } from "./plugins/index.js";
16
16
  import { type LifecycleProvider } from "./lifecycle.js";
17
17
  export interface TrafficalClientOptions {
18
+ /**
19
+ * What a resolution failure does (default: `"default"` — return the caller's
20
+ * defaults and stamp `metadata.reason: "error"`). `"throw"` rethrows, which
21
+ * is useful in CI and staging.
22
+ *
23
+ * Governs **resolution only**: `assignmentLogger`, `eventLogger`, and
24
+ * `onError` are always contained in either mode.
25
+ */
26
+ onResolutionError?: OnResolutionError;
27
+ /**
28
+ * Called for every contained error, deduplicated per `tag:name:message`.
29
+ * Errors it throws are swallowed.
30
+ */
31
+ onError?: (tag: string, error: Error) => void;
18
32
  /** Organization ID */
19
33
  orgId: string;
20
34
  /** Project ID */
@@ -58,6 +72,10 @@ export interface TrafficalClientOptions {
58
72
  */
59
73
  resolveTimeoutMs?: number;
60
74
  /** Error boundary options */
75
+ /**
76
+ * @deprecated Use the top-level `onError` (and `onResolutionError`) instead.
77
+ * `errorBoundary.onError` is still honored.
78
+ */
61
79
  errorBoundary?: ErrorBoundaryOptions;
62
80
  /** Event batching options */
63
81
  /**
@@ -133,7 +151,7 @@ export interface TrafficalClientOptions {
133
151
  disableCloudEvents?: boolean;
134
152
  /**
135
153
  * When true, assignment logger calls are deduplicated per session
136
- * (same unit+policy+variant won't fire again). Default: true.
154
+ * (same unit+policy+allocation won't fire again). Default: true.
137
155
  */
138
156
  deduplicateAssignmentLogger?: boolean;
139
157
  /**
@@ -160,7 +178,7 @@ export interface TrafficalClientOptions {
160
178
  export declare class TrafficalClient<TEvents extends TrackEventMap = TrackEventMap> {
161
179
  private readonly _options;
162
180
  private _state;
163
- private readonly _errorBoundary;
181
+ private readonly _errorPolicy;
164
182
  private readonly _storage;
165
183
  private readonly _eventLogger;
166
184
  private readonly _exposureDedup;
@@ -237,6 +255,12 @@ export declare class TrafficalClient<TEvents extends TrackEventMap = TrackEventM
237
255
  * parameter is unknown / the bundle is not yet loaded.
238
256
  */
239
257
  getParameterLayerId(key: string): string | null;
258
+ /**
259
+ * Counters for degradation that is otherwise invisible: contained resolution
260
+ * errors, dropped assignment/event log rows, rejected bundles, and the most
261
+ * recent contained error. Monotonic for the life of the client.
262
+ */
263
+ getDiagnostics(): SdkDiagnostics;
240
264
  /**
241
265
  * Resolves parameters with defaults as fallback.
242
266
  */
@@ -251,7 +275,7 @@ export declare class TrafficalClient<TEvents extends TrackEventMap = TrackEventM
251
275
  decide<T extends Record<string, ParameterValue>>(options: DecideOptions<T>): DecisionResult;
252
276
  /**
253
277
  * Tracks an exposure event.
254
- * Automatically deduplicates exposures for the same user/variant.
278
+ * Automatically deduplicates exposure events for the same user/allocation.
255
279
  *
256
280
  * Skips layers marked `attributionOnly` — those were resolved for
257
281
  * attribution/assignment purposes only (no parameters were requested
@@ -342,6 +366,16 @@ export declare class TrafficalClient<TEvents extends TrackEventMap = TrackEventM
342
366
  * Traffical edge batcher (unless cloud events are disabled).
343
367
  */
344
368
  private _dispatchEvent;
369
+ /**
370
+ * Emits assignment rows to the customer's sink.
371
+ *
372
+ * MUST be called OUTSIDE the resolution boundary, and each invocation of the
373
+ * customer callback is guarded individually. Calling it inside `resolve()`
374
+ * made a throwing sink discard the *decision*: the unit was bucketed into
375
+ * treatment and the caller received control, with `getParams()` — which
376
+ * emits nothing — still returning treatment for the same unit. Because a
377
+ * bounded queue throws when full, that biased assignment with traffic load.
378
+ */
345
379
  private _emitAssignmentLogEntries;
346
380
  private _applyOverridesToResult;
347
381
  private _getEffectiveBundle;
@@ -385,7 +419,7 @@ export declare class TrafficalClient<TEvents extends TrackEventMap = TrackEventM
385
419
  * - "cumulative": Collects layers from ALL cached decisions for this unit,
386
420
  * deduplicated by layerId:policyId (last-write-wins). This ensures cross-page
387
421
  * funnels (e.g., catalog -> PDP -> checkout) attribute correctly to all
388
- * experiments the user is exposed to. For per-entity dynamic allocation
422
+ * policies the user is exposed to. For per-entity dynamic allocation
389
423
  * policies, only the most recent allocation is kept to avoid attributing
390
424
  * rewards to allocations from other entities (e.g., different products).
391
425
  *
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,OAAO,EACZ,KAAK,cAAc,EAEnB,KAAK,cAAc,EAKnB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EAItB,KAAK,gBAAgB,EAGrB,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,iBAAiB,EACtB,KAAK,cAAc,EAYpB,MAAM,iBAAiB,CAAC;AAQzB,OAAO,EAAE,KAAK,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAIhE,OAAO,EAAyB,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAC3E,OAAO,EAAiB,KAAK,eAAe,EAAgC,MAAM,oBAAoB,CAAC;AACvG,OAAO,EAAkC,KAAK,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AA8CxF,MAAM,WAAW,sBAAsB;IACrC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IACtC;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAC9C,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,GAAG,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,YAAY,CAAC;IAC3B,wDAAwD;IACxD,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6BAA6B;IAC7B;;;OAGG;IACH,aAAa,CAAC,EAAE,oBAAoB,CAAC;IACrC,6BAA6B;IAC7B;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,oFAAoF;IACpF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uGAAuG;IACvG,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,yCAAyC;IACzC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB;;;OAGG;IACH,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,kCAAkC;IAClC,OAAO,CAAC,EAAE,eAAe,EAAE,CAAC;IAC5B,sDAAsD;IACtD,OAAO,CAAC,EAAE,eAAe,CAAC;IAC1B,6CAA6C;IAC7C,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,YAAY,GAAG,UAAU,CAAC;IAC5C;;;;OAIG;IACH,cAAc,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IACrC,mFAAmF;IACnF,iBAAiB,CAAC,EAAE,iBAAiB,CAAC;IAEtC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IAEpC;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAE7B;;;OAGG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;IAEtC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,oBAAoB,CAAC;IAEnC;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;CACrC;AAoBD,qBAAa,eAAe,CAAC,OAAO,SAAS,aAAa,GAAG,aAAa;IACxE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAMvB;IAEF,OAAO,CAAC,MAAM,CAUZ;IAEF,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAc;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAkB;IAC3C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAc;IAC3C,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAuB;IACtD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAmB;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAgB;IACzC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAoB;IACvD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAwB;IACxD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAS;IAC3C,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAmB;IACtD,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC,CAAuB;IACxD,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAU;IAC9C,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAA8B;IACrE,8EAA8E;IAC9E,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA0C;IACzE;;;;;;OAMG;IACH,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAyD;IAChG,OAAO,CAAC,kBAAkB,CAAwC;IAClE,OAAO,CAAC,kBAAkB,CAAkE;IAC5F,OAAO,CAAC,UAAU,CAAsC;IACxD,8EAA8E;IAC9E,OAAO,CAAC,sBAAsB,CAAuB;IACrD,yEAAyE;IACzE,OAAO,CAAC,aAAa,CAAc;IACnC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAE3B;gBAES,OAAO,EAAE,sBAAsB;IAmJ3C;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IA4BjC;;OAEG;IACH,IAAI,aAAa,IAAI,OAAO,CAE3B;IAED;;;OAGG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;IAInC;;;;OAIG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IA8B5B;;;;;OAKG;IACH,OAAO,IAAI,IAAI;IAoCf;;OAEG;IACG,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC;IAUpC;;OAEG;IACH,gBAAgB,IAAI,MAAM,GAAG,IAAI;IAIjC;;;;OAIG;IACH,eAAe,IAAI,MAAM,GAAG,IAAI;IAIhC;;;OAGG;IACH,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAQ/C;;;;OAIG;IACH,cAAc,IAAI,cAAc;IAIhC;;OAEG;IACH,SAAS,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,CAAC;IACrF,iFAAiF;IACjF,SAAS,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,OAAO,EAAE,gBAAgB,CAAC,CAAC,CAAC,GAAG,CAAC;IAuCpF;;OAEG;IACH,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAG,cAAc;IAC/F,iFAAiF;IACjF,MAAM,CAAC,CAAC,SAAS,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,cAAc;IA2F3F;;;;;;;OAOG;IACH,aAAa,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI;IAiE7C;;;;;;;;;;;;;;;;OAgBG;IACH,KAAK,CAAC,CAAC,SAAS,OAAO,CAAC,MAAM,OAAO,EAAE,MAAM,CAAC,EAC5C,SAAS,EAAE,CAAC,EACZ,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,EACvB,OAAO,CAAC,EAAE,iBAAiB,GAC1B,IAAI;IA+CP;;OAEG;IACG,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAUlC;;;;OAIG;IACH,GAAG,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI;IAsBlC;;OAEG;IACH,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS;IAQpD;;OAEG;IACH,WAAW,IAAI,MAAM;IAIrB;;;;OAIG;IACH,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAI7B;;;;;;;;OAQG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAW/B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI;IAO3D;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,EAAE,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,KAAK,IAAI,GAAG,MAAM,IAAI;IAWtF;;;;;OAKG;IACH,cAAc,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAAG,IAAI;IAK/D;;OAEG;IACH,cAAc,IAAI,IAAI;IAKtB;;OAEG;IACH,YAAY,IAAI,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC;IAQ9C,OAAO,CAAC,wBAAwB;IAWhC;;;OAGG;IACH,OAAO,CAAC,cAAc;IAatB;;;;;;;;;OASG;IACH,OAAO,CAAC,yBAAyB;IAyDjC,OAAO,CAAC,uBAAuB;IAU/B,OAAO,CAAC,mBAAmB;IAI3B,OAAO,CAAC,cAAc;YAeR,YAAY;IAuE1B,OAAO,CAAC,uBAAuB;IAyB/B;;;;;;OAMG;IACH,OAAO,CAAC,uBAAuB;YAajB,mBAAmB;IAejC;;OAEG;IACH,OAAO,CAAC,iBAAiB;IAezB;;;OAGG;YACW,oBAAoB;IAqDlC,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,0BAA0B;IAUlC;;;OAGG;IACH,OAAO,CAAC,cAAc;IAYtB;;;;OAIG;IACH,OAAO,CAAC,4BAA4B;IAyBpC;;;;;;;;;;;;OAYG;IACH,OAAO,CAAC,iBAAiB;CA6B1B;AAMD;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,OAAO,SAAS,aAAa,GAAG,aAAa,EAAE,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC,CAI7J;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,SAAS,aAAa,GAAG,aAAa,EAAE,OAAO,EAAE,sBAAsB,GAAG,eAAe,CAAC,OAAO,CAAC,CAElJ"}
package/dist/client.js CHANGED
@@ -9,9 +9,8 @@
9
9
  * - Plugin system (P2)
10
10
  * - Auto stable ID for anonymous users
11
11
  */
12
- import { resolveParameters, decide as coreDecide, getUnitKeyValue, getUnitKeyField as coreGetUnitKeyField, getParameterLayerId as coreGetParameterLayerId, generateExposureId, generateTrackEventId, generateDecisionId, generateAssignmentId, } from "@traffical/core";
12
+ import { ErrorPolicy, validateConfigBundle, resolveParameters, decide as coreDecide, getUnitKeyValue, getUnitKeyField as coreGetUnitKeyField, getParameterLayerId as coreGetParameterLayerId, generateExposureId, generateTrackEventId, generateDecisionId, generateAssignmentId, } from "@traffical/core";
13
13
  import { DecisionClient, createEdgeDecideRequest, } from "@traffical/core-io";
14
- import { ErrorBoundary } from "./error-boundary.js";
15
14
  import { EventLogger } from "./event-logger.js";
16
15
  import { ExposureDeduplicator } from "./exposure-dedup.js";
17
16
  import { StableIdProvider } from "./stable-id.js";
@@ -49,33 +48,6 @@ function jitteredInterval(intervalMs) {
49
48
  const delta = intervalMs * REFRESH_JITTER_RATIO;
50
49
  return intervalMs + (Math.random() * 2 - 1) * delta;
51
50
  }
52
- /**
53
- * Structural guard for a fetched config bundle. A 200 response can still carry a
54
- * malformed body (truncated CDN write, partial deploy); serving it would corrupt
55
- * every bucket assignment. Requires the hashing config the resolver depends on
56
- * (`unitKey` non-empty, `bucketCount` an integer >= 1) plus the top-level
57
- * parameters/layers arrays.
58
- */
59
- function isValidConfigBundle(bundle) {
60
- if (!bundle || typeof bundle !== "object")
61
- return false;
62
- const b = bundle;
63
- if (!Array.isArray(b.parameters))
64
- return false;
65
- if (!Array.isArray(b.layers))
66
- return false;
67
- const hashing = b.hashing;
68
- if (!hashing || typeof hashing !== "object")
69
- return false;
70
- if (typeof hashing.unitKey !== "string" || hashing.unitKey.length === 0)
71
- return false;
72
- if (typeof hashing.bucketCount !== "number" ||
73
- !Number.isInteger(hashing.bucketCount) ||
74
- hashing.bucketCount < 1) {
75
- return false;
76
- }
77
- return true;
78
- }
79
51
  // =============================================================================
80
52
  // TrafficalClient Class
81
53
  // =============================================================================
@@ -138,7 +110,11 @@ export class TrafficalClient {
138
110
  };
139
111
  this._decisionClient = new DecisionClient(decisionClientConfig);
140
112
  // Initialize components
141
- this._errorBoundary = new ErrorBoundary(options.errorBoundary);
113
+ this._errorPolicy = new ErrorPolicy({
114
+ onResolutionError: options.onResolutionError,
115
+ // `errorBoundary.onError` remains supported as the legacy spelling.
116
+ onError: options.onError ?? options.errorBoundary?.onError,
117
+ });
142
118
  this._storage = options.storage ?? createStorageProvider();
143
119
  this._lifecycleProvider = options.lifecycleProvider ?? createBrowserLifecycleProvider();
144
120
  // Default dev-mode schema warnings handler
@@ -209,11 +185,20 @@ export class TrafficalClient {
209
185
  this._plugins.register(plugin);
210
186
  }
211
187
  }
212
- // Initialize with local config if provided
188
+ // Initialize with local config if provided.
189
+ // Validated on exactly the same terms as a fetched bundle: a hand-edited
190
+ // local bundle is no more trustworthy than one off the wire.
213
191
  if (this._options.localConfig) {
214
- this._state.bundle = this._options.localConfig;
215
- // Notify plugins about the local config
216
- this._plugins.runConfigUpdate(this._options.localConfig);
192
+ const validation = validateConfigBundle(this._options.localConfig);
193
+ if (validation.ok) {
194
+ this._state.bundle = this._options.localConfig;
195
+ // Notify plugins about the local config
196
+ this._plugins.runConfigUpdate(this._options.localConfig);
197
+ }
198
+ else {
199
+ this._errorPolicy.recordRejectedBundle("localConfig", validation.path, validation.reason);
200
+ this._options.localConfig = undefined;
201
+ }
217
202
  }
218
203
  // Register on global instance list so DevTools can discover ES-module SDKs
219
204
  if (typeof window !== "undefined") {
@@ -229,7 +214,14 @@ export class TrafficalClient {
229
214
  * Initializes the client by fetching the config bundle.
230
215
  */
231
216
  async initialize() {
232
- await this._errorBoundary.captureAsync("initialize", async () => {
217
+ // Initialization is contained unconditionally, NOT under
218
+ // onResolutionError. `_readyResolve()` below must always run: rethrowing
219
+ // here would leave `waitForReady()` pending forever, and it would do so
220
+ // only in strict mode — the mode people enable in CI. Initialization
221
+ // strictness is a separate phase decision (cf. Eppo's
222
+ // `throwOnFailedInitialization`, also defaulting to false); resolution
223
+ // strictness still applies to decide()/getParams().
224
+ await this._errorPolicy.sideEffectAsync("initialize", "other", async () => {
233
225
  if (this._options.evaluationMode === "server") {
234
226
  await this._fetchServerResolve();
235
227
  }
@@ -240,7 +232,7 @@ export class TrafficalClient {
240
232
  this._state.isInitialized = true;
241
233
  // Run plugin onInitialize hooks (pass client reference for autonomous plugins)
242
234
  await this._plugins.runInitialize(this);
243
- }, undefined);
235
+ });
244
236
  // Fail-open: resolve readiness even if the config load errored/degraded.
245
237
  this._readyResolve();
246
238
  }
@@ -332,7 +324,7 @@ export class TrafficalClient {
332
324
  * Manually refreshes the config bundle.
333
325
  */
334
326
  async refreshConfig() {
335
- await this._errorBoundary.swallow("refreshConfig", async () => {
327
+ await this._errorPolicy.sideEffectAsync("refreshConfig", "other", async () => {
336
328
  if (this._options.evaluationMode === "server") {
337
329
  await this._fetchServerResolve();
338
330
  }
@@ -362,9 +354,20 @@ export class TrafficalClient {
362
354
  getParameterLayerId(key) {
363
355
  return coreGetParameterLayerId(this._getEffectiveBundle(), key);
364
356
  }
357
+ // ===========================================================================
358
+ // Parameter Resolution
359
+ // ===========================================================================
360
+ /**
361
+ * Counters for degradation that is otherwise invisible: contained resolution
362
+ * errors, dropped assignment/event log rows, rejected bundles, and the most
363
+ * recent contained error. Monotonic for the life of the client.
364
+ */
365
+ getDiagnostics() {
366
+ return this._errorPolicy.getDiagnostics();
367
+ }
365
368
  getParams(contextOrOptions, maybeDefaults) {
366
369
  const { context: rawContext, defaults } = normalizeEvalArgs(contextOrOptions, maybeDefaults);
367
- return this._errorBoundary.capture("getParams", () => {
370
+ return this._errorPolicy.resolve("getParams", () => {
368
371
  // Server mode: return from cached server response
369
372
  if (this._options.evaluationMode === "server" && this._state.serverResponse) {
370
373
  this._maybeResolveForContext(rawContext);
@@ -386,11 +389,11 @@ export class TrafficalClient {
386
389
  // Apply parameter overrides (post-resolution, post-plugin)
387
390
  this._applyOverridesToResult(params);
388
391
  return params;
389
- }, defaults);
392
+ }, () => ({ ...defaults }));
390
393
  }
391
394
  decide(contextOrOptions, maybeDefaults) {
392
395
  const { context: rawContext, defaults } = normalizeEvalArgs(contextOrOptions, maybeDefaults);
393
- return this._errorBoundary.capture("decide", () => {
396
+ const decision = this._errorPolicy.resolve("decide", () => {
394
397
  // Server mode: return from cached server response
395
398
  if (this._options.evaluationMode === "server" && this._state.serverResponse) {
396
399
  this._maybeResolveForContext(rawContext);
@@ -415,7 +418,9 @@ export class TrafficalClient {
415
418
  this._updateCumulativeAttribution(decision);
416
419
  this._plugins.runDecision(decision);
417
420
  this._applyOverridesToResult(decision.assignments);
418
- this._emitAssignmentLogEntries(decision, "decision");
421
+ decision.metadata.reason = resp.metadata.layers?.some((l) => l.policyId)
422
+ ? "resolved"
423
+ : "default";
419
424
  return decision;
420
425
  }
421
426
  const bundle = this._getEffectiveBundle();
@@ -433,36 +438,45 @@ export class TrafficalClient {
433
438
  this._plugins.runDecision(decision);
434
439
  // Apply parameter overrides (post-resolution, post-plugin)
435
440
  this._applyOverridesToResult(decision.assignments);
436
- this._emitAssignmentLogEntries(decision, "decision");
441
+ decision.metadata.reason = !bundle
442
+ ? "no-bundle"
443
+ : decision.metadata.layers.some((l) => l.policyId)
444
+ ? "resolved"
445
+ : "default";
437
446
  return decision;
438
- }, {
447
+ }, () => ({
439
448
  decisionId: generateDecisionId(),
440
- assignments: defaults,
449
+ assignments: { ...defaults },
441
450
  metadata: {
442
451
  timestamp: new Date().toISOString(),
443
452
  unitKeyValue: "",
444
453
  layers: [],
454
+ reason: "error",
445
455
  },
446
- });
456
+ }));
457
+ // Assignment rows are emitted OUTSIDE the boundary. A throwing sink must
458
+ // cost a log row, never the decision the caller just received.
459
+ this._emitAssignmentLogEntries(decision, "decision");
460
+ return decision;
447
461
  }
448
462
  // ===========================================================================
449
463
  // Event Tracking
450
464
  // ===========================================================================
451
465
  /**
452
466
  * Tracks an exposure event.
453
- * Automatically deduplicates exposures for the same user/variant.
467
+ * Automatically deduplicates exposure events for the same user/allocation.
454
468
  *
455
469
  * Skips layers marked `attributionOnly` — those were resolved for
456
470
  * attribution/assignment purposes only (no parameters were requested
457
471
  * from that layer) and should not count as exposures.
458
472
  */
459
473
  trackExposure(decision) {
460
- this._errorBoundary.capture("trackExposure", () => {
474
+ // Outside the boundary, for the same reason as decide().
475
+ this._emitAssignmentLogEntries(decision, "exposure");
476
+ this._errorPolicy.resolve("trackExposure", () => {
461
477
  const unitKey = decision.metadata.unitKeyValue;
462
478
  if (!unitKey)
463
479
  return;
464
- // Emit to assignment logger (separate from cloud events)
465
- this._emitAssignmentLogEntries(decision, "exposure");
466
480
  // Config bundle version the SDK evaluated against — from the
467
481
  // decision-time snapshot. The current version is only a fallback for
468
482
  // decisions that predate the snapshot field.
@@ -508,7 +522,7 @@ export class TrafficalClient {
508
522
  if (!this._plugins.runExposure(event))
509
523
  return;
510
524
  this._dispatchEvent(event);
511
- }, undefined);
525
+ }, () => undefined);
512
526
  }
513
527
  /**
514
528
  * Tracks a user event.
@@ -528,7 +542,7 @@ export class TrafficalClient {
528
542
  * client.track('checkout_complete', { value: 1 }, { decisionId: decision.decisionId });
529
543
  */
530
544
  track(eventName, properties, options) {
531
- this._errorBoundary.capture("track", () => {
545
+ this._errorPolicy.resolve("track", () => {
532
546
  const unitKey = options?.unitKey ?? this._stableId.getId();
533
547
  // Single numeric value: explicit options.value wins, else properties.value.
534
548
  const value = typeof options?.value === "number"
@@ -562,13 +576,13 @@ export class TrafficalClient {
562
576
  return;
563
577
  }
564
578
  this._dispatchEvent(event);
565
- }, undefined);
579
+ }, () => undefined);
566
580
  }
567
581
  /**
568
582
  * Flush pending events immediately.
569
583
  */
570
584
  async flushEvents() {
571
- await this._errorBoundary.swallow("flushEvents", async () => {
585
+ await this._errorPolicy.sideEffectAsync("flushEvents", "other", async () => {
572
586
  await this._eventLogger.flush();
573
587
  });
574
588
  }
@@ -723,6 +737,16 @@ export class TrafficalClient {
723
737
  this._eventLogger.log(event);
724
738
  }
725
739
  }
740
+ /**
741
+ * Emits assignment rows to the customer's sink.
742
+ *
743
+ * MUST be called OUTSIDE the resolution boundary, and each invocation of the
744
+ * customer callback is guarded individually. Calling it inside `resolve()`
745
+ * made a throwing sink discard the *decision*: the unit was bucketed into
746
+ * treatment and the caller received control, with `getParams()` — which
747
+ * emits nothing — still returning treatment for the same unit. Because a
748
+ * bounded queue throws when full, that biased assignment with traffic load.
749
+ */
726
750
  _emitAssignmentLogEntries(decision, type) {
727
751
  if (!this._assignmentLogger)
728
752
  return;
@@ -741,11 +765,16 @@ export class TrafficalClient {
741
765
  if (!isNew)
742
766
  continue;
743
767
  }
744
- this._assignmentLogger({
768
+ // Tier 1: the customer's sink must never change a caller's answer.
769
+ // Guarded HERE, narrowly, rather than relying on the surrounding
770
+ // resolution boundary — see the note on _emitAssignmentLogEntries.
771
+ const policyId = layer.policyId;
772
+ const allocationName = layer.allocationName;
773
+ this._errorPolicy.sideEffect("assignmentLogger", "assignmentLog", () => this._assignmentLogger({
745
774
  unitKey,
746
- policyId: layer.policyId,
775
+ policyId,
747
776
  policyKey: layer.policyKey,
748
- allocationName: layer.allocationName,
777
+ allocationName,
749
778
  allocationKey: layer.allocationKey,
750
779
  timestamp: decision.metadata.timestamp,
751
780
  layerId: layer.layerId,
@@ -764,7 +793,7 @@ export class TrafficalClient {
764
793
  probability: layer.probability,
765
794
  modelVersion: layer.modelVersion,
766
795
  configVersion,
767
- });
796
+ }));
768
797
  }
769
798
  }
770
799
  _applyOverridesToResult(target) {
@@ -825,7 +854,9 @@ export class TrafficalClient {
825
854
  // A 200 can still carry a malformed body. Discard it and keep the
826
855
  // previous last-good bundle rather than corrupting bucket assignments or
827
856
  // falling through to defaults when we already have a valid config.
828
- if (!isValidConfigBundle(bundle)) {
857
+ const validation = validateConfigBundle(bundle);
858
+ if (!validation.ok) {
859
+ this._errorPolicy.recordRejectedBundle("fetchConfig", validation.path, validation.reason);
829
860
  this._logMalformedBundleWarning();
830
861
  return;
831
862
  }
@@ -1034,7 +1065,7 @@ export class TrafficalClient {
1034
1065
  * - "cumulative": Collects layers from ALL cached decisions for this unit,
1035
1066
  * deduplicated by layerId:policyId (last-write-wins). This ensures cross-page
1036
1067
  * funnels (e.g., catalog -> PDP -> checkout) attribute correctly to all
1037
- * experiments the user is exposed to. For per-entity dynamic allocation
1068
+ * policies the user is exposed to. For per-entity dynamic allocation
1038
1069
  * policies, only the most recent allocation is kept to avoid attributing
1039
1070
  * rewards to allocations from other entities (e.g., different products).
1040
1071
  *