From 6caedd0320dd52020296cde6a76ceb9f855286de Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Wed, 16 Jul 2025 06:56:56 -0400 Subject: [PATCH 1/4] feat(util): Upgrade collection of runtime environment info. Upgrade the util get_runtime_env_info() function to also return the system/OS information about the current runtime environment. Also, create three new functions to return if the current runtime environment runs on a Windows system, and ppc64 or s390x architectures. Signed-off-by: Paulo Vital --- src/instana/util/runtime.py | 93 ++++++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 27 deletions(-) diff --git a/src/instana/util/runtime.py b/src/instana/util/runtime.py index 8fc6007b..832d37c2 100644 --- a/src/instana/util/runtime.py +++ b/src/instana/util/runtime.py @@ -13,14 +13,14 @@ def get_py_source(filename: str) -> Dict[str, str]: """ Retrieves the source code for Python files requested by the UI via the host agent. - + This function reads and returns the content of Python source files. It validates that the requested file has a .py extension and returns an appropriate error message if the file cannot be read or is not a Python file. - + Args: filename (str): The fully qualified path to a Python source file - + Returns: Dict[str, str]: A dictionary containing either: - {"data": source_code} if successful @@ -32,7 +32,7 @@ def get_py_source(filename: str) -> Dict[str, str]: response = {"error": "Only Python source files are allowed. (*.py)"} else: pysource = "" - with open(filename, 'r') as pyfile: + with open(filename, "r") as pyfile: pysource = pyfile.read() response = {"data": pysource} @@ -50,7 +50,7 @@ def get_py_source(filename: str) -> Dict[str, str]: def determine_service_name() -> str: """ Determines the most appropriate service name for this application process. - + The service name is determined using the following priority order: 1. INSTANA_SERVICE_NAME environment variable if set 2. For specific frameworks: @@ -61,7 +61,7 @@ def determine_service_name() -> str: 3. Command line arguments (first non-option argument) 4. Executable name 5. "python" as a fallback - + Returns: str: The determined service name """ @@ -75,13 +75,13 @@ def determine_service_name() -> str: basename = None try: - if not hasattr(sys, 'argv'): + if not hasattr(sys, "argv"): proc_cmdline = get_proc_cmdline(as_string=False) return os.path.basename(proc_cmdline[0]) # Get first argument that is not an CLI option for candidate in sys.argv: - if len(candidate) > 0 and candidate[0] != '-': + if len(candidate) > 0 and candidate[0] != "-": basename = candidate break @@ -93,7 +93,7 @@ def determine_service_name() -> str: basename = os.path.basename(basename) if basename == "gunicorn": - if 'setproctitle' in sys.modules: + if "setproctitle" in sys.modules: # With the setproctitle package, gunicorn renames their processes # to pretty things - we use those by default # gunicorn: master [djface.wsgi] @@ -104,8 +104,8 @@ def determine_service_name() -> str: elif "FLASK_APP" in os.environ: app_name = os.environ["FLASK_APP"] elif "DJANGO_SETTINGS_MODULE" in os.environ: - app_name = os.environ["DJANGO_SETTINGS_MODULE"].split('.')[0] - elif basename == '': + app_name = os.environ["DJANGO_SETTINGS_MODULE"].split(".")[0] + elif basename == "": if sys.stdout.isatty(): app_name = "Interactive Console" else: @@ -142,21 +142,22 @@ def determine_service_name() -> str: return app_name + def get_proc_cmdline(as_string: bool = False) -> Union[List[str], str]: """ Parses the process command line from the proc file system. - + This function attempts to read the command line of the current process from /proc/self/cmdline. If the proc filesystem is not available (e.g., on non-Unix systems), it returns a default value. - + Args: - as_string (bool, optional): If True, returns the command line as a single + as_string (bool, optional): If True, returns the command line as a single space-separated string. If False, returns a list of command line arguments. Defaults to False. - + Returns: - Union[List[str], str]: The command line as either a list of arguments or a + Union[List[str], str]: The command line as either a list of arguments or a space-separated string, depending on the as_string parameter. """ name = "python" @@ -172,7 +173,7 @@ def get_proc_cmdline(as_string: bool = False) -> Union[List[str], str]: # /proc/self/command line will have strings with null bytes such as "/usr/bin/python\0-s\0-d\0". This # bit will prep the return value and drop the trailing null byte - parts = name.split('\0') + parts = name.split("\0") parts.pop() if as_string is True: @@ -181,32 +182,70 @@ def get_proc_cmdline(as_string: bool = False) -> Union[List[str], str]: return parts -def get_runtime_env_info() -> Tuple[str, str]: +def get_runtime_env_info() -> Tuple[str, str, str]: """ Returns information about the current runtime environment. - - This function collects and returns details about the machine architecture + + This function collects and returns details about the machine architecture and Python version being used by the application. - + Returns: - Tuple[str, str]: A tuple containing: + Tuple[str, str, str]: A tuple containing: - Machine type (e.g., 'arm64', 'ppc64le') + - System/OS name (e.g., ' Linux', 'Windows') - Python version string """ machine = platform.machine() + system = platform.system() python_version = platform.python_version() - - return machine, python_version + + return machine, system, python_version def log_runtime_env_info() -> None: """ Logs debug information about the current runtime environment. - + This function retrieves machine architecture and Python version information using get_runtime_env_info() and logs it as a debug message. """ - machine, python_version = get_runtime_env_info() - logger.debug(f"Runtime environment: Machine: {machine}, Python version: {python_version}") + machine, system, python_version = get_runtime_env_info() + logger.debug( + f"Runtime environment: Machine: {machine}, System: {system}, Python version: {python_version}" + ) + + +def is_windows() -> bool: + """ + Checks if the current runtime environment is running on a Windows operating system. + + Returns: + bool: True if the current runtime environment is Windows, False otherwise. + """ + system = get_runtime_env_info()[1].lower() + return system == "windows" + + +def is_ppc64() -> bool: + """ + Checks if the current runtime environment is running on ppc64 architecture. + + Returns: + bool: True if the current runtime environment is on ppc64 architecture, False otherwise. + """ + machine = get_runtime_env_info()[0].lower() + return machine.startswith("ppc64") + + +def is_s390x() -> bool: + """ + Checks if the current runtime environment is running on s390x architecture. + + Returns: + bool: True if the current runtime environment is on s390x architecture, False otherwise. + """ + machine = get_runtime_env_info()[0].lower() + return machine == "s390x" + # Made with Bob From f52b206c7c2fbc69d6a6d513b5c41d82e5f6ef7a Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Wed, 16 Jul 2025 15:01:44 +0200 Subject: [PATCH 2/4] test(util): Upgrade unit-tests for util/runtime.py Signed-off-by: Paulo Vital --- tests/conftest.py | 7 +-- tests/util/test_util_runtime.py | 102 ++++++++++++++++++++++++++------ 2 files changed, 87 insertions(+), 22 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 98bf8f67..93f89221 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,7 @@ from instana.span.span import InstanaSpan from instana.span_context import SpanContext from instana.tracer import InstanaTracerProvider -from instana.util.runtime import get_runtime_env_info +from instana.util.runtime import is_ppc64, is_s390x collect_ignore_glob = [ "*test_gevent*", @@ -32,8 +32,7 @@ ] # ppc64le and s390x have limitations with some supported libraries. -machine, py_version = get_runtime_env_info() -if machine in ["ppc64le", "s390x"]: +if is_ppc64() or is_s390x(): collect_ignore_glob.extend( [ "*test_google-cloud*", @@ -41,7 +40,7 @@ ] ) - if machine == "ppc64le": + if is_ppc64(): collect_ignore_glob.append("*test_grpcio*") # # Cassandra and gevent tests are run in dedicated jobs on CircleCI and will diff --git a/tests/util/test_util_runtime.py b/tests/util/test_util_runtime.py index 066132dc..3dd824ee 100644 --- a/tests/util/test_util_runtime.py +++ b/tests/util/test_util_runtime.py @@ -13,6 +13,9 @@ get_proc_cmdline, get_py_source, get_runtime_env_info, + is_ppc64, + is_s390x, + is_windows, log_runtime_env_info, ) @@ -40,7 +43,7 @@ def test_get_py_source(tmp_path) -> None: [ ( "non_existent_file.py", - {"error": "[Errno 2] No such file or directory: 'non_existent_file.py'"} + {"error": "[Errno 2] No such file or directory: 'non_existent_file.py'"}, ), ("temp_file.txt", {"error": "Only Python source files are allowed. (*.py)"}), ], @@ -60,9 +63,9 @@ def test_get_py_source_exception(mocker) -> None: with pytest.raises(Exception) as exc_info: get_py_source("/path/to/non_readable_file.py") - assert str(exc_info.value) == exception_message, ( - f"Expected {exception_message}, but got {exc_info.value}" - ) + assert ( + str(exc_info.value) == exception_message + ), f"Expected {exception_message}, but got {exc_info.value}" @pytest.fixture() @@ -142,7 +145,7 @@ def test_determine_service_name_via_cli_args( ) -> None: mocker.patch("instana.util.runtime.get_proc_cmdline", return_value="python") sys.argv = argv - # We check "python" in the return of determine_service_name() because this + # We check "python" in the return of determine_service_name() because this # can be the value "python3" assert "python" in determine_service_name() @@ -173,14 +176,18 @@ def test_determine_service_name_via_tty( (True, "python script.py arg1 arg2"), ], ) -def test_get_proc_cmdline(as_string: bool, expected: Union[List[str], str], mocker: "MockerFixture") -> None: +def test_get_proc_cmdline( + as_string: bool, expected: Union[List[str], str], mocker: "MockerFixture" +) -> None: # Mock the proc filesystem presence mocker.patch("os.path.isfile", return_value="/proc/self/cmdline") # Mock the content of /proc/self/cmdline mocked_data = mocker.mock_open(read_data="python\0script.py\0arg1\0arg2\0") mocker.patch("builtins.open", mocked_data) - assert get_proc_cmdline(as_string) == expected, f"Expected {expected}, but got {get_proc_cmdline(as_string)}" + assert ( + get_proc_cmdline(as_string) == expected + ), f"Expected {expected}, but got {get_proc_cmdline(as_string)}" @pytest.mark.parametrize( @@ -198,29 +205,88 @@ def test_get_proc_cmdline_no_proc_fs( assert get_proc_cmdline(as_string) == expected - def test_get_runtime_env_info(mocker: "MockerFixture") -> None: """Test the get_runtime_env_info function.""" - expected_output = ("x86_64", "3.13.5") + expected_output = ("x86_64", "Linux", "3.13.5") mocker.patch("platform.machine", return_value=expected_output[0]) - mocker.patch("platform.python_version", return_value=expected_output[1]) + mocker.patch("platform.system", return_value=expected_output[1]) + mocker.patch("platform.python_version", return_value=expected_output[2]) - machine, py_version = get_runtime_env_info() + machine, system, py_version = get_runtime_env_info() assert machine == expected_output[0] - assert py_version == expected_output[1] + assert system == expected_output[1] + assert py_version == expected_output[2] -def test_log_runtime_env_info(mocker: "MockerFixture", caplog: "LogCaptureFixture") -> None: +def test_log_runtime_env_info( + mocker: "MockerFixture", caplog: "LogCaptureFixture" +) -> None: """Test the log_runtime_env_info function.""" - expected_output = ("x86_64", "3.13.5") + expected_output = ("x86_64", "Linux", "3.13.5") caplog.set_level(logging.DEBUG, logger="instana") mocker.patch("platform.machine", return_value=expected_output[0]) - mocker.patch("platform.python_version", return_value=expected_output[1]) + mocker.patch("platform.system", return_value=expected_output[1]) + mocker.patch("platform.python_version", return_value=expected_output[2]) log_runtime_env_info() - assert ( - f"Runtime environment: Machine: {expected_output[0]}, Python version: {expected_output[1]}" - in caplog.messages + + expected_log_message = f"Runtime environment: Machine: {expected_output[0]}, System: {expected_output[1]}, Python version: {expected_output[2]}" + assert expected_log_message in caplog.messages + + +@pytest.mark.parametrize( + "system, expected", + [ + ("Windows", True), + ("windows", True), # Test case insensitivity + ("WINDOWS", True), # Test case insensitivity + ("Linux", False), + ("Darwin", False), + ], +) +def test_is_windows(system: str, expected: bool, mocker: "MockerFixture") -> None: + """Test the is_windows function.""" + mocker.patch( + "instana.util.runtime.get_runtime_env_info", + return_value=("x86_64", system, "3.13.5"), + ) + assert is_windows() == expected + + +@pytest.mark.parametrize( + "machine, expected", + [ + ("ppc64le", True), + ("ppc64", True), + ("PPC64", True), # Test case insensitivity + ("x86_64", False), + ("arm64", False), + ], +) +def test_is_ppc64(machine: str, expected: bool, mocker: "MockerFixture") -> None: + """Test the is_ppc64 function.""" + mocker.patch( + "instana.util.runtime.get_runtime_env_info", + return_value=(machine, "Linux", "3.13.5"), + ) + assert is_ppc64() == expected + + +@pytest.mark.parametrize( + "machine, expected", + [ + ("s390x", True), + ("S390X", True), # Test case insensitivity + ("x86_64", False), + ("arm64", False), + ], +) +def test_is_s390x(machine: str, expected: bool, mocker: "MockerFixture") -> None: + """Test the is_s390x function.""" + mocker.patch( + "instana.util.runtime.get_runtime_env_info", + return_value=(machine, "Linux", "3.13.5"), ) + assert is_s390x() == expected From 4cb491850fe57a5a67c615aa91d162069275635f Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Wed, 18 Jun 2025 23:27:12 +0200 Subject: [PATCH 3/4] feat(collector): add Windows platform support for resource metrics This change adds cross-platform resource usage monitoring with Windows support: - Create new resource_usage.py module with platform-specific implementations - Add psutil dependency for Windows systems in pyproject.toml - Refactor runtime.py to use the new cross-platform resource usage functions - Implement ResourceUsage class that provides consistent interface across platforms The implementation gracefully handles platform differences, ensuring consistent metrics collection on both Unix and Windows environments. Co-authored-by: Varsha GS Signed-off-by: Paulo Vital --- pyproject.toml | 3 +- .../collector/helpers/resource_usage.py | 146 ++++++++++++++++++ src/instana/collector/helpers/runtime.py | 10 +- 3 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 src/instana/collector/helpers/resource_usage.py diff --git a/pyproject.toml b/pyproject.toml index 46d9ad4c..19ca2507 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,8 @@ dependencies = [ "opentelemetry-semantic-conventions>=0.48b0", "typing_extensions>=4.12.2", "pyyaml>=6.0.2", - "setuptools>=69.0.0; python_version >= \"3.12\"", + "setuptools>=69.0.0; python_version >= \"3.12\"", + "psutil>=5.9.0; sys_platform == \"win32\"", ] [project.entry-points."instana"] diff --git a/src/instana/collector/helpers/resource_usage.py b/src/instana/collector/helpers/resource_usage.py new file mode 100644 index 00000000..3bd171ff --- /dev/null +++ b/src/instana/collector/helpers/resource_usage.py @@ -0,0 +1,146 @@ +# (c) Copyright IBM Corp. 2025 + +"""Cross-platform resource usage information""" + +import os +from typing import NamedTuple + +from instana.log import logger +from instana.util.runtime import is_windows + + +class ResourceUsage(NamedTuple): + """ + Cross-platform resource usage information, mirroring fields found in the Unix rusage struct. + + Attributes: + ru_utime (float): User CPU time used (seconds). + ru_stime (float): System CPU time used (seconds). + ru_maxrss (int): Maximum resident set size used (bytes). + ru_ixrss (int): Integral shared memory size (bytes). + ru_idrss (int): Integral unshared data size (bytes). + ru_isrss (int): Integral unshared stack size (bytes). + ru_minflt (int): Number of page reclaims (soft page faults). + ru_majflt (int): Number of page faults requiring I/O (hard page faults). + ru_nswap (int): Number of times a process was swapped out. + ru_inblock (int): Number of file system input blocks. + ru_oublock (int): Number of file system output blocks. + ru_msgsnd (int): Number of messages sent. + ru_msgrcv (int): Number of messages received. + ru_nsignals (int): Number of signals received. + ru_nvcsw (int): Number of voluntary context switches. + ru_nivcsw (int): Number of involuntary context switches. + """ + + ru_utime: float = 0.0 + ru_stime: float = 0.0 + ru_maxrss: int = 0 + ru_ixrss: int = 0 + ru_idrss: int = 0 + ru_isrss: int = 0 + ru_minflt: int = 0 + ru_majflt: int = 0 + ru_nswap: int = 0 + ru_inblock: int = 0 + ru_oublock: int = 0 + ru_msgsnd: int = 0 + ru_msgrcv: int = 0 + ru_nsignals: int = 0 + ru_nvcsw: int = 0 + ru_nivcsw: int = 0 + + +def get_resource_usage() -> ResourceUsage: + """Get resource usage in a cross-platform way""" + if is_windows(): + return _get_windows_resource_usage() + else: + return _get_unix_resource_usage() + + +def _get_unix_resource_usage() -> ResourceUsage: + """Get resource usage on Unix systems""" + import resource + + rusage = resource.getrusage(resource.RUSAGE_SELF) + + return ResourceUsage( + ru_utime=rusage.ru_utime, + ru_stime=rusage.ru_stime, + ru_maxrss=rusage.ru_maxrss, + ru_ixrss=rusage.ru_ixrss, + ru_idrss=rusage.ru_idrss, + ru_isrss=rusage.ru_isrss, + ru_minflt=rusage.ru_minflt, + ru_majflt=rusage.ru_majflt, + ru_nswap=rusage.ru_nswap, + ru_inblock=rusage.ru_inblock, + ru_oublock=rusage.ru_oublock, + ru_msgsnd=rusage.ru_msgsnd, + ru_msgrcv=rusage.ru_msgrcv, + ru_nsignals=rusage.ru_nsignals, + ru_nvcsw=rusage.ru_nvcsw, + ru_nivcsw=rusage.ru_nivcsw, + ) + + +def _get_windows_resource_usage() -> ResourceUsage: + """Get resource usage on Windows systems""" + # On Windows, we can use psutil to get some of the metrics + # For metrics that aren't available, we return 0 + try: + import psutil + + process = psutil.Process(os.getpid()) + + # Get CPU times + cpu_times = process.cpu_times() + + # Get memory info + memory_info = process.memory_info() + + # Get IO counters + io_counters = process.io_counters() if hasattr(process, "io_counters") else None + + # Get context switch counts if available + ctx_switches = ( + process.num_ctx_switches() if hasattr(process, "num_ctx_switches") else None + ) + + return ResourceUsage( + ru_utime=cpu_times.user if hasattr(cpu_times, "user") else 0.0, + ru_stime=cpu_times.system if hasattr(cpu_times, "system") else 0.0, + ru_maxrss=memory_info.rss // 1024 + if hasattr(memory_info, "rss") + else 0, # Convert to KB to match Unix + ru_ixrss=0, # Not available on Windows + ru_idrss=0, # Not available on Windows + ru_isrss=0, # Not available on Windows + ru_minflt=0, # Not directly available on Windows + ru_majflt=0, # Not directly available on Windows + ru_nswap=0, # Not available on Windows + ru_inblock=io_counters.read_count + if io_counters and hasattr(io_counters, "read_count") + else 0, + ru_oublock=io_counters.write_count + if io_counters and hasattr(io_counters, "write_count") + else 0, + ru_msgsnd=0, # Not available on Windows + ru_msgrcv=0, # Not available on Windows + ru_nsignals=0, # Not available on Windows + ru_nvcsw=ctx_switches.voluntary + if ctx_switches and hasattr(ctx_switches, "voluntary") + else 0, + ru_nivcsw=ctx_switches.involuntary + if ctx_switches and hasattr(ctx_switches, "involuntary") + else 0, + ) + except ImportError: + # If psutil is not available, return zeros + logger.debug( + "get_windows_resource_usage: psutil is not available, returning zeros" + ) + return ResourceUsage() + + +# Made with Bob diff --git a/src/instana/collector/helpers/runtime.py b/src/instana/collector/helpers/runtime.py index 0061eeed..8156ffd9 100644 --- a/src/instana/collector/helpers/runtime.py +++ b/src/instana/collector/helpers/runtime.py @@ -7,18 +7,18 @@ import importlib.metadata import os import platform -import resource import sys import threading from types import ModuleType -from typing import Any, Dict, List, Union, Callable +from typing import Any, Callable, Dict, List, Union +from instana.collector.base import BaseCollector from instana.collector.helpers.base import BaseHelper +from instana.collector.helpers.resource_usage import get_resource_usage from instana.log import logger from instana.util import DictionaryOfStan from instana.util.runtime import determine_service_name from instana.version import VERSION -from instana.collector.base import BaseCollector PATH_OF_DEPRECATED_INSTALLATION_VIA_HOST_AGENT = "/tmp/.instana/python" @@ -42,7 +42,7 @@ def __init__( ) -> None: super(RuntimeHelper, self).__init__(collector) self.previous = DictionaryOfStan() - self.previous_rusage = resource.getrusage(resource.RUSAGE_SELF) + self.previous_rusage = get_resource_usage() if gc.isenabled(): self.previous_gc_count = gc.get_count() @@ -83,7 +83,7 @@ def _collect_runtime_metrics( """ Collect up and return the runtime metrics """ try: - rusage = resource.getrusage(resource.RUSAGE_SELF) + rusage = get_resource_usage() if gc.isenabled(): self._collect_gc_metrics(plugin_data, with_snapshot) From a7f932581db2121bf2a39d892e3adc7b2208d066 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Wed, 18 Jun 2025 23:48:44 +0200 Subject: [PATCH 4/4] test(collector): add tests for cross-platform resource metrics Signed-off-by: Paulo Vital --- tests/agent/test_host.py | 5 + .../helpers/test_collector_runtime.py | 120 +++++++++++ .../collector/helpers/test_resource_usage.py | 202 ++++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 tests/collector/helpers/test_resource_usage.py diff --git a/tests/agent/test_host.py b/tests/agent/test_host.py index 93b89c0a..29b5fd10 100644 --- a/tests/agent/test_host.py +++ b/tests/agent/test_host.py @@ -20,6 +20,7 @@ from instana.singletons import get_agent from instana.span.span import InstanaSpan from instana.span_context import SpanContext +from instana.util.runtime import is_windows class TestHostAgent: @@ -279,6 +280,10 @@ def test_agent_connection_attempt_fails_with_404( assert not result assert msg in caplog.messages[0] + @pytest.mark.skipif( + is_windows(), + reason='Avoiding "psutil.NoSuchProcess: process PID not found (pid=12345)"', + ) def test_init(self) -> None: with patch( "instana.agent.base.BaseAgent.update_log_level" diff --git a/tests/collector/helpers/test_collector_runtime.py b/tests/collector/helpers/test_collector_runtime.py index 3717f5f9..959f7611 100644 --- a/tests/collector/helpers/test_collector_runtime.py +++ b/tests/collector/helpers/test_collector_runtime.py @@ -6,6 +6,7 @@ import pytest from instana.agent.host import HostAgent +from instana.collector.helpers.resource_usage import ResourceUsage from instana.collector.helpers.runtime import RuntimeHelper from instana.collector.host import HostCollector @@ -66,3 +67,122 @@ def test_collect_gc_metrics(self) -> None: self.helper._collect_gc_metrics(plugin_data[0], True) assert len(self.helper.previous["data"]["metrics"]["gc"]) == 6 + + def test_collect_runtime_metrics(self) -> None: + """Test that _collect_runtime_metrics properly collects metrics""" + plugin_data = self.helper.collect_metrics() + + # Call the method directly + self.helper._collect_runtime_metrics(plugin_data[0], True) + + # Verify metrics were collected + assert "metrics" in plugin_data[0]["data"] + metrics = plugin_data[0]["data"]["metrics"] + + # Check that resource usage metrics are present + assert "ru_utime" in metrics + assert "ru_stime" in metrics + assert "ru_maxrss" in metrics + assert "ru_minflt" in metrics + assert "ru_majflt" in metrics + + # Check that thread metrics are present + assert "daemon_threads" in metrics + assert "alive_threads" in metrics + assert "dummy_threads" in metrics + + def test_runtime_helper_initialization_with_resource_usage(self, mocker): + """Test that RuntimeHelper initializes with resource_usage""" + mock_resource = ResourceUsage( + ru_utime=1.0, + ru_stime=2.0, + ru_maxrss=3, + ) + mocker.patch( + "instana.collector.helpers.runtime.get_resource_usage", + return_value=mock_resource, + ) + + helper = RuntimeHelper(collector=HostCollector(HostAgent())) + + assert helper.previous_rusage == mock_resource + assert helper.previous_rusage.ru_utime == 1.0 + assert helper.previous_rusage.ru_stime == 2.0 + assert helper.previous_rusage.ru_maxrss == 3 + + def test_collect_runtime_metrics_with_resource_usage(self, mocker): + """Test that _collect_runtime_metrics uses resource_usage correctly""" + # Setup initial state + initial_resource = ResourceUsage( + ru_utime=1.0, + ru_stime=2.0, + ru_maxrss=3000, + ru_minflt=100, + ru_majflt=10, + ru_nswap=5, + ru_inblock=200, + ru_oublock=300, + ru_msgsnd=10, + ru_msgrcv=20, + ru_nsignals=1, + ru_nvcsw=1000, + ru_nivcsw=500, + ) + self.helper.previous_rusage = initial_resource + + # Setup new resource usage values with increments + new_resource = ResourceUsage( + ru_utime=1.5, # +0.5 + ru_stime=3.0, # +1.0 + ru_maxrss=4000, # +1000 + ru_minflt=150, # +50 + ru_majflt=15, # +5 + ru_nswap=7, # +2 + ru_inblock=250, # +50 + ru_oublock=350, # +50 + ru_msgsnd=15, # +5 + ru_msgrcv=25, # +5 + ru_nsignals=3, # +2 + ru_nvcsw=1200, # +200 + ru_nivcsw=600, # +100 + ) + mocker.patch( + "instana.collector.helpers.runtime.get_resource_usage", + return_value=new_resource, + ) + + # Call the method + plugin_data = {"data": {"metrics": {}}} + self.helper._collect_runtime_metrics(plugin_data, True) + + # Verify metrics were collected with correct deltas + metrics = plugin_data["data"]["metrics"] + assert metrics["ru_utime"] == 0.5 # Difference between new and old + assert metrics["ru_stime"] == 1.0 + assert metrics["ru_maxrss"] == 4000 # This is absolute, not a delta + assert metrics["ru_minflt"] == 50 + assert metrics["ru_majflt"] == 5 + assert metrics["ru_nswap"] == 2 + assert metrics["ru_inblock"] == 50 + assert metrics["ru_oublock"] == 50 + assert metrics["ru_msgsnd"] == 5 + assert metrics["ru_msgrcv"] == 5 + assert metrics["ru_nsignals"] == 2 + assert metrics["ru_nvcsw"] == 200 + assert metrics["ru_nivcsw"] == 100 + + # Verify the previous_rusage was updated + assert self.helper.previous_rusage == new_resource + + @patch("os.environ") + def test_collect_runtime_metrics_disabled(self, mock_environ): + """Test that _collect_runtime_metrics respects INSTANA_DISABLE_METRICS_COLLECTION""" + # Setup environment variable + mock_environ.get.return_value = True + + # Call the method + plugin_data = {"data": {"metrics": {}}} + self.helper._collect_runtime_metrics(plugin_data, True) + + # Verify no metrics were collected + assert plugin_data["data"]["metrics"] == {} diff --git a/tests/collector/helpers/test_resource_usage.py b/tests/collector/helpers/test_resource_usage.py new file mode 100644 index 00000000..0ed1da2c --- /dev/null +++ b/tests/collector/helpers/test_resource_usage.py @@ -0,0 +1,202 @@ +# (c) Copyright IBM Corp. 2025 + + +import pytest + +from instana.collector.helpers.resource_usage import ( + ResourceUsage, + _get_unix_resource_usage, + _get_windows_resource_usage, + get_resource_usage, +) +from instana.util.runtime import is_windows + + +class TestResourceUsage: + def test_resource_usage_namedtuple_defaults(self): + """Test that ResourceUsage has proper default values""" + usage = ResourceUsage() + assert usage.ru_utime == 0.0 + assert usage.ru_stime == 0.0 + assert usage.ru_maxrss == 0 + assert usage.ru_ixrss == 0 + assert usage.ru_idrss == 0 + assert usage.ru_isrss == 0 + assert usage.ru_minflt == 0 + assert usage.ru_majflt == 0 + assert usage.ru_nswap == 0 + assert usage.ru_inblock == 0 + assert usage.ru_oublock == 0 + assert usage.ru_msgsnd == 0 + assert usage.ru_msgrcv == 0 + assert usage.ru_nsignals == 0 + assert usage.ru_nvcsw == 0 + assert usage.ru_nivcsw == 0 + + def test_resource_usage_namedtuple_custom_values(self): + """Test that ResourceUsage can be initialized with custom values""" + usage = ResourceUsage( + ru_utime=1.0, + ru_stime=2.0, + ru_maxrss=3, + ru_ixrss=4, + ru_idrss=5, + ru_isrss=6, + ru_minflt=7, + ru_majflt=8, + ru_nswap=9, + ru_inblock=10, + ru_oublock=11, + ru_msgsnd=12, + ru_msgrcv=13, + ru_nsignals=14, + ru_nvcsw=15, + ru_nivcsw=16, + ) + assert usage.ru_utime == 1.0 + assert usage.ru_stime == 2.0 + assert usage.ru_maxrss == 3 + assert usage.ru_ixrss == 4 + assert usage.ru_idrss == 5 + assert usage.ru_isrss == 6 + assert usage.ru_minflt == 7 + assert usage.ru_majflt == 8 + assert usage.ru_nswap == 9 + assert usage.ru_inblock == 10 + assert usage.ru_oublock == 11 + assert usage.ru_msgsnd == 12 + assert usage.ru_msgrcv == 13 + assert usage.ru_nsignals == 14 + assert usage.ru_nvcsw == 15 + assert usage.ru_nivcsw == 16 + + @pytest.mark.skipif( + is_windows(), + reason="Avoiding Unix resource usage collection on Windows systems.", + ) + def test_get_resource_usage_unix(self): + """Test that get_resource_usage calls _get_unix_resource_usage on Unix-like systems.""" + usage = get_resource_usage() + + assert usage.ru_utime >= 0.0 + assert usage.ru_stime >= 0.0 + assert usage.ru_maxrss >= 0 + assert usage.ru_ixrss >= 0 + assert usage.ru_idrss >= 0 + assert usage.ru_isrss >= 0 + assert usage.ru_minflt >= 0 + assert usage.ru_majflt >= 0 + assert usage.ru_nswap >= 0 + assert usage.ru_inblock >= 0 + assert usage.ru_oublock >= 0 + assert usage.ru_msgsnd >= 0 + assert usage.ru_msgrcv >= 0 + assert usage.ru_nsignals >= 0 + assert usage.ru_nvcsw >= 0 + assert usage.ru_nivcsw >= 0 + + @pytest.mark.skipif( + not is_windows(), + reason="Avoiding Windows resource usage collection on Unix-like systems.", + ) + def test_get_resource_usage_windows(self): + """Test that get_resource_usage calls _get_windows_resource_usage on Windows systems""" + usage = get_resource_usage() + + assert usage.ru_utime >= 0.0 + assert usage.ru_stime >= 0.0 + assert usage.ru_maxrss >= 0 + assert usage.ru_ixrss == 0 + assert usage.ru_idrss == 0 + assert usage.ru_isrss == 0 + assert usage.ru_minflt == 0 + assert usage.ru_majflt == 0 + assert usage.ru_nswap == 0 + assert usage.ru_inblock >= 0 + assert usage.ru_oublock >= 0 + assert usage.ru_msgsnd == 0 + assert usage.ru_msgrcv == 0 + assert usage.ru_nsignals == 0 + assert usage.ru_nvcsw >= 0 + assert usage.ru_nivcsw >= 0 + + @pytest.mark.skipif( + is_windows(), + reason="Avoiding Unix resource usage collection on Windows. systems", + ) + def test_get_unix_resource_usage(self): + """Test _get_unix_resource_usage function""" + usage = _get_unix_resource_usage() + + assert usage.ru_utime >= 0.0 + assert usage.ru_stime >= 0.0 + assert usage.ru_maxrss >= 0 + assert usage.ru_ixrss >= 0 + assert usage.ru_idrss >= 0 + assert usage.ru_isrss >= 0 + assert usage.ru_minflt >= 0 + assert usage.ru_majflt >= 0 + assert usage.ru_nswap >= 0 + assert usage.ru_inblock >= 0 + assert usage.ru_oublock >= 0 + assert usage.ru_msgsnd >= 0 + assert usage.ru_msgrcv >= 0 + assert usage.ru_nsignals >= 0 + assert usage.ru_nvcsw >= 0 + assert usage.ru_nivcsw >= 0 + + @pytest.mark.skipif( + not is_windows(), + reason="Avoiding Windows resource usage collection on Unix-like systems.", + ) + def test_get_windows_resource_usage_with_psutil(self): + """Test _get_windows_resource_usage function with psutil available""" + usage = _get_windows_resource_usage() + + assert usage.ru_utime >= 0.0 + assert usage.ru_stime >= 0.0 + assert usage.ru_maxrss >= 0 + assert usage.ru_ixrss == 0 + assert usage.ru_idrss == 0 + assert usage.ru_isrss == 0 + assert usage.ru_minflt == 0 + assert usage.ru_majflt == 0 + assert usage.ru_nswap == 0 + assert usage.ru_inblock >= 0 + assert usage.ru_oublock >= 0 + assert usage.ru_msgsnd == 0 + assert usage.ru_msgrcv == 0 + assert usage.ru_nsignals == 0 + assert usage.ru_nvcsw >= 0 + assert usage.ru_nivcsw >= 0 + + @pytest.mark.skipif( + not is_windows(), + reason="Avoiding Windows resource usage collection on Unix-like systems.", + ) + def test_get_windows_resource_usage_without_psutil(self, mocker): + """Test _get_windows_resource_usage function when psutil is not available""" + + mocker.patch("psutil.Process", side_effect=ImportError) + result = _get_windows_resource_usage() + + # Should return default ResourceUsage with all zeros + assert result.ru_utime == 0.0 + assert result.ru_stime == 0.0 + assert result.ru_maxrss == 0 + assert result.ru_ixrss == 0 + assert result.ru_idrss == 0 + assert result.ru_isrss == 0 + assert result.ru_minflt == 0 + assert result.ru_majflt == 0 + assert result.ru_nswap == 0 + assert result.ru_inblock == 0 + assert result.ru_oublock == 0 + assert result.ru_msgsnd == 0 + assert result.ru_msgrcv == 0 + assert result.ru_nsignals == 0 + assert result.ru_nvcsw == 0 + assert result.ru_nivcsw == 0 + + +# Made with Bob