diff --git a/solarwinds_apm/apm_config.py b/solarwinds_apm/apm_config.py index 4b71c3b38..ab04481ea 100644 --- a/solarwinds_apm/apm_config.py +++ b/solarwinds_apm/apm_config.py @@ -20,7 +20,7 @@ from opentelemetry.environment_variables import OTEL_PROPAGATORS from opentelemetry.sdk.resources import Resource -from solarwinds_apm import apm_logging +from solarwinds_apm import apm_logging, apm_resource from solarwinds_apm.apm_constants import ( INTL_SWO_DEFAULT_PROPAGATORS, INTL_SWO_PROPAGATOR, @@ -97,7 +97,9 @@ def __init__( """Initialize SolarWinds APM configuration. Parameters: - otel_resource (Resource): OpenTelemetry resource with attributes. Defaults to empty Resource. + otel_resource (Resource): OpenTelemetry resource with detector attributes. + In normal usage, passed from Configurator after detector resource created. + Defaults to Resource.create() for backward compatibility. **kwargs (int): Additional configuration keyword arguments. """ self.__config = {} @@ -134,6 +136,13 @@ def __init__( self.service_name, ) + # Create final APM resource with SolarWinds attributes + # and calculated service name + self.resource = apm_resource.create_apm_resource( + otel_resource, + self.service_name, + ) + # Update and apply logging settings to Python logger self._validate_log_filepath() apm_logging.update_sw_log_handler( @@ -367,10 +376,11 @@ def _calculate_service_name_apm_proto( # Calculate `service.name` by priority system (decreasing): # 1. OTEL_SERVICE_NAME # 2. service.name in OTEL_RESOURCE_ATTRIBUTES - # 3. service name component of SW_APM_SERVICE_KEY - # 4. empty string + # 3. service.name in OTel Resource set by any Resource Detectors + # 4. service name component of SW_APM_SERVICE_KEY + # 5. empty string # - # Note: 1-3 require that SW_APM_SERVICE_KEY exists and is in the correct + # Note: 1-4 require that SW_APM_SERVICE_KEY exists and is in the correct # format of ":". Otherwise agent_enabled: False # and service.name is empty string. # diff --git a/solarwinds_apm/apm_resource.py b/solarwinds_apm/apm_resource.py new file mode 100644 index 000000000..f812249a5 --- /dev/null +++ b/solarwinds_apm/apm_resource.py @@ -0,0 +1,63 @@ +# © 2026 SolarWinds Worldwide, LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at:http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +"""OpenTelemetry Resource creation for SolarWinds APM.""" + +from __future__ import annotations + +import uuid + +from opentelemetry.sdk.resources import Resource + +from solarwinds_apm.version import __version__ + + +def create_detector_resource() -> Resource: + """Create Resource from all configured detectors. + + Runs all detectors configured in OTEL_EXPERIMENTAL_RESOURCE_DETECTORS. + Should be called after SolarWindsDistro has configured default detector list. + + Returns: + Resource: Resource with attributes from process, OS, cloud, k8s, etc. detectors. + """ + return Resource.create() + + +def create_apm_resource( + detector_resource: Resource, + service_name: str, +) -> Resource: + """Create final APM Resource by merging SolarWinds attributes into detector resource. + + Adds sw.apm.version, sw.data.module, service.name, + and service.instance.id (if not already present from detectors). + + Args: + detector_resource: Resource from detectors with all their attributes. + service_name: Service name should have been calculated by precedence rules + + Returns: + Resource: Final resource for telemetry providers (TracerProvider, MeterProvider, LoggerProvider). + """ + sw_attributes = { + "sw.apm.version": __version__, + "sw.data.module": "apm", + "service.name": service_name, + } + + # Merge sw_attributes into detector_resource. + # Resource.merge(other) means other.attributes override self.attributes on conflicts. + # This preserves all detector attributes (cloud.*, k8s.*, etc.) while ensuring + # service.name (calculated via precedence) overrides any service.name from detectors. + apm_resource = detector_resource.merge(Resource(sw_attributes)) + + if "service.instance.id" not in apm_resource.attributes: + apm_resource = apm_resource.merge( + Resource({"service.instance.id": str(uuid.uuid4())}) + ) + + return apm_resource diff --git a/solarwinds_apm/configurator.py b/solarwinds_apm/configurator.py index e0da32d38..0fabb3ea4 100644 --- a/solarwinds_apm/configurator.py +++ b/solarwinds_apm/configurator.py @@ -11,7 +11,6 @@ import logging import math import os -import uuid from opentelemetry import trace from opentelemetry._logs import set_logger_provider @@ -59,7 +58,7 @@ from opentelemetry.trace import NoOpTracerProvider, set_tracer_provider from opentelemetry.util._importlib_metadata import entry_points -from solarwinds_apm import apm_logging +from solarwinds_apm import apm_logging, apm_resource from solarwinds_apm.apm_config import SolarWindsApmConfig from solarwinds_apm.apm_constants import INTL_SWO_DEFAULT_PROPAGATORS from solarwinds_apm.response_propagator import ( @@ -71,7 +70,6 @@ ServiceEntrySpanProcessor, ) from solarwinds_apm.tracer_provider import SolarwindsTracerProvider -from solarwinds_apm.version import __version__ solarwinds_apm_logger = apm_logging.logger logger = logging.getLogger(__name__) @@ -87,30 +85,8 @@ def __init__(self) -> None: Creates APM configuration instance for SDK initialization. """ super().__init__() - self.apm_config = SolarWindsApmConfig() - - def _create_apm_resource(self) -> Resource: - """Create new OpenTelemetry Resource for telemetry providers. - - Returns: - Resource: Resource with SolarWinds APM attributes and service information. - """ - apm_resource = Resource.create( - { - "sw.apm.version": __version__, - "sw.data.module": "apm", - "service.name": self.apm_config.service_name, - } - ) - # Prioritize service.instance.id set by any Resource Detectors - updated_apm_resource = apm_resource.merge( - Resource( - {"service.instance.id": str(uuid.uuid4())} - if "service.instance.id" not in apm_resource.attributes - else {} - ) - ) - return updated_apm_resource + detector_resource = apm_resource.create_detector_resource() + self.apm_config = SolarWindsApmConfig(otel_resource=detector_resource) def _configure(self, **kwargs: int) -> None: """Configure SolarWinds APM and OpenTelemetry components. @@ -143,24 +119,24 @@ def _configure(self, **kwargs: int) -> None: apm_sampler = ParentBasedSwSampler( self.apm_config, ) - apm_resource = self._create_apm_resource() + resource = self.apm_config.resource self._custom_init_tracing( exporters=span_exporters, id_generator=kwargs.get("id_generator"), sampler=apm_sampler, - resource=apm_resource, + resource=resource, ) self._custom_init_metrics( exporters_or_readers=metric_exporters, - resource=apm_resource, + resource=resource, ) # Initialize LoggerProvider, log processors, and log exporters. # As of OTel 1.40.0, auto-instrumentation of Python logging (setup_logging_handler) # is handled by opentelemetry-instrumentation-logging via instrumentation, not by the # deprecated LoggingHandler in the SDK. So we pass _init_logging false. - _init_logging(log_exporters, apm_resource, setup_logging_handler=False) + _init_logging(log_exporters, resource, setup_logging_handler=False) # Set up additional custom SW components self._configure_service_entry_span_processor() diff --git a/tests/integration/test_base_sw_headers_attrs.py b/tests/integration/test_base_sw_headers_attrs.py index c5a32064c..c311a05d3 100644 --- a/tests/integration/test_base_sw_headers_attrs.py +++ b/tests/integration/test_base_sw_headers_attrs.py @@ -117,6 +117,8 @@ def setUp(self): configurator = SolarWindsConfigurator() configurator._configure_propagator() configurator._configure_response_propagator() + # Store configurator for access to resource in tests + self.configurator = configurator # This is done because set_tracer_provider cannot override the # current tracer provider. Has to be done here. reset_trace_globals() @@ -133,7 +135,11 @@ def setUp(self): remote_parent_sampled=json_sampler, remote_parent_not_sampled=json_sampler, ) - self.tracer_provider = TracerProvider(sampler=sampler) + # Pass resource from configurator to TracerProvider so detector attributes are included + self.tracer_provider = TracerProvider( + sampler=sampler, + resource=configurator.apm_config.resource, + ) # Set InMemorySpanExporter for testing self.memory_exporter = InMemorySpanExporter() span_processor = export.SimpleSpanProcessor(self.memory_exporter) diff --git a/tests/integration/test_service_name.py b/tests/integration/test_service_name.py new file mode 100644 index 000000000..4031c7645 --- /dev/null +++ b/tests/integration/test_service_name.py @@ -0,0 +1,279 @@ +# © 2026 SolarWinds Worldwide, LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at:http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +import os +import time +from unittest import mock + +from .test_base_sw_headers_attrs import TestBaseSwHeadersAndAttributes + + +class TestServiceNamePrecedence1OtelServiceName(TestBaseSwHeadersAndAttributes): + + def setUp(self): + os.environ["OTEL_SERVICE_NAME"] = "otel-override" + os.environ["WEBSITE_SITE_NAME"] = "azure-app" + os.environ["WEBSITE_RESOURCE_GROUP"] = "azure-rg" + super().setUp() + + def tearDown(self): + os.environ.pop("OTEL_SERVICE_NAME", None) + os.environ.pop("WEBSITE_SITE_NAME", None) + os.environ.pop("WEBSITE_RESOURCE_GROUP", None) + super().tearDown() + + def test_otel_service_name_over_azure(self): + # Mock JSON read to guarantee sample decision + timestamp = int(time.time()) + with mock.patch( + target="solarwinds_apm.oboe.json_sampler.JsonSampler._read", + return_value=[ + { + "arguments": { + "BucketCapacity": 2, + "BucketRate": 1, + "MetricsFlushInterval": 60, + "SignatureKey": "", + "TriggerRelaxedBucketCapacity": 4, + "TriggerRelaxedBucketRate": 3, + "TriggerStrictBucketCapacity": 6, + "TriggerStrictBucketRate": 5, + }, + "flags": "SAMPLE_START,SAMPLE_THROUGH_ALWAYS,SAMPLE_BUCKET_ENABLED,TRIGGER_TRACE", + "layer": "", + "timestamp": timestamp, + "ttl": 120, + "type": 0, + "value": 1000000, + } + ], + ): + resp = self.client.get("/test_trace/", headers={"x-test": "value"}) + assert resp.status_code == 200 + spans = self.memory_exporter.get_finished_spans() + assert len(spans) > 0 + + # Verify service.name from OTEL_SERVICE_NAME + resource_attrs = spans[0].resource.attributes + assert resource_attrs["service.name"] == "otel-override" + # Verify Azure detector still ran and set cloud attributes + assert resource_attrs["cloud.provider"] == "azure" + assert resource_attrs["cloud.platform"] == "azure_app_service" + + +class TestServiceNamePrecedence2ResourceAttributes(TestBaseSwHeadersAndAttributes): + + def setUp(self): + os.environ[ + "OTEL_RESOURCE_ATTRIBUTES" + ] = "service.name=resource-attr-name,deployment.environment=prod" + os.environ["WEBSITE_SITE_NAME"] = "azure-app" + super().setUp() + + def tearDown(self): + os.environ.pop("OTEL_RESOURCE_ATTRIBUTES", None) + os.environ.pop("WEBSITE_SITE_NAME", None) + super().tearDown() + + def test_resource_attributes_over_detector(self): + timestamp = int(time.time()) + with mock.patch( + target="solarwinds_apm.oboe.json_sampler.JsonSampler._read", + return_value=[ + { + "arguments": { + "BucketCapacity": 2, + "BucketRate": 1, + "MetricsFlushInterval": 60, + "SignatureKey": "", + "TriggerRelaxedBucketCapacity": 4, + "TriggerRelaxedBucketRate": 3, + "TriggerStrictBucketCapacity": 6, + "TriggerStrictBucketRate": 5, + }, + "flags": "SAMPLE_START,SAMPLE_THROUGH_ALWAYS,SAMPLE_BUCKET_ENABLED,TRIGGER_TRACE", + "layer": "", + "timestamp": timestamp, + "ttl": 120, + "type": 0, + "value": 1000000, + } + ], + ): + resp = self.client.get("/test_trace/") + assert resp.status_code == 200 + spans = self.memory_exporter.get_finished_spans() + assert len(spans) > 0 + + resource_attrs = spans[0].resource.attributes + assert resource_attrs["service.name"] == "resource-attr-name" + assert resource_attrs["deployment.environment"] == "prod" + assert resource_attrs["cloud.provider"] == "azure" + + +class TestServiceNamePrecedence3AzureDetector(TestBaseSwHeadersAndAttributes): + + def setUp(self): + os.environ["WEBSITE_SITE_NAME"] = "azure-production-app" + os.environ["WEBSITE_RESOURCE_GROUP"] = "prod-rg" + os.environ["SW_APM_SERVICE_KEY"] = "token:should-be-ignored" + super().setUp() + + def tearDown(self): + os.environ.pop("WEBSITE_SITE_NAME", None) + os.environ.pop("WEBSITE_RESOURCE_GROUP", None) + os.environ.pop("SW_APM_SERVICE_KEY", None) + super().tearDown() + + def test_azure_detector_over_sw_key(self): + timestamp = int(time.time()) + with mock.patch( + target="solarwinds_apm.oboe.json_sampler.JsonSampler._read", + return_value=[ + { + "arguments": { + "BucketCapacity": 2, + "BucketRate": 1, + "MetricsFlushInterval": 60, + "SignatureKey": "", + "TriggerRelaxedBucketCapacity": 4, + "TriggerRelaxedBucketRate": 3, + "TriggerStrictBucketCapacity": 6, + "TriggerStrictBucketRate": 5, + }, + "flags": "SAMPLE_START,SAMPLE_THROUGH_ALWAYS,SAMPLE_BUCKET_ENABLED,TRIGGER_TRACE", + "layer": "", + "timestamp": timestamp, + "ttl": 120, + "type": 0, + "value": 1000000, + } + ], + ): + resp = self.client.get("/test_trace/") + assert resp.status_code == 200 + spans = self.memory_exporter.get_finished_spans() + assert len(spans) > 0 + + resource_attrs = spans[0].resource.attributes + # Verify Azure detector set service.name (overriding base class SW_APM_SERVICE_KEY="foo:bar") + assert resource_attrs["service.name"] == "azure-production-app" + # Verify basic Azure cloud attributes are set + assert resource_attrs["cloud.provider"] == "azure" + assert resource_attrs["cloud.platform"] == "azure_app_service" + + +class TestServiceNamePrecedence4SwKeyFallback(TestBaseSwHeadersAndAttributes): + + def setUp(self): + # Base class will set SW_APM_SERVICE_KEY="foo:bar" + super().setUp() + + def test_sw_key_fallback(self): + timestamp = int(time.time()) + with mock.patch( + target="solarwinds_apm.oboe.json_sampler.JsonSampler._read", + return_value=[ + { + "arguments": { + "BucketCapacity": 2, + "BucketRate": 1, + "MetricsFlushInterval": 60, + "SignatureKey": "", + "TriggerRelaxedBucketCapacity": 4, + "TriggerRelaxedBucketRate": 3, + "TriggerStrictBucketCapacity": 6, + "TriggerStrictBucketRate": 5, + }, + "flags": "SAMPLE_START,SAMPLE_THROUGH_ALWAYS,SAMPLE_BUCKET_ENABLED,TRIGGER_TRACE", + "layer": "", + "timestamp": timestamp, + "ttl": 120, + "type": 0, + "value": 1000000, + } + ], + ): + resp = self.client.get("/test_trace/") + assert resp.status_code == 200 + spans = self.memory_exporter.get_finished_spans() + assert len(spans) > 0 + + resource_attrs = spans[0].resource.attributes + # Base class sets SW_APM_SERVICE_KEY="foo:bar", so service.name should be "bar" + assert resource_attrs["service.name"] == "bar" + # Verify process/host detector attributes are present + assert "process.pid" in resource_attrs + assert isinstance(resource_attrs["process.pid"], int) + assert "host.name" in resource_attrs + assert isinstance(resource_attrs["host.name"], str) + + +class TestServiceNameAzureDetectorFullEnvironment(TestBaseSwHeadersAndAttributes): + + def setUp(self): + os.environ["WEBSITE_SITE_NAME"] = "my-azure-app" + os.environ["WEBSITE_RESOURCE_GROUP"] = "production-rg" + os.environ["WEBSITE_OWNER_NAME"] = "subscription-id+webspace-name" + os.environ["WEBSITE_INSTANCE_ID"] = "abc123def456" + os.environ["WEBSITE_SLOT_NAME"] = "staging" + os.environ["REGION_NAME"] = "eastus" + os.environ["SW_APM_SERVICE_KEY"] = "token:ignored" + super().setUp() + + def tearDown(self): + os.environ.pop("WEBSITE_SITE_NAME", None) + os.environ.pop("WEBSITE_RESOURCE_GROUP", None) + os.environ.pop("WEBSITE_OWNER_NAME", None) + os.environ.pop("WEBSITE_INSTANCE_ID", None) + os.environ.pop("WEBSITE_SLOT_NAME", None) + os.environ.pop("REGION_NAME", None) + os.environ.pop("SW_APM_SERVICE_KEY", None) + super().tearDown() + + def test_azure_detector_full_environment(self): + timestamp = int(time.time()) + with mock.patch( + target="solarwinds_apm.oboe.json_sampler.JsonSampler._read", + return_value=[ + { + "arguments": { + "BucketCapacity": 2, + "BucketRate": 1, + "MetricsFlushInterval": 60, + "SignatureKey": "", + "TriggerRelaxedBucketCapacity": 4, + "TriggerRelaxedBucketRate": 3, + "TriggerStrictBucketCapacity": 6, + "TriggerStrictBucketRate": 5, + }, + "flags": "SAMPLE_START,SAMPLE_THROUGH_ALWAYS,SAMPLE_BUCKET_ENABLED,TRIGGER_TRACE", + "layer": "", + "timestamp": timestamp, + "ttl": 120, + "type": 0, + "value": 1000000, + } + ], + ): + resp = self.client.get("/test_trace/") + assert resp.status_code == 200 + spans = self.memory_exporter.get_finished_spans() + assert len(spans) > 0 + + resource_attrs = spans[0].resource.attributes + # Verify Azure detector set service.name from WEBSITE_SITE_NAME + assert resource_attrs["service.name"] == "my-azure-app" + # Verify core Azure cloud attributes are set + assert resource_attrs["cloud.provider"] == "azure" + assert resource_attrs["cloud.platform"] == "azure_app_service" + # Verify process/host detector attributes also present + assert "process.pid" in resource_attrs + assert "host.name" in resource_attrs + # Verify SW attributes are always present + assert "sw.apm.version" in resource_attrs + assert "sw.data.module" in resource_attrs + assert resource_attrs["sw.data.module"] == "apm" diff --git a/tests/unit/test_apm_config/test_apm_config_service_name.py b/tests/unit/test_apm_config/test_apm_config_service_name.py index 306480579..ffb183de2 100644 --- a/tests/unit/test_apm_config/test_apm_config_service_name.py +++ b/tests/unit/test_apm_config/test_apm_config_service_name.py @@ -19,13 +19,19 @@ def test__calculate_service_name_is_lambda(self, mocker): "solarwinds_apm.apm_config.SolarWindsApmConfig._calculate_service_name_apm_proto" ) mock_calc_lambda = mocker.patch( - "solarwinds_apm.apm_config.SolarWindsApmConfig._calculate_service_name_lambda" + "solarwinds_apm.apm_config.SolarWindsApmConfig._calculate_service_name_lambda", + return_value="test-service", ) mocker.patch( "solarwinds_apm.apm_config.SolarWindsApmConfig.calculate_is_lambda", return_value=True, ) - test_config = apm_config.SolarWindsApmConfig("foo") + mocker.patch( + "solarwinds_apm.apm_resource.create_apm_resource", + return_value=Resource({}), + ) + test_resource = Resource({}) + test_config = apm_config.SolarWindsApmConfig(test_resource) test_config._calculate_service_name( True, {}, @@ -34,14 +40,15 @@ def test__calculate_service_name_is_lambda(self, mocker): # called twice because of init, and we call again mock_calc_lambda.assert_has_calls( [ - mocker.call("foo"), + mocker.call(test_resource), mocker.call({}), ] ) def test__calculate_service_name_not_is_lambda(self, mocker): mock_calc_proto = mocker.patch( - "solarwinds_apm.apm_config.SolarWindsApmConfig._calculate_service_name_apm_proto" + "solarwinds_apm.apm_config.SolarWindsApmConfig._calculate_service_name_apm_proto", + return_value="test-service", ) mock_calc_lambda = mocker.patch( "solarwinds_apm.apm_config.SolarWindsApmConfig._calculate_service_name_lambda" @@ -50,7 +57,12 @@ def test__calculate_service_name_not_is_lambda(self, mocker): "solarwinds_apm.apm_config.SolarWindsApmConfig.calculate_is_lambda", return_value=False, ) - test_config = apm_config.SolarWindsApmConfig("foo") + mocker.patch( + "solarwinds_apm.apm_resource.create_apm_resource", + return_value=Resource({}), + ) + test_resource = Resource({}) + test_config = apm_config.SolarWindsApmConfig(test_resource) test_config._calculate_service_name( True, {}, @@ -58,7 +70,7 @@ def test__calculate_service_name_not_is_lambda(self, mocker): # called twice because of init, and we call again mock_calc_proto.assert_has_calls( [ - mocker.call(False, "foo"), + mocker.call(False, test_resource), mocker.call(True, {}), ] ) diff --git a/tests/unit/test_apm_resource.py b/tests/unit/test_apm_resource.py new file mode 100644 index 000000000..35f80ae2e --- /dev/null +++ b/tests/unit/test_apm_resource.py @@ -0,0 +1,190 @@ +# © 2026 SolarWinds Worldwide, LLC. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at:http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +from opentelemetry.sdk.resources import Resource + +from solarwinds_apm import apm_resource +from solarwinds_apm.version import __version__ + + +class TestCreateDetectorResource: + + def test_create_detector_resource_calls_resource_create(self, mocker): + mock_resource = Resource.create({"test.attr": "test-value"}) + mock_resource_create = mocker.patch( + "solarwinds_apm.apm_resource.Resource.create", + return_value=mock_resource, + ) + result = apm_resource.create_detector_resource() + mock_resource_create.assert_called_once_with() + assert result == mock_resource + + def test_create_detector_resource_returns_resource(self): + result = apm_resource.create_detector_resource() + assert isinstance(result, Resource) + assert hasattr(result, "attributes") + + def test_create_detector_resource_includes_detector_attributes(self, mocker): + detector_attrs = { + "process.pid": 12345, + "process.executable.name": "python", + "host.name": "test-host", + } + mock_resource = Resource.create(detector_attrs) + mocker.patch( + "solarwinds_apm.apm_resource.Resource.create", + return_value=mock_resource, + ) + result = apm_resource.create_detector_resource() + assert result.attributes["process.pid"] == 12345 + assert result.attributes["process.executable.name"] == "python" + assert result.attributes["host.name"] == "test-host" + + +class TestCreateApmResource: + + def test_create_apm_resource_adds_sw_attributes(self): + detector_resource = Resource.create({"host.name": "test-host"}) + service_name = "test-service" + result = apm_resource.create_apm_resource(detector_resource, service_name) + attrs = result.attributes + assert attrs["sw.apm.version"] == __version__ + assert attrs["sw.data.module"] == "apm" + assert attrs["service.name"] == service_name + + def test_create_apm_resource_preserves_detector_attributes(self): + detector_resource = Resource.create({ + "cloud.provider": "azure", + "cloud.resource_id": "/subscriptions/test/resourceGroups/test", + "host.name": "test-host", + "process.pid": 12345, + "k8s.namespace.name": "default", + "k8s.pod.name": "test-pod", + }) + service_name = "test-service" + result = apm_resource.create_apm_resource(detector_resource, service_name) + + attrs = result.attributes + # SW attributes present + assert attrs["sw.apm.version"] == __version__ + assert attrs["sw.data.module"] == "apm" + assert attrs["service.name"] == service_name + # All detector attributes preserved + assert attrs["cloud.provider"] == "azure" + assert attrs["cloud.resource_id"] == "/subscriptions/test/resourceGroups/test" + assert attrs["host.name"] == "test-host" + assert attrs["process.pid"] == 12345 + assert attrs["k8s.namespace.name"] == "default" + assert attrs["k8s.pod.name"] == "test-pod" + + def test_create_apm_resource_overrides_detector_service_name(self): + detector_resource = Resource.create({ + "service.name": "detector-service", + "host.name": "test-host", + }) + service_name = "override-service" + + result = apm_resource.create_apm_resource(detector_resource, service_name) + + attrs = result.attributes + # Service name should be overridden + assert attrs["service.name"] == "override-service" + # Other detector attributes preserved + assert attrs["host.name"] == "test-host" + + def test_create_apm_resource_generates_service_instance_id(self): + detector_resource = Resource.create({"host.name": "test-host"}) + service_name = "test-service" + + result = apm_resource.create_apm_resource(detector_resource, service_name) + + attrs = result.attributes + assert "service.instance.id" in attrs + # Should be a UUID string (36 chars with dashes) + instance_id = attrs["service.instance.id"] + assert isinstance(instance_id, str) + assert len(instance_id) == 36 + + def test_create_apm_resource_preserves_existing_service_instance_id(self): + existing_instance_id = "existing-instance-id-123" + detector_resource = Resource.create({ + "host.name": "test-host", + "service.instance.id": existing_instance_id, + }) + service_name = "test-service" + result = apm_resource.create_apm_resource(detector_resource, service_name) + attrs = result.attributes + assert attrs["service.instance.id"] == existing_instance_id + + def test_create_apm_resource_with_empty_detector_resource(self): + detector_resource = Resource.create() + service_name = "test-service" + result = apm_resource.create_apm_resource(detector_resource, service_name) + attrs = result.attributes + assert attrs["sw.apm.version"] == __version__ + assert attrs["sw.data.module"] == "apm" + assert attrs["service.name"] == service_name + assert "service.instance.id" in attrs + + def test_create_apm_resource_with_empty_service_name(self): + detector_resource = Resource.create({"host.name": "test-host"}) + service_name = "" + result = apm_resource.create_apm_resource(detector_resource, service_name) + attrs = result.attributes + assert attrs["service.name"] == "" + assert attrs["sw.apm.version"] == __version__ + assert attrs["sw.data.module"] == "apm" + + def test_create_apm_resource_with_azure_detector_attributes(self): + detector_resource = Resource.create({ + "cloud.provider": "azure", + "cloud.platform": "azure_app_service", + "cloud.resource_id": "/subscriptions/sub-id/resourceGroups/rg-name/providers/Microsoft.Web/sites/app-name", + "service.name": "app-name", + "service.instance.id": "instance-123", + "host.id": "host-id-123", + }) + service_name = "azure-app-service" + result = apm_resource.create_apm_resource(detector_resource, service_name) + attrs = result.attributes + # SW attributes + assert attrs["sw.apm.version"] == __version__ + assert attrs["sw.data.module"] == "apm" + assert attrs["service.name"] == "azure-app-service" + # Azure detector attributes preserved + assert attrs["cloud.provider"] == "azure" + assert attrs["cloud.platform"] == "azure_app_service" + assert attrs["cloud.resource_id"] == "/subscriptions/sub-id/resourceGroups/rg-name/providers/Microsoft.Web/sites/app-name" + assert attrs["host.id"] == "host-id-123" + # Existing service.instance.id preserved + assert attrs["service.instance.id"] == "instance-123" + + def test_create_apm_resource_with_k8s_detector_attributes(self): + detector_resource = Resource.create({ + "k8s.cluster.name": "test-cluster", + "k8s.namespace.name": "default", + "k8s.pod.name": "test-pod-12345", + "k8s.pod.uid": "pod-uid-12345", + "k8s.deployment.name": "test-deployment", + "k8s.node.name": "node-1", + "container.id": "container-id-12345", + "container.name": "test-container", + }) + service_name = "k8s-service" + result = apm_resource.create_apm_resource(detector_resource, service_name) + + attrs = result.attributes + # SW attributes + assert attrs["service.name"] == "k8s-service" + # K8s detector attributes preserved + assert attrs["k8s.cluster.name"] == "test-cluster" + assert attrs["k8s.namespace.name"] == "default" + assert attrs["k8s.pod.name"] == "test-pod-12345" + assert attrs["k8s.pod.uid"] == "pod-uid-12345" + assert attrs["k8s.deployment.name"] == "test-deployment" + assert attrs["k8s.node.name"] == "node-1" + assert attrs["container.id"] == "container-id-12345" + assert attrs["container.name"] == "test-container" diff --git a/tests/unit/test_configurator/test_configurator_configure_otel.py b/tests/unit/test_configurator/test_configurator_configure_otel.py index 4280a5a02..507565eb8 100644 --- a/tests/unit/test_configurator/test_configurator_configure_otel.py +++ b/tests/unit/test_configurator/test_configurator_configure_otel.py @@ -6,9 +6,7 @@ import logging import pytest -import uuid -from opentelemetry.sdk.resources import Resource from solarwinds_apm import configurator @@ -19,81 +17,6 @@ def setup_caplog(): apm_logger.propagate = True -class TestConfiguratorCreateApmResource: - def test_create_apm_resource_basic(self, mocker, mock_apmconfig_enabled): - mocker.patch( - "solarwinds_apm.configurator.__version__", - new="1.2.3", - ) - test_configurator = configurator.SolarWindsConfigurator() - test_configurator.apm_config = mock_apmconfig_enabled - result = test_configurator._create_apm_resource() - - assert result.attributes["sw.apm.version"] == "1.2.3" - assert result.attributes["sw.data.module"] == "apm" - assert result.attributes["service.name"] == "foo-service" - assert "service.instance.id" in result.attributes - instance_id = result.attributes["service.instance.id"] - assert isinstance(instance_id, str) - # Should not raise ValueError if valid UUID - uuid.UUID(instance_id) - - def test_create_apm_resource_with_existing_instance_id_use_existing(self, mocker, mock_apmconfig_enabled): - mocker.patch( - "solarwinds_apm.configurator.__version__", - new="1.2.3", - ) - existing_instance_id = "existing-instance-id" - # Mock return of Resource.create like a ResourceDetector set service.instance.id - mock_initial_resource = Resource.create({ - "sw.apm.version": "1.2.3", - "sw.data.module": "apm", - "service.name": "foo-service", - "service.instance.id": existing_instance_id, - }) - mock_resource_create = mocker.patch( - "solarwinds_apm.configurator.Resource.create", - return_value=mock_initial_resource, - ) - test_configurator = configurator.SolarWindsConfigurator() - test_configurator.apm_config = mock_apmconfig_enabled - result = test_configurator._create_apm_resource() - - assert result.attributes["service.instance.id"] == existing_instance_id - mock_resource_create.assert_called_once_with({ - "sw.apm.version": "1.2.3", - "sw.data.module": "apm", - "service.name": "foo-service", - }) - - def test_create_apm_resource_without_existing_instance_id_generate_new(self, mocker, mock_apmconfig_enabled): - mocker.patch( - "solarwinds_apm.configurator.__version__", - new="1.2.3", - ) - mock_uuid = "test-uuid-value" - mock_uuid4 = mocker.patch( - "solarwinds_apm.configurator.uuid.uuid4", - return_value=mock_uuid, - ) - # Mock return of Resource.create like no resource detectors set instance ID - mock_initial_resource = Resource.create({ - "sw.apm.version": "1.2.3", - "sw.data.module": "apm", - "service.name": "foo-service", - }) - mocker.patch( - "solarwinds_apm.configurator.Resource.create", - return_value=mock_initial_resource, - ) - test_configurator = configurator.SolarWindsConfigurator() - test_configurator.apm_config = mock_apmconfig_enabled - result = test_configurator._create_apm_resource() - - assert result.attributes["service.instance.id"] == str(mock_uuid) - mock_uuid4.assert_called_once() - - class TestConfiguratorConfigureOtelComponents: def test_configure_otel_components_agent_enabled( self, @@ -108,6 +31,13 @@ def test_configure_otel_components_agent_enabled( mock_config_propagator, mock_config_response_propagator, ): + mock_resource = mocker.Mock() + mock_apmconfig_enabled.resource = mock_resource + mock_detector_resource = mocker.Mock() + mocker.patch( + "solarwinds_apm.apm_resource.create_detector_resource", + return_value=mock_detector_resource, + ) mocker.patch( "solarwinds_apm.configurator.SolarWindsApmConfig", return_value=mock_apmconfig_enabled, @@ -117,12 +47,6 @@ def test_configure_otel_components_agent_enabled( "solarwinds_apm.configurator.ParentBasedSwSampler", return_value=mock_apm_sampler, ) - mock_resource = mocker.Mock() - mocker.patch.object( - configurator.SolarWindsConfigurator, - "_create_apm_resource", - return_value=mock_resource, - ) test_configurator = configurator.SolarWindsConfigurator() test_configurator._configure() @@ -161,17 +85,16 @@ def test_configure_otel_components_agent_disabled( mock_config_propagator, mock_config_response_propagator, ): + mock_detector_resource = mocker.Mock() + mocker.patch( + "solarwinds_apm.apm_resource.create_detector_resource", + return_value=mock_detector_resource, + ) mock_apm_sampler = mocker.Mock() mocker.patch( "solarwinds_apm.configurator.ParentBasedSwSampler", return_value=mock_apm_sampler, ) - mock_resource = mocker.Mock() - mocker.patch.object( - configurator.SolarWindsConfigurator, - "_create_apm_resource", - return_value=mock_resource, - ) mocker.patch( "solarwinds_apm.configurator.SolarWindsApmConfig", return_value=mock_apmconfig_disabled, @@ -180,7 +103,6 @@ def test_configure_otel_components_agent_disabled( test_configurator._configure() mock_apm_sampler.assert_not_called() - mock_resource.assert_not_called() mock_config_serviceentry_processor.assert_not_called() mock_response_time_processor.assert_not_called() mock_custom_init_tracing.assert_not_called()