diff --git a/package.json b/package.json index 8ff2c85..f2175f4 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "@chakra-ui/react": "^2.8.2", "@emotion/react": "^11.13.0", "@emotion/styled": "^11.13.0", - "@growthbook/growthbook": "^1.6.5", + "@growthbook/growthbook": "^1.7.0", "@medv/finder": "^3.2.0", "@phosphor-icons/react": "^2.1.7", "@radix-ui/colors": "^3.0.0", diff --git a/src/app/components/ContextualBanditDetail.tsx b/src/app/components/ContextualBanditDetail.tsx new file mode 100644 index 0000000..68d69ab --- /dev/null +++ b/src/app/components/ContextualBanditDetail.tsx @@ -0,0 +1,321 @@ +import React from "react"; +import { Badge, Link, Text } from "@radix-ui/themes"; +import * as Accordion from "@radix-ui/react-accordion"; +import clsx from "clsx"; +import { + PiCaretRightFill, + PiCheckCircleFill, + PiInfoBold, + PiWarningFill, +} from "react-icons/pi"; +import { Experiment, Result } from "@growthbook/growthbook"; +import useTabState from "@/app/hooks/useTabState"; +import useSdkData from "@/app/hooks/useSdkData"; +import { + ContextualBanditDefinitions, + FALLBACK_LEAF_ID, + getMatchedContextAttributes, + sdkSupportsContextualBandits, + usedFallbackWeights, + CONTEXTUAL_BANDIT_SDK_VERSION, +} from "@/utils/contextualBandits"; +import { + expectsUserContextParam, + trackingCallbackParamsAreValid, +} from "@/utils/sdkCallbacks"; + +// Matches Rule.tsx +function formatWeight(weight: number) { + return Math.round(weight * 1000) / 10 + "%"; +} + +function Check({ + ok, + info, + children, + hint, +}: { + ok?: boolean; + info?: boolean; + children: React.ReactNode; + hint?: string; +}) { + return ( +
+ {info ? ( + + ) : ok ? ( + + ) : ( + + )} +
+
{children}
+ {hint ?
{hint}
: null} +
+
+ ); +} + +export default function ContextualBanditDetail({ + experiment, + variationNames, + forcedVariation, + result, +}: { + experiment: Experiment & { + contextualBanditRef?: string; + contextualVariations?: unknown[]; + }; + variationNames: string[]; + forcedVariation?: number; + result?: Result; +}) { + const sdkData = useSdkData(); + const [attributes] = useTabState>("attributes", {}); + const banditRef = experiment.contextualBanditRef; + const definitions = sdkData?.payload?.contextualBandits as + | ContextualBanditDefinitions + | undefined; + const definition = banditRef ? definitions?.[banditRef] : undefined; + + const cb = result?.variationWeights + ? { + leafId: result.leafId ?? FALLBACK_LEAF_ID, + variationWeights: result.variationWeights, + banditVersion: result.banditVersion, + } + : undefined; + + const weights = cb?.variationWeights ?? experiment.weights; + const isFallback = usedFallbackWeights(cb); + const hasContextWeights = !!cb?.variationWeights?.length && !isFallback; + const context = getMatchedContextAttributes(definition, cb, attributes || {}); + const contextKeys = Object.keys(context || {}); + const isForced = forcedVariation !== undefined; + // DevTools evaluates with its own bundled SDK, so without this the panel + // would report a bandit assignment a pre-1.7 page never made + const sdkSupported = sdkSupportsContextualBandits(sdkData?.version); + // Without a value for the hash attribute the SDK skips the rule outright, + // so there is no assignment and no bandit data at all + const hashAttribute = experiment.hashAttribute ?? "id"; + const fallbackAttribute = experiment.fallbackAttribute; + const missingHashValue = + !attributes?.[hashAttribute] && + !(fallbackAttribute && attributes?.[fallbackAttribute]); + // The SDK clamps a skipped assignment to 0 with inExperiment false, so + // without this guard the panel bolds variation 0 for a user it did not serve + const selectedVariation = result?.inExperiment + ? result.variationId + : undefined; + + // Rewards are attributed per context, so userContext has to reach the callback + const callbackPassesUserContext = + !!sdkData?.hasTrackingCallback && + expectsUserContextParam(sdkData?.version) && + trackingCallbackParamsAreValid( + sdkData?.trackingCallbackParams, + sdkData?.version, + ); + + // Only meaningful once bandit weights were actually applied + const leafLabel = !cb + ? undefined + : isFallback + ? "No matching context" + : cb.leafId; + const subline = [ + banditRef ? `contextualBanditRef: ${banditRef}` : null, + cb?.banditVersion !== undefined ? `v${cb.banditVersion}` : null, + ] + .filter(Boolean) + .join(" · "); + + if (!sdkSupported) { + return ( + <> +
Contextual Bandit
+ + SDK {sdkData?.version} does not support contextual bandits, so the + page skips this rule. Anything shown below would be DevTools’ + own evaluation, not what the page served. Upgrade to{" "} + {CONTEXTUAL_BANDIT_SDK_VERSION} or later to run it. + + + ); + } + + return ( + <> +
Contextual Bandit
+ + {subline ? ( +
{subline}
+ ) : null} + + {leafLabel !== undefined ? ( +
+
+ Current leaf +
+
{leafLabel}
+
+ ) : null} + + {banditRef && !definition ? ( + + The bandit {banditRef} is not in the SDK payload. + + ) : null} + + + {weights?.length ? ( + + + + + {hasContextWeights + ? "Weights for this context" + : "Variation Weights"} + + + + {weights.length} variation{weights.length === 1 ? "" : "s"} + + + +
+ {weights.map((weight, i) => ( +
+
+ {i} · {variationNames[i] ?? `Variation ${i}`} +
+
+
+
+
+ {formatWeight(weight)} +
+
+ ))} +
+ + + ) : null} + + {contextKeys.length ? ( + + + + + Context used for this user + + + {contextKeys.length} attribute + {contextKeys.length === 1 ? "" : "s"} + + + +
+ {contextKeys.map((key) => ( +
+
+ {key} +
+
+ {JSON.stringify(context?.[key])} +
+
+ ))} +
+
+
+ ) : null} + + +
Setup
+ {!expectsUserContextParam(sdkData?.version) ? ( + + SDK {sdkData?.version ?? "version unknown"} does not pass userContext + to trackingCallback + + ) : ( + + {callbackPassesUserContext + ? "trackingCallback passes userContext" + : "trackingCallback is missing the userContext param"} + + )} + {missingHashValue ? ( + + {`No value for the "${hashAttribute}" attribute, so the rule is skipped`} + + ) : definition && !cb ? ( + + {isForced + ? "Clear the forced variation to see the live bandit weights" + : "Bandit weights were not applied for this user"} + + ) : definition ? ( + Bandit definition found in payload + ) : banditRef ? ( + Bandit definition missing from payload + ) : ( + No trained contexts yet, so weights are still even + )} + + ); +} + +export function ContextualBanditBadge() { + return ( + + Contextual Bandit + + ); +} diff --git a/src/app/components/ExperimentDetail.tsx b/src/app/components/ExperimentDetail.tsx index 6d69df5..cc76768 100644 --- a/src/app/components/ExperimentDetail.tsx +++ b/src/app/components/ExperimentDetail.tsx @@ -38,7 +38,11 @@ import useGlobalState from "@/app/hooks/useGlobalState"; import { APP_ORIGIN, CLOUD_APP_ORIGIN } from "@/app/components/Settings"; import useTabState from "@/app/hooks/useTabState"; import { SelectedExperiment } from "@/app/components/ExperimentsTab"; -import { AutoExperimentVariation, FeatureDefinition, isURLTargeted } from "@growthbook/growthbook"; +import { + AutoExperimentVariation, + FeatureDefinition, + isURLTargeted, +} from "@growthbook/growthbook"; import clsx from "clsx"; import DebugLogger, { DebugLogAccordion } from "@/app/components/DebugLogger"; import { TbEyeSearch } from "react-icons/tb"; @@ -47,6 +51,13 @@ import { EvaluationSourceViewer, } from "@/app/components/FeatureDetail"; import { LogUnionWithSource } from "@/app/utils/logs"; +import SelectField from "@/app/components/Forms/SelectField"; +import ContextualBanditDetail, { + ContextualBanditBadge, +} from "@/app/components/ContextualBanditDetail"; + +// The panel states these itself, so echoing the SDK log adds nothing +const REDUNDANT_DEBUG_LOGS = ["In experiment", "Force via dev tools"]; export default function ExperimentDetail({ selectedEid, @@ -139,8 +150,8 @@ export default function ExperimentDetail({ const expFeatures = selectedExperiment?.experiment?.features ?? []; for (const fid of expFeatures) { const rule0 = (features[fid]?.rules ?? [])[0] as any; - const holdoutFid = rule0?.parentConditions?.find( - (pc: { id?: string }) => pc.id?.startsWith("$holdout:"), + const holdoutFid = rule0?.parentConditions?.find((pc: { id?: string }) => + pc.id?.startsWith("$holdout:"), )?.id; if (!holdoutFid) continue; const holdoutExpKey = (features[holdoutFid]?.rules?.[0] as any)?.key; @@ -257,6 +268,11 @@ export default function ExperimentDetail({ {selectedExperiment?.experiment ? getExperimentDisplayName(selectedExperiment.experiment) : selectedEid} + {types?.contextualBandit ? ( + + + + ) : null} )} - {lastDebugLog !== "In experiment" && ( + {lastDebugLog && !REDUNDANT_DEBUG_LOGS.includes(lastDebugLog) && (
+ {types?.contextualBandit && selectedExperiment?.experiment ? ( + undefined) ?? + [] + ).map((m, i) => m?.name ?? `Variation ${i}`)} + forcedVariation={ + selectedEid && selectedEid in forcedVariations + ? forcedVariations[selectedEid] + : undefined + } + result={selectedExperiment.evaluatedExperiment?.result} + /> + ) : null} + {evaluations.length ? ( -
- Targeting and Traffic -
+ {/* A bandit's weights are dynamic, so the static ones mislead */} + {!types?.contextualBandit ? ( + <> +
+ Targeting and Traffic +
- {urlPatterns?.length ? ( -
-
URL Targeting
-
    - {urlPatterns.map((pattern, i) => ( -
  • -
    {pattern.pattern}
    - {pattern.type !== "simple" && ( -
    - ({pattern.type} - {pattern.include ? ", exclude" : ""}) -
    - )} -
    - {isURLTargeted(url, [pattern]) ? ( -
    - Current URL targeted + {urlPatterns?.length ? ( +
    +
    URL Targeting
    +
      + {urlPatterns.map((pattern, i) => ( +
    • +
      {pattern.pattern}
      + {pattern.type !== "simple" && ( +
      + ({pattern.type} + {pattern.include ? ", exclude" : ""}) +
      + )} +
      + {isURLTargeted(url, [pattern]) ? ( +
      + Current URL targeted +
      + ) : ( +
      + Current URL excluded +
      + )}
      - ) : ( -
      - Current URL excluded -
      - )} -
    -
  • - ))} -
-
- ) : null} - -
-
Experiment
- -
- {condition || parentConditions ? ( - + + ))} + +
) : null} - -
-
+
+
Experiment
+ +
+ {condition || parentConditions ? ( + + ) : null} + + +
+
+ + ) : null} {selectedExperiment ? (
@@ -651,6 +689,36 @@ function EditableVariationField({ if (!variationsMeta || !experiment) return null; + // Cards stop being scannable past a handful of variations + if (variationsMeta.length > 4) { + return ( +
+ ({ + label: getVariationSummary({ experiment, i }), + value: i + "", + }))} + onChange={(v) => setValue(parseInt(v))} + formatOptionLabel={(opt) => { + // SelectField labels the selected value with the raw value, so + // derive the name from the index for the trigger and the menu alike + const i = parseInt(opt.value); + return ( +
+ + + {getVariationSummary({ experiment, i })} + +
+ ); + }} + /> +
+ ); + } + return (
) & { features?: string[]; + contextualBanditRef?: string; + contextualVariations?: unknown[]; featureTypes?: Record; isDraft?: boolean; isInactive?: boolean; @@ -51,7 +55,6 @@ export default function ExperimentsTab() { // de-dupe const allExperiments = useMemo(() => { - const merged: ExperimentWithFeatures[] = [ ...experiments, ...featureExperiments, @@ -289,7 +292,12 @@ export default function ExperimentsTab() { onClick={() => clickExperiment(eid, changeId)} >
@@ -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"