diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f252e8290..cd3698aaf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `opentelemetry-instrumentation-system-metrics`: Add `cpython.gc.collected_objects` and `cpython.gc.uncollectable_objects` metrics ([#3666](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3666)) + ## Version 1.36.0/0.57b0 (2025-07-29) ### Fixed diff --git a/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/__init__.py b/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/__init__.py index 77fcb4e443..0d9a5bb710 100644 --- a/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-system-metrics/src/opentelemetry/instrumentation/system_metrics/__init__.py @@ -45,6 +45,8 @@ "process.runtime.cpu.time": ["user", "system"], "process.runtime.gc_count": None, "cpython.gc.collections": None, + "cpython.gc.collected_objects": None, + "cpython.gc.uncollectable_objects": None, "process.runtime.thread_count": None, "process.runtime.cpu.utilization": None, "process.runtime.context_switches": ["involuntary", "voluntary"], @@ -138,6 +140,8 @@ "process.runtime.cpu.time": ["user", "system"], "process.runtime.gc_count": None, "cpython.gc.collections": None, + "cpython.gc.collected_objects": None, + "cpython.gc.uncollectable_objects": None, "process.runtime.thread_count": None, "process.runtime.cpu.utilization": None, "process.runtime.context_switches": ["involuntary", "voluntary"], @@ -199,6 +203,8 @@ def __init__( self._runtime_cpu_time_labels = self._labels.copy() self._runtime_gc_count_labels = self._labels.copy() self._runtime_gc_collections_labels = self._labels.copy() + self._runtime_gc_collected_objects_labels = self._labels.copy() + self._runtime_gc_uncollectable_objects_labels = self._labels.copy() self._runtime_thread_count_labels = self._labels.copy() self._runtime_cpu_utilization_labels = self._labels.copy() self._runtime_context_switches_labels = self._labels.copy() @@ -486,6 +492,32 @@ def _instrument(self, **kwargs: Any): unit="{collection}", ) + if "cpython.gc.collected_objects" in self._config: + if self._python_implementation == "pypy": + _logger.warning( + "The cpython.gc.collected_objects metric won't be collected because the interpreter is PyPy" + ) + else: + self._meter.create_observable_counter( + name="cpython.gc.collected_objects", + callbacks=[self._get_runtime_gc_collected_objects], + description="The total number of objects collected since interpreter start.", + unit="{object}", + ) + + if "cpython.gc.uncollectable_objects" in self._config: + if self._python_implementation == "pypy": + _logger.warning( + "The cpython.gc.uncollectable_objects metric won't be collected because the interpreter is PyPy" + ) + else: + self._meter.create_observable_counter( + name="cpython.gc.uncollectable_objects", + callbacks=[self._get_runtime_gc_uncollectable_objects], + description="The total number of uncollectable objects found since interpreter start.", + unit="{object}", + ) + if "process.runtime.thread_count" in self._config: self._meter.create_observable_up_down_counter( name=f"process.runtime.{self._python_implementation}.thread_count", @@ -911,6 +943,32 @@ def _get_runtime_gc_collections( stat["collections"], self._runtime_gc_collections_labels.copy() ) + def _get_runtime_gc_collected_objects( + self, options: CallbackOptions + ) -> Iterable[Observation]: + """Observer callback for garbage collection collected objects""" + for index, stat in enumerate(gc.get_stats()): + self._runtime_gc_collected_objects_labels["generation"] = str( + index + ) + yield Observation( + stat["collected"], + self._runtime_gc_collected_objects_labels.copy(), + ) + + def _get_runtime_gc_uncollectable_objects( + self, options: CallbackOptions + ) -> Iterable[Observation]: + """Observer callback for garbage collection uncollectable objects""" + for index, stat in enumerate(gc.get_stats()): + self._runtime_gc_uncollectable_objects_labels["generation"] = str( + index + ) + yield Observation( + stat["uncollectable"], + self._runtime_gc_uncollectable_objects_labels.copy(), + ) + def _get_runtime_thread_count( self, options: CallbackOptions ) -> Iterable[Observation]: diff --git a/instrumentation/opentelemetry-instrumentation-system-metrics/tests/test_system_metrics.py b/instrumentation/opentelemetry-instrumentation-system-metrics/tests/test_system_metrics.py index 47c309de2e..b71c307758 100644 --- a/instrumentation/opentelemetry-instrumentation-system-metrics/tests/test_system_metrics.py +++ b/instrumentation/opentelemetry-instrumentation-system-metrics/tests/test_system_metrics.py @@ -139,6 +139,12 @@ def test_system_metrics_instrument(self): observer_names.append( "cpython.gc.collections", ) + observer_names.append( + "cpython.gc.collected_objects", + ) + observer_names.append( + "cpython.gc.uncollectable_objects", + ) if sys.platform != "darwin": observer_names.append("system.network.connections") @@ -983,6 +989,54 @@ def test_runtime_get_gc_collections(self, mock_gc_get_stats): expected_gc_collections, ) + @mock.patch("gc.get_stats") + @skipIf( + python_implementation().lower() == "pypy", "not supported for pypy" + ) + def test_runtime_get_gc_collected_objects(self, mock_gc_get_stats): + mock_gc_get_stats.configure_mock( + **{ + "return_value": [ + {"collections": 10, "collected": 100, "uncollectable": 1}, + {"collections": 20, "collected": 200, "uncollectable": 2}, + {"collections": 30, "collected": 300, "uncollectable": 3}, + ] + } + ) + expected_gc_collected_objects = [ + _SystemMetricsResult({"generation": "0"}, 100), + _SystemMetricsResult({"generation": "1"}, 200), + _SystemMetricsResult({"generation": "2"}, 300), + ] + self._test_metrics( + "cpython.gc.collected_objects", + expected_gc_collected_objects, + ) + + @mock.patch("gc.get_stats") + @skipIf( + python_implementation().lower() == "pypy", "not supported for pypy" + ) + def test_runtime_get_gc_uncollectable_objects(self, mock_gc_get_stats): + mock_gc_get_stats.configure_mock( + **{ + "return_value": [ + {"collections": 10, "collected": 100, "uncollectable": 1}, + {"collections": 20, "collected": 200, "uncollectable": 2}, + {"collections": 30, "collected": 300, "uncollectable": 3}, + ] + } + ) + expected_gc_uncollectable_objects = [ + _SystemMetricsResult({"generation": "0"}, 1), + _SystemMetricsResult({"generation": "1"}, 2), + _SystemMetricsResult({"generation": "2"}, 3), + ] + self._test_metrics( + "cpython.gc.uncollectable_objects", + expected_gc_uncollectable_objects, + ) + @mock.patch("psutil.Process.num_ctx_switches") def test_runtime_context_switches(self, mock_process_num_ctx_switches): PCtxSwitches = namedtuple("PCtxSwitches", ["voluntary", "involuntary"])