2525
2626import pytest
2727from opentelemetry .metrics import MeterProvider
28+ from opentelemetry .sdk .metrics .view import ExponentialBucketHistogramAggregation , View
2829
2930from airflow_shared .observability .common import get_otel_data_exporter
3031from airflow_shared .observability .exceptions import InvalidStatsNameException
@@ -244,25 +245,28 @@ def test_timing_new_metric(self, name):
244245
245246 self .stats .timing (name , dt = datetime .timedelta (seconds = 123 ))
246247
247- self .meter .get_meter ().create_gauge .assert_called_once_with (name = full_name (name ))
248- expected_value = 123000.0
249- assert self .map [full_name (name )].value == expected_value
248+ self .meter .get_meter ().create_histogram .assert_called_once_with (name = full_name (name ), unit = "ms" )
249+ self .meter .get_meter ().create_histogram .return_value .record .assert_called_once_with (
250+ 123000.0 , attributes = None
251+ )
250252
251253 def test_timing_new_metric_with_tags (self , name ):
252254 tags = {"hello" : "world" }
253- key = _generate_key_name (full_name (name ), tags )
254255
255256 self .stats .timing (name , dt = 1 , tags = tags )
256257
257- self .meter .get_meter ().create_gauge .assert_called_once_with (name = full_name (name ))
258- self .map [key ].attributes == tags
258+ self .meter .get_meter ().create_histogram .assert_called_once_with (name = full_name (name ), unit = "ms" )
259+ self .meter .get_meter ().create_histogram .return_value .record .assert_called_once_with (
260+ 1.0 , attributes = tags
261+ )
259262
260263 def test_timing_existing_metric (self , name ):
261264 self .stats .timing (name , dt = 1 )
262265 self .stats .timing (name , dt = 2 )
263266
264- self .meter .get_meter ().create_gauge .assert_called_once_with (name = full_name (name ))
265- assert self .map [full_name (name )].value == 2
267+ # histogram created only once, but both observations are recorded
268+ self .meter .get_meter ().create_histogram .assert_called_once_with (name = full_name (name ), unit = "ms" )
269+ assert self .meter .get_meter ().create_histogram .return_value .record .call_count == 2
266270
267271 # For the four test_timer_foo tests below:
268272 # time.perf_count() is called once to get the starting timestamp and again
@@ -277,7 +281,7 @@ def test_timer_with_name_returns_float_and_stores_value(self, mock_time, name):
277281 expected_duration = 3140.0
278282 assert timer .duration == expected_duration
279283 assert mock_time .call_count == 2
280- self .meter .get_meter ().create_gauge .assert_called_once_with (name = full_name (name ))
284+ self .meter .get_meter ().create_histogram .assert_called_once_with (name = full_name (name ), unit = "ms" )
281285
282286 @mock .patch .object (time , "perf_counter" , side_effect = [0.0 , 3.14 ])
283287 def test_timer_no_name_returns_float_but_does_not_store_value (self , mock_time , name ):
@@ -288,7 +292,7 @@ def test_timer_no_name_returns_float_but_does_not_store_value(self, mock_time, n
288292 expected_duration = 3140.0
289293 assert timer .duration == expected_duration
290294 assert mock_time .call_count == 2
291- self .meter .get_meter ().create_gauge .assert_not_called ()
295+ self .meter .get_meter ().create_histogram .assert_not_called ()
292296
293297 @mock .patch .object (time , "perf_counter" , side_effect = [0.0 , 3.14 ])
294298 def test_timer_start_and_stop_manually_send_false (self , mock_time , name ):
@@ -301,7 +305,7 @@ def test_timer_start_and_stop_manually_send_false(self, mock_time, name):
301305 expected_value = 3140.0
302306 assert timer .duration == expected_value
303307 assert mock_time .call_count == 2
304- self .meter .get_meter ().create_gauge .assert_not_called ()
308+ self .meter .get_meter ().create_histogram .assert_not_called ()
305309
306310 @mock .patch .object (time , "perf_counter" , side_effect = [0.0 , 3.14 ])
307311 def test_timer_start_and_stop_manually_send_true (self , mock_time , name ):
@@ -314,7 +318,7 @@ def test_timer_start_and_stop_manually_send_true(self, mock_time, name):
314318 expected_value = 3140.0
315319 assert timer .duration == expected_value
316320 assert mock_time .call_count == 2
317- self .meter .get_meter ().create_gauge .assert_called_once_with (name = full_name (name ))
321+ self .meter .get_meter ().create_histogram .assert_called_once_with (name = full_name (name ), unit = "ms" )
318322
319323 @pytest .mark .parametrize (
320324 (
@@ -415,15 +419,30 @@ def test_config_priorities(
415419 == f"opentelemetry.exporter.otlp.proto.{ expected_exporter_module } .metric_exporter"
416420 )
417421
422+ @mock .patch ("airflow_shared.observability.metrics.otel_logger.metrics" )
423+ @mock .patch ("airflow_shared.observability.metrics.otel_logger.MeterProvider" )
424+ def test_get_otel_logger_uses_exponential_histogram_view (self , mock_provider , mock_metrics ):
425+ get_otel_logger (host = "localhost" , port = 4318 )
426+
427+ call_kwargs = mock_provider .call_args .kwargs
428+ views = call_kwargs ["views" ]
429+ assert len (views ) == 1
430+ view = views [0 ]
431+ assert isinstance (view , View )
432+ assert isinstance (view ._aggregation , ExponentialBucketHistogramAggregation )
433+
418434 def test_atexit_flush_on_process_exit (self ):
419435 """
420436 Run a process that initializes a logger, creates a stat and then exits.
421437
422438 The logger initialization registers an atexit hook.
423439 Test that the hook runs and flushes the created stat at shutdown.
424440 """
425- test_module_name = "tests.observability.metrics.test_otel_logger"
426- function_call_str = f"import { test_module_name } as m; m.mock_service_run()"
441+ function_call_str = (
442+ "from airflow_shared.observability.metrics.otel_logger import get_otel_logger; "
443+ "logger = get_otel_logger(debug=True); "
444+ "logger.incr('my_test_stat')"
445+ )
427446
428447 proc = subprocess .run (
429448 [sys .executable , "-c" , function_call_str ],
0 commit comments