Skip to content

fix(openfeature): add bounded invocation-end EVP flush - #21135

Open
vjfridge wants to merge 3 commits into
mainfrom
vickie/FFL-3412/lambda-telemetry-flush
Open

vjfridge wants to merge 3 commits into
mainfrom
vickie/FFL-3412/lambda-telemetry-flush

Conversation

@vjfridge

@vjfridge vjfridge commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

A customer asked about flushing feature-flag telemetry from Python Lambda functions (FFL-3412). Short invocations can finish before background writers send evaluation and exposure events; Lambda freezing can delay delivery, and termination can lose buffered events.

This PR provides the tracer capability that lets the Lambda wrapper flush at invocation end, without customers adding handler code or shutting down providers needed by warm invocations.

Changes

  • Add a versioned internal flush operation covering all active evaluation and exposure writers, including queued and in-flight events.
  • Bound the caller's wait and distinguish completed, failed, and timed-out delivery.
  • Coordinate periodic and invocation-end sends without adding retries or duplicate sends; recover after aggregation failures.
  • Keep providers reusable across warm invocations and reset coordination state after a fork.

Delivery remains best effort. Timeout stops waiting, not an outstanding send; the implementation adds writer synchronization and one reusable background coordinator.

Testing

  • After merging main: 269 tracer tests and 361 Lambda integration/wrapper tests plus 15 subtests passed against the rebuilt tracer; full lint checks passed.
  • Earlier local Lambda emulator smoke tests covered Agent/direct delivery, handler errors, timeouts, and warm recovery. These used local receivers and pre-merge builds.
  • Still outstanding: deployed AWS freeze/thaw and Datadog ingestion validation; I don't have access to the sandbox.

Release coordination

Companion: datadog-lambda-python #872. Either PR can merge first; release the tracer capability before the Lambda library/layer containing both changes. Record the supported versions before announcing support.

FFE reviewer selection

Reviewers: @sameerank, @leoromanovsky

Why: Agentless and serverless delivery — Adds a versioned, bounded invocation-end flush (_telemetry.flush) that the AWS Lambda wrapper calls to deliver queued and in-flight feature-flag evaluation and exposure EVP… Global reviewers help cover a shortage in the matched pod.

🤖 Automated selection.

Selection details

Initiative: project / deployment-modes

Why this match: Adds a versioned, bounded invocation-end flush (_telemetry.flush) that the AWS Lambda wrapper calls to deliver queued and in-flight feature-flag evaluation and exposure EVP events before a serverless invocation freezes or terminates. It coordinates periodic and flush-triggered sends, resets state after a fork, and keeps providers reusable across warm invocations. This is evaluation/exposure event egress for a serverless runtime without a persistent Agent, covering Agent EVP and agentless direct fallback. It does not change consent or payload semantics.

Why these owners: The matched initiative has fewer than two approved, non-author candidates. The approved global pool supplies the remaining review owners; proposed memberships were not activated.

  • @sameerank — primary (agentless-delivery): Matched pod member. Review requested.
  • @leoromanovsky — final owner (final-review-owners): Global pool filling a pod coverage gap. Existing review coverage preserved.

The PR author was excluded. Existing reviewers and teams were preserved.

Debug or fix a problem

Workflow run · Bits Code session

  • Wrong project or pod? Clarify this PR’s purpose, or correct the reviewer registry and publish the change. Sheet edits take effect after publication.
  • Need another reviewer now? Request them in GitHub. This workflow only adds requests and keeps existing reviewers.
  • Stuck beyond five minutes or failed? Inspect the run’s failed step and available Bits session. After fixing the cause, rerun the workflow with this PR URL and dry_run=false. Unchanged completed runs are skipped. If the receipt says needs_reconciliation or a write outcome is uncertain, ask the workflow owner to reconcile GitHub and the receipt before retrying; do not delete the lock blindly.
  • Report a problem: Comment on the reviewer-routing skill PR with this PR URL, the workflow run link, and the expected initiative or reviewer correction.
Evidence and run details

✅ Complete — reviewer coverage verified. The workflow checked pending requests or submitted reviews on the evaluated head.

Generated asynchronously by the FFE reviewer-routing workflow.

Changed paths: ddtrace/internal/openfeature/_telemetry.py, ddtrace/internal/openfeature/writer.py, ddtrace/internal/openfeature/_flagevaluation_writer.py, ddtrace/internal/openfeature/README.md, tests/openfeature/test_telemetry_flush.py, tests/internal/test_serverless.py

Evaluated head: 2391a8e342b49b0fa0065edc7591d68b1daa2ab7

Registry revision: revision:1946aa2a-530e-487a-84a7-83aff44a1089

Pinned skill source: d214cfc65ae154b97d39af5cf32caf48aaec9bf0

Instructions: workflow-injected snapshot (no plugin loaded)

Instruction snapshot SHA-256: f23b2a88c13c0da90e2dd58246a14f257af91e2195c555039bd555b52e6f3516

Track pending, dequeued, and in-flight feature flag telemetry and expose a versioned internal boundary for Lambda cleanup.

Environment: Datadog workspace

Co-Authored-By: Codex GPT-6 <noreply@localhost>
@cit-pr-commenter-54b7da

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.

ddtrace/internal/openfeature/README.md                                  @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_flagevaluation_writer.py                  @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/_telemetry.py                              @DataDog/feature-flagging-and-experimentation-sdk
ddtrace/internal/openfeature/writer.py                                  @DataDog/feature-flagging-and-experimentation-sdk
releasenotes/notes/fix-openfeature-lambda-flush-6dd05d039855ce76.yaml   @DataDog/apm-python
tests/internal/test_serverless.py                                       @DataDog/apm-serverless @DataDog/asm-python @DataDog/serverless-aws @DataDog/serverless-azure-and-gcp
tests/openfeature/test_telemetry_flush.py                               @DataDog/feature-flagging-and-experimentation-sdk

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

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

Show existing violations (showing 5 of 133 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=120)
ddtrace.llmobs._integrations.openai_agents -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)
ddtrace.internal.ci_visibility.git_client -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=118)
ddtrace.llmobs._integrations.claude_agent_sdk -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=118)
ddtrace.internal.ci_visibility.api._base -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=118)

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

@pr-commenter

pr-commenter Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-10-23 02:48:14

Comparing candidate commit 2391a8e in PR branch vickie/FFL-3412/lambda-telemetry-flush with baseline commit 92c1fb2 in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 3 performance regressions! Performance is the same for 17 metrics, 0 unstable metrics.

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:openfeatureflagevaluation-hook-enqueue-protected-typical

  • 🟥 execution_time [+2.522µs; +2.617µs] or [+19.547%; +20.282%]

scenario:openfeatureflagevaluation-hook-enqueue-scale

  • 🟥 execution_time [+2.748µs; +2.933µs] or [+10.212%; +10.901%]

scenario:openfeatureflagevaluation-hook-enqueue-typical

  • 🟥 execution_time [+2.821µs; +2.953µs] or [+12.972%; +13.577%]

Account for every consumed event, report aggregation failures on their batch, and allow subsequent healthy flushes to complete.

Environment: Datadog workspace

Co-Authored-By: Codex GPT-6 <noreply@localhost>
@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

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

Incorporate upstream tracer updates and validate OpenFeature flushing with the rebuilt native periodic threads.

Environment: Datadog workspace

Co-Authored-By: Codex GPT-6 <noreply@localhost>
@vjfridge
vjfridge marked this pull request as ready for review October 23, 2026 02:49
@vjfridge
vjfridge requested review from a team as code owners October 23, 2026 02:49
@vjfridge
vjfridge requested review from avara1986, kathiehuang and lym953 and removed request for a team October 23, 2026 02:49
@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T02:54:12.819468Z 2391a8e Draft marked ready
🔒 Security Review ✅ Completed 2026-10-23T02:54:04.943844Z 2391a8e Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2391a8e342

ℹ️ 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 on lines +110 to +112
failed |= progress.last_failure >= started
if progress.accepted > progress.settled:
pending.append((writer, progress.accepted, progress.failures))

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 Report failures that settle before flush begins

When a periodic writer sends and rejects an invocation's batch before the invocation-end call starts, accepted == settled and last_failure < started, so this path returns completed even though the snapshotted work was not accepted by an endpoint. This is especially reachable for invocations longer than the writers' 1- or 10-second intervals. Track unreported failures across flush calls rather than considering only failures timestamped after this call began.

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.

🤖 Comment from Claude working with Aaron Silverman:

[P2] I confirmed this with a test. Count failures per writer instead of comparing failure times with the call's start time.

When the endpoint rejects a background send before flush() starts, accepted == settled and last_failure < started, so flush() returns completed. This reproduces for both writers. With a count of failures already reported, the next call no longer returns completed after that failure, and later calls do not count it again.

Suggested fix and test — the test fails at this head and passes with the fix
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -25,7 +25,7 @@ class Progress:
         self.accepted = 0
         self.settled = 0
         self.failures = 0
-        self.last_failure = float("-inf")
+        self.reported_failures = 0
 
     def accept(self) -> None:
         with self.lock:
@@ -36,7 +36,6 @@ class Progress:
             self.settled = target
             if not success:
                 self.failures += 1
-                self.last_failure = time.monotonic()
 
 
 class Writer(Protocol):
@@ -107,7 +106,9 @@ def flush(timeout: float) -> str:
         if not progress.lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
             return "timed_out"
         try:
-            failed |= progress.last_failure >= started
+            # Count a failure in the next call, even if it settled before that call started.
+            failed |= progress.failures != progress.reported_failures
+            progress.reported_failures = progress.failures
             if progress.accepted > progress.settled:
                 pending.append((writer, progress.accepted, progress.failures))
         finally:
@@ -149,6 +150,7 @@ def flush(timeout: float) -> str:
                 return "timed_out"
             try:
                 failed |= progress.failures != failures
+                progress.reported_failures = progress.failures
                 if progress.settled < target:
                     waiting.append((writer, target, failures))
             finally:
--- a/tests/openfeature/test_telemetry_flush.py
+++ b/tests/openfeature/test_telemetry_flush.py
@@ -294,3 +294,13 @@ def test_ambiguous_send_failure_is_not_retried(kind):
     assert _telemetry.flush(1) == "failed"
     assert _telemetry.flush(1) == "completed"
     assert connection.request.call_count == 1
+
+
+@pytest.mark.parametrize("kind", ["evaluation", "exposure"])
+def test_rejection_before_flush_is_reported_once(kind):
+    writer, connection = make_writer(kind, status=500)
+    enqueue(writer)
+    writer.periodic()  # The background send is rejected before the invocation-end flush starts.
+    assert _telemetry.flush(1) == "failed"
+    assert _telemetry.flush(1) == "completed"
+    assert connection.request.call_count == 1

test_failure_does_not_poison_next_invocation and test_ambiguous_send_failure_is_not_retried still pass, along with the rest of the writer suites and tests/internal/test_serverless.py (266 tests).

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.

🤖 Comment from Codex working with @leoromanovsky:

[P1] I independently reproduced this for both writers on 2391a8e. Aaron's failure-watermark change addresses the tested sequence: report an earlier rejection once, then allow the next flush to complete without another send.

Please preserve that recovery behavior when replacing the timestamp comparison. This corrects the delivery result; it does not retry or recover the rejected batch.

Rejection and successful-delivery controls

The tests run writer.periodic() before calling flush(). With HTTP 500, the reviewed code incorrectly returns completed for both evaluation and exposure writers. HTTP 202 controls return completed as expected.

With the watermark change in the combined candidate, each rejection returns failed once, the next call returns completed, and the request count stays at one. Removing the initial unreported-failure comparison makes both rejection cases fail again while both HTTP 202 controls pass.

This validates sequential invocation boundaries. It does not establish semantics for simultaneous independent flush() callers.

Comment on lines +133 to +136
coordinator.worker.start()
except Exception:
coordinator.worker = None
raise

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 Convert coordinator startup errors into a flush result

On the first pending flush, PeriodicThread.start() can raise an OSError when the runtime cannot allocate a thread, and the explicit re-raise lets that exception escape the invocation-end boundary instead of returning one of its documented results. In a resource-constrained Lambda this can turn best-effort telemetry cleanup into an application-visible failure; return failed (or timed_out when the budget is exhausted) after resetting the worker.

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.

🤖 Comment from Claude working with Aaron Silverman:

[P2] I confirmed this with a test. Return "failed" when the coordinator thread cannot start, and clear the pending request.

flush() documents three return values, but an OSError from PeriodicThread.start() escapes it. The Lambda wrapper catches the exception and logs a traceback, so the invocation is safe but gets no result. The fix still logs the error at debug level, then returns "failed". Clearing requested drops the writer references that the failed start leaves behind, and the test checks that too.

Suggested fix and test — the test fails at this head and passes with the fix
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -133,7 +133,9 @@ def flush(timeout: float) -> str:
                 coordinator.worker.start()
             except Exception:
                 coordinator.worker = None
-                raise
+                coordinator.requested.clear()
+                log.debug("Feature flag telemetry flush worker failed to start", exc_info=True)
+                return "failed"
         coordinator.signal.set()
     finally:
         coordinator.lock.release()
--- a/tests/openfeature/test_telemetry_flush.py
+++ b/tests/openfeature/test_telemetry_flush.py
@@ -294,3 +294,14 @@ def test_ambiguous_send_failure_is_not_retried(kind):
     assert _telemetry.flush(1) == "failed"
     assert _telemetry.flush(1) == "completed"
     assert connection.request.call_count == 1
+
+
+def test_worker_start_failure_returns_failed_and_recovers():
+    writer, connection = make_writer()
+    enqueue(writer)
+    with mock.patch.object(_telemetry.PeriodicThread, "start", side_effect=OSError("can't start new thread")):
+        assert _telemetry.flush(1) == "failed"
+    assert _telemetry._coordinator.worker is None
+    assert not _telemetry._coordinator.requested
+    assert _telemetry.flush(1) == "completed"
+    assert connection.request.call_count == 1

The writer suites and tests/internal/test_serverless.py pass with this change (265 tests).

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bits Code Review: FAIL

The first flush can exceed its advertised timeout because coordinator startup waits without a deadline on thread-start synchronization. Contention during concurrent startup or post-fork restarts can therefore overrun the invocation’s remaining execution budget.

Open Bits AI session

🤖 Bits Code Review · Commit 2391a8e

# inherited worker against the parent's requested work.
coordinator.worker.__autorestart__ = False
try:
coordinator.worker.start()

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.

P2 [P2] Bound coordinator startup by the flush deadline

On the first flush with pending work, concurrent periodic-thread startup or post-fork restarts can block this call beyond the supplied timeout. PeriodicThread.start() acquires threads._forking_lock without a timeout and then waits indefinitely for native startup acknowledgement. The flush deadline is checked only after start() returns, so invocation-end flushing can exceed its remaining Lambda execution budget. Startup must respect the remaining deadline or occur outside the bounded caller path.

React: 👍 useful · 👎 wrong · 😕 unclear · 👀 right, but not acting on it

What each reaction means
  • 👍 Useful: correct, and you're fixing it or will.
  • 👎 Wrong: the finding is incorrect, for example the bug can't happen or the code was misread.
  • 😕 Unclear: you can't tell what it means or why it matters.
  • 👀 Right, not acting: correct, but intended, out of scope for this PR, a known risk, or too minor.

🤖 Bits Code Review · Open Bits AI session

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.

🤖 Comment from Codex working with @leoromanovsky:

[P1] Confirmed on 2391a8e: coordinator startup can exceed the caller's total budget. Please move fallback startup outside flush() or bound its lock acquisition and native startup acknowledgement.

Aaron's suggestion to wake existing writer threads avoids coordinator startup for running writers. The fallback for unstarted writers still needs a separate fix. Catching startup exceptions does not bound a successful but delayed start.

Startup-contention reproduction

On Python 3.13, another thread held the real threads._forking_lock for 300 ms. flush(0.02) returned timed_out after 300 ms, despite its 20 ms budget. The regression assertion allowed 150 ms and still failed.

The tested parallel-worker candidate retains synchronous fallback startup, so it does not fully address this finding. I would retain this contention test alongside the running-writer concurrency test. The fallback's initialization and fork lifecycle still need an implementation decision.

self.failures = 0
self.last_failure = float("-inf")

def accept(self) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P1] Take Progress.accept() off the ResetObject proxy and the extra lock. It runs for every evaluation the writer accepts, in every service, not only in Lambda.

accept() is the only new statement on the evaluation enqueue path. It resolves through a ResetObject proxy and then enters a second proxied lock. Both callers already hold an intake lock (_lifecycle_lock in FlagEvaluationWriter.enqueue, _lock in ExposureWriter.enqueue), which serializes the increments. flush() only reads the count, and an event accepted after its snapshot is outside that flush's target, so a stale read is harmless and the second lock adds nothing. The benchmark bot on this PR reports +10–20% on three hook-enqueue scenarios, and every accepted evaluation pays that cost.

Evidence — the regression reproduces locally, and the suggested fix measures within 1% of main

I measured in one process, interleaved: 15 rounds of 50,000 FlagEvalEVPHook.finally_after calls with the hook_enqueue benchmark setup, on Python 3.12. The table shows the median ns per call. "main" is this head with the accept() call removed.

Variant protected full evaluation data
main 3658 6231
this PR 4419 (+20.8%) 6825 (+9.5%)
suggested fix 3692 (+0.9%) 6273 (+0.7%)

In a separate run, removing only the lock recovered about 40% of the delta.

Suggested fix — a plain Progress, reset after fork by a forksafe hook
--- a/ddtrace/internal/openfeature/_flagevaluation_writer.py
+++ b/ddtrace/internal/openfeature/_flagevaluation_writer.py
@@ -812,7 +812,7 @@ class FlagEvaluationWriter(PeriodicService):
         self._counter_lock = forksafe.Lock()
         self._drain_lock = forksafe.RLock()
         self._flush_lock = forksafe.Lock()
-        self._progress = typing.cast(Progress, forksafe.ResetObject(Progress))
+        self._progress = Progress()
         register(self)
 
         self._drain_worker: typing.Optional[PeriodicThread] = None
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -15,6 +15,7 @@ from ddtrace.internal.threads import PeriodicThread
 
 FLUSH_API_VERSION = 1
 log = get_logger(__name__)
+_progress: "WeakSet[Progress]" = WeakSet()
 
 
 class Progress:
@@ -22,14 +23,18 @@ class Progress:
 
     def __init__(self) -> None:
         self.lock = forksafe.Lock()
+        self.reset()
+        _progress.add(self)
+
+    def reset(self) -> None:
         self.accepted = 0
         self.settled = 0
         self.failures = 0
         self.last_failure = float("-inf")
 
     def accept(self) -> None:
-        with self.lock:
-            self.accepted += 1
+        # Hot path: no proxy, no lock. Owners call this under their intake lock.
+        self.accepted += 1
 
     def finish(self, target: int, success: bool) -> None:
         with self.lock:
@@ -39,6 +44,12 @@ class Progress:
                 self.last_failure = time.monotonic()
 
 
+@forksafe.register
+def _reset_progress() -> None:
+    for progress in list(_progress):
+        progress.reset()
+
+
 class Writer(Protocol):
     _progress: Progress
 
--- a/ddtrace/internal/openfeature/writer.py
+++ b/ddtrace/internal/openfeature/writer.py
@@ -128,7 +128,7 @@ class ExposureWriter(PeriodicService):
         super().__init__(interval=interval)
         self._lock = RLock()
         self._flush_lock = forksafe.Lock()
-        self._progress = cast(Progress, forksafe.ResetObject(Progress))
+        self._progress = Progress()
         register(self)
         self._process_state = cast(_ExposureProcessState, forksafe.ResetObject(_ExposureProcessState))
         self._timeout: float = timeout

With this change, tests/openfeature/test_telemetry_flush.py, test_exposure_writer.py, test_flagevaluation_writer.py and tests/internal/test_serverless.py pass (264 tests). When I disable _reset_progress, test_fork_child_does_not_flush_parent_work fails for both writers, so the existing fork test covers the hook.

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.

🤖 Comment from Codex working with @leoromanovsky:

[P1] When removing the progress proxy, please preserve child-process progress reset and rerun the enqueue benchmark. I checked a candidate that uses the existing intake locks for acceptance and retains a lock for settlement. Its semantic tests pass; I have not measured its speedup.

Fork controls and measurement scope

The candidate resets each live progress object through a fork hook. Both writer types pass tests that prevent parent-event replay and then send fresh child events across two flushes. Disabling that reset makes all four fork cases fail.

There is a separate useful control: clearing the writer registry in the child leaves the two original no-replay tests passing, but fails both fresh-child-delivery tests. Preserving registration as well as resetting progress matters.

The existing CI receipt measures roughly 2.5–2.6 µs added to the protected typical hook/enqueue path, or 19.55–20.28%. That is component overhead, not a measurement of total flag-evaluation or request latency. A new benchmark is still needed for the chosen fix.

allows the native thread fork protocol to stop it; transport timeouts continue
to govern outstanding network operations during a fork.

## Release coordination

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Keep the compatibility rule here, and move the rest of "Release coordination" to the PR description.

Most of this section is release process: recording versions and layer ARNs, validation steps, and a release record. One sentence describes the branch, not the code: "No published combination is claimed by this development branch." Two sentences describe behavior and belong in this file: the Lambda side checks contract version 1, and older tracers are a no-op.

Suggested change — keep the two compatibility sentences, delete the rest
--- a/ddtrace/internal/openfeature/README.md
+++ b/ddtrace/internal/openfeature/README.md
@@ -35,18 +35,7 @@ coordinator does not restart inherited work in the child. Its bounded idle wait
 allows the native thread fork protocol to stop it; transport timeouts continue
 to govern outstanding network operations during a fork.
 
-## Release coordination
+## Compatibility
 
-The first published tracer version, Lambda library version, and compatible layer
-ARN/version must be recorded together after release artifacts are available.
-No published combination is claimed by this development branch. The Lambda side
-checks contract version 1 and safely skips older tracers. Release the tracer
-capability first, then the Lambda wrapper and a layer containing both changes.
-Python versions whose tracer release lacks contract version 1 remain a no-op.
-
-Before declaring support, validate a short successful invocation and a short
-raising invocation on the published layer, repeat them in a warm sandbox, and
-confirm ingestion of both evaluation and exposure events for a unique test
-subject. Cover local Agent EVP delivery separately from agentless direct fallback.
-Record the runtime, architecture, tracer version, Lambda library version, layer
-ARN, invocation IDs and ingestion evidence in the release record.
+The Lambda side checks contract version 1 and safely skips older tracers. Python
+versions whose tracer release lacks contract version 1 remain a no-op.

@@ -0,0 +1,6 @@
fixes:
- |
OpenFeature: Enables compatible Datadog Lambda libraries to flush pending

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Match the release-note conventions, and add the fork fix to the note.

  • All 17 other OpenFeature notes with a scope in releasenotes/notes/ use lowercase openfeature:. This one uses OpenFeature:.
  • 16 of those 17 start with ---.
  • docs/releasenotes.rst asks a fix to state the user-visible symptom: "Fixes an issue where occurs when ."
  • This PR also stops a forked child from sending exposure events that its parent had buffered (_ExposureProcessState). Before, those events could be sent twice. The description mentions resetting coordination state after a fork, but neither it nor the note mentions the duplicate exposures.
Suggested note — replaces the whole file
---
fixes:
  - |
    openfeature: Fixes an issue where feature flag evaluation and exposure events could be delayed
    or lost when an AWS Lambda invocation ended shortly after evaluating flags. Requires a Datadog
    Lambda library version that flushes these events at invocation end.
  - |
    openfeature: Fixes an issue where exposure events could be sent twice after a process forked.

It parses as YAML with two fixes entries. I could not run reno lint in my environment.

with self._lifecycle_lock:
self._accepting_events = False
self._request_drain_worker_stop()
# A stopped drain worker owns its dequeued event and final callback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Clarify this comment. The lock check does not make on_shutdown fully bounded.

The comment says: "A stopped drain worker owns its dequeued event and final callback. Do not turn the application's bounded join into an unbounded wait."

  • The non-blocking _drain_lock check is correct. A drain worker that holds the lock runs the final drain through on_shutdown=self.periodic. I traced the shutdown path and found no lost events.
  • The next line, self.periodic(), still waits on _flush_lock. A coordinator or periodic flush holds that lock across network I/O, so on_shutdown can still wait for a send in flight.
Suggested wording
--- a/ddtrace/internal/openfeature/_flagevaluation_writer.py
+++ b/ddtrace/internal/openfeature/_flagevaluation_writer.py
@@ -1174,8 +1174,8 @@ class FlagEvaluationWriter(PeriodicService):
         with self._lifecycle_lock:
             self._accepting_events = False
         self._request_drain_worker_stop()
-        # A stopped drain worker owns its dequeued event and final callback.
-        # Do not turn the application's bounded join into an unbounded wait.
+        # A drain worker that holds the lock owns the final drain through its
+        # on_shutdown callback. periodic() can still wait for an in-flight send.
         if not self._drain_lock.acquire(blocking=False):
             return
         self._drain_lock.release()

self.signal.clear()
for writer in writers:
try:
writer.periodic()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P2] Wake running writers with PeriodicThread.awake() so they flush in parallel on their own threads. Keep the coordinator only for writers whose worker is not running.

When the coordinator drives the flush for both writers, it runs their periodic() calls one after the other, so the flush waits for both sends in sequence. With a local endpoint that takes 300 ms per request, flush(0.5) returned timed_out on every call. The parallel version returned completed in about 305 ms.

Other Datadog tracers use the parallel pattern. In dd-trace-dotnet, FlagEvaluationWriter.FlushAsync wakes the writer's own consumer and waits with a cap. In dd-trace-java, the feature-flagging shutdown joins every writer thread against one shared deadline. Since #20716, awake() does not block, so the caller still waits outside the writers' flush and intake locks.

Evidence — measured wall time of flush() with both writers pending

Both writers were started. A local HTTP server slept before each 202. Each row is the median of 8 flushes.

Delay per request Budget Current coordinator Parallel
50 ms 2 s 107 ms 56 ms
150 ms 2 s 308 ms 154 ms
300 ms 2 s 610 ms 306 ms
300 ms 500 ms 500 ms, timed_out 305 ms, completed

The provider starts FlagEvaluationWriter (_provider.py:284), and start_exposure_writer() starts the exposure writer (writer.py:349). Once both run, the coordinator thread starts only when a writer is not running. Until it starts, its 10 wakeups per second do not happen.

The check uses status, not only _worker. After stop(), awake() does nothing and _worker can remain set, so a stopped writer must still go to the coordinator.

Suggested fix and test — the test fails against the current coordinator
--- a/ddtrace/internal/openfeature/README.md
+++ b/ddtrace/internal/openfeature/README.md
@@ -15,12 +15,13 @@ drops after acceptance, and rejected or ambiguous deliveries are incomplete.
 Failures observed while coordinating the snapshot are also included. A failed
 invocation does not permanently poison subsequent flushes.
 
-One lazily created coordinator serves all invocation-end requests. Calls share a
-single monotonic deadline across registry/progress coordination and waiting for
-aggregation, route discovery, and delivery. The coordinator performs the writers'
-existing transport operations; the caller does not perform network I/O. Requests
-coalesce by writer, so repeated timeouts cannot accumulate helper workers or
-unbounded request queues. Providers, hooks, polling and ordinary writer workers
+Running writers flush on their own worker threads, in parallel, after a
+non-blocking wake. One lazily created coordinator serves writers whose worker is
+not running. Calls share a single monotonic deadline across registry/progress
+coordination and waiting for aggregation, route discovery, and delivery. The
+writer workers and the coordinator perform the existing transport operations;
+the caller does not perform network I/O. Requests coalesce by writer, so repeated
+timeouts cannot accumulate helper workers or unbounded request queues. Providers, hooks, polling and ordinary writer workers
 remain active across warm invocations.
 
 Timeout stops waiting, not delivery. Queued work and in-flight batches remain with
--- a/ddtrace/internal/openfeature/_telemetry.py
+++ b/ddtrace/internal/openfeature/_telemetry.py
@@ -10,6 +10,7 @@ from weakref import WeakSet
 
 from ddtrace.internal import forksafe
 from ddtrace.internal.logger import get_logger
+from ddtrace.internal.service import ServiceStatus
 from ddtrace.internal.threads import PeriodicThread
 
 
@@ -117,26 +118,36 @@ def flush(timeout: float) -> str:
     if not pending:
         return "failed" if failed else "completed"
 
-    coordinator = _coordinator
-    if not coordinator.lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
-        return "timed_out"
-    try:
-        coordinator.requested.update(writer for writer, _, _ in pending)
-        if coordinator.worker is None:
-            coordinator.worker = PeriodicThread(
-                0.0, coordinator.run, name="ddtrace:feature-flag-flush", no_wait_at_start=True
-            )
-            # ResetObject replaces the coordinator in a child; don't restart its
-            # inherited worker against the parent's requested work.
-            coordinator.worker.__autorestart__ = False
-            try:
-                coordinator.worker.start()
-            except Exception:
-                coordinator.worker = None
-                raise
-        coordinator.signal.set()
-    finally:
-        coordinator.lock.release()
+    # Running writers flush on their own threads, in parallel. The coordinator
+    # serves writers whose worker is not running.
+    idle = []
+    for writer, _, _ in pending:
+        worker = getattr(writer, "_worker", None)
+        if worker is not None and getattr(writer, "status", None) is ServiceStatus.RUNNING:
+            worker.awake()
+        else:
+            idle.append(writer)
+    if idle:
+        coordinator = _coordinator
+        if not coordinator.lock.acquire(timeout=max(0.0, deadline - time.monotonic())):
+            return "timed_out"
+        try:
+            coordinator.requested.update(idle)
+            if coordinator.worker is None:
+                coordinator.worker = PeriodicThread(
+                    0.0, coordinator.run, name="ddtrace:feature-flag-flush", no_wait_at_start=True
+                )
+                # ResetObject replaces the coordinator in a child; don't restart its
+                # inherited worker against the parent's requested work.
+                coordinator.worker.__autorestart__ = False
+                try:
+                    coordinator.worker.start()
+                except Exception:
+                    coordinator.worker = None
+                    raise
+            coordinator.signal.set()
+        finally:
+            coordinator.lock.release()
 
     while pending:
         remaining = deadline - time.monotonic()
--- a/tests/openfeature/test_telemetry_flush.py
+++ b/tests/openfeature/test_telemetry_flush.py
@@ -294,3 +294,21 @@ def test_ambiguous_send_failure_is_not_retried(kind):
     assert _telemetry.flush(1) == "failed"
     assert _telemetry.flush(1) == "completed"
     assert connection.request.call_count == 1
+
+
+def test_running_writers_flush_in_parallel():
+    writers = [make_writer(kind) for kind in ("evaluation", "exposure")]
+    for writer, connection in writers:
+        connection.getresponse.side_effect = lambda: (time.sleep(0.2), mock.Mock(status=202))[1]
+        writer.start()
+        enqueue(writer)
+    try:
+        started = time.monotonic()
+        # One send at a time needs at least 0.4 s.
+        assert _telemetry.flush(0.35) == "completed"
+        assert time.monotonic() - started < 0.35
+        assert _telemetry._coordinator.worker is None
+    finally:
+        for writer, _ in writers:
+            writer.stop()
+            writer.join(2)

The writer suites and tests/internal/test_serverless.py pass (265 tests). The tests in test_telemetry_flush.py build writers without starting them, so they still cover the coordinator path. Against the current coordinator, the new test fails with timed_out.

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.

🤖 Comment from Codex working with @leoromanovsky:

[P2] The parallel-worker approach also passes an independent concurrency test. Please retain the ServiceStatus.RUNNING guard: a retained worker reference alone does not establish that awake() will flush it.

Concurrency control without ordinary timer wakeups

I set both writer intervals to 3,600 seconds and blocked each mocked response until both requests had entered. With a 350 ms flush budget, the reviewed coordinator returns timed_out. The candidate that wakes running workers completes and sends each request once. Restoring serial coordination makes the test fail again.

This checks overlap directly, rather than relying only on elapsed-time improvement. It supports using each running writer's existing worker to make better use of the shared budget. It does not prove permanent event loss from the serial path.

The fallback for writers that are not running remains subject to the separate startup-deadline finding.

@@ -0,0 +1,159 @@
"""Versioned internal boundary for Lambda's best-effort EVP flush.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

[P3] Consider renaming _telemetry now, before the module path becomes a cross-repo contract.

ddtrace.internal.openfeature._telemetry sits next to ddtrace.internal.telemetry, the instrumentation telemetry package, and _flagevaluation_writer.py imports both. After release, datadog-lambda-python imports this path and tests/internal/test_serverless.py pins it, so a later rename needs a compatibility alias or coordinated releases.

Suggested change: rename the module to _flush.py (or similar) and update its references: the imports in _flagevaluation_writer.py and writer.py; the allowlist entry and the new import test in tests/internal/test_serverless.py; tests/openfeature/test_telemetry_flush.py; this package's README.md; and the import in DataDog/datadog-lambda-python#872. The rename is mechanical, so I did not apply it.

assert _telemetry._coordinator.worker is None
"""
_, _, status, _ = run_python_code_in_subprocess(code)
assert status == 0

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.

This should already be covered in the test_serverless_imports above, yeah? The only thing different are these asserts, which are already tested in your other unit tests below.

@@ -1004,26 +1014,40 @@ def _stop_service(self, *args: typing.Any, **kwargs: typing.Any) -> None:
super()._stop_service(*args, **kwargs)

def periodic(self) -> None:

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.

What if..... we just call periodic directly from the lambda function? We already do that same thing for exception replay, so there's precident for it. That would mean this PR is completely unnecessary.....

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.

If that's true, then we could just do a single PR in datadog-lambda-python DataDog/datadog-lambda-python@5d3d3e3

That uses a bunch of code from ddtrace.internal which we don't love, but as long as it's well protected, I don't see why that smaller PR wouldn't work well.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🤖 Comment from Claude working with Aaron Silverman:

I replied on the Lambda PR with results from running 5d3d3e3 against main (no wait budget, and an in-flight send is not awaited): DataDog/datadog-lambda-python#872 (comment)

@greghuels
greghuels removed their request for review October 27, 2026 10:33

@leoromanovsky leoromanovsky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Commented in-line with some reproduction and more evidence.

@leoromanovsky

leoromanovsky commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

FFE reviewer selection

Reviewers: @leoromanovsky, @sameerank

Why: Agentless and serverless delivery — The PR adds a bounded, versioned flush operation that a serverless (Lambda) runtime calls at the end of each invocation. Reviewers come from the matched pod.

🤖 Automated selection.

Selection details

Initiative: project / deployment-modes

Why this match: The PR adds a bounded, versioned flush operation that a serverless (Lambda) runtime calls at the end of each invocation. It drains queued and in-flight feature-flag evaluation and exposure events across all writers, so short or frozen serverless invocations don't lose them. This is evaluation and exposure event egress for serverless server SDKs. The changes are a new flush coordinator in _telemetry.py, progress tracking and flush locks in the exposure and evaluation writers, and fork-reset buffer state. These change how events are delivered, not EVP consent or metadata semantics, and they add no onboarding diagnostics.

  • @leoromanovsky — primary (agentless-delivery): Matched pod member. Existing review coverage preserved.
  • @sameerank — core (agentless-delivery): Matched pod member. Existing review coverage preserved.

The PR author was excluded. Existing reviewers and teams were preserved.

Debug or fix a problem

Workflow run · Bits Code session

  • Wrong project or pod? Clarify this PR’s purpose, or correct the reviewer registry and publish the change. Sheet edits take effect after publication.
  • Need another reviewer now? Request them in GitHub. This workflow only adds requests and keeps existing reviewers.
  • Stuck beyond five minutes or failed? Inspect the run’s failed step and available Bits session. After fixing the cause, rerun the workflow with this PR URL and dry_run=false. Unchanged completed runs are skipped. If the receipt says needs_reconciliation or a write outcome is uncertain, ask the workflow owner to reconcile GitHub and the receipt before retrying; do not delete the lock blindly.
  • Report a problem: Comment on the reviewer-routing skill PR with this PR URL, the workflow run link, and the expected initiative or reviewer correction.
Evidence and run details

✅ Complete — reviewer coverage verified. The workflow checked pending requests or submitted reviews on the evaluated head.

Generated asynchronously by the FFE reviewer-routing workflow.

Changed paths: ddtrace/internal/openfeature/_telemetry.py, ddtrace/internal/openfeature/writer.py, ddtrace/internal/openfeature/_flagevaluation_writer.py, tests/openfeature/test_telemetry_flush.py, tests/internal/test_serverless.py

Evaluated head: 2391a8e342b49b0fa0065edc7591d68b1daa2ab7

Registry revision: revision:5067899c-c8bf-4370-84a0-3e0cb0c89e02

Pinned skill source: 7e3283c602f6b18a7e3e623eecb52d3a9c091a2e

Instructions: workflow-injected snapshot (no plugin loaded)

Instruction snapshot SHA-256: 52ed8cb68675dde1a47addaefe4a9500891c24014abf7dade6dfe38cf836c1d2

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.

5 participants