Parse observeFullEvaluationData and hash targeting_key in flagevaluations events - #12042
Conversation
…ions events Adds the top-level observeFullEvaluationData boolean to the UFC model, plumbs it through to the EVP flagevaluation event serializer, and gates PII handling on it: when the flag is absent/false the targeting key is SHA-256 hashed (sha256_<hex>) and the raw evaluation context is omitted from the wire; when true the raw targeting key and context are emitted. Environment: Datadog workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Replace the inline "sha256_" literal with a documented HASHED_TARGETING_KEY_PREFIX constant describing the cross-SDK wire contract for privacy-preserving hashed targeting keys. Environment: Datadog workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Parameterize the true/false config-parsing assertions with @valuesource and add a test locking in the fail-closed behaviour for an explicit JSON null: malformed config is rejected so full evaluation data is never observed off the back of it. Environment: Datadog workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The flush-time read of FeatureFlaggingGateway.isObserveFullEvaluationDataEnabled() was a TOCTOU bug: CURRENT_CONFIG could be overwritten by a later RC update between when an evaluation happened and when the batch flushed, so events could be emitted under the wrong environment's consent (the system test observed a targeting key hashed even though the active UFC said observeFullEvaluationData=true). Capture consent when the evaluation is folded into its EvalBucket instead. On merge the value is folded with AND, so any no-consent evaluation in a bucket's lifetime sinks the whole bucket to hashed/omitted (fail-closed). buildEventList now reads bucket.observeFullEvaluationData rather than the gateway. The gateway accessor is retained; it is read at aggregation time. Adds a writer-level regression guard (a bucket aggregated under consent-off stays hashed even if the gateway later reports consent-on) plus aggregator fold tests, and an end-to-end parse->dispatch->flush test. Environment: Datadog workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
| Test | Result |
|---|---|
Test_FFE_EVP_Flagevaluation_ObserveFullData_Absent_Hashed |
✅ PASS |
Test_FFE_EVP_Flagevaluation_ObserveFullData_False_Hashed |
✅ PASS |
Test_FFE_EVP_Flagevaluation_ObserveFullData_True_Unhashed |
✅ PASS |
36 passed, 8 skipped, 2704 deselected, 1 xfailed, 3 xpassed in 301.86s
Weblog: spring-boot | Scenario: FEATURE_FLAGGING_AND_EXPERIMENTATION
Note: an earlier build (d65c79f266) of this PR failed True_Unhashed — the SDK was always hashing regardless of the flag value. Root cause: FlagEvaluationWriterImpl.buildEventList() read observeFullEvaluationData from CURRENT_CONFIG at flush time, which could be overwritten by a subsequent RC update before the 10s flush fired. The fix in this PR (capturing the value per-bucket at enqueue time with a privacy-preserving fold) resolves it.
|
Snapshot the PII consent flag on the evaluation thread (in the OpenFeature hook) and carry it on FlagEvalEvent, instead of reading the gateway when the event is aggregated/flushed. This pins the hashed-vs-raw decision to the configuration active at evaluation time, closing a one-directional leak window where a later Remote Config update could retroactively apply a different environment's consent to already-collected evaluations. Aggregation and flush now read event.observeFullEvaluationData and never consult the gateway; the AND-fold across a bucket's evaluations is unchanged (any no-consent evaluation sinks the bucket to hashed/omitted). Environment: Datadog workspace Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ickie/FFL-2790-protect-pii-with-observeFullEvaluationData Bring the parent aggregate flagevaluation EVP branch, and the agentless configuration source and master through it, into the observeFullEvaluationData PII branch. No conflicts. Only three files were touched by both sides, and each merged into a coherent union: - FeatureFlaggingGateway: the parent's provider ActivationListener machinery and this branch's observeFullEvaluationData accessor are disjoint additions. - FeatureFlaggingGatewayTest and DDEvaluatorTest: unions of both suites, with no method dropped from either side. Environment: Datadog workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
More details
The event-time snapshot, conservative bucket fold, and serialization gates preserve the no-PII default while retaining raw data only for explicitly consented evaluations. No additional tests recommended: production telemetry is unavailable and the PR already covers the relevant consent transitions and wire shape.
📊 Validated against 3 scenarios · Open Bits AI session
🤖 Datadog Autotest · Commit e0e3632 · What is Autotest? · @DataDog review to ask questions · Any feedback? Reach out in #autotest
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e0e3632b8c
ℹ️ 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".
leoromanovsky
left a comment
There was a problem hiding this comment.
Two conditions to resolve please.
Address PR #12042 review feedback (Codex P1, leoromanovsky, dd-oleksii): the FlagEvalLoggingHook was reading observeFullEvaluationData from FeatureFlaggingGateway.isObserveFullEvaluationDataEnabled() at hook-fire time, which races against a Remote Config swap of CURRENT_CONFIG that happens after DDEvaluator.evaluate() captured its own ServerConfiguration reference. That race can retroactively mark an evaluation performed without consent as consented and leak the raw targeting key / context. DDEvaluator now stamps the boolean directly from the ServerConfiguration it used, onto every ProviderEvaluation via ImmutableMetadata under key "dd.observe_full_evaluation_data". The hook reads consent from that metadata and no longer queries the gateway. Missing metadata (PROVIDER_NOT_READY or a non-DD provider) → false, the privacy-preserving default. The gateway's isObserveFullEvaluationDataEnabled() accessor is removed since its only real caller was the hook and re-adding it would re-open the race. Adds regression tests: hook honours consent metadata (true/false/absent) and ignores a gateway value that disagrees; evaluator stamps the correct boolean on the FLAG_NOT_FOUND path and omits metadata when it holds no config. Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to the previous commit: the private error() and resolveVariant() helpers only ever read one field off the ServerConfiguration (observeFullEvaluationData), so pass the boolean directly instead of the whole config. Keeps the internal API narrow and removes the incidental coupling these helpers had to the UFC. While here, PROVIDER_NOT_READY now stamps consent as the privacy-preserving false rather than omitting the metadata. Same on-the-wire outcome the hook would have produced, but the invariant "every DD-produced evaluation carries dd.observe_full_evaluation_data" is now unconditional, which is easier to reason about. The two error() overloads collapse to one (the (String) null casts at call sites disappear along with them). Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The key is only ever read by FlagEvalLoggingHook one line later — it never lands on the wire, so it doesn't need the "dd." namespacing that "dd.eval.timestamp_ms" has (that key is re-emitted onto spans). Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The race-vs-CURRENT_CONFIG backstory is captured in the previous commits' messages; the code only needs the forward-looking invariants (metadata is source of truth, missing key = false, DD-produced evaluations always stamp). Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address PR #12042 review from leoromanovsky (escalated Codex P2 → P1): on the protected path (observeFullEvaluationData=false) the serializer drops the evaluation context, but the aggregator was still running it through pruneContext + canonicalContextKey and keying every full-tier bucket on it. A high-cardinality field on the evaluation context (request_id, timestamp, correlation id) would fragment buckets that emit byte-identical wire rows, blow out PER_FLAG_CAP (10k) inside one flush window, and force subsequent evaluations into the degraded tier — which drops the targeting key entirely. On the protected path aggregate() now uses ctxKey="" and stores prunedAttrs=null, so different contexts for the same subject collapse into one bucket. The targeting key stays in the aggregation identity, so different subjects still hash to different buckets. The consent-on path is unchanged. Regression tests: protected path collapses differing contexts for one subject; protected path still separates distinct subjects; full path still splits on context. Existing tests that exercise pruneContext / context-differentiation were updated to use consent=on (that's the code path they actually cover). Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to the aggregator fix: with observeFullEvaluationData=false the evaluation context is dropped on emit and no longer influences aggregation, so there is no reason to snapshot it on the evaluation thread. The hook now branches on consent up front — the protected path enqueues an event with an empty materialized attrs map (no map copy of the OpenFeature context, no Supplier<Map> allocation, no lambda instance), while the consent-on path is unchanged. Grep confirms the only production consumer of FlagEvalEvent.contextAttributes / FlagEvalEvent.attrs is FlagEvaluationAggregator.aggregate, which already skips them on the protected path. Regression test: mutating the EvaluationContext after finallyAfter returns still yields empty attrs on the enqueued event — proves the hook never snapshotted it. Two existing tests that exercise the snapshot mechanism were switched to pass consent-on metadata (that's the code path they cover). Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bucket keys should cover every dimension the emitter will branch on. The serializer branches on observeFullEvaluationData (hashes the targeting key and drops the context when off), so two evaluations that differ only in consent produce different wire rows and must not share a bucket. Before this change they could: same subject, same flag, same empty context would land under the same FullKey regardless of consent, and the AND-fold would silently downgrade a consent-on evaluation to the protected wire shape because a nearby consent-off event merged into its bucket first. No PII leak (fail-closed direction), but arrival-order-dependent semantics and a lost raw-context row. Add observeFullEvaluationData to FullKey / DegradedKey (equals + hashCode). The AND-fold on bucket.observeFullEvaluationData stays as defensive belt- and-suspenders; every event merging into a bucket now carries the matching consent value by construction. Regression test: two events identical except for consent land in two full- tier buckets, one consent-on and one consent-off. Updated the previous "fold to false on mixed consent" test to reflect the new invariant. Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test asserts that context attributes flow through the logging hook, but the mock metadata omitted the observe-full-evaluation-data flag, so the hook took the privacy-preserving path and dropped context. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exception messages from the evaluator's outer catch blocks (NumberFormatException, generic Exception) can echo raw evaluation-context values verbatim — for example a GT rule on "id" with a PII-shaped targeting key produced error.message="For input string: \"jane.doe@...\"" on the wire regardless of consent, defeating the PR's own PII guard. Drop the message at DDEvaluator.error() when consent is off, and add a hook-layer fallback that substitutes ErrorCode.name() so operators keep a stable signal (e.g. "TYPE_MISMATCH") even when a third-party provider hands us a raw message. Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The only observeFullEvaluationData assertions were on error paths (FLAG_NOT_FOUND, PROVIDER_NOT_READY), leaving the success-path stamp in resolveVariant and the DISABLED/DEFAULT stamps in consentMetadata uncovered — line 448 could be deleted or hardcoded to either value and every existing test would still pass. Add symmetric consent-on/consent-off tests for each of resolveVariant, DISABLED, and DEFAULT so any mutation (delete / hardcode true / hardcode false) flips at least one assertion. Rename the previously misleading …OnSuccess test to reflect what it actually exercises (FLAG_NOT_FOUND error via error()). Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two degraded buckets differing only in observeFullEvaluationData emit byte-identical wire JSON — the degraded serializer (fromBucket with isFullTier=false) drops the targeting key and context regardless of consent — so the consent dimension in DegradedKey halved effective DEGRADED_CAP for zero wire fidelity gain. FullKey correctly keeps consent (the full-tier serializer branches on it for raw-vs-hashed targeting key and context inclusion). Mixed-consent events now merge into one degraded bucket. The AND-fold on EvalBucket.observeFullEvaluationData still runs and collapses to false whenever any consent-off event lands in a mixed bucket; benign because the value has no downstream effect for degraded rows. Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this change ServerConfiguration.observeFullEvaluationData was a primitive boolean — Moshi's reflective adapter rejected the entire UFC whenever the JSON value was null or wrong-typed. Agentless swallows the IOException at DEBUG, so a pod starting after a malformed message had no last-known-good, stranded every flag on PROVIDER_NOT_READY, and served defaults forever. Fail-closed on privacy shouldn't cascade into fail-closed on availability. Box the field to Boolean so null tolerates naturally, register a LenientBooleanAdapter that maps wrong-typed values to null as well, and read via Boolean.TRUE.equals(...) at the DDEvaluator so null falls to the privacy-preserving default. The lenient adapter only intercepts Boolean (not primitive boolean), so mandatory fields like Flag.enabled keep their strict parse; the only other Boolean it touches is Allocation.doLog, which is already read as `!= null && doLog`. Reversed the earlier RejectsExplicitNull test — it had locked in the buggy behaviour — into a family of tolerance tests for null / stringified / numeric. Added a DDEvaluator test that a config with a null consent field evaluates without NPE and stamps the privacy-preserving default. Generated with Claude Code Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…evaluation-java' into vickie/FFL-2790-protect-pii-with-observeFullEvaluationData # Conflicts: # products/feature-flagging/feature-flagging-api/src/main/java/datadog/trace/api/openfeature/FlagEvalLoggingHook.java # products/feature-flagging/feature-flagging-lib/src/test/java/com/datadog/featureflag/FlagEvaluationWriterImplTest.java
🟢 Java Benchmark SLOs — All performance SLOs passed
PR vs. master results
Commit: Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion. |
…ickie/FFL-2790-protect-pii-with-observeFullEvaluationData
|
System-test validation Validated this PR against the Branch tested: The two tests that directly exercise the PII contract:
Without this PR, both hashing tests fail: the raw email |
The consent-off short-circuit in FlagEvaluationEvent.fromBucket drops the raw context before Moshi encodes it, so a NaN in the attrs never reaches the encoder and the flush succeeds. That defeated the intent of encodeFailureClearsAggregatorSoLaterFlushesRecover, which must observe a real encode failure to prove the aggregator is cleared. Co-Authored-By: Claude <noreply@anthropic.com>
The per-class JaCoCo gate (0.9 minimum, gradle/jacoco.gradle) failed on the new adapter: toJson was never invoked (20/25 instructions) and the factory's !annotations.isEmpty() short-circuit never evaluated true (3/4 branches). Neither path is reachable through the parse-driven tests in JsonApiUfcResponseParserTest. Mirror the tests the sibling FlagMapAdapter and DateAdapter already have. The primitive-boolean assertion documents the guard that keeps this leniency off mandatory fields like Flag.enabled. Environment: Datadog workspace Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ickie/FFL-2790-protect-pii-with-observeFullEvaluationData
…ickie/FFL-2790-protect-pii-with-observeFullEvaluationData
Background
Feature-flag evaluations are reported to Datadog so users can see how their flags behaved in production. Today, every evaluation carries the raw targeting key (the identifier of the subject being evaluated; often a user email or user ID) and the full evaluation context. Those fields can contain personally identifiable information (PII).
Motivation & High level changes
This PR lets the server decide, per environment, whether that raw data may be observed via a new boolean on the UFC. It is the Java piece of the cross-SDK "Protecting PII in flagevaluations" initiative.
dd-trace-javais the pilot SDK; the same pattern will be copied to the other SDKs afterward.This directly resolves the privacy concern raised on the parent PR #11639, where @AlexeyKuznetsov-DD flagged that flag-evaluation reporting was default-on and shipped the raw
targeting_key(user IDs / emails) plus the full evaluation context in clear text, and asked for hashing / opt-in / an explicit privacy sign-off. The no-PII path is now the default.observeFullEvaluationDatatruefalseor absent (privacy-preserving default)sha256_<hex>"Absent" is treated exactly like
false, so an older/cached config can never accidentally leak raw values.How it works
observeFullEvaluationDataon the downloaded flag-configuration (UFC) model (top-level boolean, defaults tofalsewhen missing).DDEvaluatorsnapshots the active UFC'sobserveFullEvaluationDataonce per evaluation and stamps it onto theProviderEvaluationmetadata; the OpenFeature hook (FlagEvalLoggingHook) reads that metadata value and stores it asFlagEvalEvent.observeFullEvaluationData. Aggregation and serialization then read that per-event snapshot and never consult the live gateway — so the hashed-vs-raw decision is pinned to the configuration active when the evaluation happened.CURRENT_CONFIGcan be overwritten by a later Remote Config update between when an evaluation happened and when the batch is drained and flushed. Reading the gateway at flush would retroactively apply a different environment's consent to already-collected evaluations. (This was a real bug caught by system-tests: a targeting key came back hashed even though the active UFC saidtrue.) Snapshotting on the evaluation thread closes that window in both directions.observeFullEvaluationDatais part of the aggregation bucket key (FullKey/DegradedKey), so events with different consent values never merge into the same bucket. Consent-off and consent-on evaluations for the same subject land in distinct buckets and are emitted with their respective policies. An AND-fold on the bucket's consent field is kept as defense-in-depth in case the key ever drifts from the field.ULeb128Encoder.hashTargetingKeyhelper and prefixed withsha256_; the context field is dropped so it's absent from the JSON (notnull, not{}).SHA-256 hash
The hash is unsalted SHA-256 over the raw UTF-8 bytes (no trimming/case/Unicode normalization), emitted as lowercase hex. Unsalted is a deliberate cross-SDK contract requirement: every SDK must produce the identical digest for a given targeting key so hashed values line up across languages and against the backend. It matches the canonical test vector shared across all SDKs:
Performance
Consent is read once per evaluation on the hook path — a single
AtomicReference.getsnapshotted onto the event (no allocation, no per-span hot path). Hashing runs at flush cadence, bounded by the number of distinct evaluation buckets, and uses aThreadLocal<MessageDigest>(no per-callMessageDigest.getInstance).Testing
true,false(as a@ValueSourcematrix), absent (defaults tofalse), and explicitnull(rejected / fail-closed).DDEvaluatorat evaluation time (true,false, absent →false), and that the hook ignores the live gateway even when it disagrees with the metadata (ignoresGatewayConsentEvenWhenItDisagreesWithMetadata).FlagEvalEventtests cover the new field and the fail-closed default on the convenience constructors.mixedConsentEvaluationsForSameSubjectLandInDistinctBuckets), and same-consent evaluations merge as before (sameConsentEvaluationsForSameSubjectMergeIntoOneBucket).🤖 Generated with Claude Code