Skip to content

Send feature flag exposures after evaluation, and let applications record them with ExposureHook - #12774

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 12 commits into
masterfrom
danyal.khan/EX-3800-shared-exposure-cache
Oct 14, 2026
Merged

gh-worker-dd-mergequeue-cf854d[bot] merged 12 commits into
masterfrom
danyal.khan/EX-3800-shared-exposure-cache

Conversation

@danyal002

@danyal002 danyal002 commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

This PR combines #12775 and #12797, which were reviewed and approved separately and squashed into this branch.

Exposures after a successful evaluation

  • Moves exposure sending from flag resolution to a provider hook, ExposureLoggingHook, at the OpenFeature finally stage. A failed evaluation sends nothing.
  • Adds a provider exposure cache, ExposureDeduplicationCache. Resolution checks it and stamps __dd_exposure_cache_hit; the hook records the exposure and sends it.
  • Requires OpenFeature SDK 1.23.0, because earlier versions do not run the hooks of a provider inside a MultiProvider.

ExposureHook (#12775)

  • Adds a public ExposureHook, which hands each completed evaluation to a callback with the provider's exposure decision (isExposure(), isCacheHit(), shouldSend(), getFeatures()).
  • Copies the selected split's HOOK features into the flag metadata as __dd_feature.<key>.
  • Adds DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED (default true). When false, the provider sends no exposures to Datadog, and the exposure cache keeps running.

EXPOSURE and EVALUATION features (#12797)

  • Sends a split's EXPOSURE features on exposure events and its EVALUATION features on flag evaluation events. Flag evaluation buckets include the features.
  • With an older dd-java-agent, the provider checks for each capability and sends events without features.

Motivation

Some customers use Datadog feature flags but keep exposures in their own data store. They need each exposure, with the holdout key and assignment group, in their own code, and they need to stop sending the same exposures to Datadog. Datadog also routes other features, such as a bandit policy id, to exposures and flag evaluations.

Additional Notes

  • The agent's exposure writer keeps its own cache, because the provider (dd-openfeature) and the agent (dd-java-agent) ship separately.
  • Exposures are sent only through an OpenFeature client. Calling Provider evaluation methods directly runs no hooks.
  • No flag configuration may route a feature to EXPOSURE or EVALUATION until ddoghq/dd-source#124673 is deployed in every datacenter.

Jira ticket: EX-3800 EX-3815 EX-3801 EX-3828

The provider now deduplicates exposures itself and sends them from a
finally-stage hook instead of at resolution. Resolution only checks the
new provider cache and stamps __dd_exposure_cache_hit in the flag
metadata. The hook records the exposure and sends it when the allocation
logs exposures, the subject is not already exposed, and the evaluation
has no error, so an evaluation that an application hook fails no longer
sends an exposure or marks the subject as exposed.

The agent's writer keeps its own cache for now, because an older
provider still sends every evaluation.
Test each condition under which the exposure hook sends nothing, and
list the exposure cache's key and value classes as POJOs excluded from
coverage verification, as the writer's cache classes are.
@dd-octo-sts

dd-octo-sts Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 13.94 s 13.96 s [-1.0%; +0.6%] (no difference)
startup:insecure-bank:tracing:Agent 12.87 s 12.96 s [-1.4%; +0.1%] (no difference)
startup:petclinic:appsec:Agent 17.12 s 16.99 s [-0.1%; +1.6%] (no difference)
startup:petclinic:iast:Agent 16.94 s 16.91 s [-0.8%; +1.2%] (no difference)
startup:petclinic:profiling:Agent 16.08 s 16.71 s [-8.0%; +0.5%] (no difference)
startup:petclinic:sca:Agent 17.07 s 16.64 s [-1.7%; +6.9%] (no difference)
startup:petclinic:tracing:Agent 16.14 s 16.19 s [-1.1%; +0.5%] (no difference)

Commit: 761bda45 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

SpotBugs reports IA_AMBIGUOUS_INVOCATION_OF_INHERITED_OR_OUTER_METHOD
because the anonymous LinkedHashMap's size() call could resolve to the
cache's own size(). A static nested map class has no outer instance, so
the call is unambiguous.
@datadog-datadog-prod-us1-2

This comment has been minimized.

@danyal002
danyal002 marked this pull request as ready for review October 9, 2026 17:51
@danyal002
danyal002 requested review from a team as code owners October 9, 2026 17:51
@danyal002
danyal002 requested review from amarziali, btthomas and leoromanovsky and removed request for a team October 9, 2026 17:51
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T17:58:57.198353Z 268677e Draft marked ready
🔒 Security Review ✅ Completed 2026-10-09T17:56:29.241486Z 268677e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 268677e167

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@danyal002
danyal002 requested review from a team and typotter October 9, 2026 18:04

@datadog-datadog-prod-us1-2 datadog-datadog-prod-us1-2 Bot 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.

Bits Code Review: FAIL

Logging allocations can fail on older agents because the new cache lookup reads an unsupported serial-ID field before checking compatibility. The provider cache can also suppress exposures after concurrent dispatch reordering or queue rejection.

Open Bits AI session

🤖 Bits Code Review · Commit 268677e

The exposure cache check read split.serialId before exposureSerialId
could apply SPLIT_SERIAL_ID_SUPPORTED. With an older dd-java-agent whose
Split has no serialId field, every evaluation of an allocation that logs
exposures then threw NoSuchFieldError. The read now sits behind the same
guard as the other serial id reads.
Exposures are now sent from a provider hook. OpenFeature SDK 1.20.1 to
1.22.1 does not run the hooks of providers wrapped in a MultiProvider, so
a Datadog provider inside one sent no exposures. SDK 1.23.0 runs them.
The README states 1.23.0 as the minimum for MultiProvider, and a test
covers the MultiProvider path; it fails on 1.20.1.
The ImmutableMetadata getters build a debug message for every absent key,
whether or not debug logging is on. __dd_do_log is absent from most
evaluations by default, so the hook built that string on most of them. It
now reads the keys from asUnmodifiableMap().
The flag now names the capability it detects, like SPLIT_SERIAL_ID_SUPPORTED
next to it, instead of the case to avoid. Its value is inverted, and every
reader is inverted with it.
They have the same size but refresh entries at different times, so once
full they can evict different subjects. The comment said they evict the
same ones.

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

Catching up; solid iteration and noticing the OF bump needed.

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

Thanks for iterating! There a few lower priority comments love for you to look at, but nothing blocking.

Comment thread dd-smoke-tests/openfeature/application/build.gradle
On dd-java-agent 1.65 and 1.66 the lookup at resolution uses no serial
id, so the hook must record none either. Otherwise every repeat of a
split with a serial id is sent again.
google-java-format had split it, leaving a line that held only one word.
@danyal002
danyal002 enabled auto-merge October 14, 2026 13:55
)

* Parse features on UFC splits

A UFC split can carry a list of key-value features for exposure hooks.
Split gains a nullable features field, and the parser reads it leniently:
a malformed list or entry is dropped instead of rejecting the flag, so
features never change which variation a subject gets.

* Cover the features adapter factory and exclude the Feature POJO from coverage

Test the factory's type and annotation checks and the read-only adapter,
as the other UFC adapters do, and list the Feature model class with the
other UFC POJOs that are excluded from coverage verification.

* Let applications record feature flag exposures with ExposureHook

Add a public ExposureHook that hands each completed evaluation to a
callback with the provider's exposure decision. shouldSend() is true for
exactly the exposures the provider sends to Datadog. getFeatures()
returns the selected split's features, which the provider now copies
into the flag metadata as __dd_feature.<key>.

DD_FEATURE_FLAGS_EXPOSURES_DATADOG_LOGGING_ENABLED=false stops sending
exposures to Datadog. The exposure cache keeps running, so ExposureHook
callbacks get the same decisions either way.

* Parse the destinations of UFC split features

Each feature now lists where SDKs deliver it, such as HOOK, EXPOSURE or
EVALUATION. Destinations are open strings: the parser keeps every
non-empty string, including ones this SDK does not know, and drops a
feature that has no destinations.

* Deliver only HOOK features to exposure hooks

Copy a split feature into the __dd_feature.<key> metadata only when its
destinations include HOOK. EXPOSURE and EVALUATION are known
destinations that are not delivered to hooks. A destination the SDK does
not know is ignored, with one warning for each new name.

* Replace getCacheHit() with a primitive isCacheHit()

SpotBugs reports NP_BOOLEAN_RETURN_NULL because getCacheHit() returned
a Boolean that is null when the evaluation is not an exposure. A caller
that unboxes it gets a NullPointerException. isCacheHit() returns false
in that case; isExposure() still tells callers whether it is one.
shouldSend() still needs an explicit false stamp, so sending is
unchanged.

* Cap the unknown feature destinations the evaluator remembers

The warn-once set kept every unknown destination name for the life of
the application, so a stream of configurations with new names grew it
without bound. It now stops at 64 names; later unknown destinations are
still ignored, only without a warning.

* State when shouldSend() matches Datadog, and surface hook failures

shouldSend() matches the exposures Datadog receives only when the
provider's own hooks run. The Javadoc now says so, names the
MultiProvider case before OpenFeature SDK 1.23.0, and says that
ExposureHook is optional because the flag metadata carries the same data.

ExposureHook now skips the callback when OpenFeature passes no details,
and logs the first callback failure at warn level instead of debug.
ExposureLoggingHook logs its first LinkageError at warn level instead of
swallowing it. Both read flag metadata through its map, because the
ImmutableMetadata getters build a debug message for every absent key.

* Test that the Datadog exposure logging setting turns sending off

The earlier tests passed false straight to the gate or the hook, so a
wrong key would still pass. This forked test sets the real system
property and checks that the provider sends nothing to Datadog while
ExposureHook keeps its decisions.

* Lead the exposure README with the flag metadata any hook can read

The README now says first that the provider puts its exposure decision
and the split's features in the flag metadata, and that ExposureHook is a
convenience on top of it. It says how to read the metadata, narrows the
shouldSend() claim to when the provider's hooks run, corrects the
__dd_do_log and __dd_split_serial_id rows, and adds a table of the
dd-java-agent version each capability needs.

* Test evaluation with an agent whose Split has no features

No released dd-java-agent has Split.features yet. The provider must
skip reading it there, or every evaluation throws NoSuchFieldError.

* Correct when shouldSend() matches what Datadog receives

It matches only while Datadog exposure logging is on, and a changed
serial id counts only with dd-java-agent 1.67.0 or later. The README
also gives 1.68.0 for split features, says that the logging setting
does not stop flag evaluation events, and that the provider does not
read dd-trace-py's DD_FFE_INTAKE_ENABLED.

* Send EXPOSURE and EVALUATION feature flag features to Datadog (#12797)

* Send EXPOSURE and EVALUATION features to Datadog

A UFC split feature can list the EXPOSURE and EVALUATION destinations.
The evaluator now writes each feature once for each destination it
lists, under that destination's metadata prefix. The exposure hook puts
EXPOSURE features on the exposure event, and the flag-evaluation hook
puts EVALUATION features on the flag-evaluation event, whatever the
consent, because they come from the flag configuration.

Flag-evaluation buckets now include the features in both tiers, so rows
with different features never merge, and each row serializes them. Both
events gain a features field with new constructors; with an older agent
the provider sends them without features.

* Keep the first feature when a split repeats a feature key

The FFE_FLAGS schema documents that keys are unique within a split and
that SDKs keep the first feature with a repeated key. Flag metadata is a
map, so the provider used to keep the last one. It now ignores a later
feature with a key it has already seen, for every destination.

* Check flag-evaluation feature support inside FlagEvalLoggingHook

DDEvaluator resolved FlagEvalEvent.class in its static initializer.
Agents released before FlagEvalEvent existed then failed DDEvaluator's
initialization with NoClassDefFoundError, which broke the provider. The
check now lives in FlagEvalLoggingHook, the only class that uses it, and
Provider already guards that hook's loading against LinkageError.

* Read split features without DDEvaluator, and scan only when needed

- Moves featuresWithPrefix into SplitFeatureMetadata, which depends only
  on the OpenFeature API. ExposureHook.getFeatures() called it on
  DDEvaluator, which needs the Datadog Java agent, so it threw
  NoClassDefFoundError where the agent is absent, such as unit tests.
- DDEvaluator marks the metadata with __dd_evaluation_features only when
  the split routes a feature to EVALUATION. FlagEvalLoggingHook scans
  the metadata only then, instead of on every evaluation.
- Adds an end-to-end test of EVALUATION features for an allocation that
  does not log exposures. Every earlier provider-backed test used
  doLog=true, so adding features only for logged allocations passed.
- Reflows a broken comment.

* Share one copy of each feature set across a flush window

Each bucket kept the features map that the provider built for its own
evaluation, and each key kept its own canonical string. Full-tier keys
include the targeting key, so up to PER_FLAG_CAP subjects of one split
held that many equal maps and strings until the flush. The aggregator now
keeps one key and map for each distinct feature set, and clears them with
the rest of the window.

* Test that ExposureHook works without the Datadog Java agent

The test classpath always has the agent classes, so no other test sees
ExposureHook reach for them. This test loads ExposureHook in a class
loader that hides DDEvaluator and the agent classes, as in an
application without the agent.

* Read flag evaluation metadata from its map, and rename the marker

- FlagEvalLoggingHook reads allocationKey, the evaluation timestamp,
  the consent key and the evaluation-features marker from one map. The
  ImmutableMetadata getters build a debug message for every absent key,
  and error and default results carry only the consent key.
- Renames the marker to __dd_has_evaluation_features, so that it does
  not differ from the __dd_evaluation_feature. prefix by one character.
- Rewraps a comment that google-java-format had split.
@danyal002 danyal002 changed the title Send feature flag exposures only after a successful evaluation Send feature flag exposures after evaluation, and let applications record them with ExposureHook Oct 14, 2026
@danyal002
danyal002 added this pull request to the merge queue Oct 14, 2026
@dd-octo-sts

dd-octo-sts Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-10-14 14:56:36 UTC ℹ️ Start processing command /merge


2026-10-14 14:56:40 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 1h (p90).


2026-10-14 15:57:45 UTC ❌ MergeQueue: The build pipeline contains failing jobs for this merge request

Build pipeline has failing jobs for 009d6b8:

⚠️ Do NOT retry failed jobs directly (why?).

What to do next?

  • Investigate the failures and when ready, re-add your pull request to the queue!
  • If your PR checks are green, try to rebase/merge. It might be because the CI run is a bit old.
  • Any question, go check the FAQ.
Details

Since those jobs are not marked as being allowed to fail, the pipeline will most likely fail.
Therefore, and to allow other builds to be processed, this merge request has been rejected and the pipeline got canceled.

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 14, 2026
@danyal002
danyal002 added this pull request to the merge queue Oct 14, 2026
@dd-octo-sts

dd-octo-sts Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

/merge

@gh-worker-devflow-routing-ef8351

gh-worker-devflow-routing-ef8351 Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

View all feedbacks in Devflow UI.

2026-10-14 16:44:05 UTC ℹ️ Start processing command /merge


2026-10-14 16:44:09 UTC ℹ️ MergeQueue: pull request added to the queue

The expected merge time in master is approximately 1h (p90).


2026-10-14 18:03:26 UTC ℹ️ MergeQueue: This merge request was merged

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 14, 2026
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 544a515 into master Oct 14, 2026
614 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the danyal.khan/EX-3800-shared-exposure-cache branch October 14, 2026 18:03
@github-actions github-actions Bot added this to the 1.68.0 milestone Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants