Skip to content

feat(feature-flagging): FFE APM feature-flag span enrichment (experimental, gated)#11658

Open
leoromanovsky wants to merge 20 commits into
masterfrom
leo.romanovsky/ffe-apm-span-enrichment
Open

feat(feature-flagging): FFE APM feature-flag span enrichment (experimental, gated)#11658
leoromanovsky wants to merge 20 commits into
masterfrom
leo.romanovsky/ffe-apm-span-enrichment

Conversation

@leoromanovsky

@leoromanovsky leoromanovsky commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

feat(feature-flagging): FFE APM feature-flag span enrichment

⚠️ Experimental, opt-in, gated behind DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED (off by default).

Summary

Adds Feature Flag Events (FFE) span enrichment to the OpenFeature integration. When feature
flags are evaluated, the evaluation metadata is attached to the root APM span so APM customers
can filter traces and errors by active flag variant, and the FFE/Experimentation platform can
correlate spans with experiments. The wire format matches the merged reference implementation
(dd-trace-js#8343) so backend/Trino decode is identical.

How it works

  1. A flag is evaluated through the OpenFeature client.
  2. The finally hook captures the evaluation (serial ID, targeting key, runtime default).
  3. Evaluations are accumulated against the local root span (resolved via SpanEnrichmentInterceptor.findLocalRootInFragment).
  4. On root-span finish, the accumulated state is encoded and written as ffe_* tags.

Configuration

Opt-in, off by default. The gate is resolved through the standard ConfigProvider precedence
(system property → stable config → environment variable), the same mechanism as the sibling
provider-enabled gate:

# environment variable
DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED=true
# or system property
-Ddd.experimental.flagging.provider.span.enrichment.enabled=true

Canonical key: experimental.flagging.provider.span.enrichment.enabled
(FeatureFlaggingConfig.SPAN_ENRICHMENT_ENABLED). This is distinct from
DD_EXPERIMENTAL_FLAGGING_PROVIDER_ENABLED — enabling the provider does not enable span
enrichment. The provider logs span enrichment enabled / span enrichment disabled at INFO on
startup.

Span tags added

Tag Description Format
ffe_flags_enc All evaluated flag serial IDs base64 delta-varint
ffe_subjects_enc Subject → flags mapping (when doLog=true) JSON { sha256(key): encodedIds }
ffe_runtime_defaults Fallback values for flags not in UFC JSON { flagKey: value }

Limits: 200 serial IDs, 10 subjects, 20 experiments/subject, 5 runtime defaults, 64 chars/runtime-default value (UTF-8-safe truncation).

Changes

  • Gate + config: add the experimental.flagging.provider.span.enrichment.enabled gate
    (FeatureFlaggingConfig.SPAN_ENRICHMENT_ENABLEDDD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED),
    off by default, read via ConfigProvider so system property + stable config + env all apply;
    log enabled/disabled at INFO on startup; surface the split serialId in eval metadata
    (ufc/v1/Split.java).
  • Codec: ULeb128Encoder — delta-varint serial IDs, SHA256-hashed subject keys.
  • Accumulator + hook + interceptor: SpanEnrichmentAccumulator, SpanEnrichmentHook, SpanEnrichmentInterceptor, SpanEnrichmentStates — accumulate evaluations and write ffe_* tags onto the local root span. State is held in a WeakHashMap keyed by the local-root span object (no central id-keyed store, no cap/eviction): it is collected with its root, so a never-completing trace can't leak. This matches the Python/Ruby SDK design.
  • Tests: JUnit 5 L0 suite (7 required cases + golden round-trip) + lifecycle regression tests (partial-flush, leak, reconfiguration).

Decisions

  • No idle per-span overhead when the gate is off — the accumulator is absent and spans carry no ffe_* tags.
  • Lifecycle: per-trace state is weak-keyed by the local-root span, so it's reclaimed with the span (no cap, no eviction); it is also removed on local-root finish and cleared on provider close. Regression tests cover partial-flush, no-leak, and reconfiguration paths.
  • ffe_* are bare tag names on span meta (not _dd.-prefixed); subject keys are SHA256 hashes emitted only when logging is authorized.

Validation

FFE dogfooding app

Validated live against the ffe-dogfooding app via a trace-intake tee-proxy that captures the raw trace payload and decodes the ffe_* tags. Flag ffe-dogfooding-string-flag (serial 2312):

  • Gate ON — the root span (trace.annotation) carried ffe_flags_enc = iBI= decoding to serial [2312] plus a SHA256-hashed ffe_subjects_enc[2312] (doLog=true), through the real dd-openfeature provider path.
  • Gate OFF — span flushed with zero ffe_* tags.

Local system-tests run

Ran the frozen system-tests parametric suite (tests/parametric/test_ffe/test_span_enrichment.py, unchanged) against dd-java-agent 1.64.0-SNAPSHOT (+ custom dd-trace-api, dd-openfeature) built from this branch (HEAD 8491123c5d):

TEST_LIBRARY=java ./run.sh PARAMETRIC -k span_enrichment
======================= 18 xpassed in 207.57s (0:03:27) ========================

All 18 cases ran and asserted green, covering ffe_flags_enc / ffe_subjects_enc / ffe_runtime_defaults, delta-varint encoding, SHA256 subject keys with doLog gating, and the 200/10/20/5/64 limits. (Reported as xpassed only because the manifest declares the feature at release v1.64.0 while the local artifact is pre-release 1.64.0-SNAPSHOT; it resolves to a clean passed at release.) No SDK source changes were required to pass. The system-tests enablement (parametric handler + manifests/java.yml) is a separate draft PR against DataDog/system-tests.

Full dogfooding matrix + regression suite (2026-06-17)

Re-validated end-to-end through the real OpenFeature provider path (javaagent built from this
branch, java@1.64.0-SNAPSHOT+109eab80bc) behind the trace-intake tee-proxy, decoding ffe_*
with scripts/decode_ffe_span_tags.py (root span trace.annotation, service ffe-dogfooding-java):

Scenario Result
Gate ON (serial 2312) ffe_flags_enc[2312]; ffe_subjects_enc = {sha256(targeting key): ids} only when __dd_do_log
Gate OFF zero ffe_* tags
Aggregation multiple flags + 2 subjects on one root → ffe_flags_enc = [829,1442,2311,2312], nothing overwritten
Child→root eval inside a child span → ffe_* on the local root only; the child span carries none
Unicode + object runtime defaults Value structure unwrapped to JSON {"grëeting":"こんにちは","emoji":"🎉","n":3} (not Value(innerObject=…)), raw UTF-8 with no \uXXXX escaping, valid JSON, values truncated to 64
Codec parity ZAgUAg==[100,108,128,130]

Deterministic regression suite (SpanEnrichmentHookTest + SpanEnrichmentLifecycleRegressionTest, 28 cases, all pass) additionally covers: distinctTracesDoNotMerge (state keyed by local-root span identity), hookCapturedRootAndInterceptorResolvedRootAreSameObject (the capture and write sides resolve the same local-root instance), newProviderAfterShutdownStillEnriches + lateShutdownOfDisplacedProviderDoesNotClobberActiveProvider (reconfiguration), eachProviderOwnsADistinctStateStore, partialFlushNeverWritesTagsOnAChildSpan, neverCompletingTracesAreNotCapped + unreachableRootStateIsWeaklyCollected (weak-keyed lifecycle), gateOffConstructsNothingAndAccumulatesNoState, and the Value→JSON runtime-default cases.

System-tests re-confirmed against this branch's artifacts: 18 passed (TEST_LIBRARY=java ./run.sh PARAMETRIC -k span_enrichment, library java@1.64.0-SNAPSHOT+109eab80bc).

…nrichment gate

- Add nullable Integer serialId to UFC Split model + constructor (Moshi
  reflectively deserializes the serialId JSON field; no custom adapter needed)
- DDEvaluator surfaces __dd_split_serial_id (when present) and __dd_do_log in
  OpenFeature eval metadata for APM span enrichment (JAVA-01)
- Add FeatureFlaggingConfig.SPAN_ENRICHMENT_ENABLED dot-form constant mapping to
  DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED (distinct, off by default)
- Register the env var in metadata/supported-configurations.json (version A, boolean)
- Update DDEvaluatorTest Split call sites for the new constructor arg
…ceptor (gate-gated)

- ULeb128Encoder: ULEB128 delta-varint + base64 codec ported verbatim from the
  frozen Node reference (dd-trace-js#8343); golden vector {100,108,128,130} -> ZAgUAg==,
  SHA-256 hex targeting-key hashing; JDK stdlib only (java.util.Base64, MessageDigest)
- SpanEnrichmentAccumulator: per-trace state keyed by trace id in a shared
  ConcurrentHashMap; frozen limits (200/10/20/5/64), UTF-8-safe truncation, object
  default -> JSON (Pattern F tag shapes: ffe_flags_enc bare base64, ffe_subjects_enc /
  ffe_runtime_defaults JSON objects)
- SpanEnrichmentHook: OpenFeature finally hook (capture) resolving the active local
  root via AgentTracer and applying the Node branch (serialId -> addSerialId/addSubject;
  missing variant -> addDefault); error-isolated
- SpanEnrichmentInterceptor: TraceInterceptor (write) flushing ffe_* onto the local
  root onTraceComplete with unique priority 4, then clearing state; disable() for
  provider-close cleanup
- Provider: gate-gated construction via ConfigHelper.env (DG-005 — nothing built/
  registered when off), hook added to getProviderHooks, interceptor registered via
  GlobalTracer, shutdown() disables interceptor + drains state
- feature-flagging-api: add compileOnly/testImplementation :internal-api for the
  tracer/interceptor types (runtime-provided by the agent)
… golden round-trip)

- Codec golden-vector round-trip {100,108,128,130} -> ZAgUAg== (encode + decode +
  empty + structural dedupe)
- no-span, finished-root (accumulate+dedupe+flush), runtime-default missing-variant
  (ffe_runtime_defaults JSON object), per-subject cap (10 subj / 20 exp/subj) +
  doLog gating, max-200 serial ids, object-default JSON + 64-char truncation
- gate-off negative control: no hook/interceptor constructed, not in getProviderHooks,
  no accumulator state (DG-005)
- gate-on construction + provider-close cleanup (shutdown disables interceptor +
  drains state); error isolation; interceptor priority uniqueness
- JUnit 5 only (no Groovy); injectable RootSpanResolver + Provider gate override
  avoid static mocking (project mockito uses the subclass mock maker)
…econfig)

Gap-closure for the three BLOCKER lifecycle defects in 02-REVIEW-java.md. The
frozen contract (codec/limits/gate/tag shapes, golden vector ZAgUAg==) is
unchanged — this fixes only how per-trace state is scoped, bounded, and flushed.

CR-01 (partial-flush data loss + tag misattribution):
  CoreTracer.write -> interceptCompleteTrace runs onTraceComplete on EVERY flush,
  and PendingTrace.write(isPartial) excludes the still-open local root from a
  partial flush (getRootSpan() is null until the final write). The interceptor
  previously resolved the root by reference (reachable even when absent from the
  fragment) and unconditionally removed the accumulator on the first partial
  flush, dropping all pre-flush flags and tagging a not-yet-finished root.
  SpanEnrichmentInterceptor now flushes + removes ONLY when the local root is
  actually present in the flushed collection (the final write); a partial flush
  leaves the accumulator intact. Also adds the missing getTraceId() null guard
  (WR-01) and never falls back to tagging a non-root span (WR-02).

CR-02 (unbounded state leak):
  State lived in a static ConcurrentHashMap whose only removal paths were
  trace-complete and shutdown, so dropped / Noop / never-finishing traces leaked
  forever (#4844 leak class). State now lives in a per-provider
  SpanEnrichmentStates store, hard-bounded at MAX_TRACES with FIFO eviction of
  the oldest in-flight entry, so a never-completing trace cannot grow the map
  unboundedly.

CR-03 (reconfiguration corruption):
  Provider ignored addTraceInterceptor's return value, so a second gate-on
  provider (rejected on duplicate priority 4) still wired a hook into shared
  static state and its shutdown() cleared the first provider's live state.
  Provider now honors the registration result (wires the hook/interceptor only
  when registration succeeds) and each provider owns its own state store, so one
  provider's shutdown can never clear another's. Removes the global static map
  (review IN-03 root cause). Adds an injectable TraceInterceptorRegistrar test
  seam (mirrors the existing gate-override seam).

Runtime-default rendering (String() parity) and hasData() are intentionally
unchanged. L0 suite migrated to the instance-owned store; module 146 tests green.
Adds SpanEnrichmentLifecycleRegressionTest exercising the real tracer lifecycle
paths the original L0 suite bypassed (single root, no children, one provider).
Each test FAILS against the pre-fix code and PASSES after the fix:

CR-01 partialFlushExcludingRootPreservesPreFlushFlags / partialFlushNeverWrites-
  TagsOnAChildSpan: a partial flush carrying only child spans (root excluded, as
  dd-trace-core does) must not drain state or tag a child; the full pre+post-flush
  serial-id set {100,108,128,130} -> 'ZAgUAg==' must land on the root at final
  completion. Fail-before: tags written on the partial flush (NeverWantedButInvoked).

CR-02 neverCompletingTracesDoNotLeakUnbounded / boundedStoreEvictsOldestFirst:
  3x MAX_TRACES distinct never-flushed traces keep the store bounded; eviction is
  FIFO. Fail-before: store grew to 3x the cap (expected <=cap but was larger).

CR-03 secondProviderRejectedRegistrationDoesNotClearFirstProviderState /
  eachProviderOwnsADistinctStateStore / hookAndInterceptorOfOneProviderShareThe-
  SameStore: a second provider whose registration is rejected wires no
  hook/interceptor and its shutdown does not clear the first provider's in-flight
  state. Fail-before: second provider kept a non-null interceptor (expected null).

Fail-before verified by temporarily reintroducing each defect: 5/7 failed (the
remaining 2 are structural-invariant checks that hold by construction).
@datadog-datadog-prod-us1-2

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Jun 16, 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 14.79 s 14.68 s [+0.0%; +1.6%] (maybe worse)
startup:insecure-bank:tracing:Agent 13.67 s 13.69 s [-0.7%; +0.5%] (no difference)
startup:petclinic:appsec:Agent 16.87 s 16.60 s [+0.7%; +2.6%] (maybe worse)
startup:petclinic:iast:Agent 16.95 s 16.95 s [-0.9%; +0.9%] (no difference)
startup:petclinic:profiling:Agent 16.74 s 16.97 s [-2.7%; +0.1%] (no difference)
startup:petclinic:sca:Agent 16.99 s 16.71 s [+0.8%; +2.7%] (maybe worse)
startup:petclinic:tracing:Agent 16.14 s 15.99 s [-0.1%; +2.0%] (no difference)

Commit: aa31d10d · 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.

leoromanovsky and others added 4 commits June 17, 2026 01:47
…hment comments

Remove leaked workflow labels (e.g. JAVA-01) from production comments so the
upstream PR carries only behavioral context.
- Reconfiguration safety: replace the per-provider trace interceptor with a
  single process-wide delegating interceptor (SpanEnrichmentInterceptor.INSTANCE)
  registered once and rebound per active provider. A closing provider unbinds
  only if still active, so provider close/reopen no longer permanently disables
  enrichment via a duplicate-priority rejection, and a displaced provider's late
  shutdown cannot clobber the active provider's in-flight state.
- Runtime defaults: recursively unwrap OpenFeature Value (structure/list/scalar)
  to its native form before JSON serialization so ffe_runtime_defaults matches
  the frozen Node contract instead of emitting Value.toString().
- 128-bit trace ids: key per-trace state by the full trace id hex string rather
  than DDTraceId.toLong(), so two traces sharing low-order 64 bits no longer
  merge enrichment state.
- Gate-off inertness: precompute the immutable provider-hook list once in the
  constructor so getProviderHooks() (called per evaluation) allocates nothing.
- Add regression tests: reconfiguration, displaced-provider late shutdown,
  128-bit low-bit collision, Value structure/list/scalar serialization, and
  gate-off zero-allocation.
@pavlokhrebto

Copy link
Copy Markdown

Finalization pass (handoff takeover)

Picking this up to get it merge-ready. Changes since the last review, all scoped to the existing feature — the frozen wire contract (codec, limits, tag shapes, golden vector ZAgUAg==) is unchanged:

  • Config gate now uses the standard resolution path. The gate was read env-var-only (ConfigHelper.env). It now goes through ConfigProvider.getInstance().getBoolean(FeatureFlaggingConfig.SPAN_ENRICHMENT_ENABLED, false), so system property + stable config + env var all apply — matching the sibling FLAGGING_PROVIDER_ENABLED gate. This also makes the previously-unused FeatureFlaggingConfig.SPAN_ENRICHMENT_ENABLED constant the single source of truth for the key (canonical experimental.flagging.provider.span.enrichment.enabled). "1"/"true" semantics preserved; still off by default.
  • Startup INFO log added. The provider now logs span enrichment enabled / span enrichment disabled at construction (parity with dd-trace-js#8343 and the cross-language handoff requirement). Reflects effective wiring — "enabled" only when the hook was actually constructed.
  • Tech-debt cleanup. Removed the unused SpanEnrichmentInterceptor.isRegistered() method (dead surface being introduced by this PR; zero callers in prod or tests).

Verification (local):

  • :products:feature-flagging:feature-flagging-api:compileJava
  • :products:feature-flagging:feature-flagging-api:test ✅ (span-enrichment suite green)
  • :products:feature-flagging:feature-flagging-api:spotlessApply ✅ (no reformatting needed)

Files touched in this pass: Provider.java, SpanEnrichmentInterceptor.java (+ the FeatureFlaggingConfig / supported-configurations.json gate entry from earlier).

…manovsky/ffe-apm-span-enrichment

# Conflicts:
#	products/feature-flagging/feature-flagging-api/src/test/java/datadog/trace/api/openfeature/DDEvaluatorTest.java
@pavlokhrebto pavlokhrebto marked this pull request as ready for review July 7, 2026 14:57
@pavlokhrebto pavlokhrebto requested review from a team as code owners July 7, 2026 14:57
@pavlokhrebto pavlokhrebto requested review from dd-oleksii, dougqh and sameerank and removed request for a team July 7, 2026 14:57
@dd-octo-sts

dd-octo-sts Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Hi! 👋 Thanks for your pull request! 🎉

To help us review it, please make sure to:

  • Add at least one type, and one component or instrumentation label to the pull request

If you need help, please check our contributing guidelines.

@sarahchen6 sarahchen6 added the type: feature Enhancements and improvements label Jul 7, 2026

@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: 99422b5c34

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

return trace; // partial flush, or no resolvable in-fragment root: keep state untouched
}
// Key by the local-root span object to match the capture-side keying.
final SpanEnrichmentAccumulator state = states.remove((AgentSpan) localRoot);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve enrichment until long-running traces finish

When trace.experimental.long-running.enabled is true and a trace survives to a periodic long-running flush, PendingTrace emits unfinished spans including the still-running local root (writeRunningSpans adds running spans to the trace). This path then treats root presence as final and removes the accumulator, so flags evaluated before the first long-running snapshot are no longer present when the trace actually completes and later snapshots can overwrite the root tag with only newer IDs. Please keep the state until the root is finished rather than using presence in the emitted collection as the only finality check.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — this is a real edge case with long-running traces. The fix needs a "is this local root actually finished?" signal on the interface the interceptor uses (AgentSpan), which DDSpan already tracks internally but doesn't expose. I'm handling that as a small separate core PR (add AgentSpan.isFinished()), then this interceptor will key off the finished root instead of fragment presence. Tracked as a follow-up; not gating this PR since long-running traces are off by default.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opened the core prerequisite PR for this: #11884 (adds AgentSpan.isFinished()). Once it lands, the interceptor will key off the finished local root instead of fragment presence, fixing this long-running-trace case. Follow-up on this branch after #11884 merges.

static final int PRIORITY = 4;

/** The single, long-lived interceptor registered with the tracer (reconfiguration safety). */
static final SpanEnrichmentInterceptor INSTANCE = new SpanEnrichmentInterceptor();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid registering an app-classloader singleton globally

In servlet/app-server deployments where dd-openfeature is loaded by a webapp classloader, this singleton is only singleton per classloader, but the tracer's interceptor set is JVM-wide and has no removal API. The first webapp's interceptor remains registered and pins that classloader; after redeploy a new classloader creates a different INSTANCE, registration is rejected by the same priority, and the new provider's store is never read, so span enrichment is disabled until JVM restart. Register the long-lived interceptor from an agent/bootstrap classloader or otherwise avoid retaining application classloaders.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved by the capture/write split: the interceptor is now created and registered by the agent-side SpanEnrichmentWriter (agent classloader), not a per-webapp singleton in the application classloader. No application classloader is retained, so redeploys are unaffected.

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

Left several questions and concerns found by me and Claude.

Comment thread dd-trace-api/src/main/java/datadog/trace/api/config/FeatureFlaggingConfig.java Outdated
Comment on lines +44 to +45
static final String SPAN_ENRICHMENT_ENABLED_KEY = FeatureFlaggingConfig.SPAN_ENRICHMENT_ENABLED;

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.

Just for my understanding: we have this SPAN_ENRICHMENT_ENABLED_KEY in order to switch to normal mode from experimental mode later?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — it's the experimental opt-in gate, distinct from the provider-enabled gate. It lets us ship the feature off-by-default and graduate it later (drop the experimental. prefix / flip the default) without touching provider enablement.

Provider(
final Options options,
final Evaluator evaluator,
final Boolean spanEnrichmentEnabledOverride) {

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.

Q: Could you explain more about this override mode? Why we need it?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a test-only seam: the spanEnrichmentEnabledOverride constructor param lets tests force the gate on/off deterministically without mutating global config or system properties (when null, the gate is read from config as normal). Not a user-facing feature.

Comment on lines +119 to +134
@Override
public Collection<? extends MutableSpan> onTraceComplete(
final Collection<? extends MutableSpan> trace) {
try {
final SpanEnrichmentStates states = this.activeStates;
if (states == null || trace == null || trace.isEmpty()) {
return trace;
}
// Resolve the local root for this fragment, then require that the root is actually PRESENT in
// this collection. A partial flush excludes the still-open root, so its absence means "not
// the
// final write" — keep the accumulator and bail.
final MutableSpan localRoot = findLocalRootInFragment(trace);
if (!(localRoot instanceof AgentSpan)) {
return trace; // partial flush, or no resolvable in-fragment root: keep state untouched
}

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.

Process-wide O(N) scan on every trace flush (the headline concern).
SpanEnrichmentInterceptor.INSTANCE is a single, long-lived TraceInterceptor that, once a gate-on provider binds it, stays bound for the life of the app (a provider is normally created once and never shutdown()). Its
onTraceComplete runs on every trace flush in the entire process — not just traces that evaluated a flag (SpanEnrichmentInterceptor.java:120). For each flush it calls findLocalRootInFragment (:161), which iterates the span
collection to confirm the local root is present:

  • Common case (first span is the local root) → O(1).
  • But SpanList order is not guaranteed root-first, and every partial flush excludes the root, forcing a full O(N) scan that returns null (:173).

So enabling the gate adds an O(N)-worst-case scan plus a global lock acquisition (states.remove, :136) to the tracer's hot serialization/write path, for services that may have zero feature flags. This is the single biggest
product-wide effect. Worth: (a) fast-pathing traces with no accumulated state, and (b) confirming the scan cost against high-span-count / high-partial-flush workloads before this graduates from experimental.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly addressed: there's now a states.isEmpty() fast path so a trace that evaluated no flags skips the scan + lock entirely. Removing the scan in the flag-bearing case (resolving the root in O(1)) needs the AgentSpan.isFinished() core addition mentioned on the long-running-traces thread; that's the same follow-up PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: the O(1) removal of this scan rides on #11884 (AgentSpan.isFinished()), which is now open. With it, the interceptor resolves the local root directly and only acts when finished, dropping the fragment scan entirely. The states.isEmpty() fast path already landed here in the meantime.

Comment on lines +397 to +402
// present so the span-enrichment hook can decide whether to record the subject.
if (split.serialId != null) {
metadataBuilder.addString("__dd_split_serial_id", split.serialId.toString());
}
metadataBuilder.addString(
"__dd_do_log", String.valueOf(allocation.doLog != null && allocation.doLog));

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.

Minor: unconditional metadata additions even when the gate is OFF.
DDEvaluator.resolveVariant now always adds __dd_do_log (and __dd_split_serial_id when present) to every evaluation's ImmutableMetadata (DDEvaluator.java:397-402), regardless of the enrichment gate. This is gated only by the
provider-enabled flag, not the enrichment flag, so the "zero idle overhead when enrichment is off" claim is slightly overstated: an enabled provider pays two extra metadata string builds per evaluation whether or not enrichment is
on.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the two addString calls are now gated on the span-enrichment flag (read once at class load), so an enabled provider with enrichment off attaches no extra metadata per evaluation.

Comment on lines +34 to +46
private final Map<AgentSpan, SpanEnrichmentAccumulator> states = new WeakHashMap<>();

/** Returns the accumulator for {@code root}, creating (and inserting) it if absent. */
synchronized SpanEnrichmentAccumulator getOrCreate(final AgentSpan root) {
SpanEnrichmentAccumulator existing = states.get(root);
if (existing != null) {
return existing;
}
final SpanEnrichmentAccumulator created = new SpanEnrichmentAccumulator();
states.put(root, created);
return created;
}

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.

Single global lock on the flag-evaluation hot path.
SpanEnrichmentStates guards a WeakHashMap with one intrinsic lock per store (SpanEnrichmentStates.java:34-45), and there is one store per active provider. Every flag evaluation's finallyAfter hook takes that lock (getOrCreate),
and the trace-writer thread takes the same lock on every flush (remove). Under high-concurrency flag evaluation across many traces, this serializes on one monitor and can briefly stall the writer thread. Lock hold times are short
(O(1) map touches), so this is a scalability ceiling rather than a correctness issue — but it's a shared choke point between app threads and the writer thread.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's a shared choke point rather than a correctness issue. Lock hold times are O(1) map touches and this is behind an experimental, off-by-default gate, so I'd rather not add lock-striping complexity pre-emptively — happy to revisit if benchmarks show contention once the feature graduates.

Comment on lines +69 to +73
static String hashTargetingKey(final String value) {
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
final StringBuilder hex = new StringBuilder(hash.length * 2);

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.

MessageDigest.getInstance("SHA-256") per subject capture.
hashTargetingKey (ULeb128Encoder.java:70) allocates a fresh MessageDigest on every addSubject call (i.e. every doLog=true evaluation with a targeting key). Provider lookup + allocation per evaluation is avoidable — a
ThreadLocal (with .reset()) would remove it from the hot path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — switched to a ThreadLocal<MessageDigest> (with reset()), so subject hashing no longer does a provider lookup + allocation on every capture.

@AlexeyKuznetsov-DD

Copy link
Copy Markdown
Contributor

Also Claude found 2 generic conserns:

  1. Tag cardinality. ffe_* land on meta as bare (non-_dd.-prefixed) tags, so they're indexable. ffe_subjects_enc and ffe_flags_enc values are near-unique per request → high-cardinality tag values. If a customer facets/indexes on
    them, this can inflate APM indexing and custom-metric cardinality costs. Consider documenting that these should not be turned into indexed facets.
  2. Internal fields leak into the public evaluation API. __dd_split_serial_id / __dd_do_log are injected into ImmutableMetadata, which is returned to the OpenFeature client via FlagEvaluationDetails.getFlagMetadata(). Customers inspecting evaluation details will see Datadog-internal _dd* keys. Cosmetic, but it's an unintended public-surface leak.

@sarahchen6 sarahchen6 added the comp: openfeature OpenFeature label Jul 7, 2026

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

  • deleted.

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

Quick feedback as there is a dependency issue wrt the product API.

// write tier + active-root-span lookup. compileOnly because the agent runtime
// (feature-flagging-agent depends on :internal-api) provides these classes; the published
// dd-openfeature jar must not bundle the tracer.
compileOnly(project(":internal-api"))

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.

🔨 issue: ‏A product API should not depend on the global internal API.
Can you move this to the FFE lib module instead?

@pavlokhrebto pavlokhrebto Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 👍

The published dd-openfeature API no longer touches :internal-api. I moved the span-enrichment write tier (SpanEnrichmentInterceptor, SpanEnrichmentStates, SpanEnrichmentAccumulator, ULeb128Encoder) into feature-flagging-lib — the agent-side module that already depends on the tracer core — and split capture from write over the existing FeatureFlaggingGateway seam, same as the exposure path does:

  • bootstrap: new SpanEnrichmentEvent (JDK types only) + a listener/dispatch on the gateway.
  • api (published): SpanEnrichmentHook just fires an event at the gateway now (unwrapping any OpenFeature Value to native first), and Provider lost all the GlobalTracer/interceptor wiring. compileOnly(:internal-api) is gone.
  • lib: a SpanEnrichmentWriter listens on the gateway, resolves the active local root, accumulates, and registers the interceptor lazily.

Nice side effect: since there's now a single agent-side owner instead of per-provider wiring, the whole rebinding / app-classloader-pinning / concurrent-provider mess just goes away. Wire contract unchanged.

@PerfectSlayer PerfectSlayer Jul 8, 2026

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.

the whole rebinding / app-classloader-pinning / concurrent-provider mess just goes away

Indeed, that’s easier when we aligned everything right 😅

I had a look at what remain as dependency to :dd-trace-api and it’s only your configuration file... I think the right this would be to move it away from tracing, in its own module, so there will no longer coupling between FFE and Tracing.
I write a proposal here #11888. Feel free to review, comment, cherry-pick. What’s best for you! (I did not want to push directly to your branch 😬 )

}

/** Serializes a String->String map to a compact JSON object string (keys + values escaped). */
static String toJsonObject(final Map<String, String> map) {

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.

🎯 suggestion: ‏It seems there is a lot of duplicate with the platform :components:json module.
Can you check if you can leverage it instead?

@pavlokhrebto pavlokhrebto Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — switched SpanEnrichmentAccumulator to datadog.json.JsonWriter.
Before adopting it I confirmed the escaping difference is safe: these tags are consumed by JSON-parsing (backend enricher via Jackson, parametric system-tests via json.loads), so JsonWriter's /→\/ and non-ASCII→\uXXXX round-trip identically — byte-parity with the JS reference isn't required. ffe_flags_enc is a bare base64 string, untouched.

Thanks for the nudge.

…digest

  - Gate the __dd_split_serial_id / __dd_do_log evaluation metadata on the
    span-enrichment flag so an enabled provider with enrichment off attaches
    nothing extra per evaluation (DDEvaluator).
  - Extract the metadata keys to named constants (METADATA_SPLIT_SERIAL_ID /
    METADATA_DO_LOG) as the single source of truth; SpanEnrichmentHook
    references them.
  - Rename the config constant to EXPERIMENTAL_SPAN_ENRICHMENT_ENABLED to make
    its experimental status obvious at call sites.
  - Reuse a ThreadLocal<MessageDigest> for subject hashing instead of
    allocating a SHA-256 instance per capture (ULeb128Encoder).
pavlokhrebto added a commit that referenced this pull request Jul 8, 2026
Add a non-breaking default boolean isFinished() to the AgentSpan
interface (internal-api), surfacing the finished state DDSpan already
tracks (durationNano != 0) so other modules can query it without
reaching into dd-trace-core. DDSpan's existing method overrides the
default; the TrackingSpanDecorator test wrapper delegates; leaf/synthetic
spans (NoopSpan etc.) keep the false default. Adds a DDSpanTest case
covering false-before / true-after-finish via the interface.

Motivated by the FFE span-enrichment interceptor (PR #11658), which needs
to distinguish a truly-finished local root from a still-running one
(long-running traces) and resolve it in O(1); that consumer lands in a
separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
metadataBuilder.addString(METADATA_SPLIT_SERIAL_ID, split.serialId.toString());
}
metadataBuilder.addString(
METADATA_DO_LOG, String.valueOf(allocation.doLog != null && allocation.doLog));

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.

Nit: Is it required that these are strings? I would have imagined __dd_do_log would be a boolean

void jsonUsesPlatformWriterEscaping() {
// The platform JsonWriter escapes '/' as backslash-slash and non-ASCII as a backslash-u escape.
// That differs from the JS reference bytes but is round-trip-equivalent: all consumers
// JSON-parse these tags (backend Jackson, system-tests json.loads), so byte-parity is not

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.

Another way to verify this is https://github.com/DataDog/ffe-dogfooding

You may be able to run a local build of this code. Might need some extra setup to enable enrichment, but I was ultimately able to verify that this worked for the JS implementation when I saw this in the traces

Image

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

left some small comments, and ran the branch by Codex!

Comment thread products/feature-flagging/feature-flagging-api/build.gradle.kts Outdated
PerfectSlayer and others added 3 commits July 9, 2026 09:48
…TraceInterceptor() false return: ensureInterceptorRegistered() now latches only on a true return; registration ordering: moved ensureInterceptorRegistered() below the root == null check

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

Approving to remove the request for changes flag.
I focused my review on the high level design, I’ll let my pear having a review of the changes in details 🙏

if (root == null) {
return; // no active span → nothing to enrich (and nothing to register the interceptor for)
}
ensureInterceptorRegistered();

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.

should we gate the following logic, e.g. get/create state and adding serial IDs, on the result of ensureInterceptorRegistered(); ?

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

Labels

comp: openfeature OpenFeature type: feature Enhancements and improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants