Skip to content

feat(openfeature): add agentless feature flag configuration source - #19331

Open
pavlokhrebto wants to merge 12 commits into
mainfrom
pavlo.khrebto/FFL-2699/agentless-ff-configs
Open

feat(openfeature): add agentless feature flag configuration source#19331
pavlokhrebto wants to merge 12 commits into
mainfrom
pavlo.khrebto/FFL-2699/agentless-ff-configs

Conversation

@pavlokhrebto

@pavlokhrebto pavlokhrebto commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Description

Adds agentless delivery for the Feature Flagging & Experimentation (FFE) OpenFeature
provider: Universal Flag Configuration (UFC) is loaded directly from Datadog over HTTPS
with no Datadog Agent. Agentless becomes the default source; the existing Agent Remote
Configuration path remains available as explicit opt-in. Ports dd-trace-js #9397 and the
follow-ups #9481 (custom endpoints) / #9482 (grandfathering, fail-closed), adapted to
Python provider-lifecycle conventions.

Key pieces:

  • Config: DD_FEATURE_FLAGS_ENABLED (stable kill switch, default true),
    DD_FEATURE_FLAGS_CONFIGURATION_SOURCE (agentless default | remote_config; offline
    reserved), and agentless tunables ..._AGENTLESS_BASE_URL,
    ..._AGENTLESS_POLL_INTERVAL_SECONDS (30, capped at 1h),
    ..._AGENTLESS_REQUEST_TIMEOUT_SECONDS (5).
  • Source resolution: kill switch → explicit source → fail-closed for unsupported/offline
    → legacy grandfathering (DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED) → default agentless.
  • AgentlessConfigurationSource (PeriodicService): fixed-delay polling, in-tick
    retry/backoff (408/429/5xx/network, jittered), per-request timeout, gzip, strict JSON:API
    validation, ETag/304, last-known-good preserved on every failure, self-tracing suppressed.
  • Activation mirrors dd-trace-js: the agentless poller starts from the provider lifecycle
    (only when the OpenFeature provider is registered), so there is no default-on CDN polling.
    RC is subscribed only when remote_config is the resolved source. Both sources feed the
    same apply path.
  • Pre-fork: the poller restarts per worker with a jittered first poll so workers don't
    hit the CDN in lockstep.

JIRA: FFL-2699

Testing

  • Unit tests: endpoint derivation (incl. staging/GovCloud, dd_env), JSON:API accept/reject,
    gzip, poller behavior (200/304/401/malformed/retry/last-known-good/ETag/headers/no-trace),
    source-resolution matrix (kill switch, grandfathering, fail-closed), agentless factory
    (API-key handling), fork jitter.
  • End-to-end: JSON:API CDN response → parse → apply → OpenFeature evaluation; provider
    initialize()/shutdown() start/stop the poller; kill switch starts no source.
  • Full tests/openfeature suite passes (613). style/typing/spelling/registry/suitespec pass.

System tests

Ran the shared configuration-source contract against this branch, with the suite activated for
Python via DataDog/system-tests#7411 and binaries/python-load-from-local pointing at it:

PYTEST_XDIST_AUTO_NUM_WORKERS=1 TEST_LIBRARY=python ./run.sh PARAMETRIC \
  tests/parametric/test_ffe/test_configuration_sources.py
29 passed in 130.99s (0:02:10)

Covers default lazy agentless delivery, explicit and grandfathered Remote Configuration, the kill
switch, invalid and reserved-offline fail-closed behavior, custom-endpoint authentication,
recovery and ETag handling, timeout/retry, and non-overlapping polling.

No regressions in the rest of the FFE parametric directory: 30 passed, 12 xfailed, 29 xpassed
(test_dynamic_evaluation.py 24 passed, test_span_enrichment.py 6 passed + 12 pre-existing
xfails).

The suite is run sequentially; with parallel workers two cases intermittently error with test-agent
port-routing noise unrelated to the library.

Risks

  • DD_FEATURE_FLAGS_ENABLED defaults true, but the agentless poller only activates when
    the OpenFeature provider is registered — users who don't use FFE are unaffected and there is
    no default-on CDN traffic.
  • Evaluation, hooks and telemetry writers are all gated on the resolved source, so the legacy
    DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED now only feeds source grandfathering. Existing users
    who set it keep Remote Configuration delivery and reporting; agentless users get reporting too.
  • Under pre-fork servers each worker polls independently; a shared-memory optimization is a
    planned follow-up.

Additional Notes

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codeowners resolved as

ddtrace/internal/openfeature/_agentless_source.py                       @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/utils/retry.py                                         @DataDog/apm-core-python
tests/openfeature/test_agentless_poller.py                              @DataDog/feature-flagging-and-experimentation-sdk

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.debug -> ddtrace.trace
ddtrace -> ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.debug -> ddtrace
ddtrace -> ddtrace.trace -> ddtrace._trace.tracer -> ddtrace.internal.debug -> ddtrace.internal.runtime.runtime_metrics -> ddtrace

@datadog-prod-us1-5

datadog-prod-us1-5 Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

🚦 9 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v113741238-d2b8243-manylinux2014_x86_64, 1]   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v126532274-233089d-musllinux_1_2_x86_64, 1]   View in Datadog   GitLab

DataDog/apm-reliability/dd-trace-py | build linux serverless: [arm64, cp315-cp315, v113741357-d2b8243-manylinux2014_aarch64, 1]   View in Datadog   GitLab

View all 9 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 898dbc7 | Docs | Datadog PR Page | Give us feedback!

@pavlokhrebto pavlokhrebto changed the title feat(openfeature): add agentless feature flag configuration options feat(openfeature): add agentless feature flag configuration source Jul 28, 2026
Comment thread tests/openfeature/test_agentless_source.py Fixed
@pr-commenter

pr-commenter Bot commented Jul 28, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-07-29 10:55:08

Comparing candidate commit 898dbc7 in PR branch pavlo.khrebto/FFL-2699/agentless-ff-configs with baseline commit 3e949c4 in branch main.

Found 0 performance improvements and 4 performance regressions! Performance is the same for 617 metrics, 10 unstable metrics.

scenario:iastaspects-lstrip_aspect

  • 🟥 execution_time [+61.646µs; +66.486µs] or [+19.349%; +20.869%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+101.395µs; +108.175µs] or [+23.661%; +25.243%]

scenario:span-start

  • 🟥 execution_time [+1.654ms; +1.818ms] or [+10.577%; +11.628%]

scenario:tracer-small

  • 🟥 execution_time [+29.998µs; +32.219µs] or [+7.966%; +8.556%]

@pavlokhrebto
pavlokhrebto marked this pull request as ready for review July 28, 2026 13:50
@pavlokhrebto
pavlokhrebto requested review from a team as code owners July 28, 2026 13:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 128172f5a9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

)
poll_interval = MAX_POLL_INTERVAL_SECONDS

super().__init__(interval=poll_interval, no_wait_at_start=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clamp non-positive poll intervals

When DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS is set to 0 or a negative value, that value reaches PeriodicService unchanged here. PeriodicThread schedules its next run as now + interval, so 0 turns successful 200/304 polls into a tight loop that repeatedly hits the CDN and burns CPU; negative values can also make the fork-jitter path call time.sleep() with a negative duration. Please reject or clamp non-positive intervals before starting the poller.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed. Non-positive poll intervals and request timeouts now fall back to the documented defaults (30s / 5s) with a warning, so a 0 cannot busy-loop the CDN and a negative cannot reach time.sleep(). Added tests.

# Legacy experimental flag: an input to source grandfathering (handled in
# resolution) and the gate for the existing exposure/evaluation telemetry
# writers below, kept unchanged to preserve current telemetry behavior.
self._enabled = ffe_config.experimental_flagging_provider_enabled

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate hooks on the stable kill switch

When DD_FEATURE_FLAGS_ENABLED=false is used as the stable kill switch but the legacy DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true remains set, _active is false but _enabled stays true here, so the constructor still allocates the OTel/EVP/span-enrichment hooks and get_provider_hooks() returns them even though initialization and resolution report the provider as disabled. The OpenFeature SDK can still run those hooks for evaluations, causing disabled FFE to emit or enqueue evaluation telemetry instead of being fully off; include _active in these hook/writer gates.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Telemetry is now gated on the resolved source (_active) instead of the legacy flag, so a disabled provider allocates and returns no hooks.

# If provider is not enabled, return default value
if not self._enabled:
# If provider is not active, return default value
if not self._active:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep exposure reporting behind the legacy telemetry gate

When users adopt the new default or explicit agentless/remote_config source without setting the legacy experimental flag, _active is true while _enabled is false. Because this guard now lets evaluation continue, any flag with do_log=true reaches _report_exposure() below, creating and enqueueing an ExposureWriter even though initialize() intentionally did not start exposure telemetry under the legacy gate; if a writer is already running, those exposures can be sent. Gate the exposure calls on _enabled or start/disable the writer consistently.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by the same change: self._enabled is gone and everything keys off _active, so evaluation and exposure reporting can no longer disagree. This also matches dd-trace-js, where the same switch gates the provider and its writers.

Comment on lines +6 to +15
over HTTPS without requiring a Datadog Agent. The delivery source is selected with
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE``: ``agentless`` (default) loads from Datadog and
requires ``DD_API_KEY``, while ``remote_config`` keeps the existing Agent Remote
Configuration path. Agentless polling is tuned with
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS`` (default ``30``,
capped at one hour) and
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS`` (default ``5``),
and can target a custom endpoint with
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL``. The provider can be turned off
with ``DD_FEATURE_FLAGS_ENABLED=false``.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
over HTTPS without requiring a Datadog Agent. The delivery source is selected with
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE``: ``agentless`` (default) loads from Datadog and
requires ``DD_API_KEY``, while ``remote_config`` keeps the existing Agent Remote
Configuration path. Agentless polling is tuned with
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_POLL_INTERVAL_SECONDS`` (default ``30``,
capped at one hour) and
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_REQUEST_TIMEOUT_SECONDS`` (default ``5``),
and can target a custom endpoint with
``DD_FEATURE_FLAGS_CONFIGURATION_SOURCE_AGENTLESS_BASE_URL``. The provider can be turned off
with ``DD_FEATURE_FLAGS_ENABLED=false``.
over HTTPS without requiring a Datadog Agent. The provider can be turned off
with ``DD_FEATURE_FLAGS_ENABLED=false``.

Is all that part really needed? I feel like it's duplicating the documentation and not necessarily something relevant as a release note.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, applied — trimmed to the feature and the kill switch. The env vars are covered in docs/configuration.rst.

@leoromanovsky leoromanovsky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please show proof in the PR body that this branch ran against system tests, something like:

 TEST_LIBRARY=python ./run.sh PARAMETRIC \
    tests/parametric/test_ffe/test_configuration_sources.py

After activating them.

if self._configuration_source is not None:
return

from ddtrace.internal.openfeature._native import process_ffe_configuration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catches a value error; would that mean that the etag can be advanced with a bad configuration? Please add a test to prove/disprove.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed reachable, and fixed. process_ffe_configuration swallowed the ValueError and returned None, so the poller read every apply as success and advanced the ETag — the next poll would then get a 304 and keep the stale config indefinitely. It now returns a bool, and the agentless path raises on rejection, so the ETag stays put and the payload is retried next poll.

One thing the tests turned up: the evaluator tolerates malformed individual flags (they're skipped) but rejects an unparseable createdAt — which passes our JSON:API validation since we only check that it's a string. That's the payload the two regression tests use.

result = provider.resolve_boolean_details("my-flag", False)
assert result.value is True
finally:
provider.shutdown()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does shutdown abort active work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It didn't, and now it does. stop() ended the poll loop, but an in-tick retry backoff ran to completion on a blocking sleep, so shutdown could wait up to ~30s. There's now a shutdown Event: backoffs wait on it (via a new optional sleep_func on the shared retry() util), the retry predicate stops retrying once it's set, and a response arriving after shutdown no longer applies config or advances the ETag.

Remaining bound is one in-flight request (..._REQUEST_TIMEOUT_SECONDS, default 5s) — Python can't abort a blocking socket read from another thread the way AbortController can.

"DD-Client-Library-Version": _pep440_to_semver(),
}
if self._api_key:
headers["DD-API-KEY"] = self._api_key

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

omit from custom backends

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already the case — create_agentless_source passes api_key=None whenever a custom base URL is set, so the header is never added (covered by test_create_custom_endpoint_omits_api_key). Matches merged dd-trace-js #9481 (apiKey: hasCustomEndpoint ? undefined : ...). Let me know if you also meant the DD-Client-Library-* headers — those are currently sent to custom endpoints too.

@pavlokhrebto

Copy link
Copy Markdown
Contributor Author

Added the proof to the PR body. Activated the suite for Python in DataDog/system-tests#7411 and ran it against this branch via binaries/python-load-from-local:

PYTEST_XDIST_AUTO_NUM_WORKERS=1 TEST_LIBRARY=python ./run.sh PARAMETRIC \
  tests/parametric/test_ffe/test_configuration_sources.py
29 passed in 130.99s (0:02:10)

Getting there needed two fixes in the Python parametric controller, both in #7411 and mirroring what #7355 did for Node.js:

  • /ffe/evaluate used get_*_value(), which drops reason/errorCode, so the cold-state assertions could never see PROVIDER_NOT_READY — the library was already returning it. Now uses get_*_details().
  • The controller eagerly constructed the provider at import when DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED=true, which polled the CDN before /ffe/start and broke the lazy-activation assertions. Now skipped when Feature Flagging configuration variables are set (gated rather than removed, since older tests rely on the eager path).

Progression as those landed: 21 passed/6 failed → 27 passed → 29 passed. Also ran the full tests/parametric/test_ffe/ directory: 30 passed, 12 xfailed, 29 xpassed, no regressions.

Only the parametric suite is activated; tests/ffe/test_agentless_configuration.py (weblog e2e) stays missing_feature — not exercised here.

after: t.Union[int, float, t.Iterable[t.Union[int, float]]],
until: t.Callable[[t.Any], bool] = lambda result: result is None,
initial_wait: float = 0,
sleep_func: t.Callable[[float], t.Any] = sleep,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we make this as it's own PR and add tests for the change in tests/internal/test_utils_retry.py ?

return status == 408 or status == 429 or (500 <= status <= 599)


class AgentlessConfigurationSource(PeriodicService):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would this make sense as a component in libdatadog built on the shared runtime?

I know there are some existing pieces of FFE in libdatadog, would it save on some duplication to add this there as well?

openfeature: Adds agentless delivery for the Feature Flagging and Experimentation
(FFE) OpenFeature provider, loading Universal Flag Configuration directly from Datadog
over HTTPS without requiring a Datadog Agent. The provider can be turned off
with ``DD_FEATURE_FLAGS_ENABLED=false``.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you want to guide towards DD_FEATURE_FLAGS_ENABLED=false? or the setting for changing the source from agentless to remote config ?

this is wording as "added support for agentless", but doesn't claim that it is now the default.

you should also consider adding the new feature, not on by default, so you can ship and validate internally or with select customers, then turn to making it on by default. it helps derisk this PR. (not a requirement for you to do it just calling it out)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants