Files
openclaude/src/services/analytics/growthbook.ts

383 lines
9.6 KiB
TypeScript

import { isEqual } from 'lodash-es'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { logError } from '../../utils/log.js'
import { createSignal } from '../../utils/signal.js'
type GrowthBookRefreshListener = () => void | Promise<void>
// Subscribers use this to react to local cache / override changes.
const refreshed = createSignal()
function callSafe(listener: GrowthBookRefreshListener): void {
try {
void Promise.resolve(listener()).catch(e => {
logError(e)
})
} catch (e) {
logError(e)
}
}
function hasAnyCachedGrowthBookFeatures(): boolean {
try {
return Object.keys(getGlobalConfig().cachedGrowthBookFeatures ?? {})
.length > 0
} catch {
return false
}
}
/**
* Register a callback to fire when GrowthBook values change.
* This is now backed by local cache / override changes only.
*/
export function onGrowthBookRefresh(
listener: GrowthBookRefreshListener,
): () => void {
let subscribed = true
const unsubscribe = refreshed.subscribe(() => callSafe(listener))
if (hasAnyCachedGrowthBookFeatures()) {
queueMicrotask(() => {
if (subscribed) {
callSafe(listener)
}
})
}
return () => {
subscribed = false
unsubscribe()
}
}
/**
* Parse env var overrides for GrowthBook features.
* These bypass disk cache and are used by eval harnesses / tests.
*/
let envOverrides: Record<string, unknown> | null = null
let envOverridesParsed = false
function getEnvOverrides(): Record<string, unknown> | null {
if (!envOverridesParsed) {
envOverridesParsed = true
if (process.env.USER_TYPE === 'ant') {
const raw = process.env.CLAUDE_INTERNAL_FC_OVERRIDES
if (raw) {
try {
envOverrides = JSON.parse(raw) as Record<string, unknown>
logForDebugging(
`GrowthBook: Using env var overrides for ${Object.keys(envOverrides).length} features: ${Object.keys(envOverrides).join(', ')}`,
)
} catch {
logError(
new Error(
`GrowthBook: Failed to parse CLAUDE_INTERNAL_FC_OVERRIDES: ${raw}`,
),
)
}
}
}
}
return envOverrides
}
/**
* Check if a feature has an env-var override (CLAUDE_INTERNAL_FC_OVERRIDES).
*/
export function hasGrowthBookEnvOverride(feature: string): boolean {
const overrides = getEnvOverrides()
return overrides !== null && feature in overrides
}
/**
* Local config overrides set via /config Gates tab (ant-only).
*/
function getConfigOverrides(): Record<string, unknown> | undefined {
if (process.env.USER_TYPE !== 'ant') return undefined
try {
return getGlobalConfig().growthBookOverrides
} catch {
return undefined
}
}
function getCachedGrowthBookFeature<T>(feature: string): T | undefined {
try {
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature]
return cached !== undefined ? (cached as T) : undefined
} catch {
return undefined
}
}
function getCachedStatsigGate(gate: string): boolean | undefined {
try {
const cached = getGlobalConfig().cachedStatsigGates?.[gate]
return cached !== undefined ? Boolean(cached) : undefined
} catch {
return undefined
}
}
/**
* Enumerate all known GrowthBook features from the local cache.
*/
export function getAllGrowthBookFeatures(): Record<string, unknown> {
try {
return getGlobalConfig().cachedGrowthBookFeatures ?? {}
} catch {
return {}
}
}
export function getGrowthBookConfigOverrides(): Record<string, unknown> {
return getConfigOverrides() ?? {}
}
/**
* Set or clear a single config override. Pass undefined to clear.
*/
export function setGrowthBookConfigOverride(
feature: string,
value: unknown,
): void {
if (process.env.USER_TYPE !== 'ant') return
try {
saveGlobalConfig(c => {
const current = c.growthBookOverrides ?? {}
if (value === undefined) {
if (!(feature in current)) return c
const { [feature]: _, ...rest } = current
if (Object.keys(rest).length === 0) {
const { growthBookOverrides: __, ...configWithout } = c
return configWithout
}
return { ...c, growthBookOverrides: rest }
}
if (isEqual(current[feature], value)) return c
return { ...c, growthBookOverrides: { ...current, [feature]: value } }
})
refreshed.emit()
} catch (e) {
logError(e)
}
}
export function clearGrowthBookConfigOverrides(): void {
if (process.env.USER_TYPE !== 'ant') return
try {
saveGlobalConfig(c => {
if (
!c.growthBookOverrides ||
Object.keys(c.growthBookOverrides).length === 0
) {
return c
}
const { growthBookOverrides: _, ...rest } = c
return rest
})
refreshed.emit()
} catch (e) {
logError(e)
}
}
async function getFeatureValueInternal<T>(
feature: string,
defaultValue: T,
): Promise<T> {
const overrides = getEnvOverrides()
if (overrides && feature in overrides) {
return overrides[feature] as T
}
const configOverrides = getConfigOverrides()
if (configOverrides && feature in configOverrides) {
return configOverrides[feature] as T
}
const cached = getCachedGrowthBookFeature<T>(feature)
if (cached !== undefined) {
return cached
}
return defaultValue
}
/**
* GrowthBook is local-cache-only in this build.
* These no-op lifecycle helpers keep call sites stable.
*/
export async function initializeGrowthBook(): Promise<null> {
return null
}
export function refreshGrowthBookAfterAuthChange(): void {
refreshed.emit()
}
export function resetGrowthBook(): void {
envOverrides = null
envOverridesParsed = false
refreshed.emit()
}
/**
* Get a feature value with a default fallback.
*/
export async function getFeatureValue_DEPRECATED<T>(
feature: string,
defaultValue: T,
): Promise<T> {
return getFeatureValueInternal(feature, defaultValue)
}
/**
* Get a feature value from local cache immediately.
*/
export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
feature: string,
defaultValue: T,
): T {
const envOverride = getEnvOverrides()
if (envOverride && feature in envOverride) {
return envOverride[feature] as T
}
const configOverride = getConfigOverrides()
if (configOverride && feature in configOverride) {
return configOverride[feature] as T
}
const cached = getCachedGrowthBookFeature<T>(feature)
return cached !== undefined ? cached : defaultValue
}
/**
* Keep the old API shape; this now resolves from local cache only.
*/
export function getFeatureValue_CACHED_WITH_REFRESH<T>(
feature: string,
defaultValue: T,
_refreshIntervalMs: number,
): T {
return getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue)
}
/**
* Check a Statsig feature gate value via local GrowthBook cache, with fallback
* to Statsig's cached gates.
*/
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
gate: string,
): boolean {
const overrides = getEnvOverrides()
if (overrides && gate in overrides) {
return Boolean(overrides[gate])
}
const configOverrides = getConfigOverrides()
if (configOverrides && gate in configOverrides) {
return Boolean(configOverrides[gate])
}
const cached = getCachedGrowthBookFeature<boolean>(gate)
if (cached !== undefined) {
return Boolean(cached)
}
const statsigCached = getCachedStatsigGate(gate)
if (statsigCached !== undefined) {
return Boolean(statsigCached)
}
return false
}
/**
* Check a security restriction gate using only local caches.
*/
export async function checkSecurityRestrictionGate(
gate: string,
): Promise<boolean> {
const overrides = getEnvOverrides()
if (overrides && gate in overrides) {
return Boolean(overrides[gate])
}
const configOverrides = getConfigOverrides()
if (configOverrides && gate in configOverrides) {
return Boolean(configOverrides[gate])
}
const cached = getCachedGrowthBookFeature<boolean>(gate)
if (cached !== undefined) {
return Boolean(cached)
}
const statsigCached = getCachedStatsigGate(gate)
if (statsigCached !== undefined) {
return Boolean(statsigCached)
}
return false
}
/**
* Check a boolean entitlement gate with fallback-to-blocking semantics.
* In this build the slow path no longer blocks on network.
*/
export async function checkGate_CACHED_OR_BLOCKING(
gate: string,
): Promise<boolean> {
const overrides = getEnvOverrides()
if (overrides && gate in overrides) {
return Boolean(overrides[gate])
}
const configOverrides = getConfigOverrides()
if (configOverrides && gate in configOverrides) {
return Boolean(configOverrides[gate])
}
const cached = getCachedGrowthBookFeature<boolean>(gate)
if (cached !== undefined) {
return Boolean(cached)
}
const statsigCached = getCachedStatsigGate(gate)
if (statsigCached !== undefined) {
return Boolean(statsigCached)
}
return false
}
// ============================================================================
// Dynamic Config Functions
// These are semantic wrappers around feature functions for Statsig API parity.
// In GrowthBook, dynamic configs are just features with object values.
// ============================================================================
/**
* Get a dynamic config value - cached/local-override only.
*/
export async function getDynamicConfig_BLOCKS_ON_INIT<T>(
configName: string,
defaultValue: T,
): Promise<T> {
return getFeatureValue_DEPRECATED(configName, defaultValue)
}
/**
* Get a dynamic config value from local cache immediately.
*/
export function getDynamicConfig_CACHED_MAY_BE_STALE<T>(
configName: string,
defaultValue: T,
): T {
return getFeatureValue_CACHED_MAY_BE_STALE(configName, defaultValue)
}