Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4941a9b
feat(openfeature): add agentless feature flag configuration options
pavlokhrebto Jul 28, 2026
4a7cf86
feat(openfeature): add agentless endpoint derivation and UFC validation
pavlokhrebto Jul 28, 2026
b587317
feat(openfeature): add agentless UFC poller with endpoint/validation …
pavlokhrebto Jul 28, 2026
22f654d
feat(openfeature): wire agentless/RC source selection into the provid…
pavlokhrebto Jul 28, 2026
d5e1fff
feat(openfeature): stagger the agentless first poll in forked workers
pavlokhrebto Jul 28, 2026
8891e4c
docs(openfeature): add release note for agentless feature flag config…
pavlokhrebto Jul 28, 2026
149f36a
test(openfeature): add end-to-end tests for agentless delivery and pr…
pavlokhrebto Jul 28, 2026
b1a284c
Fix
pavlokhrebto Jul 28, 2026
128172f
test(openfeature): assert exact agentless URL to satisfy CodeQL
pavlokhrebto Jul 28, 2026
66f72ad
fix(openfeature): gate telemetry on resolved source and reject non-po…
pavlokhrebto Jul 28, 2026
fb4bec4
fix(openfeature): keep the agentless ETag when the evaluator rejects …
pavlokhrebto Jul 29, 2026
898dbc7
fix(openfeature): make agentless shutdown interrupt retry backoff
pavlokhrebto Jul 29, 2026
badd957
refactor(internal): allow an injectable sleep in the retry utility
pavlokhrebto Jul 31, 2026
f786d5a
Merge branch 'pavlo.khrebto/adjust-retry-logic' into pavlo.khrebto/FF…
pavlokhrebto Jul 31, 2026
c41819f
docs(openfeature): note that agentless is the default configuration s…
pavlokhrebto Jul 31, 2026
20d09f7
refactor(internal): resolve the retry sleep per call and drop threadi…
pavlokhrebto Jul 31, 2026
765b044
Merge branch 'pavlo.khrebto/adjust-retry-logic' of github.com:DataDog…
pavlokhrebto Jul 31, 2026
4892c28
refactor(openfeature): replace the agentless shutdown event with a sl…
pavlokhrebto Jul 31, 2026
0b8b822
Merge branch 'main' into pavlo.khrebto/FFL-2699/agentless-ff-configs
pavlokhrebto Aug 3, 2026
844ca9d
Merge branch 'main' into pavlo.khrebto/FFL-2699/agentless-ff-configs
pavlokhrebto Aug 3, 2026
e217ca1
fix(openfeature): correct a spelling error flagged by the lint prechecks
pavlokhrebto Aug 4, 2026
b5cb121
Merge branch 'pavlo.khrebto/FFL-2699/agentless-ff-configs' of github.…
pavlokhrebto Aug 4, 2026
836b9d7
fix(openfeature): cancel an in-flight agentless poll on shutdown
pavlokhrebto Aug 6, 2026
2ddddf2
Merge branch 'main' of github.com:DataDog/dd-trace-py into pavlo.khre…
pavlokhrebto Aug 6, 2026
c097c0d
fix(openfeature): wait for configuration during provider initialization
pavlokhrebto Aug 6, 2026
504ee05
fix(openfeature): report initialization timeout
leoromanovsky Aug 6, 2026
b03516e
style(openfeature): format provider status test
leoromanovsky Aug 6, 2026
35752da
Merge branch 'main' into pavlo.khrebto/FFL-2699/agentless-ff-configs
brettlangdon Aug 6, 2026
978bf6c
Merge branch 'main' into pavlo.khrebto/FFL-2699/agentless-ff-configs
leoromanovsky Aug 7, 2026
774fa3a
Merge branch 'main' into pavlo.khrebto/FFL-2699/agentless-ff-configs
pavlokhrebto Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions ddtrace/internal/openfeature/_agentless.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""
Helpers for the agentless Feature Flagging configuration source.

These are pure, dependency-light functions (endpoint derivation, JSON:API
validation, gzip decoding) used by the agentless poller. They deliberately take
their inputs explicitly rather than reading global config so they can be unit
tested in isolation.
"""

import gzip
import json
from typing import Any
from typing import Optional
from urllib.parse import urlencode
from urllib.parse import urlsplit
from urllib.parse import urlunsplit


# Canonical rules-based server path appended to the managed CDN host and to
# custom base URLs that only supply an origin.
DEFAULT_AGENTLESS_PATH = "/api/v2/feature-flagging/config/rules-based/server"


def build_agentless_endpoint(site: str, env: Optional[str] = None, base_url: Optional[str] = None) -> str:
"""Build the agentless UFC endpoint URL.

Without a custom ``base_url`` the managed Datadog CDN endpoint is derived
from ``site`` (lowercased), adding ``dd_env`` only when ``env`` is set. This
resolves staging (``datad0g.com``) and GovCloud (``ddog-gov.com``)
automatically with no site allowlist.

A custom ``base_url`` that is a root/origin receives the standard rules-based
path; one with a non-root path is used verbatim as the exact endpoint. HTTP
is permitted for custom endpoints (operator-owned trust).

:raises ValueError: if a custom ``base_url`` is malformed or uses a scheme
other than http/https. Error messages never include the URL, which is
sensitive.
"""
configured = base_url.strip() if base_url else ""

if not configured:
netloc = "ufc-server.ff-cdn.{}".format(site.strip().lower())
query = urlencode({"dd_env": env}) if env else ""
return urlunsplit(("https", netloc, DEFAULT_AGENTLESS_PATH, query, ""))

# A URL with internal whitespace is malformed; urlsplit is lenient and would
# otherwise accept it. Do not surface the value.
if any(ch.isspace() for ch in configured):
raise ValueError("Invalid Feature Flagging agentless URL")

try:
parts = urlsplit(configured)
except ValueError:
raise ValueError("Invalid Feature Flagging agentless URL")

if parts.scheme not in ("http", "https"):
raise ValueError("Feature Flagging agentless URL must use HTTP or HTTPS")
if not parts.netloc:
raise ValueError("Invalid Feature Flagging agentless URL")

path = parts.path
if path in ("", "/"):
path = DEFAULT_AGENTLESS_PATH

return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))


def decode_response_body(body: bytes, content_encoding: Optional[str]) -> bytes:
"""Return ``body`` decompressed when the response was gzip-encoded.

gzip is NOT auto-decoded by the HTTP layer, so callers pass the raw body and
the ``Content-Encoding`` header value. The check is case-insensitive.

:raises: propagates gzip errors (e.g. ``OSError``/``EOFError``) on a body
that claims gzip encoding but cannot be decompressed.
"""
if content_encoding and content_encoding.strip().lower() == "gzip":
return gzip.decompress(body)
return body


def parse_ufc_configuration(body: Any) -> "dict[str, Any]":
"""Validate a JSON:API UFC response envelope and return ``data.attributes``.

The envelope must be::

{"data": {"type": "universal-flag-configuration",
"attributes": {"format": <str>, "createdAt": <str>,
"environment": {"name": <str>}, "flags": {}}}}

Only ``data.attributes`` is returned (and passed to the evaluator). Raw UFC
(non-JSON:API) is not accepted, even for custom endpoints.

:raises ValueError: if the payload is not valid JSON or does not match the
JSON:API Universal Flag Configuration v1 contract.
"""
try:
payload = json.loads(body)
except (ValueError, TypeError) as e:
raise ValueError("Malformed UFC payload") from e

if not isinstance(payload, dict):
raise ValueError("Expected a JSON:API Universal Flag Configuration resource")

data = payload.get("data")
if not isinstance(data, dict) or data.get("type") != "universal-flag-configuration":
raise ValueError("Expected a JSON:API Universal Flag Configuration resource")

attributes = data.get("attributes")
if not isinstance(attributes, dict):
raise ValueError("Expected a Universal Flag Configuration v1 object")

environment = attributes.get("environment")
if (
not isinstance(attributes.get("format"), str)
or not isinstance(attributes.get("createdAt"), str)
or not isinstance(environment, dict)
or not isinstance(environment.get("name"), str)
or not isinstance(attributes.get("flags"), dict)
):
raise ValueError("Expected a Universal Flag Configuration v1 object")

return attributes
Loading
Loading