@avsbhq/node 1.7.2 → 1.9.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 +34 -23
- package/dist/index.cjs +913 -881
- package/dist/index.d.cts +9 -107
- package/dist/index.d.ts +9 -107
- package/dist/index.js +905 -872
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
// src/AvsbServer.ts
|
|
2
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
3
|
-
import { evaluateFlag } from "@avsbhq/core/engine";
|
|
4
|
-
import {
|
|
5
|
-
toLegacyEvalContext,
|
|
6
|
-
createFlag,
|
|
7
|
-
notFoundFlag as notFoundFlag2,
|
|
8
|
-
notReadyFlag,
|
|
9
|
-
mapLegacySource,
|
|
10
|
-
checkExpectedType,
|
|
11
|
-
findDatafileFlag,
|
|
12
|
-
contextSummary,
|
|
13
|
-
privateAttributesFrom,
|
|
14
|
-
bucketingKey as bucketingKey2,
|
|
15
|
-
resolveStreamEndpoint
|
|
16
|
-
} from "@avsbhq/core";
|
|
17
|
-
import { validateSdkKeyShape } from "@avsbhq/utils/sdkKey";
|
|
18
|
-
import { jitteredInterval } from "@avsbhq/utils/backoff";
|
|
19
|
-
import { redactExposure } from "@avsbhq/utils/exposure";
|
|
20
|
-
|
|
21
1
|
// src/datasets.ts
|
|
22
2
|
var DEFAULT_REQUEST_TIMEOUT = 1e4;
|
|
23
3
|
function miss(slug, version) {
|
|
@@ -352,6 +332,126 @@ function appendDecisionLogEntry(log, input) {
|
|
|
352
332
|
return entry;
|
|
353
333
|
}
|
|
354
334
|
|
|
335
|
+
// src/AvsbServer.ts
|
|
336
|
+
import { bucketingKey as bucketingKey4 } from "@avsbhq/core";
|
|
337
|
+
|
|
338
|
+
// src/UserBoundClient.ts
|
|
339
|
+
import { bucketingKey } from "@avsbhq/core";
|
|
340
|
+
function createUserBoundClient(server, context) {
|
|
341
|
+
const decisionLog = createDecisionLog();
|
|
342
|
+
const forcedDecisions = /* @__PURE__ */ new Map();
|
|
343
|
+
function evaluate(flagKey, defaultValue, expectedType, options) {
|
|
344
|
+
const forced = forcedDecisions.get(flagKey);
|
|
345
|
+
return server.evaluateInternal(flagKey, defaultValue, context, {
|
|
346
|
+
decideOptions: options?.decideOptions,
|
|
347
|
+
// A caller may point one evaluation at their own log; the bound log is
|
|
348
|
+
// the default so `getDecisionLog()` is complete without extra wiring.
|
|
349
|
+
decisionLog: options?.decisionLog ?? decisionLog,
|
|
350
|
+
...expectedType ? { expectedType } : {},
|
|
351
|
+
...forced ? { forcedVariationKey: forced.variationKey } : {}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
getFlag(flagKey, defaultValue, options) {
|
|
356
|
+
return evaluate(flagKey, defaultValue, void 0, options);
|
|
357
|
+
},
|
|
358
|
+
getBoolFlag(flagKey, defaultValue, options) {
|
|
359
|
+
return evaluate(flagKey, defaultValue, "boolean", options);
|
|
360
|
+
},
|
|
361
|
+
getStringFlag(flagKey, defaultValue, options) {
|
|
362
|
+
return evaluate(flagKey, defaultValue, "string", options);
|
|
363
|
+
},
|
|
364
|
+
getNumberFlag(flagKey, defaultValue, options) {
|
|
365
|
+
return evaluate(flagKey, defaultValue, "number", options);
|
|
366
|
+
},
|
|
367
|
+
getJsonFlag(flagKey, defaultValue, options) {
|
|
368
|
+
return evaluate(flagKey, defaultValue, "json", options);
|
|
369
|
+
},
|
|
370
|
+
getAllFlags(options) {
|
|
371
|
+
return server.getAllFlags(context, {
|
|
372
|
+
...options,
|
|
373
|
+
decisionLog: options?.decisionLog ?? decisionLog
|
|
374
|
+
});
|
|
375
|
+
},
|
|
376
|
+
track(eventKey, payload) {
|
|
377
|
+
server.track(eventKey, { ...payload, context });
|
|
378
|
+
},
|
|
379
|
+
trackPurchase(order) {
|
|
380
|
+
return server.trackPurchase(bucketingKey(context), order);
|
|
381
|
+
},
|
|
382
|
+
manualExposure(flagKey) {
|
|
383
|
+
server.manualExposure(flagKey, context);
|
|
384
|
+
},
|
|
385
|
+
getContext() {
|
|
386
|
+
return context;
|
|
387
|
+
},
|
|
388
|
+
setForcedDecision(flagKey, opts) {
|
|
389
|
+
forcedDecisions.set(flagKey, opts);
|
|
390
|
+
},
|
|
391
|
+
clearForcedDecision(flagKey) {
|
|
392
|
+
forcedDecisions.delete(flagKey);
|
|
393
|
+
},
|
|
394
|
+
getDecisionLog() {
|
|
395
|
+
return decisionLog;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/utils.ts
|
|
401
|
+
import { createUtils } from "@avsbhq/utils";
|
|
402
|
+
import { notFoundFlag } from "@avsbhq/core";
|
|
403
|
+
function buildServerUtils(server) {
|
|
404
|
+
const adapter = {
|
|
405
|
+
getFlag(flagKey, defaultValue) {
|
|
406
|
+
return notFoundFlag(
|
|
407
|
+
defaultValue,
|
|
408
|
+
"no bound context: call server.forUser(context) and use its flag methods"
|
|
409
|
+
);
|
|
410
|
+
},
|
|
411
|
+
getAllFlags(opts) {
|
|
412
|
+
const ctx = opts?.context;
|
|
413
|
+
if (!ctx) return {};
|
|
414
|
+
return server.getAllFlags(ctx, {
|
|
415
|
+
fireExposures: opts.fireExposures
|
|
416
|
+
});
|
|
417
|
+
},
|
|
418
|
+
track(eventKey, payload) {
|
|
419
|
+
if (!payload?.context) return;
|
|
420
|
+
server.track(eventKey, {
|
|
421
|
+
...payload,
|
|
422
|
+
context: payload.context
|
|
423
|
+
});
|
|
424
|
+
},
|
|
425
|
+
on(event, cb) {
|
|
426
|
+
return server.on(event, cb);
|
|
427
|
+
},
|
|
428
|
+
getDatafile: () => server.getDatafile(),
|
|
429
|
+
getCurrentContext: () => null,
|
|
430
|
+
/**
|
|
431
|
+
* `snapshotFresh` and `refreshAndSnapshot` call this before they read, so
|
|
432
|
+
* a "fresh" snapshot really is taken against the datafile published right
|
|
433
|
+
* now. Without it both helpers quietly returned the in-memory datafile.
|
|
434
|
+
*/
|
|
435
|
+
refresh: () => server.refreshDatafile(),
|
|
436
|
+
/**
|
|
437
|
+
* Real, not a comment: `client.utils.hydrateBootstrap(blob)` on the server
|
|
438
|
+
* adopts the blob's datafile and marks the SDK ready. That is the
|
|
439
|
+
* serverless cold-start path (read a datafile from KV or a file, hand it
|
|
440
|
+
* over, evaluate on the same tick).
|
|
441
|
+
*/
|
|
442
|
+
applyDatafile: (df) => server.applyDatafileBootstrap(df)
|
|
443
|
+
// `applySnapshot` is deliberately ABSENT. A snapshot carries one context's
|
|
444
|
+
// evaluated values, and this client evaluates a different context per
|
|
445
|
+
// request, so adopting one process-wide would serve user A's values to user
|
|
446
|
+
// B. hydrateBootstrap throws with that explanation when it is handed a
|
|
447
|
+
// snapshot-only blob here, instead of quietly ignoring it.
|
|
448
|
+
};
|
|
449
|
+
return createUtils(adapter);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/serverState.ts
|
|
453
|
+
import { validateSdkKeyShape } from "@avsbhq/utils/sdkKey";
|
|
454
|
+
|
|
355
455
|
// src/decideOptions.ts
|
|
356
456
|
import { DecideOption } from "@avsbhq/core";
|
|
357
457
|
var KNOWN = Object.values(DecideOption);
|
|
@@ -427,7 +527,7 @@ function createOverrideStore() {
|
|
|
427
527
|
}
|
|
428
528
|
|
|
429
529
|
// src/datafileSource.ts
|
|
430
|
-
import { datafileUrl, resolveHeartbeatEndpoint } from "@avsbhq/core";
|
|
530
|
+
import { datafileShapeProblem, datafileUrl, resolveHeartbeatEndpoint } from "@avsbhq/core";
|
|
431
531
|
async function fetchDatafile(input) {
|
|
432
532
|
const url = datafileUrl(input.sdkKey, input.cdnHost);
|
|
433
533
|
const headers = {};
|
|
@@ -441,8 +541,15 @@ async function fetchDatafile(input) {
|
|
|
441
541
|
);
|
|
442
542
|
return { status: "failed" };
|
|
443
543
|
}
|
|
444
|
-
const
|
|
445
|
-
|
|
544
|
+
const body = await response.json();
|
|
545
|
+
const problem = datafileShapeProblem(body);
|
|
546
|
+
if (problem) {
|
|
547
|
+
input.logger.error(
|
|
548
|
+
`AvsB datafile from ${url} is not a datafile the SDK can read: ${problem}. The SDK keeps serving whatever datafile it already has and retries on the polling interval.`
|
|
549
|
+
);
|
|
550
|
+
return { status: "failed" };
|
|
551
|
+
}
|
|
552
|
+
return { status: "ok", datafile: body, etag: response.headers.get("etag") };
|
|
446
553
|
} catch (error) {
|
|
447
554
|
if (input.signal.aborted) return { status: "aborted" };
|
|
448
555
|
input.logger.error(
|
|
@@ -451,6 +558,14 @@ async function fetchDatafile(input) {
|
|
|
451
558
|
return { status: "failed" };
|
|
452
559
|
}
|
|
453
560
|
}
|
|
561
|
+
function acceptDatafile(candidate, origin, logger) {
|
|
562
|
+
const problem = datafileShapeProblem(candidate);
|
|
563
|
+
if (problem === null) return candidate;
|
|
564
|
+
logger.error(
|
|
565
|
+
origin === "bootstrap" ? `AvsB ignored the bootstrap datafile passed to new AvsbServer(): ${problem}. Flags return the default you passed, with source "not_ready", until a datafile loads from the CDN. Pass the datafile JSON exactly as the CDN serves it for this SDK key.` : `AvsB ignored the datafile passed to applyDatafileBootstrap(): ${problem}. The SDK keeps serving the datafile it already has, if any. Pass the datafile JSON exactly as the CDN serves it for this SDK key.`
|
|
566
|
+
);
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
454
569
|
function sendHeartbeat(input) {
|
|
455
570
|
const endpoint = resolveHeartbeatEndpoint(input.datafileHeartbeatEndpoint);
|
|
456
571
|
if (!endpoint) return null;
|
|
@@ -494,6 +609,119 @@ function resolveServerLogger(options) {
|
|
|
494
609
|
return defaultLogger();
|
|
495
610
|
}
|
|
496
611
|
|
|
612
|
+
// src/serverState.ts
|
|
613
|
+
var DEFAULT_EVENTS = {
|
|
614
|
+
flushInterval: 2e3,
|
|
615
|
+
batchSize: 10,
|
|
616
|
+
maxQueueSize: 1e4,
|
|
617
|
+
retryAttempts: 3
|
|
618
|
+
};
|
|
619
|
+
function createServerState(options) {
|
|
620
|
+
const logger = resolveServerLogger(options);
|
|
621
|
+
const datafile = options.bootstrap ? acceptDatafile(options.bootstrap, "bootstrap", logger) : null;
|
|
622
|
+
const state = {
|
|
623
|
+
sdkKey: options.sdkKey,
|
|
624
|
+
pollingInterval: Math.max(options.pollingInterval ?? 6e4, 5e3),
|
|
625
|
+
cdnHost: options.cdnHost,
|
|
626
|
+
initTimeout: options.initTimeout ?? 1e4,
|
|
627
|
+
logger,
|
|
628
|
+
stickyBucketService: options.stickyBucketService ?? null,
|
|
629
|
+
eventOptions: { ...DEFAULT_EVENTS, ...options.events },
|
|
630
|
+
// Streaming is ON by default on the server (B12). Resolution happens once
|
|
631
|
+
// a datafile exists, because the endpoint is a datafile field.
|
|
632
|
+
streamingEnabled: options.streaming !== false,
|
|
633
|
+
streamingEndpointOption: options.streamingEndpoint ?? null,
|
|
634
|
+
overrides: createOverrideStore(),
|
|
635
|
+
resolveDecideOptions: createDecideOptionResolver(logger),
|
|
636
|
+
datafile,
|
|
637
|
+
datafileEtag: null,
|
|
638
|
+
ready: datafile !== null,
|
|
639
|
+
destroyed: false,
|
|
640
|
+
tracker: null,
|
|
641
|
+
streamingClient: null,
|
|
642
|
+
pollingTimer: null,
|
|
643
|
+
abortController: new AbortController(),
|
|
644
|
+
listeners: /* @__PURE__ */ new Map(),
|
|
645
|
+
readyPromise: null,
|
|
646
|
+
pendingEvents: [],
|
|
647
|
+
pendingPurchases: [],
|
|
648
|
+
pendingExposures: [],
|
|
649
|
+
lastHeartbeatAt: 0,
|
|
650
|
+
warnedOnce: /* @__PURE__ */ new Set()
|
|
651
|
+
};
|
|
652
|
+
const keyShape = validateSdkKeyShape(state.sdkKey);
|
|
653
|
+
if (!keyShape.valid && keyShape.message) {
|
|
654
|
+
logger.error(keyShape.message);
|
|
655
|
+
}
|
|
656
|
+
return state;
|
|
657
|
+
}
|
|
658
|
+
function warnOnce(s, key, message) {
|
|
659
|
+
if (s.warnedOnce.has(key)) return;
|
|
660
|
+
s.warnedOnce.add(key);
|
|
661
|
+
s.logger.warn(message);
|
|
662
|
+
}
|
|
663
|
+
function emit(s, event, ...args) {
|
|
664
|
+
const cbs = s.listeners.get(event);
|
|
665
|
+
if (!cbs) return;
|
|
666
|
+
for (const cb of cbs) {
|
|
667
|
+
try {
|
|
668
|
+
;
|
|
669
|
+
cb(...args);
|
|
670
|
+
} catch (e) {
|
|
671
|
+
s.logger.error("Listener error", { event, error: String(e) });
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function addListener(s, event, cb) {
|
|
676
|
+
if (!s.listeners.has(event)) s.listeners.set(event, /* @__PURE__ */ new Set());
|
|
677
|
+
s.listeners.get(event).add(cb);
|
|
678
|
+
return () => {
|
|
679
|
+
s.listeners.get(event)?.delete(cb);
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function onSharedEvent(s, server, event, cb) {
|
|
683
|
+
if (event === "flagChange") {
|
|
684
|
+
warnOnce(
|
|
685
|
+
s,
|
|
686
|
+
"no-flag-change",
|
|
687
|
+
'The server SDK does not emit "flagChange": it holds one datafile for the whole process and does not diff individual flags. Subscribe to "configUpdate" for "a new datafile was published" instead.'
|
|
688
|
+
);
|
|
689
|
+
return () => void 0;
|
|
690
|
+
}
|
|
691
|
+
return server.on(
|
|
692
|
+
event,
|
|
693
|
+
cb
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/serverDatafile.ts
|
|
698
|
+
import { jitteredInterval } from "@avsbhq/utils/backoff";
|
|
699
|
+
|
|
700
|
+
// src/initTimeout.ts
|
|
701
|
+
async function raceInitTimeout(work, timeoutMs) {
|
|
702
|
+
let timer;
|
|
703
|
+
const timeout = new Promise((resolve) => {
|
|
704
|
+
timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
|
|
705
|
+
if (typeof timer === "object" && timer !== null && "unref" in timer) timer.unref();
|
|
706
|
+
});
|
|
707
|
+
try {
|
|
708
|
+
return await Promise.race([
|
|
709
|
+
work.then((value) => ({ timedOut: false, value })),
|
|
710
|
+
timeout
|
|
711
|
+
]);
|
|
712
|
+
} finally {
|
|
713
|
+
clearTimeout(timer);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// src/version.ts
|
|
718
|
+
var SDK_VERSION = "1.9.0";
|
|
719
|
+
var SDK_TYPE = "@avsbhq/node";
|
|
720
|
+
|
|
721
|
+
// src/serverTracking.ts
|
|
722
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
723
|
+
import { bucketingKey as bucketingKey2 } from "@avsbhq/core";
|
|
724
|
+
|
|
497
725
|
// src/serverTracker.ts
|
|
498
726
|
import { randomUUID } from "crypto";
|
|
499
727
|
import { collectUrl } from "@avsbhq/core";
|
|
@@ -608,7 +836,7 @@ function createServerTracker(options) {
|
|
|
608
836
|
}
|
|
609
837
|
}
|
|
610
838
|
}
|
|
611
|
-
async function
|
|
839
|
+
async function trackPurchase2(visitorId, order) {
|
|
612
840
|
if (destroyed) return;
|
|
613
841
|
const payload = {
|
|
614
842
|
...order,
|
|
@@ -657,169 +885,632 @@ function createServerTracker(options) {
|
|
|
657
885
|
}
|
|
658
886
|
await flush();
|
|
659
887
|
}
|
|
660
|
-
return { queue, queueMetric, trackPurchase, flush, destroy };
|
|
888
|
+
return { queue, queueMetric, trackPurchase: trackPurchase2, flush, destroy };
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// src/serverTracking.ts
|
|
892
|
+
function trackMetric(s, eventKey, payload) {
|
|
893
|
+
if (payload.properties) {
|
|
894
|
+
warnOnce(
|
|
895
|
+
s,
|
|
896
|
+
"track-properties",
|
|
897
|
+
`track("${eventKey}") was called with properties, which are NOT stored for metric events: the ingestion contract for a conversion keeps the event name, the numeric value, the timestamp, and the visitor id. Exposure events do carry properties. Nothing else about the call is affected.`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
if (!s.tracker) {
|
|
901
|
+
enqueuePending(s, eventKey, payload);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
s.tracker.queueMetric({
|
|
905
|
+
visitorId: bucketingKey2(payload.context),
|
|
906
|
+
eventName: eventKey,
|
|
907
|
+
// TWO columns, not one. `revenue` is money in decimal major units;
|
|
908
|
+
// `value` is the numeric metric value for average-value metrics. Storage
|
|
909
|
+
// declares both (`metric_events.revenue`, `metric_events.value`). This
|
|
910
|
+
// SDK used to ship `payload.value` into the revenue column and could
|
|
911
|
+
// never write the value column at all, so a quantity was recorded as
|
|
912
|
+
// money and every average-value metric read empty.
|
|
913
|
+
revenue: payload.revenue ?? null,
|
|
914
|
+
value: payload.value ?? null,
|
|
915
|
+
timestamp: Date.now(),
|
|
916
|
+
pageUrl: ""
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
async function trackPurchase(s, visitorId, order) {
|
|
920
|
+
if (s.destroyed) return;
|
|
921
|
+
if (!s.tracker) {
|
|
922
|
+
if (s.pendingPurchases.length >= s.eventOptions.maxQueueSize) {
|
|
923
|
+
s.pendingPurchases.shift();
|
|
924
|
+
warnOnce(
|
|
925
|
+
s,
|
|
926
|
+
"pending-purchase-full",
|
|
927
|
+
`Pre-ready purchase queue reached ${s.eventOptions.maxQueueSize} orders; the oldest is being dropped. This means the datafile never loaded: check the datafile error logged above.`
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
s.pendingPurchases.push({ visitorId, order });
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
return s.tracker.trackPurchase(visitorId, order);
|
|
934
|
+
}
|
|
935
|
+
function deliverExposure(s, event) {
|
|
936
|
+
const stamped = event.eventId ? event : { ...event, eventId: randomUUID2() };
|
|
937
|
+
if (s.tracker) {
|
|
938
|
+
s.tracker.queue(stamped);
|
|
939
|
+
} else {
|
|
940
|
+
enqueuePendingExposure(s, stamped);
|
|
941
|
+
}
|
|
942
|
+
emit(s, "exposure", stamped);
|
|
943
|
+
}
|
|
944
|
+
function initTracker(s, server) {
|
|
945
|
+
if (!s.datafile || s.tracker) return;
|
|
946
|
+
const tracker = createServerTracker({
|
|
947
|
+
sdkKey: s.sdkKey,
|
|
948
|
+
collectEndpoint: s.datafile.collectEndpoint,
|
|
949
|
+
flushInterval: s.eventOptions.flushInterval,
|
|
950
|
+
batchSize: s.eventOptions.batchSize,
|
|
951
|
+
maxQueueSize: s.eventOptions.maxQueueSize,
|
|
952
|
+
retryAttempts: s.eventOptions.retryAttempts,
|
|
953
|
+
logger: s.logger
|
|
954
|
+
});
|
|
955
|
+
s.tracker = tracker;
|
|
956
|
+
if (s.pendingExposures.length > 0) {
|
|
957
|
+
const queued = s.pendingExposures;
|
|
958
|
+
s.pendingExposures = [];
|
|
959
|
+
for (const event of queued) tracker.queue(event);
|
|
960
|
+
}
|
|
961
|
+
if (s.pendingEvents.length > 0) {
|
|
962
|
+
const queued = s.pendingEvents;
|
|
963
|
+
s.pendingEvents = [];
|
|
964
|
+
for (const item of queued) server.track(item.eventKey, item.payload);
|
|
965
|
+
}
|
|
966
|
+
if (s.pendingPurchases.length > 0) {
|
|
967
|
+
const queued = s.pendingPurchases;
|
|
968
|
+
s.pendingPurchases = [];
|
|
969
|
+
for (const item of queued) void server.trackPurchase(item.visitorId, item.order);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function enqueuePendingExposure(s, event) {
|
|
973
|
+
if (s.pendingExposures.length >= s.eventOptions.maxQueueSize) {
|
|
974
|
+
s.pendingExposures.shift();
|
|
975
|
+
warnOnce(
|
|
976
|
+
s,
|
|
977
|
+
"pending-exposures-full",
|
|
978
|
+
`Pre-ready exposure queue reached ${s.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
s.pendingExposures.push(event);
|
|
982
|
+
}
|
|
983
|
+
function enqueuePending(s, eventKey, payload) {
|
|
984
|
+
if (s.pendingEvents.length >= s.eventOptions.maxQueueSize) {
|
|
985
|
+
s.pendingEvents.shift();
|
|
986
|
+
warnOnce(
|
|
987
|
+
s,
|
|
988
|
+
"pending-events-full",
|
|
989
|
+
`Pre-ready event queue reached ${s.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
s.pendingEvents.push({ eventKey, payload });
|
|
661
993
|
}
|
|
662
994
|
|
|
995
|
+
// src/serverStreaming.ts
|
|
996
|
+
import { resolveStreamEndpoint } from "@avsbhq/core";
|
|
997
|
+
|
|
663
998
|
// src/streaming.ts
|
|
664
999
|
import {
|
|
665
1000
|
createStreamingClient,
|
|
666
1001
|
createBrowserStreamingClient
|
|
667
1002
|
} from "@avsbhq/utils/streaming";
|
|
668
1003
|
|
|
669
|
-
// src/
|
|
670
|
-
|
|
1004
|
+
// src/serverStreaming.ts
|
|
1005
|
+
function startStreamingIfEnabled(s, onDatafileUpdated) {
|
|
1006
|
+
if (!s.streamingEnabled || s.streamingClient || s.destroyed) return;
|
|
1007
|
+
const resolution = resolveStreamEndpoint({
|
|
1008
|
+
optionEndpoint: s.streamingEndpointOption,
|
|
1009
|
+
datafileStreamEndpoint: s.datafile?.streamEndpoint ?? null
|
|
1010
|
+
});
|
|
1011
|
+
if (!resolution.available) {
|
|
1012
|
+
s.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
s.streamingClient = createStreamingClient({
|
|
1016
|
+
endpoint: resolution.endpoint,
|
|
1017
|
+
sdkKey: s.sdkKey,
|
|
1018
|
+
onDatafileUpdated,
|
|
1019
|
+
logger: s.logger
|
|
1020
|
+
});
|
|
1021
|
+
s.streamingClient.connect();
|
|
1022
|
+
s.logger.debug(
|
|
1023
|
+
`Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
671
1026
|
|
|
672
|
-
// src/
|
|
673
|
-
|
|
674
|
-
function
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
const forced = forcedDecisions.get(flagKey);
|
|
679
|
-
return server.evaluateInternal(flagKey, defaultValue, context, {
|
|
680
|
-
decideOptions: options?.decideOptions,
|
|
681
|
-
// A caller may point one evaluation at their own log; the bound log is
|
|
682
|
-
// the default so `getDecisionLog()` is complete without extra wiring.
|
|
683
|
-
decisionLog: options?.decisionLog ?? decisionLog,
|
|
684
|
-
...expectedType ? { expectedType } : {},
|
|
685
|
-
...forced ? { forcedVariationKey: forced.variationKey } : {}
|
|
686
|
-
});
|
|
1027
|
+
// src/serverDatafile.ts
|
|
1028
|
+
var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
|
|
1029
|
+
async function initializeAndReady(s, server) {
|
|
1030
|
+
if (s.ready && s.datafile) {
|
|
1031
|
+
onDatafileReadable(s, server);
|
|
1032
|
+
return { success: true, source: "bootstrap" };
|
|
687
1033
|
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
return
|
|
703
|
-
},
|
|
704
|
-
getAllFlags(options) {
|
|
705
|
-
return server.getAllFlags(context, {
|
|
706
|
-
...options,
|
|
707
|
-
decisionLog: options?.decisionLog ?? decisionLog
|
|
708
|
-
});
|
|
709
|
-
},
|
|
710
|
-
track(eventKey, payload) {
|
|
711
|
-
server.track(eventKey, { ...payload, context });
|
|
712
|
-
},
|
|
713
|
-
trackPurchase(order) {
|
|
714
|
-
return server.trackPurchase(bucketingKey(context), order);
|
|
715
|
-
},
|
|
716
|
-
manualExposure(flagKey) {
|
|
717
|
-
server.manualExposure(flagKey, context);
|
|
718
|
-
},
|
|
719
|
-
getContext() {
|
|
720
|
-
return context;
|
|
721
|
-
},
|
|
722
|
-
setForcedDecision(flagKey, opts) {
|
|
723
|
-
forcedDecisions.set(flagKey, opts);
|
|
724
|
-
},
|
|
725
|
-
clearForcedDecision(flagKey) {
|
|
726
|
-
forcedDecisions.delete(flagKey);
|
|
727
|
-
},
|
|
728
|
-
getDecisionLog() {
|
|
729
|
-
return decisionLog;
|
|
1034
|
+
try {
|
|
1035
|
+
const outcome = await raceInitTimeout(
|
|
1036
|
+
// At init there is no stored etag, so 'unchanged' cannot happen here;
|
|
1037
|
+
// it is folded in anyway so a future caching layer cannot make init
|
|
1038
|
+
// silently report failure.
|
|
1039
|
+
loadDatafile(s).then((result) => result !== "failed"),
|
|
1040
|
+
s.initTimeout
|
|
1041
|
+
);
|
|
1042
|
+
if (outcome.timedOut) {
|
|
1043
|
+
startPolling(s, server);
|
|
1044
|
+
const error2 = new Error(
|
|
1045
|
+
`AvsB init timed out after ${s.initTimeout}ms waiting for the datafile. Polling continues in the background, so flags start working as soon as the request completes; raise initTimeout if your cold start is slower.`
|
|
1046
|
+
);
|
|
1047
|
+
emit(s, "error", error2);
|
|
1048
|
+
return { success: false, source: "timeout", error: error2 };
|
|
730
1049
|
}
|
|
731
|
-
|
|
1050
|
+
if (outcome.value) {
|
|
1051
|
+
onDatafileReadable(s, server);
|
|
1052
|
+
return { success: true, source: "network" };
|
|
1053
|
+
}
|
|
1054
|
+
startPolling(s, server);
|
|
1055
|
+
const error = new Error(
|
|
1056
|
+
'AvsB init failed: no datafile could be loaded, so every flag serves the default you passed and flag.source is "not_ready". The datafile request error is logged above with the URL and status. Polling continues.'
|
|
1057
|
+
);
|
|
1058
|
+
emit(s, "error", error);
|
|
1059
|
+
return { success: false, source: "error", error };
|
|
1060
|
+
} catch (e) {
|
|
1061
|
+
startPolling(s, server);
|
|
1062
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
1063
|
+
emit(s, "error", error);
|
|
1064
|
+
return { success: false, source: "error", error };
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
function onDatafileReadable(s, server) {
|
|
1068
|
+
initTracker(s, server);
|
|
1069
|
+
s.ready = true;
|
|
1070
|
+
startPolling(s, server);
|
|
1071
|
+
startStreamingIfEnabled(s, () => {
|
|
1072
|
+
void loadDatafile(s, "stream");
|
|
1073
|
+
});
|
|
1074
|
+
sendHeartbeat2(s);
|
|
1075
|
+
emit(s, "ready");
|
|
1076
|
+
}
|
|
1077
|
+
async function closeServer(s, drainUtils) {
|
|
1078
|
+
s.destroyed = true;
|
|
1079
|
+
s.abortController.abort();
|
|
1080
|
+
if (s.streamingClient) {
|
|
1081
|
+
s.streamingClient.disconnect();
|
|
1082
|
+
s.streamingClient = null;
|
|
1083
|
+
}
|
|
1084
|
+
if (s.pollingTimer) {
|
|
1085
|
+
clearTimeout(s.pollingTimer);
|
|
1086
|
+
s.pollingTimer = null;
|
|
1087
|
+
}
|
|
1088
|
+
if (s.tracker) {
|
|
1089
|
+
await s.tracker.destroy();
|
|
1090
|
+
s.tracker = null;
|
|
1091
|
+
}
|
|
1092
|
+
drainUtils();
|
|
1093
|
+
s.datafile = null;
|
|
1094
|
+
s.ready = false;
|
|
1095
|
+
}
|
|
1096
|
+
async function refreshDatafile(s, server) {
|
|
1097
|
+
const result = await loadDatafile(s, "manual");
|
|
1098
|
+
if (result === "failed") {
|
|
1099
|
+
s.logger.warn(
|
|
1100
|
+
"AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
|
|
1101
|
+
);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (result === "applied" && !s.ready) onDatafileReadable(s, server);
|
|
1105
|
+
}
|
|
1106
|
+
function applyDatafileBootstrap(s, server, datafile) {
|
|
1107
|
+
if (s.destroyed) return;
|
|
1108
|
+
if (!acceptDatafile(datafile, "applyDatafileBootstrap", s.logger)) return;
|
|
1109
|
+
const changed = s.datafile?.publishedAt !== datafile.publishedAt;
|
|
1110
|
+
s.datafile = datafile;
|
|
1111
|
+
s.datafileEtag = null;
|
|
1112
|
+
if (!s.ready) {
|
|
1113
|
+
onDatafileReadable(s, server);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
initTracker(s, server);
|
|
1117
|
+
if (changed) {
|
|
1118
|
+
emit(s, "configUpdate", {
|
|
1119
|
+
publishedAt: datafile.publishedAt,
|
|
1120
|
+
reason: "manual"
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
async function loadDatafile(s, reason = "manual") {
|
|
1125
|
+
const outcome = await fetchDatafile({
|
|
1126
|
+
sdkKey: s.sdkKey,
|
|
1127
|
+
cdnHost: s.cdnHost,
|
|
1128
|
+
signal: s.abortController.signal,
|
|
1129
|
+
logger: s.logger,
|
|
1130
|
+
etag: s.datafileEtag
|
|
1131
|
+
});
|
|
1132
|
+
if (outcome.status === "ok" || outcome.status === "not-modified") {
|
|
1133
|
+
sendHeartbeat2(s);
|
|
1134
|
+
}
|
|
1135
|
+
if (outcome.status === "not-modified") return "unchanged";
|
|
1136
|
+
if (outcome.status !== "ok") return "failed";
|
|
1137
|
+
const changed = !s.datafile || s.datafile.publishedAt !== outcome.datafile.publishedAt;
|
|
1138
|
+
s.datafile = outcome.datafile;
|
|
1139
|
+
s.datafileEtag = outcome.etag;
|
|
1140
|
+
if (s.ready && changed) {
|
|
1141
|
+
emit(s, "configUpdate", {
|
|
1142
|
+
publishedAt: outcome.datafile.publishedAt,
|
|
1143
|
+
reason
|
|
1144
|
+
});
|
|
1145
|
+
s.logger.info("Datafile updated", {
|
|
1146
|
+
publishedAt: outcome.datafile.publishedAt,
|
|
1147
|
+
reason
|
|
1148
|
+
});
|
|
1149
|
+
startStreamingIfEnabled(s, () => {
|
|
1150
|
+
void loadDatafile(s, "stream");
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
return "applied";
|
|
1154
|
+
}
|
|
1155
|
+
function startPolling(s, server) {
|
|
1156
|
+
if (s.pollingTimer || s.destroyed) return;
|
|
1157
|
+
const schedule = () => {
|
|
1158
|
+
s.pollingTimer = setTimeout(() => {
|
|
1159
|
+
s.pollingTimer = null;
|
|
1160
|
+
void (async () => {
|
|
1161
|
+
const result = await loadDatafile(s, "poll");
|
|
1162
|
+
if (result === "applied" && !s.ready) onDatafileReadable(s, server);
|
|
1163
|
+
if (!s.destroyed) schedule();
|
|
1164
|
+
})();
|
|
1165
|
+
}, jitteredInterval(s.pollingInterval));
|
|
1166
|
+
};
|
|
1167
|
+
schedule();
|
|
1168
|
+
}
|
|
1169
|
+
function sendHeartbeat2(s) {
|
|
1170
|
+
const now = Date.now();
|
|
1171
|
+
if (now - s.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
|
|
1172
|
+
const posted = sendHeartbeat({
|
|
1173
|
+
sdkKey: s.sdkKey,
|
|
1174
|
+
sdkType: SDK_TYPE,
|
|
1175
|
+
sdkVersion: SDK_VERSION,
|
|
1176
|
+
datafileHeartbeatEndpoint: s.datafile?.heartbeatEndpoint,
|
|
1177
|
+
signal: s.abortController.signal
|
|
1178
|
+
});
|
|
1179
|
+
if (posted) s.lastHeartbeatAt = now;
|
|
732
1180
|
}
|
|
733
1181
|
|
|
734
|
-
// src/
|
|
735
|
-
import {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1182
|
+
// src/serverEvaluation.ts
|
|
1183
|
+
import {
|
|
1184
|
+
checkExpectedType,
|
|
1185
|
+
contextSummary,
|
|
1186
|
+
createFlag,
|
|
1187
|
+
findDatafileFlag,
|
|
1188
|
+
flagNotFoundMessage,
|
|
1189
|
+
notFoundFlag as notFoundFlag2,
|
|
1190
|
+
notReadyFlag
|
|
1191
|
+
} from "@avsbhq/core";
|
|
1192
|
+
|
|
1193
|
+
// src/serverDecision.ts
|
|
1194
|
+
import { evaluateFlag } from "@avsbhq/core/engine";
|
|
1195
|
+
import {
|
|
1196
|
+
bucketingKey as bucketingKey3,
|
|
1197
|
+
mapLegacySource,
|
|
1198
|
+
privateAttributesFrom,
|
|
1199
|
+
toLegacyEvalContext
|
|
1200
|
+
} from "@avsbhq/core";
|
|
1201
|
+
import { redactExposure } from "@avsbhq/utils/exposure";
|
|
1202
|
+
function evaluateDecision(s, flagKey, uc, defaultValue) {
|
|
1203
|
+
const reasons = [];
|
|
1204
|
+
const overrideKey = s.overrides.resolve(flagKey, uc.userId);
|
|
1205
|
+
const rtOver = overrideKey ? findVariation(s, flagKey, overrideKey) : null;
|
|
1206
|
+
if (rtOver) {
|
|
1207
|
+
reasons.push(`matched runtime override for flagKey="${flagKey}"`);
|
|
1208
|
+
return {
|
|
1209
|
+
value: rtOver.value,
|
|
1210
|
+
variationKey: rtOver.key,
|
|
1211
|
+
source: "runtimeOverride",
|
|
1212
|
+
ruleId: null,
|
|
1213
|
+
ruleType: null,
|
|
1214
|
+
reasons
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
if (s.stickyBucketService) {
|
|
1218
|
+
const sticky = resolveStickyBucket(s, flagKey, uc.userId);
|
|
1219
|
+
if (sticky !== void 0) {
|
|
1220
|
+
reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
|
|
1221
|
+
if (!uc.suppressExposure) {
|
|
1222
|
+
fireExposureForStickyAssignment(s, flagKey, sticky, uc);
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
value: sticky.vref.value,
|
|
1226
|
+
variationKey: sticky.vref.key,
|
|
1227
|
+
source: "sticky",
|
|
1228
|
+
ruleId: sticky.assignment.ruleId,
|
|
1229
|
+
ruleType: sticky.assignment.ruleType,
|
|
1230
|
+
reasons
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
|
|
1235
|
+
const onExposure = uc.suppressExposure ? void 0 : (event) => {
|
|
1236
|
+
deliverExposure(s, redactExposure(event, uc.privateAttributes));
|
|
1237
|
+
};
|
|
1238
|
+
const r = evaluateFlag(
|
|
1239
|
+
flagKey,
|
|
1240
|
+
defaultValue,
|
|
1241
|
+
legacyCtx,
|
|
1242
|
+
// Callers check readiness first, so a datafile is loaded here.
|
|
1243
|
+
s.datafile,
|
|
1244
|
+
onExposure,
|
|
1245
|
+
reasons
|
|
1246
|
+
);
|
|
1247
|
+
if (s.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
|
|
1248
|
+
const v = findVariation(s, flagKey, r.variationKey);
|
|
1249
|
+
if (v) {
|
|
1250
|
+
s.stickyBucketService.save(uc.userId, flagKey, {
|
|
1251
|
+
variationId: v.id,
|
|
1252
|
+
ruleId: r.ruleId,
|
|
1253
|
+
ruleType: r.ruleType,
|
|
1254
|
+
assignedAt: Date.now()
|
|
757
1255
|
});
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
value: r.value,
|
|
1260
|
+
variationKey: r.variationKey,
|
|
1261
|
+
source: mapLegacySource(r.source),
|
|
1262
|
+
ruleId: r.ruleId,
|
|
1263
|
+
ruleType: r.ruleType,
|
|
1264
|
+
reasons
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
function buildLegacyContext(context, suppressExposure) {
|
|
1268
|
+
const legacy = toLegacyEvalContext(context);
|
|
1269
|
+
const userId = legacy.attributes["userId"] ?? "";
|
|
1270
|
+
const privateAttrs = privateAttributesFrom(context);
|
|
1271
|
+
const { userId: _uid, ...rest } = legacy.attributes;
|
|
1272
|
+
return {
|
|
1273
|
+
userId,
|
|
1274
|
+
attributes: rest,
|
|
1275
|
+
suppressExposure,
|
|
1276
|
+
privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
|
|
776
1277
|
};
|
|
777
|
-
|
|
1278
|
+
}
|
|
1279
|
+
function manualExposure(s, server, flagKey, context) {
|
|
1280
|
+
if (!server.isReady() || !s.datafile || !s.tracker) return;
|
|
1281
|
+
const legacyCtx = buildLegacyContext(context, true);
|
|
1282
|
+
const result = evaluateDecision(s, flagKey, legacyCtx, void 0);
|
|
1283
|
+
if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
|
|
1284
|
+
if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
|
|
1285
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1286
|
+
if (!flag) return;
|
|
1287
|
+
const variation = flag.variations.find((v) => v.key === result.variationKey);
|
|
1288
|
+
if (!variation) return;
|
|
1289
|
+
deliverExposure(s, {
|
|
1290
|
+
visitorId: bucketingKey3(context),
|
|
1291
|
+
experimentId: result.ruleId,
|
|
1292
|
+
variationId: variation.id,
|
|
1293
|
+
eventName: "flag_exposure",
|
|
1294
|
+
revenue: null,
|
|
1295
|
+
timestamp: Date.now(),
|
|
1296
|
+
pageUrl: "",
|
|
1297
|
+
properties: { flagKey, flagId: flag.id, manuallyFired: true }
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
function resolveStickyBucket(s, flagKey, userId) {
|
|
1301
|
+
if (!s.stickyBucketService || !s.datafile) return void 0;
|
|
1302
|
+
const assignment = s.stickyBucketService.lookup(userId, flagKey);
|
|
1303
|
+
if (!assignment) return void 0;
|
|
1304
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1305
|
+
if (!flag) return void 0;
|
|
1306
|
+
const v = flag.variations.find((x) => x.id === assignment.variationId);
|
|
1307
|
+
if (!v) return void 0;
|
|
1308
|
+
return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
|
|
1309
|
+
}
|
|
1310
|
+
function findVariation(s, flagKey, variationKey) {
|
|
1311
|
+
if (!s.datafile) return null;
|
|
1312
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1313
|
+
if (!flag) return null;
|
|
1314
|
+
const v = flag.variations.find((x) => x.key === variationKey);
|
|
1315
|
+
if (!v) return null;
|
|
1316
|
+
return { value: v.value, key: v.key, id: v.id };
|
|
1317
|
+
}
|
|
1318
|
+
function fireExposureForStickyAssignment(s, flagKey, sticky, uc) {
|
|
1319
|
+
if (!s.tracker || !s.datafile) return;
|
|
1320
|
+
const flag = s.datafile.flags.find((f) => f.key === flagKey);
|
|
1321
|
+
if (!flag) return;
|
|
1322
|
+
const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
|
|
1323
|
+
if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
|
|
1324
|
+
s.logger.warn(
|
|
1325
|
+
"Sticky assignment references a rule no longer matching the datafile",
|
|
1326
|
+
{
|
|
1327
|
+
flagKey,
|
|
1328
|
+
userId: uc.userId,
|
|
1329
|
+
assignedRuleId: sticky.assignment.ruleId,
|
|
1330
|
+
assignedVariationId: sticky.vref.id
|
|
1331
|
+
}
|
|
1332
|
+
);
|
|
1333
|
+
emit(s, "stickyOrphan", {
|
|
1334
|
+
flagKey,
|
|
1335
|
+
userId: uc.userId,
|
|
1336
|
+
assignment: sticky.assignment
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
deliverExposure(s, {
|
|
1341
|
+
visitorId: uc.userId,
|
|
1342
|
+
experimentId: rule.id,
|
|
1343
|
+
variationId: sticky.vref.id,
|
|
1344
|
+
eventName: "flag_exposure",
|
|
1345
|
+
revenue: null,
|
|
1346
|
+
timestamp: Date.now(),
|
|
1347
|
+
pageUrl: "",
|
|
1348
|
+
properties: { flagKey, flagId: flag.id }
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// src/serverEvaluation.ts
|
|
1353
|
+
function evaluateServerFlag(s, server, flagKey, defaultValue, context, options) {
|
|
1354
|
+
if (!context) {
|
|
1355
|
+
warnOnce(
|
|
1356
|
+
s,
|
|
1357
|
+
"no-context",
|
|
1358
|
+
`getFlag("${flagKey}") was called without a context, so it cannot be evaluated and returns the default you passed. Bind one per request with server.forUser({ kind: "user", key: userId }), or pass it as the third argument: server.getFlag(key, default, { kind: "user", key: userId }).`
|
|
1359
|
+
);
|
|
1360
|
+
return notReadyFlag(
|
|
1361
|
+
defaultValue,
|
|
1362
|
+
"no context passed; call server.forUser(context) or pass the context positionally"
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
if (!server.isReady() || !s.datafile) {
|
|
1366
|
+
warnOnce(
|
|
1367
|
+
s,
|
|
1368
|
+
"not-ready",
|
|
1369
|
+
`getFlag("${flagKey}") was called before the datafile loaded, so it returns the default you passed and flag.source is "not_ready". Await server.onReady() during boot, or pass a bootstrap datafile to the constructor for an instant start.`
|
|
1370
|
+
);
|
|
1371
|
+
return notReadyFlag(defaultValue, "SDK not ready: no datafile loaded yet");
|
|
1372
|
+
}
|
|
1373
|
+
const decide = s.resolveDecideOptions(options?.decideOptions);
|
|
1374
|
+
const flag = options?.forcedVariationKey ? buildForcedFlag(
|
|
1375
|
+
s,
|
|
1376
|
+
flagKey,
|
|
1377
|
+
options.forcedVariationKey,
|
|
1378
|
+
defaultValue,
|
|
1379
|
+
context,
|
|
1380
|
+
options
|
|
1381
|
+
) : evaluateAndBuild(
|
|
1382
|
+
s,
|
|
1383
|
+
flagKey,
|
|
1384
|
+
defaultValue,
|
|
1385
|
+
context,
|
|
1386
|
+
decide,
|
|
1387
|
+
options
|
|
1388
|
+
);
|
|
1389
|
+
return flag;
|
|
1390
|
+
}
|
|
1391
|
+
function evaluateAndBuild(s, flagKey, defaultValue, context, decide, options) {
|
|
1392
|
+
const legacyCtx = buildLegacyContext(context, decide.suppressExposure);
|
|
1393
|
+
const result = evaluateDecision(s, flagKey, legacyCtx, defaultValue);
|
|
1394
|
+
if (result.source === "not_found") {
|
|
1395
|
+
warnOnce(s, `missing:${flagKey}`, flagNotFoundMessage(flagKey));
|
|
1396
|
+
}
|
|
1397
|
+
const typeMismatch = checkType(s, flagKey, options?.expectedType, result);
|
|
1398
|
+
if (typeMismatch) {
|
|
1399
|
+
s.logger.warn(
|
|
1400
|
+
`Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
|
|
1401
|
+
);
|
|
1402
|
+
const fallback = notFoundFlag2(defaultValue, typeMismatch);
|
|
1403
|
+
recordDecision(s, flagKey, fallback, context, options);
|
|
1404
|
+
return fallback;
|
|
1405
|
+
}
|
|
1406
|
+
const flag = toFlag(result, defaultValue);
|
|
1407
|
+
recordDecision(s, flagKey, flag, context, options);
|
|
1408
|
+
return flag;
|
|
1409
|
+
}
|
|
1410
|
+
function checkType(s, flagKey, expectedType, result) {
|
|
1411
|
+
if (!expectedType) return null;
|
|
1412
|
+
if (result.source === "not_found" || result.source === "disabled") return null;
|
|
1413
|
+
const datafileFlag = findDatafileFlag(s.datafile, flagKey);
|
|
1414
|
+
return checkExpectedType(datafileFlag, expectedType, result.value) ?? null;
|
|
1415
|
+
}
|
|
1416
|
+
function toFlag(result, defaultValue) {
|
|
1417
|
+
return createFlag({
|
|
1418
|
+
value: result.value ?? defaultValue,
|
|
1419
|
+
variationKey: result.variationKey,
|
|
1420
|
+
source: result.source,
|
|
1421
|
+
ruleId: result.ruleId,
|
|
1422
|
+
ruleType: result.ruleType,
|
|
1423
|
+
reasons: result.reasons,
|
|
1424
|
+
evaluatedAt: Date.now(),
|
|
1425
|
+
durationMicros: 0
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
function buildForcedFlag(s, flagKey, variationKey, defaultValue, context, options) {
|
|
1429
|
+
const vref = findVariation(s, flagKey, variationKey);
|
|
1430
|
+
const flag = createFlag({
|
|
1431
|
+
value: vref ? vref.value : defaultValue,
|
|
1432
|
+
variationKey: vref ? vref.key : null,
|
|
1433
|
+
source: "runtimeOverride",
|
|
1434
|
+
ruleId: null,
|
|
1435
|
+
ruleType: null,
|
|
1436
|
+
reasons: [`forced decision: variationKey="${variationKey}"`]
|
|
1437
|
+
});
|
|
1438
|
+
recordDecision(s, flagKey, flag, context, options);
|
|
1439
|
+
return flag;
|
|
1440
|
+
}
|
|
1441
|
+
function recordDecision(s, flagKey, flag, context, options) {
|
|
1442
|
+
const summary = contextSummary(context);
|
|
1443
|
+
emit(s, "evaluation", {
|
|
1444
|
+
flagKey,
|
|
1445
|
+
flag,
|
|
1446
|
+
contextKeys: summary.map((entry) => entry.key)
|
|
1447
|
+
});
|
|
1448
|
+
if (!options?.decisionLog) return;
|
|
1449
|
+
appendDecisionLogEntry(options.decisionLog, {
|
|
1450
|
+
flagKey,
|
|
1451
|
+
value: flag.value,
|
|
1452
|
+
variationKey: flag.variationKey,
|
|
1453
|
+
source: flag.source,
|
|
1454
|
+
ruleId: flag.ruleId,
|
|
1455
|
+
ruleType: flag.ruleType,
|
|
1456
|
+
reasons: flag.reasons,
|
|
1457
|
+
evaluatedAt: flag.evaluatedAt,
|
|
1458
|
+
durationMicros: flag.durationMicros,
|
|
1459
|
+
contextSummary: summary,
|
|
1460
|
+
privateValues: privateAttributeValues(context)
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
function evaluateAllServerFlags(s, server, context, options) {
|
|
1464
|
+
if (!context) {
|
|
1465
|
+
warnOnce(
|
|
1466
|
+
s,
|
|
1467
|
+
"get-all-no-context",
|
|
1468
|
+
"getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
|
|
1469
|
+
);
|
|
1470
|
+
return {};
|
|
1471
|
+
}
|
|
1472
|
+
if (!server.isReady() || !s.datafile) {
|
|
1473
|
+
warnOnce(
|
|
1474
|
+
s,
|
|
1475
|
+
"get-all-not-ready",
|
|
1476
|
+
"getAllFlags() was called before the datafile loaded and returned an empty object, which is indistinguishable from a project with no flags. Await server.onReady() first."
|
|
1477
|
+
);
|
|
1478
|
+
return {};
|
|
1479
|
+
}
|
|
1480
|
+
const fireExposures = options?.fireExposures === true;
|
|
1481
|
+
const decideOptions = [...options?.decideOptions ?? []];
|
|
1482
|
+
if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
|
|
1483
|
+
decideOptions.push("DISABLE_EXPOSURE");
|
|
1484
|
+
}
|
|
1485
|
+
const out = {};
|
|
1486
|
+
for (const entry of s.datafile.flags) {
|
|
1487
|
+
out[entry.key] = server.evaluateInternal(entry.key, void 0, context, {
|
|
1488
|
+
decideOptions,
|
|
1489
|
+
decisionLog: options?.decisionLog
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
return out;
|
|
778
1493
|
}
|
|
779
1494
|
|
|
780
1495
|
// src/AvsbServer.ts
|
|
781
|
-
var DEFAULT_EVENTS = {
|
|
782
|
-
flushInterval: 2e3,
|
|
783
|
-
batchSize: 10,
|
|
784
|
-
maxQueueSize: 1e4,
|
|
785
|
-
retryAttempts: 3
|
|
786
|
-
};
|
|
787
|
-
var SDK_TYPE = "@avsbhq/node";
|
|
788
|
-
var HEARTBEAT_MIN_INTERVAL_MS = 12e4;
|
|
789
1496
|
var AvsbServer = class {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
pollingTimer;
|
|
808
|
-
abortController = new AbortController();
|
|
809
|
-
listeners = /* @__PURE__ */ new Map();
|
|
810
|
-
readyPromise = null;
|
|
811
|
-
pendingEvents = [];
|
|
812
|
-
pendingPurchases = [];
|
|
813
|
-
/**
|
|
814
|
-
* Exposures produced before the tracker existed. Third member of the
|
|
815
|
-
* pre-ready family alongside `pendingEvents` and `pendingPurchases`; it used
|
|
816
|
-
* to be missing, and exposures were dropped on the floor instead.
|
|
817
|
-
*/
|
|
818
|
-
pendingExposures = [];
|
|
819
|
-
/** ms-epoch of the last liveness heartbeat, for the throttle. */
|
|
820
|
-
lastHeartbeatAt = 0;
|
|
821
|
-
/** Keys of one-time warnings, so a request loop cannot flood a log. */
|
|
822
|
-
warnedOnce = /* @__PURE__ */ new Set();
|
|
1497
|
+
/** Everything mutable about this client. See ./serverState. */
|
|
1498
|
+
s;
|
|
1499
|
+
// Private seams kept on the instance because
|
|
1500
|
+
// AvsbServer.exposureDelivery.test.ts reaches them directly to stage a
|
|
1501
|
+
// missing tracker. Each one is a straight pass-through to the state.
|
|
1502
|
+
get tracker() {
|
|
1503
|
+
return this.s.tracker;
|
|
1504
|
+
}
|
|
1505
|
+
set tracker(tracker) {
|
|
1506
|
+
this.s.tracker = tracker;
|
|
1507
|
+
}
|
|
1508
|
+
deliverExposure(event) {
|
|
1509
|
+
deliverExposure(this.s, event);
|
|
1510
|
+
}
|
|
1511
|
+
initTracker() {
|
|
1512
|
+
initTracker(this.s, this);
|
|
1513
|
+
}
|
|
823
1514
|
/**
|
|
824
1515
|
* `client.utils.*` surface, constructed lazily on first access so the utils
|
|
825
1516
|
* package is only imported when actually consumed.
|
|
@@ -831,9 +1522,10 @@ var AvsbServer = class {
|
|
|
831
1522
|
getAllFlags: (ctx, opts) => this.getAllFlags(ctx, opts),
|
|
832
1523
|
getFlag: (flagKey, defaultValue, context) => this.getFlag(flagKey, defaultValue, context),
|
|
833
1524
|
track: (eventKey, payload) => this.track(eventKey, payload),
|
|
834
|
-
on: (event, cb) => this.
|
|
835
|
-
getDatafile: () => this.datafile,
|
|
836
|
-
applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df)
|
|
1525
|
+
on: (event, cb) => onSharedEvent(this.s, this, event, cb),
|
|
1526
|
+
getDatafile: () => this.s.datafile,
|
|
1527
|
+
applyDatafileBootstrap: (df) => this.applyDatafileBootstrap(df),
|
|
1528
|
+
refreshDatafile: () => this.refreshDatafile()
|
|
837
1529
|
});
|
|
838
1530
|
}
|
|
839
1531
|
return this._utils;
|
|
@@ -846,9 +1538,9 @@ var AvsbServer = class {
|
|
|
846
1538
|
get datasets() {
|
|
847
1539
|
if (!this._datasets) {
|
|
848
1540
|
this._datasets = createDatasetsClient({
|
|
849
|
-
sdkKey: this.sdkKey,
|
|
850
|
-
getDatafile: () => this.datafile,
|
|
851
|
-
logger: this.logger
|
|
1541
|
+
sdkKey: this.s.sdkKey,
|
|
1542
|
+
getDatafile: () => this.s.datafile,
|
|
1543
|
+
logger: this.s.logger
|
|
852
1544
|
});
|
|
853
1545
|
}
|
|
854
1546
|
return this._datasets;
|
|
@@ -863,132 +1555,38 @@ var AvsbServer = class {
|
|
|
863
1555
|
get recs() {
|
|
864
1556
|
if (!this._recs) {
|
|
865
1557
|
this._recs = createRecsClient({
|
|
866
|
-
sdkKey: this.sdkKey,
|
|
867
|
-
getDatafile: () => this.datafile,
|
|
868
|
-
getTracker: () => this.tracker,
|
|
869
|
-
logger: this.logger
|
|
1558
|
+
sdkKey: this.s.sdkKey,
|
|
1559
|
+
getDatafile: () => this.s.datafile,
|
|
1560
|
+
getTracker: () => this.s.tracker,
|
|
1561
|
+
logger: this.s.logger
|
|
870
1562
|
});
|
|
871
1563
|
}
|
|
872
1564
|
return this._recs;
|
|
873
1565
|
}
|
|
874
1566
|
constructor(options) {
|
|
875
|
-
this.
|
|
876
|
-
this.
|
|
877
|
-
this.cdnHost = options.cdnHost;
|
|
878
|
-
this.initTimeout = options.initTimeout ?? 1e4;
|
|
879
|
-
this.logger = resolveServerLogger(options);
|
|
880
|
-
this.stickyBucketService = options.stickyBucketService ?? null;
|
|
881
|
-
this.eventOptions = { ...DEFAULT_EVENTS, ...options.events };
|
|
882
|
-
this.streamingEnabled = options.streaming !== false;
|
|
883
|
-
this.streamingEndpointOption = options.streamingEndpoint ?? null;
|
|
884
|
-
this.datafile = options.bootstrap ?? null;
|
|
885
|
-
this.ready = !!options.bootstrap;
|
|
886
|
-
this.destroyed = false;
|
|
887
|
-
this.tracker = null;
|
|
888
|
-
this.streamingClient = null;
|
|
889
|
-
this.pollingTimer = null;
|
|
890
|
-
this.resolveDecideOptions = createDecideOptionResolver(this.logger);
|
|
891
|
-
const keyShape = validateSdkKeyShape(this.sdkKey);
|
|
892
|
-
if (!keyShape.valid && keyShape.message) {
|
|
893
|
-
this.logger.error(keyShape.message);
|
|
894
|
-
}
|
|
895
|
-
if (this.datafile) this.initTracker();
|
|
1567
|
+
this.s = createServerState(options);
|
|
1568
|
+
if (this.s.datafile) this.initTracker();
|
|
896
1569
|
}
|
|
897
|
-
//
|
|
898
|
-
// Readiness
|
|
899
|
-
// ---------------------------------------------------------------------------
|
|
1570
|
+
// ---- Readiness ----
|
|
900
1571
|
/**
|
|
901
1572
|
* Await the init attempt. Never rejects; always resolves to an InitResult.
|
|
902
1573
|
* Calling it twice returns the same promise.
|
|
903
1574
|
*/
|
|
904
1575
|
onReady() {
|
|
905
|
-
if (this.readyPromise) return this.readyPromise;
|
|
906
|
-
this.readyPromise = this.
|
|
907
|
-
return this.readyPromise;
|
|
1576
|
+
if (this.s.readyPromise) return this.s.readyPromise;
|
|
1577
|
+
this.s.readyPromise = initializeAndReady(this.s, this);
|
|
1578
|
+
return this.s.readyPromise;
|
|
908
1579
|
}
|
|
909
1580
|
/** True when a datafile is loaded and flags are answerable right now. */
|
|
910
1581
|
isReady() {
|
|
911
|
-
return this.ready && this.datafile !== null;
|
|
912
|
-
}
|
|
913
|
-
async initializeAndReady() {
|
|
914
|
-
if (this.ready && this.datafile) {
|
|
915
|
-
this.onDatafileReadable();
|
|
916
|
-
return { success: true, source: "bootstrap" };
|
|
917
|
-
}
|
|
918
|
-
try {
|
|
919
|
-
const outcome = await Promise.race([
|
|
920
|
-
// At init there is no stored etag, so 'unchanged' cannot happen here;
|
|
921
|
-
// it is folded in anyway so a future caching layer cannot make init
|
|
922
|
-
// silently report failure.
|
|
923
|
-
this.loadDatafile().then((result) => ({
|
|
924
|
-
applied: result !== "failed",
|
|
925
|
-
timedOut: false
|
|
926
|
-
})),
|
|
927
|
-
new Promise(
|
|
928
|
-
(resolve) => setTimeout(
|
|
929
|
-
() => resolve({ applied: false, timedOut: true }),
|
|
930
|
-
this.initTimeout
|
|
931
|
-
)
|
|
932
|
-
)
|
|
933
|
-
]);
|
|
934
|
-
if (outcome.timedOut) {
|
|
935
|
-
this.startPolling();
|
|
936
|
-
const error2 = new Error(
|
|
937
|
-
`AvsB init timed out after ${this.initTimeout}ms waiting for the datafile. Polling continues in the background, so flags start working as soon as the request completes; raise initTimeout if your cold start is slower.`
|
|
938
|
-
);
|
|
939
|
-
this.emit("error", error2);
|
|
940
|
-
return { success: false, source: "timeout", error: error2 };
|
|
941
|
-
}
|
|
942
|
-
if (outcome.applied) {
|
|
943
|
-
this.onDatafileReadable();
|
|
944
|
-
return { success: true, source: "network" };
|
|
945
|
-
}
|
|
946
|
-
this.startPolling();
|
|
947
|
-
const error = new Error(
|
|
948
|
-
'AvsB init failed: no datafile could be loaded, so every flag serves the default you passed and flag.source is "not_ready". The datafile request error is logged above with the URL and status. Polling continues.'
|
|
949
|
-
);
|
|
950
|
-
this.emit("error", error);
|
|
951
|
-
return { success: false, source: "error", error };
|
|
952
|
-
} catch (e) {
|
|
953
|
-
this.startPolling();
|
|
954
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
955
|
-
this.emit("error", error);
|
|
956
|
-
return { success: false, source: "error", error };
|
|
957
|
-
}
|
|
1582
|
+
return this.s.ready && this.s.datafile !== null;
|
|
958
1583
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
this.
|
|
962
|
-
this.ready = true;
|
|
963
|
-
this.startPolling();
|
|
964
|
-
this.startStreamingIfEnabled();
|
|
965
|
-
this.sendHeartbeat();
|
|
966
|
-
this.emit("ready");
|
|
967
|
-
}
|
|
968
|
-
// ---------------------------------------------------------------------------
|
|
969
|
-
// Lifecycle
|
|
970
|
-
// ---------------------------------------------------------------------------
|
|
971
|
-
async close() {
|
|
972
|
-
this.destroyed = true;
|
|
973
|
-
this.abortController.abort();
|
|
974
|
-
if (this.streamingClient) {
|
|
975
|
-
this.streamingClient.disconnect();
|
|
976
|
-
this.streamingClient = null;
|
|
977
|
-
}
|
|
978
|
-
if (this.pollingTimer) {
|
|
979
|
-
clearTimeout(this.pollingTimer);
|
|
980
|
-
this.pollingTimer = null;
|
|
981
|
-
}
|
|
982
|
-
if (this.tracker) {
|
|
983
|
-
await this.tracker.destroy();
|
|
984
|
-
this.tracker = null;
|
|
985
|
-
}
|
|
986
|
-
this._utils?._drain();
|
|
987
|
-
this.datafile = null;
|
|
988
|
-
this.ready = false;
|
|
1584
|
+
// ---- Lifecycle ----
|
|
1585
|
+
close() {
|
|
1586
|
+
return closeServer(this.s, () => this._utils?._drain());
|
|
989
1587
|
}
|
|
990
1588
|
async flush() {
|
|
991
|
-
if (this.tracker) await this.tracker.flush();
|
|
1589
|
+
if (this.s.tracker) await this.s.tracker.flush();
|
|
992
1590
|
}
|
|
993
1591
|
/**
|
|
994
1592
|
* Fetch the datafile now and apply it if it changed.
|
|
@@ -996,15 +1594,8 @@ var AvsbServer = class {
|
|
|
996
1594
|
* A 304 is not a problem and is not warned about: it means the datafile in
|
|
997
1595
|
* memory IS the published one.
|
|
998
1596
|
*/
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
if (result === "failed") {
|
|
1002
|
-
this.logger.warn(
|
|
1003
|
-
"AvsB datafile refresh did not apply a new datafile; the SDK keeps serving the one it already has. The request error is logged above."
|
|
1004
|
-
);
|
|
1005
|
-
return;
|
|
1006
|
-
}
|
|
1007
|
-
if (result === "applied" && !this.ready) this.onDatafileReadable();
|
|
1597
|
+
refreshDatafile() {
|
|
1598
|
+
return refreshDatafile(this.s, this);
|
|
1008
1599
|
}
|
|
1009
1600
|
/**
|
|
1010
1601
|
* Adopt a datafile received out of band: a bootstrap blob from
|
|
@@ -1016,33 +1607,16 @@ var AvsbServer = class {
|
|
|
1016
1607
|
* handoff silently do nothing.
|
|
1017
1608
|
*/
|
|
1018
1609
|
applyDatafileBootstrap(datafile) {
|
|
1019
|
-
|
|
1020
|
-
const changed = this.datafile?.publishedAt !== datafile.publishedAt;
|
|
1021
|
-
this.datafile = datafile;
|
|
1022
|
-
this.datafileEtag = null;
|
|
1023
|
-
if (!this.ready) {
|
|
1024
|
-
this.onDatafileReadable();
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
this.initTracker();
|
|
1028
|
-
if (changed) {
|
|
1029
|
-
this.emit("configUpdate", {
|
|
1030
|
-
publishedAt: datafile.publishedAt,
|
|
1031
|
-
reason: "manual"
|
|
1032
|
-
});
|
|
1033
|
-
}
|
|
1610
|
+
applyDatafileBootstrap(this.s, this, datafile);
|
|
1034
1611
|
}
|
|
1035
1612
|
/** The datafile currently in memory, or null before init completes. */
|
|
1036
1613
|
getDatafile() {
|
|
1037
|
-
return this.datafile;
|
|
1614
|
+
return this.s.datafile;
|
|
1038
1615
|
}
|
|
1039
|
-
//
|
|
1040
|
-
// Evaluation
|
|
1041
|
-
//
|
|
1616
|
+
// ---- Evaluation ----
|
|
1042
1617
|
// Context is the THIRD POSITIONAL argument on every call (B3), matching
|
|
1043
1618
|
// @avsbhq/edge. `forUser(ctx)` is the primary server pattern and binds it
|
|
1044
1619
|
// once per request.
|
|
1045
|
-
// ---------------------------------------------------------------------------
|
|
1046
1620
|
getFlag(flagKey, defaultValue, context, options) {
|
|
1047
1621
|
return this.evaluateInternal(flagKey, defaultValue, context, options);
|
|
1048
1622
|
}
|
|
@@ -1076,60 +1650,7 @@ var AvsbServer = class {
|
|
|
1076
1650
|
* published API: its options bag carries package-internal fields.
|
|
1077
1651
|
*/
|
|
1078
1652
|
evaluateInternal(flagKey, defaultValue, context, options) {
|
|
1079
|
-
|
|
1080
|
-
this.warnOnce(
|
|
1081
|
-
"no-context",
|
|
1082
|
-
`getFlag("${flagKey}") was called without a context, so it cannot be evaluated and returns the default you passed. Bind one per request with server.forUser({ kind: "user", key: userId }), or pass it as the third argument: server.getFlag(key, default, { kind: "user", key: userId }).`
|
|
1083
|
-
);
|
|
1084
|
-
return notReadyFlag(
|
|
1085
|
-
defaultValue,
|
|
1086
|
-
"no context passed; call server.forUser(context) or pass the context positionally"
|
|
1087
|
-
);
|
|
1088
|
-
}
|
|
1089
|
-
if (!this.isReady() || !this.datafile) {
|
|
1090
|
-
this.warnOnce(
|
|
1091
|
-
"not-ready",
|
|
1092
|
-
`getFlag("${flagKey}") was called before the datafile loaded, so it returns the default you passed and flag.source is "not_ready". Await server.onReady() during boot, or pass a bootstrap datafile to the constructor for an instant start.`
|
|
1093
|
-
);
|
|
1094
|
-
return notReadyFlag(defaultValue, "SDK not ready: no datafile loaded yet");
|
|
1095
|
-
}
|
|
1096
|
-
const decide = this.resolveDecideOptions(options?.decideOptions);
|
|
1097
|
-
const flag = options?.forcedVariationKey ? this.buildForcedFlag(
|
|
1098
|
-
flagKey,
|
|
1099
|
-
options.forcedVariationKey,
|
|
1100
|
-
defaultValue,
|
|
1101
|
-
context,
|
|
1102
|
-
options
|
|
1103
|
-
) : this.evaluateAndBuild(
|
|
1104
|
-
flagKey,
|
|
1105
|
-
defaultValue,
|
|
1106
|
-
context,
|
|
1107
|
-
decide,
|
|
1108
|
-
options
|
|
1109
|
-
);
|
|
1110
|
-
return flag;
|
|
1111
|
-
}
|
|
1112
|
-
evaluateAndBuild(flagKey, defaultValue, context, decide, options) {
|
|
1113
|
-
const legacyCtx = this.buildLegacyContext(context, decide.suppressExposure);
|
|
1114
|
-
const result = this.evaluate(flagKey, legacyCtx, defaultValue);
|
|
1115
|
-
const typeMismatch = this.checkType(flagKey, options?.expectedType, result);
|
|
1116
|
-
if (typeMismatch) {
|
|
1117
|
-
this.logger.warn(
|
|
1118
|
-
`Type mismatch on flag "${flagKey}": ${typeMismatch}; returning the default.`
|
|
1119
|
-
);
|
|
1120
|
-
const fallback = notFoundFlag2(defaultValue, typeMismatch);
|
|
1121
|
-
this.recordDecision(flagKey, fallback, context, options);
|
|
1122
|
-
return fallback;
|
|
1123
|
-
}
|
|
1124
|
-
const flag = this.toFlag(result, defaultValue);
|
|
1125
|
-
this.recordDecision(flagKey, flag, context, options);
|
|
1126
|
-
return flag;
|
|
1127
|
-
}
|
|
1128
|
-
checkType(flagKey, expectedType, result) {
|
|
1129
|
-
if (!expectedType) return null;
|
|
1130
|
-
if (result.source === "not_found" || result.source === "disabled") return null;
|
|
1131
|
-
const datafileFlag = findDatafileFlag(this.datafile, flagKey);
|
|
1132
|
-
return checkExpectedType(datafileFlag, expectedType, result.value) ?? null;
|
|
1653
|
+
return evaluateServerFlag(this.s, this, flagKey, defaultValue, context, options);
|
|
1133
1654
|
}
|
|
1134
1655
|
/**
|
|
1135
1656
|
* Every flag in the environment, evaluated against one context.
|
|
@@ -1140,37 +1661,9 @@ var AvsbServer = class {
|
|
|
1140
1661
|
* arguments.
|
|
1141
1662
|
*/
|
|
1142
1663
|
getAllFlags(context, options) {
|
|
1143
|
-
|
|
1144
|
-
this.warnOnce(
|
|
1145
|
-
"get-all-no-context",
|
|
1146
|
-
"getAllFlags() was called without a context and returned an empty object. Pass the context positionally, or use server.forUser(context).getAllFlags()."
|
|
1147
|
-
);
|
|
1148
|
-
return {};
|
|
1149
|
-
}
|
|
1150
|
-
if (!this.isReady() || !this.datafile) {
|
|
1151
|
-
this.warnOnce(
|
|
1152
|
-
"get-all-not-ready",
|
|
1153
|
-
"getAllFlags() was called before the datafile loaded and returned an empty object, which is indistinguishable from a project with no flags. Await server.onReady() first."
|
|
1154
|
-
);
|
|
1155
|
-
return {};
|
|
1156
|
-
}
|
|
1157
|
-
const fireExposures = options?.fireExposures === true;
|
|
1158
|
-
const decideOptions = [...options?.decideOptions ?? []];
|
|
1159
|
-
if (!fireExposures && !decideOptions.includes("DISABLE_EXPOSURE")) {
|
|
1160
|
-
decideOptions.push("DISABLE_EXPOSURE");
|
|
1161
|
-
}
|
|
1162
|
-
const out = {};
|
|
1163
|
-
for (const entry of this.datafile.flags) {
|
|
1164
|
-
out[entry.key] = this.evaluateInternal(entry.key, void 0, context, {
|
|
1165
|
-
decideOptions,
|
|
1166
|
-
decisionLog: options?.decisionLog
|
|
1167
|
-
});
|
|
1168
|
-
}
|
|
1169
|
-
return out;
|
|
1664
|
+
return evaluateAllServerFlags(this.s, this, context, options);
|
|
1170
1665
|
}
|
|
1171
|
-
//
|
|
1172
|
-
// Tracking
|
|
1173
|
-
// ---------------------------------------------------------------------------
|
|
1666
|
+
// ---- Tracking ----
|
|
1174
1667
|
/**
|
|
1175
1668
|
* Queue a metric (conversion) event. Synchronous: it returns as soon as the
|
|
1176
1669
|
* event is queued, and the tracker batches it.
|
|
@@ -1180,30 +1673,7 @@ var AvsbServer = class {
|
|
|
1180
1673
|
* the server; see the README for why the two runtimes read differently here.
|
|
1181
1674
|
*/
|
|
1182
1675
|
track(eventKey, payload) {
|
|
1183
|
-
|
|
1184
|
-
this.warnOnce(
|
|
1185
|
-
"track-properties",
|
|
1186
|
-
`track("${eventKey}") was called with properties, which are NOT stored for metric events: the ingestion contract for a conversion keeps the event name, the numeric value, the timestamp, and the visitor id. Exposure events do carry properties. Nothing else about the call is affected.`
|
|
1187
|
-
);
|
|
1188
|
-
}
|
|
1189
|
-
if (!this.tracker) {
|
|
1190
|
-
this.enqueuePending(eventKey, payload);
|
|
1191
|
-
return;
|
|
1192
|
-
}
|
|
1193
|
-
this.tracker.queueMetric({
|
|
1194
|
-
visitorId: bucketingKey2(payload.context),
|
|
1195
|
-
eventName: eventKey,
|
|
1196
|
-
// TWO columns, not one. `revenue` is money in decimal major units;
|
|
1197
|
-
// `value` is the numeric metric value for average-value metrics. Storage
|
|
1198
|
-
// declares both (`metric_events.revenue`, `metric_events.value`). This
|
|
1199
|
-
// SDK used to ship `payload.value` into the revenue column and could
|
|
1200
|
-
// never write the value column at all, so a quantity was recorded as
|
|
1201
|
-
// money and every average-value metric read empty.
|
|
1202
|
-
revenue: payload.revenue ?? null,
|
|
1203
|
-
value: payload.value ?? null,
|
|
1204
|
-
timestamp: Date.now(),
|
|
1205
|
-
pageUrl: ""
|
|
1206
|
-
});
|
|
1676
|
+
trackMetric(this.s, eventKey, payload);
|
|
1207
1677
|
}
|
|
1208
1678
|
/**
|
|
1209
1679
|
* Send a purchase to `/v1/collect/orders`.
|
|
@@ -1211,20 +1681,8 @@ var AvsbServer = class {
|
|
|
1211
1681
|
* Queued before the tracker exists, exactly like `track` (they used to
|
|
1212
1682
|
* disagree: one queued, the other warned and dropped the order).
|
|
1213
1683
|
*/
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
if (!this.tracker) {
|
|
1217
|
-
if (this.pendingPurchases.length >= this.eventOptions.maxQueueSize) {
|
|
1218
|
-
this.pendingPurchases.shift();
|
|
1219
|
-
this.warnOnce(
|
|
1220
|
-
"pending-purchase-full",
|
|
1221
|
-
`Pre-ready purchase queue reached ${this.eventOptions.maxQueueSize} orders; the oldest is being dropped. This means the datafile never loaded: check the datafile error logged above.`
|
|
1222
|
-
);
|
|
1223
|
-
}
|
|
1224
|
-
this.pendingPurchases.push({ visitorId, order });
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
return this.tracker.trackPurchase(visitorId, order);
|
|
1684
|
+
trackPurchase(visitorId, order) {
|
|
1685
|
+
return trackPurchase(this.s, visitorId, order);
|
|
1228
1686
|
}
|
|
1229
1687
|
/**
|
|
1230
1688
|
* Mark the moment a signup or login claimed an anonymous session. Same shape
|
|
@@ -1245,8 +1703,8 @@ var AvsbServer = class {
|
|
|
1245
1703
|
* the README for the cross-surface version of the same rule.
|
|
1246
1704
|
*/
|
|
1247
1705
|
alias(previousContext, newContext) {
|
|
1248
|
-
this.logger.debug(
|
|
1249
|
-
`alias: ${
|
|
1706
|
+
this.s.logger.debug(
|
|
1707
|
+
`alias: ${bucketingKey4(previousContext)} to ${bucketingKey4(newContext)}`
|
|
1250
1708
|
);
|
|
1251
1709
|
this.track("avsb_alias", { context: newContext });
|
|
1252
1710
|
}
|
|
@@ -1256,29 +1714,9 @@ var AvsbServer = class {
|
|
|
1256
1714
|
* now actually seen the result.
|
|
1257
1715
|
*/
|
|
1258
1716
|
manualExposure(flagKey, context) {
|
|
1259
|
-
|
|
1260
|
-
const legacyCtx = this.buildLegacyContext(context, true);
|
|
1261
|
-
const result = this.evaluate(flagKey, legacyCtx, void 0);
|
|
1262
|
-
if (result.source !== "rule" || !result.ruleId || !result.variationKey) return;
|
|
1263
|
-
if (result.ruleType !== "ab_test" && result.ruleType !== "targeted_delivery") return;
|
|
1264
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1265
|
-
if (!flag) return;
|
|
1266
|
-
const variation = flag.variations.find((v) => v.key === result.variationKey);
|
|
1267
|
-
if (!variation) return;
|
|
1268
|
-
this.deliverExposure({
|
|
1269
|
-
visitorId: bucketingKey2(context),
|
|
1270
|
-
experimentId: result.ruleId,
|
|
1271
|
-
variationId: variation.id,
|
|
1272
|
-
eventName: "flag_exposure",
|
|
1273
|
-
revenue: null,
|
|
1274
|
-
timestamp: Date.now(),
|
|
1275
|
-
pageUrl: "",
|
|
1276
|
-
properties: { flagKey, flagId: flag.id, manuallyFired: true }
|
|
1277
|
-
});
|
|
1717
|
+
manualExposure(this.s, this, flagKey, context);
|
|
1278
1718
|
}
|
|
1279
|
-
//
|
|
1280
|
-
// Per-request scoping
|
|
1281
|
-
// ---------------------------------------------------------------------------
|
|
1719
|
+
// ---- Per-request scoping ----
|
|
1282
1720
|
/** A client with the context bound. The primary server pattern. */
|
|
1283
1721
|
forUser(context) {
|
|
1284
1722
|
return createUserBoundClient(this, context);
|
|
@@ -1287,437 +1725,32 @@ var AvsbServer = class {
|
|
|
1287
1725
|
createUserContext(context) {
|
|
1288
1726
|
return createUserBoundClient(this, context);
|
|
1289
1727
|
}
|
|
1290
|
-
//
|
|
1291
|
-
// Overrides
|
|
1292
|
-
// ---------------------------------------------------------------------------
|
|
1728
|
+
// ---- Overrides ----
|
|
1293
1729
|
setOverrideForUser(flagKey, contextKey, variationKey, contextKind = "user") {
|
|
1294
|
-
this.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
|
|
1730
|
+
this.s.overrides.setForContext(flagKey, contextKey, variationKey, contextKind);
|
|
1295
1731
|
}
|
|
1296
1732
|
setGlobalOverride(flagKey, variationKey) {
|
|
1297
|
-
this.overrides.setGlobal(flagKey, variationKey);
|
|
1733
|
+
this.s.overrides.setGlobal(flagKey, variationKey);
|
|
1298
1734
|
}
|
|
1299
1735
|
clearOverrideForUser(flagKey, contextKey, contextKind = "user") {
|
|
1300
|
-
this.overrides.clearForContext(flagKey, contextKey, contextKind);
|
|
1736
|
+
this.s.overrides.clearForContext(flagKey, contextKey, contextKind);
|
|
1301
1737
|
}
|
|
1302
1738
|
clearGlobalOverride(flagKey) {
|
|
1303
|
-
this.overrides.clearGlobal(flagKey);
|
|
1739
|
+
this.s.overrides.clearGlobal(flagKey);
|
|
1304
1740
|
}
|
|
1305
1741
|
clearAllOverrides() {
|
|
1306
|
-
this.overrides.clearAll();
|
|
1742
|
+
this.s.overrides.clearAll();
|
|
1307
1743
|
}
|
|
1308
1744
|
/** A fresh decision log to pass per call. `forUser` carries its own. */
|
|
1309
1745
|
createDecisionLog() {
|
|
1310
1746
|
return createDecisionLog();
|
|
1311
1747
|
}
|
|
1312
|
-
//
|
|
1313
|
-
// Events
|
|
1314
|
-
// ---------------------------------------------------------------------------
|
|
1748
|
+
// ---- Events ----
|
|
1315
1749
|
on(event, cb) {
|
|
1316
|
-
|
|
1317
|
-
this.listeners.get(event).add(cb);
|
|
1318
|
-
return () => {
|
|
1319
|
-
this.listeners.get(event)?.delete(cb);
|
|
1320
|
-
};
|
|
1750
|
+
return addListener(this.s, event, cb);
|
|
1321
1751
|
}
|
|
1322
1752
|
off(event, cb) {
|
|
1323
|
-
this.listeners.get(event)?.delete(cb);
|
|
1324
|
-
}
|
|
1325
|
-
/**
|
|
1326
|
-
* `on` for the shared `ClientEventMap` names the utils layer speaks. The
|
|
1327
|
-
* server emits every one of them except `flagChange`, which needs per-flag
|
|
1328
|
-
* diffing the server does not do; subscribing to it warns and hands back a
|
|
1329
|
-
* no-op disposer instead of pretending.
|
|
1330
|
-
*/
|
|
1331
|
-
onSharedEvent(event, cb) {
|
|
1332
|
-
if (event === "flagChange") {
|
|
1333
|
-
this.warnOnce(
|
|
1334
|
-
"no-flag-change",
|
|
1335
|
-
'The server SDK does not emit "flagChange": it holds one datafile for the whole process and does not diff individual flags. Subscribe to "configUpdate" for "a new datafile was published" instead.'
|
|
1336
|
-
);
|
|
1337
|
-
return () => void 0;
|
|
1338
|
-
}
|
|
1339
|
-
return this.on(
|
|
1340
|
-
event,
|
|
1341
|
-
cb
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
1344
|
-
emit(event, ...args) {
|
|
1345
|
-
const cbs = this.listeners.get(event);
|
|
1346
|
-
if (!cbs) return;
|
|
1347
|
-
for (const cb of cbs) {
|
|
1348
|
-
try {
|
|
1349
|
-
;
|
|
1350
|
-
cb(...args);
|
|
1351
|
-
} catch (e) {
|
|
1352
|
-
this.logger.error("Listener error", { event, error: String(e) });
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
warnOnce(key, message) {
|
|
1357
|
-
if (this.warnedOnce.has(key)) return;
|
|
1358
|
-
this.warnedOnce.add(key);
|
|
1359
|
-
this.logger.warn(message);
|
|
1360
|
-
}
|
|
1361
|
-
// ---------------------------------------------------------------------------
|
|
1362
|
-
// Private — evaluation core
|
|
1363
|
-
// ---------------------------------------------------------------------------
|
|
1364
|
-
evaluate(flagKey, uc, defaultValue) {
|
|
1365
|
-
const reasons = [];
|
|
1366
|
-
const overrideKey = this.overrides.resolve(flagKey, uc.userId);
|
|
1367
|
-
const rtOver = overrideKey ? this.findVariation(flagKey, overrideKey) : null;
|
|
1368
|
-
if (rtOver) {
|
|
1369
|
-
reasons.push(`matched runtime override for flagKey="${flagKey}"`);
|
|
1370
|
-
return {
|
|
1371
|
-
value: rtOver.value,
|
|
1372
|
-
variationKey: rtOver.key,
|
|
1373
|
-
source: "runtimeOverride",
|
|
1374
|
-
ruleId: null,
|
|
1375
|
-
ruleType: null,
|
|
1376
|
-
reasons
|
|
1377
|
-
};
|
|
1378
|
-
}
|
|
1379
|
-
if (this.stickyBucketService) {
|
|
1380
|
-
const sticky = this.resolveStickyBucket(flagKey, uc.userId);
|
|
1381
|
-
if (sticky !== void 0) {
|
|
1382
|
-
reasons.push(`matched sticky bucket assignment for userId="${uc.userId}"`);
|
|
1383
|
-
if (!uc.suppressExposure) {
|
|
1384
|
-
this.fireExposureForStickyAssignment(flagKey, sticky, uc);
|
|
1385
|
-
}
|
|
1386
|
-
return {
|
|
1387
|
-
value: sticky.vref.value,
|
|
1388
|
-
variationKey: sticky.vref.key,
|
|
1389
|
-
source: "sticky",
|
|
1390
|
-
ruleId: sticky.assignment.ruleId,
|
|
1391
|
-
ruleType: sticky.assignment.ruleType,
|
|
1392
|
-
reasons
|
|
1393
|
-
};
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
1396
|
-
const legacyCtx = { attributes: { userId: uc.userId, ...uc.attributes } };
|
|
1397
|
-
const onExposure = uc.suppressExposure ? void 0 : (event) => {
|
|
1398
|
-
this.deliverExposure(redactExposure(event, uc.privateAttributes));
|
|
1399
|
-
};
|
|
1400
|
-
const r = evaluateFlag(
|
|
1401
|
-
flagKey,
|
|
1402
|
-
defaultValue,
|
|
1403
|
-
legacyCtx,
|
|
1404
|
-
this.datafile,
|
|
1405
|
-
onExposure,
|
|
1406
|
-
reasons
|
|
1407
|
-
);
|
|
1408
|
-
if (this.stickyBucketService && r.variationKey && r.source === "rule" && r.ruleId && r.ruleType === "ab_test") {
|
|
1409
|
-
const v = this.findVariation(flagKey, r.variationKey);
|
|
1410
|
-
if (v) {
|
|
1411
|
-
this.stickyBucketService.save(uc.userId, flagKey, {
|
|
1412
|
-
variationId: v.id,
|
|
1413
|
-
ruleId: r.ruleId,
|
|
1414
|
-
ruleType: r.ruleType,
|
|
1415
|
-
assignedAt: Date.now()
|
|
1416
|
-
});
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
return {
|
|
1420
|
-
value: r.value,
|
|
1421
|
-
variationKey: r.variationKey,
|
|
1422
|
-
source: mapLegacySource(r.source),
|
|
1423
|
-
ruleId: r.ruleId,
|
|
1424
|
-
ruleType: r.ruleType,
|
|
1425
|
-
reasons
|
|
1426
|
-
};
|
|
1427
|
-
}
|
|
1428
|
-
/**
|
|
1429
|
-
* One exposure, delivered everywhere: queued for ingestion AND emitted on
|
|
1430
|
-
* the event bus with the SAME `eventId`, so a customer forwarding decisions
|
|
1431
|
-
* to their own analytics can reconcile rows with A vs B results.
|
|
1432
|
-
*/
|
|
1433
|
-
deliverExposure(event) {
|
|
1434
|
-
const stamped = event.eventId ? event : { ...event, eventId: randomUUID2() };
|
|
1435
|
-
if (this.tracker) {
|
|
1436
|
-
this.tracker.queue(stamped);
|
|
1437
|
-
} else {
|
|
1438
|
-
this.enqueuePendingExposure(stamped);
|
|
1439
|
-
}
|
|
1440
|
-
this.emit("exposure", stamped);
|
|
1441
|
-
}
|
|
1442
|
-
toFlag(result, defaultValue) {
|
|
1443
|
-
return createFlag({
|
|
1444
|
-
value: result.value ?? defaultValue,
|
|
1445
|
-
variationKey: result.variationKey,
|
|
1446
|
-
source: result.source,
|
|
1447
|
-
ruleId: result.ruleId,
|
|
1448
|
-
ruleType: result.ruleType,
|
|
1449
|
-
reasons: result.reasons,
|
|
1450
|
-
evaluatedAt: Date.now(),
|
|
1451
|
-
durationMicros: 0
|
|
1452
|
-
});
|
|
1453
|
-
}
|
|
1454
|
-
/** Build a Flag from a forced variation key (per-request forced decision). */
|
|
1455
|
-
buildForcedFlag(flagKey, variationKey, defaultValue, context, options) {
|
|
1456
|
-
const vref = this.findVariation(flagKey, variationKey);
|
|
1457
|
-
const flag = createFlag({
|
|
1458
|
-
value: vref ? vref.value : defaultValue,
|
|
1459
|
-
variationKey: vref ? vref.key : null,
|
|
1460
|
-
source: "runtimeOverride",
|
|
1461
|
-
ruleId: null,
|
|
1462
|
-
ruleType: null,
|
|
1463
|
-
reasons: [`forced decision: variationKey="${variationKey}"`]
|
|
1464
|
-
});
|
|
1465
|
-
this.recordDecision(flagKey, flag, context, options);
|
|
1466
|
-
return flag;
|
|
1467
|
-
}
|
|
1468
|
-
/** Emit the evaluation event and append to a decision log if one was passed. */
|
|
1469
|
-
recordDecision(flagKey, flag, context, options) {
|
|
1470
|
-
const summary = contextSummary(context);
|
|
1471
|
-
this.emit("evaluation", {
|
|
1472
|
-
flagKey,
|
|
1473
|
-
flag,
|
|
1474
|
-
contextKeys: summary.map((entry) => entry.key)
|
|
1475
|
-
});
|
|
1476
|
-
if (!options?.decisionLog) return;
|
|
1477
|
-
appendDecisionLogEntry(options.decisionLog, {
|
|
1478
|
-
flagKey,
|
|
1479
|
-
value: flag.value,
|
|
1480
|
-
variationKey: flag.variationKey,
|
|
1481
|
-
source: flag.source,
|
|
1482
|
-
ruleId: flag.ruleId,
|
|
1483
|
-
ruleType: flag.ruleType,
|
|
1484
|
-
reasons: flag.reasons,
|
|
1485
|
-
evaluatedAt: flag.evaluatedAt,
|
|
1486
|
-
durationMicros: flag.durationMicros,
|
|
1487
|
-
contextSummary: summary,
|
|
1488
|
-
privateValues: privateAttributeValues(context)
|
|
1489
|
-
});
|
|
1490
|
-
}
|
|
1491
|
-
buildLegacyContext(context, suppressExposure) {
|
|
1492
|
-
const legacy = toLegacyEvalContext(context);
|
|
1493
|
-
const userId = legacy.attributes["userId"] ?? "";
|
|
1494
|
-
const privateAttrs = privateAttributesFrom(context);
|
|
1495
|
-
const { userId: _uid, ...rest } = legacy.attributes;
|
|
1496
|
-
return {
|
|
1497
|
-
userId,
|
|
1498
|
-
attributes: rest,
|
|
1499
|
-
suppressExposure,
|
|
1500
|
-
privateAttributes: privateAttrs.size > 0 ? [...privateAttrs] : void 0
|
|
1501
|
-
};
|
|
1502
|
-
}
|
|
1503
|
-
// ---------------------------------------------------------------------------
|
|
1504
|
-
// Private — sticky helpers
|
|
1505
|
-
// ---------------------------------------------------------------------------
|
|
1506
|
-
resolveStickyBucket(flagKey, userId) {
|
|
1507
|
-
if (!this.stickyBucketService || !this.datafile) return void 0;
|
|
1508
|
-
const assignment = this.stickyBucketService.lookup(userId, flagKey);
|
|
1509
|
-
if (!assignment) return void 0;
|
|
1510
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1511
|
-
if (!flag) return void 0;
|
|
1512
|
-
const v = flag.variations.find((x) => x.id === assignment.variationId);
|
|
1513
|
-
if (!v) return void 0;
|
|
1514
|
-
return { vref: { value: v.value, key: v.key, id: v.id }, assignment };
|
|
1515
|
-
}
|
|
1516
|
-
findVariation(flagKey, variationKey) {
|
|
1517
|
-
if (!this.datafile) return null;
|
|
1518
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1519
|
-
if (!flag) return null;
|
|
1520
|
-
const v = flag.variations.find((x) => x.key === variationKey);
|
|
1521
|
-
if (!v) return null;
|
|
1522
|
-
return { value: v.value, key: v.key, id: v.id };
|
|
1523
|
-
}
|
|
1524
|
-
fireExposureForStickyAssignment(flagKey, sticky, uc) {
|
|
1525
|
-
if (!this.tracker || !this.datafile) return;
|
|
1526
|
-
const flag = this.datafile.flags.find((f) => f.key === flagKey);
|
|
1527
|
-
if (!flag) return;
|
|
1528
|
-
const rule = flag.rules.find((r) => r.id === sticky.assignment.ruleId);
|
|
1529
|
-
if (!rule || !rule.variations.some((rv) => rv.variationId === sticky.vref.id)) {
|
|
1530
|
-
this.logger.warn(
|
|
1531
|
-
"Sticky assignment references a rule no longer matching the datafile",
|
|
1532
|
-
{
|
|
1533
|
-
flagKey,
|
|
1534
|
-
userId: uc.userId,
|
|
1535
|
-
assignedRuleId: sticky.assignment.ruleId,
|
|
1536
|
-
assignedVariationId: sticky.vref.id
|
|
1537
|
-
}
|
|
1538
|
-
);
|
|
1539
|
-
this.emit("stickyOrphan", {
|
|
1540
|
-
flagKey,
|
|
1541
|
-
userId: uc.userId,
|
|
1542
|
-
assignment: sticky.assignment
|
|
1543
|
-
});
|
|
1544
|
-
return;
|
|
1545
|
-
}
|
|
1546
|
-
this.deliverExposure({
|
|
1547
|
-
visitorId: uc.userId,
|
|
1548
|
-
experimentId: rule.id,
|
|
1549
|
-
variationId: sticky.vref.id,
|
|
1550
|
-
eventName: "flag_exposure",
|
|
1551
|
-
revenue: null,
|
|
1552
|
-
timestamp: Date.now(),
|
|
1553
|
-
pageUrl: "",
|
|
1554
|
-
properties: { flagKey, flagId: flag.id }
|
|
1555
|
-
});
|
|
1556
|
-
}
|
|
1557
|
-
// ---------------------------------------------------------------------------
|
|
1558
|
-
// Private — tracker, polling, streaming, heartbeat
|
|
1559
|
-
// ---------------------------------------------------------------------------
|
|
1560
|
-
initTracker() {
|
|
1561
|
-
if (!this.datafile || this.tracker) return;
|
|
1562
|
-
const tracker = createServerTracker({
|
|
1563
|
-
sdkKey: this.sdkKey,
|
|
1564
|
-
collectEndpoint: this.datafile.collectEndpoint,
|
|
1565
|
-
flushInterval: this.eventOptions.flushInterval,
|
|
1566
|
-
batchSize: this.eventOptions.batchSize,
|
|
1567
|
-
maxQueueSize: this.eventOptions.maxQueueSize,
|
|
1568
|
-
retryAttempts: this.eventOptions.retryAttempts,
|
|
1569
|
-
logger: this.logger
|
|
1570
|
-
});
|
|
1571
|
-
this.tracker = tracker;
|
|
1572
|
-
if (this.pendingExposures.length > 0) {
|
|
1573
|
-
const queued = this.pendingExposures;
|
|
1574
|
-
this.pendingExposures = [];
|
|
1575
|
-
for (const event of queued) tracker.queue(event);
|
|
1576
|
-
}
|
|
1577
|
-
if (this.pendingEvents.length > 0) {
|
|
1578
|
-
const queued = this.pendingEvents;
|
|
1579
|
-
this.pendingEvents = [];
|
|
1580
|
-
for (const item of queued) this.track(item.eventKey, item.payload);
|
|
1581
|
-
}
|
|
1582
|
-
if (this.pendingPurchases.length > 0) {
|
|
1583
|
-
const queued = this.pendingPurchases;
|
|
1584
|
-
this.pendingPurchases = [];
|
|
1585
|
-
for (const item of queued) void this.trackPurchase(item.visitorId, item.order);
|
|
1586
|
-
}
|
|
1587
|
-
}
|
|
1588
|
-
enqueuePendingExposure(event) {
|
|
1589
|
-
if (this.pendingExposures.length >= this.eventOptions.maxQueueSize) {
|
|
1590
|
-
this.pendingExposures.shift();
|
|
1591
|
-
this.warnOnce(
|
|
1592
|
-
"pending-exposures-full",
|
|
1593
|
-
`Pre-ready exposure queue reached ${this.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
1594
|
-
);
|
|
1595
|
-
}
|
|
1596
|
-
this.pendingExposures.push(event);
|
|
1597
|
-
}
|
|
1598
|
-
enqueuePending(eventKey, payload) {
|
|
1599
|
-
if (this.pendingEvents.length >= this.eventOptions.maxQueueSize) {
|
|
1600
|
-
this.pendingEvents.shift();
|
|
1601
|
-
this.warnOnce(
|
|
1602
|
-
"pending-events-full",
|
|
1603
|
-
`Pre-ready event queue reached ${this.eventOptions.maxQueueSize} events; the oldest are being dropped. This means the datafile never loaded, so nothing has been delivered yet: check the datafile error logged above.`
|
|
1604
|
-
);
|
|
1605
|
-
}
|
|
1606
|
-
this.pendingEvents.push({ eventKey, payload });
|
|
1607
|
-
}
|
|
1608
|
-
/**
|
|
1609
|
-
* Fetch and apply the datafile.
|
|
1610
|
-
*
|
|
1611
|
-
* Three outcomes, not two, because a conditional request has three:
|
|
1612
|
-
* 'applied' a datafile came back and is now in memory
|
|
1613
|
-
* 'unchanged' the CDN answered 304: what we hold IS current
|
|
1614
|
-
* 'failed' nothing usable came back; the error is already logged
|
|
1615
|
-
*
|
|
1616
|
-
* 'unchanged' is a success. Collapsing it into 'failed' made
|
|
1617
|
-
* `refreshDatafile()` warn that a refresh "did not apply a new datafile"
|
|
1618
|
-
* every time the datafile was simply up to date.
|
|
1619
|
-
*/
|
|
1620
|
-
async loadDatafile(reason = "manual") {
|
|
1621
|
-
const outcome = await fetchDatafile({
|
|
1622
|
-
sdkKey: this.sdkKey,
|
|
1623
|
-
cdnHost: this.cdnHost,
|
|
1624
|
-
signal: this.abortController.signal,
|
|
1625
|
-
logger: this.logger,
|
|
1626
|
-
etag: this.datafileEtag
|
|
1627
|
-
});
|
|
1628
|
-
if (outcome.status === "ok" || outcome.status === "not-modified") {
|
|
1629
|
-
this.sendHeartbeat();
|
|
1630
|
-
}
|
|
1631
|
-
if (outcome.status === "not-modified") return "unchanged";
|
|
1632
|
-
if (outcome.status !== "ok") return "failed";
|
|
1633
|
-
const changed = !this.datafile || this.datafile.publishedAt !== outcome.datafile.publishedAt;
|
|
1634
|
-
this.datafile = outcome.datafile;
|
|
1635
|
-
this.datafileEtag = outcome.etag;
|
|
1636
|
-
if (this.ready && changed) {
|
|
1637
|
-
this.emit("configUpdate", {
|
|
1638
|
-
publishedAt: outcome.datafile.publishedAt,
|
|
1639
|
-
reason
|
|
1640
|
-
});
|
|
1641
|
-
this.logger.info("Datafile updated", {
|
|
1642
|
-
publishedAt: outcome.datafile.publishedAt,
|
|
1643
|
-
reason
|
|
1644
|
-
});
|
|
1645
|
-
this.startStreamingIfEnabled();
|
|
1646
|
-
}
|
|
1647
|
-
return "applied";
|
|
1648
|
-
}
|
|
1649
|
-
/**
|
|
1650
|
-
* Poll on a JITTERED interval. Without the jitter every process that
|
|
1651
|
-
* deployed together polls the CDN on the same tick forever.
|
|
1652
|
-
*/
|
|
1653
|
-
startPolling() {
|
|
1654
|
-
if (this.pollingTimer || this.destroyed) return;
|
|
1655
|
-
const schedule = () => {
|
|
1656
|
-
this.pollingTimer = setTimeout(() => {
|
|
1657
|
-
this.pollingTimer = null;
|
|
1658
|
-
void (async () => {
|
|
1659
|
-
const result = await this.loadDatafile("poll");
|
|
1660
|
-
if (result === "applied" && !this.ready) this.onDatafileReadable();
|
|
1661
|
-
if (!this.destroyed) schedule();
|
|
1662
|
-
})();
|
|
1663
|
-
}, jitteredInterval(this.pollingInterval));
|
|
1664
|
-
};
|
|
1665
|
-
schedule();
|
|
1666
|
-
}
|
|
1667
|
-
/**
|
|
1668
|
-
* Open the SSE stream when one is available, and fall back to polling in
|
|
1669
|
-
* silence when it is not (B12). The endpoint comes from the datafile's
|
|
1670
|
-
* `streamEndpoint` field, or an explicit option; it is never guessed from
|
|
1671
|
-
* another host, which is what gave one port an infinite reconnect loop
|
|
1672
|
-
* against DNS that does not resolve.
|
|
1673
|
-
*/
|
|
1674
|
-
startStreamingIfEnabled() {
|
|
1675
|
-
if (!this.streamingEnabled || this.streamingClient || this.destroyed) return;
|
|
1676
|
-
const resolution = resolveStreamEndpoint({
|
|
1677
|
-
optionEndpoint: this.streamingEndpointOption,
|
|
1678
|
-
datafileStreamEndpoint: this.datafile?.streamEndpoint ?? null
|
|
1679
|
-
});
|
|
1680
|
-
if (!resolution.available) {
|
|
1681
|
-
this.logger.debug(`Streaming stays off, polling continues. ${resolution.reason}`);
|
|
1682
|
-
return;
|
|
1683
|
-
}
|
|
1684
|
-
this.streamingClient = createStreamingClient({
|
|
1685
|
-
endpoint: resolution.endpoint,
|
|
1686
|
-
sdkKey: this.sdkKey,
|
|
1687
|
-
onDatafileUpdated: () => {
|
|
1688
|
-
void this.loadDatafile("stream");
|
|
1689
|
-
},
|
|
1690
|
-
logger: this.logger
|
|
1691
|
-
});
|
|
1692
|
-
this.streamingClient.connect();
|
|
1693
|
-
this.logger.debug(
|
|
1694
|
-
`Streaming datafile updates from ${resolution.endpoint} (${resolution.source}).`
|
|
1695
|
-
);
|
|
1696
|
-
}
|
|
1697
|
-
/**
|
|
1698
|
-
* Report liveness, throttled.
|
|
1699
|
-
*
|
|
1700
|
-
* Called on every successful datafile load, not just at init: the dashboard
|
|
1701
|
-
* marks an environment Stale after five minutes without a heartbeat, so an
|
|
1702
|
-
* init-only heartbeat made every long-running process look dead a few
|
|
1703
|
-
* minutes after it started. The throttle keeps a fast polling interval (or a
|
|
1704
|
-
* burst of stream-triggered refreshes) from turning liveness into traffic.
|
|
1705
|
-
*
|
|
1706
|
-
* Fire and forget: a failed heartbeat never affects evaluation, and the
|
|
1707
|
-
* throttle clock is stamped by the synchronous send below, so a hanging
|
|
1708
|
-
* request cannot queue a second one.
|
|
1709
|
-
*/
|
|
1710
|
-
sendHeartbeat() {
|
|
1711
|
-
const now = Date.now();
|
|
1712
|
-
if (now - this.lastHeartbeatAt < HEARTBEAT_MIN_INTERVAL_MS) return;
|
|
1713
|
-
const posted = sendHeartbeat({
|
|
1714
|
-
sdkKey: this.sdkKey,
|
|
1715
|
-
sdkType: SDK_TYPE,
|
|
1716
|
-
sdkVersion: SDK_VERSION,
|
|
1717
|
-
datafileHeartbeatEndpoint: this.datafile?.heartbeatEndpoint,
|
|
1718
|
-
signal: this.abortController.signal
|
|
1719
|
-
});
|
|
1720
|
-
if (posted) this.lastHeartbeatAt = now;
|
|
1753
|
+
this.s.listeners.get(event)?.delete(cb);
|
|
1721
1754
|
}
|
|
1722
1755
|
};
|
|
1723
1756
|
|