Skip to content

[FFL-2926] Add opt-in tracking hooks for browser CoreProvider - #349

Merged
sameerank merged 23 commits into
mainfrom
sameerank/FFL-2926/offline-tracking-parity
Sep 21, 2026
Merged

sameerank merged 23 commits into
mainfrom
sameerank/FFL-2926/offline-tracking-parity

Conversation

@sameerank

@sameerank sameerank commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • expose DatadogCoreProvider from @datadog/openfeature-browser/rules-based as the minimal browser provider for application-managed flag configuration
  • rename the shared internal provider base to DatadogProviderBase so DatadogCoreProvider can be the concrete customer-facing provider
  • leave a convenience DatadogOfflineProvider out of this PR; it can be considered later as a wrapper that auto-registers side effects if customer feedback calls for it
  • expose separate Datadog tracking hook factories:
    • createDatadogExposureLoggingHook(options)
    • createDatadogEvaluationLoggingHook(options)
    • createDatadogRumTrackingHook()
  • keep composeDatadogTrackingHooks(...hooks) as a small controller composition helper: { hooks, initialize, shutdown }
  • make hook inclusion import/call-based instead of boolean-option-based, so unused tracking modules are easier for bundlers to tree-shake
  • report packed-app bundle-size deltas for each tracking hook scenario in CI
  • keep core evaluation telemetry-free unless the application registers the returned OpenFeature hooks
  • support the same tracking behavior for precomputed and rules-based local evaluation
  • keep exposure deduplication persistent by default, with telemetry-scoped storage shared by the standalone implementation and DatadogProvider
  • handle exposure re-emission internally by tying exposure deduplication to the active DatadogCoreProvider configuration identity
  • document and test domain/client-scoped OpenFeature hook registration

Why

DatadogCoreProvider is intended to be the smallest browser building block over @datadog/flagging-core: it evaluates a supplied flags configuration and does not fetch, poll, register hooks, or send telemetry by itself.

The portable-configuration RFC prioritizes minimal, composable building blocks before provider-level convenience options. This PR therefore avoids adding tracking constructor options or an all-in-one offline convenience provider. Instead, Datadog side effects are exposed as OpenFeature hooks that the application registers explicitly.

The API uses separate hook factories instead of boolean opt-outs on one all-in-one factory. This makes the selected tracking integrations visible in imports and call sites, and avoids forcing apps that omit RUM/exposure/evaluation tracking to import those hook implementations through runtime boolean parameters.

The exposure hook is intended to be paired with DatadogCoreProvider from the same @datadog/openfeature-browser/rules-based entrypoint. The provider stamps exposure-eligible evaluations with an internal configuration identity, and the exposure hook uses that identity only for deduplication. This keeps resetExposureCache() out of the public API while still allowing exposures to be emitted again when the active configuration changes.

A future DatadogOfflineProvider could layer on top of these primitives as a convenience provider that configures delivery and auto-registers standard Datadog side effects for a simpler developer experience. This PR keeps that as a follow-up design rather than committing to that API shape now.

Jira: FFL-2926

API proposal

Minimal evaluation only:

import { OpenFeature } from "@openfeature/web-sdk"
import {
  configurationFromString,
  DatadogCoreProvider,
} from "@datadog/openfeature-browser/rules-based"

const configuration = configurationFromString("...flags configuration string...")

const provider = new DatadogCoreProvider()
provider.setConfiguration(configuration)

await OpenFeature.setProviderAndWait("datadog-core", provider, { targetingKey: "user-123" })

const client = OpenFeature.getClient("datadog-core")
const enabled = client.getBooleanValue("new-checkout", false)

Optional Datadog tracking hooks:

import { OpenFeature } from "@openfeature/web-sdk"
import {
  configurationFromString,
  createDatadogEvaluationLoggingHook,
  createDatadogExposureLoggingHook,
  createDatadogRumTrackingHook,
  composeDatadogTrackingHooks,
  DatadogCoreProvider,
} from "@datadog/openfeature-browser/rules-based"

const configuration = configurationFromString("...flags configuration string...")

const trackingOptions = {
  clientToken: "client-token",
  applicationId: "application-id",
  site: "datadoghq.com",
  service: "storefront",
  env: "production",
}

const tracking = composeDatadogTrackingHooks(
  createDatadogExposureLoggingHook(trackingOptions),
  createDatadogEvaluationLoggingHook(trackingOptions),
  createDatadogRumTrackingHook()
)

await tracking.initialize()

const provider = new DatadogCoreProvider()
provider.setConfiguration(configuration)

await OpenFeature.setProviderAndWait("datadog-core", provider, { targetingKey: "user-123" })

const client = OpenFeature.getClient("datadog-core")
client.addHooks(...tracking.hooks)

// Later, when replacing the active flag configuration before another evaluation:
provider.setConfiguration(nextConfiguration)

// When this client no longer needs tracking:
client.clearHooks()
await tracking.shutdown()

composeDatadogTrackingHooks() combines the supplied controllers' hooks and lifecycle methods. The name emphasizes composition: individual factories create the integrations, while this helper does not initialize resources or register hooks with OpenFeature automatically. Applications can also use individual controllers without this helper.

To exclude an integration, omit that hook factory from both the import list and the composeDatadogTrackingHooks() call.

tracking.initialize() loads the exposure deduplication cache and starts the included hooks' transports, timers, and subscriptions. Exposure/evaluation factories do not start these resources at construction, and their hooks do not collect events until initialization completes. If only hooks without lifecycle are included, such as RUM tracking, initialization and shutdown are no-ops. Omitting the exposure factory creates no exposure hook or exposure cache.

tracking.shutdown() flushes pending exposure/evaluation events, stops aggregation and batch timers, and removes subscriptions. Requests already handed to the browser transport may still complete or retry. Both methods are idempotent, serialize pending setup and teardown, and allow reinitialization without clearing persisted exposure deduplication. Individual exposure/evaluation controllers also expose both methods.

Applications own manually registered hooks. Clearing client hooks or removing DatadogCoreProvider does not dispose of their resources automatically; unregister them and explicitly call tracking.shutdown(). The regular DatadogProvider owns its built-in tracking and cleans it up through OpenFeature's onClose() provider lifecycle. This preserves the core provider's evaluation-only responsibility.

There is no public exposure-cache reset method: exposure deduplication is tied to the active DatadogCoreProvider configuration identity, so replacing the provider configuration allows exposures for the new configuration to be emitted without clearing application-managed hook state.

Configuration identity excludes retrieval metadata (fetchedAt and etag). For rules-based configurations, it also excludes the server's per-build createdAt timestamp, matching the backend's semantic Fingerprint() behavior. Refetching otherwise unchanged configurations therefore keeps exposures deduplicated, while content changes allow new exposures. These fields are excluded only from deduplication identity; the configuration object and portable wire representation retain them.

Persistent caches are scoped by a hash of telemetry site, client token, proxy URL, environment, application, service, and source. Both the standalone hook and regular provider use this scope, and clearing Chrome storage removes only that cache's keys. Raw tokens are not stored in cache keys. Legacy unscoped entries are not reused, which can cause a one-time repeat exposure after upgrading. Function-valued telemetry proxies use memory-only deduplication because callback identity cannot reliably identify the destination selected through closure state. The scope matches the browser-core 7.1.0 transport options now on main; the removed internalAnalyticsSubdomain option is no longer included.

Cross-SDK shape

The public shape is intentionally a small provider plus tracking controller rather than a browser-specific provider option:

const provider = new DatadogCoreProvider()
provider.setConfiguration(configuration)

const tracking = composeDatadogTrackingHooks(
  createDatadogExposureLoggingHook(options),
  createDatadogEvaluationLoggingHook(options)
)

await tracking.initialize()
client.addHooks(...tracking.hooks)

// Later, when these hooks are no longer needed:
client.clearHooks()
await tracking.shutdown()

This is meant to keep the customer-facing lifecycle portable across SDKs. Browser and React Native can expose the same initialization, hook-registration, and shutdown flow while using different platform implementations behind the scenes. A React Native implementation should release only resources owned by these hooks, not shut down a shared native SDK.

In the browser SDK, exposure/evaluation hook factories own browser tracking configuration, browser transport setup, and browser exposure deduplication storage. In React Native, the same controller shape could delegate tracking side effects to the existing native DdFlags bridge/native SDK configuration instead of browser transports. If React Native needs native exposure deduplication to account for configuration replacement, that can be handled behind the provider/hook implementation boundary rather than exposing a customer-facing cache reset API.

Hook scope

OpenFeature gives applications several hook registration scopes. The Web SDK hooks docs say hooks can be registered globally, on a client, or for a single flag invocation. The Web SDK domains docs define domains as the mechanism for binding clients to providers, and the provider docs call out domain-scoped providers for applications that use multiple providers. The hooks specification also defines hook registration and ordering across the API, client, provider, and invocation levels.

Based on those docs, this PR recommends registering Datadog tracking hooks on the domain-scoped OpenFeature client when an application has multiple providers. Global hooks are OpenFeature-compliant, but they would run for every evaluation in the application. Invocation hooks are also valid, but too repetitive for telemetry that should accompany every evaluation from the Datadog client. Domain/client-scoped registration keeps Datadog telemetry attached to evaluations made through the Datadog provider without affecting other providers.

Behavior and compatibility

  • new DatadogCoreProvider() is the new rules-based entrypoint provider construction path for application-managed configuration
  • DatadogCoreProvider evaluates both precomputed/static-context and rules-based/dynamic-context configurations supplied through setConfiguration()
  • DatadogCoreProvider fetches nothing, polls nothing, registers no hooks, and sends no telemetry by itself
  • hook registration is application-controlled through OpenFeature; this PR recommends domain/client-scoped hooks for apps with multiple providers based on OpenFeature hook and domain scoping docs
  • createDatadogExposureLoggingHook(), createDatadogEvaluationLoggingHook(), and createDatadogRumTrackingHook() can be imported and composed independently
  • exposure deduplication uses Chrome storage when available, then localStorage, then memory; function-valued telemetry proxies use memory-only storage
  • exposure deduplication is isolated by telemetry scope for both standalone hooks and DatadogProvider
  • exposure deduplication accounts for DatadogCoreProvider configuration identity internally: changed configurations can emit fresh exposures, while refetching unchanged content remains deduped
  • tracking hooks observe the OpenFeature context used for local evaluation
  • successful precomputed and rules evaluations carry existing allocation, variation, context, logging, and timestamp metadata
  • defaults and failed evaluations do not emit exposure events
  • tracking lifecycle/cache failures do not affect local evaluation or provider lifecycle
  • the existing DatadogProvider constructor and configuration fetching remain unchanged; built-in tracking starts during initialization, shuts down through onClose(), and uses scoped exposure caches
  • the default browser entrypoint still does not load Protobuf-ES or export/register DatadogCoreProvider

Validation

  • latest composition-helper rename: 328 browser unit tests, browser typecheck/build, 4 packed-package browser smoke tests and the bundle report, formatting, and Biome passed; the separate local toy app also passed its build and all 7 mocked browser checks
  • validated the merge with Node 24.14.1 and the latest main lockfile
  • full monorepo unit tests passed: core 681, browser 326, Node server 364 (1,371 total)
  • regression coverage for unchanged refetches (including rules build-timestamp changes), changed-content re-emission, preservation of configuration metadata and wire content, lazy startup, idempotent teardown/reinitialization, pending cache reads, provider-owned shutdown, multiple telemetry scopes in both localStorage and Chrome storage, and namespace-specific Chrome storage reads
  • yarn workspace @datadog/openfeature-browser typecheck
  • yarn workspace @datadog/openfeature-browser build
  • packed browser install smoke passed, including 4 Playwright smoke tests and the entrypoint/tracking-hook bundle-size report
  • prettier --check packages/browser/README.md packages/browser/src packages/browser/test test-app/src/tracking*.ts scripts/report-entrypoint-bundle-sizes.js test-app/vite.config.ts test-app/*.html
  • biome check packages/browser/src packages/browser/test --diagnostic-level=error
  • git diff --check

@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch 2 times, most recently from 4601849 to b077ce1 Compare August 6, 2026 20:56
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch 2 times, most recently from c5671fe to d22ce15 Compare August 6, 2026 21:30
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from b077ce1 to 35e7307 Compare August 6, 2026 21:30
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch from d22ce15 to e4d574a Compare August 6, 2026 23:24
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 35e7307 to d45c3b0 Compare August 6, 2026 23:25
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch from e4d574a to 592713b Compare August 7, 2026 00:53
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from d45c3b0 to dfaa31f Compare August 7, 2026 00:53
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch from 592713b to d22d4e7 Compare August 7, 2026 01:24
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from dfaa31f to 26553ff Compare August 7, 2026 01:25
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch from d22d4e7 to 9d718e1 Compare August 9, 2026 14:03
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 26553ff to e0aeed0 Compare August 9, 2026 14:06
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from e0aeed0 to 880ef0f Compare August 11, 2026 14:21
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch 2 times, most recently from 1adf3ea to 7beba56 Compare August 13, 2026 22:52
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 880ef0f to 2304a11 Compare August 13, 2026 22:52
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch from 7beba56 to e1d30d6 Compare August 13, 2026 23:28
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 2304a11 to 4c38e3f Compare August 13, 2026 23:28
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch from e1d30d6 to 9160faa Compare August 18, 2026 15:21
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 4c38e3f to 471d42b Compare August 18, 2026 15:21
@sameerank
sameerank force-pushed the sameerank/FFL-2871/static-context-offline-init branch from 9160faa to 81106cc Compare August 19, 2026 20:28
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 471d42b to d4b7c33 Compare August 19, 2026 21:51
Base automatically changed from sameerank/FFL-2871/static-context-offline-init to main September 14, 2026 18:47
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 6a78e4d to ff35a7d Compare September 15, 2026 15:45
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

OpenFeature Browser Entrypoint Bundle Sizes

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

Entrypoint HTML JS Assets Raw JS Gzip JS Protobuf Markers
root provider smoke index.html 11 82.3 KiB 28.7 KiB no
precomputed configuration precomputed.html 6 6.0 KiB 2.8 KiB no
rules-based configuration protobuf.html 13 174.4 KiB 54.9 KiB yes

Default and precomputed entrypoints are expected to keep Protobuf-ES out of their bundles. Rules-based entrypoints are expected to include protobuf markers as a positive control. The marker check is a packed-artifact backstop; the source import boundary is enforced by packages/core/test/entrypoint-boundaries.spec.ts.

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 5 2.4 KiB 0 B 1.4 KiB 0 B
exposure logging hook tracking-exposure.html 10 39.7 KiB +37.3 KiB 15.6 KiB +14.2 KiB
evaluation logging hook tracking-evaluation.html 10 37.2 KiB +34.8 KiB 14.8 KiB +13.4 KiB
RUM tracking hook tracking-rum.html 8 4.8 KiB +2.4 KiB 2.7 KiB +1.3 KiB
all tracking hooks tracking-all.html 12 43.8 KiB +41.4 KiB 17.4 KiB +16.1 KiB

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

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

Overall it looks close.

Found three things from AI.

sameerank and others added 4 commits September 18, 2026 18:41
Always run exposure and evaluation cleanup after a failed shutdown flush.
Cover throwing proxies, repeated shutdown, and reinitialization.

Environment: Datadog workspace

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

Minor improvement if you'd like to accept now or later: #391

@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 1e1ba4b to 5f06704 Compare September 21, 2026 06:20
@sameerank
sameerank force-pushed the sameerank/FFL-2926/offline-tracking-parity branch from 5f06704 to 885716b Compare September 21, 2026 06:38
Preserve scoped Chrome storage reads with the updated Chrome types. Adapt telemetry cache scope to browser-core 7.1.0 and cover namespace-specific reads.
Merge #349 into #391 without rewriting history. Retain the current cache scoping, timestamp identity, and browser-core compatibility fixes alongside the final-flush cleanup and regression tests.
@sameerank
sameerank removed this pull request from stack #392 September 21, 2026 07:15
@sameerank
sameerank requested a review from btthomas September 21, 2026 20:15
@sameerank
sameerank merged commit 186ad20 into main Sep 21, 2026
4 checks passed
@sameerank
sameerank deleted the sameerank/FFL-2926/offline-tracking-parity branch September 21, 2026 22:04
@linear-code

linear-code Bot commented Sep 23, 2026

Copy link
Copy Markdown

FFLSDK-219

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