- 
        Couldn't load subscription status. 
- Fork 2
Support Datadog feature flags #208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
826b998
              d0ef55f
              d7a0c79
              e0fc87d
              fa3cd53
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com> | ||
| # | ||
| # SPDX-License-Identifier: MIT | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| # SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com> | ||
| # | ||
| # SPDX-License-Identifier: MIT | ||
| from __future__ import annotations | ||
|  | ||
| from typing import TYPE_CHECKING, Any | ||
|  | ||
| from dda._version import __version__ | ||
|  | ||
| if TYPE_CHECKING: | ||
| from dda.cli.application import Application | ||
|  | ||
|  | ||
| class DatadogFeatureFlag: | ||
| """ | ||
| Direct HTTP client for Datadog Feature Flag API | ||
| """ | ||
|  | ||
| def __init__(self, client_token: str | None, app: Application): | ||
| """ | ||
| Initialize the Datadog Feature Flag client | ||
|  | ||
| Parameters: | ||
| client_token: Your Datadog client token (starts with 'pub_') | ||
| app: The application instance | ||
| """ | ||
| self.__client_token = client_token | ||
| self.__env = "Production" | ||
| self.__url = f"https://preview.ff-cdn.datadoghq.com/precompute-assignments?dd_env={self.__env}" | ||
| self.__app_id = "dda" | ||
| self.__app = app | ||
|  | ||
| def _fetch_flags( | ||
| self, targeting_key: str = "", targeting_attributes: dict[str, Any] | None = None | ||
| ) -> dict[str, Any]: | ||
| """ | ||
| Fetch flag configuration from Datadog API | ||
|  | ||
| Parameters: | ||
| targeting_key: The targeting key (typically user ID) | ||
| targeting_attributes: Additional targeting attributes (context) | ||
| 
      Comment on lines
    
      +40
     to 
      +41
    
   There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's change these to something more meaningful and shorter. 
 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can update these for public facing functions. But for that private one I would rather keep that terminology that is given to us by the Feature flag API | ||
|  | ||
| Returns: | ||
| Dictionary containing the flag configuration response | ||
| """ | ||
| if not self.__client_token: | ||
| return {} | ||
|  | ||
| from httpx import HTTPError | ||
|  | ||
| # Build headers | ||
| headers = { | ||
| "Content-Type": "application/vnd.api+json", | ||
| "dd-client-token": self.__client_token, | ||
| "dd-application-id": self.__app_id, | ||
| } | ||
|  | ||
| # Build request payload (following JSON:API format) | ||
| payload = { | ||
| "data": { | ||
| "type": "precompute-assignments-request", | ||
| "attributes": { | ||
| "env": { | ||
| "dd_env": self.__env, | ||
| }, | ||
| "sdk": { | ||
| "name": "dda", | ||
| "version": __version__, | ||
| }, | ||
| "subject": { | ||
| "targeting_key": targeting_key, | ||
| "targeting_attributes": targeting_attributes, | ||
| }, | ||
| }, | ||
| }, | ||
| } | ||
|  | ||
| try: | ||
| # Make the request | ||
| response = self.__app.http.client().post(self.__url, headers=headers, json=payload) | ||
| except HTTPError as e: | ||
| self.__app.display_warning(f"Error fetching flags: {e}") | ||
| return {} | ||
|  | ||
| return response.json() | ||
|  | ||
| def get_flag_value(self, flag: str, targeting_key: str, targeting_attributes: dict[str, Any]) -> bool | None: | ||
| """ | ||
| Get a flag value by key | ||
|  | ||
| Parameters: | ||
| flag: The flag key to evaluate | ||
| targeting_key: The targeting key to use for feature flag evaluation | ||
| targeting_attributes: The targeting attributes to use for feature flag evaluation | ||
|  | ||
| Returns: | ||
| The flag value or None if the flag is not found | ||
| """ | ||
| response = self._fetch_flags(targeting_key, targeting_attributes) | ||
| flags = response.get("data", {}).get("attributes", {}).get("flags", {}) | ||
| if flag in flags: | ||
| return flags[flag].get("variationValue", None) | ||
|  | ||
| return None | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| # SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com> | ||
| # | ||
| # SPDX-License-Identifier: MIT | ||
| from __future__ import annotations | ||
|  | ||
| from functools import cached_property | ||
| from typing import TYPE_CHECKING, Any, Optional | ||
|  | ||
| from dda.feature_flags.client import DatadogFeatureFlag | ||
| from dda.user.datadog import User | ||
| from dda.utils.ci import running_in_ci | ||
|  | ||
| if TYPE_CHECKING: | ||
| from dda.cli.application import Application | ||
| from dda.config.model import RootConfig | ||
|  | ||
|  | ||
| class FeatureFlagUser(User): | ||
| def __init__(self, config: RootConfig) -> None: | ||
| super().__init__(config) | ||
|  | ||
|  | ||
| class FeatureFlagManager: | ||
| """ | ||
| A class for querying feature flags. This is available as the | ||
| [`Application.features`][dda.cli.application.Application.features] property. | ||
| """ | ||
|  | ||
| def __init__(self, app: Application) -> None: | ||
| self.__app = app | ||
| # Manually implemented cache to avoid calling several time Feature flag backend on the same flag evaluation. | ||
| # Cache key is a tuple of the flag, entity and scopes, to make it hashable. | ||
| # For example after calling `enabled("test-flag", default=False, scopes={"user": "user1"}), | ||
| # the cache will contain the result for the tuple ("test-flag", "entity", (("user", "user1"),)). | ||
| self.__cache: dict[tuple[str, str, tuple[tuple[str, str], ...]], Any] = {} | ||
|         
                  KevinFairise2 marked this conversation as resolved.
              Show resolved
            Hide resolved | ||
|  | ||
| @cached_property | ||
| def __client(self) -> DatadogFeatureFlag | None: | ||
| if running_in_ci(): # We do not support feature flags token retrieval in the CI yet. | ||
| return None | ||
|  | ||
| from contextlib import suppress | ||
|  | ||
| from dda.secrets.api import fetch_client_token, read_client_token, save_client_token | ||
|  | ||
| client_token: str | None = None | ||
| with suppress(Exception): | ||
| client_token = read_client_token() | ||
| if not client_token: | ||
| client_token = fetch_client_token() | ||
| save_client_token(client_token) | ||
|  | ||
| return DatadogFeatureFlag(client_token, self.__app) | ||
|  | ||
| @property | ||
| def __user(self) -> FeatureFlagUser: | ||
| return FeatureFlagUser(self.__app.config) | ||
|  | ||
| def __get_entity(self) -> str: | ||
| if running_in_ci(): | ||
| import os | ||
|  | ||
| return os.getenv("CI_JOB_ID", "default_job_id") | ||
|  | ||
| return self.__user.machine_id | ||
|  | ||
| def enabled(self, flag: str, *, default: bool = False, scopes: Optional[dict[str, str]] = None) -> bool: | ||
| entity = self.__get_entity() | ||
| base_scopes = self.__get_base_scopes() | ||
| if scopes is not None: | ||
| base_scopes.update(scopes) | ||
|  | ||
| attributes_items = base_scopes.items() | ||
| tuple_attributes = tuple(((key, value) for key, value in sorted(attributes_items))) | ||
|  | ||
| self.__app.display_debug(f"Checking flag {flag} with entity {entity} and scopes {base_scopes}") | ||
| flag_value = self.__check_flag(flag, entity, tuple_attributes) | ||
| if flag_value is None: | ||
| return default | ||
| return flag_value | ||
|  | ||
| def __check_flag(self, flag: str, entity: str, scopes: tuple[tuple[str, str], ...]) -> bool | None: | ||
| if self.__client is None: | ||
| self.__app.display_debug("Feature flag client not initialized properly") | ||
| return None | ||
|  | ||
| cache_key = (flag, entity, scopes) | ||
| if cache_key in self.__cache: | ||
| return self.__cache[cache_key] | ||
|  | ||
| flag_value = self.__client.get_flag_value(flag, entity, dict(scopes)) | ||
| self.__cache[cache_key] = flag_value | ||
| return flag_value | ||
|  | ||
| def __get_base_scopes(self) -> dict[str, str]: | ||
| return { | ||
| "ci": "true" if running_in_ci() else "false", | ||
| "user": self.__user.email, | ||
| } | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| # SPDX-FileCopyrightText: 2025-present Datadog, Inc. <dev@datadoghq.com> | ||
|         
                  KevinFairise2 marked this conversation as resolved.
              Show resolved
            Hide resolved | ||
| # | ||
| # SPDX-License-Identifier: MIT | ||
Uh oh!
There was an error while loading. Please reload this page.