From c086e088f9726050d997d0d6b734762c884cb24d Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Sun, 28 Jun 2026 17:45:29 -0300 Subject: [PATCH 1/4] fix(metrics): emit gpu_usage_percent as float for Cloud Monitoring (FILTER-585) NVML reports GPU utilization as an integer, so gpu_usage_percent was emitted as INT64 whenever a GPU was active. The Cloud Monitoring descriptor for this gauge is type-locked to DOUBLE, so every active-GPU point was rejected with 'value type for metric must be DOUBLE, but is INT64' and silently dropped. Cast the value to float so it matches the descriptor. The per-device _gpuN counters and the INT64 gpu_accessible/camera_connected metrics are deliberately left untouched. Adds a regression test that drives the GPU poll path with an integer NVML reading and asserts a float result. Signed-off-by: Rui Andrada --- openfilter/filter_runtime/metrics.py | 2 +- tests/test_gpu_usage_percent_float.py | 32 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/test_gpu_usage_percent_float.py diff --git a/openfilter/filter_runtime/metrics.py b/openfilter/filter_runtime/metrics.py index edbc201..e130f7e 100644 --- a/openfilter/filter_runtime/metrics.py +++ b/openfilter/filter_runtime/metrics.py @@ -134,7 +134,7 @@ def gpu_thread_func(self, stop_evt: Event): self.gpu = gpu self.gpu_accessible = 1 - self.gpu_usage_percent = gpu.get('gpu0', 0.0) + self.gpu_usage_percent = float(gpu.get('gpu0', 0.0)) # float() so Cloud Monitoring DOUBLE-typed gpu_usage_percent descriptor accepts it (NVML gpu_util is int) except Exception as exc: logger.error(exc) diff --git a/tests/test_gpu_usage_percent_float.py b/tests/test_gpu_usage_percent_float.py new file mode 100644 index 0000000..13e696e --- /dev/null +++ b/tests/test_gpu_usage_percent_float.py @@ -0,0 +1,32 @@ +"""Tests for ensuring that openfilter_gpu_usage_percent is always emitted as a float.""" + +from unittest.mock import MagicMock, patch + +from openfilter.filter_runtime.gpu import GPUMetrics +from openfilter.filter_runtime.metrics import Metrics + + +def test_gpu_usage_percent_is_float(): + """Test that self.gpu_usage_percent is cast to float even when NVML returns an int.""" + # Construct a bare Metrics object to avoid background thread execution + metrics = object.__new__(Metrics) + metrics.gpu = {} + metrics.gpu_accessible = 0 + metrics.gpu_usage_percent = 0.0 + + # Simulate NVML returning an integer gpu_util + mock_metrics = [ + GPUMetrics(index=0, gpu_util=55, mem_used_mb=1024) + ] + + # Create a mock Event that lets gpu_thread_func run exactly one iteration + stop_evt = MagicMock() + stop_evt.wait.side_effect = [False, True] + + # Patch get_gpu_metrics to return our mock integer metric + with patch("openfilter.filter_runtime.metrics.get_gpu_metrics", return_value=mock_metrics): + metrics.gpu_thread_func(stop_evt) + + # Assert that the type is strictly float and the value is 55.0 + assert type(metrics.gpu_usage_percent) is float + assert metrics.gpu_usage_percent == 55.0 From 88727c8fae41822b4a3489f79f0d0c47c5e03ba5 Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Sun, 28 Jun 2026 18:00:17 -0300 Subject: [PATCH 2/4] docs(release): record gpu_usage_percent float fix as v1.1.2 (FILTER-585) Signed-off-by: Rui Andrada --- RELEASE.md | 6 ++++++ VERSION | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 9f13364..f9d2e2a 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,6 +4,12 @@ OpenFilter Library release notes ## [Unreleased] +## v1.1.2 - 2026-06-28 + +### Fixed + +- **GPU usage percent emitted as float for Cloud Monitoring ([FILTER-585](https://plainsight-ai.atlassian.net/browse/FILTER-585))**: `Metrics.gpu_thread_func` now casts `gpu_usage_percent` to `float` before export. NVML reports `gpu_util` as an integer, but the Cloud Monitoring descriptor `openfilter_gpu_usage_percent` is type-locked to DOUBLE, so each export batch had one point rejected with `value type for metric must be DOUBLE, but is INT64`. The cast is targeted to this single summary field; the per-device `gpuN` series and the legitimately INT64 descriptors (`gpu_accessible`, `camera_connected`) are unchanged. + ## v1.1.1 - 2026-06-01 ### Fixed diff --git a/VERSION b/VERSION index 56130fb..0f1acbd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.1.1 +v1.1.2 From 75bf65f2d7d87fed2bc18b8dc326c5bb512839e8 Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Sun, 28 Jun 2026 18:00:17 -0300 Subject: [PATCH 3/4] test(metrics): cover gpu0-absent default path, trim cast comment (FILTER-585) Signed-off-by: Rui Andrada --- openfilter/filter_runtime/metrics.py | 2 +- tests/test_gpu_usage_percent_float.py | 39 ++++++++++++++------------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/openfilter/filter_runtime/metrics.py b/openfilter/filter_runtime/metrics.py index e130f7e..2990157 100644 --- a/openfilter/filter_runtime/metrics.py +++ b/openfilter/filter_runtime/metrics.py @@ -134,7 +134,7 @@ def gpu_thread_func(self, stop_evt: Event): self.gpu = gpu self.gpu_accessible = 1 - self.gpu_usage_percent = float(gpu.get('gpu0', 0.0)) # float() so Cloud Monitoring DOUBLE-typed gpu_usage_percent descriptor accepts it (NVML gpu_util is int) + self.gpu_usage_percent = float(gpu.get('gpu0', 0.0)) # NVML gpu_util is int; Cloud Monitoring's DOUBLE descriptor rejects it otherwise except Exception as exc: logger.error(exc) diff --git a/tests/test_gpu_usage_percent_float.py b/tests/test_gpu_usage_percent_float.py index 13e696e..fb7a91d 100644 --- a/tests/test_gpu_usage_percent_float.py +++ b/tests/test_gpu_usage_percent_float.py @@ -6,27 +6,30 @@ from openfilter.filter_runtime.metrics import Metrics -def test_gpu_usage_percent_is_float(): - """Test that self.gpu_usage_percent is cast to float even when NVML returns an int.""" - # Construct a bare Metrics object to avoid background thread execution - metrics = object.__new__(Metrics) - metrics.gpu = {} - metrics.gpu_accessible = 0 - metrics.gpu_usage_percent = 0.0 - - # Simulate NVML returning an integer gpu_util - mock_metrics = [ - GPUMetrics(index=0, gpu_util=55, mem_used_mb=1024) - ] - - # Create a mock Event that lets gpu_thread_func run exactly one iteration +def _run_one_iteration(mock_metrics): + """Run gpu_thread_func for exactly one loop iteration with mocked NVML metrics.""" + metrics = object.__new__(Metrics) # bare object, skip __init__'s background threads + stop_evt = MagicMock() - stop_evt.wait.side_effect = [False, True] + stop_evt.wait.side_effect = [False, True] # run the loop body exactly once - # Patch get_gpu_metrics to return our mock integer metric - with patch("openfilter.filter_runtime.metrics.get_gpu_metrics", return_value=mock_metrics): + with patch('openfilter.filter_runtime.metrics.get_gpu_metrics', return_value=mock_metrics): metrics.gpu_thread_func(stop_evt) - # Assert that the type is strictly float and the value is 55.0 + return metrics + + +def test_gpu_usage_percent_is_float(): + """gpu_usage_percent is cast to float even when NVML returns an int gpu_util.""" + metrics = _run_one_iteration([GPUMetrics(index=0, gpu_util=55, mem_used_mb=1024)]) + assert type(metrics.gpu_usage_percent) is float assert metrics.gpu_usage_percent == 55.0 + + +def test_gpu_usage_percent_is_float_when_gpu0_absent(): + """The default path (no gpu0 present) still yields a float, not an int.""" + metrics = _run_one_iteration([GPUMetrics(index=1, gpu_util=42, mem_used_mb=1024)]) + + assert type(metrics.gpu_usage_percent) is float + assert metrics.gpu_usage_percent == 0.0 From 700adc792391e6985b7b4aab3351a2b0e66c67bf Mon Sep 17 00:00:00 2001 From: Rui Andrada Date: Sun, 28 Jun 2026 20:25:16 -0300 Subject: [PATCH 4/4] test(metrics): guard gpu_usage_percent float cast on idle GPU (FILTER-585) Signed-off-by: Rui Andrada --- tests/test_gpu_usage_percent_float.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_gpu_usage_percent_float.py b/tests/test_gpu_usage_percent_float.py index fb7a91d..4f9d524 100644 --- a/tests/test_gpu_usage_percent_float.py +++ b/tests/test_gpu_usage_percent_float.py @@ -27,9 +27,9 @@ def test_gpu_usage_percent_is_float(): assert metrics.gpu_usage_percent == 55.0 -def test_gpu_usage_percent_is_float_when_gpu0_absent(): - """The default path (no gpu0 present) still yields a float, not an int.""" - metrics = _run_one_iteration([GPUMetrics(index=1, gpu_util=42, mem_used_mb=1024)]) +def test_gpu_usage_percent_is_float_when_gpu_idle(): + """An idle GPU reports integer gpu_util==0; without the float() cast this path would emit an int 0 and be rejected by the DOUBLE-locked Cloud Monitoring descriptor, so this test fails on the un-cast code.""" + metrics = _run_one_iteration([GPUMetrics(index=0, gpu_util=0, mem_used_mb=1024)]) assert type(metrics.gpu_usage_percent) is float assert metrics.gpu_usage_percent == 0.0