diff --git a/README.md b/README.md index 6d62459..9f20c5a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ The GrowthBook Proxy repository is a mono-repo containing the following packages ### What's new +**Version 1.1.4** +- Support Redis-based sticky bucketing for remote evaluation +- Update remote evaluation to allow for buffered sticky bucket writes +- Update SDK version to support sticky bucketing and prerequisite flags + **Version 1.1.2** - Fix max payload size bug - Deprecate `CLUSTER_ROOT_NODES` in favor of `CLUSTER_ROOT_NODES_JSON` @@ -149,6 +154,12 @@ If the GrowthBook app your proxy is connecting to is using a self-signed certifi **Remote Evaluation** - `ENABLE_REMOTE_EVAL` - "true" or "1" to enable remote evaluation (default: `true`) +- `ENABLE_STICKY_BUCKETING` - "true" or "1" to enable sticky bucketing for remote evaluation. Requires a Redis connection (default: `false`) + - `STICKY_BUCKET_ENGINE` - One of: `redis`, `none` (only Redis is supported) (default: `none`) + - `STICKY_BUCKET_CONNECTION_URL` - The URL of your Redis Database + - `STICKY_BUCKET_USE_CLUSTER` - "true" or "1" to enable Redis cluster mode (default: `false`) + - `STICKY_BUCKET_CLUSTER_ROOT_NODES_JSON` - JSON array of ClusterNode objects (ioredis) + - `STICKY_BUCKET_CLUSTER_OPTIONS_JSON` - JSON object of ClusterOptions (ioredis) **Misc** - `MAX_PAYLOAD_SIZE` - The maximum size of a request body (default: `"2mb"`) diff --git a/packages/apps/proxy/package.json b/packages/apps/proxy/package.json index 0b9bc21..b43eaca 100644 --- a/packages/apps/proxy/package.json +++ b/packages/apps/proxy/package.json @@ -31,7 +31,7 @@ "pino-http": "^8.3.1", "spdy": "^4.0.2", "uuid": "^9.0.0", - "@growthbook/proxy-eval": "^1.0.0" + "@growthbook/proxy-eval": "^1.0.2" }, "devDependencies": { "@types/cors": "^2.8.14", @@ -44,6 +44,7 @@ "nodemon": "^3.0.1", "pino-pretty": "^10.3.1", "rimraf": "^5.0.5", - "typescript": "5.2.2" + "typescript": "5.2.2", + "@growthbook/growthbook": "^0.34.0" } } diff --git a/packages/apps/proxy/src/app.ts b/packages/apps/proxy/src/app.ts index 9419151..ed36c13 100644 --- a/packages/apps/proxy/src/app.ts +++ b/packages/apps/proxy/src/app.ts @@ -14,6 +14,7 @@ import { } from "./services/eventStreamManager"; import { Context, GrowthBookProxy } from "./types"; import logger, { initializeLogger } from "./services/logger"; +import { initializeStickyBucketService } from "./services/stickyBucket"; export { Context, GrowthBookProxy, CacheEngine } from "./types"; @@ -57,6 +58,7 @@ const defaultContext: Context = { enableEventStream: true, enableEventStreamHeaders: true, enableRemoteEval: true, + enableStickyBucketing: false, proxyAllRequests: false, }; @@ -76,6 +78,9 @@ export const growthBookProxy = async ( initializeLogger(ctx); await initializeRegistrar(ctx); ctx.enableCache && (await initializeCache(ctx)); + ctx.enableRemoteEval && + ctx.enableStickyBucketing && + (await initializeStickyBucketService(ctx)); ctx.enableEventStream && initializeEventStreamManager(ctx); // set up handlers diff --git a/packages/apps/proxy/src/controllers/featuresController.ts b/packages/apps/proxy/src/controllers/featuresController.ts index db81f05..e96c53b 100644 --- a/packages/apps/proxy/src/controllers/featuresController.ts +++ b/packages/apps/proxy/src/controllers/featuresController.ts @@ -2,6 +2,7 @@ import express, { NextFunction, Request, Response } from "express"; import { evaluateFeatures } from "@growthbook/proxy-eval"; import readThroughCacheMiddleware from "../middleware/cache/readThroughCacheMiddleware"; import { featuresCache } from "../services/cache"; +import { stickyBucketService } from "../services/stickyBucket"; import { registrar } from "../services/registrar"; import { apiKeyMiddleware } from "../middleware/apiKeyMiddleware"; import webhookVerificationMiddleware from "../middleware/webhookVerificationMiddleware"; @@ -144,12 +145,13 @@ const getEvaluatedFeatures = async (req: Request, res: Response) => { ); const url = req.body?.url; - payload = evaluateFeatures({ + payload = await evaluateFeatures({ payload, attributes, forcedVariations, forcedFeatures, url, + stickyBucketService, ctx: req.app.locals?.ctx, }); diff --git a/packages/apps/proxy/src/init.ts b/packages/apps/proxy/src/init.ts index 5a1a026..87e59af 100644 --- a/packages/apps/proxy/src/init.ts +++ b/packages/apps/proxy/src/init.ts @@ -1,7 +1,7 @@ import express from "express"; import * as spdy from "spdy"; import dotenv from "dotenv"; -import { CacheEngine, Context } from "./types"; +import { CacheEngine, Context, StickyBucketEngine } from "./types"; dotenv.config({ path: "./.env.local" }); export const MAX_PAYLOAD_SIZE = "2mb"; @@ -72,6 +72,25 @@ export default async () => { enableRemoteEval: ["true", "1"].includes( process.env.ENABLE_REMOTE_EVAL ?? "1", ), + // Sticky Bucket settings (for remote eval): + enableStickyBucketing: ["true", "1"].includes( + process.env.ENABLE_STICKY_BUCKETING ?? "0", + ), + stickyBucketSettings: { + engine: (process.env.STICKY_BUCKET_ENGINE || + "none") as StickyBucketEngine, + connectionUrl: process.env.STICKY_BUCKET_CONNECTION_URL, + // Redis only - cluster: + useCluster: ["true", "1"].includes( + process.env.STICKY_BUCKET_USE_CLUSTER ?? "0", + ), + clusterRootNodesJSON: process.env.STICKY_BUCKET_CLUSTER_ROOT_NODES_JSON + ? JSON.parse(process.env.STICKY_BUCKET_CLUSTER_ROOT_NODES_JSON) + : undefined, + clusterOptionsJSON: process.env.STICKY_BUCKET_CLUSTER_OPTIONS_JSON + ? JSON.parse(process.env.STICKY_BUCKET_CLUSTER_OPTIONS_JSON) + : undefined, + }, }; // Express configuration consts: diff --git a/packages/apps/proxy/src/services/stickyBucket/RedisStickyBucketService.ts b/packages/apps/proxy/src/services/stickyBucket/RedisStickyBucketService.ts new file mode 100644 index 0000000..45fe912 --- /dev/null +++ b/packages/apps/proxy/src/services/stickyBucket/RedisStickyBucketService.ts @@ -0,0 +1,104 @@ +import Redis, { Cluster, ClusterNode, ClusterOptions } from "ioredis"; + +import { StickyBucketService } from "@growthbook/growthbook"; +import logger from "../logger"; +import { StickyBucketSettings } from "./index"; + +// from JS SDK (todo: export from sdk): +export type StickyAttributeKey = string; // `${attributeName}||${attributeValue}` +export type StickyExperimentKey = string; // `${experimentId}__{version}` +export type StickyAssignments = Record; +export interface StickyAssignmentsDocument { + attributeName: string; + attributeValue: string; + assignments: StickyAssignments; +} + +// Mostly taken from the JS SDK, with write buffer for when remote eval finishes + +export class RedisStickyBucketService extends StickyBucketService { + private client: Redis | Cluster | undefined; + private readonly connectionUrl: string | undefined; + private readonly useCluster: boolean; + private readonly clusterRootNodesJSON?: ClusterNode[]; + private readonly clusterOptions?: ClusterOptions; + + private writeBuffer: Record = {}; + + public constructor({ + connectionUrl, + useCluster = false, + clusterRootNodesJSON, + clusterOptionsJSON, + }: StickyBucketSettings = {}) { + super(); + this.connectionUrl = connectionUrl; + this.useCluster = useCluster; + this.clusterRootNodesJSON = clusterRootNodesJSON; + this.clusterOptions = clusterOptionsJSON; + } + + public async connect() { + if (!this.useCluster) { + this.client = this.connectionUrl + ? new Redis(this.connectionUrl) + : new Redis(); + } else { + if (this.clusterRootNodesJSON) { + this.client = new Redis.Cluster( + this.clusterRootNodesJSON, + this.clusterOptions, + ); + } else { + throw new Error("No cluster root nodes"); + } + } + } + + public async getAllAssignments( + attributes: Record, + ): Promise> { + const docs: Record = {}; + const keys = Object.entries(attributes).map( + ([attributeName, attributeValue]) => + `${attributeName}||${attributeValue}`, + ); + if (!this.client) return docs; + await this.client.mget(...keys).then((values) => { + values.forEach((raw) => { + try { + const data = JSON.parse(raw || "{}"); + if (data.attributeName && data.attributeValue && data.assignments) { + const key = `${data.attributeName}||${data.attributeValue}`; + docs[key] = data; + } + } catch (e) { + logger.error("unable to parse sticky bucket json"); + } + }); + }); + return docs; + } + + public async getAssignments(_attributeName: string, _attributeValue: string) { + // not implemented + return null; + } + + public async saveAssignments(doc: StickyAssignmentsDocument) { + const key = `${doc.attributeName}||${doc.attributeValue}`; + if (!this.client) return; + this.writeBuffer[key] = JSON.stringify(doc); + } + + public async onEvaluate() { + if (!this.client) return; + if (Object.keys(this.writeBuffer).length === 0) return; + this.client.mset(this.writeBuffer); + this.writeBuffer = {}; + } + + public getClient() { + return this.client; + } +} diff --git a/packages/apps/proxy/src/services/stickyBucket/index.ts b/packages/apps/proxy/src/services/stickyBucket/index.ts new file mode 100644 index 0000000..183b8f3 --- /dev/null +++ b/packages/apps/proxy/src/services/stickyBucket/index.ts @@ -0,0 +1,33 @@ +import { ClusterNode, ClusterOptions } from "ioredis"; +import { StickyBucketService } from "@growthbook/growthbook"; +import { Context } from "../../types"; +import logger from "../logger"; +import { RedisStickyBucketService } from "./RedisStickyBucketService"; + +// enhanced StickyBucketService, optimized for opening connections and listening to remote eval lifecycle events +export let stickyBucketService: + | (StickyBucketService & { + connect: () => Promise; + onEvaluate?: () => Promise; + }) + | null = null; + +export interface StickyBucketSettings { + connectionUrl?: string; + useCluster?: boolean; // for RedisCache + clusterRootNodesJSON?: ClusterNode[]; // for RedisCache + clusterOptionsJSON?: ClusterOptions; // for RedisCache +} + +export const initializeStickyBucketService = async (context: Context) => { + if ( + context.stickyBucketSettings && + context.stickyBucketSettings.engine !== "none" + ) { + if (context.stickyBucketSettings.engine === "redis") { + logger.info("using Redis sticky bucketing"); + stickyBucketService = new RedisStickyBucketService(context.cacheSettings); + await stickyBucketService.connect(); + } + } +}; diff --git a/packages/apps/proxy/src/types.ts b/packages/apps/proxy/src/types.ts index 49aa2ad..be82b03 100644 --- a/packages/apps/proxy/src/types.ts +++ b/packages/apps/proxy/src/types.ts @@ -3,6 +3,7 @@ import { HttpLogger } from "pino-http"; import { Connection, Registrar } from "./services/registrar"; import { EventStreamManager } from "./services/eventStreamManager"; import { FeaturesCache, CacheSettings } from "./services/cache"; +import { StickyBucketSettings } from "./services/stickyBucket"; export interface GrowthBookProxy { app: Express; @@ -27,6 +28,9 @@ export interface Context { cacheSettings?: { cacheEngine: CacheEngine; } & CacheSettings; + stickyBucketSettings?: { + engine: StickyBucketEngine; + } & StickyBucketSettings; enableHealthCheck?: boolean; enableCors?: boolean; enableAdmin?: boolean; @@ -37,6 +41,7 @@ export interface Context { eventStreamMaxDurationMs?: number; eventStreamPingIntervalMs?: number; enableRemoteEval?: boolean; + enableStickyBucketing?: boolean; proxyAllRequests?: boolean; environment?: "development" | "production"; verboseDebugging?: boolean; @@ -44,3 +49,4 @@ export interface Context { } export type CacheEngine = "memory" | "redis" | "mongo"; +export type StickyBucketEngine = "redis" | "none"; diff --git a/packages/shared/eval/package.json b/packages/shared/eval/package.json index 71ca3f7..5e99158 100644 --- a/packages/shared/eval/package.json +++ b/packages/shared/eval/package.json @@ -1,7 +1,7 @@ { "name": "@growthbook/proxy-eval", "description": "Remote evaluation service for proxies, edge workers, or backend APIs", - "version": "1.0.1", + "version": "1.0.2", "main": "dist/index.js", "license": "MIT", "repository": { @@ -18,7 +18,7 @@ "dev": "tsc --watch" }, "dependencies": { - "@growthbook/growthbook": "^0.30.0" + "@growthbook/growthbook": "^0.34.0" }, "devDependencies": { "rimraf": "^5.0.5", diff --git a/packages/shared/eval/src/index.ts b/packages/shared/eval/src/index.ts index f2f0bdb..0fe010c 100644 --- a/packages/shared/eval/src/index.ts +++ b/packages/shared/eval/src/index.ts @@ -1,12 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { GrowthBook, Context as GBContext } from "@growthbook/growthbook"; +import { + GrowthBook, + Context as GBContext, + StickyBucketService, +} from "@growthbook/growthbook"; -export function evaluateFeatures({ +export async function evaluateFeatures({ payload, attributes, forcedVariations, forcedFeatures, url, + stickyBucketService = null, ctx, }: { payload: any; @@ -14,6 +19,9 @@ export function evaluateFeatures({ forcedVariations?: Record; forcedFeatures?: Map; url?: string; + stickyBucketService?: + | (StickyBucketService & { onEvaluate?: () => Promise }) + | null; ctx?: any; }) { const evaluatedFeatures: Record = {}; @@ -34,6 +42,9 @@ export function evaluateFeatures({ if (url !== undefined) { context.url = url; } + if (stickyBucketService) { + context.stickyBucketService = stickyBucketService; + } if (features || experiments) { const gb = new GrowthBook(context); @@ -43,6 +54,9 @@ export function evaluateFeatures({ if (ctx?.verboseDebugging) { gb.debug = true; } + if (stickyBucketService) { + await gb.refreshStickyBuckets(); + } const gbFeatures = gb.getFeatures(); for (const key in gbFeatures) { @@ -85,6 +99,8 @@ export function evaluateFeatures({ } } + stickyBucketService?.onEvaluate?.(); + return { ...payload, features: evaluatedFeatures, diff --git a/yarn.lock b/yarn.lock index 1fb32cf..9061d77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -51,10 +51,10 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.56.0.tgz#ef20350fec605a7f7035a01764731b2de0f3782b" integrity sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A== -"@growthbook/growthbook@^0.30.0": - version "0.30.0" - resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-0.30.0.tgz#52532c2d5dfa7c017815027678bb241421c308b1" - integrity sha512-ennMHKZIhAZokHHcnA9/tOTLFdBB4DQR0WHT8Lf1pD80jWVv8JBCAzsV1jzSjYUQhb8R8pLR2Kho0IfgjzIZGQ== +"@growthbook/growthbook@^0.34.0": + version "0.34.0" + resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-0.34.0.tgz#df5275fafbbe055c7cfeea2953a4218a9ec9027f" + integrity sha512-3K5JL8QftX7y77EpieYg9anXVYb6+ZafTsrNCWp0HOdmtf4lxHOzCUYwuYDaS3bvmaeIUWJ5hYcOTc6WhNbfJw== dependencies: dom-mutator "^0.6.0"