Coverage for agentos/core/feature_flags.py: 41%

174 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 08:01 +0800

1""" 

2AgentOS Feature Flags — Gradual Rollout & Experimentation Engine 

3━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 

4 

5Production-grade feature flag system with: 

6 - Percentage-based gradual rollout 

7 - User/tenant targeting (allowlist/blocklist) 

8 - Time-based scheduling (start/end dates) 

9 - Kill-switch for emergency disable 

10 - Audit trail for flag changes 

11 - Backend-agnostic storage (in-memory / Redis / DB) 

12 

13Architecture: 

14 FlagStore (abstract) 

15 ├─ InMemoryFlagStore 

16 ├─ RedisFlagStore (coming) 

17 └─ DatabaseFlagStore (coming) 

18 

19 FeatureFlagManager 

20 ├─ is_enabled(flag_name, context) → bool 

21 ├─ get_variant(flag_name, context) → str 

22 └─ set_flag(...) / delete_flag(...) 

23""" 

24 

25from __future__ import annotations 

26 

27import hashlib 

28import time 

29from abc import ABC, abstractmethod 

30from dataclasses import dataclass, field 

31from datetime import datetime, timezone 

32from enum import Enum 

33from typing import Any, Dict, List, Optional, Set 

34 

35 

36# --------------------------------------------------------------------------- 

37# Data Classes 

38# --------------------------------------------------------------------------- 

39 

40 

41class FlagType(str, Enum): 

42 """Type of feature flag.""" 

43 BOOLEAN = "boolean" # Simple on/off toggle 

44 PERCENTAGE = "percentage" # Gradual rollout (0-100%) 

45 VARIANT = "variant" # A/B test variants 

46 SCHEDULED = "scheduled" # Time-based enable/disable 

47 

48 

49@dataclass 

50class FlagRule: 

51 """Targeting rule for a feature flag.""" 

52 flag_type: FlagType = FlagType.BOOLEAN 

53 enabled: bool = False 

54 rollout_percentage: int = 0 # 0-100 for PERCENTAGE type 

55 variants: Dict[str, int] = field(default_factory=dict) # variant_name → weight 

56 allowlist_users: Set[str] = field(default_factory=set) 

57 allowlist_tenants: Set[str] = field(default_factory=set) 

58 blocklist_users: Set[str] = field(default_factory=set) 

59 blocklist_tenants: Set[str] = field(default_factory=set) 

60 start_time: Optional[datetime] = None 

61 end_time: Optional[datetime] = None 

62 metadata: Dict[str, Any] = field(default_factory=dict) 

63 

64 def to_dict(self) -> Dict[str, Any]: 

65 return { 

66 "flag_type": self.flag_type.value, 

67 "enabled": self.enabled, 

68 "rollout_percentage": self.rollout_percentage, 

69 "variants": self.variants, 

70 "allowlist_users": list(self.allowlist_users), 

71 "allowlist_tenants": list(self.allowlist_tenants), 

72 "blocklist_users": list(self.blocklist_users), 

73 "blocklist_tenants": list(self.blocklist_tenants), 

74 "start_time": self.start_time.isoformat() if self.start_time else None, 

75 "end_time": self.end_time.isoformat() if self.end_time else None, 

76 "metadata": self.metadata, 

77 } 

78 

79 

80@dataclass 

81class FlagContext: 

82 """Context for feature flag evaluation.""" 

83 user_id: Optional[str] = None 

84 tenant_id: Optional[str] = None 

85 attributes: Dict[str, Any] = field(default_factory=dict) 

86 request_id: Optional[str] = None 

87 

88 

89@dataclass 

90class FlagEvaluation: 

91 """Result of a feature flag evaluation.""" 

92 flag_name: str 

93 enabled: bool 

94 variant: Optional[str] = None 

95 reason: str = "" 

96 evaluated_at: float = field(default_factory=time.time) 

97 

98 

99# --------------------------------------------------------------------------- 

100# Flag Store Interface 

101# --------------------------------------------------------------------------- 

102 

103 

104class FlagStore(ABC): 

105 """Abstract storage backend for feature flags.""" 

106 

107 @abstractmethod 

108 async def get(self, flag_name: str) -> Optional[FlagRule]: 

109 """Get a flag rule by name.""" 

110 

111 @abstractmethod 

112 async def set(self, flag_name: str, rule: FlagRule) -> None: 

113 """Set or update a flag rule.""" 

114 

115 @abstractmethod 

116 async def delete(self, flag_name: str) -> bool: 

117 """Delete a flag. Returns True if existed.""" 

118 

119 @abstractmethod 

120 async def list(self) -> List[str]: 

121 """List all flag names.""" 

122 

123 

124class InMemoryFlagStore(FlagStore): 

125 """In-memory flag store for development and testing.""" 

126 

127 def __init__(self): 

128 self._flags: Dict[str, FlagRule] = {} 

129 

130 async def get(self, flag_name: str) -> Optional[FlagRule]: 

131 return self._flags.get(flag_name) 

132 

133 async def set(self, flag_name: str, rule: FlagRule) -> None: 

134 self._flags[flag_name] = rule 

135 

136 async def delete(self, flag_name: str) -> bool: 

137 return self._flags.pop(flag_name, None) is not None 

138 

139 async def list(self) -> List[str]: 

140 return list(self._flags.keys()) 

141 

142 

143# --------------------------------------------------------------------------- 

144# Feature Flag Manager 

145# --------------------------------------------------------------------------- 

146 

147 

148class FeatureFlagManager: 

149 """ 

150 Production feature flag manager. 

151 

152 Supports: 

153 - Boolean toggles (on/off) 

154 - Percentage-based gradual rollout 

155 - A/B test variants 

156 - User/tenant targeting (allowlist/blocklist) 

157 - Time-based scheduling 

158 - Kill-switch (immediate disable) 

159 

160 Usage: 

161 manager = FeatureFlagManager(InMemoryFlagStore()) 

162 

163 # Register a flag 

164 await manager.set_flag("new_search", FlagRule( 

165 flag_type=FlagType.PERCENTAGE, 

166 enabled=True, 

167 rollout_percentage=10, 

168 )) 

169 

170 # Check in application code 

171 ctx = FlagContext(user_id="user_123", tenant_id="tenant_a") 

172 if await manager.is_enabled("new_search", ctx): 

173 use_new_search() 

174 """ 

175 

176 def __init__(self, store: FlagStore): 

177 self._store = store 

178 self._evaluation_log: List[FlagEvaluation] = [] 

179 

180 # ── Flag Management ──────────────────────────────────────────────── 

181 

182 async def set_flag(self, flag_name: str, rule: FlagRule) -> None: 

183 """Create or update a feature flag.""" 

184 await self._store.set(flag_name, rule) 

185 

186 async def delete_flag(self, flag_name: str) -> bool: 

187 """Delete a feature flag.""" 

188 return await self._store.delete(flag_name) 

189 

190 async def get_flag(self, flag_name: str) -> Optional[FlagRule]: 

191 """Get a flag's rule.""" 

192 return await self._store.get(flag_name) 

193 

194 async def list_flags(self) -> List[str]: 

195 """List all registered flags.""" 

196 return await self._store.list() 

197 

198 async def kill_switch(self, flag_name: str) -> None: 

199 """Emergency disable a flag (kill-switch).""" 

200 rule = await self._store.get(flag_name) 

201 if rule: 

202 rule.enabled = False 

203 await self._store.set(flag_name, rule) 

204 

205 # ── Flag Evaluation ──────────────────────────────────────────────── 

206 

207 async def is_enabled(self, flag_name: str, context: Optional[FlagContext] = None) -> bool: 

208 """Check if a feature flag is enabled for the given context.""" 

209 evaluation = await self.evaluate(flag_name, context) 

210 return evaluation.enabled 

211 

212 async def get_variant(self, flag_name: str, context: Optional[FlagContext] = None) -> Optional[str]: 

213 """Get the variant name for an A/B test flag.""" 

214 evaluation = await self.evaluate(flag_name, context) 

215 return evaluation.variant 

216 

217 async def evaluate( 

218 self, flag_name: str, context: Optional[FlagContext] = None 

219 ) -> FlagEvaluation: 

220 """Full evaluation of a feature flag with audit trail.""" 

221 ctx = context or FlagContext() 

222 rule = await self._store.get(flag_name) 

223 

224 # Flag not found → disabled 

225 if rule is None: 

226 evaluation = FlagEvaluation( 

227 flag_name=flag_name, 

228 enabled=False, 

229 reason="Flag not found", 

230 ) 

231 self._evaluation_log.append(evaluation) 

232 return evaluation 

233 

234 # Not enabled at rule level 

235 if not rule.enabled: 

236 evaluation = FlagEvaluation( 

237 flag_name=flag_name, 

238 enabled=False, 

239 reason="Flag disabled at rule level", 

240 ) 

241 self._evaluation_log.append(evaluation) 

242 return evaluation 

243 

244 # Blocklist check (highest priority) 

245 if ctx.user_id and ctx.user_id in rule.blocklist_users: 

246 evaluation = FlagEvaluation( 

247 flag_name=flag_name, 

248 enabled=False, 

249 reason="User in blocklist", 

250 ) 

251 self._evaluation_log.append(evaluation) 

252 return evaluation 

253 

254 if ctx.tenant_id and ctx.tenant_id in rule.blocklist_tenants: 

255 evaluation = FlagEvaluation( 

256 flag_name=flag_name, 

257 enabled=False, 

258 reason="Tenant in blocklist", 

259 ) 

260 self._evaluation_log.append(evaluation) 

261 return evaluation 

262 

263 # Allowlist check — exclusive: if allowlist is non-empty and user/tenant NOT in it, deny 

264 has_user_allowlist = bool(rule.allowlist_users) 

265 has_tenant_allowlist = bool(rule.allowlist_tenants) 

266 

267 if has_user_allowlist and (not ctx.user_id or ctx.user_id not in rule.allowlist_users): 

268 evaluation = FlagEvaluation( 

269 flag_name=flag_name, 

270 enabled=False, 

271 reason="User not in allowlist", 

272 ) 

273 self._evaluation_log.append(evaluation) 

274 return evaluation 

275 

276 if has_tenant_allowlist and (not ctx.tenant_id or ctx.tenant_id not in rule.allowlist_tenants): 

277 evaluation = FlagEvaluation( 

278 flag_name=flag_name, 

279 enabled=False, 

280 reason="Tenant not in allowlist", 

281 ) 

282 self._evaluation_log.append(evaluation) 

283 return evaluation 

284 

285 if ctx.user_id and ctx.user_id in rule.allowlist_users: 

286 return self._enabled_eval(flag_name, rule, "User in allowlist") 

287 

288 if ctx.tenant_id and ctx.tenant_id in rule.allowlist_tenants: 

289 return self._enabled_eval(flag_name, rule, "Tenant in allowlist") 

290 

291 # Time-based scheduling 

292 now = datetime.now(timezone.utc) 

293 if rule.start_time and now < rule.start_time: 

294 evaluation = FlagEvaluation( 

295 flag_name=flag_name, 

296 enabled=False, 

297 reason="Before start_time", 

298 ) 

299 self._evaluation_log.append(evaluation) 

300 return evaluation 

301 

302 if rule.end_time and now > rule.end_time: 

303 evaluation = FlagEvaluation( 

304 flag_name=flag_name, 

305 enabled=False, 

306 reason="After end_time", 

307 ) 

308 self._evaluation_log.append(evaluation) 

309 return evaluation 

310 

311 # Type-specific evaluation 

312 if rule.flag_type == FlagType.BOOLEAN: 

313 return self._enabled_eval(flag_name, rule, "Boolean: enabled=True") 

314 

315 elif rule.flag_type == FlagType.PERCENTAGE: 

316 hash_val = self._hash_context(flag_name, ctx) 

317 bucket = hash_val % 100 

318 if bucket < rule.rollout_percentage: 

319 return self._enabled_eval( 

320 flag_name, rule, 

321 f"Percentage: bucket {bucket} < {rule.rollout_percentage}%" 

322 ) 

323 else: 

324 evaluation = FlagEvaluation( 

325 flag_name=flag_name, 

326 enabled=False, 

327 reason=f"Percentage: bucket {bucket} >= {rule.rollout_percentage}%", 

328 ) 

329 self._evaluation_log.append(evaluation) 

330 return evaluation 

331 

332 elif rule.flag_type == FlagType.VARIANT: 

333 variant = self._select_variant(flag_name, ctx, rule) 

334 if variant: 

335 evaluation = FlagEvaluation( 

336 flag_name=flag_name, 

337 enabled=True, 

338 variant=variant, 

339 reason=f"Variant selected: {variant}", 

340 ) 

341 self._evaluation_log.append(evaluation) 

342 return evaluation 

343 else: 

344 evaluation = FlagEvaluation( 

345 flag_name=flag_name, 

346 enabled=False, 

347 reason="Variant: no variant selected", 

348 ) 

349 self._evaluation_log.append(evaluation) 

350 return evaluation 

351 

352 elif rule.flag_type == FlagType.SCHEDULED: 

353 return self._enabled_eval(flag_name, rule, "Scheduled: within time window") 

354 

355 # Fallback 

356 evaluation = FlagEvaluation( 

357 flag_name=flag_name, 

358 enabled=False, 

359 reason="Unknown flag type", 

360 ) 

361 self._evaluation_log.append(evaluation) 

362 return evaluation 

363 

364 # ── Internal Helpers ─────────────────────────────────────────────── 

365 

366 def _hash_context(self, flag_name: str, ctx: FlagContext) -> int: 

367 """Deterministic hash for percentage-based rollout.""" 

368 seed = f"{flag_name}:{ctx.user_id or ''}:{ctx.tenant_id or ''}" 

369 return int(hashlib.md5(seed.encode()).hexdigest(), 16) 

370 

371 def _select_variant( 

372 self, flag_name: str, ctx: FlagContext, rule: FlagRule 

373 ) -> Optional[str]: 

374 """Select a variant based on weighted distribution.""" 

375 if not rule.variants: 

376 return None 

377 

378 hash_val = self._hash_context(flag_name, ctx) 

379 bucket = hash_val % 100 

380 cumulative = 0 

381 for variant_name, weight in rule.variants.items(): 

382 cumulative += weight 

383 if bucket < cumulative: 

384 return variant_name 

385 return None 

386 

387 def _enabled_eval( 

388 self, flag_name: str, rule: FlagRule, reason: str 

389 ) -> FlagEvaluation: 

390 evaluation = FlagEvaluation( 

391 flag_name=flag_name, 

392 enabled=True, 

393 reason=reason, 

394 ) 

395 self._evaluation_log.append(evaluation) 

396 return evaluation 

397 

398 # ── Audit ────────────────────────────────────────────────────────── 

399 

400 def get_evaluation_log(self, limit: int = 100) -> List[FlagEvaluation]: 

401 """Get recent flag evaluations for audit.""" 

402 return self._evaluation_log[-limit:] 

403 

404 def clear_evaluation_log(self) -> None: 

405 self._evaluation_log.clear() 

406 

407 

408# --------------------------------------------------------------------------- 

409# Convenience 

410# --------------------------------------------------------------------------- 

411 

412def create_flag_manager() -> FeatureFlagManager: 

413 """Create a FeatureFlagManager with in-memory store.""" 

414 return FeatureFlagManager(InMemoryFlagStore())