Skip to content

feat(openfeature): add flag_selection provider option - #21572

Draft
mark-caii wants to merge 2 commits into
mark.cai/FFL-3624from
mark.cai/FFL-3571
Draft

mark-caii wants to merge 2 commits into
mark.cai/FFL-3624from
mark.cai/FFL-3571

Conversation

@mark-caii

@mark-caii mark-caii commented Nov 6, 2026 •

Copy link
Copy Markdown

Description

Jira: FFL-3571

Stacked on #21571, which fixes evaluation of contexts with nested attributes and pins the fixture snapshot to ffe-system-test-data e8777633. The base branch is mark.cai/FFL-3624, so this diff shows only flag selection; it retargets to main once #21571 merges.

Motivation

Services that use only part of an organization's flags still download, parse and hold the whole flag configuration. Flag selection lets a service ask the agentless configuration endpoint for only the flags it needs, by tag or by key pattern, so it transfers and keeps less configuration. Without a selection nothing changes.

This brings the server Python provider in line with the client SDKs: the browser (@datadog/openfeature-browser 2.2.0, openfeature-js-client#426) for option semantics, canonicalization, limits, request encoding and log strings, and iOS (dd-sdk-ios#3344) and Android (dd-sdk-android#4031) for sanitizing the ignored-selection response header. The two counts correspond to the client SDKs' flag selection dropped and ignored debug events.

Changes

  • DataDogProvider(flag_selection={"tags": [...], "key_patterns": [...]}): a keyword-only constructor argument, canonicalized once in __init__ (the caller's lists are copied). No environment variable.
  • New ddtrace/internal/openfeature/_flag_selection.py:
    • Canonicalization: trims Unicode White_Space with an explicit character set (not str.strip(), which also strips U+001C to U+001F), drops empty values and duplicates, and sorts by UTF-8 bytes.
    • Validation, stopping at the first failure: malformed_selection (a top level that is not a mapping; a list that is not a list, tuple, set or frozenset, so a bare str is rejected; a non-str element, None included; a surrogate code point), then too_many_values (more than 31 values in a list), then invalid_key_pattern and invalid_tag (more than 200 UTF-8 bytes).
    • Request: each value is sent as a repeated selected_flag_tags / selected_flag_key_patterns query parameter, tags first, encoded with quote(value, safe=""), and merged into the existing query, including a custom base URL's. When the whole request URL, fragment included, would exceed 8192 bytes, the request goes out without the selection and reports too_many_values.
    • One selection per process: every provider writes the same module-level snapshot, so the first provider to start fetching claims the selection (no selection claims "all flags"). A later provider with a different selection logs one warning per process and requests the claimed one. The claim is reference-counted and released when the last provider shuts down. After a release, a provider with a different selection does not report READY on the snapshot the previous selection loaded; it waits for its own first poll.
    • Diagnostics, at error level through the tracer logger, with reasons and limits only and never selection values:
      • flag_selection was not sent (<reason>: <limit>). Loading the full flag configuration. once per provider initialization;
      • flag_selection was ignored by the server (<reason>). Loaded the full flag configuration. once per process per reason, after the configuration is applied.
    • These messages are exempt from the per-call-site log rate limiter, so a second reason within 60 seconds is not dropped.
    • Telemetry: flag_selection.dropped and flag_selection.ignored counts in the tracers namespace, tagged only with reason, once per process per reason.
    • The once-per-process guards reset in a forked child, so each worker reports its own diagnostics. The claim stays with the inherited providers.
  • _agentless_source.py: reads DD-Flag-Selection-Ignored only when a selection was sent and the response is a 200, never on a 304, an error or a request without a selection. An empty value is absent; anything other than one of the five known reasons byte for byte (malformed_selection, too_many_values, invalid_key_pattern, invalid_tag, tag_load_failed) is reported as unknown, including trailing whitespace or a comma-joined repeated header.
  • _source_selection.py: with flags delivered through Remote Config and a selection set, logs one warning per process (flag_selection is not supported when flags are delivered through Remote Config. Loading the full flag configuration.) and loads the full configuration.
  • scripts/update-ffe-fixtures.py also copies and checks flag-selection/selection-vectors.json, so the pinned snapshot now includes the shared selection vectors.
  • Release note.

Decisions

  • Option keys follow Python naming: tags and key_patterns. Unknown keys, keyPatterns included, are ignored, as the browser ignores unknown keys.
  • The malformed_selection limit text is the browser's sentence with this SDK's names: flag_selection must be a dict with optional tags and key_patterns string arrays.
  • DD-Flag-Selection-Ignored is read only on a 200 because that is the only 2xx status this source applies.
  • flag_selection.dropped is counted where the "not sent" error is logged, that is, for the provider whose own selection could not be sent. A later provider with a different selection gets only the second-selection warning.
  • Canonicalization is pure Python. The native extension's FFE binding covers evaluation only, and the 31-value, 200-byte and 8192-byte limits are checked here before sending.
  • The selection list types are checked at runtime because Python does not enforce the annotation.
  • Out of scope: an environment variable, per-request or runtime selection changes, local filtering, tag or pattern syntax checks, persistence, filtering in Remote Config mode, and a shared poller across providers.

Testing

  • tests/openfeature/test_flag_selection.py:
    • all 44 shared vectors loaded; the 38 SDK cases run through the provider's request construction, and the 6 raw-query cases (server-side percent-decoding) are skipped by name;
    • canonicalization: U+001C to U+001F kept, U+FEFF and U+200B kept, UTF-8 byte order;
    • encoding with safe="";
    • malformed shapes and precedence; unknown keys ignored;
    • the 8192/8193-byte boundary for managed and custom URLs, fragment included; the custom-base-URL merge; an unchanged request without a selection;
    • exact log strings and limit texts with no selection values in logs; "not sent" once per provider initialization; two reasons within the rate-limit window both logged;
    • header sanitization, reported once per reason and only after the configuration is applied, and never read on a 304, an error or without a selection;
    • telemetry calls;
    • first selection wins across providers, the no-selection claim, the second-selection warning once, a fresh claim after the last shutdown, and no READY on the previous selection's snapshot;
    • the Remote Config warning;
    • guard reset in a forked child;
    • no exception reaching the application for malformed values, including a mapping or list that raises when read.
  • tests/internal/test_update_ffe_fixtures.py: the selection vectors are copied, symlinks are rejected, and the vectors are validated.

Validation Evidence

Dogfooding and system-tests validation have not run yet. They need the ffe-dogfooding Python app to pass the selection through and the parametric ffe_start wiring for Python; both are follow-ups that build on this branch.

Risks

  • With two providers the process runs two pollers; both request the claimed selection's URL.

Additional Notes

The flag_selection.dropped and flag_selection.ignored metric names are new.

DataDogProvider(flag_selection={"tags": [...], "key_patterns": [...]})
asks the agentless configuration endpoint for only the selected flags.
The selection is canonicalized (Unicode White_Space trim, dedupe, UTF-8
byte order) and validated against the 31-value, 200-byte and 8192-byte
URL limits before it is sent; an invalid selection loads every flag and
logs the reason. The first provider to start fetching claims the
process selection until the last provider shuts down. The ignored-
selection response header is sanitized and reported once per reason
after the configuration is applied, with flag_selection.dropped and
flag_selection.ignored counts. The fixture script also copies the
shared flag-selection vectors from the pinned snapshot.

Jira: FFL-3571

Environment: Datadog workspace
@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against mark.cai/FFL-3624 using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/internal/openfeature/_agentless_source.py                       @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_flag_selection.py                         @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_provider.py                               @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_source_selection.py                       @DataDog/feature-flagging-and-experimentation-sdk
docs/contributing-testing.rst                                           @DataDog/python-guild
releasenotes/notes/openfeature-flag-selection-f45645ce7c159d85.yaml     @DataDog/apm-python
scripts/update-ffe-fixtures.py                                          @DataDog/feature-flagging-and-experimentation-sdk
tests/internal/test_update_ffe_fixtures.py                              @DataDog/feature-flagging-and-experimentation-sdk
tests/openfeature/conftest.py                                           @DataDog/feature-flagging-and-experimentation-sdk
tests/openfeature/ffe-system-test-data/flag-selection/selection-vectors.json  @DataDog/feature-flagging-and-experimentation-sdk
tests/openfeature/test_flag_selection.py                                @DataDog/feature-flagging-and-experimentation-sdk

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 89 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 89 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=120)
ddtrace.llmobs._integrations.base -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)
ddtrace.llmobs._integrations.typesafe -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)
ddtrace.llmobs._integrations.langchain -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)
ddtrace.llmobs._integrations.bedrock_agents -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@pr-commenter

pr-commenter Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-11-06 18:52:23

Comparing candidate commit e9b1ba2 in PR branch mark.cai/FFL-3571 with baseline commit f9a7155 in branch mark.cai/FFL-3624.

📊 Benchmarking dashboard

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

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

@mark-caii mark-caii added the crucible: reviewing A Crucible review has started; this PR has reviewer attention. label Nov 6, 2026 — with ddtool CLI
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

crucible: reviewing A Crucible review has started; this PR has reviewer attention.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant