Skip to content

feat(openfeature): bound evaluation context reported with flag evaluations - #19881

Open
vjfridge wants to merge 24 commits into
mainfrom
vickie/ffl-3060-bounded-evaluation-context
Open

feat(openfeature): bound evaluation context reported with flag evaluations#19881
vjfridge wants to merge 24 commits into
mainfrom
vickie/ffl-3060-bounded-evaluation-context

Conversation

@vjfridge

@vjfridge vjfridge commented Aug 27, 2026

Copy link
Copy Markdown

Description

Implements FFL-3060 by moving OpenFeature evaluation-context ownership to a bounded, immutable snapshot created synchronously before queue admission.

The snapshot boundary:

  • retains at most 256 flattened fields;
  • limits flattened keys and string values to 256 characters;
  • limits mapping/list width to 256 and traversal depth to 4;
  • uses a 1,280 visited-node guard and path-local cycle detection;
  • preserves mapping insertion order and Python list paths such as tags[0], while omitting None leaves;
  • does not introduce a new numeric or targeting-key length cap;
  • drops non-finite floats (NaN, Infinity) as unsupported_value, since json.dumps would otherwise emit bare tokens that are invalid JSON under RFC 8259;
  • drops individual unsupported fields with unsupported_value; snapshot traversal or canonicalization failures fail closed to empty context plus snapshot_error, without calling arbitrary conversion hooks;
  • stores no caller-owned mutable values.

The writer also separates fork-safe queue, lifecycle, counter, and aggregation state; keeps pre_queue_overflow, queue_overflow, and closed accounting distinct; and makes shutdown linearizable with accepted queue events and counters included in the final drain. Both the drain worker's join and the provider's join are bounded by DRAIN_WORKER_JOIN_TIMEOUT.

Benchmark profiles and deterministic regression coverage are included, with SLO entries for all eight configs.

Testing

Repository-approved product validation on commit 3bbe2021ff:

  • scripts/run-tests --venv 18a4a8d -- -s
    • 703 passed, 2 skipped
  • scripts/run-tests --venv 17b66d6
    • global-lock detection passed
  • source typing, formatting, and style checks passed
  • scripts/lint suitespec-check and scripts/lint error-log-check passed
  • git diff origin/main..HEAD --check passed

The benchmark-only follow-up commits were validated on the current head:

  • scripts/lint checks passed all 24 checks after the deterministic benchmark and SLO changes
  • scripts/gen_gitlab_config.py confirms the benchmark scenario is gated and all eight SLO thresholds survive the generator's filter
  • the corrected local A/B run measured hook-plus-drain-scale at 64.62 us for the merge-base baseline and 30.66 us for the candidate
  • GitLab x86 job 1992838683 passed and supplied the candidate measurements used for the SLOs

The three guards in this round were mutation-tested by reverting each one and confirming that the corresponding tests fail:

  • reverting the empty-key fix makes {"": {"a": 1}} flatten to {'a': 1} instead of {'.a': 1}
  • reverting either inline finiteness guard fails the corresponding non-finite scalar or list-element cases

Risks

The corrected x86 benchmark measurements and calibrated absolute SLOs are:

config candidate SLO headroom
aggregate-typical 25.668 us 29 us 13.0%
hook-enqueue-typical 29.913 us 33 us 10.3%
hook-enqueue-scale 38.937 us 43 us 10.4%
aggregate-scale 57.159 us 64 us 12.0%
hook-plus-drain-scale 103.885 us 120 us 15.5%
hook-enqueue-stress 263.562 us 300 us 13.8%
hook-enqueue-bounded-adversarial 337.808 us 380 us 12.5%
aggregate-stress 496.515 us 550 us 10.8%

hook-plus-drain-scale now drains every complete 2,500-event cycle and the final partial cycle. This prevents independent pyperf loop calibration from making the baseline and candidate perform different amounts of aggregation. The corrected x86 run measured 215.990 us for the baseline and 103.885 us for the candidate, so the candidate is approximately 51.9% faster. This supersedes the apparent 63.5-65.1% regression reported for commit 3bbe2021ff.

The Riot Docker setup prints a worktree-path Git warning during preparation, but the selected suites complete successfully.

Additional Notes

FFL-3118 and FFL-3119 remain separate dependencies and are not modified here.

Capture immutable, bounded evaluation context before queue admission and harden writer lifecycle, shutdown, fork, and drop accounting. Add deterministic regression coverage and registered benchmark profiles for FFL-3060.

Generated with Claude Code
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

.gitlab/benchmarks/bp-runner.microbenchmarks.fail-on-breach.template.yml  @DataDog/apm-ecosystems-performance @DataDog/apm-core-python
benchmarks/openfeature_flagevaluation/config.yaml                       @DataDog/apm-core-python
benchmarks/openfeature_flagevaluation/scenario.py                       @DataDog/apm-core-python
benchmarks/suitespec.yml                                                @DataDog/apm-core-python
ddtrace/internal/openfeature/_flag_eval_evp_hook.py                     @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_flagevaluation_writer.py                  @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_provider.py                               @DataDog/feature-flagging-and-experimentation-sdk
releasenotes/notes/bound-openfeature-evaluation-context-622f80a3e1cf72d7.yaml  @DataDog/apm-python
tests/openfeature/test_flag_eval_evp_hook.py                            @DataDog/feature-flagging-and-experimentation-sdk
tests/openfeature/test_flagevaluation_writer.py                         @DataDog/feature-flagging-and-experimentation-sdk

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 27, 2026

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector
ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented Aug 27, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog auto-retried 2 jobs - 2 passed on retry View in Datadog

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 2443fd9 | Docs | View more details | Give us feedback!

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 27, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 240 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 240 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=135)
ddtrace.llmobs._utils -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)
ddtrace.llmobs._integrations.langchain -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)
ddtrace.llmobs._integrations.pydantic_ai -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)
ddtrace.internal.test_visibility.api -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=133)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

@vjfridge vjfridge added the changelog/no-changelog A changelog entry is not required for this PR. label Aug 27, 2026
@pr-commenter

pr-commenter Bot commented Aug 27, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-28 05:25:18

Comparing candidate commit 2443fd9 in PR branch vickie/ffl-3060-bounded-evaluation-context with baseline commit 3e13bc9 in branch main.

📊 Benchmarking dashboard

Found 4 performance improvements and 6 performance regressions! Performance is the same for 574 metrics, 10 unstable metrics, 1 known flaky benchmarks, 17 flaky benchmarks without significant changes.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+2.415µs; +2.544µs] or [+14.779%; +15.571%]

scenario:iastaspects-rstrip_aspect

  • 🟥 execution_time [+72.114µs; +79.707µs] or [+18.819%; +20.800%]

scenario:iastaspects-stringio_noaspect

  • 🟥 execution_time [+27.027µs; +31.402µs] or [+8.185%; +9.510%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+121.180µs; +126.793µs] or [+30.923%; +32.356%]

scenario:openfeatureflagevaluation-hook-enqueue-bounded-adversarial

  • 🟩 execution_time [-253.838µs; -239.564µs] or [-43.401%; -40.960%]

scenario:openfeatureflagevaluation-hook-enqueue-scale

  • 🟩 execution_time [-11.082µs; -10.732µs] or [-22.372%; -21.665%]

scenario:openfeatureflagevaluation-hook-enqueue-stress

  • 🟩 execution_time [-258.961µs; -243.018µs] or [-50.314%; -47.217%]

scenario:openfeatureflagevaluation-hook-plus-drain-scale

  • 🟩 execution_time [-111.929µs; -110.787µs] or [-51.983%; -51.453%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+541.732ns; +588.470ns] or [+20.285%; +22.035%]

scenario:tracer-small

  • 🟥 execution_time [+32.109µs; +34.385µs] or [+10.104%; +10.820%]

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:coreapiscenario-context_with_data_listeners

  • unstable execution_time [-696.758ns; +786.362ns] or [-6.305%; +7.116%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-31.627ns; +34.503ns] or [-5.226%; +5.702%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1586.693ns; +1719.004ns] or [-9.344%; +10.123%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1338.468ns; +1195.268ns] or [-10.204%; +9.112%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-328.999ns; +320.499ns] or [-8.995%; +8.762%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-250.482ns; +254.386ns] or [-8.660%; +8.794%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-77.748ns; +66.352ns] or [-6.495%; +5.543%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3989.104ns; +4081.982ns] or [-9.650%; +9.874%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-724.660ns; +832.850ns] or [-8.834%; +10.153%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-60.007µs; +57.156µs] or [-9.633%; +9.175%]

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

scenario:span-start

  • 🟥 execution_time [+1.515ms; +1.762ms] or [+11.261%; +13.092%]

Known flaky benchmarks without significant changes:

  • scenario:errortrackingflasksqli-baseline
  • scenario:flasksimple-iast-get
  • scenario:iastaspects-casefold_aspect
  • scenario:iastaspects-casefold_noaspect
  • scenario:iastaspects-index_aspect
  • scenario:iastaspects-ljust_noaspect
  • scenario:iastaspects-lower_aspect
  • scenario:iastaspects-replace_aspect
  • scenario:iastaspects-swapcase_aspect
  • scenario:iastaspects-title_noaspect
  • scenario:iastaspects-translate_aspect
  • scenario:iastaspects-translate_noaspect
  • scenario:iastaspects-upper_noaspect
  • scenario:packagespackageforrootmodulemapping-cache_off
  • scenario:packagespackageforrootmodulemapping-cache_on
  • scenario:sethttpmeta-all-enabled
  • scenario:telemetryaddmetric-record-100-metrics

vjfridge and others added 8 commits August 27, 2026 00:27
The two snapshot-error log calls used exc_info=True. The bounded walk calls
__iter__ and __getitem__ on the caller's own context object, so a
caller-raised exception message and its traceback can carry customer context
data. The canonicalization handler on the drain thread has the same exposure
through traceback frame locals.

Context data is consent-gated in the context.evaluation payload, so a leak to
the log sink bypasses that gate entirely. Log the exception type name instead,
which is a fixed vocabulary.

Adds a test per site asserting the sensitive text does not appear in the log
record and that exc_info stays unset.

Co-Authored-By: Claude <noreply@anthropic.com>
…e snapshot

An unsupported value or a non-string key raised TypeError from _validated_leaf
and _flatten_mapping. That aborted the whole walk, so one bad field discarded
every valid field around it.

Ruby's bounded_context_snapshot falls through its leaf type case instead. One
caller value cannot discard its neighbours. This change matches that behaviour:
the offending field is skipped, a new unsupported_value truncation reason is
counted, and the walk continues.

The leaf check also moves from exact type identity to isinstance with unbound
builtin normalization. IntEnum, StrEnum, and str subclasses used for lazy
translation are common in real evaluation context. They were dropped before.
Normalizing through str.__str__, int.__int__, float.__float__, and
datetime.isoformat keeps a caller override of those hooks from running.

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
_stop_drain_worker called worker.join() with no timeout. Aggregation invokes
__eq__ and __hash__ on the caller's own canonical context key, so a caller
implementation that blocks or deadlocks holds process exit open forever.

Bound the wait at 5 seconds, matching dd-trace-rb and dd-trace-java.

A timeout is safe. _aggregate and the periodic() snapshot both hold self._lock,
so a worker that is still running writes into the post-swap maps instead of
corrupting the flush. Such an event flushes late rather than never.

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
_accepting_events was a plain attribute, so it survived a fork unchanged. Only
_start_service sets it back to True, and a fork child never calls it: threads.
_after_fork_child restarts the PeriodicThread directly.

A child forked while the parent was in on_shutdown or _stop_service therefore
inherited False. Its drain and flush threads ran, but every enqueue dropped as
"closed" for the life of the process.

Move the flag into _WriterProcessState, which forksafe.ResetObject replaces in
the child, alongside the queue, aggregation maps, and counters. The child now
starts with its intake open. The parent state is unaffected.

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
The hook returns to the caller immediately after enqueue, so the caller is free
to reuse or mutate the context object it passed. The PR relies on the snapshot
being detached, but no test held that guarantee.

Add two tests. The first mutates every container kind after
flatten_and_prune_context: a scalar, a nested mapping, a list element, plus a
key added and a key deleted. The second does the same through enqueue and
asserts the queued event, including that the buffered mapping rejects writes.

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
…apshots

The existing tests cover the same dict object and differing insertion order.
Neither covers what aggregation actually depends on: two separate evaluations,
each building its own snapshot object, must produce one identical key so they
bucket together.

Add a test over every supported leaf type — str, int, bool, float, None,
datetime, nested mapping, and list — built twice in opposite field order, and a
test that fifty repeated calls yield exactly one distinct key.

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
enqueue runs on arbitrary caller hook threads and spans three separate locks.
The O(1) queue-full check is not atomic with put_nowait, so the pre-queue and
queue.Full drop paths can both run for the same offered load. The file had no
concurrency test, so a lost update or a double count in either counter would
not have been caught.

Add two tests. The first drives 8 threads at a 64-slot queue and asserts
buffered + dropped_pre_queue + dropped_queue equals the total attempted, and
that both drop paths were exercised. The second races 6 producers against a
drain-and-snapshot loop and asserts the same identity across the flushed,
residual, and dropped counts.

Both assert no caller thread blocked. Verified over five consecutive suite runs
under pytest-randomly reordering.

Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
Remove the Python-only traversal guard, preserve exact drop telemetry, and make shutdown flushing race-safe across writer workers.

Generated with Claude Code
@vjfridge vjfridge changed the title feat(openfeature): bound evaluation context snapshots perf(openfeature): bound evaluation context snapshots Aug 27, 2026
@vjfridge vjfridge changed the title perf(openfeature): bound evaluation context snapshots Bound flag evaluation context snapshots Aug 27, 2026
@vjfridge vjfridge changed the title Bound flag evaluation context snapshots feat(openfeature): bound flag evaluation context snapshots Aug 27, 2026
vjfridge and others added 3 commits August 27, 2026 18:55
Remove six tests that assert implementation text, base classes, or the
test helper itself rather than writer behavior, and merge four groups of
duplicated setup into their emission-side counterparts.

The cap constants keep a single assertion, restated as the cross-SDK
derivation they encode: these bounds are mirrored in dd-trace-rb,
dd-trace-java, and dd-trace-go, so drift makes the same customer context
serialize differently per language.

Rename test_drop_accounting_is_complete_no_silent_loss, whose docstring
claimed a conservation check the body never performed, and point it at
the concurrent test that does.

684 passed, 2 skipped on riot venv 14fc413 (py3.13), unchanged from
before the trim.

Generated with Claude Code
… walk

The bounded traversal added in this branch regressed the hook enqueue path by
60-75% across the openfeatureflagevaluation benchmarks. Nearly all of it was
per-field dispatch overhead rather than the new caps themselves.

Every field, including a plain string, reached its leaf through a call to
_flatten_bounded, which tests isinstance against the Mapping and Sequence ABCs
before falling through to _validated_leaf and its five-branch isinstance
ladder. ABC isinstance is not cheap, and an exact scalar can match neither
container protocol, so that dispatch was pure overhead on the common case.

Take exact str/int/float/bool/None to the output dict directly from the
container loops. Subclasses still take the _flatten_bounded path, so the
unbound-builtin normalization that keeps a caller __str__ or __float__
override from running is unchanged.

Also hoist the per-container key allowance out of the loops; it depends only
on the prefix, which is fixed for the life of one container frame.

Verified behavior-identical against the pre-change walk over 4046 inputs
(cap boundaries, cycles, depth limits, StrEnum/IntEnum, caller-override
subclasses, raising iterators, plus 4000 randomized contexts): snapshots,
truncation reasons, raised exceptions, and canonical keys all agree.

flatten_and_prune_context, vs this branch / vs main:
  typical (10 str fields)    5.57 -> 1.45 us   -74% / -63%
  scale (20 str fields)     10.85 -> 2.49 us   -77% / -66%
  mixed types (10)           4.73 -> 1.44 us   -69% / -56%
  list (10 elements)         6.72 -> 2.43 us   -64% / -51%
  adversarial (257x257ch)  186.76 -> 32.46 us  -83% / -72%

Co-Authored-By: Claude <noreply@anthropic.com>
canonical_context_key runs on the drain worker, so this is off the evaluation
hot path, but it is charged per aggregated bucket and shows up in the
openfeatureflagevaluation-hook-plus-drain-scale benchmark.

Three costs removed, all per context field:
- struct.pack(">Q", n) re-parses the format string on every call. A prebound
  Struct(">Q").pack skips that.
- _length_delimited allocated an intermediate prefix+data bytes object that
  was immediately concatenated again by the caller. Append the length and the
  payload to the parts list separately and let the single join do the work.
- sorted(attrs.keys()) materializes a keys view; sorted(attrs) iterates the
  mapping directly for the same result.

_length_delimited had no remaining callers and no test or external reference,
so it is removed rather than left as dead code. The wire encoding is
unchanged, which the existing canonical-key tests cover.

canonical_context_key, vs this branch:
  typical (10 fields)    3.50 -> 2.92 us   -17%
  scale (20 fields)      6.68 -> 5.72 us   -14%
  mixed types (10)       3.48 -> 2.98 us   -14%
  list (10 elements)     3.24 -> 2.89 us   -11%

Co-Authored-By: Claude <noreply@anthropic.com>
@vjfridge vjfridge removed the changelog/no-changelog A changelog entry is not required for this PR. label Aug 27, 2026
vjfridge and others added 3 commits August 27, 2026 20:02
…ce-rb

The bounded context snapshot diverged from dd-trace-rb's bounded_flatten in
two ways, so the same caller context produced different context.evaluation
payloads and different canonical keys — meaning the same user could land in
different aggregation buckets depending on the SDK.

Depth was off by one. _flatten_bounded checked the depth of the container it
was descending from, permitting a retained leaf at five path segments where
Ruby caps it at four. The check now compares depth + 1 in both the mapping
and sequence branches.

Null leaves were retained. Python emitted them as fields and canonicalized
them as tag "o" + "None", while Ruby drops nil leaves entirely and encodes a
nil value as tag "o" with no bytes. Nulls are now omitted on both inline fast
paths and in _validated_leaf, via a sentinel that drops the field without
recording a truncation reason (omission is by design, not truncation). The
canonical encoder's unreachable null branch is aligned for defense in depth.

Verified by running both implementations over identical inputs; all vectors
now agree except the known tags[0] vs tags.0 array notation, which is
unchanged pending backend-owner approval under FFL-3060.

Generated with Claude Code

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commit 75dbf4c ("harden bounded evaluation context handling") removed
MAX_VISITED_NODES as a "Python-only traversal guard". That premise was wrong:
dd-trace-rb defines MAX_VISITED_NODES and enforces it at four sites in
bounded_context_snapshot, so the cap is part of the cross-SDK contract.

Without it, the remaining caps do not bound total work. Null leaves are omitted
rather than emitted, so output never grows, so the global field cap never trips,
and the width caps are only per-container. Traversal cost is therefore
width ** (depth + 1) -- about 1.1e12 nodes at the configured caps -- on a
caller-supplied mapping, inline on the evaluation path before queue admission.
A nested all-null context measured at over two minutes without terminating;
it now completes in under a millisecond and reports max_visited_nodes.

The budget rides in the existing shared traversal cell (negative means
terminal), so the hot loops keep a single check instead of two. Cost is about
8% on typical contexts (1.37 -> 1.48 us) and 17% on a 256-field context.

Also replaces test_more_than_old_visited_node_cap_retains_later_scalar, which
was added by the removing commit and asserted that a 1,286-node context still
retains a later scalar. dd-trace-rb returns {} with max_visited_nodes on that
exact input, so the test asserted the opposite of the parity it cited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e-rb

Python reports unsupported_value for a type-rejected key or leaf; dd-trace-rb
drops the same field silently and records nothing, so Ruby operators cannot see
type-rejected fields. Verified against bounded_context_snapshot for unsupported
value types, unsupported key types, and the nested case: all three return the
field-dropped snapshot with an empty reason set.

Keep the Python reason. It is additive operator visibility -- the retained key
set is identical across SDKs and truncation reasons are local advisory
telemetry, so it does not bucket the same caller context differently per
language. Documented at the constant so the next reader does not delete it
chasing reason-vocabulary parity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vjfridge vjfridge changed the title feat(openfeature): bound flag evaluation context snapshots feat(openfeature): bound evaluation context reported with flag evaluations Aug 28, 2026
vjfridge and others added 3 commits August 27, 2026 21:12
Match the conventions the FFE notes have converged on in review: lowercase
the scope prefix, which the note's own "openfeature:" tag already implies,
and drop "telemetry" as internal vocabulary.

Lead with the consequence rather than the mechanism. A customer needs to
know that context fields past the limits are omitted from the reported
evaluation, and that flag resolution is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…context

The separator decision in _flatten_mapping keyed off len(prefix) rather than
the root flag. Only the root mapping legitimately omits the "." separator, but
a nested container reached through an empty caller key also has an empty
prefix, so it reclaimed the separator's character and dropped its own path
segment.

The result is that {"": {"a": 1}} and {"a": 1} both flattened to {"a": 1}.
Two distinct caller contexts then produced the same canonical key and merged
into one aggregation bucket. That breaks the guarantee the canonical key exists
to provide: the key is deliberately not a hash, so distinct contexts must
always produce distinct keys.

Key the separator and the per-field key budget off root instead. The budget
change is the same bug in the other direction -- a nested container with an
empty prefix would silently reclaim the separator's character from the 256-char
key allowance.

Verified by reverting the fix against the new test: {"": {"a": 1}} flattens to
{'a': 1} instead of {'.a': 1}.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
json.dumps defaults to allow_nan=True, which emits bare NaN, Infinity, and
-Infinity tokens. None of those are valid JSON under RFC 8259. A single such
value in an evaluation context therefore made the whole request body
undecodable at the intake, discarding every event batched with it -- not just
the event that carried the value.

A caller reaches this without doing anything unusual. numpy.float64 arrives as
NaN whenever a context passes through a dataframe with a missing cell, and a
computed ratio yields inf on a zero denominator.

Guard the float leaf at all three sites that can admit one:

* the inline fast path in _flatten_mapping, split out from the int/bool case so
  integer leaves keep paying nothing for the check
* the inline fast path in _flatten_sequence, which has its own leaf handling
* _validated_leaf, which is where float subclasses such as numpy.float64 land

Each drops the single field and records unsupported_value, matching how every
other rejected leaf behaves. The ValueError raised in _validated_leaf is
already an enumerated case in the caller's handler, so it drops one field
rather than escaping.

Also set allow_nan=False in _json_dumps as a backstop. If a non-finite value
ever bypasses the snapshot, _encode_payload_event catches ValueError and counts
one serialization_error drop, so the cost is one event instead of the payload.

Verified by reverting the two inline guards against the new tests: all three
parametrized cases and the list-element case fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vjfridge and others added 5 commits August 27, 2026 21:56
…iter

The writer's own drain worker already had DRAIN_WORKER_JOIN_TIMEOUT, but the
provider's shutdown path called join() with no argument. PeriodicService.join
forwards its timeout straight to Thread.join, so None means block forever.

The final flush runs inside the worker's on_shutdown, which talks to the agent.
A hung agent connection there would hold process exit open until the
orchestrator's kill timer fired, turning a telemetry problem into a visibly
slow shutdown for the application.

Reuse the writer's existing constant rather than introducing a second timeout,
so both joins stay bounded by the same value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…note

Two gaps remained after the previous pass.

The note described the effect but not the limits, so a customer could not tell
whether their context was affected without reading the source. State the cap
values: 256 fields, 256-character keys and string values, 256 entries per list
or nested object, and 4 levels of nesting.

The change to which value *types* are reported was invisible. It is a separate
concern from the caps and it is the part that silently drops data a customer
was previously getting: Decimal, UUID, and custom objects used to be converted
to strings and are now omitted. Move that to upgrade:, where a behavior change
that needs action belongs, and give the workaround -- convert to a string in
the evaluation context before calling the client.

Keep "flag resolution itself is unaffected" in both sections. It is the line
that stops a reader worrying this changes which variant they receive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The scenario was added with nine configs but no SLO entries, so it ran and
reported without gating anything. A regression on the evaluation hot path would
have landed silently.

Thresholds are the higher of two local runs plus the ~10% margin that
.gitlab/benchmarks/microbenchmarks.md prescribes. Both runs agreed within
about 6% on every config, so the suite is stable enough to gate:

  aggregate-typical                  7.2 /  7.3 us
  hook-enqueue-typical               8.3 /  8.6 us
  hook-enqueue-scale                10.4 / 11.8 us
  aggregate-scale                   13.7 / 14.4 us
  hook-plus-drain-scale             29.3 / 29.9 us
  hook-enqueue-stress               64.0 / 67.7 us
  hook-enqueue-bounded-adversarial  80.5 / 83.0 us
  aggregate-stress                 142.6 / 144.6 us

Measured on ARM Docker, so the absolute numbers will shift on the pinned x86
runners. The first pipeline has no baseline containing the scenario, so a
mis-set threshold surfaces as a visible gate result before it can block anyone;
widen the specific entry if one trips.

Verified with scripts/gen_gitlab_config.py that the generator lists the
scenario in a matrix entry and that all eight thresholds survive its filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drain every complete cycle and any calibrated residual batch so pyperf loop-count differences cannot change how much aggregation each side measures. Remove duplicate queue-full polling from the timed path.

Co-Authored-By: Claude <noreply@anthropic.com>
Set the eight absolute execution-time gates from corrected measurements on the pinned x86 runner. Each threshold keeps approximately 10-16% headroom over GitLab job 1992838683.

Co-Authored-By: Claude <noreply@anthropic.com>
@vjfridge

Copy link
Copy Markdown
Author

Note to self: ffe-dogfooding

Tested ffe-dogfooding main (91c8cf523809ccb4facf8bd54a6eb9cac69f1533) against the local PR head (2443fd95e5da487abf166f0738fcb9978e6f7286).

  • Built and installed local ddtrace 4.15.0rc1; the installed flag-evaluation writer checksum matched the checkout.
  • Python reached PROVIDER_READY.
  • Sent 18 evaluations. All returned HTTP 200 and produced EVP data. Mock intake captured 15 aggregated rows representing all 18 evaluations, with the expected fields and counts.
  • Context bounds worked: 300 attributes were capped at 256; overlong keys/values and excess nesting were dropped without losing the event; valid siblings remained.
  • No flag-evaluation writer errors or dropped events occurred.

As expected, PII protection is not complete yet: targeting keys remained raw and context.evaluation remained present. I did not treat this as a blocker.

The initial source build exceeded the Docker memory limit and passed after retrying with one build job. Gunicorn also logged two pre-existing TraceExporter has already been consumed errors, but evaluation and EVP delivery continued normally.

Result: no non-PII blocker found. The Python SDK behavior covered by this PR worked as expected.

@vjfridge

Copy link
Copy Markdown
Author

Note to self: system-tests

Tested system-tests vickie/enable-ffe-evp-go-validation (2f1361225) against the local PR head (2443fd95e5, ddtrace 4.15.0rc1). The built wheel's flag-evaluation writer checksum matched the local checkout.

./build.sh python -w flask-poc
./run.sh FEATURE_FLAGGING_AND_EXPERIMENTATION --library python \
  -F tests/ffe/test_flag_eval_evp.py

Results:

  • Full scenario: 44 passed, 2 failed, 2 skipped in 170.67s.
  • Forced EVP suite: 9 passed, 2 failed.
  • All 8 non-PII EVP tests passed, including context bounds, count/load/burst/high-cardinality aggregation, runtime default, and degradation.
  • Of the three PII tests, ObserveFullData_True_Unhashed passed. The absent/false tests failed only because Python still emits the raw targeting key and evaluation context instead of hashing/omitting them. This is the expected pending PII work.
  • The two skips are existing manifest declarations for FFL-1622 (flaky) and FFL-1980 (irrelevant).

The initial parallel wheel build exceeded Docker memory; rebuilding with one compilation job passed. No non-PII test failed.

Result: safe to ship for the bounded-context and other non-PII behavior validated here. The known PII-protection gap remains.

@vjfridge
vjfridge marked this pull request as ready for review August 28, 2026 04:17
@vjfridge
vjfridge requested review from a team as code owners August 28, 2026 04:17

@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: 2443fd95e5

ℹ️ 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".

Comment thread ddtrace/internal/openfeature/_flagevaluation_writer.py
Comment on lines +7 to +8
and nesting is limited to 4 levels. Context data beyond these limits is omitted from the
reported evaluation; flag resolution itself is unaffected.

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 Document the total traversal cap

The implementation also stops after 1,280 inspected nodes (MAX_VISITED_NODES), but this note says context is omitted only beyond the listed field, width, length, and depth limits. For example, several permitted 256-entry containers containing null or unsupported leaves can consume that budget and cause a later valid field to be omitted even though fewer than 256 fields would be reported and every listed limit is respected. Include the visited-node cap so customers can understand this reporting behavior.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant