Skip to content

Commit 59b7d8c

Browse files
DX-116029: LaunchDarkly feature flag integration (#87)
* initial changes * DX-116029: Fix LD integration — add enable_search override, fix tests, clean up API - Add enable_search property with LD flag override (matching allow_dml pattern) - Fix get_bool_flag/get_string_flag signatures (were passing wrong args) - Fix bare except clause in feature_flags.py - Rewrite test_launchdarkly_integration.py with proper mocked LD client tests (old tests referenced non-existent APIs: _extract_flag_metadata, set_ld_context) - Remove relay proxy references (not needed yet) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * DX-116029: Refactor LD integration — GetterModel/FlagAwareModel with auto prefix propagation - Introduce GetterModel base with get(field_name) pass-through - Introduce FlagAwareModel that checks LD singleton before fallback - Auto-propagate _flag_prefix via Settings.model_post_init tree walk - Remove _ld_property, raw_allow_dml/raw_enable_search, Dremio.get_flag() - Remove build_context() from FeatureFlagManager (inlined into get_flag) - Migrate all sub-models (Wlm, Metrics, HttpRetry, ApiSettings, etc.) to FlagAwareModel - Update callers (mcp.py, tools.py) to use .get() for LD-aware access - Add tests for prefix propagation, sub-model overrides, pat/enable_search regression - Add copyright header to launchdarkly_manual_test.py - All 270 tests passing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * DX-116029: Use FeatureFlagManager.instance(), top-level import, add default param to get() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove _init_flag_manager; FeatureFlagManager.instance() self-initializes from settings FeatureFlagManager.instance() now lazily reads sdk_key from settings.instance().dremio.launchdarkly instead of requiring it as a parameter. This removes the eager initialization in Dremio.__init__ and simplifies the bootstrapping flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove context builder, trivial docstrings, and try-except in FlagAwareModel.get() - Remove multi-context builder and unused get_bool_flag/get_string_flag from FeatureFlagManager (project_id/org_id were never passed by callers) - FeatureFlagManager.instance() now returns a disabled instance when LD is not configured instead of raising ValueError - Remove try-except ValueError block from FlagAwareModel.get() - Strip trivial docstrings from settings models and test classes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add FlagAwareModel comments, log_level setting, align tests with simplified FeatureFlagManager - Add block comment on FlagAwareModel explaining how LD flag lookup works (prefix propagation, .get() vs direct access, model_dump unaffected) - Add log_level as top-level Settings field (default "INFO"), LD-overridable via flag key "log_level" - Settings now extends FlagAwareModel so .get() works at the top level - Update tests to match user's simplified FeatureFlagManager (raises on construction failure, no empty-string fallback) - Add TestLogLevel covering default, config, env, and LD override Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Refactor GetterModel/FlagAwareModel into mixins; .get() raises AttributeError for invalid fields Extract GetterMixin and FlagAwareMixin as pure mixins (no BaseModel inheritance) so they compose cleanly with both BaseModel and BaseSettings. FlagAwareModel is now just FlagAwareMixin + BaseModel for convenience. Settings uses FlagAwareMixin + BaseSettings directly, avoiding diamond inheritance. .get() now raises AttributeError if the field_name is not a valid attribute of the model, instead of silently returning None. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Align tests with simplified FlagAwareMixin and safe FeatureFlagManager.instance() - FlagAwareMixin.get() now uses _flag_prefix when calling get_flag - FeatureFlagManager.instance() gracefully handles missing LD config (catches AttributeError/TypeError, passes sdk_key=None) - Tests updated: singleton test expects disabled manager instead of exception, get_flag calls use positional args Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Flatten test classes into plain functions and use parametrize for repetitive patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move launchdarkly config to top-level Settings with default instance launchdarkly is always present (defaulting to LaunchDarkly()), so only sdk_key can be None. This simplifies FeatureFlagManager.instance() to a single-line read without try/except. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add e2e tests for LD feature flags; merge FFM get_flag tests - e2e: test allow_dml blocked without LD, allowed with LD override - e2e: test log_level override via .get() vs direct access - e2e: test LD flags don't affect config values or model_dump() - Merge test_ffm_get_flag_returns_ld_value and _not_found into parametrized test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify _propagate_flag_prefixes: add type hint, use direct assignment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Collapse duplicate LD env var tests into parametrized test Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix cyclic import in feature_flags and add golden flag keys test - Add FeatureFlagManager.initialize() classmethod called by Settings.model_post_init, eliminating the need for feature_flags to import settings - Add scripts/generate_flag_keys.py to generate golden YAML of all flag keys - Add golden_flag_keys.yaml and test_flag_keys_match_golden to catch accidental flag key changes from field renames Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Move collect_flag_keys to settings.py using typing.get_args/get_type_hints Consolidate the flag key collection logic as a utility in settings.py, replacing manual __args__/__origin__ with typing.get_args and get_type_hints. Script and test now import from settings instead of duplicating the logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add comment explaining Optional unwrap in collect_flag_keys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove LD support from deprecated LangChain, BeeAI, and Prometheus models Change OpenAi, Ollama, Anthropic, LangChain, and BeeAI from FlagAwareModel to plain BaseModel since they don't need LaunchDarkly overrides and LangChain/BeeAI are being deprecated. Prometheus already used BaseModel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Exclude non-flag-aware BaseModel sub-models from golden flag keys Skip fields whose type is a BaseModel subclass but not FlagAwareMixin (e.g. LangChain, BeeAI, LaunchDarkly, Prometheus, OAuth2) since they are opaque objects, not individual LD-toggleable values. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Exclude credentials and non-flag-aware models from LD flag keys - Add _flag_exclude ClassVar to FlagAwareMixin; Dremio excludes uri, raw_pat, raw_project_id (connection credentials, not feature flags) - Change Tools from FlagAwareModel to plain BaseModel - get() skips LD lookup for excluded fields - collect_flag_keys() skips excluded fields - Golden keys reduced to 12 meaningful feature flags Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use Annotated[..., NoFlag()] for flag exclusion; add periodic log level refresh - Replace _flag_exclude ClassVar with NoFlag() annotation marker on fields. Pydantic exposes Annotated metadata in field_info.metadata, so both get() and collect_flag_keys() check for NoFlag without extra introspection. - Add _start_log_level_refresh() daemon thread that syncs log level from LD flags every 60 seconds while the MCP server is running. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Replace log level refresh thread with asyncio task via FastMCP lifespan Use FastMCP's lifespan context manager to run the periodic log level refresh as an asyncio task instead of a daemon thread, avoiding thread overhead and keeping everything in the same event loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add unit tests for periodic log level refresh loop Test that the async refresh loop updates logging level when LD returns a different value, and skips set_level when the level hasn't changed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Switch generate_flag_keys.py to typer; update help to use uv run Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 584ca0f commit 59b7d8c

12 files changed

Lines changed: 1205 additions & 25 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ dependencies = [
2020
"langchain-ollama>=0.2.3",
2121
"langchain-openai>=0.3.7",
2222
"langgraph>=0.3.12",
23+
"launchdarkly-server-sdk>=9.15.0",
2324
"mcp>=1.9.4",
2425
"multidict>=6.4.0",
2526
"openai>=1.65.3",

scripts/generate_flag_keys.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#
2+
# Copyright (C) 2017-2025 Dremio Corporation
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
"""Generate the golden YAML of all possible LaunchDarkly flag keys.
17+
18+
Recursively walks Settings and all FlagAwareMixin sub-models to produce
19+
a sorted list of every flag key that .get() could consult.
20+
21+
Usage:
22+
uv run python scripts/generate_flag_keys.py # print to stdout
23+
uv run python scripts/generate_flag_keys.py --write # overwrite golden file
24+
"""
25+
from pathlib import Path
26+
27+
from typer import Typer, Option
28+
from yaml import dump
29+
30+
from dremioai.config.settings import Settings, collect_flag_keys
31+
32+
app = Typer(help="Generate the golden YAML of all possible LaunchDarkly flag keys.")
33+
34+
GOLDEN_PATH = Path(__file__).resolve().parent.parent / "tests" / "config" / "golden_flag_keys.yaml"
35+
36+
37+
@app.command()
38+
def main(
39+
write: bool = Option(False, "--write", help="Overwrite the golden file instead of printing to stdout."),
40+
):
41+
"""Print or write the sorted list of all LD flag keys.
42+
43+
Examples:
44+
uv run python scripts/generate_flag_keys.py
45+
uv run python scripts/generate_flag_keys.py --write
46+
"""
47+
keys = collect_flag_keys(Settings)
48+
output = dump({"flag_keys": keys}, default_flow_style=False, sort_keys=False)
49+
50+
if write:
51+
GOLDEN_PATH.write_text(output)
52+
print(f"Written to {GOLDEN_PATH}")
53+
else:
54+
print(output)
55+
56+
57+
if __name__ == "__main__":
58+
app()
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#
2+
# Copyright (C) 2017-2025 Dremio Corporation
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
import logging
17+
from typing import Optional, Any, Self, ClassVar
18+
from dremioai import log
19+
import ldclient
20+
from ldclient.config import Config
21+
22+
23+
class FeatureFlagManager:
24+
"""Manages LaunchDarkly feature flags for MCP server."""
25+
26+
_log = log.logger("feature_flags")
27+
_instance: ClassVar[Self] = None
28+
29+
def __init__(self, sdk_key: str):
30+
if sdk_key is not None:
31+
self._log.info(
32+
f"Initializing LaunchDarkly client with SDK key: {len(sdk_key)} bytes"
33+
)
34+
ldclient.set_config(Config(sdk_key))
35+
self._client = ldclient.get()
36+
37+
if self._client.is_initialized():
38+
self._log.info("LaunchDarkly client initialized successfully")
39+
else:
40+
self._log.warning("LaunchDarkly client initialization pending")
41+
else:
42+
self._client = None
43+
44+
@classmethod
45+
def initialize(cls, sdk_key: str = None):
46+
"""Initialize the singleton with the given SDK key.
47+
48+
Called by Settings.model_post_init to avoid circular imports
49+
(feature_flags never imports settings).
50+
"""
51+
cls.reset()
52+
cls._instance = cls(sdk_key)
53+
54+
@classmethod
55+
def instance(cls) -> Self:
56+
if cls._instance is None:
57+
cls._instance = cls(None)
58+
return cls._instance
59+
60+
@classmethod
61+
def reset(cls):
62+
if cls._instance and cls._instance._client:
63+
cls._instance._client.close()
64+
cls._instance = None
65+
66+
def is_enabled(self) -> bool:
67+
return self._client is not None and self._client.is_initialized()
68+
69+
def get_flag(self, flag_key: str, default: Any) -> Any:
70+
if not self.is_enabled():
71+
state, level = "enabled", logging.DEBUG
72+
if self._client is not None:
73+
state, level = "initialized", logging.WARNING
74+
self._log.log(
75+
level,
76+
f"Flag '{flag_key}' not evaluated, LaunchDarkly not {state}",
77+
)
78+
return default
79+
value = self._client.variation(
80+
flag_key, ldclient.Context.create("mcp-server"), default
81+
)
82+
self._log.debug(f"Flag '{flag_key}' evaluated to: {value} (default: {default})")
83+
return value

src/dremioai/config/settings.py

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
Callable,
3939
Literal,
4040
Tuple,
41+
get_args,
42+
get_type_hints,
4143
)
4244
from dremioai.config.tools import ToolType
4345
from enum import auto, StrEnum
@@ -51,10 +53,64 @@
5153
from importlib.util import find_spec
5254
from datetime import datetime
5355
from dremioai import log
56+
from dremioai.config.feature_flags import FeatureFlagManager
5457

5558
ProjectId = Union[UUID, Literal["DREMIO_DYNAMIC"]]
5659

5760

61+
class GetterMixin:
62+
"""Mixin that adds .get(field_name) to any Pydantic model.
63+
64+
Raises AttributeError if field_name is not a valid attribute.
65+
Use with BaseModel or BaseSettings via multiple inheritance.
66+
"""
67+
68+
def get(self, field_name: str):
69+
return getattr(self, field_name)
70+
71+
72+
# Annotated marker to exclude a field from LaunchDarkly flag lookups.
73+
# Usage: field: Annotated[type, NoFlag()] = Field(...)
74+
class NoFlag:
75+
"""Mark a field to skip LD flag lookups in FlagAwareMixin.get()."""
76+
pass
77+
78+
79+
def _has_no_flag(model_cls: type, field_name: str) -> bool:
80+
"""Check if a field has the NoFlag annotation marker."""
81+
info = model_cls.model_fields.get(field_name)
82+
return info is not None and any(isinstance(m, NoFlag) for m in info.metadata)
83+
84+
85+
# FlagAwareMixin overrides .get() to check LaunchDarkly before returning the
86+
# config value. The LD flag key is "{_flag_prefix}.{field_name}", e.g.
87+
# "dremio.allow_dml" or "dremio.api.http_retry.max_retries".
88+
#
89+
# _flag_prefix is auto-set by _propagate_flag_prefixes() during
90+
# Settings.model_post_init — it mirrors the model's nesting path.
91+
#
92+
# Direct attribute access (obj.field) always returns the config value;
93+
# only .get() consults LD. This keeps model_dump() and serialization
94+
# unaffected by remote flag state.
95+
#
96+
# Fields annotated with NoFlag() are excluded from LD lookups.
97+
class FlagAwareMixin(GetterMixin):
98+
_flag_prefix: str = ""
99+
100+
def get(self, field_name: str):
101+
if _has_no_flag(type(self), field_name):
102+
return super().get(field_name)
103+
key = f"{self._flag_prefix}.{field_name}" if self._flag_prefix else field_name
104+
return FeatureFlagManager.instance().get_flag(
105+
key, super().get(field_name)
106+
)
107+
108+
109+
# Convenience base for sub-models that need both FlagAwareMixin and BaseModel.
110+
class FlagAwareModel(FlagAwareMixin, BaseModel):
111+
model_config = ConfigDict(validate_assignment=True)
112+
113+
58114
def _resolve_tools_settings(server_mode: Union[ToolType, int, str]) -> ToolType:
59115
if isinstance(server_mode, str):
60116
try:
@@ -132,19 +188,16 @@ def has_expired(self) -> bool:
132188
return self.expiry is not None and self.expiry < datetime.now()
133189

134190

135-
class Wlm(BaseModel):
191+
class Wlm(FlagAwareModel):
136192
engine_name: Optional[str] = None
137-
model_config = ConfigDict(validate_assignment=True)
138193

139194

140-
class Metrics(BaseModel):
195+
class Metrics(FlagAwareModel):
141196
enabled: Optional[bool] = True
142197
port: Optional[int] = 9091
143-
model_config = ConfigDict(validate_assignment=True)
144198

145199

146-
class HttpRetry(BaseModel):
147-
"""Configuration for HTTP retry behavior with exponential backoff"""
200+
class HttpRetry(FlagAwareModel):
148201

149202
max_retries: Optional[int] = Field(
150203
default=20,
@@ -159,36 +212,46 @@ class HttpRetry(BaseModel):
159212
backoff_multiplier: Optional[float] = Field(
160213
default=2.0, description="Multiplier for exponential backoff"
161214
)
162-
model_config = ConfigDict(validate_assignment=True)
163215

164216

165-
class ApiSettings(BaseModel):
217+
class ApiSettings(FlagAwareModel):
166218
# HTTP retry configuration
167219
http_retry: Optional[HttpRetry] = Field(default_factory=HttpRetry)
168220
polling_interval: Optional[float] = Field(
169221
default=1, description="Polling interval for REST api in seconds"
170222
)
223+
224+
225+
class LaunchDarkly(BaseModel):
226+
227+
sdk_key: Optional[Annotated[str, AfterValidator(_resolve_token_file)]] = Field(
228+
default=None,
229+
description="LaunchDarkly SDK key (can be file path with @ prefix or direct value)",
230+
)
171231
model_config = ConfigDict(validate_assignment=True)
172232

233+
@property
234+
def enabled(self) -> bool:
235+
return self.sdk_key is not None
236+
173237

174-
class Dremio(BaseModel):
238+
class Dremio(FlagAwareModel):
175239
uri: Annotated[
176-
Union[str, HttpUrl, DremioCloudUri], AfterValidator(_resolve_dremio_uri)
240+
Union[str, HttpUrl, DremioCloudUri], AfterValidator(_resolve_dremio_uri), NoFlag()
177241
]
178-
raw_pat: Optional[str] = Field(default=None, alias="pat")
179-
raw_project_id: Optional[ProjectId] = Field(default=None, alias="project_id")
242+
raw_pat: Annotated[Optional[str], NoFlag()] = Field(default=None, alias="pat")
243+
raw_project_id: Annotated[Optional[ProjectId], NoFlag()] = Field(default=None, alias="project_id")
180244
enable_search: Optional[bool] = Field(
181245
default=False,
182246
alias=AliasChoices("enable_search", "enable_experimental"),
183247
description="enable experimental tools",
184248
)
185249
oauth2: Optional[OAuth2] = None
186-
allow_dml: Optional[bool] = False
250+
allow_dml: Optional[bool] = Field(default=False)
187251
auth_issuer_uri_override: Optional[str] = None
188252
wlm: Optional[Wlm] = None
189253
api: Optional[ApiSettings] = Field(default_factory=ApiSettings)
190254
metrics: Optional[Metrics] = None
191-
model_config = ConfigDict(validate_assignment=True)
192255

193256
@field_serializer("raw_pat")
194257
def serialize_pat(self, pat: str):
@@ -316,9 +379,11 @@ class BeeAI(BaseModel):
316379
model_config = ConfigDict(validate_assignment=True)
317380

318381

319-
class Settings(BaseSettings):
382+
class Settings(FlagAwareMixin, BaseSettings):
383+
log_level: Optional[str] = Field(default="INFO")
320384
dremio: Optional[Dremio] = Field(default=None)
321385
tools: Optional[Tools] = Field(default_factory=Tools)
386+
launchdarkly: Optional[LaunchDarkly] = Field(default_factory=LaunchDarkly)
322387
prometheus: Optional[Prometheus] = Field(default=None)
323388
langchain: Optional[LangChain] = Field(default=None)
324389
beeai: Optional[BeeAI] = Field(default=None)
@@ -328,8 +393,14 @@ class Settings(BaseSettings):
328393
env_prefix="DREMIOAI_",
329394
env_extra="allow",
330395
use_enum_values=True,
396+
validate_assignment=True,
331397
)
332398

399+
def model_post_init(self, __context):
400+
_propagate_flag_prefixes(self, "")
401+
if self.launchdarkly and self.launchdarkly.sdk_key:
402+
FeatureFlagManager.initialize(self.launchdarkly.sdk_key)
403+
333404
def with_overrides(self, overrides: Dict[str, Any]) -> Self:
334405
def set_values(aparts: List[str], value: Any, obj: Any):
335406
if len(aparts) == 1 and hasattr(obj, aparts[0]):
@@ -347,6 +418,43 @@ def set_values(aparts: List[str], value: Any, obj: Any):
347418
return self
348419

349420

421+
def _propagate_flag_prefixes(obj: BaseModel, prefix: str):
422+
for name in type(obj).model_fields:
423+
child = getattr(obj, name, None)
424+
if isinstance(child, FlagAwareMixin):
425+
child_prefix = f"{prefix}.{name}" if prefix else name
426+
child._flag_prefix = child_prefix
427+
_propagate_flag_prefixes(child, child_prefix)
428+
429+
430+
def collect_flag_keys(model_cls: type, prefix: str = "") -> list[str]:
431+
"""Recursively collect all LD flag keys from a FlagAwareMixin model class."""
432+
keys = []
433+
hints = get_type_hints(model_cls, include_extras=True)
434+
for name in model_cls.model_fields:
435+
if _has_no_flag(model_cls, name):
436+
continue
437+
key = f"{prefix}.{name}" if prefix else name
438+
annotation = hints[name]
439+
# Unwrap Optional[X] (Union[X, None]) to get the inner type X.
440+
# Without this, annotation is a generic alias (not a type), so
441+
# isinstance(annotation, type) would be False and we'd never
442+
# recurse into sub-models. We only unwrap when there's exactly
443+
# one non-None arg (i.e. Optional[X]); complex Unions are left as-is.
444+
inner = [a for a in get_args(annotation) if a is not type(None)]
445+
if len(inner) == 1:
446+
annotation = inner[0]
447+
if isinstance(annotation, type) and issubclass(annotation, FlagAwareMixin):
448+
keys.extend(collect_flag_keys(annotation, key))
449+
elif isinstance(annotation, type) and issubclass(annotation, BaseModel):
450+
# Non-flag-aware sub-models (e.g. LangChain, BeeAI) are opaque
451+
# objects, not individual LD flags — skip them entirely.
452+
continue
453+
else:
454+
keys.append(key)
455+
return sorted(keys)
456+
457+
350458
_settings: ContextVar[Settings] = ContextVar("settings", default=None)
351459

352460

0 commit comments

Comments
 (0)