From b1d13bd5f81f8eaf63404c09993ae67541d95346 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Thu, 26 Jun 2025 16:54:11 +0200 Subject: [PATCH 1/7] fix: Cassandra imports. Reorganize the Cassandra imports to not break initialization of the Tracer. Signed-off-by: Paulo Vital --- src/instana/instrumentation/cassandra.py | 34 +++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/instana/instrumentation/cassandra.py b/src/instana/instrumentation/cassandra.py index 3b6e9713..2ad9d768 100644 --- a/src/instana/instrumentation/cassandra.py +++ b/src/instana/instrumentation/cassandra.py @@ -7,15 +7,19 @@ https://github.com/datastax/python-driver """ -from typing import Any, Callable, Dict, Tuple -import wrapt -from instana.log import logger -from instana.span.span import InstanaSpan -from instana.util.traceutils import get_tracer_tuple, tracing_is_off - try: + from typing import TYPE_CHECKING, Any, Callable, Dict, Tuple + import cassandra - from cassandra.cluster import ResponseFuture, Session + import wrapt + + from instana.log import logger + from instana.util.traceutils import get_tracer_tuple, tracing_is_off + + if TYPE_CHECKING: + from cassandra.cluster import ResponseFuture, Session + + from instana.span.span import InstanaSpan consistency_levels = dict( { @@ -34,8 +38,8 @@ ) def collect_attributes( - span: InstanaSpan, - fn: ResponseFuture, + span: "InstanaSpan", + fn: "ResponseFuture", ) -> None: tried_hosts = [] for host in fn.attempted_hosts: @@ -50,23 +54,23 @@ def collect_attributes( def cb_request_finish( _, - span: InstanaSpan, - fn: ResponseFuture, + span: "InstanaSpan", + fn: "ResponseFuture", ) -> None: collect_attributes(span, fn) span.end() def cb_request_error( results: Dict[str, Any], - span: InstanaSpan, - fn: ResponseFuture, + span: "InstanaSpan", + fn: "ResponseFuture", ) -> None: collect_attributes(span, fn) span.mark_as_errored({"cassandra.error": results.summary}) span.end() def request_init_with_instana( - fn: ResponseFuture, + fn: "ResponseFuture", ) -> None: tracer, parent_span, _ = get_tracer_tuple() parent_context = parent_span.get_span_context() if parent_span else None @@ -95,7 +99,7 @@ def request_init_with_instana( @wrapt.patch_function_wrapper("cassandra.cluster", "Session.__init__") def init_with_instana( wrapped: Callable[..., object], - instance: Session, + instance: "Session", args: Tuple[object, ...], kwargs: Dict[str, Any], ) -> object: From 5f4c7c0eec402f799eb0a2900b9e8f9c0de77408 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Fri, 27 Jun 2025 14:49:45 +0200 Subject: [PATCH 2/7] feat: Add collection of runtime environment info. Add the util get_runtime_env_info() function to return a Tuple with the information about the current runtime environment. Signed-off-by: Paulo Vital --- src/instana/util/runtime.py | 88 ++++++++++++++++++++++++++++++------- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/src/instana/util/runtime.py b/src/instana/util/runtime.py index 86c75440..32b29e8a 100644 --- a/src/instana/util/runtime.py +++ b/src/instana/util/runtime.py @@ -1,18 +1,30 @@ # (c) Copyright IBM Corp. 2021 # (c) Copyright Instana Inc. 2020 -import re import os +import platform +import re import sys +from typing import Dict, List, Tuple, Union -from ..log import logger +from instana.log import logger -def get_py_source(filename): - """ - Retrieves and returns the source code for any Python - files requested by the UI via the host agent - @param filename [String] The fully qualified path to a file +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 + - {"error": error_message} if an error occurred """ response = None try: @@ -35,9 +47,24 @@ def get_py_source(filename): regexp_py = re.compile(r"\.py$") -def determine_service_name(): - """ This function makes a best effort to name this application process. """ - +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: + - For gunicorn: process title or "gunicorn" + - For Flask: FLASK_APP environment variable + - For Django: first part of DJANGO_SETTINGS_MODULE + - For uwsgi: "uWSGI master/worker [app_name]" + 3. Command line arguments (first non-option argument) + 4. Executable name + 5. "python" as a fallback + + Returns: + str: The determined service name + """ # One environment variable to rule them all if "INSTANA_SERVICE_NAME" in os.environ: return os.environ["INSTANA_SERVICE_NAME"] @@ -115,11 +142,22 @@ def determine_service_name(): return app_name -def get_proc_cmdline(as_string=False): +def get_proc_cmdline(as_string: bool = False) -> Union[List[str], str]: """ - Parse the proc file system for the command line of this process. If not available, then return a default. - Return is dependent on the value of `as_string`. If True, return the full command line as a string, - otherwise a list. + 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 + 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 + space-separated string, depending on the as_string parameter. """ name = "python" if os.path.isfile("/proc/self/cmdline"): @@ -140,4 +178,24 @@ def get_proc_cmdline(as_string=False): if as_string is True: parts = " ".join(parts) - return parts \ No newline at end of file + return parts + + +def get_runtime_env_info() -> Tuple[str, str]: + """ + Returns information about the current runtime environment. + + 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: + - Machine type (e.g., 'arm64', 'ppc64le') + - Python version string + """ + machine = platform.machine() + python_version = platform.python_version() + + return machine, python_version + +# Made with Bob From 9f41fedd88eae87b13b5cb5681dbf8ca559fff70 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Fri, 27 Jun 2025 15:00:43 +0200 Subject: [PATCH 3/7] feat: Log runtime env info in agent/host.py Signed-off-by: Paulo Vital --- src/instana/agent/host.py | 5 +++-- src/instana/util/runtime.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/instana/agent/host.py b/src/instana/agent/host.py index ee0e1d79..177ca44c 100644 --- a/src/instana/agent/host.py +++ b/src/instana/agent/host.py @@ -10,10 +10,10 @@ import os from datetime import datetime from typing import Any, Dict, List, Optional, Union -from requests import Response import requests import urllib3 +from requests import Response from instana.agent.base import BaseAgent from instana.collector.host import HostCollector @@ -21,7 +21,7 @@ from instana.log import logger from instana.options import StandardOptions from instana.util import to_json -from instana.util.runtime import get_py_source +from instana.util.runtime import get_py_source, log_runtime_env_info from instana.util.span_utils import get_operation_specifiers from instana.version import VERSION @@ -62,6 +62,7 @@ def __init__(self) -> None: logger.info( f"Stan is on the scene. Starting Instana instrumentation version: {VERSION}" ) + log_runtime_env_info() self.collector = HostCollector(self) self.machine = TheMachine(self) diff --git a/src/instana/util/runtime.py b/src/instana/util/runtime.py index 32b29e8a..8fc6007b 100644 --- a/src/instana/util/runtime.py +++ b/src/instana/util/runtime.py @@ -198,4 +198,15 @@ def get_runtime_env_info() -> Tuple[str, str]: return machine, 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}") + # Made with Bob From fe3ea2cadee7d59aa363a8ac687de5af0071dc5e Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Mon, 30 Jun 2025 02:38:31 +0200 Subject: [PATCH 4/7] feat(tests): Add unit-tests for util/runtime.py with 91% of coverage. Signed-off-by: Paulo Vital --- tests/conftest.py | 13 ++ tests/util/test_util_runtime.py | 226 ++++++++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 tests/util/test_util_runtime.py diff --git a/tests/conftest.py b/tests/conftest.py index e86c4be3..7a2aa884 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -237,3 +237,16 @@ def announce(monkeypatch, request) -> None: monkeypatch.setattr(HostAgent, "announce", HostAgent.announce) else: monkeypatch.setattr(HostAgent, "announce", always_true) + +# Mocking the import of uwsgi +def _uwsgi_masterpid() -> int: + return 12345 + +module = type(sys)("uwsgi") +module.opt = { + "master": True, + "lazy-apps": True, + "enable-threads": True, +} +module.masterpid = _uwsgi_masterpid +sys.modules["uwsgi"] = module \ No newline at end of file diff --git a/tests/util/test_util_runtime.py b/tests/util/test_util_runtime.py new file mode 100644 index 00000000..066132dc --- /dev/null +++ b/tests/util/test_util_runtime.py @@ -0,0 +1,226 @@ +# (c) Copyright IBM Corp. 2025 +# Assisted by watsonx Code Assistant + +import logging +import os +import sys +from typing import TYPE_CHECKING, Generator, List, Union + +import pytest + +from instana.util.runtime import ( + determine_service_name, + get_proc_cmdline, + get_py_source, + get_runtime_env_info, + log_runtime_env_info, +) + +if TYPE_CHECKING: + from pytest import LogCaptureFixture + from pytest_mock import MockerFixture + + +def test_get_py_source(tmp_path) -> None: + """Test the get_py_source.""" + filename = "temp_file.py" + file_contents = "print('Hello, World!')\n" + expected_output = {"data": file_contents} + + # Create a temporary file for testing purposes. + temp_file = tmp_path / filename + temp_file.write_text(file_contents) + + result = get_py_source(f"{tmp_path}/{filename}") + assert result == expected_output, f"Expected {expected_output}, but got {result}" + + +@pytest.mark.parametrize( + "filename, expected_output", + [ + ( + "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)"}), + ], +) +def test_get_py_source_error(filename, expected_output) -> None: + """Test the get_py_source function with various scenarios with errors.""" + result = get_py_source(filename) + assert result == expected_output, f"Expected {expected_output}, but got {result}" + + +def test_get_py_source_exception(mocker) -> None: + """Test the get_py_source function with an exception scenario.""" + exception_message = "No such file or directory" + mocker.patch( + "instana.util.runtime.get_py_source", side_effect=Exception(exception_message) + ) + + 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}" + ) + + +@pytest.fixture() +def _resource_determine_service_name_via_env_var() -> Generator[None, None, None]: + """SetUp and TearDown""" + # setup + yield + # teardown + os.environ.pop("INSTANA_SERVICE_NAME", None) + os.environ.pop("FLASK_APP", None) + os.environ.pop("DJANGO_SETTINGS_MODULE", None) + + +@pytest.mark.parametrize( + "env_var, value, expected_output", + [ + ("INSTANA_SERVICE_NAME", "test_service", "test_service"), + ("FLASK_APP", "test_flask_app.py", "test_flask_app.py"), + ("DJANGO_SETTINGS_MODULE", "test_django_app.settings", "test_django_app"), + ], +) +def test_determine_service_name_via_env_var( + env_var: str, + value: str, + expected_output: str, + _resource_determine_service_name_via_env_var: None, +) -> None: + # Test with multiple environment variables + os.environ[env_var] = value + sys.argv = ["something", "nothing"] + assert determine_service_name() == expected_output + + +@pytest.mark.parametrize( + "web_browser, argv, expected_output", + [ + ("gunicorn", ["gunicorn", "djface.wsgi:app"], "gunicorn"), + ( + "uwsgi", + [ + "uwsgi", + "--master", + "--processes", + "4", + "--threads", + "2", + "djface.wsgi:app", + ], + "uWSGI master", + ), + ], +) +def test_determine_service_name_via_web_browser( + web_browser: str, + argv: List[str], + expected_output: str, + _resource_determine_service_name_via_env_var: None, + mocker: "MockerFixture", +) -> None: + mocker.patch("instana.util.runtime.get_proc_cmdline", return_value="python") + mocker.patch("os.getpid", return_value=12345) + sys.argv = argv + assert determine_service_name() == expected_output + + +@pytest.mark.parametrize( + "argv", + [ + (["python", "test_app.py", "arg1", "arg2"]), + ([]), + ], +) +def test_determine_service_name_via_cli_args( + argv: List[str], + _resource_determine_service_name_via_env_var: None, + mocker: "MockerFixture", +) -> 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 + # can be the value "python3" + assert "python" in determine_service_name() + + +@pytest.mark.parametrize( + "isatty, expected_output", + [ + (True, "Interactive Console"), + (False, ""), + ], +) +def test_determine_service_name_via_tty( + isatty: bool, + expected_output: str, + _resource_determine_service_name_via_env_var: None, + mocker: "MockerFixture", +) -> None: + sys.argv = [] + sys.executable = "" + sys.stdout.isatty = lambda: isatty + assert determine_service_name() == expected_output + + +@pytest.mark.parametrize( + "as_string, expected", + [ + (False, ["python", "script.py", "arg1", "arg2"]), + (True, "python script.py arg1 arg2"), + ], +) +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)}" + + +@pytest.mark.parametrize( + "as_string, expected", + [ + (False, ["python"]), + (True, "python"), + ], +) +def test_get_proc_cmdline_no_proc_fs( + as_string: bool, expected: Union[List[str], str], mocker: "MockerFixture" +): + # Mock the proc filesystem absence + mocker.patch("os.path.isfile", return_value=False) + 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") + + mocker.patch("platform.machine", return_value=expected_output[0]) + mocker.patch("platform.python_version", return_value=expected_output[1]) + + machine, py_version = get_runtime_env_info() + assert machine == expected_output[0] + assert py_version == expected_output[1] + + +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") + 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]) + + log_runtime_env_info() + assert ( + f"Runtime environment: Machine: {expected_output[0]}, Python version: {expected_output[1]}" + in caplog.messages + ) From 929874bc1a51ea3e1e0caccd0a8fe91d43256be1 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Sun, 29 Jun 2025 20:46:15 +0200 Subject: [PATCH 5/7] fix (tests): Skipping tests not supported on ppc64. The following tests are not executed in a ppc64le environment due to lack of support to run them: - grpcio: not installing in ppc64le. - google-cloud-*: depends on grpcio. - pymongo: only the Enterprise edition is supported in ppc64le. Signed-off-by: Paulo Vital --- tests/conftest.py | 12 ++++++++++-- tests_aws/conftest.py | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7a2aa884..18f89443 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,6 +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 collect_ignore_glob = [ "*test_gevent*", @@ -30,6 +31,13 @@ "*agent/test_google*", ] +# ppc64le has limitations with some supported libraries. +machine, py_version = get_runtime_env_info() +if machine == "ppc64le": + collect_ignore_glob.append("*test_grpcio*") + collect_ignore_glob.append("*test_google-cloud*") + collect_ignore_glob.append("*test_pymongo*") + # # Cassandra and gevent tests are run in dedicated jobs on CircleCI and will # # be run explicitly. (So always exclude them here) if not os.environ.get("CASSANDRA_TEST"): @@ -55,7 +63,7 @@ collect_ignore_glob.append("*test_fastapi*") # aiohttp-server tests failing due to deprecated methods used collect_ignore_glob.append("*test_aiohttp_server*") - # Currently Saniic does not support python >= 3.14 + # Currently Sanic does not support python >= 3.14 collect_ignore_glob.append("*test_sanic*") @@ -249,4 +257,4 @@ def _uwsgi_masterpid() -> int: "enable-threads": True, } module.masterpid = _uwsgi_masterpid -sys.modules["uwsgi"] = module \ No newline at end of file +sys.modules["uwsgi"] = module diff --git a/tests_aws/conftest.py b/tests_aws/conftest.py index 90dea412..767147fa 100644 --- a/tests_aws/conftest.py +++ b/tests_aws/conftest.py @@ -2,6 +2,14 @@ # (c) Copyright Instana Inc. 2020 import os +import platform os.environ["INSTANA_ENDPOINT_URL"] = "https://localhost/notreal" os.environ["INSTANA_AGENT_KEY"] = "Fake_Key" + +# ppc64le is not supported by AWS Serverless Services. +collect_ignore_glob = [] +if platform.machine() == "ppc64le": + collect_ignore_glob.append("*test_lambda*") + collect_ignore_glob.append("*test_fargate*") + collect_ignore_glob.append("*test_eks*") \ No newline at end of file From 7c070d3d93c28b3c64e606f09269c9d4d4bc2fdc Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Mon, 30 Jun 2025 01:47:05 -0700 Subject: [PATCH 6/7] fix: logging stacklevel for ppc64le Signed-off-by: Paulo Vital --- src/instana/instrumentation/logging.py | 3 ++- tests/clients/test_logging.py | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/instana/instrumentation/logging.py b/src/instana/instrumentation/logging.py index 4efc265a..040df5b4 100644 --- a/src/instana/instrumentation/logging.py +++ b/src/instana/instrumentation/logging.py @@ -9,6 +9,7 @@ from typing import Any, Tuple, Dict, Callable from instana.log import logger +from instana.util.runtime import get_runtime_env_info from instana.util.traceutils import get_tracer_tuple, tracing_is_off @@ -25,7 +26,7 @@ def log_with_instana( # We take into consideration if `stacklevel` is already present in `kwargs`. # This prevents the error `_log() got multiple values for keyword argument 'stacklevel'` - stacklevel_in = kwargs.pop("stacklevel", 1) + stacklevel_in = kwargs.pop("stacklevel", 1 if get_runtime_env_info()[0] != "ppc64le" else 2) stacklevel = stacklevel_in + 1 + (sys.version_info >= (3, 14)) try: diff --git a/tests/clients/test_logging.py b/tests/clients/test_logging.py index 37faa941..239dc0c5 100644 --- a/tests/clients/test_logging.py +++ b/tests/clients/test_logging.py @@ -8,6 +8,7 @@ import pytest from opentelemetry.trace import SpanKind +from instana.util.runtime import get_runtime_env_info from instana.singletons import agent, tracer @@ -146,6 +147,9 @@ def test_log_caller_with_stacklevel( ) self.logger.addHandler(handler) + if get_runtime_env_info()[0] == "ppc64le": + stacklevel += 1 + def log_custom_warning(): self.logger.warning("foo %s", "bar", stacklevel=stacklevel) From 5a5f424595c44a77b13949a93379dc03d49a1691 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Mon, 30 Jun 2025 02:13:45 -0700 Subject: [PATCH 7/7] fix(tests): add conftest.py for AutoWrapt test error Signed-off-by: Paulo Vital --- tests_autowrapt/__init__.py | 0 tests_autowrapt/conftest.py | 7 +++++++ 2 files changed, 7 insertions(+) create mode 100644 tests_autowrapt/__init__.py create mode 100644 tests_autowrapt/conftest.py diff --git a/tests_autowrapt/__init__.py b/tests_autowrapt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests_autowrapt/conftest.py b/tests_autowrapt/conftest.py new file mode 100644 index 00000000..23090651 --- /dev/null +++ b/tests_autowrapt/conftest.py @@ -0,0 +1,7 @@ +# (c) Copyright IBM Corp. 2025 + +import os + +collect_ignore_glob = [] +if not os.environ.get("AUTOWRAPT_BOOTSTRAP", None): + collect_ignore_glob.append("*test_autowrapt*")