Skip to content

Commit e09b016

Browse files
committed
Support Datadog feauture flags
1 parent b7579b8 commit e09b016

File tree

12 files changed

+336
-54
lines changed

12 files changed

+336
-54
lines changed

src/dda/cli/application.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from dda.config.file import ConfigFile
1818
from dda.config.model import RootConfig
19+
from dda.feature_flags.manager import FeatureFlagManager
1920
from dda.github.core import GitHub
2021
from dda.telemetry.manager import TelemetryManager
2122
from dda.tools import Tools
@@ -126,6 +127,12 @@ def telemetry(self) -> TelemetryManager:
126127

127128
return TelemetryManager(self)
128129

130+
@cached_property
131+
def ff(self) -> FeatureFlagManager:
132+
from dda.feature_flags.manager import FeatureFlagManager
133+
134+
return FeatureFlagManager(self)
135+
129136
@cached_property
130137
def dynamic_deps_allowed(self) -> bool:
131138
return os.getenv(AppEnvVars.NO_DYNAMIC_DEPS) not in {"1", "true"}

src/dda/config/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class AppEnvVars:
2222
VERBOSE = "DDA_VERBOSE"
2323
NO_DYNAMIC_DEPS = "DDA_NO_DYNAMIC_DEPS"
2424
TELEMETRY_API_KEY = "DDA_TELEMETRY_API_KEY"
25+
FEATURE_FLAGS_CLIENT_TOKEN = "DDA_FEATURE_FLAGS_CLIENT_TOKEN" # noqa: S105 This is not a hardcoded secret but the linter complains on it
2526
TELEMETRY_USER_MACHINE_ID = "DDA_TELEMETRY_USER_MACHINE_ID"
2627
# https://no-color.org
2728
NO_COLOR = "NO_COLOR"

src/dda/feature_flags/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT

src/dda/feature_flags/client.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
import json
5+
from typing import Any, Optional
6+
7+
from dda.utils.network.http.client import get_http_client
8+
9+
10+
class DatadogFeatureFlag:
11+
"""
12+
Direct HTTP client for Datadog Feature Flag API
13+
14+
Based on the JavaScript implementation at:
15+
/Users/kevin.fairise/dd/openfeature-js-client/packages/browser/src/transport/fetchConfiguration.ts
16+
"""
17+
18+
def __init__(
19+
self,
20+
client_token: str,
21+
):
22+
"""
23+
Initialize the Datadog Feature Flag client
24+
25+
Args:
26+
client_token: Your Datadog client token (starts with 'pub_')
27+
site: Datadog site (e.g., 'datadoghq.com', 'datadoghq.eu')
28+
env: Environment name
29+
application_id: Your application ID for RUM attribution
30+
service: Service name
31+
version: Application version
32+
flagging_proxy: Optional proxy URL for flagging configuration requests
33+
custom_headers: Optional custom headers to add to requests
34+
"""
35+
self.client_token = client_token
36+
self.env = "Production"
37+
self.endpoint_url = f"https://preview.ff-cdn.datadoghq.com/precompute-assignments?dd_env={self.env}"
38+
self.application_id = "dda"
39+
self.__client = get_http_client()
40+
41+
def _fetch_flags(
42+
self, targeting_key: str = "", targeting_attributes: Optional[dict[str, Any]] = None
43+
) -> dict[str, Any]:
44+
"""
45+
Fetch flag configuration from Datadog API
46+
47+
Args:
48+
targeting_key: The targeting key (typically user ID)
49+
targeting_attributes: Additional targeting attributes (context)
50+
51+
Returns:
52+
Dictionary containing the flag configuration response
53+
54+
Raises:
55+
requests.HTTPError: If the API request fails
56+
"""
57+
# Build headers
58+
headers = {
59+
"Content-Type": "application/vnd.api+json",
60+
"dd-client-token": self.client_token,
61+
}
62+
63+
if self.application_id:
64+
headers["dd-application-id"] = self.application_id
65+
66+
# Stringify all targeting attributes
67+
stringified_attributes = {}
68+
if targeting_attributes:
69+
for key, value in targeting_attributes.items():
70+
if isinstance(value, str):
71+
stringified_attributes[key] = value
72+
else:
73+
stringified_attributes[key] = json.dumps(value)
74+
75+
# Build request payload (following JSON:API format)
76+
payload = {
77+
"data": {
78+
"type": "precompute-assignments-request",
79+
"attributes": {
80+
"env": {
81+
"dd_env": self.env,
82+
},
83+
"sdk": {
84+
"name": "python-example",
85+
"version": "0.1.0",
86+
},
87+
"subject": {
88+
"targeting_key": targeting_key,
89+
"targeting_attributes": stringified_attributes,
90+
},
91+
},
92+
},
93+
}
94+
95+
try:
96+
# Make the request
97+
response = self.__client.post(self.endpoint_url, headers=headers, json=payload, timeout=10)
98+
except Exception: # noqa: BLE001
99+
return {}
100+
101+
return response.json()
102+
103+
def get_flag_value(self, flag_key: str, context: dict[str, Any]) -> Any:
104+
"""
105+
Get a flag value by key
106+
107+
Args:
108+
flag_key: The flag key to evaluate
109+
default_value: Default value if flag is not found
110+
targeting_key: The targeting key (typically user ID)
111+
targeting_attributes: Additional targeting attributes
112+
113+
Returns:
114+
The flag value or default value
115+
"""
116+
try:
117+
response = self._fetch_flags(context["targeting_key"], context["targeting_attributes"])
118+
# Navigate the response structure
119+
flags = response.get("data", {}).get("attributes", {}).get("flags", {})
120+
if flag_key in flags:
121+
return flags[flag_key].get("variationValue", None)
122+
123+
except Exception: # noqa: BLE001
124+
return None
125+
126+
return None

src/dda/feature_flags/manager.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
from __future__ import annotations
5+
6+
from functools import cached_property
7+
from typing import TYPE_CHECKING, Any, Optional
8+
9+
from dda.feature_flags.client import DatadogFeatureFlag
10+
from dda.utils.ci import running_in_ci
11+
from dda.utils.user import User
12+
13+
if TYPE_CHECKING:
14+
from dda.cli.application import Application
15+
from dda.config.model import RootConfig
16+
17+
18+
class FeatureFlagUser(User):
19+
def __init__(self, config: RootConfig) -> None:
20+
super().__init__(config)
21+
22+
23+
class FeatureFlagManager:
24+
"""
25+
A class for querying feature flags. This is available as the
26+
[`Application.ff`][dda.cli.application.Application.ff] property.
27+
"""
28+
29+
def __init__(self, app: Application) -> None:
30+
self.__app = app
31+
32+
self.__started = False
33+
34+
if self.client_token is not None:
35+
self.__ff_client = DatadogFeatureFlag(self.client_token)
36+
self.__started = True
37+
38+
self.__cache: dict[tuple[str, str, tuple[tuple[str, str],...]], Any] = {}
39+
40+
@cached_property
41+
def client_token(self) -> str | None:
42+
from contextlib import suppress
43+
44+
from dda.utils.secrets.secrets import fetch_client_token, read_client_token, save_client_token
45+
46+
client_token: str | None = None
47+
with suppress(Exception):
48+
client_token = read_client_token()
49+
if not client_token:
50+
client_token = fetch_client_token()
51+
save_client_token(client_token)
52+
53+
return client_token
54+
55+
@property
56+
def user(self) -> FeatureFlagUser:
57+
return FeatureFlagUser(self.__app.config)
58+
59+
def get_targeting_key(self) -> str:
60+
if running_in_ci():
61+
import os
62+
63+
return os.getenv("CI_JOB_ID", "default_job_id")
64+
65+
return self.user.machine_id
66+
67+
def check_flag(self, flag_key: str, default_value: Any, extra_attributes: Optional[dict[str, str]] = None) -> bool:
68+
if not self.__started:
69+
return default_value
70+
71+
targeting_key = self.get_targeting_key()
72+
targeting_attributes = self._get_base_context()
73+
if extra_attributes is not None:
74+
targeting_attributes.update(extra_attributes)
75+
76+
attributes_items = targeting_attributes.items()
77+
tuple_attributes = tuple(((key, value) for key, value in sorted(attributes_items)))
78+
79+
self.__app.display_debug(
80+
f"Checking flag {flag_key} with targeting key {targeting_key} and targeting attributes {tuple_attributes}"
81+
)
82+
flag_value = self._check_flag(flag_key, targeting_key, tuple_attributes)
83+
if flag_value is None:
84+
return default_value
85+
return flag_value
86+
87+
def _check_flag(self, flag_key: str, targeting_key: str, targeting_attributes: tuple[tuple[str, str],...]) -> bool:
88+
cache_key = (flag_key, targeting_key, targeting_attributes)
89+
if cache_key in self.__cache:
90+
return self.__cache[cache_key]
91+
92+
context = {
93+
"targeting_key": targeting_key,
94+
"targeting_attributes": dict(targeting_attributes),
95+
}
96+
97+
flag_value = self.__ff_client.get_flag_value(flag_key, context)
98+
self.__cache[cache_key] = flag_value
99+
return flag_value
100+
101+
def _get_base_context(self) -> dict[str, str]:
102+
return {
103+
"platform": "toto",
104+
"ci": "true" if running_in_ci() else "false",
105+
"env": "prod",
106+
"user": self.user.email,
107+
}

src/dda/telemetry/daemon/__main__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
from dda.telemetry.constants import DaemonEnvVars
1818
from dda.telemetry.daemon.handler import finalize_error
19-
from dda.telemetry.secrets import fetch_api_key, read_api_key, save_api_key
2019
from dda.utils.fs import Path
20+
from dda.utils.secrets.secrets import fetch_api_key, read_api_key, save_api_key
2121

2222
if TYPE_CHECKING:
2323
from collections.abc import AsyncIterator

src/dda/telemetry/manager.py

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from functools import cached_property
88
from typing import TYPE_CHECKING
99

10+
from dda.utils.user import User
11+
1012
if TYPE_CHECKING:
1113
from dda.cli.application import Application
1214
from dda.config.model import RootConfig
@@ -76,7 +78,7 @@ def api_key(self) -> str | None:
7678

7779
from contextlib import suppress
7880

79-
from dda.telemetry.secrets import fetch_api_key, read_api_key, save_api_key
81+
from dda.utils.secrets.secrets import fetch_api_key, read_api_key, save_api_key
8082

8183
api_key: str | None = None
8284
with suppress(Exception):
@@ -116,7 +118,6 @@ def __write_dir(self) -> Path:
116118
return Path(mkdtemp(prefix="dda-telemetry-"))
117119

118120
def __start_daemon(self) -> None:
119-
import os
120121
import sys
121122

122123
from dda.telemetry.constants import DaemonEnvVars
@@ -132,25 +133,6 @@ def __start_daemon(self) -> None:
132133
self.__started = True
133134

134135

135-
class TelemetryUser:
136+
class TelemetryUser(User):
136137
def __init__(self, config: RootConfig) -> None:
137-
self.__config = config
138-
139-
@cached_property
140-
def machine_id(self) -> str:
141-
from dda.config.constants import AppEnvVars
142-
143-
if machine_id := os.environ.get(AppEnvVars.TELEMETRY_USER_MACHINE_ID):
144-
return machine_id
145-
146-
from dda.utils.platform import get_machine_id
147-
148-
return str(get_machine_id())
149-
150-
@cached_property
151-
def name(self) -> str:
152-
return self.__config.user.name if self.__config.user.name != "auto" else self.__config.tools.git.author.name
153-
154-
@cached_property
155-
def email(self) -> str:
156-
return self.__config.user.email if self.__config.user.email != "auto" else self.__config.tools.git.author.email
138+
super().__init__(config)

src/dda/telemetry/secrets.py

Lines changed: 0 additions & 30 deletions
This file was deleted.

src/dda/utils/secrets/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2025-present Datadog, Inc. <[email protected]>
2+
#
3+
# SPDX-License-Identifier: MIT

0 commit comments

Comments
 (0)