1- import importlib .util
21import os
32import subprocess
43import sys
76from unittest .mock import Mock , patch
87
98from datadog_lambda import feature_flags
10- from datadog_lambda import wrapper
119from datadog_lambda .config import Config
1210
1311
1412class FeatureFlagFlushTest (unittest .TestCase ):
13+ def setUp (self ):
14+ for name , value in [("_consecutive_timeouts" , 0 ), ("_skip_until" , 0.0 )]:
15+ patcher = patch .object (feature_flags , name , value )
16+ patcher .start ()
17+ self .addCleanup (patcher .stop )
18+
19+ def test_full_budget_timeouts_back_off_and_retry_after_expiry (self ):
20+ with patch .object (
21+ feature_flags , "monotonic" , return_value = 1000.0
22+ ) as now , patch .object (feature_flags , "_telemetry" ) as telemetry , patch .object (
23+ Config , "feature_flag_flush_timeout_ms" , 250
24+ ):
25+ telemetry .FLUSH_API_VERSION = 1
26+ telemetry .flush .return_value = "timed_out"
27+ context = Mock (get_remaining_time_in_millis = lambda : 30000 )
28+ for _ in range (3 ):
29+ feature_flags .flush_feature_flag_telemetry (context )
30+ self .assertEqual (telemetry .flush .call_count , 3 )
31+ telemetry .flush .assert_called_with (0.25 )
32+ for timestamp in (1000.0 , 1030.0 , 1059.999 ):
33+ now .return_value = timestamp
34+ feature_flags .flush_feature_flag_telemetry (context )
35+ self .assertEqual (telemetry .flush .call_count , 3 )
36+ now .return_value = 1060.0
37+ telemetry .flush .return_value = "completed"
38+ feature_flags .flush_feature_flag_telemetry (context )
39+ self .assertEqual (telemetry .flush .call_count , 4 )
40+ # Recovery starts a new streak, not an immediate second cooldown.
41+ telemetry .flush .return_value = "timed_out"
42+ for _ in range (2 ):
43+ feature_flags .flush_feature_flag_telemetry (context )
44+ telemetry .flush .return_value = "completed"
45+ feature_flags .flush_feature_flag_telemetry (context )
46+ self .assertEqual (telemetry .flush .call_count , 7 )
47+
48+ def test_completed_and_failed_flushes_reset_timeout_streak (self ):
49+ for outcome in ("completed" , "failed" ):
50+ with self .subTest (outcome = outcome ), patch .object (
51+ feature_flags , "_telemetry"
52+ ) as telemetry , patch .object (
53+ feature_flags , "monotonic" , return_value = 1000.0
54+ ):
55+ telemetry .FLUSH_API_VERSION = 1
56+ outcomes = [
57+ "timed_out" ,
58+ "timed_out" ,
59+ outcome ,
60+ "timed_out" ,
61+ "timed_out" ,
62+ "completed" ,
63+ ]
64+ telemetry .flush .side_effect = outcomes
65+ for _ in outcomes :
66+ feature_flags .flush_feature_flag_telemetry (
67+ Mock (get_remaining_time_in_millis = lambda : 30000 )
68+ )
69+ self .assertEqual (telemetry .flush .call_count , len (outcomes ))
70+
71+ def test_short_budget_timeouts_neither_count_nor_reset_streak (self ):
72+ with patch .object (feature_flags , "_telemetry" ) as telemetry , patch .object (
73+ feature_flags , "monotonic" , return_value = 1000.0
74+ ):
75+ telemetry .FLUSH_API_VERSION = 1
76+ telemetry .flush .return_value = "timed_out"
77+ for remaining in (30000 , 200 , 200 , 200 , 30000 , 30000 ):
78+ feature_flags .flush_feature_flag_telemetry (
79+ Mock (get_remaining_time_in_millis = lambda : remaining )
80+ )
81+ self .assertEqual (telemetry .flush .call_count , 6 )
82+ feature_flags .flush_feature_flag_telemetry (
83+ Mock (get_remaining_time_in_millis = lambda : 30000 )
84+ )
85+ self .assertEqual (telemetry .flush .call_count , 6 )
86+
1587 def test_budget (self ):
1688 for remaining , cap , expected in [
1789 (5000 , 500 , 0.5 ),
@@ -118,7 +190,8 @@ def test_unsupported_tracer_skip_is_logged(self):
118190 )
119191
120192 def test_disabled_feature_flags_do_not_import_flush_modules (self ):
121- code = textwrap .dedent ("""
193+ code = textwrap .dedent (
194+ """
122195 import sys
123196
124197 class BlockFlushImports:
@@ -134,7 +207,8 @@ def find_spec(self, fullname, *args):
134207 assert not wrapper.config.feature_flags_enabled
135208 assert "datadog_lambda.feature_flags" not in sys.modules
136209 assert "ddtrace.internal.openfeature._telemetry" not in sys.modules
137- """ )
210+ """
211+ )
138212 result = subprocess .run (
139213 [sys .executable , "-c" , code ],
140214 env = {
@@ -160,88 +234,3 @@ def test_incomplete_diagnostic(self):
160234 log .debug .assert_called_with (
161235 "Feature flag telemetry flush incomplete: %s" , "timed_out"
162236 )
163-
164- def test_wrapper_preserves_result_and_exception_when_cleanup_fails (self ):
165- for raises in [False , True ]:
166- original = ValueError ("handler error" )
167- handler = (
168- Mock (side_effect = original )
169- if raises
170- else Mock (return_value = {"ok" : True })
171- )
172- # Isolate invocation control flow from tracing/metrics initialization.
173- decorated = object .__new__ (wrapper ._LambdaDecorator )
174- decorated .func = handler
175- decorated .blocking_response = None
176- decorated .span = None
177- decorated ._before = Mock ()
178- decorated ._after = Mock (side_effect = RuntimeError ("cleanup error" ))
179- context = Mock (get_remaining_time_in_millis = lambda : 30000 )
180- with patch .object (wrapper , "flush_feature_flag_telemetry" ) as flush , patch (
181- "datadog_lambda.metric.submit_errors_metric" ,
182- side_effect = RuntimeError ("metrics error" ),
183- ):
184- if raises :
185- with self .assertRaises (ValueError ) as caught :
186- decorated ({}, context )
187- self .assertIs (caught .exception , original )
188- else :
189- self .assertEqual (decorated ({}, context ), {"ok" : True })
190- flush .assert_called_once_with (context )
191-
192-
193- class FeatureFlagTracerIntegrationTest (unittest .TestCase ):
194- @unittest .skipUnless (
195- feature_flags ._telemetry is not None
196- and importlib .util .find_spec ("openfeature" ) is not None ,
197- "requires tracer flush contract v1 and the optional OpenFeature SDK" ,
198- )
199- def test_short_warm_invocations_flush_both_writers (self ):
200- from ddtrace .internal .openfeature ._flagevaluation_writer import (
201- FlagEvaluationWriter ,
202- _EvalEvent ,
203- )
204- from ddtrace .internal .openfeature ._evp_transport import EVPRoute
205- from ddtrace .internal .openfeature .writer import ExposureWriter
206-
207- route = EVPRoute ("http://agent:8126" , "/evp_proxy/v2" , {})
208- selector = Mock (configuration_source = "agentless" )
209- selector .select .return_value = route
210- selector .send .side_effect = lambda route , send : send (route )
211- connection = Mock ()
212- connection .getresponse .return_value .status = 202
213- evaluations = FlagEvaluationWriter (
214- route_selector = selector , connection_factory = lambda * a , ** k : connection
215- )
216- exposures = ExposureWriter (
217- enabled = True ,
218- route_selector = selector ,
219- connection_factory = lambda * a , ** k : connection ,
220- )
221- original = ValueError ("handler failure" )
222-
223- def handler (event , context ):
224- evaluations .enqueue (
225- _EvalEvent ("flag" , "on" , "allocation" , "subject" , {}, False , "" , 1 )
226- )
227- exposures .enqueue ({"timestamp" : 1 })
228- if event .get ("raise" ):
229- raise original
230- return {"ok" : True }
231-
232- decorated = object .__new__ (wrapper ._LambdaDecorator )
233- decorated .func = handler
234- decorated .blocking_response = None
235- decorated .span = None
236- decorated ._before = Mock ()
237- decorated ._after = Mock (side_effect = RuntimeError ("unrelated cleanup" ))
238- context = Mock (get_remaining_time_in_millis = lambda : 30000 )
239- with patch ("datadog_lambda.metric.submit_errors_metric" ):
240- self .assertEqual (decorated ({}, context ), {"ok" : True })
241- self .assertEqual (connection .request .call_count , 2 )
242- with self .assertRaises (ValueError ) as caught :
243- decorated ({"raise" : True }, context )
244- self .assertIs (caught .exception , original )
245- self .assertEqual (connection .request .call_count , 4 )
246- self .assertEqual (decorated ({}, context ), {"ok" : True })
247- self .assertEqual (connection .request .call_count , 6 )
0 commit comments