Expose feature flags type in packages/type so it can be exposed to the frontend.

This commit is contained in:
Sam Rose 2025-01-06 12:11:14 +00:00
parent 7c9b0dbd21
commit a58c75e328
No known key found for this signature in database
12 changed files with 57 additions and 150 deletions

View File

@ -385,17 +385,17 @@ export function getCurrentContext(): ContextMap | undefined {
} }
} }
export function getFeatureFlags<T extends Record<string, any>>( export function getFeatureFlags(
key: string key: string
): T | undefined { ): Record<string, boolean> | undefined {
const context = getCurrentContext() const context = getCurrentContext()
if (!context) { if (!context) {
return undefined return undefined
} }
return context.featureFlagCache?.[key] as T return context.featureFlagCache?.[key]
} }
export function setFeatureFlags(key: string, value: Record<string, any>) { export function setFeatureFlags(key: string, value: Record<string, boolean>) {
const context = getCurrentContext() const context = getCurrentContext()
if (!context) { if (!context) {
return return

View File

@ -20,7 +20,7 @@ export type ContextMap = {
clients: Record<string, GoogleSpreadsheet> clients: Record<string, GoogleSpreadsheet>
} }
featureFlagCache?: { featureFlagCache?: {
[key: string]: Record<string, any> [key: string]: Record<string, boolean>
} }
viewToTableCache?: Record<string, Table> viewToTableCache?: Record<string, Table>
} }

View File

@ -2,9 +2,10 @@ import env from "../environment"
import * as crypto from "crypto" import * as crypto from "crypto"
import * as context from "../context" import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node" import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag } from "@budibase/types"
import tracer from "dd-trace" import tracer from "dd-trace"
import { Duration } from "../utils" import { Duration } from "../utils"
import { cloneDeep } from "lodash"
import { FeatureFlagDefaults } from "@budibase/types"
let posthog: PostHog | undefined let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) { export function init(opts?: PostHogOptions) {
@ -30,74 +31,6 @@ export function shutdown() {
posthog?.shutdown() posthog?.shutdown()
} }
export abstract class Flag<T> {
static boolean(defaultValue: boolean): Flag<boolean> {
return new BooleanFlag(defaultValue)
}
static string(defaultValue: string): Flag<string> {
return new StringFlag(defaultValue)
}
static number(defaultValue: number): Flag<number> {
return new NumberFlag(defaultValue)
}
protected constructor(public defaultValue: T) {}
abstract parse(value: any): T
}
type UnwrapFlag<F> = F extends Flag<infer U> ? U : never
export type FlagValues<T> = {
[K in keyof T]: UnwrapFlag<T[K]>
}
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends Flag<U> ? K : never
}[keyof T]
class BooleanFlag extends Flag<boolean> {
parse(value: any) {
if (typeof value === "string") {
return ["true", "t", "1"].includes(value.toLowerCase())
}
if (typeof value === "boolean") {
return value
}
throw new Error(`could not parse value "${value}" as boolean`)
}
}
class StringFlag extends Flag<string> {
parse(value: any) {
if (typeof value === "string") {
return value
}
throw new Error(`could not parse value "${value}" as string`)
}
}
class NumberFlag extends Flag<number> {
parse(value: any) {
if (typeof value === "number") {
return value
}
if (typeof value === "string") {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
return parsed
}
}
throw new Error(`could not parse value "${value}" as number`)
}
}
export interface EnvFlagEntry { export interface EnvFlagEntry {
tenantId: string tenantId: string
key: string key: string
@ -120,7 +53,7 @@ export function parseEnvFlags(flags: string): EnvFlagEntry[] {
return result return result
} }
export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> { export class FlagSet<T extends { [name: string]: boolean }> {
// This is used to safely cache flags sets in the current request context. // This is used to safely cache flags sets in the current request context.
// Because multiple sets could theoretically exist, we don't want the cache of // Because multiple sets could theoretically exist, we don't want the cache of
// one to leak into another. // one to leak into another.
@ -130,34 +63,25 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
this.setId = crypto.randomUUID() this.setId = crypto.randomUUID()
} }
defaults(): FlagValues<T> { defaults(): T {
return Object.keys(this.flagSchema).reduce((acc, key) => { return cloneDeep(this.flagSchema)
const typedKey = key as keyof T
acc[typedKey] = this.flagSchema[key].defaultValue
return acc
}, {} as FlagValues<T>)
} }
isFlagName(name: string | number | symbol): name is keyof T { isFlagName(name: string | number | symbol): name is keyof T {
return this.flagSchema[name as keyof T] !== undefined return this.flagSchema[name as keyof T] !== undefined
} }
async get<K extends keyof T>(key: K): Promise<FlagValues<T>[K]> { async isEnabled<K extends keyof T>(key: K): Promise<T[K]> {
const flags = await this.fetch() const flags = await this.fetch()
return flags[key] return flags[key]
} }
async isEnabled<K extends KeysOfType<T, boolean>>(key: K): Promise<boolean> { async fetch(): Promise<T> {
const flags = await this.fetch()
return flags[key]
}
async fetch(): Promise<FlagValues<T>> {
return await tracer.trace("features.fetch", async span => { return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId) const cachedFlags = context.getFeatureFlags(this.setId)
if (cachedFlags) { if (cachedFlags) {
span?.addTags({ fromCache: true }) span?.addTags({ fromCache: true })
return cachedFlags return cachedFlags as T
} }
const tags: Record<string, any> = {} const tags: Record<string, any> = {}
@ -189,7 +113,7 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// @ts-expect-error - TS does not like you writing into a generic type, // @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object. // but we know that it's okay in this case because it's just an object.
flagValues[key as keyof FlagValues] = value flagValues[key as keyof T] = value
tags[`flags.${key}.source`] = "environment" tags[`flags.${key}.source`] = "environment"
} }
@ -217,11 +141,11 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
tags[`readFromPostHog`] = true tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = { tenantId } const personProperties: Record<string, string> = { tenantId }
const posthogFlags = await posthog.getAllFlagsAndPayloads(userId, { const posthogFlags = await posthog.getAllFlags(userId, {
personProperties, personProperties,
}) })
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) { for (const [name, value] of Object.entries(posthogFlags)) {
if (!this.isFlagName(name)) { if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we // We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue. // just log it and continue.
@ -229,19 +153,20 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
continue continue
} }
if (typeof value !== "boolean") {
console.warn(`Invalid value for posthog flag "${name}": ${value}`)
continue
}
if (flagValues[name] === true || specificallySetFalse.has(name)) { if (flagValues[name] === true || specificallySetFalse.has(name)) {
// If the flag is already set to through environment variables, we // If the flag is already set to through environment variables, we
// don't want to override it back to false here. // don't want to override it back to false here.
continue continue
} }
const payload = posthogFlags.featureFlagPayloads?.[name]
const flag = this.flagSchema[name]
try { try {
// @ts-expect-error - TS does not like you writing into a generic // @ts-expect-error - TS does not like you writing into a generic type.
// type, but we know that it's okay in this case because it's just flagValues[name] = value
// an object.
flagValues[name] = flag.parse(payload || value)
tags[`flags.${name}.source`] = "posthog" tags[`flags.${name}.source`] = "posthog"
} catch (err) { } catch (err) {
// We don't want an invalid PostHog flag to break the app, so we just // We don't want an invalid PostHog flag to break the app, so we just
@ -262,18 +187,12 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
} }
} }
// This is the primary source of truth for feature flags. If you want to add a export const flags = new FlagSet(FeatureFlagDefaults)
// new flag, add it here and use the `fetch` and `get` functions to access it.
// All of the machinery in this file is to make sure that flags have their
// default values set correctly and their types flow through the system.
const flagsConfig: Record<FeatureFlag, Flag<any>> = {
[FeatureFlag.DEFAULT_VALUES]: Flag.boolean(true),
[FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(true),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(true),
[FeatureFlag.BUDIBASE_AI]: Flag.boolean(true),
[FeatureFlag.USE_ZOD_VALIDATOR]: Flag.boolean(env.isDev()),
}
export const flags = new FlagSet(flagsConfig)
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T export async function isEnabled(flag: keyof typeof FeatureFlagDefaults) {
export type FeatureFlags = UnwrapPromise<ReturnType<typeof flags.fetch>> return await flags.isEnabled(flag)
}
export async function all() {
return await flags.fetch()
}

View File

@ -1,5 +1,5 @@
import { IdentityContext, IdentityType } from "@budibase/types" import { IdentityContext, IdentityType } from "@budibase/types"
import { Flag, FlagSet, FlagValues, init, shutdown } from "../" import { FlagSet, init, shutdown } from "../"
import * as context from "../../context" import * as context from "../../context"
import environment, { withEnv } from "../../environment" import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch" import nodeFetch from "node-fetch"
@ -7,10 +7,8 @@ import nock from "nock"
import * as crypto from "crypto" import * as crypto from "crypto"
const schema = { const schema = {
TEST_BOOLEAN: Flag.boolean(false), TEST_BOOLEAN: false,
TEST_STRING: Flag.string("default value"), TEST_BOOLEAN_DEFAULT_TRUE: true,
TEST_NUMBER: Flag.number(0),
TEST_BOOLEAN_DEFAULT_TRUE: Flag.boolean(true),
} }
const flags = new FlagSet(schema) const flags = new FlagSet(schema)
@ -19,7 +17,7 @@ interface TestCase {
identity?: Partial<IdentityContext> identity?: Partial<IdentityContext>
environmentFlags?: string environmentFlags?: string
posthogFlags?: PostHogFlags posthogFlags?: PostHogFlags
expected?: Partial<FlagValues<typeof schema>> expected?: Partial<typeof schema>
errorMessage?: string | RegExp errorMessage?: string | RegExp
} }
@ -83,22 +81,6 @@ describe("feature flags", () => {
}, },
expected: { TEST_BOOLEAN: true }, expected: { TEST_BOOLEAN: true },
}, },
{
it: "should be able to read string flags from PostHog",
posthogFlags: {
featureFlags: { TEST_STRING: true },
featureFlagPayloads: { TEST_STRING: "test" },
},
expected: { TEST_STRING: "test" },
},
{
it: "should be able to read numeric flags from PostHog",
posthogFlags: {
featureFlags: { TEST_NUMBER: true },
featureFlagPayloads: { TEST_NUMBER: "123" },
},
expected: { TEST_NUMBER: 123 },
},
{ {
it: "should not be able to override a negative environment flag from PostHog", it: "should not be able to override a negative environment flag from PostHog",
environmentFlags: "default:!TEST_BOOLEAN", environmentFlags: "default:!TEST_BOOLEAN",
@ -177,7 +159,7 @@ describe("feature flags", () => {
expect(values).toMatchObject(expected) expect(values).toMatchObject(expected)
for (const [key, expectedValue] of Object.entries(expected)) { for (const [key, expectedValue] of Object.entries(expected)) {
const value = await flags.get(key as keyof typeof schema) const value = await flags.isEnabled(key as keyof typeof schema)
expect(value).toBe(expectedValue) expect(value).toBe(expectedValue)
} }
} else { } else {

@ -1 +1 @@
Subproject commit ae786121d923449b0ad5fcbd123d0a9fec28f65e Subproject commit 9091868986fbc6aae580280f48853482e0b06c6a

View File

@ -163,9 +163,9 @@ export async function finaliseRow(
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
const aiEnabled = const aiEnabled =
((await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) && ((await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled())) || (await pro.features.isBudibaseAIEnabled())) ||
((await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) && ((await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled())) (await pro.features.isAICustomConfigsEnabled()))
if (aiEnabled) { if (aiEnabled) {
row = await processAIColumns(table, row, { row = await processAIColumns(table, row, {

View File

@ -105,13 +105,13 @@ if (env.SELF_HOSTED) {
export async function getActionDefinitions(): Promise< export async function getActionDefinitions(): Promise<
Record<keyof typeof AutomationActionStepId, AutomationStepDefinition> Record<keyof typeof AutomationActionStepId, AutomationStepDefinition>
> { > {
if (await features.flags.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) { if (await features.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
} }
if ( if (
env.SELF_HOSTED || env.SELF_HOSTED ||
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) || (await features.isEnabled(FeatureFlag.BUDIBASE_AI)) ||
(await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) (await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS))
) { ) {
BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition
} }

View File

@ -100,10 +100,10 @@ export async function run({
try { try {
let response let response
const customConfigsEnabled = const customConfigsEnabled =
(await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) && (await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled()) (await pro.features.isAICustomConfigsEnabled())
const budibaseAIEnabled = const budibaseAIEnabled =
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) && (await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled()) (await pro.features.isBudibaseAIEnabled())
let llmWrapper let llmWrapper

View File

@ -7,7 +7,7 @@ import { fromZodError } from "zod-validation-error"
function validate(schema: AnyZodObject, property: "body" | "params") { function validate(schema: AnyZodObject, property: "body" | "params") {
// Return a Koa middleware function // Return a Koa middleware function
return async (ctx: Ctx, next: any) => { return async (ctx: Ctx, next: any) => {
if (!(await features.flags.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) { if (!(await features.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) {
return next() return next()
} }

View File

@ -1,3 +1,4 @@
import { FeatureFlags } from "packages/types/src/sdk"
import { DevInfo, User } from "../../../documents" import { DevInfo, User } from "../../../documents"
export interface GenerateAPIKeyRequest { export interface GenerateAPIKeyRequest {
@ -8,5 +9,5 @@ export interface GenerateAPIKeyResponse extends DevInfo {}
export interface FetchAPIKeyResponse extends DevInfo {} export interface FetchAPIKeyResponse extends DevInfo {}
export interface GetGlobalSelfResponse extends User { export interface GetGlobalSelfResponse extends User {
flags?: Record<string, string> flags?: FeatureFlags
} }

View File

@ -6,6 +6,12 @@ export enum FeatureFlag {
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR", USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
} }
export interface TenantFeatureFlags { export const FeatureFlagDefaults = {
[key: string]: FeatureFlag[] [FeatureFlag.DEFAULT_VALUES]: true,
[FeatureFlag.AUTOMATION_BRANCHING]: true,
[FeatureFlag.AI_CUSTOM_CONFIGS]: true,
[FeatureFlag.BUDIBASE_AI]: true,
[FeatureFlag.USE_ZOD_VALIDATOR]: false,
} }
export type FeatureFlags = typeof FeatureFlagDefaults

View File

@ -111,8 +111,7 @@ export async function getSelf(ctx: UserCtx<void, GetGlobalSelfResponse>) {
ctx.body = await groups.enrichUserRolesFromGroups(user) ctx.body = await groups.enrichUserRolesFromGroups(user)
// add the feature flags for this tenant // add the feature flags for this tenant
const flags = await features.flags.fetch() ctx.body.flags = await features.flags.fetch()
ctx.body.flags = flags
addSessionAttributesToUser(ctx) addSessionAttributesToUser(ctx)
} }