Skip to content

Parse observeFullEvaluationData and hash targeting_key in flagevaluations events#12042

Draft
vjfridge wants to merge 4 commits into
leo.romanovsky/ffl-2446-evp-flagevaluation-javafrom
vickie/FFL-2790-protect-pii-with-observeFullEvaluationData
Draft

Parse observeFullEvaluationData and hash targeting_key in flagevaluations events#12042
vjfridge wants to merge 4 commits into
leo.romanovsky/ffl-2446-evp-flagevaluation-javafrom
vickie/FFL-2790-protect-pii-with-observeFullEvaluationData

Conversation

@vjfridge

@vjfridge vjfridge commented Jul 22, 2026

Copy link
Copy Markdown

What & why

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

This PR lets the server decide, per environment, whether that raw data may be observed via a new boolean on the UFC the SDK already downloads. It is the Java piece of the cross-SDK "Protecting PII in flagevaluations" initiative. dd-trace-java is 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 helpfully 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.

Behavior, applied only when reporting flag-evaluations is already enabled (the existing kill switch is untouched):

Server says observeFullEvaluationData targeting key evaluation context
true sent as-is (raw) included
false or absent (privacy-preserving default) SHA-256 hashed, sha256_<hex> omitted entirely

"Absent" is treated exactly like false, so an older/cached config can never accidentally leak raw values.

How it works

  • New field observeFullEvaluationData on the downloaded flag-configuration (UFC) model (top-level boolean, defaults to false when missing).
  • Consent is captured when each evaluation is aggregated into its bucket. Evaluations are enqueued on the hook path and later folded into two-tier aggregation buckets; the gateway's consent value is read at that fold point and stored on the bucket. At serialization time the writer reads the bucket's captured value.
    • Why not read it at flush? CURRENT_CONFIG can be overwritten by a later Remote Config update between when an evaluation happened and when the batch flushes. 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 said true.)
    • Conservative fold: when multiple evaluations merge into one bucket, the consent flag is folded with AND. If any evaluation in the bucket's lifetime saw consent off, the whole bucket falls back to hashed/omitted. Fail-closed toward privacy. The bucket key is unchanged (no split on consent).
  • When hashing, the targeting key is run through the existing shared SHA-256 helper and prefixed with sha256_; the context field is dropped so it's absent from the JSON (not null, not {}).
  • Evaluation logic is unchanged.

Unsalted 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:

"jane.doe@datadoghq.com" -> sha256_b4698f9b6d186781fa8dc59e533578fa2d8379a46b1cf6db85cda6aa9c99e51b

Performance

Consent is read once per aggregated evaluation (a single AtomicReference.get plus a boolean AND on merge) on the aggregation path — not per event on the wire and not in any per-span hot path. Hashing runs at flush cadence, bounded by the number of distinct evaluation buckets, and uses a ThreadLocal<MessageDigest> (no per-call MessageDigest.getInstance).

Malformed config is fail-closed

An absent field defaults to false. An explicit null for the field is malformed input that Moshi rejects; the whole config update is then dropped by the callers (AgentlessConfigurationSource / the remote-config poller both swallow the failure and keep the last-known-good config), and the gateway defaults to the privacy-preserving behavior when no valid config has been dispatched. In other words, malformed config can never flip the gate on. This is covered by a test.

Testing

  • Hash helper matches the canonical PII vector; preserves whitespace/case; is lowercase 64-char hex.
  • Config parsing covers true, false (as a @ValueSource matrix), absent (defaults to false), and explicit null (rejected / fail-closed).
  • Serialization covers both raw and hashed paths, asserting on the raw wire bytes that the hashed value is present, the raw PII value never appears, and no per-event context is emitted.
  • Consent-timing regression guards: a bucket aggregated while consent was off stays hashed even if a later RC update turns consent on before the flush; aggregator fold tests cover mixed-consent buckets (any off → hashed) and all-on buckets (stays raw); and an end-to-end test parses a real UFC → dispatches → flushes and asserts the raw wire value.

🤖 Generated with Claude Code

…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>
@vjfridge vjfridge added tag: ai generated Largely based on code generated by an AI or LLM comp: openfeature OpenFeature type: feature Enhancements and improvements labels Jul 22, 2026 — with ddtool CLI
@datadog-prod-us1-5

This comment has been minimized.

vjfridge and others added 3 commits July 23, 2026 15:58
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>
@vjfridge

vjfridge commented Jul 23, 2026

Copy link
Copy Markdown
Author

system-tests validation (L3) — Java SDK ✅

Ran the three new observeFullEvaluationData PII-protection tests from system-tests#7316 locally against this branch (java@1.65.0-SNAPSHOT+81af153ca7).

All three pass:

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.

@vjfridge

vjfridge commented Jul 23, 2026

Copy link
Copy Markdown
Author

ffe-dogfooding validation (L2) - Java SDK ✅

I ran this branch's dd-java-agent + dd-openfeature build through our OpenFeature dogfooding harness against a staging UFC, exercising all three behaviors of the PII-protection contract:

Protected — observeFullEvaluationData = false (default):

  • targeting_key = sha256_ + 64-char lowercase-hex SHA-256 digest (71 chars total)
  • context.evaluation omitted
  • consistent across every emitted flagevaluation event

Full — observeFullEvaluationData = true (opt-in):

  • targeting_key raw (verbatim subject id)
  • context.evaluation present with the full evaluation context

Kill switch — DD_FLAGGING_EVALUATION_COUNTS_ENABLED = false:

  • zero flagevaluation events emitted regardless of observeFullEvaluationData (SDK logs Flag evaluation EVP writer disabled)

Notes:

  • Pointing the app at environments with observeFullEvaluationData false vs true produced exactly the expected payload shapes.
  • Hashing was applied even to FLAG_NOT_FOUND (runtime-default) evaluations — the privacy posture is enforced independent of evaluation outcome. 👍
  • The SDK posts to the singular /api/v2/flagevaluation EVP route, as expected for the flagevaluation track.

Validation harness changes: ffe-dogfooding#100 — adds the per-SDK PII posture dashboard badge and fixes the singular /api/v2/flagevaluation intake route.

Verdict: the Java SDK is performing as expected for the PII-protection contract — default hashing, opt-in full data, and kill switch all behave correctly. Cross-SDK hash equality is covered in the companion system-tests work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: openfeature OpenFeature tag: ai generated Largely based on code generated by an AI or LLM type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant