Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions datadog_lambda/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ def _resolve_env(self, key, default=None, cast=None, depends_on_tracing=False):
enhanced_metrics_enabled = _get_env("DD_ENHANCED_METRICS", "true", as_bool)

flush_in_thread = _get_env("DD_FLUSH_IN_THREAD", "false", as_bool)
feature_flags_enabled = _get_env("DD_FEATURE_FLAGS_ENABLED", "true", as_bool)
feature_flag_flush_timeout_ms = _get_env(
"DD_FEATURE_FLAGS_FLUSH_TIMEOUT_MS", 500.0, float
)
flush_to_log = _get_env("DD_FLUSH_TO_LOG", "false", as_bool)
logs_injection = _get_env("DD_LOGS_INJECTION", "true", as_bool)
merge_xray_traces = _get_env("DD_MERGE_XRAY_TRACES", "false", as_bool)
Expand Down
61 changes: 61 additions & 0 deletions datadog_lambda/feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Compatibility boundary for invocation-end feature flag telemetry."""

import logging
import math
from time import monotonic

from datadog_lambda.config import config

try:
from ddtrace.internal.openfeature import _telemetry
except ImportError:
_telemetry = 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.

Once we get this PR more ready to merge, I'd like to run a before/after test on the cold start duration. We've found in the past that adding imports, when the code ultimately isn't being used here, can add significant amounts of time to the beginning of the first lambda invocation. From what I can tell, I don't think that will be the case here, but it will be good to know for certain. I'll run that testing once we get to a good point.



logger = logging.getLogger(__name__)
SAFETY_MARGIN_MS = 100.0
MAX_CONSECUTIVE_TIMEOUTS = 3
TIMEOUT_BACKOFF_S = 60.0
# Warm invocations share this state; a new process starts with no cooldown.
_consecutive_timeouts = 0
_skip_until = 0.0


def flush_feature_flag_telemetry(context):
global _consecutive_timeouts, _skip_until
try:
if _telemetry is None:
return
if getattr(_telemetry, "FLUSH_API_VERSION", None) != 1:
logger.debug("Skipping feature flag telemetry flush: unsupported tracer")
return
cap_ms = config.feature_flag_flush_timeout_ms
if not math.isfinite(cap_ms) or cap_ms <= 0:
return
# No remaining-time information means no safe invocation budget.
Comment thread
vjfridge marked this conversation as resolved.
get_remaining_ms = getattr(context, "get_remaining_time_in_millis", None)
if get_remaining_ms is None:
return
budget_ms = min(cap_ms, get_remaining_ms() - SAFETY_MARGIN_MS)
if budget_ms <= 0:
logger.debug(
"Skipping feature flag telemetry flush: no invocation time remains"
)
return
if monotonic() < _skip_until:
logger.debug("Skipping feature flag telemetry flush: backing off")
return
outcome = _telemetry.flush(budget_ms / 1000.0)

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] Back off after repeated timeouts that use the full cap. Without a backoff, a slow or unreachable intake adds up to the whole cap, 500 ms by default, to every invocation that has unsent flag events.

  • For a positive budget, flush() in fix(openfeature): add bounded invocation-end EVP flush dd-trace-py#21135 returns timed_out only after its deadline passes, so each timeout costs the full budget.
  • The tracer's direct-intake route has no cooldown. Only agentless-local routes have one (FeatureFlagEVPRouteSelector.send() in ddtrace/internal/openfeature/_evp_transport.py).
  • Before this change, the wrapper did not wait for feature flag intake at invocation end. During an intake incident, functions with unsent flag events can pay up to the cap on each invocation, and Lambda bills that time.

Datadog's serverless libraries already back off in this situation. datadogpy, which this wrapper uses to send metrics without the extension, stops sending for 300 s after 3 timeouts. datadog-lambda-go opens a circuit breaker for 60 s after more than 4 failures in 30 s. The suggested fix takes the threshold from datadogpy and the window from datadog-lambda-go: after 3 full-cap timeouts with no completed or failed flush between them, it skips the wait for 60 s. A timeout from a short remaining-time budget neither counts nor resets the streak. During the window, invocations skip the invocation-end flush, and the background writers keep sending on their own schedule. After the window, the next invocation flushes again.

Keep the 500 ms default and the 100 ms margin. The industry standard for serverless SDKs is to flush before the invocation returns, with caps of 1 s or longer, so 500 ms is already conservative. A reserve of about 100 ms of remaining time matches published guidance.

Precedent — Datadog libraries that already back off
  • datadogpy: _max_timeouts = 3 and _backoff_period = 300 in datadog/api/__init__.py. api_client.py logs "Max number of datadog timeouts exceeded, backing off for %s seconds" when the timeout counter reaches the maximum.
  • datadog-lambda-go: defaultCircuitBreakerInterval = 30s, defaultCircuitBreakerTimeout = 60s and defaultCircuitBreakerTotalFailures = 4 in internal/metrics/constants.go. The breaker trips when TotalFailures > 4.

A timeout caused by a short remaining-time budget does not count, because it says nothing about the intake. Any outcome other than timed_out resets the count.

Suggested fix and tests — each test fails when its branch is removed
--- a/datadog_lambda/feature_flags.py
+++ b/datadog_lambda/feature_flags.py
@@ -2,6 +2,7 @@
 
 import logging
 import math
+import time
 
 from datadog_lambda.config import config
 
@@ -13,9 +14,15 @@ except ImportError:
 
 logger = logging.getLogger(__name__)
 SAFETY_MARGIN_MS = 100.0
+# After repeated full-cap timeouts, stop adding the wait to every invocation for a while.
+MAX_CONSECUTIVE_TIMEOUTS = 3
+TIMEOUT_BACKOFF_S = 60.0
+_consecutive_timeouts = 0
+_skip_until = 0.0
 
 
 def flush_feature_flag_telemetry(context):
+    global _consecutive_timeouts, _skip_until
     try:
         if _telemetry is None or getattr(_telemetry, "FLUSH_API_VERSION", None) != 1:
             return
@@ -30,7 +37,17 @@ def flush_feature_flag_telemetry(context):
                 "Skipping feature flag telemetry flush: no invocation time remains"
             )
             return
+        if time.time() < _skip_until:
+            logger.debug("Skipping feature flag telemetry flush: backing off")
+            return
         outcome = _telemetry.flush(budget_ms / 1000.0)
+        if outcome != "timed_out":
+            _consecutive_timeouts = 0
+        elif budget_ms >= cap_ms:
+            _consecutive_timeouts += 1
+            if _consecutive_timeouts >= MAX_CONSECUTIVE_TIMEOUTS:
+                _consecutive_timeouts = 0
+                _skip_until = time.time() + TIMEOUT_BACKOFF_S
         if outcome != "completed":
             logger.debug("Feature flag telemetry flush incomplete: %s", outcome)
     except Exception:
--- a/tests/test_feature_flags.py
+++ b/tests/test_feature_flags.py
@@ -9,6 +9,37 @@ from datadog_lambda.config import Config
 
 
 class FeatureFlagFlushTest(unittest.TestCase):
+    def setUp(self):
+        for name, value in [("_consecutive_timeouts", 0), ("_skip_until", 0.0)]:
+            patcher = patch.object(feature_flags, name, value)
+            patcher.start()
+            self.addCleanup(patcher.stop)
+
+    def _flush_with_outcomes(self, outcomes, remaining_ms=30000):
+        context = Mock(get_remaining_time_in_millis=lambda: remaining_ms)
+        with patch.object(feature_flags, "_telemetry") as telemetry:
+            telemetry.FLUSH_API_VERSION = 1
+            telemetry.flush.side_effect = outcomes
+            for _ in outcomes:
+                feature_flags.flush_feature_flag_telemetry(context)
+            return telemetry.flush.call_count
+
+    def test_repeated_full_budget_timeouts_back_off(self):
+        with patch.object(feature_flags.time, "time", return_value=1000.0) as now:
+            self.assertEqual(self._flush_with_outcomes(["timed_out"] * 4), 3)
+            now.return_value = 1000.0 + feature_flags.TIMEOUT_BACKOFF_S
+            self.assertEqual(self._flush_with_outcomes(["completed"]), 1)
+
+    def test_success_resets_timeout_count(self):
+        outcomes = ["timed_out", "timed_out", "completed", "timed_out", "timed_out"]
+        self.assertEqual(self._flush_with_outcomes(outcomes), 5)
+        self.assertEqual(feature_flags._skip_until, 0.0)
+
+    def test_short_budget_timeouts_do_not_back_off(self):
+        self.assertEqual(
+            self._flush_with_outcomes(["timed_out"] * 4, remaining_ms=200), 4
+        )
+
     def test_budget(self):
         for remaining, cap, expected in [
             (5000, 500, 0.5),

The change adds module state, so FeatureFlagFlushTest needs the setUp shown, which resets it between tests. The full suite passes (1000 tests). Each new test fails when I remove its branch: the backoff, the full-cap check, or the reset on success.

if outcome != "timed_out":
_consecutive_timeouts = 0
elif budget_ms >= cap_ms:
# A short invocation budget says nothing about intake health.
_consecutive_timeouts += 1
if _consecutive_timeouts >= MAX_CONSECUTIVE_TIMEOUTS:
_consecutive_timeouts = 0
_skip_until = monotonic() + TIMEOUT_BACKOFF_S
if outcome != "completed":
logger.debug("Feature flag telemetry flush incomplete: %s", outcome)

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.

I checked your other PR, do we even need to return something from the _telemetry.flush? If all we're doing is logging, then why not just log from dd-trace-py?

except Exception:
# Never replace the response or the original handler exception.
logger.debug("Feature flag telemetry flush failed", exc_info=True)

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.

To be very honest, this method just seems really over engineered as it is right now. Why the check of FLUSH_API_VERSION? And if we route the flushed telemetry data through the lambda extension (instead of agentless), the outbound flush http request will be very fast and wouldn't need all this extra overhead.

Instead, this PR could simply be these two additions to datadog_lambda/wrapper.py and the config addition:

# added to datadog_lambda/config.py
feature_flags_enabled = _get_env("DD_FEATURE_FLAGS_ENABLED", "true", as_bool)
# at top of file
_feature_flags = None
if config.feature_flags_enabled:
    try:
        from ddtrace.internal.openfeature import _telemetry as _feature_flags
    except ImportError:
        pass
# at end of self._after
if _feature_flags is not None:
    _feature_flags.flush()

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.

Actually, if we just call periodic on the writer directly, this PR could become just 5d3d3e3 and you wouldn't even need the ddtrace 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.

🤖 Comment from Claude working with Aaron Silverman:

Calling periodic() directly is much simpler, and exception replay is good precedent. Before we switch, two gaps in 5d3d3e3 need a decision: it has no wait budget, and it does not wait for a send that is already in flight. I ran it against dd-trace-py main to check both.

  1. No wait budget. Each periodic() sends synchronously, with a 2 s socket timeout per writer, and nothing limits the total wait to the remaining invocation time. With a local endpoint that takes 1.5 s per request, the wrapper took about 3 s with 1000 ms remaining. With only that much time left, Lambda would end the invocation with a timeout. The tracer's direct-intake route has no cooldown, so a slow intake can affect every invocation that still has unsent events.
  2. An in-flight send is not awaited. When a writer's own periodic tick has already taken the batch, periodic() finds an empty buffer and returns. In the test, the wrapper returned in 1–2 ms while the server still held that batch's request, so the sandbox can freeze in the middle of the send.

On routing: with the default agentless configuration source, the tracer uses a local route only if /info advertises an EVP proxy route and evp_proxy_allowed_headers. The extension's /info lists neither, so these events go directly to the intake over HTTPS with DD_API_KEY, not through the extension.

A possible middle path: keep your wiring, and bound the wait in the wrapper, for example by running the two periodic() calls on a helper thread and joining it with a timeout (the smaller of a cap and the remaining time minus 100 ms, with the backoff from this comment). Then add a flush lock to the tracer writers, so that a second periodic() waits for an in-flight send. The flush lock needs care in ExposureWriter.enqueue, which calls periodic() while it holds _lock on main. I have not built this combination yet. I can prototype it if that direction sounds right.

Results — 5d3d3e3 with dd-trace-py main 9b07368c88, Python 3.12, three runs

Both writers pointed at a local HTTP server through a remote-config route selector, which uses the local route without discovery. The handler enqueued one evaluation and one exposure. For the in-flight case, a background periodic() call stands in for the writer's own tick, and the server holds that request until the wrapper returns.

Case Result
Endpoint answers at once, 30 s remaining wrapper took 4–5 ms; the server received both batches
Endpoint takes 1.5 s per request, 1000 ms remaining wrapper took 3014–3026 ms; the server received both batches
Background send in progress, server holding it wrapper returned in 1–2 ms; that batch was still unacknowledged
Probe test — runs inside this repo's TestDatadogLambdaWrapper setup
"""Probes for 5d3d3e3. Run: pytest -s tests/test_review_probe_rey.py -k test_probe"""

import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from unittest.mock import Mock

import datadog_lambda.wrapper as wrapper
from ddtrace.internal.openfeature import _config as ffe_config
from ddtrace.internal.openfeature import writer as ffe_writer_mod
from ddtrace.internal.openfeature._evp_transport import EVP_ORIGIN_HEADERS
from ddtrace.internal.openfeature._evp_transport import FeatureFlagEVPRouteSelector
from ddtrace.internal.openfeature._flagevaluation_writer import FlagEvaluationWriter
from ddtrace.internal.openfeature._flagevaluation_writer import _EvalEvent
from ddtrace.internal.openfeature.writer import ExposureWriter
from ddtrace.internal.settings.openfeature import REMOTE_CONFIG
from tests.test_wrapper import TestDatadogLambdaWrapper
from tests.utils import get_mock_context


class ReyProbe(TestDatadogLambdaWrapper):
    def _server(self, delay, gate=None, arrived=None):
        posts = []

        class Handler(BaseHTTPRequestHandler):
            def do_POST(self):
                self.rfile.read(int(self.headers["Content-Length"]))
                if arrived is not None:
                    arrived.set()
                if gate is not None:
                    gate.wait(5)
                time.sleep(delay)
                posts.append((self.path, time.monotonic()))
                self.send_response(202)
                self.end_headers()

            def log_message(self, *args):
                pass

        srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
        threading.Thread(target=srv.serve_forever, daemon=True).start()
        self.addCleanup(srv.shutdown)
        return srv, posts

    def _writers(self, srv):
        sel = FeatureFlagEVPRouteSelector(
            configuration_source=REMOTE_CONFIG,
            agent_url="http://127.0.0.1:%d" % srv.server_port,
            api_key=None,
            site="datadoghq.com",
            info_provider=lambda _: {
                "endpoints": ("/evp_proxy/v2/",),
                "evp_proxy_allowed_headers": tuple(EVP_ORIGIN_HEADERS),
            },
        )
        ev = FlagEvaluationWriter(interval=3600.0, route_selector=sel)
        ex = ExposureWriter(enabled=True, interval=3600.0, route_selector=sel)
        previous = ffe_writer_mod._EXPOSURE_WRITER
        ffe_writer_mod._EXPOSURE_WRITER = ex
        self.addCleanup(setattr, ffe_writer_mod, "_EXPOSURE_WRITER", previous)
        provider = Mock(_flag_eval_evp_writer=ev)
        ffe_config._provider_instances.append(provider)
        self.addCleanup(ffe_config._provider_instances.remove, provider)
        return ev, ex

    def _invoke(self, handler, remaining_ms):
        ctx = get_mock_context()
        ctx.get_remaining_time_in_millis = lambda: remaining_ms
        decorated = wrapper.datadog_lambda_wrapper(handler)
        started = time.monotonic()
        result = decorated({}, ctx)
        return result, (time.monotonic() - started) * 1000

    def _handler(self, ev, ex):
        def handler(event, context):
            ev.enqueue(
                _EvalEvent("flag", "on", "allocation", "subject", {}, False, "", 1)
            )
            ex.enqueue({"timestamp": 1})
            return {"ok": True}

        return handler

    def test_probe_fast_intake(self):
        srv, posts = self._server(0.0)
        ev, ex = self._writers(srv)
        _, ms = self._invoke(self._handler(ev, ex), 30000)
        print(
            "RESULT fast: wrapper %.0f ms, posts=%d %s"
            % (ms, len(posts), [p for p, _ in posts])
        )

    def test_probe_slow_intake_ignores_remaining_time(self):
        srv, posts = self._server(1.5)
        ev, ex = self._writers(srv)
        _, ms = self._invoke(self._handler(ev, ex), 1000)
        print(
            "RESULT slow: wrapper %.0f ms with 1000 ms remaining, posts=%d"
            % (ms, len(posts))
        )

    def test_probe_inflight_send_not_awaited(self):
        gate, arrived = threading.Event(), threading.Event()
        srv, posts = self._server(0.0, gate=gate, arrived=arrived)
        ev, ex = self._writers(srv)
        ex.enqueue({"timestamp": 1})
        # Stands in for the writer's own 1 s tick, which has taken the batch.
        background = threading.Thread(target=ex.periodic)
        background.start()
        assert arrived.wait(2)  # the server now holds that request
        _, ms = self._invoke(lambda e, c: {"ok": True}, 30000)
        pending = background.is_alive() and not posts
        gate.set()
        background.join(5)
        print(
            "RESULT inflight: wrapper returned in %.0f ms while the batch was unacknowledged: %s"
            % (ms, pending)
        )
Routing — why the events skip the extension
  • The extension's info() handler lists its endpoints in bottlecap/src/traces/trace_agent.rs. The list has no /evp_proxy/v2 or /evp_proxy/v4 base path, and the response has no evp_proxy_allowed_headers.
  • dd-trace-py's FeatureFlagEVPRouteSelector._discover_local_route() (ddtrace/internal/openfeature/_evp_transport.py) returns no local route unless /info advertises one of those base paths and allows the DD-EVP-ORIGIN headers. The selector then uses direct intake with DD_API_KEY.

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 hasn't been an issue for any of the other flushes we do at the end of invocations, but none of those are agentless. I'd like to test out adding the EVP proxy to /info and then seeing if calling periodic() directly causes any issues.

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:

Your plan works with today's tracer. I tested it from a real dd-trace-py process through the patched extension's router to its relay queue. One change in bottlecap/src/traces/trace_agent.rs (patch below) is enough, and both periodic() calls returned in 0.8–1.9 ms across three runs. I did not run the extension's flush to the intake.

With dd-trace-py's default agentless configuration source, the tracer needs four things from the extension:

  1. List the bare base path /evp_proxy/v2/ in /info endpoints. Full routes alone are not enough: dd-trace-py compares each endpoint with the base path (a trailing slash is ignored) before it uses an EVP route.
  2. Add evp_proxy_allowed_headers with DD-EVP-ORIGIN and DD-EVP-ORIGIN-VERSION. The match ignores case. Without these headers, the tracer sends directly to the intake when DD_API_KEY is set, and has no route otherwise.
  3. Route /evp_proxy/v2/api/v2/flagevaluation and /evp_proxy/v2/api/v2/exposures to event-platform-intake. Use handle_proxy, as the LLM Obs routes do, with event-platform-intake as the backend domain. handle_proxy already forwards the request headers, and the proxy flusher adds DD-API-KEY.
  4. Do not list /evp_proxy/v4/ unless you also route it. When the tracer sees v4, it sends to /evp_proxy/v4/... instead.

What I ran, with dd-trace-py main 9b07368c88 and bottlecap main 8a2cc02:

  • Patched bottlecap: the real tracer chose the local /evp_proxy/v2 route from the real /info. Bottlecap queued both batches for event-platform-intake with the origin headers intact. I passed no API key to the tracer's route selector. The extension adds its own DD-API-KEY when it flushes (proxy_flusher.rs), so this route should also serve functions that keep the key in DD_API_KEY_SECRET_ARN. I did not run that flush.
  • Unpatched bottlecap: with no API key, the tracer found no route, and nothing reached bottlecap. With DD_API_KEY set, the tracer uses direct HTTPS.
  • Fallback: if an advertised route answers 404 or 405, the tracer replays that batch directly to the intake (when DD_API_KEY is set) and uses direct for later batches. I saw the replay attempt. My stand-in intake was plain HTTP, which the tracer refuses, so I did not see the replay succeed.

Three side effects to plan for:

  • Other runtimes move too. Java and Go look for the exact /evp_proxy/v2/ endpoint when they choose agent mode for LLM Observability. With DD_LLMOBS_AGENTLESS_ENABLED unset, they would switch from agentless to the extension in Lambda. The extension already relays those LLM Obs routes, but they are worth a smoke test. dd-trace-py (substring match) and Node (prefix match) already send LLM Obs to the extension today.
  • Loading flags still needs DD_API_KEY. dd-trace-py's default agentless configuration source does not start without it (create_agentless_source), so a function that only has DD_API_KEY_SECRET_ARN gets its events relayed but loads no flags. That is a separate gap.
  • The in-flight case from my earlier comment remains. In my runs the two local sends took 0.8–1.9 ms together; I did not measure the race itself. Functions without the extension still send directly and still need a wait cap.
Patch — cargo fmt clean, no clippy findings in trace_agent.rs, and the 11 trace_agent tests pass, including the 2 new ones
--- a/bottlecap/src/traces/trace_agent.rs
+++ b/bottlecap/src/traces/trace_agent.rs
@@ -64,6 +64,10 @@ const LLM_OBS_EVAL_METRIC_ENDPOINT_PATH: &str = "/evp_proxy/v2/api/intake/llm-ob
 const LLM_OBS_EVAL_METRIC_ENDPOINT_PATH_V2: &str =
     "/evp_proxy/v2/api/intake/llm-obs/v2/eval-metric";
 const LLM_OBS_SPANS_ENDPOINT_PATH: &str = "/evp_proxy/v2/api/v2/llmobs";
+// Some tracers need this base path in /info before they use EVP proxy routes.
+const EVP_PROXY_V2_BASE_PATH: &str = "/evp_proxy/v2/";
+const FFE_FLAG_EVALUATION_ENDPOINT_PATH: &str = "/evp_proxy/v2/api/v2/flagevaluation";
+const FFE_EXPOSURES_ENDPOINT_PATH: &str = "/evp_proxy/v2/api/v2/exposures";
 const INFO_ENDPOINT_PATH: &str = "/info";
 const V1_DEBUGGER_ENDPOINT_PATH: &str = "/debugger/v1/input";
 const V2_DEBUGGER_ENDPOINT_PATH: &str = "/debugger/v2/input";
@@ -73,6 +77,8 @@ const INSTRUMENTATION_ENDPOINT_PATH: &str = "/telemetry/proxy/api/v2/apmtelemetr
 // Intake endpoints
 const DSM_INTAKE_PATH: &str = "/api/v0.1/pipeline_stats";
 const LLM_OBS_SPANS_INTAKE_PATH: &str = "/api/v2/llmobs";
+const FFE_FLAG_EVALUATION_INTAKE_PATH: &str = "/api/v2/flagevaluation";
+const FFE_EXPOSURES_INTAKE_PATH: &str = "/api/v2/exposures";
 const LLM_OBS_EVAL_METRIC_INTAKE_PATH: &str = "/api/intake/llm-obs/v1/eval-metric";
 const LLM_OBS_EVAL_METRIC_INTAKE_PATH_V2: &str = "/api/intake/llm-obs/v2/eval-metric";
 const PROFILING_INTAKE_PATH: &str = "/api/v2/profile";
@@ -452,6 +458,11 @@ impl TraceAgent {
                 post(Self::llm_obs_eval_metric_proxy_v2),
             )
             .route(LLM_OBS_SPANS_ENDPOINT_PATH, post(Self::llm_obs_spans_proxy))
+            .route(
+                FFE_FLAG_EVALUATION_ENDPOINT_PATH,
+                post(Self::ffe_flag_evaluation_proxy),
+            )
+            .route(FFE_EXPOSURES_ENDPOINT_PATH, post(Self::ffe_exposures_proxy))
             .route(V1_DEBUGGER_ENDPOINT_PATH, post(Self::debugger_logs_proxy))
             .route(V2_DEBUGGER_ENDPOINT_PATH, post(Self::debugger_intake_proxy))
             .route(
@@ -594,6 +605,33 @@ impl TraceAgent {
         .await
     }
 
+    async fn ffe_flag_evaluation_proxy(
+        State(state): State<ProxyState>,
+        request: Request,
+    ) -> Response {
+        Self::handle_proxy(
+            state.config,
+            state.proxy_aggregator,
+            request,
+            "event-platform-intake",
+            FFE_FLAG_EVALUATION_INTAKE_PATH,
+            "ffe_flag_evaluation",
+        )
+        .await
+    }
+
+    async fn ffe_exposures_proxy(State(state): State<ProxyState>, request: Request) -> Response {
+        Self::handle_proxy(
+            state.config,
+            state.proxy_aggregator,
+            request,
+            "event-platform-intake",
+            FFE_EXPOSURES_INTAKE_PATH,
+            "ffe_exposures",
+        )
+        .await
+    }
+
     // Used for `/debugger/v1/input` in Exception Replay
     async fn debugger_logs_proxy(State(state): State<ProxyState>, request: Request) -> Response {
         Self::handle_proxy(
@@ -646,10 +684,15 @@ impl TraceAgent {
                     LLM_OBS_EVAL_METRIC_ENDPOINT_PATH,
                     LLM_OBS_EVAL_METRIC_ENDPOINT_PATH_V2,
                     LLM_OBS_SPANS_ENDPOINT_PATH,
+                    EVP_PROXY_V2_BASE_PATH,
+                    FFE_FLAG_EVALUATION_ENDPOINT_PATH,
+                    FFE_EXPOSURES_ENDPOINT_PATH,
                     V1_DEBUGGER_ENDPOINT_PATH,
                     V2_DEBUGGER_ENDPOINT_PATH,
                     DEBUGGER_DIAGNOSTICS_ENDPOINT_PATH,
                 ],
+                // handle_proxy forwards request headers; tracers check these before using EVP.
+                "evp_proxy_allowed_headers": ["DD-EVP-ORIGIN", "DD-EVP-ORIGIN-VERSION"],
                 "client_drop_p0s": false,
             }
         );
@@ -1286,4 +1329,66 @@ mod tests {
 
         assert_eq!(response.status(), StatusCode::NOT_FOUND);
     }
+
+    #[tokio::test]
+    async fn info_advertises_evp_proxy_for_feature_flags() {
+        let response = TraceAgent::info().await;
+        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+            .await
+            .expect("info body");
+        let info: serde_json::Value = serde_json::from_slice(&body).expect("info json");
+        let endpoints = info["endpoints"].as_array().expect("endpoints");
+        for path in [
+            EVP_PROXY_V2_BASE_PATH,
+            FFE_FLAG_EVALUATION_ENDPOINT_PATH,
+            FFE_EXPOSURES_ENDPOINT_PATH,
+        ] {
+            assert!(endpoints.iter().any(|e| e == path), "missing {path}");
+        }
+        let headers = info["evp_proxy_allowed_headers"]
+            .as_array()
+            .expect("evp_proxy_allowed_headers");
+        for header in ["DD-EVP-ORIGIN", "DD-EVP-ORIGIN-VERSION"] {
+            assert!(headers.iter().any(|h| h == header), "missing {header}");
+        }
+    }
+
+    #[tokio::test]
+    async fn feature_flag_events_are_relayed_to_event_platform_intake() {
+        let agent = build_test_agent();
+        let site = agent.config.site.clone();
+        let router = agent.make_router().expect("make_router");
+        for (path, intake) in [
+            (
+                FFE_FLAG_EVALUATION_ENDPOINT_PATH,
+                FFE_FLAG_EVALUATION_INTAKE_PATH,
+            ),
+            (FFE_EXPOSURES_ENDPOINT_PATH, FFE_EXPOSURES_INTAKE_PATH),
+        ] {
+            let response = router
+                .clone()
+                .oneshot(
+                    Request::builder()
+                        .method("POST")
+                        .uri(path)
+                        .header("Content-Type", "application/json")
+                        .header("DD-EVP-ORIGIN", "dd-trace-py")
+                        .header("DD-EVP-ORIGIN-VERSION", "4.17.0")
+                        .header("X-Datadog-EVP-Subdomain", "event-platform-intake")
+                        .body(Body::from(r#"{"events":[]}"#))
+                        .expect("build request"),
+                )
+                .await
+                .expect("route response");
+            assert_eq!(response.status(), StatusCode::OK, "{path}");
+            let batch = agent.proxy_aggregator.lock().await.get_batch();
+            assert_eq!(batch.len(), 1, "{path}");
+            assert_eq!(
+                batch[0].target_url,
+                format!("https://event-platform-intake.{site}{intake}")
+            );
+            assert_eq!(batch[0].headers["dd-evp-origin"], "dd-trace-py");
+            assert_eq!(batch[0].headers["dd-evp-origin-version"], "4.17.0");
+        }
+    }
 }
Tracer-to-extension check — a real dd-trace-py process against the real router, patched and unpatched

I added a temporary ignored test that serves make_router() on a port, ran the tracer against it, and then printed what bottlecap queued for relay. The harness stops at that queue; it does not run the proxy flusher.

    /// Serves the real router on 127.0.0.1:18126 for an out-of-process tracer, then prints what it
    /// would relay. Run with `cargo test serve_router_for_tracer -- --ignored --nocapture`.
    #[tokio::test]
    #[ignore = "manual end-to-end harness"]
    async fn serve_router_for_tracer() {
        let agent = build_test_agent();
        let router = agent.make_router().expect("make_router");
        let listener = tokio::net::TcpListener::bind("127.0.0.1:18126")
            .await
            .expect("bind");
        println!("E2E listening");
        let server = tokio::spawn(async move { axum::serve(listener, router).await });
        tokio::time::sleep(std::time::Duration::from_secs(25)).await;
        server.abort();
        for request in agent.proxy_aggregator.lock().await.get_batch() {
            println!(
                "E2E relay target={} origin={:?} version={:?} subdomain={:?} bytes={}",
                request.target_url,
                request.headers.get("dd-evp-origin"),
                request.headers.get("dd-evp-origin-version"),
                request.headers.get("x-datadog-evp-subdomain"),
                request.body.len()
            );
        }
    }
"""Real dd-trace-py writers -> real bottlecap router on 127.0.0.1:18126. No API key passed to the tracer."""
import time

from ddtrace.internal.openfeature._evp_transport import FeatureFlagEVPRouteSelector
from ddtrace.internal.openfeature._flagevaluation_writer import FlagEvaluationWriter, _EvalEvent
from ddtrace.internal.openfeature.writer import ExposureWriter
from ddtrace.internal.settings.openfeature import AGENTLESS

sel = FeatureFlagEVPRouteSelector(
    configuration_source=AGENTLESS, agent_url="http://127.0.0.1:18126", api_key=None, site="datadoghq.com"
)
route = sel.select()
print("TRACER route:", None if route is None else (route.intake, route.base_path))
ev = FlagEvaluationWriter(interval=3600.0, route_selector=sel)
ex = ExposureWriter(enabled=True, interval=3600.0, route_selector=sel)
ev.enqueue(_EvalEvent("flag", "on", "allocation", "subject", {}, False, "", 1))
ex.enqueue(
    {
        "timestamp": int(time.time() * 1000),
        "allocation": {"key": "allocation"},
        "flag": {"key": "flag"},
        "variant": {"key": "on"},
        "subject": {"id": "subject", "attributes": {}},
    }
)
started = time.monotonic()
ev.periodic()
ex.periodic()
print("TRACER periodic() x2 took %.1f ms" % ((time.monotonic() - started) * 1000))
Bottlecap Tracer route Queued for relay
patched local /evp_proxy/v2; both periodic() calls took 1.9, 0.9 and 0.8 ms in three runs https://event-platform-intake.<site>/api/v2/flagevaluation (312 bytes) and /api/v2/exposures (216 bytes), each with DD-EVP-ORIGIN: dd-trace-py, DD-EVP-ORIGIN-VERSION and X-Datadog-EVP-Subdomain: event-platform-intake
unpatched none; both periodic() calls returned in 0.1 ms nothing

The test config has an empty site, so the printed host was event-platform-intake. with no domain.

What the tracer checks — real dd-trace-py discovery against each /info shape

FeatureFlagEVPRouteSelector with the default agentless configuration source, pointed at a local /info:

/info shape DD_API_KEY set DD_API_KEY unset
bottlecap today direct no route
plus the two flag routes direct no route
plus /evp_proxy/v2/, no allowed headers direct no route
plus /evp_proxy/v2/ and the two allowed headers local /evp_proxy/v2, direct fallback local /evp_proxy/v2
same, header names in lower case local /evp_proxy/v2, direct fallback local /evp_proxy/v2
same, plus /evp_proxy/v4/ local /evp_proxy/v4 local /evp_proxy/v4

With the remote-config source, the tracer uses the local v2 route without discovery. On the local route, the tracer sends Content-Type: application/json, DD-EVP-ORIGIN, DD-EVP-ORIGIN-VERSION and X-Datadog-EVP-Subdomain: event-platform-intake, and no DD-API-KEY. The checks live in FeatureFlagEVPRouteSelector._discover_local_route() in ddtrace/internal/openfeature/_evp_transport.py.

10 changes: 10 additions & 0 deletions datadog_lambda/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@
if config.llmobs_enabled:
from ddtrace.llmobs import LLMObs

flush_feature_flag_telemetry = None
if config.feature_flags_enabled:
from datadog_lambda.feature_flags import flush_feature_flag_telemetry

if config.exception_replay_enabled:
from ddtrace.debugging._exception.replay import SpanExceptionHandler

Expand Down Expand Up @@ -400,6 +404,12 @@ def _after(self, event, context):
except Exception as e:
logger.debug("Failed to create cold start spans. %s", e)

if (
config.feature_flags_enabled
and flush_feature_flag_telemetry is not None
):
flush_feature_flag_telemetry(context)

if not config.flush_to_log or should_use_extension:
from datadog_lambda.metric import flush_stats

Expand Down
49 changes: 49 additions & 0 deletions tests/integration/feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Short-invocation smoke handler; use an exposure-enabled string test flag."""

import json
import os
import time
import uuid

from datadog_lambda.wrapper import datadog_lambda_wrapper
from ddtrace.openfeature import DataDogProvider
from openfeature import api
from openfeature.evaluation_context import EvaluationContext

provider = DataDogProvider()
api.set_provider_and_wait(provider)
client = api.get_client("lambda-flush-smoke")
sandbox_id = uuid.uuid4().hex
invocations = 0


@datadog_lambda_wrapper
def handle(event, context):
global invocations
invocations += 1
started = time.monotonic()
subject = event["subject"]
details = client.get_string_details(
os.environ["FFL_SMOKE_FLAG_KEY"],
"smoke-default",
EvaluationContext(targeting_key=subject),
)
result = {
"request_id": context.aws_request_id,
"sandbox_id": sandbox_id,
"invocation": invocations,
"subject": subject,
"value": details.value,
"variant": details.variant,
"error_code": str(details.error_code) if details.error_code else None,
"evaluation_ms": (time.monotonic() - started) * 1000,
}
# Only synthetic test subjects belong in this smoke-test log.
print(json.dumps(result))
if details.error_code:
raise RuntimeError(
"Smoke flag did not resolve; verify test flag and configuration"
)
if event.get("raise"):
raise RuntimeError("intentional smoke-test handler failure")
return result
1 change: 1 addition & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def _test_as_list(env_key, conf_key, default):
*_test_as_bool("DD_MERGE_XRAY_TRACES", "merge_xray_traces", default=False),
*_test_as_bool("DD_PROFILING_ENABLED", "profiling_enabled", default=False),
*_test_as_bool("DD_LLMOBS_ENABLED", "llmobs_enabled", default=False),
*_test_as_bool("DD_FEATURE_FLAGS_ENABLED", "feature_flags_enabled", default=True),
*_test_as_bool(
"DD_EXCEPTION_REPLAY_ENABLED", "exception_replay_enabled", default=False
),
Expand Down
236 changes: 236 additions & 0 deletions tests/test_feature_flags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import os
import subprocess
import sys
import textwrap
import unittest
from unittest.mock import Mock, patch

from datadog_lambda import feature_flags
from datadog_lambda.config import Config


class FeatureFlagFlushTest(unittest.TestCase):
def setUp(self):
for name, value in [("_consecutive_timeouts", 0), ("_skip_until", 0.0)]:
patcher = patch.object(feature_flags, name, value)
patcher.start()
self.addCleanup(patcher.stop)

def test_full_budget_timeouts_back_off_and_retry_after_expiry(self):
with patch.object(
feature_flags, "monotonic", return_value=1000.0
) as now, patch.object(feature_flags, "_telemetry") as telemetry, patch.object(
Config, "feature_flag_flush_timeout_ms", 250
):
telemetry.FLUSH_API_VERSION = 1
telemetry.flush.return_value = "timed_out"
context = Mock(get_remaining_time_in_millis=lambda: 30000)
for _ in range(3):
feature_flags.flush_feature_flag_telemetry(context)
self.assertEqual(telemetry.flush.call_count, 3)
telemetry.flush.assert_called_with(0.25)
for timestamp in (1000.0, 1030.0, 1059.999):
now.return_value = timestamp
feature_flags.flush_feature_flag_telemetry(context)
self.assertEqual(telemetry.flush.call_count, 3)
now.return_value = 1060.0
telemetry.flush.return_value = "completed"
feature_flags.flush_feature_flag_telemetry(context)
self.assertEqual(telemetry.flush.call_count, 4)
# Recovery starts a new streak, not an immediate second cooldown.
telemetry.flush.return_value = "timed_out"
for _ in range(2):
feature_flags.flush_feature_flag_telemetry(context)
telemetry.flush.return_value = "completed"
feature_flags.flush_feature_flag_telemetry(context)
self.assertEqual(telemetry.flush.call_count, 7)

def test_completed_and_failed_flushes_reset_timeout_streak(self):
for outcome in ("completed", "failed"):
with self.subTest(outcome=outcome), patch.object(
feature_flags, "_telemetry"
) as telemetry, patch.object(
feature_flags, "monotonic", return_value=1000.0
):
telemetry.FLUSH_API_VERSION = 1
outcomes = [
"timed_out",
"timed_out",
outcome,
"timed_out",
"timed_out",
"completed",
]
telemetry.flush.side_effect = outcomes
for _ in outcomes:
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: 30000)
)
self.assertEqual(telemetry.flush.call_count, len(outcomes))

def test_short_budget_timeouts_neither_count_nor_reset_streak(self):
with patch.object(feature_flags, "_telemetry") as telemetry, patch.object(
feature_flags, "monotonic", return_value=1000.0
):
telemetry.FLUSH_API_VERSION = 1
telemetry.flush.return_value = "timed_out"
for remaining in (30000, 200, 200, 200, 30000, 30000):
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: remaining)
)
self.assertEqual(telemetry.flush.call_count, 6)
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: 30000)
)
self.assertEqual(telemetry.flush.call_count, 6)

def test_budget(self):
for remaining, cap, expected in [
(5000, 500, 0.5),
(200, 500, 0.1),
(101, 500, 0.001),
]:
with self.subTest(remaining=remaining), patch.object(
feature_flags, "_telemetry"
) as telemetry:
telemetry.FLUSH_API_VERSION = 1
telemetry.flush.return_value = "completed"
with patch.object(
type(feature_flags.config), "feature_flag_flush_timeout_ms", cap
):
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: remaining)
)
telemetry.flush.assert_called_once_with(expected)

def test_no_budget(self):
for remaining, cap in [
(100, 500),
(0, 500),
(1000, 0),
(1000, -1),
(1000, float("nan")),
]:
with self.subTest(remaining=remaining, cap=cap), patch.object(
feature_flags, "_telemetry"
) as telemetry:
telemetry.FLUSH_API_VERSION = 1
with patch.object(
type(feature_flags.config), "feature_flag_flush_timeout_ms", cap
):
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: remaining)
)
telemetry.flush.assert_not_called()

def test_old_tracer(self):
for telemetry in [None, Mock(FLUSH_API_VERSION=2)]:
with self.subTest(telemetry=telemetry), patch.object(
feature_flags, "_telemetry", telemetry
):
context = Mock()
context.get_remaining_time_in_millis.return_value = 1000
feature_flags.flush_feature_flag_telemetry(context)
context.get_remaining_time_in_millis.assert_not_called()
if telemetry is not None:
telemetry.flush.assert_not_called()

def test_timeout_environment_parsing_preserves_flush(self):
for value, expected in [
(None, 0.5),
("250.5", 0.2505),
("", 0.5),
("garbage", 0.5),
("500ms", 0.5),
]:
with self.subTest(value=value), patch.dict(os.environ), patch.object(
feature_flags, "config", Config()
), patch.object(feature_flags, "_telemetry") as telemetry:
if value is None:
os.environ.pop("DD_FEATURE_FLAGS_FLUSH_TIMEOUT_MS", None)
else:
os.environ["DD_FEATURE_FLAGS_FLUSH_TIMEOUT_MS"] = value
telemetry.FLUSH_API_VERSION = 1
telemetry.flush.return_value = "completed"
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: 1000)
)
telemetry.flush.assert_called_once_with(expected)

def test_missing_context_and_telemetry_errors_are_safe(self):
with patch.object(feature_flags, "_telemetry") as telemetry:
telemetry.FLUSH_API_VERSION = 1
telemetry.flush.side_effect = RuntimeError("unavailable")
feature_flags.flush_feature_flag_telemetry(None)
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: 1000)
)
telemetry.flush.assert_called_once()

def test_missing_remaining_time_skips_without_failure_log(self):
for context in (None, object()):
with self.subTest(context=context), patch.object(
feature_flags, "_telemetry"
) as telemetry, patch.object(feature_flags, "logger") as log:
telemetry.FLUSH_API_VERSION = 1
feature_flags.flush_feature_flag_telemetry(context)
telemetry.flush.assert_not_called()
log.debug.assert_not_called()

def test_unsupported_tracer_skip_is_logged(self):
with patch.object(
feature_flags, "_telemetry", Mock(FLUSH_API_VERSION=2)
) as telemetry, patch.object(feature_flags, "logger") as log:
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: 1000)
)
telemetry.flush.assert_not_called()
log.debug.assert_called_once_with(
"Skipping feature flag telemetry flush: unsupported tracer"
)

def test_disabled_feature_flags_do_not_import_flush_modules(self):
code = textwrap.dedent(
"""
import sys

class BlockFlushImports:
def find_spec(self, fullname, *args):
if fullname in (
"datadog_lambda.feature_flags",
"ddtrace.internal.openfeature._telemetry",
):
raise AssertionError("unexpected import: " + fullname)

sys.meta_path.insert(0, BlockFlushImports())
from datadog_lambda import wrapper
assert not wrapper.config.feature_flags_enabled
assert "datadog_lambda.feature_flags" not in sys.modules
assert "ddtrace.internal.openfeature._telemetry" not in sys.modules
"""
)
result = subprocess.run(
[sys.executable, "-c", code],
env={
**os.environ,
"DD_FEATURE_FLAGS_ENABLED": "false",
"DD_INSTRUMENTATION_TELEMETRY_ENABLED": "false",
},
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)

def test_incomplete_diagnostic(self):
with patch.object(feature_flags, "_telemetry") as telemetry, patch.object(
feature_flags, "logger"
) as log:
telemetry.FLUSH_API_VERSION = 1
telemetry.flush.return_value = "timed_out"
feature_flags.flush_feature_flag_telemetry(
Mock(get_remaining_time_in_millis=lambda: 1000)
)
log.debug.assert_called_with(
"Feature flag telemetry flush incomplete: %s", "timed_out"
)
Loading
Loading