From a0f06f35912fe03a05cd2abc35526b33cd22e88c Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Sat, 26 Jul 2025 11:26:44 +0200 Subject: [PATCH 1/5] refactor: Instana module init. Signed-off-by: Paulo Vital --- src/instana/__init__.py | 43 +++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/instana/__init__.py b/src/instana/__init__.py index 7a9ec0b1..afda3ab1 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -11,6 +11,7 @@ """ import importlib +import importlib.util import os import sys from typing import Tuple @@ -70,7 +71,7 @@ def load(_: object) -> None: def apply_gevent_monkey_patch() -> None: from gevent import monkey - if os.environ.get("INSTANA_GEVENT_MONKEY_OPTIONS"): + if provided_options := os.environ.get("INSTANA_GEVENT_MONKEY_OPTIONS"): def short_key(k: str) -> str: return k[3:] if k.startswith("no-") else k @@ -81,12 +82,8 @@ def key_to_bool(k: str) -> bool: import inspect all_accepted_patch_all_args = inspect.getfullargspec(monkey.patch_all)[0] - provided_options = ( - os.environ.get("INSTANA_GEVENT_MONKEY_OPTIONS") - .replace(" ", "") - .replace("--", "") - .split(",") - ) + provided_options.replace(" ", "").replace("--", "").split(",") + provided_options = [ k for k in provided_options if short_key(k) in all_accepted_patch_all_args ] @@ -115,9 +112,7 @@ def get_aws_lambda_handler() -> Tuple[str, str]: handler_function = "lambda_handler" try: - handler = os.environ.get("LAMBDA_HANDLER", False) - - if handler: + if handler := os.environ.get("LAMBDA_HANDLER", None): parts = handler.split(".") handler_function = parts.pop().strip() handler_module = ".".join(parts).strip() @@ -159,13 +154,10 @@ def boot_agent() -> None: import instana.singletons # noqa: F401 - # Instrumentation + # Import & initialize instrumentation if "INSTANA_DISABLE_AUTO_INSTR" not in os.environ: - # TODO: remove the following entries as the migration of the - # instrumentation codes are finalised. - - # Import & initialize instrumentation from instana.instrumentation import ( + aio_pika, # noqa: F401 aioamqp, # noqa: F401 asyncio, # noqa: F401 cassandra, # noqa: F401 @@ -173,7 +165,6 @@ def boot_agent() -> None: couchbase, # noqa: F401 fastapi, # noqa: F401 flask, # noqa: F401 - # gevent_inst, # noqa: F401 grpcio, # noqa: F401 httpx, # noqa: F401 logging, # noqa: F401 @@ -186,11 +177,10 @@ def boot_agent() -> None: pyramid, # noqa: F401 redis, # noqa: F401 sanic, # noqa: F401 + spyne, # noqa: F401 sqlalchemy, # noqa: F401 starlette, # noqa: F401 urllib3, # noqa: F401 - spyne, # noqa: F401 - aio_pika, # noqa: F401 ) from instana.instrumentation.aiohttp import ( client as aiohttp_client, # noqa: F401 @@ -218,6 +208,8 @@ def boot_agent() -> None: server as tornado_server, # noqa: F401 ) + # from instana.instrumentation import gevent_inst # noqa: F401 + # Hooks from instana.hooks import ( hook_gunicorn, # noqa: F401 @@ -225,6 +217,14 @@ def boot_agent() -> None: ) +def _start_profiler() -> None: + """Start the Instana Auto Profile.""" + from instana.singletons import get_profiler + + if profiler := get_profiler(): + profiler.start() + + if "INSTANA_DISABLE" not in os.environ: # There are cases when sys.argv may not be defined at load time. Seems to happen in embedded Python, # and some Pipenv installs. If this is the case, it's best effort. @@ -246,12 +246,9 @@ def boot_agent() -> None: and importlib.util.find_spec("gevent") ): apply_gevent_monkey_patch() + # AutoProfile if "INSTANA_AUTOPROFILE" in os.environ: - from instana.singletons import get_profiler - - profiler = get_profiler() - if profiler: - profiler.start() + _start_profiler() boot_agent() From 71b5e8389d4cf4eb0e2c1011e71417355f41f861 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Tue, 29 Jul 2025 17:19:08 +0200 Subject: [PATCH 2/5] feat: Add Span Disabling. The span disabling allows the exclusion of specific traces or calls from tracing based on the category (technology) or type (frameworks, libraries, instrumentations) supported by the traces. This commit adds support to handle the configuration from the INSTANA_TRACING_DISABLE, INSTANA_CONFIG_PATH, and Agent-provided configuration. Signed-off-by: Paulo Vital --- src/instana/__init__.py | 5 +- src/instana/options.py | 103 +++++++++++++++++- src/instana/util/config.py | 157 +++++++++++++++++++++++++-- tests/test_options.py | 153 +++++++++++++++++++++++++- tests/test_span_disabling.py | 79 ++++++++++++++ tests/util/test_config_reader.py | 23 +++- tests/util/test_configuration-1.yaml | 5 + tests/util/test_configuration-2.yaml | 4 + 8 files changed, 510 insertions(+), 19 deletions(-) create mode 100644 tests/test_span_disabling.py diff --git a/src/instana/__init__.py b/src/instana/__init__.py index afda3ab1..6b91824d 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -20,6 +20,7 @@ is_autowrapt_instrumented, is_webhook_instrumented, ) +from instana.util.config import is_truthy from instana.version import VERSION __author__ = "Instana Inc." @@ -225,7 +226,9 @@ def _start_profiler() -> None: profiler.start() -if "INSTANA_DISABLE" not in os.environ: +if "INSTANA_DISABLE" not in os.environ and not is_truthy( + os.environ.get("INSTANA_TRACING_DISABLE", None) +): # There are cases when sys.argv may not be defined at load time. Seems to happen in embedded Python, # and some Pipenv installs. If this is the case, it's best effort. if ( diff --git a/src/instana/options.py b/src/instana/options.py index 356ea961..affaa266 100644 --- a/src/instana/options.py +++ b/src/instana/options.py @@ -16,12 +16,20 @@ import logging import os -from typing import Any, Dict +from typing import Any, Dict, Sequence from instana.configurator import config from instana.log import logger -from instana.util.config import (is_truthy, parse_ignored_endpoints, - parse_ignored_endpoints_from_yaml) +from instana.util.config import ( + SPAN_TYPE_TO_CATEGORY, + get_disable_trace_configurations_from_env, + get_disable_trace_configurations_from_local, + get_disable_trace_configurations_from_yaml, + is_truthy, + parse_ignored_endpoints, + parse_ignored_endpoints_from_yaml, + parse_span_disabling, +) from instana.util.runtime import determine_service_name @@ -37,6 +45,11 @@ def __init__(self, **kwds: Dict[str, Any]) -> None: self.ignore_endpoints = [] self.kafka_trace_correlation = True + # disabled_spans lists all categories and types that should be disabled + self.disabled_spans = [] + # enabled_spans lists all categories and types that should be enabled, preceding disabled_spans + self.enabled_spans = [] + self.set_trace_configurations() # Defaults @@ -75,8 +88,9 @@ def set_trace_configurations(self) -> None: ) # Check if either of the environment variables is truthy - if is_truthy(os.environ.get("INSTANA_ALLOW_EXIT_AS_ROOT", None)) or \ - is_truthy(os.environ.get("INSTANA_ALLOW_ROOT_EXIT_SPAN", None)): + if is_truthy(os.environ.get("INSTANA_ALLOW_EXIT_AS_ROOT", None)) or is_truthy( + os.environ.get("INSTANA_ALLOW_ROOT_EXIT_SPAN", None) + ): self.allow_exit_as_root = True # The priority is as follows: @@ -99,12 +113,69 @@ def set_trace_configurations(self) -> None: ) if "INSTANA_KAFKA_TRACE_CORRELATION" in os.environ: - self.kafka_trace_correlation = is_truthy(os.environ["INSTANA_KAFKA_TRACE_CORRELATION"]) + self.kafka_trace_correlation = is_truthy( + os.environ["INSTANA_KAFKA_TRACE_CORRELATION"] + ) elif isinstance(config.get("tracing"), dict) and "kafka" in config["tracing"]: self.kafka_trace_correlation = config["tracing"]["kafka"].get( "trace_correlation", True ) + self.set_disable_trace_configurations() + + def set_disable_trace_configurations(self) -> None: + disabled_spans = [] + enabled_spans = [] + + # The precedence is as follows: + # environment variables > in-code (local) config > agent config (configuration.yaml) + # For the env vars: INSTANA_TRACING_DISABLE > INSTANA_CONFIG_PATH + if "INSTANA_TRACING_DISABLE" in os.environ: + disabled_spans, enabled_spans = get_disable_trace_configurations_from_env() + elif "INSTANA_CONFIG_PATH" in os.environ: + disabled_spans, enabled_spans = get_disable_trace_configurations_from_yaml() + else: + # In-code (local) config + # The agent config (configuration.yaml) is handled in StandardOptions.set_disable_tracing() + disabled_spans, enabled_spans = ( + get_disable_trace_configurations_from_local() + ) + + self.disabled_spans.extend(disabled_spans) + self.enabled_spans.extend(enabled_spans) + + def is_span_disabled(self, category=None, span_type=None) -> bool: + """ + Check if a span is disabled based on its category and type. + + Args: + category (str): The span category (e.g., "logging", "databases") + span_type (str): The span type (e.g., "redis", "kafka") + + Returns: + bool: True if the span is disabled, False otherwise + """ + # If span_type is provided, check if it's disabled + if span_type and span_type in self.disabled_spans: + return True + + # If category is provided directly, check if it's disabled + if category and category in self.disabled_spans: + return True + + # If span_type is provided but not explicitly configured, + # check if its parent category is disabled. Also check for the precedence rules + if span_type and span_type in SPAN_TYPE_TO_CATEGORY: + parent_category = SPAN_TYPE_TO_CATEGORY[span_type] + if ( + parent_category in self.disabled_spans + and span_type not in self.enabled_spans + ): + return True + + # Default: not disabled + return False + class StandardOptions(BaseOptions): """The options class used when running directly on a host/node with an Instana agent""" @@ -177,6 +248,26 @@ def set_tracing(self, tracing: Dict[str, Any]) -> None: if "extra-http-headers" in tracing: self.extra_http_headers = tracing["extra-http-headers"] + # Handle span disabling configuration + if "disable" in tracing: + self.set_disable_tracing(tracing["disable"]) + + def set_disable_tracing(self, tracing_config: Sequence[Dict[str, Any]]) -> None: + # The precedence is as follows: + # environment variables > in-code (local) config > agent config (configuration.yaml) + if ( + "INSTANA_TRACING_DISABLE" not in os.environ + and "INSTANA_CONFIG_PATH" not in os.environ + and not ( + isinstance(config.get("tracing"), dict) + and "disable" in config["tracing"] + ) + ): + # agent config (configuration.yaml) + disabled_spans, enabled_spans = parse_span_disabling(tracing_config) + self.disabled_spans.extend(disabled_spans) + self.enabled_spans.extend(enabled_spans) + def set_from(self, res_data: Dict[str, Any]) -> None: """ Set the source identifiers given to use by the Instana Host agent. diff --git a/src/instana/util/config.py b/src/instana/util/config.py index 887c2292..6cc1e109 100644 --- a/src/instana/util/config.py +++ b/src/instana/util/config.py @@ -2,11 +2,43 @@ import itertools import os -from typing import Any, Dict, List, Union +from typing import Any, Dict, List, Sequence, Tuple, Union +from instana.configurator import config from instana.log import logger from instana.util.config_reader import ConfigReader +# List of supported span categories (technology or protocol) +SPAN_CATEGORIES = [ + "logging", + "databases", + "messaging", + "protocols", # http, grpc, etc. +] + +# Mapping of span type calls (framework, library name, instrumentation name) to categories +SPAN_TYPE_TO_CATEGORY = { + # Database types + "redis": "databases", + "mysql": "databases", + "postgresql": "databases", + "mongodb": "databases", + "cassandra": "databases", + "couchbase": "databases", + "dynamodb": "databases", + "sqlalchemy": "databases", + # Messaging types + "kafka": "messaging", + "rabbitmq": "messaging", + "pika": "messaging", + "aio_pika": "messaging", + "aioamqp": "messaging", + # Protocol types + "http": "protocols", + "grpc": "protocols", + "graphql": "protocols", +} + def parse_service_pair(pair: str) -> List[str]: """ @@ -151,10 +183,10 @@ def parse_ignored_endpoints_from_yaml(file_path: str) -> List[str]: def is_truthy(value: Any) -> bool: """ Check if a value is truthy, accepting various formats. - + @param value: The value to check @return: True if the value is considered truthy, False otherwise - + Accepts the following as True: - True (Python boolean) - "True", "true" (case-insensitive string) @@ -163,17 +195,128 @@ def is_truthy(value: Any) -> bool: """ if value is None: return False - + if isinstance(value, bool): return value - + if isinstance(value, int): return value == 1 - + if isinstance(value, str): value_lower = value.lower() return value_lower == "true" or value == "1" - + return False + +def parse_span_disabling( + disable_list: Sequence[Union[str, Dict[str, Any]]], +) -> Tuple[List[str], List[str]]: + """ + Process a list of span disabling configurations and return lists of disabled and enabled spans. + + @param disable_list: List of span disabling configurations + @return: Tuple of (disabled_spans, enabled_spans) + """ + if not isinstance(disable_list, list): + logger.debug( + f"parse_span_disabling: Invalid disable_list type: {type(disable_list)}" + ) + return [], [] + + disabled_spans = [] + enabled_spans = [] + + for item in disable_list: + if isinstance(item, str): + disabled = parse_span_disabling_str(item) + disabled_spans.extend(disabled) + elif isinstance(item, dict): + disabled, enabled = parse_span_disabling_dict(item) + disabled_spans.extend(disabled) + enabled_spans.extend(enabled) + else: + logger.debug( + f"parse_span_disabling: Invalid disable_list item type: {type(item)}" + ) + + return disabled_spans, enabled_spans + + +def parse_span_disabling_str(item: str) -> List[str]: + """ + Process a string span disabling configuration and return a list of disabled spans. + + @param item: String span disabling configuration + @return: List of disabled spans + """ + if item.lower() in SPAN_CATEGORIES or item.lower() in SPAN_TYPE_TO_CATEGORY.keys(): + return [item.lower()] + else: + logger.debug(f"set_span_disabling_str: Invalid span category/type: {item}") + return [] + + +def parse_span_disabling_dict(items: Dict[str, bool]) -> Tuple[List[str], List[str]]: + """ + Process a dictionary span disabling configuration and return lists of disabled and enabled spans. + + @param items: Dictionary span disabling configuration + @return: Tuple of (disabled_spans, enabled_spans) + """ + disabled_spans = [] + enabled_spans = [] + + for key, value in items.items(): + if key in SPAN_CATEGORIES or key in SPAN_TYPE_TO_CATEGORY.keys(): + if is_truthy(value): + disabled_spans.append(key) + else: + enabled_spans.append(key) + else: + logger.debug(f"set_span_disabling_dict: Invalid span category/type: {key}") + + return disabled_spans, enabled_spans + + +def get_disable_trace_configurations_from_env() -> Tuple[List[str], List[str]]: + # Read INSTANA_TRACING_DISABLE environment variable + if tracing_disable := os.environ.get("INSTANA_TRACING_DISABLE", None): + if is_truthy(tracing_disable): + # INSTANA_TRACING_DISABLE is True/true/1, then we disable all tracing + disabled_spans = [] + for category in SPAN_CATEGORIES: + disabled_spans.append(category) + return disabled_spans, [] + else: + # INSTANA_TRACING_DISABLE is a comma-separated list of span categories/types + tracing_disable_list = [x.strip() for x in tracing_disable.split(",")] + return parse_span_disabling(tracing_disable_list) + return [], [] + + +def get_disable_trace_configurations_from_yaml() -> Tuple[List[str], List[str]]: + config_reader = ConfigReader(os.environ.get("INSTANA_CONFIG_PATH", "")) + + if "tracing" in config_reader.data: + root_key = "tracing" + elif "com.instana.tracing" in config_reader.data: + logger.warning( + 'Please use "tracing" instead of "com.instana.tracing" for local configuration file.' + ) + root_key = "com.instana.tracing" + else: + return [], [] + + tracing_disable_config = config_reader.data[root_key].get("disable", "") + return parse_span_disabling(tracing_disable_config) + + +def get_disable_trace_configurations_from_local() -> Tuple[List[str], List[str]]: + if "tracing" in config: + if tracing_disable_config := config["tracing"].get("disable", None): + return parse_span_disabling(tracing_disable_config) + return [], [] + + # Made with Bob diff --git a/tests/test_options.py b/tests/test_options.py index a2130c38..4c3d3869 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -4,8 +4,8 @@ import os from typing import Generator -from mock import patch import pytest +from mock import patch from instana.configurator import config from instana.options import ( @@ -41,6 +41,8 @@ def test_base_options(self) -> None: assert self.base_options.secrets_matcher == "contains-ignore-case" assert self.base_options.secrets_list == ["key", "pass", "secret"] assert not self.base_options.secrets + assert self.base_options.disabled_spans == [] + assert self.base_options.enabled_spans == [] def test_base_options_with_config(self) -> None: config["tracing"] = { @@ -62,6 +64,7 @@ def test_base_options_with_config(self) -> None: "INSTANA_EXTRA_HTTP_HEADERS": "SOMETHING;HERE", "INSTANA_IGNORE_ENDPOINTS": "service1;service2:method1,method2", "INSTANA_SECRETS": "secret1:username,password", + "INSTANA_TRACING_DISABLE": "logging, redis,kafka", }, ) def test_base_options_with_env_vars(self) -> None: @@ -80,6 +83,11 @@ def test_base_options_with_env_vars(self) -> None: assert self.base_options.secrets_matcher == "secret1" assert self.base_options.secrets_list == ["username", "password"] + assert "logging" in self.base_options.disabled_spans + assert "redis" in self.base_options.disabled_spans + assert "kafka" in self.base_options.disabled_spans + assert len(self.base_options.enabled_spans) == 0 + @patch.dict( os.environ, {"INSTANA_IGNORE_ENDPOINTS_PATH": "tests/util/test_configuration-1.yaml"}, @@ -108,17 +116,29 @@ def test_base_options_with_endpoint_file(self) -> None: "INSTANA_IGNORE_ENDPOINTS": "env_service1;env_service2:method1,method2", "INSTANA_KAFKA_TRACE_CORRELATION": "false", "INSTANA_IGNORE_ENDPOINTS_PATH": "tests/util/test_configuration-1.yaml", + "INSTANA_TRACING_DISABLE": "logging,redis, kafka", }, ) def test_set_trace_configurations_by_env_variable(self) -> None: # The priority is as follows: # environment variables > in-code configuration > # > agent config (configuration.yaml) > default value + + # in-code configuration + config["tracing"] = {} config["tracing"]["ignore_endpoints"] = ( "config_service1;config_service2:method1,method2" ) config["tracing"]["kafka"] = {"trace_correlation": True} - test_tracing = {"ignore-endpoints": "service1;service2:method1,method2"} + config["tracing"]["disable"] = [{"databases": True}] + + # agent config (configuration.yaml) + test_tracing = { + "ignore-endpoints": "service1;service2:method1,method2", + "disable": [ + {"messaging": True}, + ], + } # Setting by env variable self.base_options = StandardOptions() @@ -131,6 +151,14 @@ def test_set_trace_configurations_by_env_variable(self) -> None: ] assert not self.base_options.kafka_trace_correlation + # Check disabled_spans list + assert "logging" in self.base_options.disabled_spans + assert "redis" in self.base_options.disabled_spans + assert "kafka" in self.base_options.disabled_spans + assert "databases" not in self.base_options.disabled_spans + assert "messaging" not in self.base_options.disabled_spans + assert len(self.base_options.enabled_spans) == 0 + @patch.dict( os.environ, { @@ -138,12 +166,25 @@ def test_set_trace_configurations_by_env_variable(self) -> None: "INSTANA_IGNORE_ENDPOINTS_PATH": "tests/util/test_configuration-1.yaml", }, ) - def test_set_trace_configurations_by_local_configuration_file(self) -> None: + def test_set_trace_configurations_by_in_code_configuration(self) -> None: + # The priority is as follows: + # in-code configuration > agent config (configuration.yaml) > default value + + # in-code configuration + config["tracing"] = {} config["tracing"]["ignore_endpoints"] = ( "config_service1;config_service2:method1,method2" ) config["tracing"]["kafka"] = {"trace_correlation": True} - test_tracing = {"ignore-endpoints": "service1;service2:method1,method2"} + config["tracing"]["disable"] = [{"databases": True}] + + # agent config (configuration.yaml) + test_tracing = { + "ignore-endpoints": "service1;service2:method1,method2", + "disable": [ + {"messaging": True}, + ], + } self.base_options = StandardOptions() self.base_options.set_tracing(test_tracing) @@ -163,7 +204,16 @@ def test_set_trace_configurations_by_local_configuration_file(self) -> None: "kafka.*.topic4", ] + # Check disabled_spans list + assert "databases" in self.base_options.disabled_spans + assert "logging" not in self.base_options.disabled_spans + assert "redis" not in self.base_options.disabled_spans + assert "kafka" not in self.base_options.disabled_spans + assert "messaging" not in self.base_options.disabled_spans + assert len(self.base_options.enabled_spans) == 0 + def test_set_trace_configurations_by_in_code_variable(self) -> None: + config["tracing"] = {} config["tracing"]["ignore_endpoints"] = ( "config_service1;config_service2:method1,method2" ) @@ -184,6 +234,13 @@ def test_set_trace_configurations_by_agent_configuration(self) -> None: test_tracing = { "ignore-endpoints": "service1;service2:method1,method2", "trace-correlation": True, + "disable": [ + { + "messaging": True, + "logging": True, + "kafka": False, + }, + ], } self.base_options = StandardOptions() @@ -196,12 +253,78 @@ def test_set_trace_configurations_by_agent_configuration(self) -> None: ] assert self.base_options.kafka_trace_correlation + # Check disabled_spans list + assert "databases" not in self.base_options.disabled_spans + assert "logging" in self.base_options.disabled_spans + assert "messaging" in self.base_options.disabled_spans + assert "kafka" in self.base_options.enabled_spans + def test_set_trace_configurations_by_default(self) -> None: self.base_options = StandardOptions() self.base_options.set_tracing({}) assert not self.base_options.ignore_endpoints assert self.base_options.kafka_trace_correlation + assert len(self.base_options.disabled_spans) == 0 + assert len(self.base_options.enabled_spans) == 0 + + @patch.dict( + os.environ, + {"INSTANA_TRACING_DISABLE": "true"}, + ) + def test_set_trace_configurations_disable_all_tracing(self) -> None: + self.base_options = BaseOptions() + + # All categories should be disabled + assert "logging" in self.base_options.disabled_spans + assert "databases" in self.base_options.disabled_spans + assert "messaging" in self.base_options.disabled_spans + assert "protocols" in self.base_options.disabled_spans + + # Check is_span_disabled method + assert self.base_options.is_span_disabled(category="logging") + assert self.base_options.is_span_disabled(category="databases") + assert self.base_options.is_span_disabled(span_type="redis") + + @patch.dict( + os.environ, + { + "INSTANA_CONFIG_PATH": "tests/util/test_configuration-1.yaml", + }, + ) + def test_set_trace_configurations_disable_local_yaml(self) -> None: + self.base_options = BaseOptions() + + # All categories should be disabled + assert "logging" in self.base_options.disabled_spans + assert "databases" in self.base_options.disabled_spans + assert "redis" not in self.base_options.disabled_spans + assert "redis" in self.base_options.enabled_spans + + # Check is_span_disabled method + assert self.base_options.is_span_disabled(category="logging") + assert self.base_options.is_span_disabled(category="databases") + assert not self.base_options.is_span_disabled(span_type="redis") + + def test_is_span_disabled_method(self) -> None: + self.base_options = BaseOptions() + + # Default behavior - nothing disabled + assert not self.base_options.is_span_disabled(category="logging") + assert not self.base_options.is_span_disabled(span_type="redis") + + # Disable a category + self.base_options.disabled_spans = ["databases"] + assert not self.base_options.is_span_disabled(category="logging") + assert self.base_options.is_span_disabled(category="databases") + assert self.base_options.is_span_disabled(span_type="redis") + assert self.base_options.is_span_disabled(span_type="mysql") + + # Test precedence rules + self.base_options.enabled_spans = ["redis"] + assert self.base_options.is_span_disabled(category="databases") + assert self.base_options.is_span_disabled(span_type="mysql") + assert not self.base_options.is_span_disabled(span_type="redis") class TestStandardOptions: @@ -258,6 +381,25 @@ def test_set_tracing( ) assert not self.standart_options.extra_http_headers + def test_set_tracing_with_span_disabling(self) -> None: + self.standart_options = StandardOptions() + + test_tracing = { + "disable": [{"logging": True}, {"redis": False}, {"databases": True}] + } + self.standart_options.set_tracing(test_tracing) + + # Check disabled_spans and enabled_spans lists + assert "logging" in self.standart_options.disabled_spans + assert "databases" in self.standart_options.disabled_spans + assert "redis" in self.standart_options.enabled_spans + + # Check is_span_disabled method + assert self.standart_options.is_span_disabled(category="logging") + assert self.standart_options.is_span_disabled(category="databases") + assert self.standart_options.is_span_disabled(span_type="mysql") + assert not self.standart_options.is_span_disabled(span_type="redis") + def test_set_from(self) -> None: self.standart_options = StandardOptions() test_res_data = { @@ -493,3 +635,6 @@ def test_gcr_options_with_env_vars(self) -> None: assert self.gcr_options.endpoint_proxy == {"https": "proxy1"} assert self.gcr_options.timeout == 3 assert self.gcr_options.log_level == logging.INFO + + +# Made with Bob diff --git a/tests/test_span_disabling.py b/tests/test_span_disabling.py new file mode 100644 index 00000000..e1e1cbf5 --- /dev/null +++ b/tests/test_span_disabling.py @@ -0,0 +1,79 @@ +# (c) Copyright IBM Corp. 2025 + +import pytest + +from instana.options import BaseOptions, StandardOptions +from instana.singletons import agent + + +class TestSpanDisabling: + @pytest.fixture(autouse=True) + def setup(self): + # Save original options + self.original_options = agent.options + yield + # Restore original options + agent.options = self.original_options + + def test_is_span_disabled_default(self): + options = BaseOptions() + assert not options.is_span_disabled(category="logging") + assert not options.is_span_disabled(category="databases") + assert not options.is_span_disabled(span_type="redis") + + def test_disable_category(self): + options = BaseOptions() + options.disabled_spans = ["logging"] + assert options.is_span_disabled(category="logging") + assert not options.is_span_disabled(category="databases") + + def test_disable_type(self): + options = BaseOptions() + options.disabled_spans = ["redis"] + assert options.is_span_disabled(span_type="redis") + assert not options.is_span_disabled(span_type="mysql") + + def test_type_category_relationship(self): + options = BaseOptions() + options.disabled_spans = ["databases"] + assert options.is_span_disabled(span_type="redis") + assert options.is_span_disabled(span_type="mysql") + + def test_precedence_rules(self): + options = BaseOptions() + options.disabled_spans = ["databases"] + options.enabled_spans = ["redis"] + assert options.is_span_disabled(category="databases") + assert options.is_span_disabled(span_type="mysql") + assert not options.is_span_disabled(span_type="redis") + + @pytest.mark.parametrize("value", ["True", "true", "1"]) + def test_env_var_disable_all(self, value, monkeypatch): + monkeypatch.setenv("INSTANA_TRACING_DISABLE", value) + options = BaseOptions() + assert options.is_span_disabled(category="logging") is True + assert options.is_span_disabled(category="databases") is True + assert options.is_span_disabled(category="messaging") is True + assert options.is_span_disabled(category="protocols") is True + + def test_env_var_disable_specific(self, monkeypatch): + monkeypatch.setenv("INSTANA_TRACING_DISABLE", "logging, redis") + options = BaseOptions() + assert options.is_span_disabled(category="logging") is True + assert options.is_span_disabled(category="databases") is False + assert options.is_span_disabled(span_type="redis") is True + assert options.is_span_disabled(span_type="mysql") is False + + def test_yaml_config(self): + options = StandardOptions() + tracing_config = { + "disable": [{"logging": True}, {"redis": False}, {"databases": True}] + } + options.set_tracing(tracing_config) + assert options.is_span_disabled(category="logging") + assert options.is_span_disabled(category="databases") + assert options.is_span_disabled(span_type="mysql") + assert not options.is_span_disabled(span_type="redis") + + +# Made with Bob diff --git a/tests/util/test_config_reader.py b/tests/util/test_config_reader.py index b9bb063d..7957efd4 100644 --- a/tests/util/test_config_reader.py +++ b/tests/util/test_config_reader.py @@ -1,10 +1,14 @@ # (c) Copyright IBM Corp. 2025 import logging +import os import pytest -from instana.util.config import parse_ignored_endpoints_from_yaml +from instana.util.config import ( + get_disable_trace_configurations_from_yaml, + parse_ignored_endpoints_from_yaml, +) class TestConfigReader: @@ -32,6 +36,14 @@ def test_load_configuration_with_tracing( "kafka.*.topic4", ] + os.environ["INSTANA_CONFIG_PATH"] = "tests/util/test_configuration-1.yaml" + disabled_spans, enabled_spans = get_disable_trace_configurations_from_yaml() + # Check disabled_spans list + assert "logging" in disabled_spans + assert "databases" in disabled_spans + assert "redis" not in disabled_spans + assert "redis" in enabled_spans + assert ( 'Please use "tracing" instead of "com.instana.tracing" for local configuration file.' not in caplog.messages @@ -58,6 +70,15 @@ def test_load_configuration_legacy(self, caplog: pytest.LogCaptureFixture) -> No "kafka.*.span-topic", "kafka.*.topic4", ] + + os.environ["INSTANA_CONFIG_PATH"] = "tests/util/test_configuration-2.yaml" + disabled_spans, enabled_spans = get_disable_trace_configurations_from_yaml() + # Check disabled_spans list + assert "logging" in disabled_spans + assert "databases" in disabled_spans + assert "redis" not in disabled_spans + assert "redis" in enabled_spans + assert ( 'Please use "tracing" instead of "com.instana.tracing" for local configuration file.' in caplog.messages diff --git a/tests/util/test_configuration-1.yaml b/tests/util/test_configuration-1.yaml index af890a35..ac61d362 100644 --- a/tests/util/test_configuration-1.yaml +++ b/tests/util/test_configuration-1.yaml @@ -17,3 +17,8 @@ tracing: endpoints: ["span-topic", "topic4"] # - methods: ["consume", "send"] # endpoints: ["*"] # Applied to all topics + disable: + - "logging": true + - "databases": true + - "redis": false + \ No newline at end of file diff --git a/tests/util/test_configuration-2.yaml b/tests/util/test_configuration-2.yaml index b418cd55..5ed83ec1 100644 --- a/tests/util/test_configuration-2.yaml +++ b/tests/util/test_configuration-2.yaml @@ -18,3 +18,7 @@ com.instana.tracing: endpoints: ["span-topic", "topic4"] # - methods: ["consume", "send"] # endpoints: ["*"] # Applied to all topics + disable: + - "logging": true + - "databases": true + - "redis": false From 8e6d2e6cce9306a45aedffd701632eb18abac6ad Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Tue, 29 Jul 2025 18:10:14 +0200 Subject: [PATCH 3/5] feat: Add support to disable log collection. Disabling log spans collection at the tracer level to prevent duplication in the backend when both the application tracer and an OpenTelemetry collector are running on the same system. Signed-off-by: Paulo Vital --- src/instana/instrumentation/logging.py | 13 ++++- tests/clients/test_logging.py | 77 +++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/src/instana/instrumentation/logging.py b/src/instana/instrumentation/logging.py index 9bb58885..8bc9acd6 100644 --- a/src/instana/instrumentation/logging.py +++ b/src/instana/instrumentation/logging.py @@ -10,6 +10,7 @@ import wrapt from instana.log import logger +from instana.singletons import agent from instana.util.runtime import get_runtime_env_info from instana.util.traceutils import get_tracer_tuple, tracing_is_off @@ -27,12 +28,18 @@ 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 if get_runtime_env_info()[0] not in ["ppc64le", "s390x"] else 2) + stacklevel_in = kwargs.pop( + "stacklevel", 1 if get_runtime_env_info()[0] not in ["ppc64le", "s390x"] else 2 + ) stacklevel = stacklevel_in + 1 + (sys.version_info >= (3, 14)) try: - # Only needed if we're tracing and serious log - if tracing_is_off() or argv[0] < logging.WARN: + # Only needed if we're tracing and serious log and logging spans are not disabled + if ( + tracing_is_off() + or argv[0] < logging.WARN + or agent.options.is_span_disabled(category="logging") + ): return wrapped(*argv, **kwargs, stacklevel=stacklevel) tracer, parent_span, _ = get_tracer_tuple() diff --git a/tests/clients/test_logging.py b/tests/clients/test_logging.py index e924ac1c..0fa5d2dc 100644 --- a/tests/clients/test_logging.py +++ b/tests/clients/test_logging.py @@ -70,7 +70,7 @@ def test_parameters(self) -> None: try: a = 42 b = 0 - c = a / b + c = a / b # noqa: F841 except Exception as e: self.logger.exception("Exception: %s", str(e)) @@ -168,3 +168,78 @@ def main(): assert spans[0].k is SpanKind.CLIENT assert spans[0].data["log"].get("message") == "foo bar" + + +class TestLoggingDisabling: + @pytest.fixture(autouse=True) + def _resource(self) -> Generator[None, None, None]: + # Setup + self.recorder = tracer.span_processor + self.recorder.clear_spans() + self.logger = logging.getLogger("unit test") + + # Save original options + self.original_options = agent.options + + yield + + # Teardown + agent.options = self.original_options + agent.options.allow_exit_as_root = False + + def test_logging_enabled(self) -> None: + with tracer.start_as_current_span("test"): + self.logger.warning("test message") + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + assert spans[0].k is SpanKind.CLIENT + assert spans[0].data["log"].get("message") == "test message" + + def test_logging_disabled(self) -> None: + # Disable logging spans + agent.options.disabled_spans = ["logging"] + + with tracer.start_as_current_span("test"): + self.logger.warning("test message") + + spans = self.recorder.queued_spans() + assert len(spans) == 1 # Only the parent span, no logging span + + def test_logging_disabled_via_env_var(self, monkeypatch): + # Disable logging spans via environment variable + monkeypatch.setenv("INSTANA_TRACING_DISABLE", "logging") + + # Create new options to read from environment + original_options = agent.options + agent.options = type(original_options)() + + with tracer.start_as_current_span("test"): + self.logger.warning("test message") + + spans = self.recorder.queued_spans() + assert len(spans) == 1 # Only the parent span, no logging span + + # Restore original options + agent.options = original_options + + def test_logging_disabled_via_yaml(self) -> None: + # Disable logging spans via YAML configuration + original_options = agent.options + agent.options = type(original_options)() + + # Simulate YAML configuration + tracing_config = {"disable": [{"logging": True}]} + agent.options.set_tracing(tracing_config) + + with tracer.start_as_current_span("test"): + self.logger.warning("test message") + + spans = self.recorder.queued_spans() + assert len(spans) == 1 # Only the parent span, no logging span + + # Restore original options + agent.options = original_options + + +# Made with Bob From c1192225faba7c1b2981d430231bb3482592bed1 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Tue, 29 Jul 2025 23:58:59 +0200 Subject: [PATCH 4/5] refactor: ConfigReader Signed-off-by: Paulo Vital --- src/instana/util/config_reader.py | 19 +++++---- tests/util/test_config_reader.py | 64 +++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/src/instana/util/config_reader.py b/src/instana/util/config_reader.py index ddec31ec..87b5f8c1 100644 --- a/src/instana/util/config_reader.py +++ b/src/instana/util/config_reader.py @@ -1,15 +1,18 @@ # (c) Copyright IBM Corp. 2025 -from typing import Union -from instana.log import logger import yaml +from instana.log import logger + class ConfigReader: - def __init__(self, file_path: Union[str]) -> None: + def __init__(self, file_path: str) -> None: self.file_path = file_path - self.data = None - self.load_file() + self.data = {} + if file_path: + self.load_file() + else: + logger.warning("ConfigReader: No configuration file specified") def load_file(self) -> None: """Loads and parses the YAML file""" @@ -17,6 +20,8 @@ def load_file(self) -> None: with open(self.file_path, "r") as file: self.data = yaml.safe_load(file) except FileNotFoundError: - logger.error(f"Configuration file has not found: {self.file_path}") + logger.error( + f"ConfigReader: Configuration file has not found: {self.file_path}" + ) except yaml.YAMLError as e: - logger.error(f"Error parsing YAML file: {e}") + logger.error(f"ConfigReader: Error parsing YAML file: {e}") diff --git a/tests/util/test_config_reader.py b/tests/util/test_config_reader.py index 7957efd4..c5753f8e 100644 --- a/tests/util/test_config_reader.py +++ b/tests/util/test_config_reader.py @@ -2,19 +2,77 @@ import logging import os +from typing import TYPE_CHECKING, Generator import pytest +from yaml import YAMLError from instana.util.config import ( get_disable_trace_configurations_from_yaml, parse_ignored_endpoints_from_yaml, ) +from instana.util.config_reader import ConfigReader + +if TYPE_CHECKING: + from pytest import LogCaptureFixture + from pytest_mock import MockerFixture class TestConfigReader: - def test_load_configuration_with_tracing( - self, caplog: pytest.LogCaptureFixture + @pytest.fixture(autouse=True) + def _resource( + self, + caplog: "LogCaptureFixture", + ) -> Generator[None, None, None]: + yield + caplog.clear() + if "INSTANA_CONFIG_PATH" in os.environ: + os.environ.pop("INSTANA_CONFIG_PATH") + + def test_config_reader_null(self, caplog: "LogCaptureFixture") -> None: + config_reader = ConfigReader(os.environ.get("INSTANA_CONFIG_PATH", "")) + assert config_reader.file_path == "" + assert config_reader.data == {} + assert "ConfigReader: No configuration file specified" in caplog.messages + + def test_config_reader_default(self) -> None: + filename = "tests/util/test_configuration-1.yaml" + os.environ["INSTANA_CONFIG_PATH"] = filename + config_reader = ConfigReader(os.environ.get("INSTANA_CONFIG_PATH", "")) + assert config_reader.file_path == filename + assert "tracing" in config_reader.data + assert len(config_reader.data["tracing"]) == 2 + + def test_config_reader_file_not_found_error( + self, caplog: "LogCaptureFixture" ) -> None: + filename = "tests/util/test_configuration-3.yaml" + os.environ["INSTANA_CONFIG_PATH"] = filename + config_reader = ConfigReader(os.environ.get("INSTANA_CONFIG_PATH", "")) + assert config_reader.file_path == filename + assert config_reader.data == {} + assert ( + f"ConfigReader: Configuration file has not found: {filename}" + in caplog.messages + ) + + def test_config_reader_yaml_error( + self, caplog: "LogCaptureFixture", mocker: "MockerFixture" + ) -> None: + filename = "tests/util/test_configuration-1.yaml" + exception_message = "BLAH" + mocker.patch( + "instana.util.config_reader.yaml.safe_load", + side_effect=YAMLError(exception_message), + ) + + config_reader = ConfigReader(filename) # noqa: F841 + assert ( + f"ConfigReader: Error parsing YAML file: {exception_message}" + in caplog.messages + ) + + def test_load_configuration_with_tracing(self, caplog: "LogCaptureFixture") -> None: caplog.set_level(logging.DEBUG, logger="instana") ignore_endpoints = parse_ignored_endpoints_from_yaml( @@ -49,7 +107,7 @@ def test_load_configuration_with_tracing( not in caplog.messages ) - def test_load_configuration_legacy(self, caplog: pytest.LogCaptureFixture) -> None: + def test_load_configuration_legacy(self, caplog: "LogCaptureFixture") -> None: caplog.set_level(logging.DEBUG, logger="instana") ignore_endpoints = parse_ignored_endpoints_from_yaml( From 61b2a69d04ef607864bb514f1d2d374e13e06624 Mon Sep 17 00:00:00 2001 From: Paulo Vital Date: Wed, 30 Jul 2025 12:21:20 +0200 Subject: [PATCH 5/5] refactor: Remove legacy `INSTANA_DISABLE` handling. The `INSTANA_DISABLE` is not a general environment variable from Instana, and with the adoption of the new `INSTANA_TRACING_DISABLE` we can have the same effect. So this commit removes the legacy one. Signed-off-by: Paulo Vital --- src/instana/__init__.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/instana/__init__.py b/src/instana/__init__.py index 6b91824d..7add8c29 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -11,9 +11,9 @@ """ import importlib -import importlib.util import os import sys +from importlib import util as importlib_util from typing import Tuple from instana.collector.helpers.runtime import ( @@ -226,9 +226,15 @@ def _start_profiler() -> None: profiler.start() -if "INSTANA_DISABLE" not in os.environ and not is_truthy( - os.environ.get("INSTANA_TRACING_DISABLE", None) -): +if "INSTANA_DISABLE" in os.environ: # pragma: no cover + import warnings + + message = "Instana: The INSTANA_DISABLE environment variable is deprecated. Please use INSTANA_TRACING_DISABLE=True instead." + warnings.simplefilter("always") + warnings.warn(message, DeprecationWarning) + + +if not is_truthy(os.environ.get("INSTANA_TRACING_DISABLE", None)): # There are cases when sys.argv may not be defined at load time. Seems to happen in embedded Python, # and some Pipenv installs. If this is the case, it's best effort. if ( @@ -246,7 +252,7 @@ def _start_profiler() -> None: if ( (is_autowrapt_instrumented() or is_webhook_instrumented()) and "INSTANA_DISABLE_AUTO_INSTR" not in os.environ - and importlib.util.find_spec("gevent") + and importlib_util.find_spec("gevent") ): apply_gevent_monkey_patch()