@traffical/js-client 0.14.0 → 0.16.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 +8 -2
- package/dist/client.d.ts +93 -13
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +268 -59
- package/dist/client.js.map +1 -1
- package/dist/event-logger.d.ts +36 -0
- package/dist/event-logger.d.ts.map +1 -1
- package/dist/event-logger.js +88 -13
- package/dist/event-logger.js.map +1 -1
- package/dist/plugins/decision-tracking.d.ts +7 -0
- package/dist/plugins/decision-tracking.d.ts.map +1 -1
- package/dist/plugins/decision-tracking.js +3 -0
- package/dist/plugins/decision-tracking.js.map +1 -1
- package/dist/plugins/warehouse-native-logger.d.ts.map +1 -1
- package/dist/plugins/warehouse-native-logger.js +4 -0
- package/dist/plugins/warehouse-native-logger.js.map +1 -1
- package/dist/traffical.min.js +2 -2
- package/dist/traffical.min.js.map +3 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -5
package/dist/client.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - Plugin system (P2)
|
|
10
10
|
* - Auto stable ID for anonymous users
|
|
11
11
|
*/
|
|
12
|
-
import { resolveParameters, decide as coreDecide, getUnitKeyValue, generateExposureId, generateTrackEventId, generateDecisionId, generateAssignmentId, } from "@traffical/core";
|
|
12
|
+
import { 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
14
|
import { ErrorBoundary } from "./error-boundary.js";
|
|
15
15
|
import { EventLogger } from "./event-logger.js";
|
|
@@ -23,10 +23,59 @@ import { SDK_VERSION } from "./version.js";
|
|
|
23
23
|
// Constants
|
|
24
24
|
// =============================================================================
|
|
25
25
|
const SDK_NAME = "js-client";
|
|
26
|
+
/**
|
|
27
|
+
* Normalizes evaluation-method arguments so both the canonical positional form
|
|
28
|
+
* `decide(context, defaults)` and the legacy object-bag form
|
|
29
|
+
* `decide({ context, defaults })` work. Positional is detected by the presence
|
|
30
|
+
* of the second (`defaults`) argument; the bag form is soft-deprecated.
|
|
31
|
+
*/
|
|
32
|
+
function normalizeEvalArgs(contextOrOptions, maybeDefaults) {
|
|
33
|
+
if (maybeDefaults !== undefined) {
|
|
34
|
+
return { context: contextOrOptions, defaults: maybeDefaults };
|
|
35
|
+
}
|
|
36
|
+
const bag = contextOrOptions;
|
|
37
|
+
return { context: bag.context, defaults: bag.defaults };
|
|
38
|
+
}
|
|
26
39
|
const DEFAULT_BASE_URL = "https://sdk.traffical.io";
|
|
27
40
|
const DEFAULT_REFRESH_INTERVAL_MS = 60000; // 1 minute
|
|
41
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10000; // 10 seconds
|
|
28
42
|
const OFFLINE_WARNING_INTERVAL_MS = 300000; // 5 minutes
|
|
43
|
+
const MALFORMED_BUNDLE_WARNING_INTERVAL_MS = 300000; // 5 minutes
|
|
29
44
|
const DECISION_CACHE_MAX_SIZE = 100; // Max decisions to cache for attribution lookup
|
|
45
|
+
/** +/-10% jitter on the background refresh interval to avoid thundering-herd sync. */
|
|
46
|
+
const REFRESH_JITTER_RATIO = 0.1;
|
|
47
|
+
/** Returns `intervalMs` perturbed by uniform +/-REFRESH_JITTER_RATIO jitter. */
|
|
48
|
+
function jitteredInterval(intervalMs) {
|
|
49
|
+
const delta = intervalMs * REFRESH_JITTER_RATIO;
|
|
50
|
+
return intervalMs + (Math.random() * 2 - 1) * delta;
|
|
51
|
+
}
|
|
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
|
+
}
|
|
30
79
|
// =============================================================================
|
|
31
80
|
// TrafficalClient Class
|
|
32
81
|
// =============================================================================
|
|
@@ -37,6 +86,7 @@ export class TrafficalClient {
|
|
|
37
86
|
etag: null,
|
|
38
87
|
lastFetchTime: 0,
|
|
39
88
|
lastOfflineWarning: 0,
|
|
89
|
+
lastMalformedWarning: 0,
|
|
40
90
|
refreshTimer: null,
|
|
41
91
|
isInitialized: false,
|
|
42
92
|
serverResponse: null,
|
|
@@ -55,6 +105,11 @@ export class TrafficalClient {
|
|
|
55
105
|
this._identityListeners = [];
|
|
56
106
|
this._overrideListeners = [];
|
|
57
107
|
this._overrides = {};
|
|
108
|
+
/** Serialized context of the last server-mode resolve (per-call throttle). */
|
|
109
|
+
this._lastResolveContextKey = null;
|
|
110
|
+
this._readyPromise = new Promise((resolve) => {
|
|
111
|
+
this._readyResolve = resolve;
|
|
112
|
+
});
|
|
58
113
|
const evaluationMode = options.evaluationMode ?? "bundle";
|
|
59
114
|
this._options = {
|
|
60
115
|
orgId: options.orgId,
|
|
@@ -67,6 +122,9 @@ export class TrafficalClient {
|
|
|
67
122
|
attributionMode: options.attributionMode ?? "cumulative",
|
|
68
123
|
evaluationMode,
|
|
69
124
|
};
|
|
125
|
+
// Config-fetch timeout: canonical configTimeoutMs wins, else legacy requestTimeoutMs.
|
|
126
|
+
this._requestTimeoutMs =
|
|
127
|
+
options.configTimeoutMs ?? options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
70
128
|
// Create DecisionClient when needed (server mode, or bundle mode may use for edge policies)
|
|
71
129
|
const decisionClientConfig = {
|
|
72
130
|
baseUrl: this._options.baseUrl,
|
|
@@ -74,6 +132,9 @@ export class TrafficalClient {
|
|
|
74
132
|
projectId: this._options.projectId,
|
|
75
133
|
env: this._options.env,
|
|
76
134
|
apiKey: this._options.apiKey,
|
|
135
|
+
// Server-resolve timeout: canonical resolveTimeoutMs wins, else legacy
|
|
136
|
+
// requestTimeoutMs; undefined lets DecisionClient apply its own 5s default.
|
|
137
|
+
defaultTimeoutMs: options.resolveTimeoutMs ?? options.requestTimeoutMs,
|
|
77
138
|
};
|
|
78
139
|
this._decisionClient = new DecisionClient(decisionClientConfig);
|
|
79
140
|
// Initialize components
|
|
@@ -102,8 +163,12 @@ export class TrafficalClient {
|
|
|
102
163
|
apiKey: options.apiKey,
|
|
103
164
|
storage: this._storage,
|
|
104
165
|
lifecycleProvider: this._lifecycleProvider,
|
|
105
|
-
batchSize
|
|
106
|
-
|
|
166
|
+
// Canonical batchSize/flushIntervalMs win over legacy event* names.
|
|
167
|
+
batchSize: options.batchSize ?? options.eventBatchSize,
|
|
168
|
+
flushIntervalMs: options.flushIntervalMs ?? options.eventFlushIntervalMs,
|
|
169
|
+
maxQueueSize: options.eventMaxQueueSize,
|
|
170
|
+
// Event-delivery timeout: canonical eventsTimeoutMs wins, else legacy requestTimeoutMs.
|
|
171
|
+
requestTimeoutMs: options.eventsTimeoutMs ?? options.requestTimeoutMs,
|
|
107
172
|
onError: (error) => {
|
|
108
173
|
console.warn("[Traffical] Event logging error:", error.message);
|
|
109
174
|
},
|
|
@@ -133,6 +198,7 @@ export class TrafficalClient {
|
|
|
133
198
|
projectId: this._options.projectId,
|
|
134
199
|
env: this._options.env,
|
|
135
200
|
log: (event) => this._dispatchEvent(event),
|
|
201
|
+
getConfigVersion: () => this.getConfigVersion(),
|
|
136
202
|
}),
|
|
137
203
|
priority: 100, // High priority so it runs before user plugins
|
|
138
204
|
});
|
|
@@ -175,6 +241,8 @@ export class TrafficalClient {
|
|
|
175
241
|
// Run plugin onInitialize hooks (pass client reference for autonomous plugins)
|
|
176
242
|
await this._plugins.runInitialize(this);
|
|
177
243
|
}, undefined);
|
|
244
|
+
// Fail-open: resolve readiness even if the config load errored/degraded.
|
|
245
|
+
this._readyResolve();
|
|
178
246
|
}
|
|
179
247
|
/**
|
|
180
248
|
* Check if the client is initialized.
|
|
@@ -182,8 +250,51 @@ export class TrafficalClient {
|
|
|
182
250
|
get isInitialized() {
|
|
183
251
|
return this._state.isInitialized;
|
|
184
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* Resolves once the first usable config has loaded (or the SDK has failed
|
|
255
|
+
* open on an unavailable/malformed bundle). Never rejects.
|
|
256
|
+
*/
|
|
257
|
+
async waitForReady() {
|
|
258
|
+
return this._readyPromise;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Single teardown verb (spec 0.7.0 design contract). Awaits a final event
|
|
262
|
+
* flush before returning; on page unload it falls back to sendBeacon so the
|
|
263
|
+
* final batch still ships. Prefer this over destroy()/destroySync().
|
|
264
|
+
*/
|
|
265
|
+
async close() {
|
|
266
|
+
if (this._state.refreshTimer) {
|
|
267
|
+
clearInterval(this._state.refreshTimer);
|
|
268
|
+
this._state.refreshTimer = null;
|
|
269
|
+
}
|
|
270
|
+
// Await the final flush in the normal path; fall back to a beacon on unload
|
|
271
|
+
// (a normal async flush can't complete as the page tears down).
|
|
272
|
+
if (this._lifecycleProvider.isUnloading()) {
|
|
273
|
+
this._eventLogger.flushBeacon();
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
await this._eventLogger.flush().catch(() => { });
|
|
277
|
+
}
|
|
278
|
+
this._eventLogger.destroy();
|
|
279
|
+
this._plugins.runDestroy();
|
|
280
|
+
this._identityListeners = [];
|
|
281
|
+
this._overrideListeners = [];
|
|
282
|
+
this._overrides = {};
|
|
283
|
+
if (typeof window !== "undefined") {
|
|
284
|
+
const w = window;
|
|
285
|
+
const instances = w.__TRAFFICAL_INSTANCES__;
|
|
286
|
+
if (instances) {
|
|
287
|
+
const idx = instances.indexOf(this);
|
|
288
|
+
if (idx !== -1)
|
|
289
|
+
instances.splice(idx, 1);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
185
293
|
/**
|
|
186
294
|
* Stops background refresh and cleans up resources.
|
|
295
|
+
*
|
|
296
|
+
* @deprecated Use {@link close} instead — the canonical single teardown verb
|
|
297
|
+
* that awaits the final flush.
|
|
187
298
|
*/
|
|
188
299
|
destroy() {
|
|
189
300
|
if (this._state.refreshTimer) {
|
|
@@ -236,17 +347,28 @@ export class TrafficalClient {
|
|
|
236
347
|
getConfigVersion() {
|
|
237
348
|
return this._state.serverResponse?.stateVersion ?? this._state.bundle?.version ?? null;
|
|
238
349
|
}
|
|
239
|
-
// ===========================================================================
|
|
240
|
-
// Parameter Resolution
|
|
241
|
-
// ===========================================================================
|
|
242
350
|
/**
|
|
243
|
-
*
|
|
351
|
+
* Returns the context field the bundle buckets on (the project's unit key),
|
|
352
|
+
* or null before the bundle has loaded. Adapters (e.g. an OpenFeature
|
|
353
|
+
* provider) map their targeting key onto this field.
|
|
354
|
+
*/
|
|
355
|
+
getUnitKeyField() {
|
|
356
|
+
return coreGetUnitKeyField(this._getEffectiveBundle());
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Returns the id of the layer a parameter belongs to, or null if the
|
|
360
|
+
* parameter is unknown / the bundle is not yet loaded.
|
|
244
361
|
*/
|
|
245
|
-
|
|
362
|
+
getParameterLayerId(key) {
|
|
363
|
+
return coreGetParameterLayerId(this._getEffectiveBundle(), key);
|
|
364
|
+
}
|
|
365
|
+
getParams(contextOrOptions, maybeDefaults) {
|
|
366
|
+
const { context: rawContext, defaults } = normalizeEvalArgs(contextOrOptions, maybeDefaults);
|
|
246
367
|
return this._errorBoundary.capture("getParams", () => {
|
|
247
368
|
// Server mode: return from cached server response
|
|
248
369
|
if (this._options.evaluationMode === "server" && this._state.serverResponse) {
|
|
249
|
-
|
|
370
|
+
this._maybeResolveForContext(rawContext);
|
|
371
|
+
const result = { ...defaults };
|
|
250
372
|
for (const [key, value] of Object.entries(this._state.serverResponse.assignments)) {
|
|
251
373
|
if (key in result) {
|
|
252
374
|
result[key] = value;
|
|
@@ -257,33 +379,37 @@ export class TrafficalClient {
|
|
|
257
379
|
return result;
|
|
258
380
|
}
|
|
259
381
|
const bundle = this._getEffectiveBundle();
|
|
260
|
-
const context = this._enrichContext(
|
|
261
|
-
const params = resolveParameters(bundle, context,
|
|
382
|
+
const context = this._enrichContext(rawContext);
|
|
383
|
+
const params = resolveParameters(bundle, context, defaults);
|
|
262
384
|
// Run plugin onResolve hooks (e.g., DOM binding plugin)
|
|
263
385
|
this._plugins.runResolve(params);
|
|
264
386
|
// Apply parameter overrides (post-resolution, post-plugin)
|
|
265
387
|
this._applyOverridesToResult(params);
|
|
266
388
|
return params;
|
|
267
|
-
},
|
|
389
|
+
}, defaults);
|
|
268
390
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
*/
|
|
272
|
-
decide(options) {
|
|
391
|
+
decide(contextOrOptions, maybeDefaults) {
|
|
392
|
+
const { context: rawContext, defaults } = normalizeEvalArgs(contextOrOptions, maybeDefaults);
|
|
273
393
|
return this._errorBoundary.capture("decide", () => {
|
|
274
394
|
// Server mode: return from cached server response
|
|
275
395
|
if (this._options.evaluationMode === "server" && this._state.serverResponse) {
|
|
396
|
+
this._maybeResolveForContext(rawContext);
|
|
276
397
|
const resp = this._state.serverResponse;
|
|
277
|
-
const assignments = { ...
|
|
398
|
+
const assignments = { ...defaults };
|
|
278
399
|
for (const [key, value] of Object.entries(resp.assignments)) {
|
|
279
400
|
if (key in assignments) {
|
|
280
401
|
assignments[key] = value;
|
|
281
402
|
}
|
|
282
403
|
}
|
|
283
404
|
const decision = {
|
|
284
|
-
decisionId
|
|
405
|
+
// Fresh decisionId per call — never reuse the resolve response's
|
|
406
|
+
// decisionId across decisions (spec 0.7.0 S8).
|
|
407
|
+
decisionId: generateDecisionId(),
|
|
285
408
|
assignments,
|
|
286
|
-
|
|
409
|
+
// Snapshot the resolve stateVersion at decision time so events
|
|
410
|
+
// built later stamp the version this decision was evaluated
|
|
411
|
+
// against (not whatever response is cached at event-build time).
|
|
412
|
+
metadata: { ...resp.metadata, configVersion: resp.stateVersion },
|
|
287
413
|
};
|
|
288
414
|
this._cacheDecision(decision);
|
|
289
415
|
this._updateCumulativeAttribution(decision);
|
|
@@ -294,11 +420,11 @@ export class TrafficalClient {
|
|
|
294
420
|
}
|
|
295
421
|
const bundle = this._getEffectiveBundle();
|
|
296
422
|
// Run plugin onBeforeDecision hooks
|
|
297
|
-
let context = this._enrichContext(
|
|
423
|
+
let context = this._enrichContext(rawContext);
|
|
298
424
|
context = this._plugins.runBeforeDecision(context);
|
|
299
425
|
// Pass cached edge results (from bundle mode pre-fetch) if available
|
|
300
426
|
const edgeOpts = this._state.cachedEdgeResults ?? undefined;
|
|
301
|
-
const decision = coreDecide(bundle, context,
|
|
427
|
+
const decision = coreDecide(bundle, context, defaults, edgeOpts);
|
|
302
428
|
// Cache decision for attribution lookup when track() is called
|
|
303
429
|
this._cacheDecision(decision);
|
|
304
430
|
// Accumulate attribution entries (survives decision cache eviction)
|
|
@@ -311,7 +437,7 @@ export class TrafficalClient {
|
|
|
311
437
|
return decision;
|
|
312
438
|
}, {
|
|
313
439
|
decisionId: generateDecisionId(),
|
|
314
|
-
assignments:
|
|
440
|
+
assignments: defaults,
|
|
315
441
|
metadata: {
|
|
316
442
|
timestamp: new Date().toISOString(),
|
|
317
443
|
unitKeyValue: "",
|
|
@@ -337,39 +463,51 @@ export class TrafficalClient {
|
|
|
337
463
|
return;
|
|
338
464
|
// Emit to assignment logger (separate from cloud events)
|
|
339
465
|
this._emitAssignmentLogEntries(decision, "exposure");
|
|
340
|
-
//
|
|
466
|
+
// Config bundle version the SDK evaluated against — from the
|
|
467
|
+
// decision-time snapshot. The current version is only a fallback for
|
|
468
|
+
// decisions that predate the snapshot field.
|
|
469
|
+
const configVersion = decision.metadata.configVersion ?? this.getConfigVersion() ?? undefined;
|
|
470
|
+
// S4 canonical shape: ONE exposure event per trackExposure() carrying
|
|
471
|
+
// only newly-exposed, non-attributionOnly layers. Skip layers without
|
|
472
|
+
// a policy/allocation, skip attribution-only layers (parameters not
|
|
473
|
+
// requested by this decision), and dedup per (unit, policy, allocation)
|
|
474
|
+
// for the session (dedup on by default). Mirrors the Node SDK — no
|
|
475
|
+
// longer one event per layer with the full unfiltered layers array.
|
|
476
|
+
const exposedLayers = [];
|
|
341
477
|
for (const layer of decision.metadata.layers) {
|
|
342
478
|
if (!layer.policyId || !layer.allocationName)
|
|
343
479
|
continue;
|
|
344
|
-
// Skip attribution-only layers — the user wasn't exposed to
|
|
345
|
-
// parameters from this layer, so no exposure event should fire.
|
|
346
480
|
if (layer.attributionOnly)
|
|
347
481
|
continue;
|
|
348
|
-
// Deduplicate
|
|
349
482
|
const isNew = this._exposureDedup.checkAndMark(unitKey, layer.policyId, layer.allocationName);
|
|
350
483
|
if (!isNew)
|
|
351
484
|
continue;
|
|
352
|
-
|
|
353
|
-
type: "exposure",
|
|
354
|
-
id: generateExposureId(), // Unique exposure ID (not same as decision)
|
|
355
|
-
decisionId: decision.decisionId,
|
|
356
|
-
orgId: this._options.orgId,
|
|
357
|
-
projectId: this._options.projectId,
|
|
358
|
-
env: this._options.env,
|
|
359
|
-
unitKey,
|
|
360
|
-
timestamp: new Date().toISOString(),
|
|
361
|
-
assignments: decision.assignments,
|
|
362
|
-
layers: decision.metadata.layers,
|
|
363
|
-
context: decision.metadata.filteredContext,
|
|
364
|
-
sdkName: SDK_NAME,
|
|
365
|
-
sdkVersion: SDK_VERSION,
|
|
366
|
-
};
|
|
367
|
-
// Run plugin onExposure hooks
|
|
368
|
-
if (!this._plugins.runExposure(event)) {
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
this._dispatchEvent(event);
|
|
485
|
+
exposedLayers.push(layer);
|
|
372
486
|
}
|
|
487
|
+
// Nothing new to expose (all attribution-only or already seen this
|
|
488
|
+
// session) — emit NO event (never an empty-layers event).
|
|
489
|
+
if (exposedLayers.length === 0)
|
|
490
|
+
return;
|
|
491
|
+
const event = {
|
|
492
|
+
type: "exposure",
|
|
493
|
+
id: generateExposureId(), // Unique exposure ID (not same as decision)
|
|
494
|
+
decisionId: decision.decisionId,
|
|
495
|
+
orgId: this._options.orgId,
|
|
496
|
+
projectId: this._options.projectId,
|
|
497
|
+
env: this._options.env,
|
|
498
|
+
unitKey,
|
|
499
|
+
timestamp: new Date().toISOString(),
|
|
500
|
+
assignments: decision.assignments,
|
|
501
|
+
layers: exposedLayers,
|
|
502
|
+
context: decision.metadata.filteredContext,
|
|
503
|
+
configVersion,
|
|
504
|
+
sdkName: SDK_NAME,
|
|
505
|
+
sdkVersion: SDK_VERSION,
|
|
506
|
+
};
|
|
507
|
+
// Run plugin onExposure hooks
|
|
508
|
+
if (!this._plugins.runExposure(event))
|
|
509
|
+
return;
|
|
510
|
+
this._dispatchEvent(event);
|
|
373
511
|
}, undefined);
|
|
374
512
|
}
|
|
375
513
|
/**
|
|
@@ -392,7 +530,12 @@ export class TrafficalClient {
|
|
|
392
530
|
track(eventName, properties, options) {
|
|
393
531
|
this._errorBoundary.capture("track", () => {
|
|
394
532
|
const unitKey = options?.unitKey ?? this._stableId.getId();
|
|
395
|
-
|
|
533
|
+
// Single numeric value: explicit options.value wins, else properties.value.
|
|
534
|
+
const value = typeof options?.value === "number"
|
|
535
|
+
? options.value
|
|
536
|
+
: typeof properties?.value === "number"
|
|
537
|
+
? properties.value
|
|
538
|
+
: undefined;
|
|
396
539
|
// Auto-populate attribution from cached decisions
|
|
397
540
|
const attribution = this._buildAttribution(unitKey, options?.decisionId);
|
|
398
541
|
const decisionId = options?.decisionId;
|
|
@@ -406,9 +549,11 @@ export class TrafficalClient {
|
|
|
406
549
|
timestamp: new Date().toISOString(),
|
|
407
550
|
event: eventName,
|
|
408
551
|
value,
|
|
552
|
+
values: options?.values,
|
|
409
553
|
properties,
|
|
410
554
|
decisionId,
|
|
411
555
|
attribution,
|
|
556
|
+
eventTimestamp: options?.eventTimestamp,
|
|
412
557
|
sdkName: SDK_NAME,
|
|
413
558
|
sdkVersion: SDK_VERSION,
|
|
414
559
|
};
|
|
@@ -584,6 +729,9 @@ export class TrafficalClient {
|
|
|
584
729
|
const unitKey = decision.metadata.unitKeyValue;
|
|
585
730
|
if (!unitKey)
|
|
586
731
|
return;
|
|
732
|
+
// Config bundle version the SDK evaluated against — from the
|
|
733
|
+
// decision-time snapshot, falling back to the current version.
|
|
734
|
+
const configVersion = decision.metadata.configVersion ?? this.getConfigVersion() ?? undefined;
|
|
587
735
|
for (const layer of decision.metadata.layers) {
|
|
588
736
|
if (!layer.policyId || !layer.allocationName)
|
|
589
737
|
continue;
|
|
@@ -612,6 +760,10 @@ export class TrafficalClient {
|
|
|
612
760
|
decisionId: decision.decisionId,
|
|
613
761
|
anonymousId: this._stableId.getId(),
|
|
614
762
|
id: generateAssignmentId(),
|
|
763
|
+
bucket: layer.bucket >= 0 ? layer.bucket : undefined,
|
|
764
|
+
probability: layer.probability,
|
|
765
|
+
modelVersion: layer.modelVersion,
|
|
766
|
+
configVersion,
|
|
615
767
|
});
|
|
616
768
|
}
|
|
617
769
|
}
|
|
@@ -641,7 +793,9 @@ export class TrafficalClient {
|
|
|
641
793
|
return context;
|
|
642
794
|
}
|
|
643
795
|
async _fetchConfig() {
|
|
644
|
-
|
|
796
|
+
// URL-encode path/query components so an env or projectId containing
|
|
797
|
+
// reserved characters (spaces, &, ?, /) can't corrupt the request URL.
|
|
798
|
+
const url = `${this._options.baseUrl}/v1/config/${encodeURIComponent(this._options.projectId)}?env=${encodeURIComponent(this._options.env)}`;
|
|
645
799
|
const headers = {
|
|
646
800
|
"Content-Type": "application/json",
|
|
647
801
|
Authorization: `Bearer ${this._options.apiKey}`,
|
|
@@ -649,8 +803,16 @@ export class TrafficalClient {
|
|
|
649
803
|
if (this._state.etag) {
|
|
650
804
|
headers["If-None-Match"] = this._state.etag;
|
|
651
805
|
}
|
|
806
|
+
// Abort the request if the edge hangs (slow TCP, not a 5xx) so the
|
|
807
|
+
// promise settles and we fall back to cached/local config.
|
|
808
|
+
const controller = new AbortController();
|
|
809
|
+
const timeoutId = setTimeout(() => controller.abort(), this._requestTimeoutMs);
|
|
652
810
|
try {
|
|
653
|
-
const response = await fetch(url, {
|
|
811
|
+
const response = await fetch(url, {
|
|
812
|
+
method: "GET",
|
|
813
|
+
headers,
|
|
814
|
+
signal: controller.signal,
|
|
815
|
+
});
|
|
654
816
|
if (response.status === 304) {
|
|
655
817
|
this._state.lastFetchTime = Date.now();
|
|
656
818
|
return;
|
|
@@ -660,6 +822,13 @@ export class TrafficalClient {
|
|
|
660
822
|
}
|
|
661
823
|
const bundle = (await response.json());
|
|
662
824
|
const etag = response.headers.get("ETag");
|
|
825
|
+
// A 200 can still carry a malformed body. Discard it and keep the
|
|
826
|
+
// previous last-good bundle rather than corrupting bucket assignments or
|
|
827
|
+
// falling through to defaults when we already have a valid config.
|
|
828
|
+
if (!isValidConfigBundle(bundle)) {
|
|
829
|
+
this._logMalformedBundleWarning();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
663
832
|
this._state.bundle = bundle;
|
|
664
833
|
this._state.etag = etag;
|
|
665
834
|
this._state.lastFetchTime = Date.now();
|
|
@@ -677,6 +846,9 @@ export class TrafficalClient {
|
|
|
677
846
|
catch (error) {
|
|
678
847
|
this._logOfflineWarning(error);
|
|
679
848
|
}
|
|
849
|
+
finally {
|
|
850
|
+
clearTimeout(timeoutId);
|
|
851
|
+
}
|
|
680
852
|
}
|
|
681
853
|
_startBackgroundRefresh() {
|
|
682
854
|
const interval = this._options.evaluationMode === "server"
|
|
@@ -684,20 +856,50 @@ export class TrafficalClient {
|
|
|
684
856
|
: this._options.refreshIntervalMs;
|
|
685
857
|
if (interval <= 0)
|
|
686
858
|
return;
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
859
|
+
// Reschedule via setTimeout with fresh +/-10% jitter each tick (instead of a
|
|
860
|
+
// fixed setInterval) so fleets of clients don't converge on the same refresh
|
|
861
|
+
// instant and stampede the edge.
|
|
862
|
+
const scheduleNext = () => {
|
|
863
|
+
this._state.refreshTimer = setTimeout(() => {
|
|
864
|
+
// Schedule the next tick before firing so cadence stays independent of
|
|
865
|
+
// fetch latency (matching the previous fire-and-forget setInterval).
|
|
866
|
+
scheduleNext();
|
|
867
|
+
if (this._options.evaluationMode === "server") {
|
|
868
|
+
this._fetchServerResolve().catch(() => { });
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
this._fetchConfig().catch(() => { });
|
|
872
|
+
}
|
|
873
|
+
}, jitteredInterval(interval));
|
|
874
|
+
};
|
|
875
|
+
scheduleNext();
|
|
695
876
|
}
|
|
696
|
-
|
|
877
|
+
/**
|
|
878
|
+
* Server mode: threads the per-call context into a background /v1/resolve so
|
|
879
|
+
* the cached snapshot converges to the contexts actually being evaluated.
|
|
880
|
+
* Throttled by serialized context so repeated identical contexts don't hammer
|
|
881
|
+
* the edge. decide()/getParams() are synchronous and cannot await this; the
|
|
882
|
+
* current call degrades to the last-good snapshot.
|
|
883
|
+
*/
|
|
884
|
+
_maybeResolveForContext(context) {
|
|
885
|
+
const enriched = this._enrichContext(context);
|
|
886
|
+
let key;
|
|
887
|
+
try {
|
|
888
|
+
key = JSON.stringify(enriched);
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
key = "";
|
|
892
|
+
}
|
|
893
|
+
if (key === this._lastResolveContextKey)
|
|
894
|
+
return;
|
|
895
|
+
this._lastResolveContextKey = key;
|
|
896
|
+
void this._fetchServerResolve(enriched);
|
|
897
|
+
}
|
|
898
|
+
async _fetchServerResolve(contextOverride) {
|
|
697
899
|
if (!this._decisionClient)
|
|
698
900
|
return;
|
|
699
901
|
try {
|
|
700
|
-
const context = this._enrichContext({});
|
|
902
|
+
const context = contextOverride ?? this._enrichContext({});
|
|
701
903
|
const response = await this._decisionClient.resolve({ context });
|
|
702
904
|
if (response) {
|
|
703
905
|
this._state.serverResponse = response;
|
|
@@ -775,6 +977,13 @@ export class TrafficalClient {
|
|
|
775
977
|
this._state.lastOfflineWarning = now;
|
|
776
978
|
}
|
|
777
979
|
}
|
|
980
|
+
_logMalformedBundleWarning() {
|
|
981
|
+
const now = Date.now();
|
|
982
|
+
if (now - this._state.lastMalformedWarning > MALFORMED_BUNDLE_WARNING_INTERVAL_MS) {
|
|
983
|
+
console.warn(`[Traffical] Discarded malformed config bundle (invalid hashing/shape). Using ${this._state.bundle ? "cached" : "local"} config.`);
|
|
984
|
+
this._state.lastMalformedWarning = now;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
778
987
|
/**
|
|
779
988
|
* Caches a decision for attribution lookup when track() is called.
|
|
780
989
|
* Maintains a bounded cache to prevent memory leaks.
|