@@ -315,6 +323,9 @@ export default function ExperimentsTab() {
>
{types ? (
+ {types.contextualBandit ? (
+
+ ) : null}
{types.redirect ? (
@@ -402,7 +413,12 @@ export type SelectedExperiment = {
eid: string;
experiment: ExperimentWithFeatures;
meta?: any;
- types: { features?: string[]; redirect?: boolean; visual?: boolean };
+ types: {
+ features?: string[];
+ redirect?: boolean;
+ visual?: boolean;
+ contextualBandit?: boolean;
+ };
evaluatedExperiment?: EvaluatedExperiment;
isForced: boolean;
};
@@ -455,6 +471,7 @@ export function getExperimentTypes(experiment: ExperimentWithFeatures) {
visual: experiment?.variations?.some(
(v) => v?.domMutations?.length || v?.css || v?.js,
),
+ contextualBandit: isContextualBandit(experiment),
};
}
@@ -466,13 +483,15 @@ export function getFeatureExperiments(
const feature = features[fid];
const details = getFeatureDetails({ fid, features });
for (const rule of feature.rules || []) {
- if (rule.variations) {
+ const variations = ruleVariations(rule);
+ if (variations) {
// @ts-ignore
experiments.push({
key: rule.key ?? fid,
features: [fid],
featureTypes: { [fid]: details.valueType },
...rule,
+ variations,
});
}
}
diff --git a/src/app/components/LogsList.tsx b/src/app/components/LogsList.tsx
index 7da6f89..b758444 100644
--- a/src/app/components/LogsList.tsx
+++ b/src/app/components/LogsList.tsx
@@ -1,4 +1,12 @@
-import { Box, Checkbox, Flex, Link, Text, Tooltip } from "@radix-ui/themes";
+import {
+ Badge,
+ Box,
+ Checkbox,
+ Flex,
+ Link,
+ Text,
+ Tooltip,
+} from "@radix-ui/themes";
import React, { ReactNode, useMemo, useState } from "react";
import useTabState from "../hooks/useTabState";
import { useSearch } from "../hooks/useSearch";
@@ -15,6 +23,7 @@ import {
import ValueField from "./ValueField";
import clsx from "clsx";
import { LogUnionWithSource } from "@/app/utils/logs";
+import { ContextualBanditBadge } from "@/app/components/ContextualBanditDetail";
export const HEADER_H = 40;
@@ -250,6 +259,11 @@ export default function LogsList({
)}
>
{evt.eventInfo}
+ {evt.isContextualBandit ? (
+
+
+
+ ) : null}
{!isResponsive && (
diff --git a/src/app/components/Rule.tsx b/src/app/components/Rule.tsx
index a184084..011a648 100644
--- a/src/app/components/Rule.tsx
+++ b/src/app/components/Rule.tsx
@@ -23,10 +23,19 @@ import {
import { EvaluatedFeature } from "@/app/hooks/useGBSandboxEval";
import DebugLogger from "@/app/components/DebugLogger";
import useGlobalState from "@/app/hooks/useGlobalState";
-import { formatExperimentKey, holdoutIdFromFid } from "@/app/components/ExperimentsTab";
+import {
+ formatExperimentKey,
+ holdoutIdFromFid,
+} from "@/app/components/ExperimentsTab";
import { isDark, Theme } from "@/app";
+import { ruleVariations } from "@/utils/contextualBandits";
-type RuleType = "force" | "rollout" | "experiment" | "prerequisite" | "safe-rollout";
+type RuleType =
+ | "force"
+ | "rollout"
+ | "experiment"
+ | "prerequisite"
+ | "safe-rollout";
const RULE_MATCHED_LOGS = [
"Force",
@@ -102,19 +111,19 @@ export default function Rule({
condition,
parentConditions,
force,
- variations,
weights,
hashAttribute,
coverage,
namespace,
} = rule;
+ // Bandit rules keep theirs under contextualVariations
+ const variations = ruleVariations(rule);
const key = rule.key ?? fid;
- let ruleType: RuleType =
- rule?.key?.startsWith("srk_")
- ? "safe-rollout"
- : variations
- ? "experiment"
- : "coverage" in rule
+ let ruleType: RuleType = rule?.key?.startsWith("srk_")
+ ? "safe-rollout"
+ : variations
+ ? "experiment"
+ : "coverage" in rule
? "rollout"
: rule?.parentConditions?.some((p) => p.gate)
? "prerequisite"
@@ -535,7 +544,11 @@ export function ConditionDisplay({
);
- const renderOrBlock = (branches: Condition[][], blockKey: string, prefix: string) => (
+ const renderOrBlock = (
+ branches: Condition[][],
+ blockKey: string,
+ prefix: string,
+ ) => (
{prefix} [
@@ -550,7 +563,11 @@ export function ConditionDisplay({
)}
{group.map((cond, i) =>
- renderCondRow(cond, `${blockKey}-${gi}-${i}`, i > 0 ? "AND" : null),
+ renderCondRow(
+ cond,
+ `${blockKey}-${gi}-${i}`,
+ i > 0 ? "AND" : null,
+ ),
)}
diff --git a/src/app/components/SdkTab/SdkItemPanel.tsx b/src/app/components/SdkTab/SdkItemPanel.tsx
index ae8473e..71b478d 100644
--- a/src/app/components/SdkTab/SdkItemPanel.tsx
+++ b/src/app/components/SdkTab/SdkItemPanel.tsx
@@ -23,6 +23,11 @@ import { useResponsiveContext } from "@/app/hooks/useResponsive";
import { SdkItem } from "./index";
import useSdkData from "@/app/hooks/useSdkData";
import { SDKHealthCheckResult } from "devtools";
+import {
+ expectsUserContextParam,
+ trackingCallbackParamsAreValid,
+ USER_CONTEXT_SDK_VERSION,
+} from "@/utils/sdkCallbacks";
import { getActiveTabId } from "@/app/hooks/useTabState";
import { paddedVersionString } from "@growthbook/growthbook";
@@ -528,33 +533,59 @@ function versionPanel({
function trackingCallbackPanel({
trackingCallbackParams,
hasTrackingCallback,
+ version,
}: SDKHealthCheckResult) {
+ const missingUserContext =
+ expectsUserContextParam(version) && trackingCallbackParams?.length === 2;
+ const unusedUserContext =
+ version &&
+ !expectsUserContextParam(version) &&
+ trackingCallbackParams?.length === 3;
return (
- {trackingCallbackParams?.length === 2 ? (
- <>
- The SDK is using a{" "}
- trackingCallback.
- >
- ) : !hasTrackingCallback ? (
+ {!hasTrackingCallback ? (
<>
The SDK is not using a{" "}
trackingCallback. You will need
to add one to track experiment exposure to your data warehouse.
>
- ) : (
+ ) : missingUserContext ? (
<>
The SDK is using a{" "}
trackingCallback with{" "}
- {trackingCallbackParams?.length ? (
- {trackingCallbackParams.length}
- ) : (
- <>
- an unknown number of
- >
- )}{" "}
- param{trackingCallbackParams?.length !== 1 ? "s" : ""} instead of 2.
- Please check your implementation.
+ 2 params. Add a third{" "}
+ userContext param to use newer features.
+ >
+ ) : unusedUserContext ? (
+ <>
+ The SDK is using a{" "}
+ trackingCallback with{" "}
+ 3 params, but SDK {version} never
+ passes userContext. Upgrade to {USER_CONTEXT_SDK_VERSION}{" "}
+ or later to use it.
+ >
+ ) : !trackingCallbackParamsAreValid(trackingCallbackParams, version) ? (
+ <>
+ The SDK is using a{" "}
+ trackingCallback with{" "}
+ {trackingCallbackParams?.length}{" "}
+ param{trackingCallbackParams?.length === 1 ? "" : "s"} instead of{" "}
+ (experiment, result) or{" "}
+ (experiment, result, userContext). Please check your
+ implementation.
+ >
+ ) : trackingCallbackParams?.length ? (
+ <>
+ The SDK is using a{" "}
+ trackingCallback with{" "}
+ {trackingCallbackParams.length} param
+ {trackingCallbackParams.length === 1 ? "" : "s"}:{" "}
+ ({trackingCallbackParams.join(", ")}).
+ >
+ ) : (
+ <>
+ The SDK is using a{" "}
+ trackingCallback.
>
)}
diff --git a/src/app/components/SdkTab/index.tsx b/src/app/components/SdkTab/index.tsx
index 4f11d70..b197122 100644
--- a/src/app/components/SdkTab/index.tsx
+++ b/src/app/components/SdkTab/index.tsx
@@ -9,6 +9,7 @@ import SdkItemPanel from "./SdkItemPanel";
import useSdkData from "@/app/hooks/useSdkData";
import { paddedVersionString } from "@growthbook/growthbook";
import packageJson from "@growthbook/growthbook/package.json";
+import { hasTrackingCallbackIssues } from "@/utils/sdkCallbacks";
const latestSdkVersion = packageJson.version;
const latestSdkParts = latestSdkVersion.split(".");
@@ -62,18 +63,29 @@ export default function SdkTab() {
: isRemoteEval
? "Remote Eval"
: "Plain Text";
- const trackingCallbackStatus =
- trackingCallbackParams?.length === 2
- ? "Found"
- : !hasTrackingCallback
- ? "None Found"
- : "Found (issues)";
- const trackingCallbackStatusColor =
- trackingCallbackParams?.length === 2
- ? "green"
- : !hasTrackingCallback
- ? "red"
- : "orange";
+ const trackingCallbackIssues = hasTrackingCallbackIssues({
+ hasTrackingCallback,
+ trackingCallbackParams,
+ version,
+ });
+ // A rest param stands in for any arity, so a count would mislead
+ const trackingCallbackParamCount = trackingCallbackParams?.some((p) =>
+ p.startsWith("..."),
+ )
+ ? undefined
+ : trackingCallbackParams?.length;
+ const trackingCallbackStatus = !hasTrackingCallback
+ ? "None Found"
+ : trackingCallbackIssues
+ ? "Found (issues)"
+ : trackingCallbackParamCount
+ ? `Found (${trackingCallbackParamCount} param${trackingCallbackParamCount === 1 ? "" : "s"})`
+ : "Found";
+ const trackingCallbackStatusColor = !hasTrackingCallback
+ ? "red"
+ : trackingCallbackIssues
+ ? "orange"
+ : "green";
const canConnectStatus =
sdkFound === undefined
? "Loading..."
@@ -301,8 +313,7 @@ export function getSdkStatus(
(!sdkData.canConnect && !numExternalSdks) ||
(sdkData.canConnect && !sdkData.hasPayload) ||
(!sdkData.hasTrackingCallback && !numExternalSdks) ||
- (sdkData.hasTrackingCallback &&
- sdkData.trackingCallbackParams?.length !== 2) ||
+ hasTrackingCallbackIssues(sdkData) ||
(sdkData.hasPayload && !sdkData.payloadDecrypted) ||
(paddedVersionString(sdkData.version) <
paddedVersionString(latestMinorSdkVersion) &&
diff --git a/src/app/hooks/useGBSandboxEval.ts b/src/app/hooks/useGBSandboxEval.ts
index 5d0c6d6..41ee014 100644
--- a/src/app/hooks/useGBSandboxEval.ts
+++ b/src/app/hooks/useGBSandboxEval.ts
@@ -12,6 +12,7 @@ import {
import useTabState from "@/app/hooks/useTabState";
import { DebugLog } from "devtools";
import { getFeatureExperiments } from "@/app/components/ExperimentsTab";
+import { ruleVariations } from "@/utils/contextualBandits";
import useSdkData from "./useSdkData";
import { FeatureDefinitionWithId } from "@/app/components/FeaturesTab";
import { LogUnionWithSource } from "@/app/utils/logs";
@@ -103,8 +104,14 @@ export default function useGBSandboxEval() {
...rule,
// Stuff rule index + featureId into meta so it survives into exp.meta.
// featureId is needed for experiment-ref rules where exp.key !== fid.
- meta: rule.meta
- ? rule.meta.map((m) => ({ ...m, ruleI: i, featureId: fid }))
+ // Must stay as long as the variations: the SDK indexes meta by
+ // variation and throws on a short array (core.ts getExperimentResult).
+ meta: ruleVariations(rule)?.length
+ ? ruleVariations(rule)!.map((_, vi) => ({
+ ...(rule.meta?.[vi] ?? {}),
+ ruleI: i,
+ featureId: fid,
+ }))
: [{ ruleI: i, featureId: fid }],
}));
}
@@ -172,12 +179,27 @@ export default function useGBSandboxEval() {
};
}
+ // run() skips the feature-rule path, so it never applies bandit weights.
+ // Reuse the feature's own result for those to avoid reporting a variation
+ // the page isn't serving.
+ const banditResults = new Map
>();
+ for (const fid in evaluatedFeatures) {
+ const featureResult = evaluatedFeatures[fid]?.result;
+ const key = featureResult?.experiment?.key;
+ if (key && featureResult?.experimentResult?.variationWeights) {
+ banditResults.set(key, featureResult.experimentResult);
+ }
+ }
+
[...experiments, ...featureExperiments].forEach((experiment) => {
growthbook.debug = true;
- const result = growthbook.run(experiment);
+ // Always run: run() is what populates the debug log, even when the
+ // bandit's own result is the one we report
+ const ranResult = growthbook.run(experiment);
growthbook.debug = false;
const debug = [...log];
log = [];
+ const result = banditResults.get(experiment.key) ?? ranResult;
evaluatedExperiments.push({
key: experiment.key,
diff --git a/src/app/utils/logs.ts b/src/app/utils/logs.ts
index 43fdc33..6026bd1 100644
--- a/src/app/utils/logs.ts
+++ b/src/app/utils/logs.ts
@@ -1,9 +1,11 @@
import { LogUnion } from "@growthbook/growthbook";
+import { appliedContextualBandit } from "@/utils/contextualBandits";
export interface FlattenedLogEvent {
logType: string;
timestamp: string;
eventInfo: string;
+ isContextualBandit?: boolean;
details: Record;
context: {
source?: string;
@@ -34,6 +36,11 @@ export function reshapeEventLog(evt: LogUnionWithSource): FlattenedLogEvent {
logType: evt.logType,
timestamp: evt.timestamp,
eventInfo: evt.experiment.name || "",
+ // Omitted when false: useSearch matches JSON.stringify(item), so a
+ // literal false makes every row match a search for "bandit"
+ ...(appliedContextualBandit(evt.experiment)
+ ? { isContextualBandit: true }
+ : {}),
details: {
experiment: evt.experiment,
result: evt.result,
diff --git a/src/background/index.ts b/src/background/index.ts
index 6466e1d..c975163 100644
--- a/src/background/index.ts
+++ b/src/background/index.ts
@@ -22,6 +22,7 @@ import {
} from "@/background/visualEditorHandlers";
import packageJson from "@growthbook/growthbook/package.json";
import { paddedVersionString } from "@growthbook/growthbook";
+import { hasTrackingCallbackIssues } from "@/utils/sdkCallbacks";
const latestSdkVersion = packageJson.version;
const latestSdkParts = latestSdkVersion.split(".");
@@ -210,9 +211,8 @@ const UpdateTabIconBasedOnSDK = (
: !data.hasPayload
? "No SDK payload\n"
: "SDK connected\n") +
- (data.trackingCallbackParams?.length !== 2
- ? "Tracking callback issues\n"
- : "") +
+ (!data.hasTrackingCallback ? "No tracking callback\n" : "") +
+ (hasTrackingCallbackIssues(data) ? "Tracking callback issues\n" : "") +
(!data.payloadDecrypted ? "Decryption issues\n" : "") +
(paddedVersionString(data.version) <
paddedVersionString(latestMinorSdkVersion)
@@ -309,8 +309,7 @@ export function getSdkStatus(
(!sdkData.canConnect && !numExternalSdks) ||
(sdkData.canConnect && !sdkData.hasPayload) ||
(!sdkData.hasTrackingCallback && !numExternalSdks) ||
- (sdkData.hasTrackingCallback &&
- sdkData.trackingCallbackParams?.length !== 2) ||
+ hasTrackingCallbackIssues(sdkData) ||
(sdkData.hasPayload && !sdkData.payloadDecrypted) ||
(paddedVersionString(sdkData.version) <
paddedVersionString(latestMinorSdkVersion) &&
diff --git a/src/content_script/embed_script.ts b/src/content_script/embed_script.ts
index 282e7e1..6574d9c 100644
--- a/src/content_script/embed_script.ts
+++ b/src/content_script/embed_script.ts
@@ -7,9 +7,11 @@ import type {
Options,
Result,
TrackingCallback,
+ TrackingUserContext,
} from "@growthbook/growthbook";
import type { ErrorMessage, SDKHealthCheckResult } from "devtools";
import { Attributes } from "@growthbook/growthbook";
+import { parseCallbackParams } from "@/utils/sdkCallbacks";
type LogUnionWithSource = LogUnion & { source?: string; clientKey?: string };
@@ -458,6 +460,8 @@ function subscribeToSdkChanges(
const patchedCallBack = (
experiment: Experiment,
result: Result,
+ // SDK 1.7+ passes a third userContext arg
+ user?: TrackingUserContext,
) => {
if (!hasSdkLogSupport) {
gb.logs!.push({
@@ -468,21 +472,18 @@ function subscribeToSdkChanges(
});
}
if ("isNoopCallback" in callback && callback.isNoopCallback) {
+ // fireDeferredTrackingCalls replays these with call.user
gb.setDeferredTrackingCalls?.([
...gb.getDeferredTrackingCalls(),
- { experiment, result },
+ { experiment, result, user },
]);
}
- callback(experiment, result);
+ return callback(experiment, result, user);
};
if ("isNoopCallback" in callback && callback.isNoopCallback) {
patchedCallBack.isNoopCallback = true;
} else {
- patchedCallBack.originalParams = callback
- .toString()
- .match(/\(([^)]+)\)/)?.[1]
- .split(",")
- .map((param: string) => param.trim());
+ patchedCallBack.originalParams = parseCallbackParams(callback);
}
_setTrackingCallback?.call(gb, patchedCallBack);
pushAppUpdates();
diff --git a/src/utils/contextualBandits.test.ts b/src/utils/contextualBandits.test.ts
new file mode 100644
index 0000000..7e2727d
--- /dev/null
+++ b/src/utils/contextualBandits.test.ts
@@ -0,0 +1,220 @@
+import {
+ getMatchedContextAttributes,
+ isContextualBandit,
+ ruleVariations,
+ sdkSupportsContextualBandits,
+ usedFallbackWeights,
+} from "./contextualBandits";
+
+describe("ruleVariations", () => {
+ it("reads contextualVariations, which bandit rules use instead", () => {
+ expect(ruleVariations({ contextualVariations: ["a", "b", "c"] })).toEqual([
+ "a",
+ "b",
+ "c",
+ ]);
+ });
+
+ it("falls back to variations for a normal experiment rule", () => {
+ expect(ruleVariations({ variations: ["on", "off"] })).toEqual([
+ "on",
+ "off",
+ ]);
+ });
+
+ it("prefers contextualVariations when a rule somehow has both", () => {
+ expect(
+ ruleVariations({ variations: ["x"], contextualVariations: ["a", "b"] }),
+ ).toEqual(["a", "b"]);
+ });
+
+ it("returns undefined for a rule with neither", () => {
+ expect(ruleVariations({ force: true })).toBeUndefined();
+ });
+});
+
+describe("isContextualBandit", () => {
+ it("keys off contextualBanditRef", () => {
+ expect(isContextualBandit({ contextualBanditRef: "cb_x" })).toBe(true);
+ expect(isContextualBandit({})).toBe(false);
+ expect(isContextualBandit(undefined)).toBe(false);
+ });
+});
+
+describe("usedFallbackWeights", () => {
+ it("treats leafId -1 as the fallback", () => {
+ expect(usedFallbackWeights({ leafId: -1, variationWeights: [1] })).toBe(
+ true,
+ );
+ expect(usedFallbackWeights({ leafId: 0, variationWeights: [1] })).toBe(
+ false,
+ );
+ expect(usedFallbackWeights(undefined)).toBe(false);
+ });
+});
+
+describe("getMatchedContextAttributes", () => {
+ const definition = {
+ banditVersion: 4,
+ contexts: [
+ {
+ leafId: 0,
+ condition: { country: "US", device: "mobile" },
+ weights: [0.12, 0.63, 0.25],
+ },
+ { leafId: 1, condition: { country: "US" }, weights: [0.3, 0.4, 0.3] },
+ ],
+ };
+ const attributes = { id: "u1", country: "US", device: "mobile", plan: "pro" };
+
+ it("returns only the attributes the matched context tested", () => {
+ expect(
+ getMatchedContextAttributes(
+ definition,
+ { leafId: 0, variationWeights: [] },
+ attributes,
+ ),
+ ).toEqual({ country: "US", device: "mobile" });
+ });
+
+ it("narrows to the matched context, not every context", () => {
+ expect(
+ getMatchedContextAttributes(
+ definition,
+ { leafId: 1, variationWeights: [] },
+ attributes,
+ ),
+ ).toEqual({ country: "US" });
+ });
+
+ it("returns nothing when the weights came from the fallback", () => {
+ expect(
+ getMatchedContextAttributes(
+ definition,
+ { leafId: -1, variationWeights: [] },
+ attributes,
+ ),
+ ).toBeUndefined();
+ });
+
+ it("descends through $and / $or in a context condition", () => {
+ const nested = {
+ contexts: [
+ {
+ leafId: 0,
+ condition: { $or: [{ country: "US" }, { plan: "pro" }] },
+ weights: [0.5, 0.5],
+ },
+ ],
+ };
+ expect(
+ getMatchedContextAttributes(
+ nested,
+ { leafId: 0, variationWeights: [] },
+ attributes,
+ ),
+ ).toEqual({ country: "US", plan: "pro" });
+ });
+
+ it("skips attributes the user does not have set", () => {
+ expect(
+ getMatchedContextAttributes(
+ definition,
+ { leafId: 0, variationWeights: [] },
+ { country: "US" },
+ ),
+ ).toEqual({ country: "US" });
+ });
+});
+
+// Result.key is the variation key ("0", "1", …), not the experiment key, so a
+// lookup keyed on it silently finds nothing and the bandit panel goes inert
+describe("finding a bandit result among evaluated features", () => {
+ const evaluatedFeatures = {
+ f1: {
+ result: {
+ experiment: { key: "my-bandit" },
+ experimentResult: {
+ key: "1",
+ variationId: 1,
+ leafId: 3,
+ variationWeights: [0.02, 0.96, 0.02],
+ banditVersion: 9,
+ },
+ },
+ },
+ };
+
+ it("matches on the experiment key, not the result key", () => {
+ const found = Object.values(evaluatedFeatures).find(
+ (f) => f?.result?.experiment?.key === "my-bandit",
+ )?.result?.experimentResult;
+ expect(found?.leafId).toBe(3);
+ expect(found?.variationWeights).toEqual([0.02, 0.96, 0.02]);
+ });
+
+ it("does not match the result key against the experiment key", () => {
+ expect(evaluatedFeatures.f1.result.experimentResult.key).not.toBe(
+ "my-bandit",
+ );
+ });
+});
+
+// The sandbox stuffs rule metadata for its debug log. The SDK indexes
+// experiment.meta by variation index and throws on a short array, which took
+// down the whole evaluation and left every experiment showing as inactive.
+describe("stuffed rule meta", () => {
+ const stuff = (rule: any, i = 0, fid = "f1") => ({
+ ...rule,
+ meta: ruleVariations(rule)?.length
+ ? ruleVariations(rule)!.map((_, vi) => ({
+ ...(rule.meta?.[vi] ?? {}),
+ ruleI: i,
+ featureId: fid,
+ }))
+ : [{ ruleI: i, featureId: fid }],
+ });
+
+ it("stays as long as the variations when the rule has no meta", () => {
+ expect(stuff({ variations: ["off", "on"] }).meta).toHaveLength(2);
+ });
+
+ it("stays as long as the variations for a bandit rule", () => {
+ expect(
+ stuff({ contextualVariations: ["a", "b", "c", "d"] }).meta,
+ ).toHaveLength(4);
+ });
+
+ it("keeps existing meta entries", () => {
+ const meta = stuff({
+ variations: ["a", "b"],
+ meta: [
+ { key: "0", name: "A" },
+ { key: "1", name: "B" },
+ ],
+ }).meta;
+ expect(meta[1]).toMatchObject({ key: "1", name: "B", ruleI: 0 });
+ });
+
+ it("still tags a rule with no variations, which the debug log reads", () => {
+ expect(stuff({ force: true }).meta).toEqual([
+ { ruleI: 0, featureId: "f1" },
+ ]);
+ });
+});
+
+describe("sdkSupportsContextualBandits", () => {
+ it("is false below 1.7.0, where the SDK skips bandit rules", () => {
+ expect(sdkSupportsContextualBandits("1.6.5")).toBe(false);
+ expect(sdkSupportsContextualBandits("0.36.0")).toBe(false);
+ });
+
+ it("is true from 1.7.0", () => {
+ expect(sdkSupportsContextualBandits("1.7.0")).toBe(true);
+ expect(sdkSupportsContextualBandits("1.8.2")).toBe(true);
+ });
+
+ it("assumes support when the version is unknown", () => {
+ expect(sdkSupportsContextualBandits(undefined)).toBe(true);
+ });
+});
diff --git a/src/utils/contextualBandits.ts b/src/utils/contextualBandits.ts
new file mode 100644
index 0000000..0f9f9e4
--- /dev/null
+++ b/src/utils/contextualBandits.ts
@@ -0,0 +1,103 @@
+import { paddedVersionString } from "@growthbook/growthbook";
+import type { Experiment, FeatureRule } from "@growthbook/growthbook";
+
+// Declared by the SDK but not exported from its entry point
+type CBContext = {
+ leafId: number;
+ variationWeights: number[];
+ banditVersion?: number;
+};
+type ContextualBanditDefinition = {
+ banditVersion?: number;
+ contexts: {
+ leafId: number;
+ condition: Record;
+ weights: number[];
+ }[];
+};
+export type ContextualBanditDefinitions = Record<
+ string,
+ ContextualBanditDefinition
+>;
+
+// leafId -1 means no context matched and the SDK used the aggregate weights
+export const FALLBACK_LEAF_ID = -1;
+
+// Contextual bandits arrived in 1.7.0; older SDKs skip the rule entirely
+export const CONTEXTUAL_BANDIT_SDK_VERSION = "1.7.0";
+
+export function sdkSupportsContextualBandits(version?: string): boolean {
+ if (!version) return true;
+ return (
+ paddedVersionString(version) >=
+ paddedVersionString(CONTEXTUAL_BANDIT_SDK_VERSION)
+ );
+}
+
+// Bandit rules keep their variations here so pre-1.7 SDKs skip the rule
+export function ruleVariations(
+ rule: FeatureRule,
+): Experiment["variations"] | undefined {
+ return (rule.contextualVariations ?? rule.variations) as
+ | Experiment["variations"]
+ | undefined;
+}
+
+// contextualBanditRef only appears once the bandit has trained contexts
+export function isContextualBandit(
+ experiment:
+ | { contextualVariations?: unknown[]; contextualBanditRef?: string }
+ | undefined,
+): boolean {
+ return (
+ !!experiment?.contextualVariations || !!experiment?.contextualBanditRef
+ );
+}
+
+// The runtime experiment only keeps this once bandit weights were applied
+export function appliedContextualBandit(
+ experiment: { contextualBandit?: CBContext } | undefined,
+): boolean {
+ return !!experiment?.contextualBandit;
+}
+
+export function usedFallbackWeights(cb: CBContext | undefined): boolean {
+ return cb?.leafId === FALLBACK_LEAF_ID;
+}
+
+// The attributes the matched context tested - the "contextual" part
+export function getMatchedContextAttributes(
+ definition: ContextualBanditDefinition | undefined,
+ cb: CBContext | undefined,
+ attributes: Record,
+): Record | undefined {
+ if (!definition || !cb || usedFallbackWeights(cb)) return undefined;
+ const context = definition.contexts.find((c) => c.leafId === cb.leafId);
+ if (!context) return undefined;
+ const used: Record = {};
+ for (const key of conditionAttributeKeys(context.condition)) {
+ if (key in attributes) used[key] = attributes[key];
+ }
+ return used;
+}
+
+// Attribute names in a condition, descending through $and/$or wrappers
+function conditionAttributeKeys(condition: Record): string[] {
+ const keys = new Set();
+ const walk = (node: unknown) => {
+ if (Array.isArray(node)) {
+ node.forEach(walk);
+ return;
+ }
+ if (!node || typeof node !== "object") return;
+ for (const [key, value] of Object.entries(node)) {
+ if (key.startsWith("$")) {
+ walk(value);
+ } else {
+ keys.add(key);
+ }
+ }
+ };
+ walk(condition);
+ return [...keys];
+}
diff --git a/src/utils/realPayload.test.ts b/src/utils/realPayload.test.ts
new file mode 100644
index 0000000..d899e7c
--- /dev/null
+++ b/src/utils/realPayload.test.ts
@@ -0,0 +1,41 @@
+import { isContextualBandit, ruleVariations } from "./contextualBandits";
+
+// The teresa-test-cb rule exactly as a live GrowthBook payload serves it:
+// contextualVariations is present, but there is no contextualBanditRef and no
+// top-level contextualBandits block until the bandit has trained contexts.
+const realRule = {
+ id: "fr_19g6mms6mcr6p",
+ hashAttribute: "id",
+ seed: "d5212dbe-3cee-430b-9a43-75d5c7b13474",
+ hashVersion: 2,
+ disableStickyBucketing: true,
+ contextualVariations: [true, true, true, true, true],
+ weights: [0.2, 0.2, 0.2, 0.2, 0.2],
+ key: "teresa-test-cb",
+ meta: [{ key: "0" }, { key: "1" }, { key: "2" }, { key: "3" }, { key: "4" }],
+ phase: "0",
+};
+
+describe("a contextual bandit from a live payload", () => {
+ it("is detected without a contextualBanditRef", () => {
+ expect(isContextualBandit(realRule)).toBe(true);
+ });
+
+ it("reaches the experiment list via contextualVariations", () => {
+ expect(ruleVariations(realRule)).toHaveLength(5);
+ });
+
+ it("is still detected once the ref appears", () => {
+ expect(
+ isContextualBandit({ ...realRule, contextualBanditRef: "cb_x" }),
+ ).toBe(true);
+ });
+
+ it("does not flag an ordinary experiment rule", () => {
+ const ordinaryRule: {
+ variations: boolean[];
+ contextualVariations?: unknown[];
+ } = { variations: [true, false] };
+ expect(isContextualBandit(ordinaryRule)).toBe(false);
+ });
+});
diff --git a/src/utils/sdkCallbacks.test.ts b/src/utils/sdkCallbacks.test.ts
new file mode 100644
index 0000000..412fef0
--- /dev/null
+++ b/src/utils/sdkCallbacks.test.ts
@@ -0,0 +1,182 @@
+import {
+ hasTrackingCallbackIssues,
+ parseCallbackParams,
+ trackingCallbackParamsAreValid,
+} from "./sdkCallbacks";
+
+function getDefault() {
+ return undefined;
+}
+
+describe("parseCallbackParams", () => {
+ it("parses arrow functions", () => {
+ expect(parseCallbackParams((experiment, result) => {})).toEqual([
+ "experiment",
+ "result",
+ ]);
+ });
+
+ it("parses arrow functions with a userContext param", () => {
+ expect(
+ parseCallbackParams((experiment, result, userContext) => {}),
+ ).toEqual(["experiment", "result", "userContext"]);
+ });
+
+ it("parses function expressions", () => {
+ expect(parseCallbackParams(function (experiment, result) {})).toEqual([
+ "experiment",
+ "result",
+ ]);
+ });
+
+ it("parses async functions", () => {
+ expect(parseCallbackParams(async function (experiment, result) {})).toEqual(
+ ["experiment", "result"],
+ );
+ });
+
+ it("parses a single unparenthesized arrow param", () => {
+ // Built at runtime - a type annotation forces parens, which would silently
+ // take the paren-walking path instead of the bare-arrow branch
+ const cb = Function("return experiment => ({ key: experiment.key })")();
+ expect(cb.toString()).toMatch(/^experiment\s*=>/);
+ expect(parseCallbackParams(cb)).toEqual(["experiment"]);
+ });
+
+ it("does not read a bare arrow's body as its params", () => {
+ const cb = Function(
+ 'return e => window.gtag("event", "x", { id: e.key })',
+ )();
+ expect(parseCallbackParams(cb)).toEqual(["e"]);
+ });
+
+ it("does not split on a comma inside a string default", () => {
+ expect(
+ parseCallbackParams((experiment, result, sep = ",") => [
+ experiment,
+ result,
+ sep,
+ ]),
+ ).toEqual(["experiment", "result", 'sep = ","']);
+ });
+
+ it("does not desync on a bracket inside a string default", () => {
+ expect(parseCallbackParams((a, b = "){[") => [a, b])).toEqual([
+ "a",
+ 'b = "){["',
+ ]);
+ });
+
+ it("does not truncate at a destructured param", () => {
+ expect(
+ parseCallbackParams(
+ (experiment, { variationId }, userContext) => undefined,
+ ),
+ ).toEqual(["experiment", "{ variationId }", "userContext"]);
+ });
+
+ it("does not truncate at a default value containing parens", () => {
+ expect(
+ parseCallbackParams((experiment, result = getDefault(), extra) => {}),
+ ).toEqual(["experiment", "result = getDefault()", "extra"]);
+ });
+
+ it("parses rest params", () => {
+ expect(parseCallbackParams((...args) => {})).toEqual(["...args"]);
+ });
+
+ it("parses zero params", () => {
+ expect(parseCallbackParams(() => {})).toEqual([]);
+ });
+
+ it("returns undefined for native functions", () => {
+ expect(parseCallbackParams(Math.max)).toBeUndefined();
+ });
+});
+
+describe("trackingCallbackParamsAreValid", () => {
+ it("accepts 2 params", () => {
+ expect(trackingCallbackParamsAreValid(["experiment", "result"])).toBe(true);
+ });
+
+ it("accepts 3 params", () => {
+ expect(
+ trackingCallbackParamsAreValid(["experiment", "result", "userContext"]),
+ ).toBe(true);
+ });
+
+ it("accepts rest params", () => {
+ expect(trackingCallbackParamsAreValid(["...args"])).toBe(true);
+ });
+
+ it("accepts unknown params rather than warning", () => {
+ expect(trackingCallbackParamsAreValid(undefined)).toBe(true);
+ });
+
+ it("rejects too few params", () => {
+ expect(trackingCallbackParamsAreValid(["experiment"])).toBe(false);
+ });
+
+ it("accepts a zero-arity forwarding wrapper rather than warning", () => {
+ // `function () { cb.apply(this, arguments) }` parses to [] and works fine
+ expect(trackingCallbackParamsAreValid([])).toBe(true);
+ });
+
+ it("rejects too many params", () => {
+ expect(trackingCallbackParamsAreValid(["a", "b", "c", "d"])).toBe(false);
+ });
+
+ it("rejects 2 params once the SDK passes a userContext", () => {
+ expect(
+ trackingCallbackParamsAreValid(["experiment", "result"], "1.7.0"),
+ ).toBe(false);
+ expect(
+ trackingCallbackParamsAreValid(["experiment", "result"], "1.8.2"),
+ ).toBe(false);
+ });
+
+ it("accepts 2 params on SDKs that never pass a userContext", () => {
+ expect(
+ trackingCallbackParamsAreValid(["experiment", "result"], "1.6.5"),
+ ).toBe(true);
+ expect(
+ trackingCallbackParamsAreValid(["experiment", "result"], "0.36.0"),
+ ).toBe(true);
+ });
+
+ it("rejects 3 params on SDKs that never pass a userContext", () => {
+ const params = ["experiment", "result", "userContext"];
+ expect(trackingCallbackParamsAreValid(params, "1.6.5")).toBe(false);
+ expect(trackingCallbackParamsAreValid(params, "1.7.0")).toBe(true);
+ });
+
+ it("stays lenient when the version is unknown", () => {
+ expect(trackingCallbackParamsAreValid(["experiment", "result"])).toBe(true);
+ });
+});
+
+describe("hasTrackingCallbackIssues", () => {
+ it("does not flag a missing tracking callback as an issue", () => {
+ expect(hasTrackingCallbackIssues({ hasTrackingCallback: false })).toBe(
+ false,
+ );
+ });
+
+ it("does not flag a 3-param tracking callback", () => {
+ expect(
+ hasTrackingCallbackIssues({
+ hasTrackingCallback: true,
+ trackingCallbackParams: ["experiment", "result", "userContext"],
+ }),
+ ).toBe(false);
+ });
+
+ it("flags a 1-param tracking callback", () => {
+ expect(
+ hasTrackingCallbackIssues({
+ hasTrackingCallback: true,
+ trackingCallbackParams: ["experiment"],
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/src/utils/sdkCallbacks.ts b/src/utils/sdkCallbacks.ts
new file mode 100644
index 0000000..2c00712
--- /dev/null
+++ b/src/utils/sdkCallbacks.ts
@@ -0,0 +1,89 @@
+import { paddedVersionString } from "@growthbook/growthbook";
+import type { SDKHealthCheckResult } from "devtools";
+
+// Pull the parameter names off a function's source. Returns undefined when we can't tell, eg native or bound functions
+export function parseCallbackParams(
+ callback: (...args: any[]) => any,
+): string[] | undefined {
+ const src = callback.toString();
+ if (src.includes("[native code]")) return undefined;
+
+ // Single-param arrow function without parens, eg `experiment => ...`
+ const bareArrowParam = src.match(/^\s*(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/);
+ if (bareArrowParam) return [bareArrowParam[1]];
+
+ const open = src.indexOf("(");
+ if (open === -1) return undefined;
+
+ // Walk to the matching close paren so defaults and destructured params don't cut the list short
+ const params: string[] = [];
+ let current = "";
+ let depth = 0;
+ let quote: string | null = null;
+ for (let i = open; i < src.length; i++) {
+ const char = src[i];
+ if (quote) {
+ current += char;
+ if (char === "\\") current += src[++i] ?? "";
+ else if (char === quote) quote = null;
+ continue;
+ }
+ if (char === '"' || char === "'" || char === "`") {
+ quote = char;
+ current += char;
+ continue;
+ }
+ if (char === "(" || char === "[" || char === "{") {
+ depth++;
+ if (depth === 1) continue;
+ } else if (char === ")" || char === "]" || char === "}") {
+ depth--;
+ if (depth === 0) {
+ if (current.trim()) params.push(current.trim());
+ return params;
+ }
+ } else if (char === "," && depth === 1) {
+ if (current.trim()) params.push(current.trim());
+ current = "";
+ continue;
+ }
+ current += char;
+ }
+ return undefined;
+}
+
+// From this version the SDK also passes a userContext to trackingCallback
+export const USER_CONTEXT_SDK_VERSION = "1.7.0";
+
+export function expectsUserContextParam(version?: string): boolean {
+ if (!version) return false;
+ return (
+ paddedVersionString(version) >=
+ paddedVersionString(USER_CONTEXT_SDK_VERSION)
+ );
+}
+
+// A trackingCallback takes `(experiment, result)`, or `(experiment, result, userContext)` on 1.7.0+
+export function trackingCallbackParamsAreValid(
+ params: string[] | undefined,
+ version?: string,
+): boolean {
+ if (!params) return true;
+ // Zero params means a forwarding wrapper that reads `arguments` instead
+ if (params.length === 0) return true;
+ if (params.some((param) => param.startsWith("..."))) return true;
+ if (!version) return params.length === 2 || params.length === 3;
+ return params.length === (expectsUserContextParam(version) ? 3 : 2);
+}
+
+export function hasTrackingCallbackIssues({
+ hasTrackingCallback,
+ trackingCallbackParams,
+ version,
+}: Pick<
+ SDKHealthCheckResult,
+ "hasTrackingCallback" | "trackingCallbackParams" | "version"
+>): boolean {
+ if (!hasTrackingCallback) return false;
+ return !trackingCallbackParamsAreValid(trackingCallbackParams, version);
+}
diff --git a/yarn.lock b/yarn.lock
index 270d224..b8c0215 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -641,10 +641,10 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429"
integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==
-"@growthbook/growthbook@^1.6.5":
- version "1.6.5"
- resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.6.5.tgz#c9e2119187ee3288525a77a7c64353276f5ba91b"
- integrity sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A==
+"@growthbook/growthbook@^1.7.0":
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.7.0.tgz#4507d6ca08c9e9c8fa311b708292181d37ac1a17"
+ integrity sha512-Vmad3fWRh1XaFH4q+AS62ofX6Q3IxSTD7sBGuhEHCaad8VyZerxrVdojucjzlnJDUocfrnZFUSpEkR6R/wCV+A==
dependencies:
dom-mutator "^0.6.0"