Skip to content

feat(browser): add evaluation telemetry opt-out - #418

Open
leoromanovsky wants to merge 5 commits into
mainfrom
leoromanovsky/browser-evaluation-telemetry-opt-out
Open

leoromanovsky wants to merge 5 commits into
mainfrom
leoromanovsky/browser-evaluation-telemetry-opt-out

Conversation

@leoromanovsky

@leoromanovsky leoromanovsky commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Applications need to stop evaluation telemetry for selected subjects while continuing to serve flags. The existing provider-wide tracking switches cannot express that decision in the evaluation context.

Changes and Decisions

Add enableEvaluationTelemetryOptOutParameter to the online and portable browser providers. When enabled, only boolean true at dd_evaluation_telemetry_opt_out suppresses new evaluation events, counts, and automatic RUM flag annotations.

flowchart TD
    Context[OpenFeature context] --> Provider[Datadog provider]
    Provider --> Result[Flag result unchanged]
    Provider --> Exposures[Experiment exposures unchanged]
    Provider --> Policy{Opt-out enabled and attribute true?}
    Policy -->|Yes| Suppress[Suppress evaluation telemetry and RUM flag annotations]
    Policy -->|No| Track[Record evaluation telemetry and RUM flag annotations]
Loading
import { DatadogProvider } from '@datadog/openfeature-browser'
import { OpenFeature } from '@openfeature/web-sdk'

await OpenFeature.setProviderAndWait(
  new DatadogProvider({
    clientToken: 'your-client-token',
    env: 'production',
    enableEvaluationTelemetryOptOutParameter: true,
  }),
  {
    targetingKey: 'user-123',
    dd_evaluation_telemetry_opt_out: true,
  }
)

const enabled = OpenFeature.getClient().getBooleanValue('new-feature', false)
  • OpenFeature.setContext() uses normal context-change handling. The attribute remains in assignment requests, cached context, and exposure attributes.
  • Targeting rules can use the attribute. Portable providers support the same option and their composed Datadog tracking hooks honor it.
  • Missing, false, and non-boolean values preserve telemetry. Default behavior, experiment participation, exposures, customer hooks, queued events, and unrelated telemetry remain unchanged.
  • Reuse the existing core context comparator without changing its behavior. Release the core export before the browser package and update its exact dependency pin through the normal release process.
  • Keep each subject's latest reporting decision during refresh. Switching subjects cannot enable reporting for opted-out assignments that remain active. An explicit opt-in for that subject takes effect without waiting for new assignments.

Validation

Tested commit Validation Result
DataDog/openfeature-js-client@a7aeeae1bc3dbdb142e5ffed170c2b06e4034ffc Local integration: packed SDK, direct/composite providers, subject changes, evaluation uploads, and preserved exposures; Chromium 29 passed; 0 failed or skipped. Intercepted transport and a RUM callback substitute; no full RUM SDK or ingestion validation.

@github-actions

github-actions Bot commented Oct 15, 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 20 84.0 KiB 32.0 KiB no
DatadogCoreProvider + fetchRulesConfiguration core-provider.html 16 150.5 KiB 47.6 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 14 57.0 KiB +54.3 KiB 22.1 KiB +20.5 KiB
evaluation logging hook tracking-evaluation.html 15 54.2 KiB +51.6 KiB 21.3 KiB +19.7 KiB
RUM tracking hook tracking-rum.html 11 6.1 KiB +3.5 KiB 3.5 KiB +1.9 KiB
all tracking hooks tracking-all.html 18 62.3 KiB +59.6 KiB 24.7 KiB +23.1 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 36.3 KiB 11.5 KiB no
SSR helpers only (fetch client rules + serialize) 80.1 KiB 23.1 KiB yes
Node provider + SSR helpers 115.9 KiB 33.6 KiB yes

Adding SSR helpers to the provider adds 79.7 KiB raw / 22.1 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.

@leoromanovsky
leoromanovsky marked this pull request as ready for review October 15, 2026 12:20
@leoromanovsky
leoromanovsky requested a review from a team as a code owner October 15, 2026 12:20
Comment thread packages/browser/src/openfeature/provider.ts

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

I think it's worth taking a look at that P2 before merging, but otherwise looks good!

let currentContext: EvaluationContext = {}
const policy = {
setContext(context: EvaluationContext) {
currentContext = { ...context }

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.

Non-blocking optimization: snapshot the opt-out boolean instead of copying the whole context.

setContext spreads every attribute even when this capability is disabled, although the retained copy is only consulted for one boolean. Keeping that decision alone would avoid the extra property copy on context updates while preserving the requested/installed-context checks.

Executed evidence and a tested minimization

I executed the production policy and EVP hook/aggregator path at 1b8fe37. With 1,000 unrelated getter-backed attributes, the current policy read all 1,000 on each update, including with the capability disabled; the boolean-only variant read none. The variant passed the policy-level enabled/disabled, requested/current/installed-context and admission checks. The test also preserves the spread's behavior for inherited and non-enumerable properties and exercises an enumerable getter and an ordinary Proxy.

This is an extra shallow copy on context changes, not a deep copy per evaluation or a measured throughput regression. The provider already retains context values. Arbitrary side-effectful or throwing getters/Proxy traps are outside the equivalence claim: deliberately skipping unrelated getters can change those side effects/errors.

The existing registration and return of policy can stay as they are; the tested replacement for its retained state is:

let currentContextOptedOut = false
const policy = {
  setContext(context: EvaluationContext) {
    currentContextOptedOut =
      enabled &&
      Object.prototype.propertyIsEnumerable.call(context, attribute) &&
      context[attribute] === true
  },
  allowsTelemetry(context: EvaluationContext) {
    return (
      !enabled ||
      !(
        isOptedOut(context) ||
        currentContextOptedOut ||
        (getTrackingContext && isOptedOut(getTrackingContext()))
      )
    )
  },
}

export function withEvaluationTelemetryPolicy<T extends Hook>(hook: T): T {
const admissions = new WeakMap<HookContext, boolean>()
const allowsTelemetry = (context: HookContext) =>
policies.get(context.providerMetadata)?.allowsTelemetry(context.context) ?? true

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.

[P2] Preserve the opt-out policy through OpenFeature MultiProvider before allowing telemetry.

The policy lookup uses the registered provider metadata object. OpenFeature's built-in MultiProvider supplies its own metadata, so this lookup misses and ?? true permits telemetry even when the Datadog child enabled the control and the context contains boolean true.

Executed reproduction and suggested direction

At 1b8fe37, I ran OpenFeature web-sdk 1.7.3/core 1.9.2 with the production DatadogCoreProvider, policy and RUM hook. The direct provider produced 0 automatic RUM callbacks; new MultiProvider([{ provider: inner }]) produced 1 for the same opted-out context. The reproduced setup uses DatadogCoreProvider with a separately composed client RUM hook. The returned flag value stayed unchanged.

The evaluator/serializer and DD_RUM callback were controlled; this did not run the full RUM SDK or Datadog ingestion. EVP uses the same guard, but its MultiProvider uploads were not separately executed.

Please retain an explicit association between a tracking hook and its Datadog provider's policy across composition, and add a regression test using the actual MultiProvider. The fix should also preserve independent policies when multiple children have different settings; a blanket change of the fallback would change behavior for providers without this capability.

This branch has not been deployed

No deployments
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