Skip to content

[FFL-3370] Browser SDK: support flagSelection (tags + key patterns) - #426

Merged
mark-caii merged 9 commits into
mainfrom
mark.cai/FFL-3370
Oct 26, 2026
Merged

mark-caii merged 9 commits into
mainfrom
mark.cai/FFL-3370

Conversation

@mark-caii

@mark-caii mark-caii commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

FFL-3370: let @datadog/openfeature-browser users configure flagSelection: { tags, keyPatterns } so the SDK loads only matching flags, on both the precompute POST and the rules-based GET.

Changes

@datadog/flagging-core

  • canonicalizeFlagSelection(input: unknown) (configuration/flag-selection.ts, exported from the package root and marked @internal with the other shared selection helpers and limits) returns { selection?: { tags, keyPatterns }; error?: 'malformed_selection' | 'too_many_values' | 'invalid_key_pattern' | 'invalid_tag' }.
    • Each list: trim the Unicode White_Space set (the same set as Compute's Rust str::trim; not String.prototype.trim), drop empty values, dedupe, sort in UTF-8 byte order.
    • Checks run in the order malformed → too_many_values (more than 31 values in a list) → invalid_key_pattern (over 200 UTF-8 bytes) → invalid_tag (over 200 UTF-8 bytes).
    • Type-invalid input returns malformed_selection: a non-object, a list that is not an array, a sparse-array hole, a non-string value, or a string with an unpaired UTF-16 surrogate. Unknown keys are ignored. As in Compute, null for the selection or for a list counts as absent.
    • When both lists are empty, it returns no selection.
  • compareBytes moves from evaluateProtobufConfiguration.ts to utf8.ts; both places use it.

@datadog/openfeature-browser

  • flagSelection?: { tags?: readonly string[]; keyPatterns?: readonly string[] } on the init configuration and on RulesConfigurationFetchOptions, with JSDoc.
  • Precompute POST: createFlagsConfigurationFetcher canonicalizes once and sends data.attributes.flag_selection: { tags, key_patterns }, including when flaggingProxy is set. The standalone fetchPrecomputedConfiguration does not take flagSelection.
  • Rules-based GET (fetchRulesConfiguration): appends repeated selected_flag_tags and selected_flag_key_patterns params to the URL's existing query string, keeping dd_env. Values are percent-encoded per RFC 3986 (encodeURIComponent plus !'()* as uppercase %XX); URLSearchParams is not used. If the full URL is over 8192 bytes (Fastly's 8 KB URL limit, which answers longer URLs with 414 URI Too Long), the single request is sent without the selection, with reason too_many_values.
  • Both paths read DD-Flag-Selection-Ignored with response.headers?.get, once per successful response, and only when the request carried a selection.
  • The IndexedDB cache key gets a -sel-<md5 of the canonical selection> suffix when a selection is set, so providers with the same token and context but different selections never share an entry. Unselected keys are unchanged; a selected provider does not read legacy (pre-v2) entries.
  • When a selection is dropped (a limit, type-invalid input, or URL length) or the server ignores it, the SDK logs one display.error naming the limit or reason and emits one addTelemetryDebug('Flag selection dropped' | 'Flag selection ignored', { reason }). Telemetry carries only the reason, never selection values. The full configuration loads, and the provider stays READY.
  • README: a "Flag selection" section documents the option and its rules, including that a flag is selected if it matches either list, that the server may ignore very large selections (too_many_values), that older servers or disabled selection gates may return the full configuration without a warning, and that caller-supplied initialFlagsConfiguration is not filtered locally.

With no selection, or one that is empty after canonicalization, both requests are unchanged.

Out of scope

  • iOS and Android SDKs (FFL-3458, FFL-3459)
  • node-server package
  • initialFlags / startup options / NDJSON (FFL-3454)
  • Public documentation and the proxy CORS note (FFL-3457)
  • Backend changes (FFL-3393, FFL-3397)
  • React and Angular wrappers (they take a provider instance, so flagSelection passes through)
  • flagSelection on the standalone fetchPrecomputedConfiguration
  • Rules-based GET coverage in ffe-dogfooding (the harness uses only the precompute provider; unit tests cover the GET)

Test instructions

  • yarn build && yarn test && yarn lint
  • packages/core/test/configuration/flag-selection.spec.ts: canonicalization, White_Space trimming, UTF-8 byte-order sort, each limit at the 200/201-byte and 31/32-value boundaries, type-invalid input (including lone high and low surrogates), well-formed surrogate pairs accepted, unknown keys ignored, sparse-array holes, readonly (as const) lists type-check, check order.
  • packages/browser/test/transport/fetchConfiguration.spec.ts: POST body (including flaggingProxy), GET params via RulesConfigurationFetchOptions, encoding (ffe-* → ffe-%2A, a b → a%20b, (x)! → %28x%29%21), URL at 8192 bytes and over, the Ignored header on both paths and not read without a selection, telemetry emitted without values.
  • packages/browser/test/cache/indexeddb-flags-cache.spec.ts and packages/browser/test/openfeature/provider-persistence.spec.ts: different selections get different cache keys, the same canonical selection recovers its snapshot, the unselected key is unchanged.
  • packages/core/test/configuration/flag-selection-vectors.spec.ts: each sdk expectation in the shared flag-selection vectors checked against canonicalizeFlagSelection; rawQuery cases have no sdk expectation and are skipped.
  • fetchConfiguration.spec.ts also covers a request retried through withRetry: the final response's Ignored header is reported once on both paths. The precompute POST reports a dropped selection once per provider; the rules-based GET reports it on every call.
  • End-to-end verification in ffe-dogfooding against staging: see Validation.

Shared tests

Shared flag-selection vectors from ffe-system-test-data #50 (FFL-3470), through a separate submodule, packages/core/test/ffe-flag-selection-data, pinned to ffe-system-test-data e8777633 (#50 merged). The main packages/core/test/ffe-system-test-data pin is unchanged because e8777633 breaks an existing flags-v1 opt-out case.

Validation

End-to-end in ffe-dogfooding (headless Chromium app-browser, locally built SDK) against staging:

  • SDK: this branch at 1a0ce73
  • Compute (edge-assignments): FFL-3393 build 161375a on shared staging
  • ffe-service: test drive from mark.cai/FFL-3393 at 4c48c574
  • Expected sets: all staging flags for org 2 with their tags (GET /api/v2/feature-flags), intersected with the no-selection baseline. A flag is expected if it has any requested tag (exact match) or its whole key matches the glob.
Case Expected Actual
No selection (baseline) 794 794
Tag team:feature-flagging 22 22
Two tags 20 20
abin-* 11 11
*-v2 2 2
*flag* 132 132
Tag + abin-* 41 41
Zero match 0 (READY, no error) 0 (READY, no error)
32 tags 794, 1 display.error, no flag_selection sent same
201-byte tag 794, 1 display.error, no flag_selection sent same

Every case matched the expected key set exactly. Requests carried canonical flag_selection bodies, and responses carried Access-Control-Expose-Headers: DD-Flag-Selection-Ignored.

Not covered end-to-end: the rules-based GET (the harness uses only the precompute provider; jest covers it), flag values (key sets only), and a server-side Ignored response (the SDK dropped invalid selections before sending; jest covers it).

Checklist

  • Updated Documentation
  • Tested locally
  • Tested on staging
  • Added unit tests for this change.
  • Added e2e/integration tests for this change.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

OpenFeature Browser Provider Bundle Sizes

Measured from the Vite production output after installing packed @datadog/flagging-core and @datadog/openfeature-browser tarballs.

Both scenarios initialize an OpenFeature provider, evaluate a boolean flag, change context, and evaluate again. Telemetry is disabled for DatadogProvider; no tracking hooks are registered for DatadogCoreProvider. DatadogProvider fetches precomputed assignments for each context; DatadogCoreProvider receives rules from fetchRulesConfiguration once and evaluates locally.

Sizes include OpenFeature and the same small scenario harness. Configuration responses are supplied by Playwright and are not bundled. These are complete scenario JS sizes, not configuration payload sizes or isolated provider/Protobuf costs; the difference between rows is not a decoder-only delta.

Scenario HTML JS Assets Raw JS Gzip JS Protobuf Markers
DatadogProvider (precomputed fetching) provider.html 18 101.4 KiB 37.3 KiB no
DatadogCoreProvider + fetchRulesConfiguration core-provider.html 15 170.5 KiB 53.9 KiB yes

Dependency checks passed: no Protobuf markers in default/precomputed scenarios; markers present in rules-based scenarios.

OpenFeature Browser Tracking Hook Bundle Sizes

Synthetic entrypoints import and call tracking hook factories from the packed @datadog/openfeature-browser/rules-based ESM package. Deltas are measured against the no-hook baseline from the same Vite production build.

Scenario HTML JS Assets Raw JS Raw Δ Gzip JS Gzip Δ
tracking hook baseline tracking-baseline.html 6 2.7 KiB 0 B 1.6 KiB 0 B
exposure logging hook tracking-exposure.html 13 68.8 KiB +66.1 KiB 26.2 KiB +24.6 KiB
evaluation logging hook tracking-evaluation.html 13 65.1 KiB +62.4 KiB 24.8 KiB +23.2 KiB
RUM tracking hook tracking-rum.html 9 6.7 KiB +4.1 KiB 3.6 KiB +2.0 KiB
all tracking hooks tracking-all.html 16 73.1 KiB +70.4 KiB 28.2 KiB +26.6 KiB

OpenFeature Node Server Bundle Sizes

Independent Webpack production builds of the installed core and Node SDK tarballs, targeting Node 18 with ESM (module export condition). Node built-ins remain external. OpenFeature, dd-trace, and configuration fixtures are not bundled.

Scenario Raw JS Gzip JS Protobuf Modules
Node provider only 39.1 KiB 12.5 KiB no
SSR helpers only (fetch client rules + serialize) 80.8 KiB 23.4 KiB yes
Node provider + SSR helpers 118.8 KiB 34.7 KiB yes

Adding SSR helpers to the provider adds 79.7 KiB raw / 22.2 KiB gzip in this build. This includes fetching, parsing, and serialization, not just the decoder. SSR helpers fetch rules for the browser, not for configuring that provider (which uses its existing JSON API).

These are bundler sizes, not npm install sizes, unbundled Node require/import costs, payload sizes, or latency. Gzip compares compressed artifacts. The browser scenarios include OpenFeature and a different harness, so their sizes are not directly comparable.

Dependency checks passed: no Protobuf runtime or generated schema modules in the provider-only bundle; present in both SSR bundles (checked using Webpack module statistics).

This report shows current PR artifact sizes only; it does not compare against the base branch.

@aarsilv aarsilv 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.

🤖 Comment from Claude working with Aaron:

Review of flag selection in the browser SDK, read together with ddoghq/dd-source#132875 (Compute and ffe-service). The inline comments carry a priority tag ([P1] should fix, [P2] worthwhile, [P3] optional), plus one heads-up. Folds marked "tested" ran on this branch merged with main.

Comment thread packages/browser/README.md Resolved

@leoromanovsky leoromanovsky left a comment

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.

🤖 Review from Codex working with Leo Romanovsky:

Approved at ef40519. No blocking implementation defects found in the browser flag-selection change; the inline P2 is a non-blocking README clarification.

Validation: 1,127 core tests and 562 browser tests passed, browser typecheck passed, and four additional rollout probes passed. Removing selection cache isolation made the relevant probe fail; restoring it passed. This approval covers the SDK change, not backend deployment or gate activation.

Comment thread packages/browser/README.md Resolved
mark-caii and others added 9 commits October 26, 2026 13:49
Add flagSelection to the browser init configuration and to
RulesConfigurationFetchOptions. canonicalizeFlagSelection in
@datadog/flagging-core trims Unicode White_Space, drops empty values,
dedupes, sorts in UTF-8 byte order, and validates the 31-value and
200-byte limits in Compute's check order.

The precompute POST sends data.attributes.flag_selection, including
through flaggingProxy. The rules-based GET appends repeated
selected_flag_tags and selected_flag_key_patterns params, RFC 3986
encoded, and drops the selection when the URL would exceed 8192 bytes.
Dropped selections and DD-Flag-Selection-Ignored responses log one
display.error and emit one telemetry debug event with only the reason.

Move compareBytes to utf8.ts for reuse.

FFL-3370

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
FFL-3370

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Unknown keys in flagSelection no longer make the selection malformed.
Strings with unpaired UTF-16 surrogates are type-invalid and return
malformed_selection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Pin ffe-system-test-data to PR #50's head (99cd41e) and check each
vector's sdk expectation against canonicalizeFlagSelection. rawQuery
cases carry no sdk expectation and are skipped. The pin moves to

FFL-3370, FFL-3470

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… calls

With withRetry, a retried request whose final response carries
DD-Flag-Selection-Ignored logs and emits telemetry once, on both the
precompute POST and the rules-based GET. The rules-based GET reports a
dropped selection on every call.

FFL-3370

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Key the IndexedDB snapshot by the canonical selection when one is set;
  unselected keys are unchanged and selected providers skip legacy entries.
- Type FlagSelection lists as readonly string[].
- Treat sparse-array holes as malformed_selection instead of throwing.
- Mark the shared selection helpers @internal.
- Cite Fastly's 8 KB URL limit for MAX_RULES_URL_BYTES.
- Read DD-Flag-Selection-Ignored only when the request carried a selection.
- README: either-list matching and server-side too_many_values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Older servers or disabled selection gates may return the full config, and initialFlagsConfiguration is not filtered locally.

Environment: Datadog workspace

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@mark-caii
mark-caii merged commit 886f732 into main Oct 26, 2026
5 checks passed
@mark-caii
mark-caii deleted the mark.cai/FFL-3370 branch October 26, 2026 13:57
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.

3 participants