import env from "../environment" import * as context from "../context" import { PostHog, PostHogOptions } from "posthog-node" import { IdentityType } from "@budibase/types" import tracer from "dd-trace" let posthog: PostHog | undefined export function init(opts?: PostHogOptions) { if (env.POSTHOG_TOKEN) { posthog = new PostHog(env.POSTHOG_TOKEN, { host: env.POSTHOG_API_HOST, ...opts, }) } } export abstract class Flag { static boolean(defaultValue: boolean): Flag { return new BooleanFlag(defaultValue) } static string(defaultValue: string): Flag { return new StringFlag(defaultValue) } static number(defaultValue: number): Flag { return new NumberFlag(defaultValue) } protected constructor(public defaultValue: T) {} abstract parse(value: any): T } type UnwrapFlag = F extends Flag ? U : never export type FlagValues = { [K in keyof T]: UnwrapFlag } type KeysOfType = { [K in keyof T]: T[K] extends Flag ? K : never }[keyof T] class BooleanFlag extends Flag { 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 { parse(value: any) { if (typeof value === "string") { return value } throw new Error(`could not parse value "${value}" as string`) } } class NumberFlag extends Flag { 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 class FlagSet, T extends { [key: string]: V }> { private readonly flags: T constructor(flags: T) { this.flags = flags } defaults(): FlagValues { return Object.keys(this.flags).reduce((acc, key) => { const typedKey = key as keyof T acc[typedKey] = this.flags[key].defaultValue return acc }, {} as FlagValues) } isFlagName(name: string | number | symbol): name is keyof T { return this.flags[name as keyof T] !== undefined } async get(key: K): Promise[K]> { const flags = await this.fetch() return flags[key] } async isEnabled>(key: K): Promise { const flags = await this.fetch() return flags[key] } async fetch(): Promise> { return await tracer.trace("features.fetch", async span => { const tags: Record = {} const flags = this.defaults() const currentTenantId = context.getTenantId() const specificallySetFalse = new Set() const split = (env.TENANT_FEATURE_FLAGS || "") .split(",") .map(x => x.split(":")) for (const [tenantId, ...features] of split) { if (!tenantId || (tenantId !== "*" && tenantId !== currentTenantId)) { continue } for (let feature of features) { let value = true if (feature.startsWith("!")) { feature = feature.slice(1) value = false specificallySetFalse.add(feature) } if (!this.isFlagName(feature)) { throw new Error(`Feature: ${feature} is not an allowed option`) } if (typeof flags[feature] !== "boolean") { throw new Error(`Feature: ${feature} is not a boolean`) } // @ts-ignore flags[feature] = value tags[`flags.${feature}.source`] = "environment" } } const identity = context.getIdentity() if (posthog && identity?.type === IdentityType.USER) { const posthogFlags = await posthog.getAllFlagsAndPayloads(identity._id) for (const [name, value] of Object.entries(posthogFlags.featureFlags)) { const flag = this.flags[name] if (!flag) { // We don't want an unexpected PostHog flag to break the app, so we // just log it and continue. console.warn(`Unexpected posthog flag "${name}": ${value}`) continue } if (flags[name] === true || specificallySetFalse.has(name)) { // If the flag is already set to through environment variables, we // don't want to override it back to false here. continue } const payload = posthogFlags.featureFlagPayloads?.[name] try { // @ts-ignore flags[name] = flag.parse(payload || value) tags[`flags.${name}.source`] = "posthog" } catch (err) { // We don't want an invalid PostHog flag to break the app, so we just // log it and continue. console.warn(`Error parsing posthog flag "${name}": ${value}`, err) } } } for (const [key, value] of Object.entries(flags)) { tags[`flags.${key}.value`] = value } span?.addTags(tags) return flags }) } } // This is the primary source of truth for feature flags. If you want to add a // 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. export const flags = new FlagSet({ LICENSING: Flag.boolean(false), GOOGLE_SHEETS: Flag.boolean(false), USER_GROUPS: Flag.boolean(false), ONBOARDING_TOUR: Flag.boolean(false), })