diff --git a/src/instana/instrumentation/aws/lambda_inst.py b/src/instana/instrumentation/aws/lambda_inst.py index 9b737e3b..0e2cd455 100644 --- a/src/instana/instrumentation/aws/lambda_inst.py +++ b/src/instana/instrumentation/aws/lambda_inst.py @@ -20,9 +20,18 @@ from instana import get_aws_lambda_handler from instana.instrumentation.aws.triggers import enrich_lambda_span, get_context from instana.log import logger + from instana.propagators.base_propagator import BasePropagator from instana.singletons import env_is_aws_lambda, get_agent, get_tracer from instana.util.ids import define_server_timing + def add_server_timing_header(result: Dict[str, Any], trace_id: int) -> None: + """Add the Server-Timing header to the Lambda response, if it has headers.""" + server_timing_value = define_server_timing(trace_id) + if "headers" in result: + result["headers"]["Server-Timing"] = server_timing_value + elif "multiValueHeaders" in result: + result["multiValueHeaders"]["Server-Timing"] = [server_timing_value] + def lambda_handler_with_instana( wrapped: Callable[..., object], instance: object, @@ -45,13 +54,8 @@ def lambda_handler_with_instana( result = wrapped(*args, **kwargs) if isinstance(result, dict): - server_timing_value = define_server_timing(span.context.trace_id) - if "headers" in result: - result["headers"]["Server-Timing"] = server_timing_value - elif "multiValueHeaders" in result: - result["multiValueHeaders"]["Server-Timing"] = [ - server_timing_value - ] + if not BasePropagator.is_server_timing_disabled(): + add_server_timing_header(result, span.context.trace_id) if "statusCode" in result and result.get("statusCode"): status_code = int(result["statusCode"]) span.set_attribute(SpanAttributes.HTTP_STATUS_CODE, status_code) diff --git a/src/instana/options.py b/src/instana/options.py index 2ca532b8..44095dd4 100644 --- a/src/instana/options.py +++ b/src/instana/options.py @@ -25,6 +25,7 @@ get_disable_trace_configurations_from_env, get_disable_trace_configurations_from_local, get_disable_trace_configurations_from_yaml, + get_disable_server_timing_from_yaml, get_http_exit_classification_from_yaml, get_stack_trace_config_from_yaml, is_truthy, @@ -64,6 +65,9 @@ def __init__(self, **kwds: dict[str, Any]) -> None: self.stack_trace_level = "all" # Options: "all", "error", "none" self.stack_trace_length = 30 # Default: 30, recommended range: 10-40 + # Disable injection of the Server-Timing response header (tracing.global.disable-server-timing) + self.disable_server_timing = False + # Technology-specific stack trace overrides # Format: {"kafka": {"level": "all", "length": 25}, "redis": {"level": "error", "length": 20}} self.stack_trace_technology_config = {} @@ -128,6 +132,7 @@ def set_trace_configurations(self) -> None: self.set_http_exit_classification_configurations() self.set_disable_trace_configurations() self.set_stack_trace_configurations() + self.set_disable_server_timing_configuration() self.set_span_filter_configurations() def set_http_exit_classification_configurations(self) -> None: @@ -290,6 +295,30 @@ def set_stack_trace_configurations(self) -> None: elif isinstance(config.get("tracing"), dict): self._apply_in_code_stack_trace_config() + @staticmethod + def _has_env_disable_server_timing() -> bool: + """ + Return True if INSTANA_TRACING_DISABLE_SERVER_TIMING is set to a non-empty value. + + An empty string is treated as unset, so it does not take precedence over + the YAML file or agent config. Any non-empty value (even a non-truthy one + such as "false" or "0") is an explicit setting and takes precedence. + """ + return bool(os.environ.get("INSTANA_TRACING_DISABLE_SERVER_TIMING")) + + def set_disable_server_timing_configuration(self) -> None: + """ + Set disable-server-timing following precedence: + INSTANA_TRACING_DISABLE_SERVER_TIMING > INSTANA_CONFIG_PATH > agent config > default (False) + The agent config is handled in StandardOptions.set_tracing(). + """ + if self._has_env_disable_server_timing(): + self.disable_server_timing = is_truthy( + os.environ["INSTANA_TRACING_DISABLE_SERVER_TIMING"] + ) + elif "INSTANA_CONFIG_PATH" in os.environ: + self.disable_server_timing = get_disable_server_timing_from_yaml() + def set_disable_trace_configurations(self) -> None: disabled_spans = [] enabled_spans = [] @@ -459,10 +488,28 @@ def set_tracing(self, tracing: dict[str, Any]) -> None: # Handle stack trace configuration from agent config self.set_stack_trace_from_agent(tracing) + self._apply_agent_disable_server_timing(tracing) + # HTTP exit 4xx classification — only apply if env var didn't already set it if "http" in tracing and not self._has_env_http_exit_classification(): self._apply_agent_http_exit_classification(tracing["http"]) + def _apply_agent_disable_server_timing(self, tracing: dict[str, Any]) -> None: + """ + Apply tracing.global.disable-server-timing from agent config (lowest + priority: the environment variable and the YAML file take precedence). + """ + global_config = tracing.get("global") + if ( + not self._has_env_disable_server_timing() + and "INSTANA_CONFIG_PATH" not in os.environ + and isinstance(global_config, dict) + and "disable-server-timing" in global_config + ): + self.disable_server_timing = is_truthy( + global_config["disable-server-timing"] + ) + def _apply_agent_filter_config(self, filter_config: dict[str, Any]) -> None: """Apply span filter rules from agent config.""" parsed = parse_filter_rules(filter_config) diff --git a/src/instana/propagators/base_propagator.py b/src/instana/propagators/base_propagator.py index acb79e23..cd34804e 100644 --- a/src/instana/propagators/base_propagator.py +++ b/src/instana/propagators/base_propagator.py @@ -89,6 +89,25 @@ def __init__(self): self._tp = Traceparent() self._ts = Tracestate() + @staticmethod + def is_server_timing_disabled() -> bool: + """ + Whether injection of the Instana Server-Timing value is disabled + (tracing.global.disable-server-timing / INSTANA_TRACING_DISABLE_SERVER_TIMING). + Evaluated on every call so runtime configuration changes are honoured. + Falls back to False (Server-Timing enabled) if the agent is unavailable. + """ + try: + # Imported here to avoid a circular import: instana.singletons + # imports the tracer, which imports the propagators. + from instana.singletons import get_agent + + options = getattr(get_agent(), "options", None) + return bool(getattr(options, "disable_server_timing", False)) + except Exception: + logger.debug("is_server_timing_disabled error:", exc_info=True) + return False + @staticmethod def extract_headers_dict(carrier: CarrierT) -> Optional[Dict]: """ diff --git a/src/instana/propagators/binary_propagator.py b/src/instana/propagators/binary_propagator.py index d94f77fc..61662a57 100644 --- a/src/instana/propagators/binary_propagator.py +++ b/src/instana/propagators/binary_propagator.py @@ -39,7 +39,11 @@ def inject( trace_id = format_span_id(span_context.trace_id).encode() span_id = format_span_id(span_context.span_id).encode() level = str(span_context.level).encode() - server_timing = define_server_timing(span_context.trace_id).encode() + server_timing = ( + None + if self.is_server_timing_disabled() + else define_server_timing(span_context.trace_id).encode() + ) if disable_w3c_trace_context: traceparent, tracestate = [None] * 2 @@ -53,42 +57,26 @@ def inject( except Exception: traceparent, tracestate = [None] * 2 + headers = [] + if traceparent and tracestate: + headers.append((self.HEADER_KEY_TRACEPARENT, traceparent)) + headers.append((self.HEADER_KEY_TRACESTATE, tracestate)) + headers.append((self.HEADER_KEY_T, trace_id)) + headers.append((self.HEADER_KEY_S, span_id)) + headers.append((self.HEADER_KEY_L, level)) + if server_timing: + headers.append((self.HEADER_SERVER_TIMING, server_timing)) + if isinstance(carrier, dict) or hasattr(carrier, "__dict__"): - if traceparent and tracestate: - carrier[self.HEADER_KEY_TRACEPARENT] = traceparent # type: ignore[index] - carrier[self.HEADER_KEY_TRACESTATE] = tracestate # type: ignore[index] - carrier[self.HEADER_KEY_T] = trace_id # type: ignore[index] - carrier[self.HEADER_KEY_S] = span_id # type: ignore[index] - carrier[self.HEADER_KEY_L] = level # type: ignore[index] - carrier[self.HEADER_SERVER_TIMING] = server_timing # type: ignore[index] + for key, value in headers: + carrier[key] = value # type: ignore[index] elif isinstance(carrier, list): - if traceparent and tracestate: - carrier.append((self.HEADER_KEY_TRACEPARENT, traceparent)) - carrier.append((self.HEADER_KEY_TRACESTATE, tracestate)) - carrier.append((self.HEADER_KEY_T, trace_id)) - carrier.append((self.HEADER_KEY_S, span_id)) - carrier.append((self.HEADER_KEY_L, level)) - carrier.append((self.HEADER_SERVER_TIMING, server_timing)) + carrier.extend(headers) elif isinstance(carrier, tuple): - if traceparent and tracestate: - carrier = carrier.__add__( - ((self.HEADER_KEY_TRACEPARENT, traceparent),) - ) - carrier = carrier.__add__( - ((self.HEADER_KEY_TRACESTATE, tracestate),) - ) - carrier = carrier.__add__(((self.HEADER_KEY_T, trace_id),)) - carrier = carrier.__add__(((self.HEADER_KEY_S, span_id),)) - carrier = carrier.__add__(((self.HEADER_KEY_L, level),)) - carrier = carrier.__add__(((self.HEADER_SERVER_TIMING, server_timing),)) + carrier = carrier.__add__(tuple(headers)) elif hasattr(carrier, "__setitem__"): - if traceparent and tracestate: - carrier.__setitem__(self.HEADER_KEY_TRACEPARENT, traceparent) - carrier.__setitem__(self.HEADER_KEY_TRACESTATE, tracestate) - carrier.__setitem__(self.HEADER_KEY_T, trace_id) - carrier.__setitem__(self.HEADER_KEY_S, span_id) - carrier.__setitem__(self.HEADER_KEY_L, level) - carrier.__setitem__(self.HEADER_SERVER_TIMING, server_timing) + for key, value in headers: + carrier.__setitem__(key, value) else: raise Exception("Unsupported carrier type", type(carrier)) diff --git a/src/instana/propagators/http_propagator.py b/src/instana/propagators/http_propagator.py index 667b1ddd..ba479f3e 100644 --- a/src/instana/propagators/http_propagator.py +++ b/src/instana/propagators/http_propagator.py @@ -89,9 +89,12 @@ def inject_key_value(carrier: CarrierT, key: str, value: Any) -> None: inject_key_value(carrier, self.HEADER_KEY_T, hex_id_limited(trace_id)) inject_key_value(carrier, self.HEADER_KEY_S, format_span_id(span_id)) - inject_key_value( - carrier, self.HEADER_KEY_SERVER_TIMING, define_server_timing(trace_id) - ) + if not self.is_server_timing_disabled(): + inject_key_value( + carrier, + self.HEADER_KEY_SERVER_TIMING, + define_server_timing(trace_id), + ) except Exception: logger.debug("inject error:", exc_info=True) diff --git a/src/instana/propagators/text_propagator.py b/src/instana/propagators/text_propagator.py index 96f6e8e0..b806e775 100644 --- a/src/instana/propagators/text_propagator.py +++ b/src/instana/propagators/text_propagator.py @@ -29,30 +29,30 @@ def inject( try: trace_id = format_span_id(span_context.trace_id) span_id = format_span_id(span_context.span_id) - server_timing = define_server_timing(span_context.trace_id).encode() + server_timing = ( + None + if self.is_server_timing_disabled() + else define_server_timing(span_context.trace_id).encode() + ) + + headers = [ + (self.LC_HEADER_KEY_T, trace_id), + (self.LC_HEADER_KEY_S, span_id), + (self.LC_HEADER_KEY_L, "1"), + ] + if server_timing: + headers.append((self.LC_HEADER_KEY_SERVER_TIMING, server_timing)) if isinstance(carrier, dict) or hasattr(carrier, "__dict__"): - carrier[self.LC_HEADER_KEY_T] = trace_id # type: ignore[index] - carrier[self.LC_HEADER_KEY_S] = span_id # type: ignore[index] - carrier[self.LC_HEADER_KEY_L] = "1" # type: ignore[index] - carrier[self.LC_HEADER_KEY_SERVER_TIMING] = server_timing # type: ignore[index] + for key, value in headers: + carrier[key] = value # type: ignore[index] elif isinstance(carrier, list): - carrier.append((self.LC_HEADER_KEY_T, trace_id)) - carrier.append((self.LC_HEADER_KEY_S, span_id)) - carrier.append((self.LC_HEADER_KEY_L, "1")) - carrier.append((self.LC_HEADER_KEY_SERVER_TIMING, server_timing)) + carrier.extend(headers) elif isinstance(carrier, tuple): - carrier = carrier.__add__(((self.LC_HEADER_KEY_T, trace_id),)) - carrier = carrier.__add__(((self.LC_HEADER_KEY_S, span_id),)) - carrier = carrier.__add__(((self.LC_HEADER_KEY_L, "1"),)) - carrier = carrier.__add__( - ((self.LC_HEADER_KEY_SERVER_TIMING, server_timing),) - ) + carrier = carrier.__add__(tuple(headers)) elif hasattr(carrier, "__setitem__"): - carrier.__setitem__(self.LC_HEADER_KEY_T, trace_id) - carrier.__setitem__(self.LC_HEADER_KEY_S, span_id) - carrier.__setitem__(self.LC_HEADER_KEY_L, "1") - carrier.__setitem__(self.LC_HEADER_KEY_SERVER_TIMING, server_timing) + for key, value in headers: + carrier.__setitem__(key, value) else: raise Exception("Unsupported carrier type", type(carrier)) diff --git a/src/instana/util/config.py b/src/instana/util/config.py index 0bd5b0eb..baf968b1 100644 --- a/src/instana/util/config.py +++ b/src/instana/util/config.py @@ -583,6 +583,27 @@ def get_stack_trace_config_from_yaml() -> Tuple[ return level, length, tech_config +def get_disable_server_timing_from_yaml() -> bool: + """ + Get the tracing.global.disable-server-timing setting from the YAML file + specified by INSTANA_CONFIG_PATH. + + Returns: + True if Server-Timing header injection is disabled, False otherwise (default) + """ + config_reader = ConfigReader(os.environ.get("INSTANA_CONFIG_PATH", "")) + + root_key = get_tracing_root_key(config_reader.data) + if not root_key: + return False + + global_config = (config_reader.data[root_key] or {}).get("global") + if not isinstance(global_config, dict): + return False + + return is_truthy(global_config.get("disable-server-timing", False)) + + def get_http_exit_classification_from_yaml() -> Tuple[bool, List[int]]: """ Get HTTP exit 4xx classification configuration from the YAML file specified diff --git a/tests/instrumentation/aws/__init__.py b/tests/instrumentation/aws/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/instrumentation/aws/test_lambda_inst.py b/tests/instrumentation/aws/test_lambda_inst.py new file mode 100644 index 00000000..44d3756e --- /dev/null +++ b/tests/instrumentation/aws/test_lambda_inst.py @@ -0,0 +1,73 @@ +# (c) Copyright IBM Corp. 2026 + +from typing import Any, Generator +from unittest.mock import MagicMock, patch + +import pytest + +from instana.instrumentation.aws import lambda_inst + +TRACE_ID = 1812338823475918251 +SERVER_TIMING = "intid;desc=1922fa4b3d1d76ab" + + +class TestLambdaHandlerServerTiming: + @pytest.fixture(autouse=True) + def _resources(self) -> Generator[None, None, None]: + self.agent = MagicMock() + self.agent.collector.started = False + self.agent.options.disable_server_timing = False + self.span = MagicMock() + self.span.context.trace_id = TRACE_ID + tracer = MagicMock() + tracer.start_as_current_span.return_value.__enter__.return_value = self.span + + with ( + patch.object(lambda_inst, "get_agent", return_value=self.agent), + patch("instana.singletons.get_agent", return_value=self.agent), + patch.object(lambda_inst, "get_tracer", return_value=tracer), + patch.object(lambda_inst, "get_context"), + patch.object(lambda_inst, "enrich_lambda_span"), + patch.object( + lambda_inst, "define_server_timing", return_value=SERVER_TIMING + ), + ): + yield + + def _invoke(self, result: Any) -> Any: + handler = MagicMock(return_value=result) + return lambda_inst.lambda_handler_with_instana(handler, None, ({}, {}), {}) + + def test_headers_get_server_timing(self) -> None: + result = self._invoke({"statusCode": 200, "headers": {}}) + assert result["headers"]["Server-Timing"] == SERVER_TIMING + + def test_multi_value_headers_get_server_timing(self) -> None: + result = self._invoke({"statusCode": 200, "multiValueHeaders": {}}) + assert result["multiValueHeaders"]["Server-Timing"] == [SERVER_TIMING] + + def test_headers_untouched_when_disabled(self) -> None: + self.agent.options.disable_server_timing = True + result = self._invoke({"statusCode": 200, "headers": {}}) + assert "Server-Timing" not in result["headers"] + + def test_multi_value_headers_untouched_when_disabled(self) -> None: + self.agent.options.disable_server_timing = True + result = self._invoke({"statusCode": 200, "multiValueHeaders": {}}) + assert "Server-Timing" not in result["multiValueHeaders"] + + def test_status_code_still_recorded_when_disabled(self) -> None: + self.agent.options.disable_server_timing = True + self._invoke({"statusCode": 502, "headers": {}}) + self.span.set_attribute.assert_called_once() + self.span.record_exception.assert_called_once_with("HTTP status 502") + + def test_non_dict_result_returned_unchanged(self) -> None: + assert self._invoke("plain") == "plain" + + def test_handler_exception_is_recorded_and_raised(self) -> None: + handler = MagicMock(side_effect=ValueError("boom")) + with pytest.raises(ValueError): + lambda_inst.lambda_handler_with_instana(handler, None, ({}, {}), {}) + self.span.record_exception.assert_called_once() + self.agent.collector.shutdown.assert_called() diff --git a/tests/propagators/test_base_propagator.py b/tests/propagators/test_base_propagator.py index ed299d1d..c4a3913b 100644 --- a/tests/propagators/test_base_propagator.py +++ b/tests/propagators/test_base_propagator.py @@ -1,7 +1,7 @@ # (c) Copyright IBM Corp. 2024 from typing import Generator -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest @@ -97,3 +97,25 @@ def test_extract(self) -> None: } span_context = self.propagator.extract(carrier=carrier) assert span_context + + +class TestIsServerTimingDisabled: + @pytest.mark.parametrize("value", [True, False]) + def test_reads_agent_option(self, value: bool) -> None: + agent = Mock() + agent.options.disable_server_timing = value + with patch("instana.singletons.get_agent", return_value=agent): + assert BasePropagator.is_server_timing_disabled() is value + + def test_agent_without_options_defaults_to_enabled(self) -> None: + agent = Mock(spec=[]) + with patch("instana.singletons.get_agent", return_value=agent): + assert BasePropagator.is_server_timing_disabled() is False + + def test_no_agent_defaults_to_enabled(self) -> None: + with patch("instana.singletons.get_agent", return_value=None): + assert BasePropagator.is_server_timing_disabled() is False + + def test_exception_defaults_to_enabled(self) -> None: + with patch("instana.singletons.get_agent", side_effect=RuntimeError("boom")): + assert BasePropagator.is_server_timing_disabled() is False diff --git a/tests/propagators/test_binary_propagator.py b/tests/propagators/test_binary_propagator.py index 7f0f32a0..12ae5616 100644 --- a/tests/propagators/test_binary_propagator.py +++ b/tests/propagators/test_binary_propagator.py @@ -2,6 +2,7 @@ # (c) Copyright Instana Inc. 2021 from typing import Generator +from unittest.mock import patch import pytest from opentelemetry.trace import ( @@ -9,6 +10,7 @@ format_trace_id, ) +from instana.propagators.base_propagator import BasePropagator from instana.propagators.binary_propagator import BinaryPropagator from instana.span_context import SpanContext from instana.util.ids import hex_id @@ -185,3 +187,53 @@ def test_inject_carrier_set_exception(self, trace_id: int, span_id: int) -> None ) carrier = self.bp.inject(ctx, carrier) assert not carrier + + +class TestBinaryPropagatorServerTimingDisabled: + @pytest.fixture(autouse=True) + def _resources(self) -> Generator[None, None, None]: + self.bp = BinaryPropagator() + with patch.object( + BasePropagator, "is_server_timing_disabled", return_value=True + ): + yield + + @staticmethod + def _ctx(trace_id: int, span_id: int) -> SpanContext: + return SpanContext( + span_id=span_id, + trace_id=trace_id, + is_remote=False, + level=1, + baggage={}, + sampled=True, + synthetic=False, + ) + + def test_dict(self, trace_id: int, span_id: int, hex_trace_id: str) -> None: + carrier = self.bp.inject(self._ctx(trace_id, span_id), {}) + assert carrier[b"x-instana-t"] == hex_trace_id.encode("utf-8") + assert b"server-timing" not in carrier + + def test_list(self, trace_id: int, span_id: int) -> None: + carrier = self.bp.inject(self._ctx(trace_id, span_id), []) + assert len(carrier) == 3 + assert all(key != b"server-timing" for key, _ in carrier) + + def test_tuple(self, trace_id: int, span_id: int) -> None: + carrier = self.bp.inject(self._ctx(trace_id, span_id), ()) + assert len(carrier) == 3 + assert all(key != b"server-timing" for key, _ in carrier) + + def test_setitem_carrier(self, trace_id: int, span_id: int) -> None: + class Carrier: + def __init__(self) -> None: + self.items = {} + + def __setitem__(self, key, value) -> None: + self.items[key] = value + + carrier = Carrier() + self.bp.inject(self._ctx(trace_id, span_id), carrier) + assert b"x-instana-t" in carrier.items + assert b"server-timing" not in carrier.items diff --git a/tests/propagators/test_http_propagator.py b/tests/propagators/test_http_propagator.py index dfb3ce5f..17b75672 100644 --- a/tests/propagators/test_http_propagator.py +++ b/tests/propagators/test_http_propagator.py @@ -3,6 +3,7 @@ import os from typing import Any, Dict, Generator +from unittest.mock import patch import pytest from opentelemetry.context.context import Context @@ -399,3 +400,31 @@ def test_suppression_when_child_level_is_lower( # Verify that no trace or span IDs are injected when suppressed assert "X-INSTANA-T" not in downstream_carrier assert "X-INSTANA-S" not in downstream_carrier + + +class TestHTTPPropagatorServerTiming: + @staticmethod + def _ctx(trace_id: int, span_id: int) -> SpanContext: + return SpanContext(trace_id=trace_id, span_id=span_id, is_remote=False, level=1) + + def test_server_timing_injected_by_default( + self, trace_id: int, span_id: int + ) -> None: + carrier = {} + with patch.object( + HTTPPropagator, "is_server_timing_disabled", return_value=False + ): + HTTPPropagator().inject(self._ctx(trace_id, span_id), carrier) + assert "Server-Timing" in carrier + + def test_server_timing_not_injected_when_disabled( + self, trace_id: int, span_id: int + ) -> None: + carrier = {} + with patch.object( + HTTPPropagator, "is_server_timing_disabled", return_value=True + ): + HTTPPropagator().inject(self._ctx(trace_id, span_id), carrier) + assert "Server-Timing" not in carrier + assert "X-INSTANA-T" in carrier + assert "X-INSTANA-S" in carrier diff --git a/tests/propagators/test_text_propagator.py b/tests/propagators/test_text_propagator.py new file mode 100644 index 00000000..2f0bde13 --- /dev/null +++ b/tests/propagators/test_text_propagator.py @@ -0,0 +1,128 @@ +# (c) Copyright IBM Corp. 2026 + +from typing import Generator +from unittest.mock import patch + +import pytest + +from instana.propagators.base_propagator import BasePropagator +from instana.propagators.text_propagator import TextPropagator +from instana.span_context import SpanContext +from instana.util.ids import hex_id + + +class TestTextPropagator: + @pytest.fixture(autouse=True) + def _resources(self) -> Generator[None, None, None]: + self.tp = TextPropagator() + yield + + @staticmethod + def _ctx(trace_id: int, span_id: int) -> SpanContext: + return SpanContext( + span_id=span_id, + trace_id=trace_id, + is_remote=False, + level=1, + baggage={}, + sampled=True, + synthetic=False, + ) + + def test_inject_dict( + self, trace_id: int, span_id: int, hex_trace_id: str, hex_span_id: str + ) -> None: + carrier = self.tp.inject(self._ctx(trace_id, span_id), {}) + assert carrier["x-instana-t"] == hex_trace_id + assert carrier["x-instana-s"] == hex_span_id + assert carrier["x-instana-l"] == "1" + assert carrier["server-timing"] == f"intid;desc={hex_id(trace_id)}".encode() + + def test_inject_list( + self, trace_id: int, span_id: int, hex_trace_id: str, hex_span_id: str + ) -> None: + carrier = self.tp.inject(self._ctx(trace_id, span_id), []) + assert carrier[0] == ("x-instana-t", hex_trace_id) + assert carrier[1] == ("x-instana-s", hex_span_id) + assert carrier[2] == ("x-instana-l", "1") + assert carrier[3] == ( + "server-timing", + f"intid;desc={hex_id(trace_id)}".encode(), + ) + + def test_inject_tuple( + self, trace_id: int, span_id: int, hex_trace_id: str, hex_span_id: str + ) -> None: + carrier = self.tp.inject(self._ctx(trace_id, span_id), ()) + assert isinstance(carrier, tuple) + assert carrier[0] == ("x-instana-t", hex_trace_id) + assert carrier[1] == ("x-instana-s", hex_span_id) + assert carrier[2] == ("x-instana-l", "1") + assert carrier[3] == ( + "server-timing", + f"intid;desc={hex_id(trace_id)}".encode(), + ) + + def test_inject_setitem_carrier(self, trace_id: int, span_id: int) -> None: + class Carrier: + def __init__(self) -> None: + self.items = {} + + def __setitem__(self, key, value) -> None: + self.items[key] = value + + carrier = Carrier() + self.tp.inject(self._ctx(trace_id, span_id), carrier) + assert "x-instana-t" in carrier.items + assert "server-timing" in carrier.items + + def test_inject_unsupported_carrier(self, trace_id: int, span_id: int) -> None: + assert not self.tp.inject(self._ctx(trace_id, span_id), set()) + + def test_inject_dict_server_timing_disabled( + self, trace_id: int, span_id: int, hex_trace_id: str + ) -> None: + with patch.object( + BasePropagator, "is_server_timing_disabled", return_value=True + ): + carrier = self.tp.inject(self._ctx(trace_id, span_id), {}) + assert carrier["x-instana-t"] == hex_trace_id + assert "server-timing" not in carrier + + def test_inject_list_server_timing_disabled( + self, trace_id: int, span_id: int + ) -> None: + with patch.object( + BasePropagator, "is_server_timing_disabled", return_value=True + ): + carrier = self.tp.inject(self._ctx(trace_id, span_id), []) + assert len(carrier) == 3 + assert all(key != "server-timing" for key, _ in carrier) + + def test_inject_tuple_server_timing_disabled( + self, trace_id: int, span_id: int + ) -> None: + with patch.object( + BasePropagator, "is_server_timing_disabled", return_value=True + ): + carrier = self.tp.inject(self._ctx(trace_id, span_id), ()) + assert len(carrier) == 3 + assert all(key != "server-timing" for key, _ in carrier) + + def test_inject_setitem_server_timing_disabled( + self, trace_id: int, span_id: int + ) -> None: + class Carrier: + def __init__(self) -> None: + self.items = {} + + def __setitem__(self, key, value) -> None: + self.items[key] = value + + carrier = Carrier() + with patch.object( + BasePropagator, "is_server_timing_disabled", return_value=True + ): + self.tp.inject(self._ctx(trace_id, span_id), carrier) + assert "x-instana-t" in carrier.items + assert "server-timing" not in carrier.items diff --git a/tests/test_options.py b/tests/test_options.py index 3ef3cf1b..4c2ce8ad 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1876,3 +1876,75 @@ def test_env_classify_all_false_does_not_fall_through_to_yaml( opts = BaseOptions() # Env var says false explicitly → YAML must NOT override assert opts.http_exit_classify_all_4xx_as_errors is False + + +class TestDisableServerTiming: + """Tests for tracing.global.disable-server-timing configuration.""" + + @pytest.fixture(autouse=True) + def _cleanup(self) -> Generator[None, None, None]: + yield + for key in ("INSTANA_TRACING_DISABLE_SERVER_TIMING", "INSTANA_CONFIG_PATH"): + os.environ.pop(key, None) + + @staticmethod + def _write_yaml(tmp_path, value: str, root: str = "tracing") -> str: + path = tmp_path / "config.yaml" + path.write_text(f"{root}:\n global:\n disable-server-timing: {value}\n") + return str(path) + + def test_default_is_enabled(self) -> None: + assert BaseOptions().disable_server_timing is False + assert StandardOptions().disable_server_timing is False + + @pytest.mark.parametrize("value", ["true", "True", "1"]) + def test_env_var_truthy(self, value: str) -> None: + os.environ["INSTANA_TRACING_DISABLE_SERVER_TIMING"] = value + assert BaseOptions().disable_server_timing is True + + @pytest.mark.parametrize("value", ["false", "0", "", "nope"]) + def test_env_var_not_truthy(self, value: str) -> None: + os.environ["INSTANA_TRACING_DISABLE_SERVER_TIMING"] = value + assert BaseOptions().disable_server_timing is False + + @pytest.mark.parametrize("root", ["tracing", "com.instana.tracing"]) + def test_yaml(self, tmp_path, root: str) -> None: + os.environ["INSTANA_CONFIG_PATH"] = self._write_yaml(tmp_path, "true", root) + assert BaseOptions().disable_server_timing is True + + def test_yaml_false(self, tmp_path) -> None: + os.environ["INSTANA_CONFIG_PATH"] = self._write_yaml(tmp_path, "false") + assert BaseOptions().disable_server_timing is False + + def test_env_var_wins_over_yaml(self, tmp_path) -> None: + os.environ["INSTANA_CONFIG_PATH"] = self._write_yaml(tmp_path, "false") + os.environ["INSTANA_TRACING_DISABLE_SERVER_TIMING"] = "true" + assert BaseOptions().disable_server_timing is True + + def test_agent_config(self) -> None: + opts = StandardOptions() + opts.set_from({"tracing": {"global": {"disable-server-timing": True}}}) + assert opts.disable_server_timing is True + + def test_agent_config_runtime_change(self) -> None: + opts = StandardOptions() + opts.set_from({"tracing": {"global": {"disable-server-timing": True}}}) + opts.set_from({"tracing": {"global": {"disable-server-timing": False}}}) + assert opts.disable_server_timing is False + + def test_agent_config_unrelated_global_keys_do_not_change_it(self) -> None: + opts = StandardOptions() + opts.set_from({"tracing": {"global": {"stack-trace": "error"}}}) + assert opts.disable_server_timing is False + + def test_env_var_wins_over_agent_config(self) -> None: + os.environ["INSTANA_TRACING_DISABLE_SERVER_TIMING"] = "true" + opts = StandardOptions() + opts.set_from({"tracing": {"global": {"disable-server-timing": False}}}) + assert opts.disable_server_timing is True + + def test_yaml_wins_over_agent_config(self, tmp_path) -> None: + os.environ["INSTANA_CONFIG_PATH"] = self._write_yaml(tmp_path, "true") + opts = StandardOptions() + opts.set_from({"tracing": {"global": {"disable-server-timing": False}}}) + assert opts.disable_server_timing is True diff --git a/tests/util/test_config.py b/tests/util/test_config.py index 9ba6d781..2a13284f 100644 --- a/tests/util/test_config.py +++ b/tests/util/test_config.py @@ -1,8 +1,11 @@ # (c) Copyright IBM Corp. 2025 +import os + import pytest from instana.util.config import ( + get_disable_server_timing_from_yaml, is_truthy, parse_filter_rules, parse_filter_rules_dict, @@ -252,3 +255,51 @@ def test_parse_filtered_endpoints(self) -> None: def test_is_truthy(self, value, expected) -> None: """Test the is_truthy function with various input values.""" assert is_truthy(value) == expected + + +class TestGetDisableServerTimingFromYaml: + @pytest.fixture(autouse=True) + def _cleanup(self): + yield + os.environ.pop("INSTANA_CONFIG_PATH", None) + + @staticmethod + def _use_yaml(tmp_path, content: str) -> None: + path = tmp_path / "config.yaml" + path.write_text(content) + os.environ["INSTANA_CONFIG_PATH"] = str(path) + + def test_true(self, tmp_path) -> None: + self._use_yaml( + tmp_path, "tracing:\n global:\n disable-server-timing: true\n" + ) + assert get_disable_server_timing_from_yaml() is True + + def test_deprecated_root_key(self, tmp_path) -> None: + self._use_yaml( + tmp_path, + "com.instana.tracing:\n global:\n disable-server-timing: true\n", + ) + assert get_disable_server_timing_from_yaml() is True + + def test_false(self, tmp_path) -> None: + self._use_yaml( + tmp_path, "tracing:\n global:\n disable-server-timing: false\n" + ) + assert get_disable_server_timing_from_yaml() is False + + def test_key_missing(self, tmp_path) -> None: + self._use_yaml(tmp_path, "tracing:\n global:\n stack-trace: error\n") + assert get_disable_server_timing_from_yaml() is False + + def test_global_not_a_dict(self, tmp_path) -> None: + self._use_yaml(tmp_path, "tracing:\n global: nope\n") + assert get_disable_server_timing_from_yaml() is False + + def test_empty_tracing_section(self, tmp_path) -> None: + self._use_yaml(tmp_path, "tracing:\n") + assert get_disable_server_timing_from_yaml() is False + + def test_no_tracing_root_key(self, tmp_path) -> None: + self._use_yaml(tmp_path, "other:\n key: value\n") + assert get_disable_server_timing_from_yaml() is False