Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.51.1"
version = "0.51.2"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
34 changes: 34 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
)
from opentelemetry.sdk.resources import Resource

# Stable reference for isinstance checks — not overwritten when tests patch MeterProvider
_SDKMeterProvider = MeterProvider

from sap_cloud_sdk.core.telemetry.config import (
get_config,
create_resource_attributes_from_env,
Expand All @@ -48,6 +51,22 @@
logger = logging.getLogger(__name__)


def _merge_sdk_resource_into_meter_provider(
provider: MeterProvider, sdk_resource: Resource
) -> None:
"""Merge SDK resource attrs into an already-installed MeterProvider.

No lock needed (unlike the log provider): meters don't cache a resource, so
there's no per-instance collection to iterate. collect() reads
_sdk_config.resource live at export time via the same shared reference we
mutate here, so this single reassignment propagates.
"""
provider._sdk_config.resource = provider._sdk_config.resource.merge(sdk_resource)
logger.info(
"Merged sap-cloud-sdk resource attrs onto wrapper-installed MeterProvider"
)


def _merge_sdk_resource_into_log_provider(
provider: LoggerProvider, sdk_resource: Resource
) -> None:
Expand Down Expand Up @@ -214,6 +233,21 @@ def _setup_meter_provider() -> Optional[MeterProvider]:

try:
resource = Resource.create(create_resource_attributes_from_env())
existing = cast(MeterProvider, metrics.get_meter_provider())

if isinstance(existing, _SDKMeterProvider):
logger.warning(
"Global MeterProvider was already set by another library. "
"Merging sap.cloud_sdk.* resource attributes into the existing provider."
)
_merge_sdk_resource_into_meter_provider(existing, resource)
logger.info(
f"OpenTelemetry meter provider merged. "
f"Service: {config.service_name}, "
f"Endpoint: {config.otlp_endpoint}"
)
return existing

exporter = _create_metric_exporter()
reader = PeriodicExportingMetricReader(exporter=exporter)
provider = MeterProvider(resource=resource, metric_readers=[reader])
Expand Down
97 changes: 85 additions & 12 deletions tests/core/unit/telemetry/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
shutdown,
_setup_meter_provider,
_create_metric_exporter,
_merge_sdk_resource_into_meter_provider,
setup_log_provider,
_create_log_exporter,
_merge_sdk_resource_into_log_provider,
Expand Down Expand Up @@ -173,32 +174,66 @@ def test_delegates_to_create_metric_exporter(self):
mock_exporter = MagicMock()
with patch("sap_cloud_sdk.core.telemetry._provider.get_config", return_value=_ENABLED_CONFIG):
with patch("sap_cloud_sdk.core.telemetry._provider.Resource"):
with patch("sap_cloud_sdk.core.telemetry._provider._create_metric_exporter", return_value=mock_exporter) as mock_create:
with patch("sap_cloud_sdk.core.telemetry._provider.PeriodicExportingMetricReader") as mock_reader:
with patch("sap_cloud_sdk.core.telemetry._provider.MeterProvider"):
with patch("opentelemetry.metrics.set_meter_provider"):
with patch("sap_cloud_sdk.core.telemetry._provider.metrics") as mock_metrics:
mock_metrics.get_meter_provider.return_value = MagicMock()
with patch("sap_cloud_sdk.core.telemetry._provider._create_metric_exporter", return_value=mock_exporter) as mock_create:
with patch("sap_cloud_sdk.core.telemetry._provider.PeriodicExportingMetricReader") as mock_reader:
with patch("sap_cloud_sdk.core.telemetry._provider.MeterProvider"):
_setup_meter_provider()

mock_create.assert_called_once_with()
mock_reader.assert_called_once_with(exporter=mock_exporter)
mock_create.assert_called_once_with()
mock_reader.assert_called_once_with(exporter=mock_exporter)

def test_unsupported_protocol_returns_none(self):
with patch("sap_cloud_sdk.core.telemetry._provider.get_config", return_value=_ENABLED_CONFIG):
with patch("sap_cloud_sdk.core.telemetry._provider.Resource"):
with patch.dict("os.environ", {"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"}):
assert _setup_meter_provider() is None
with patch("sap_cloud_sdk.core.telemetry._provider.metrics") as mock_metrics:
mock_metrics.get_meter_provider.return_value = MagicMock()
with patch.dict("os.environ", {"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"}):
assert _setup_meter_provider() is None

def test_returns_configured_provider(self):
mock_provider = MagicMock()
with patch("sap_cloud_sdk.core.telemetry._provider.get_config", return_value=_ENABLED_CONFIG):
with patch("sap_cloud_sdk.core.telemetry._provider.Resource"):
with patch("sap_cloud_sdk.core.telemetry._provider._create_metric_exporter"):
with patch("sap_cloud_sdk.core.telemetry._provider.PeriodicExportingMetricReader"):
with patch("sap_cloud_sdk.core.telemetry._provider.MeterProvider", return_value=mock_provider):
with patch("opentelemetry.metrics.set_meter_provider"):
with patch("sap_cloud_sdk.core.telemetry._provider.metrics") as mock_metrics:
mock_metrics.get_meter_provider.return_value = MagicMock()
with patch("sap_cloud_sdk.core.telemetry._provider._create_metric_exporter"):
with patch("sap_cloud_sdk.core.telemetry._provider.PeriodicExportingMetricReader"):
with patch("sap_cloud_sdk.core.telemetry._provider.MeterProvider", return_value=mock_provider):
assert _setup_meter_provider() is mock_provider


def test_reuses_existing_sdk_meter_provider(self):
"""When a MeterProvider is already set, merge into it instead of creating a new one."""
from opentelemetry.sdk.metrics import MeterProvider as _MP
existing = MagicMock(spec=_MP)
with patch("sap_cloud_sdk.core.telemetry._provider.get_config", return_value=_ENABLED_CONFIG):
with patch("sap_cloud_sdk.core.telemetry._provider.Resource"):
with patch("sap_cloud_sdk.core.telemetry._provider.metrics") as mock_metrics:
mock_metrics.get_meter_provider.return_value = existing
with patch("sap_cloud_sdk.core.telemetry._provider._merge_sdk_resource_into_meter_provider") as mock_merge:
with patch("sap_cloud_sdk.core.telemetry._provider._SDKMeterProvider", _MP):
result = _setup_meter_provider()
assert result is existing
mock_merge.assert_called_once()
mock_metrics.set_meter_provider.assert_not_called()

def test_existing_provider_no_new_reader(self):
"""Reuse path must not create a new PeriodicExportingMetricReader."""
from opentelemetry.sdk.metrics import MeterProvider as _MP
existing = MagicMock(spec=_MP)
with patch("sap_cloud_sdk.core.telemetry._provider.get_config", return_value=_ENABLED_CONFIG):
with patch("sap_cloud_sdk.core.telemetry._provider.Resource"):
with patch("sap_cloud_sdk.core.telemetry._provider.metrics") as mock_metrics:
mock_metrics.get_meter_provider.return_value = existing
with patch("sap_cloud_sdk.core.telemetry._provider._merge_sdk_resource_into_meter_provider"):
with patch("sap_cloud_sdk.core.telemetry._provider._SDKMeterProvider", _MP):
with patch("sap_cloud_sdk.core.telemetry._provider.PeriodicExportingMetricReader") as mock_reader:
_setup_meter_provider()
mock_reader.assert_not_called()


_LOGGING_HANDLER = "sap_cloud_sdk.core.telemetry._provider.LoggingHandler"
_GRPC_LOG_EXPORTER = "sap_cloud_sdk.core.telemetry._provider.GRPCLogExporter"
_HTTP_LOG_EXPORTER = "sap_cloud_sdk.core.telemetry._provider.HTTPLogExporter"
Expand Down Expand Up @@ -330,6 +365,44 @@ def test_platform_path_adds_handler_when_none_present(self):
mock_handler_cls.assert_called_once_with(logger_provider=external)


class TestMergeSdkResourceIntoMeterProvider:
def test_updates_sdk_config_resource(self):
from opentelemetry.sdk.metrics import MeterProvider as _MP
from opentelemetry.sdk.resources import Resource as _R

sdk_resource = _R({"sap.cloud_sdk.language": "python"})
existing_resource = _R({"service.name": "svc"})
provider = _MP(resource=existing_resource)

_merge_sdk_resource_into_meter_provider(provider, sdk_resource)

assert provider._sdk_config.resource.attributes["sap.cloud_sdk.language"] == "python"
assert provider._sdk_config.resource.attributes["service.name"] == "svc"

def test_measurement_consumer_sees_update(self):
"""_measurement_consumer shares the same SdkConfiguration object."""
from opentelemetry.sdk.metrics import MeterProvider as _MP
from opentelemetry.sdk.resources import Resource as _R

sdk_resource = _R({"sap.cloud_sdk.language": "python"})
provider = _MP(resource=_R({"service.name": "svc"}))

_merge_sdk_resource_into_meter_provider(provider, sdk_resource)

assert provider._measurement_consumer._sdk_config.resource is provider._sdk_config.resource

def test_sdk_attrs_win_on_collision(self):
from opentelemetry.sdk.metrics import MeterProvider as _MP
from opentelemetry.sdk.resources import Resource as _R

sdk_resource = _R({"service.name": "sdk-name"})
provider = _MP(resource=_R({"service.name": "platform-name"}))

_merge_sdk_resource_into_meter_provider(provider, sdk_resource)

assert provider._sdk_config.resource.attributes["service.name"] == "sdk-name"


class TestMergeSdkResourceIntoLogProvider:
def test_updates_provider_resource(self):
from opentelemetry.sdk._logs import LoggerProvider as _LP
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading