Files
claude-code/src/services/analytics/growthbook.ts
unraid c7e1c50b86 feat: 添加服务层增强与零散改进
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 22:38:10 +08:00

1263 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { GrowthBook } from '@growthbook/growthbook'
import { isEqual, memoize } from 'lodash-es'
import {
getIsNonInteractiveSession,
getSessionTrustAccepted,
} from '../../bootstrap/state.js'
import { getGrowthBookClientKey } from '../../constants/keys.js'
import {
checkHasTrustDialogAccepted,
getGlobalConfig,
saveGlobalConfig,
} from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { toError } from '../../utils/errors.js'
import { getAuthHeaders } from '../../utils/http.js'
import { logError } from '../../utils/log.js'
import { createSignal } from '../../utils/signal.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import {
type GitHubActionsMetadata,
getUserForGrowthBook,
} from '../../utils/user.js'
import {
is1PEventLoggingEnabled,
logGrowthBookExperimentTo1P,
} from './firstPartyEventLogger.js'
/**
* User attributes sent to GrowthBook for targeting.
* Uses UUID suffix (not Uuid) to align with GrowthBook conventions.
*/
export type GrowthBookUserAttributes = {
id: string
sessionId: string
deviceID: string
platform: 'win32' | 'darwin' | 'linux'
apiBaseUrlHost?: string
organizationUUID?: string
accountUUID?: string
userType?: string
subscriptionType?: string
rateLimitTier?: string
firstTokenTime?: number
email?: string
appVersion?: string
github?: GitHubActionsMetadata
}
/**
* Malformed feature response from API that uses "value" instead of "defaultValue".
* This is a workaround until the API is fixed.
*/
type MalformedFeatureDefinition = {
value?: unknown
defaultValue?: unknown
[key: string]: unknown
}
let client: GrowthBook | null = null
// Named handler refs so resetGrowthBook can remove them to prevent accumulation
let currentBeforeExitHandler: (() => void) | null = null
let currentExitHandler: (() => void) | null = null
// Track whether auth was available when the client was created
// This allows us to detect when we need to recreate with fresh auth headers
let clientCreatedWithAuth = false
// Store experiment data from payload for logging exposures later
type StoredExperimentData = {
experimentId: string
variationId: number
inExperiment?: boolean
hashAttribute?: string
hashValue?: string
}
const experimentDataByFeature = new Map<string, StoredExperimentData>()
// Cache for remote eval feature values - workaround for SDK not respecting remoteEval response
// The SDK's setForcedFeatures also doesn't work reliably with remoteEval
const remoteEvalFeatureValues = new Map<string, unknown>()
// Track features accessed before init that need exposure logging
const pendingExposures = new Set<string>()
// Track features that have already had their exposure logged this session (dedup)
// This prevents firing duplicate exposure events when getFeatureValue_CACHED_MAY_BE_STALE
// is called repeatedly in hot paths (e.g., isAutoMemoryEnabled in render loops)
const loggedExposures = new Set<string>()
// Track re-initialization promise for security gate checks
// When GrowthBook is re-initializing (e.g., after auth change), security gate checks
// should wait for init to complete to avoid returning stale values
let reinitializingPromise: Promise<unknown> | null = null
// Listeners notified when GrowthBook feature values refresh (initial init or
// periodic refresh). Use for systems that bake feature values into long-lived
// objects at construction time (e.g. firstPartyEventLogger reads
// tengu_1p_event_batch_config once and builds a LoggerProvider with it) and
// need to rebuild when config changes. Per-call readers like
// getEventSamplingConfig / isSinkKilled don't need this — they're already
// reactive.
//
// NOT cleared by resetGrowthBook — subscribers register once (typically in
// init.ts) and must survive auth-change resets.
type GrowthBookRefreshListener = () => void | Promise<void>
const refreshed = createSignal()
/** Call a listener with sync-throw and async-rejection both routed to logError. */
function callSafe(listener: GrowthBookRefreshListener): void {
try {
// Promise.resolve() normalizes sync returns and Promises so both
// sync throws (caught by outer try) and async rejections (caught
// by .catch) hit logError. Without the .catch, an async listener
// that rejects becomes an unhandled rejection — the try/catch
// only sees the Promise, not its eventual rejection.
void Promise.resolve(listener()).catch(e => {
logError(e)
})
} catch (e) {
logError(e)
}
}
/**
* Register a callback to fire when GrowthBook feature values refresh.
* Returns an unsubscribe function.
*
* If init has already completed with features by the time this is called
* (remoteEvalFeatureValues is populated), the listener fires once on the
* next microtask. This catch-up handles the race where GB's network response
* lands before the REPL's useEffect commits — on external builds with fast
* networks and MCP-heavy configs, init can finish in ~100ms while REPL mount
* takes ~600ms (see #20951 external-build trace at 30.540 vs 31.046).
*
* Change detection is on the subscriber: the callback fires on every refresh;
* use isEqual against your last-seen config to decide whether to act.
*/
export function onGrowthBookRefresh(
listener: GrowthBookRefreshListener,
): () => void {
let subscribed = true
const unsubscribe = refreshed.subscribe(() => callSafe(listener))
if (remoteEvalFeatureValues.size > 0) {
queueMicrotask(() => {
// Re-check: listener may have been removed, or resetGrowthBook may have
// cleared the Map, between registration and this microtask running.
if (subscribed && remoteEvalFeatureValues.size > 0) {
callSafe(listener)
}
})
}
return () => {
subscribed = false
unsubscribe()
}
}
/**
* Parse env var overrides for GrowthBook features.
* Set CLAUDE_INTERNAL_FC_OVERRIDES to a JSON object mapping feature keys to values
* to bypass remote eval and disk cache. Useful for eval harnesses that need to
* test specific feature flag configurations. Only active when USER_TYPE is 'ant'.
*
* Example: CLAUDE_INTERNAL_FC_OVERRIDES='{"my_feature": true, "my_config": {"key": "val"}}'
*/
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).
* When true, _CACHED_MAY_BE_STALE will return the override without touching
* disk or network — callers can skip awaiting init for that feature.
*/
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). Checked after
* env-var overrides — env wins so eval harnesses remain deterministic. Unlike
* getEnvOverrides this is not memoized: the user can change overrides at
* runtime, and getGlobalConfig() is already memory-cached (pointer-chase)
* until the next saveGlobalConfig() invalidates it.
*/
function getConfigOverrides(): Record<string, unknown> | undefined {
if (process.env.USER_TYPE !== 'ant') return undefined
try {
return getGlobalConfig().growthBookOverrides
} catch {
// getGlobalConfig() throws before configReadingAllowed is set (early
// main.tsx startup path). Same degrade as the disk-cache fallback below.
return undefined
}
}
/**
* Enumerate all known GrowthBook features and their current resolved values
* (not including overrides). In-memory payload first, disk cache fallback —
* same priority as the getters. Used by the /config Gates tab.
*/
export function getAllGrowthBookFeatures(): Record<string, unknown> {
if (remoteEvalFeatureValues.size > 0) {
return Object.fromEntries(remoteEvalFeatureValues)
}
return getGlobalConfig().cachedGrowthBookFeatures ?? {}
}
export function getGrowthBookConfigOverrides(): Record<string, unknown> {
return getConfigOverrides() ?? {}
}
/**
* Set or clear a single config override. Pass undefined to clear.
* Fires onGrowthBookRefresh listeners so systems that bake gate values into
* long-lived objects (useMainLoopModel, useSkillsChange, etc.) rebuild —
* otherwise overriding e.g. tengu_ant_model_override wouldn't actually
* change the model until the next periodic refresh.
*/
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 } }
})
// Subscribers do their own change detection (see onGrowthBookRefresh docs),
// so firing on a no-op write is fine.
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)
}
}
/**
* Log experiment exposure for a feature if it has experiment data.
* Deduplicates within a session - each feature is logged at most once.
*/
function logExposureForFeature(feature: string): void {
// Skip if already logged this session (dedup)
if (loggedExposures.has(feature)) {
return
}
const expData = experimentDataByFeature.get(feature)
if (expData) {
loggedExposures.add(feature)
logGrowthBookExperimentTo1P({
experimentId: expData.experimentId,
variationId: expData.variationId,
userAttributes: getUserAttributes(),
experimentMetadata: {
feature_id: feature,
},
})
}
}
/**
* Process a remote eval payload from the GrowthBook server and populate
* local caches. Called after both initial client.init() and after
* client.refreshFeatures() so that _BLOCKS_ON_INIT callers see fresh values
* across the process lifetime, not just init-time snapshots.
*
* Without this running on refresh, remoteEvalFeatureValues freezes at its
* init-time snapshot and getDynamicConfig_BLOCKS_ON_INIT returns stale values
* for the entire process lifetime — which broke the tengu_max_version_config
* kill switch for long-running sessions.
*/
async function processRemoteEvalPayload(
gbClient: GrowthBook,
): Promise<boolean> {
// WORKAROUND: Transform remote eval response format
// The API returns { "value": ... } but SDK expects { "defaultValue": ... }
// TODO: Remove this once the API is fixed to return correct format
const payload = gbClient.getPayload()
// Empty object is truthy — without the length check, `{features: {}}`
// (transient server bug, truncated response) would pass, clear the maps
// below, return true, and syncRemoteEvalToDisk would wholesale-write `{}`
// to disk: total flag blackout for every process sharing ~/.claude.json.
if (!payload?.features || Object.keys(payload.features).length === 0) {
return false
}
// Clear before rebuild so features removed between refreshes don't
// leave stale ghost entries that short-circuit getFeatureValueInternal.
experimentDataByFeature.clear()
const transformedFeatures: Record<string, MalformedFeatureDefinition> = {}
for (const [key, feature] of Object.entries(payload.features)) {
const f = feature as MalformedFeatureDefinition
if ('value' in f && !('defaultValue' in f)) {
transformedFeatures[key] = {
...f,
defaultValue: f.value,
}
} else {
transformedFeatures[key] = f
}
// Store experiment data for later logging when feature is accessed
if (f.source === 'experiment' && f.experimentResult) {
const expResult = f.experimentResult as {
variationId?: number
}
const exp = f.experiment as { key?: string } | undefined
if (exp?.key && expResult.variationId !== undefined) {
experimentDataByFeature.set(key, {
experimentId: exp.key,
variationId: expResult.variationId,
})
}
}
}
// Re-set the payload with transformed features
await gbClient.setPayload({
...payload,
features: transformedFeatures,
})
// WORKAROUND: Cache the evaluated values directly from remote eval response.
// The SDK's evalFeature() tries to re-evaluate rules locally, ignoring the
// pre-evaluated 'value' from remoteEval. setForcedFeatures also doesn't work
// reliably. So we cache values ourselves and use them in getFeatureValueInternal.
remoteEvalFeatureValues.clear()
for (const [key, feature] of Object.entries(transformedFeatures)) {
// Under remoteEval:true the server pre-evaluates. Whether the answer
// lands in `value` (current API) or `defaultValue` (post-TODO API shape),
// it's the authoritative value for this user. Guarding on both keeps
// syncRemoteEvalToDisk correct across a partial or full API migration.
const v = 'value' in feature ? feature.value : feature.defaultValue
if (v !== undefined) {
remoteEvalFeatureValues.set(key, v)
}
}
return true
}
/**
* Write the complete remoteEvalFeatureValues map to disk. Called exactly
* once per successful processRemoteEvalPayload — never from a failure path,
* so init-timeout poisoning is structurally impossible (the .catch() at init
* never reaches here).
*
* Wholesale replace (not merge): features deleted server-side are dropped
* from disk on the next successful payload. Ant builds ⊇ external, so
* switching builds is safe — the write is always a complete answer for this
* process's SDK key.
*/
function syncRemoteEvalToDisk(): void {
const fresh = Object.fromEntries(remoteEvalFeatureValues)
const config = getGlobalConfig()
if (isEqual(config.cachedGrowthBookFeatures, fresh)) {
return
}
saveGlobalConfig(current => ({
...current,
cachedGrowthBookFeatures: fresh,
}))
}
/**
* Local default overrides for GrowthBook feature gates.
*
* When GrowthBook is not connected (e.g. no 1P event logging, no adapter),
* these values are used instead of the hard-coded defaults (usually false).
* This allows enabling features that have real implementations without
* requiring a GrowthBook server connection.
*
* Set CLAUDE_CODE_DISABLE_LOCAL_GATES=1 to bypass these defaults.
*
* Categories:
* P0 — Pure local features (no external dependencies)
* P1 — Requires Claude API (works with any valid API key)
* KS — Kill switches (default true, keep them true)
*/
const LOCAL_GATE_DEFAULTS: Record<string, unknown> = {
// ── P0: Pure local features ──────────────────────────────────────
tengu_keybinding_customization_release: true, // Custom keybindings
tengu_streaming_tool_execution2: true, // Streaming tool execution
tengu_kairos_cron: true, // Cron/scheduled tasks
tengu_amber_json_tools: true, // Token-efficient JSON tools (~4.5% savings)
tengu_immediate_model_command: true, // Instant /model, /fast, /effort during query
tengu_basalt_3kr: true, // MCP instructions delta (send only changes)
tengu_pebble_leaf_prune: true, // Session storage leaf pruning
tengu_chair_sermon: true, // Message smooshing (merge adjacent blocks)
tengu_lodestone_enabled: true, // Deep link protocol (claude://)
tengu_auto_background_agents: true, // Auto-background agents after 120s
tengu_fgts: true, // Fine-grained tool state in system prompt
// ── P1: API-dependent features ───────────────────────────────────
tengu_session_memory: true, // Session memory (cross-session persistence)
tengu_passport_quail: true, // Auto memory extraction
tengu_moth_copse: true, // Skip memory index, use prefetched memories
tengu_coral_fern: true, // "Searching past context" section
tengu_chomp_inflection: true, // Prompt suggestions
tengu_hive_evidence: true, // Verification agent
tengu_kairos_brief: true, // Brief mode
tengu_kairos_brief_config: { enable_slash_command: true }, // Brief /slash command visibility
tengu_sedge_lantern: true, // Away summary
tengu_onyx_plover: { enabled: true }, // Auto dream (memory consolidation)
tengu_willow_mode: 'dialog', // Idle return prompt
// ── Kill switches (keep true to prevent remote disable) ──────────
tengu_turtle_carbon: true, // Ultrathink extended thinking
tengu_amber_stoat: true, // Built-in Explore/Plan agents
tengu_amber_flint: true, // Agent teams/swarms
tengu_slim_subagent_claudemd: true, // Slim CLAUDE.md for subagents
tengu_birch_trellis: true, // Tree-sitter bash security analysis
tengu_collage_kaleidoscope: true, // macOS clipboard image reading
tengu_compact_cache_prefix: true, // Reuse prompt cache during compaction
tengu_kairos_assistant: true, // KAIROS assistant mode activation
tengu_kairos_cron_durable: true, // Persistent cron tasks
tengu_attribution_header: true, // API request attribution header
tengu_slate_prism: true, // Agent progress summaries
// ── Ultrareview (cloud code review via CCR) ─────────────────────
tengu_review_bughunter_config: { enabled: true }, // /ultrareview command visibility
tengu_ccr_bundle_seed_enabled: true, // Bundle seed: skip GitHub App check for branch mode
}
/**
* Look up a local gate default. Returns undefined if not configured,
* allowing the caller to fall through to the original defaultValue.
*/
function getLocalGateDefault(feature: string): unknown | undefined {
if (process.env.CLAUDE_CODE_DISABLE_LOCAL_GATES) {
return undefined
}
return LOCAL_GATE_DEFAULTS[feature]
}
/**
* Check if GrowthBook operations should be enabled
*/
function isGrowthBookEnabled(): boolean {
// 适配器模式:有自定义服务器配置时直接启用
if (process.env.CLAUDE_GB_ADAPTER_URL && process.env.CLAUDE_GB_ADAPTER_KEY) {
return true
}
// GrowthBook depends on 1P event logging.
return is1PEventLoggingEnabled()
}
/**
* Hostname of ANTHROPIC_BASE_URL when it points at a non-Anthropic proxy.
*
* Enterprise-proxy deployments (Epic, Marble, etc.) typically use
* apiKeyHelper auth, which means isAnthropicAuthEnabled() returns false and
* organizationUUID/accountUUID/email are all absent from GrowthBook
* attributes. Without this, there's no stable attribute to target them on
* — only per-device IDs. See src/utils/auth.ts isAnthropicAuthEnabled().
*
* Returns undefined for unset/default (api.anthropic.com) so the attribute
* is absent for direct-API users. Hostname only — no path/query/creds.
*/
export function getApiBaseUrlHost(): string | undefined {
const baseUrl = process.env.ANTHROPIC_BASE_URL
if (!baseUrl) return undefined
try {
const host = new URL(baseUrl).host
if (host === 'api.anthropic.com') return undefined
return host
} catch {
return undefined
}
}
/**
* Get user attributes for GrowthBook from CoreUserData
*/
function getUserAttributes(): GrowthBookUserAttributes {
const user = getUserForGrowthBook()
// For ants, always try to include email from OAuth config even if ANTHROPIC_API_KEY is set.
// This ensures GrowthBook targeting by email works regardless of auth method.
let email = user.email
if (!email && process.env.USER_TYPE === 'ant') {
email = getGlobalConfig().oauthAccount?.emailAddress
}
const apiBaseUrlHost = getApiBaseUrlHost()
const attributes = {
id: user.deviceId,
sessionId: user.sessionId,
deviceID: user.deviceId,
platform: user.platform,
...(apiBaseUrlHost && { apiBaseUrlHost }),
...(user.organizationUuid && { organizationUUID: user.organizationUuid }),
...(user.accountUuid && { accountUUID: user.accountUuid }),
...(user.userType && { userType: user.userType }),
...(user.subscriptionType && { subscriptionType: user.subscriptionType }),
...(user.rateLimitTier && { rateLimitTier: user.rateLimitTier }),
...(user.firstTokenTime && { firstTokenTime: user.firstTokenTime }),
...(email && { email }),
...(user.appVersion && { appVersion: user.appVersion }),
...(user.githubActionsMetadata && {
githubActionsMetadata: user.githubActionsMetadata,
}),
}
return attributes
}
/**
* Get or create the GrowthBook client instance
*/
const getGrowthBookClient = memoize(
(): { client: GrowthBook; initialized: Promise<void> } | null => {
if (!isGrowthBookEnabled()) {
return null
}
const attributes = getUserAttributes()
const clientKey = getGrowthBookClientKey()
const baseUrl =
process.env.CLAUDE_GB_ADAPTER_URL ||
(process.env.USER_TYPE === 'ant'
? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/'
: 'https://api.anthropic.com/')
const isAdapterMode = !!(
process.env.CLAUDE_GB_ADAPTER_URL && process.env.CLAUDE_GB_ADAPTER_KEY
)
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`GrowthBook: Creating client with clientKey=${clientKey}, attributes: ${jsonStringify(attributes)}`,
)
}
// Skip auth if trust hasn't been established yet
// This prevents executing apiKeyHelper commands before the trust dialog
// Non-interactive sessions implicitly have workspace trust
// getSessionTrustAccepted() covers the case where the TrustDialog auto-resolved
// without persisting trust for the specific CWD (e.g., home directory) —
// showSetupScreens() sets this after the trust dialog flow completes.
const hasTrust =
checkHasTrustDialogAccepted() ||
getSessionTrustAccepted() ||
getIsNonInteractiveSession()
const authHeaders = hasTrust
? getAuthHeaders()
: { headers: {}, error: 'trust not established' }
// 适配器模式下不需要 authGrowthBook Cloud 用 clientKey 即可
const hasAuth = isAdapterMode || !authHeaders.error
clientCreatedWithAuth = hasAuth
// Capture in local variable so the init callback operates on THIS client,
// not a later client if reinitialization happens before init completes
const thisClient = new GrowthBook({
apiHost: baseUrl,
clientKey,
attributes,
// remoteEval only works with Anthropic internal API, GrowthBook Cloud doesn't support it
remoteEval: !isAdapterMode,
// cacheKeyAttributes only valid with remoteEval
...(!isAdapterMode
? { cacheKeyAttributes: ['id', 'organizationUUID'] }
: {}),
// Add auth headers if available
...(authHeaders.error
? {}
: { apiHostRequestHeaders: authHeaders.headers }),
// Debug logging for Ants
...(process.env.USER_TYPE === 'ant'
? {
log: (msg: string, ctx: Record<string, unknown>) => {
logForDebugging(`GrowthBook: ${msg} ${jsonStringify(ctx)}`)
},
}
: {}),
})
client = thisClient
if (!hasAuth) {
// No auth available yet — skip HTTP init, rely on disk-cached values.
// initializeGrowthBook() will reset and re-create with auth when available.
return { client: thisClient, initialized: Promise.resolve() }
}
const initialized = thisClient
.init({ timeout: 5000 })
.then(async result => {
// Guard: if this client was replaced by a newer one, skip processing
if (client !== thisClient) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
'GrowthBook: Skipping init callback for replaced client',
)
}
return
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`GrowthBook initialized, source: ${result.source}, success: ${result.success}`,
)
}
const hadFeatures = await processRemoteEvalPayload(thisClient)
// Re-check: processRemoteEvalPayload yields at `await setPayload`.
// Microtask-only today (no encryption, no sticky-bucket service), but
// the guard at the top of this callback runs before that await;
// this runs after.
if (client !== thisClient) return
if (hadFeatures) {
for (const feature of pendingExposures) {
logExposureForFeature(feature)
}
pendingExposures.clear()
syncRemoteEvalToDisk()
// Notify subscribers: remoteEvalFeatureValues is populated and
// disk is freshly synced. _CACHED_MAY_BE_STALE reads memory first
// (#22295), so subscribers see fresh values immediately.
refreshed.emit()
}
// Log what features were loaded
if (process.env.USER_TYPE === 'ant') {
const features = thisClient.getFeatures()
if (features) {
const featureKeys = Object.keys(features)
logForDebugging(
`GrowthBook loaded ${featureKeys.length} features: ${featureKeys.slice(0, 10).join(', ')}${featureKeys.length > 10 ? '...' : ''}`,
)
}
}
})
.catch(error => {
if (process.env.USER_TYPE === 'ant') {
logError(toError(error))
}
})
// Register cleanup handlers for graceful shutdown (named refs so resetGrowthBook can remove them)
currentBeforeExitHandler = () => client?.destroy()
currentExitHandler = () => client?.destroy()
process.on('beforeExit', currentBeforeExitHandler)
process.on('exit', currentExitHandler)
return { client: thisClient, initialized }
},
)
/**
* Initialize GrowthBook client (blocks until ready)
*/
export const initializeGrowthBook = memoize(
async (): Promise<GrowthBook | null> => {
let clientWrapper = getGrowthBookClient()
if (!clientWrapper) {
return null
}
// Check if auth has become available since the client was created
// If so, we need to recreate the client with fresh auth headers
// Only check if trust is established to avoid triggering apiKeyHelper before trust dialog
if (!clientCreatedWithAuth) {
const hasTrust =
checkHasTrustDialogAccepted() ||
getSessionTrustAccepted() ||
getIsNonInteractiveSession()
if (hasTrust) {
const currentAuth = getAuthHeaders()
if (!currentAuth.error) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
'GrowthBook: Auth became available after client creation, reinitializing',
)
}
// Use resetGrowthBook to properly destroy old client and stop periodic refresh
// This prevents double-init where old client's init promise continues running
resetGrowthBook()
clientWrapper = getGrowthBookClient()
if (!clientWrapper) {
return null
}
}
}
}
await clientWrapper.initialized
// Set up periodic refresh after successful initialization
// This is called here (not separately) so it's always re-established after any reinit
setupPeriodicGrowthBookRefresh()
return clientWrapper.client
},
)
/**
* Get a feature value with a default fallback - blocks until initialized.
* @internal Used by both deprecated and cached functions.
*/
async function getFeatureValueInternal<T>(
feature: string,
defaultValue: T,
logExposure: boolean,
): Promise<T> {
// Check env var overrides first (for eval harnesses)
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
}
if (!isGrowthBookEnabled()) {
const localDefault = getLocalGateDefault(feature)
return localDefault !== undefined ? (localDefault as T) : defaultValue
}
const growthBookClient = await initializeGrowthBook()
if (!growthBookClient) {
const localDefault = getLocalGateDefault(feature)
return localDefault !== undefined ? (localDefault as T) : defaultValue
}
// Use cached remote eval values if available (workaround for SDK bug)
let result: T
if (remoteEvalFeatureValues.has(feature)) {
result = remoteEvalFeatureValues.get(feature) as T
} else {
result = growthBookClient.getFeatureValue(feature, defaultValue) as T
}
// Log experiment exposure using stored experiment data
if (logExposure) {
logExposureForFeature(feature)
}
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
`GrowthBook: getFeatureValue("${feature}") = ${jsonStringify(result)}`,
)
}
return result
}
/**
* @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE instead, which is non-blocking.
* This function blocks on GrowthBook initialization which can slow down startup.
*/
export async function getFeatureValue_DEPRECATED<T>(
feature: string,
defaultValue: T,
): Promise<T> {
return getFeatureValueInternal(feature, defaultValue, true)
}
/**
* Get a feature value from disk cache immediately. Pure read — disk is
* populated by syncRemoteEvalToDisk on every successful payload (init +
* periodic refresh), not by this function.
*
* This is the preferred method for startup-critical paths and sync contexts.
* The value may be stale if the cache was written by a previous process.
*/
export function getFeatureValue_CACHED_MAY_BE_STALE<T>(
feature: string,
defaultValue: T,
): T {
// Check env var overrides first (for eval harnesses)
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
}
if (!isGrowthBookEnabled()) {
const localDefault = getLocalGateDefault(feature)
return localDefault !== undefined ? (localDefault as T) : defaultValue
}
// LOCAL_GATE_DEFAULTS take priority over remote values and disk cache.
// In fork/self-hosted deployments, the GrowthBook server may push false
// for gates we intentionally enable. Local defaults represent the
// project's intentional configuration and override everything except
// env/config overrides (which are explicit user intent).
const localDefault = getLocalGateDefault(feature)
if (localDefault !== undefined) {
return localDefault as T
}
// Log experiment exposure if data is available, otherwise defer until after init
if (experimentDataByFeature.has(feature)) {
logExposureForFeature(feature)
} else {
pendingExposures.add(feature)
}
// In-memory payload is authoritative once processRemoteEvalPayload has run.
if (remoteEvalFeatureValues.has(feature)) {
return remoteEvalFeatureValues.get(feature) as T
}
// Fall back to disk cache (survives across process restarts)
try {
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature]
if (cached !== undefined) {
return cached as T
}
} catch {
// Config not yet initialized — fall through to defaultValue
}
return defaultValue
}
/**
* @deprecated Disk cache is now synced on every successful payload load
* (init + 20min/6h periodic refresh). The per-feature TTL never fetched
* fresh data from the server — it only re-wrote in-memory state to disk,
* which is now redundant. Use getFeatureValue_CACHED_MAY_BE_STALE directly.
*/
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 GrowthBook, with fallback to Statsig cache.
*
* **MIGRATION ONLY**: This function is for migrating existing Statsig gates to GrowthBook.
* For new features, use `getFeatureValue_CACHED_MAY_BE_STALE()` instead.
*
* - Checks GrowthBook disk cache first
* - Falls back to Statsig's cachedStatsigGates during migration
* - The value may be stale if the cache hasn't been updated recently
*
* @deprecated Use getFeatureValue_CACHED_MAY_BE_STALE() for new code. This function
* exists only to support migration of existing Statsig gates.
*/
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
gate: string,
): boolean {
// Check env var overrides first (for eval harnesses)
const overrides = getEnvOverrides()
if (overrides && gate in overrides) {
return Boolean(overrides[gate])
}
const configOverrides = getConfigOverrides()
if (configOverrides && gate in configOverrides) {
return Boolean(configOverrides[gate])
}
if (!isGrowthBookEnabled()) {
const localDefault = getLocalGateDefault(gate)
return localDefault !== undefined ? Boolean(localDefault) : false
}
// Log experiment exposure if data is available, otherwise defer until after init
if (experimentDataByFeature.has(gate)) {
logExposureForFeature(gate)
} else {
pendingExposures.add(gate)
}
// Return cached value immediately from disk
// First check GrowthBook cache, then fall back to Statsig cache for migration
try {
const config = getGlobalConfig()
const gbCached = config.cachedGrowthBookFeatures?.[gate]
if (gbCached !== undefined) {
return Boolean(gbCached)
}
// Fallback to Statsig cache for migration period
const statsigCached = config.cachedStatsigGates?.[gate]
if (statsigCached !== undefined) {
return statsigCached
}
} catch {
// Config not yet initialized — fall through to local gate defaults
}
// Neither cache has a value (or config not initialized) — use local gate defaults
const localDefault = getLocalGateDefault(gate)
return localDefault !== undefined ? Boolean(localDefault) : false
}
/**
* Check a security restriction gate, waiting for re-init if in progress.
*
* Use this for security-critical gates where we need fresh values after auth changes.
*
* Behavior:
* - If GrowthBook is re-initializing (e.g., after login), waits for it to complete
* - Otherwise, returns cached value immediately (Statsig cache first, then GrowthBook)
*
* Statsig cache is checked first as a safety measure for security-related checks:
* if the Statsig cache indicates the gate is enabled, we honor it.
*/
export async function checkSecurityRestrictionGate(
gate: string,
): Promise<boolean> {
// Check env var overrides first (for eval harnesses)
const overrides = getEnvOverrides()
if (overrides && gate in overrides) {
return Boolean(overrides[gate])
}
const configOverrides = getConfigOverrides()
if (configOverrides && gate in configOverrides) {
return Boolean(configOverrides[gate])
}
if (!isGrowthBookEnabled()) {
return false
}
// If re-initialization is in progress, wait for it to complete
// This ensures we get fresh values after auth changes
if (reinitializingPromise) {
await reinitializingPromise
}
// Check Statsig cache first - it may have correct value from previous logged-in session
const config = getGlobalConfig()
const statsigCached = config.cachedStatsigGates?.[gate]
if (statsigCached !== undefined) {
return Boolean(statsigCached)
}
// Then check GrowthBook cache
const gbCached = config.cachedGrowthBookFeatures?.[gate]
if (gbCached !== undefined) {
return Boolean(gbCached)
}
// No cache - return false (don't block on init for uncached gates)
return false
}
/**
* Check a boolean entitlement gate with fallback-to-blocking semantics.
*
* Fast path: if the disk cache already says `true`, return it immediately.
* Slow path: if disk says `false`/missing, await GrowthBook init and fetch the
* fresh server value (max ~5s). Disk is populated by syncRemoteEvalToDisk
* inside init, so by the time the slow path returns, disk already has the
* fresh value — no write needed here.
*
* Use for user-invoked features (e.g. /remote-control) that are gated on
* subscription/org, where a stale `false` would unfairly block access but a
* stale `true` is acceptable (the server is the real gatekeeper).
*/
export async function checkGate_CACHED_OR_BLOCKING(
gate: string,
): Promise<boolean> {
// Check env var overrides first (for eval harnesses)
const overrides = getEnvOverrides()
if (overrides && gate in overrides) {
return Boolean(overrides[gate])
}
const configOverrides = getConfigOverrides()
if (configOverrides && gate in configOverrides) {
return Boolean(configOverrides[gate])
}
if (!isGrowthBookEnabled()) {
const localDefault = getLocalGateDefault(gate)
return localDefault !== undefined ? Boolean(localDefault) : false
}
// Fast path: disk cache already says true — trust it
const cached = getGlobalConfig().cachedGrowthBookFeatures?.[gate]
if (cached === true) {
// Log experiment exposure if data is available, otherwise defer
if (experimentDataByFeature.has(gate)) {
logExposureForFeature(gate)
} else {
pendingExposures.add(gate)
}
return true
}
// Slow path: disk says false/missing — may be stale, fetch fresh
return getFeatureValueInternal(gate, false, true)
}
/**
* Refresh GrowthBook after auth changes (login/logout).
*
* NOTE: This must destroy and recreate the client because GrowthBook's
* apiHostRequestHeaders cannot be updated after client creation.
*/
export function refreshGrowthBookAfterAuthChange(): void {
if (!isGrowthBookEnabled()) {
return
}
try {
// Reset the client completely to get fresh auth headers
// This is necessary because apiHostRequestHeaders can't be updated after creation
resetGrowthBook()
// resetGrowthBook cleared remoteEvalFeatureValues. If re-init below
// times out (hadFeatures=false) or short-circuits on !hasAuth (logout),
// the init-callback notify never fires — subscribers stay synced to the
// previous account's memoized state. Notify here so they re-read now
// (falls to disk cache). If re-init succeeds, they'll notify again with
// fresh values; if not, at least they're synced to the post-reset state.
refreshed.emit()
// Reinitialize with fresh auth headers and attributes
// Track this promise so security gate checks can wait for it.
// .catch before .finally: initializeGrowthBook can reject if its sync
// helpers throw (getGrowthBookClient, getAuthHeaders, resetGrowthBook —
// clientWrapper.initialized itself has its own .catch so never rejects),
// and .finally re-settles with the original rejection — the sync
// try/catch below cannot catch async rejections.
reinitializingPromise = initializeGrowthBook()
.catch(error => {
logError(toError(error))
return null
})
.finally(() => {
reinitializingPromise = null
})
} catch (error) {
if (process.env.NODE_ENV === 'development') {
throw error
}
logError(toError(error))
}
}
/**
* Reset GrowthBook client state (primarily for testing)
*/
export function resetGrowthBook(): void {
stopPeriodicGrowthBookRefresh()
// Remove process handlers before destroying client to prevent accumulation
if (currentBeforeExitHandler) {
process.off('beforeExit', currentBeforeExitHandler)
currentBeforeExitHandler = null
}
if (currentExitHandler) {
process.off('exit', currentExitHandler)
currentExitHandler = null
}
client?.destroy()
client = null
clientCreatedWithAuth = false
reinitializingPromise = null
experimentDataByFeature.clear()
pendingExposures.clear()
loggedExposures.clear()
remoteEvalFeatureValues.clear()
getGrowthBookClient.cache?.clear?.()
initializeGrowthBook.cache?.clear?.()
envOverrides = null
envOverridesParsed = false
}
// Periodic refresh interval (matches Statsig's 6-hour interval)
const GROWTHBOOK_REFRESH_INTERVAL_MS =
process.env.USER_TYPE !== 'ant'
? 6 * 60 * 60 * 1000 // 6 hours
: 20 * 60 * 1000 // 20 min (for ants)
let refreshInterval: ReturnType<typeof setInterval> | null = null
let beforeExitListener: (() => void) | null = null
/**
* Light refresh - re-fetch features from server without recreating client.
* Use this for periodic refresh when auth headers haven't changed.
*
* Unlike refreshGrowthBookAfterAuthChange() which destroys and recreates the client,
* this preserves client state and just fetches fresh feature values.
*/
export async function refreshGrowthBookFeatures(): Promise<void> {
if (!isGrowthBookEnabled()) {
return
}
try {
const growthBookClient = await initializeGrowthBook()
if (!growthBookClient) {
return
}
await growthBookClient.refreshFeatures()
// Guard: if this client was replaced during the in-flight refresh
// (e.g. refreshGrowthBookAfterAuthChange ran), skip processing the
// stale payload. Mirrors the init-callback guard above.
if (growthBookClient !== client) {
if (process.env.USER_TYPE === 'ant') {
logForDebugging(
'GrowthBook: Skipping refresh processing for replaced client',
)
}
return
}
// Rebuild remoteEvalFeatureValues from the refreshed payload so that
// _BLOCKS_ON_INIT callers (e.g. getMaxVersion for the auto-update kill
// switch) see fresh values, not the stale init-time snapshot.
const hadFeatures = await processRemoteEvalPayload(growthBookClient)
// Same re-check as init path: covers the setPayload yield inside
// processRemoteEvalPayload (the guard above only covers refreshFeatures).
if (growthBookClient !== client) return
if (process.env.USER_TYPE === 'ant') {
logForDebugging('GrowthBook: Light refresh completed')
}
// Gate on hadFeatures: if the payload was empty/malformed,
// remoteEvalFeatureValues wasn't rebuilt — skip both the no-op disk
// write and the spurious subscriber churn (clearCommandMemoizationCaches
// + getCommands + 4× model re-renders).
if (hadFeatures) {
syncRemoteEvalToDisk()
refreshed.emit()
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
throw error
}
logError(toError(error))
}
}
/**
* Set up periodic refresh of GrowthBook features.
* Uses light refresh (refreshGrowthBookFeatures) to re-fetch without recreating client.
*
* Call this after initialization for long-running sessions to ensure
* feature values stay fresh. Matches Statsig's 6-hour refresh interval.
*/
export function setupPeriodicGrowthBookRefresh(): void {
if (!isGrowthBookEnabled()) {
return
}
// Clear any existing interval to avoid duplicates
if (refreshInterval) {
clearInterval(refreshInterval)
}
refreshInterval = setInterval(() => {
void refreshGrowthBookFeatures()
}, GROWTHBOOK_REFRESH_INTERVAL_MS)
// Allow process to exit naturally - this timer shouldn't keep the process alive
refreshInterval.unref?.()
// Register cleanup listener only once
if (!beforeExitListener) {
beforeExitListener = () => {
stopPeriodicGrowthBookRefresh()
}
process.once('beforeExit', beforeExitListener)
}
}
/**
* Stop periodic refresh (for testing or cleanup)
*/
export function stopPeriodicGrowthBookRefresh(): void {
if (refreshInterval) {
clearInterval(refreshInterval)
refreshInterval = null
}
if (beforeExitListener) {
process.removeListener('beforeExit', beforeExitListener)
beforeExitListener = null
}
}
// ============================================================================
// 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 - blocks until GrowthBook is initialized.
* Prefer getFeatureValue_CACHED_MAY_BE_STALE for startup-critical paths.
*/
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 disk cache immediately. Pure read — see
* getFeatureValue_CACHED_MAY_BE_STALE.
* This is the preferred method for startup-critical paths and sync contexts.
*
* In GrowthBook, dynamic configs are just features with object values.
*/
export function getDynamicConfig_CACHED_MAY_BE_STALE<T>(
configName: string,
defaultValue: T,
): T {
return getFeatureValue_CACHED_MAY_BE_STALE(configName, defaultValue)
}