Skip to content
Merged
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.11.6"
version = "0.11.7"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
44 changes: 39 additions & 5 deletions src/sap_cloud_sdk/core/telemetry/auto_instrument.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import logging
import os
from collections.abc import Mapping

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter as GRPCSpanExporter,
)
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
OTLPSpanExporter as HTTPSpanExporter,
)
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.processor.baggage import ALLOW_ALL_BAGGAGE_KEYS, BaggageSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SpanExporter
from traceloop.sdk import Traceloop

from sap_cloud_sdk.core.telemetry import Module, Operation
from sap_cloud_sdk.core.telemetry.config import (
create_resource_attributes_from_env,
_get_app_name,
ENV_OTLP_ENDPOINT,
ENV_TRACES_EXPORTER,
ENV_OTLP_PROTOCOL,
ENV_TRACES_EXPORTER,
_get_app_name,
create_resource_attributes_from_env,
)
from sap_cloud_sdk.core.telemetry.genai_attribute_transformer import (
GenAIAttributeTransformer,
Expand Down Expand Up @@ -62,6 +64,8 @@ def auto_instrument(disable_batch: bool = False):
disable_batch=disable_batch,
)

_merge_resource_attrs_into_active_provider_if_wrapper_installed(resource)

_set_baggage_processor()

logger.info("Cloud auto instrumentation initialized successfully")
Expand Down Expand Up @@ -96,3 +100,33 @@ def _set_baggage_processor():

provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
logger.info("Registered BaggageSpanProcessor for extension attribute propagation")


def _merge_resource_attrs_into_active_provider_if_wrapper_installed(
sap_attrs: dict,
) -> None:
"""Merge sap-cloud-sdk resource attrs onto the active TracerProvider's
Resource when an OTel auto-instrumentation wrapper has pre-installed it.

Resource.merge direction puts ``sap_attrs`` on the right, so colliding
keys (e.g. ``service.name``) are won by the sap-cloud-sdk side (e.g. the
APPFND_CONHOS_APP_NAME-derived value beats the operator's
k8s-deployment-derived default).

Mutates ``provider._resource`` because OTel SDK exposes no public API
to swap a TracerProvider's Resource post-construction.
"""
provider = trace.get_tracer_provider()
if not isinstance(provider, TracerProvider):
return
existing_attrs = getattr(provider.resource, "attributes", None)
if not isinstance(existing_attrs, Mapping):
return
if "telemetry.auto.version" not in existing_attrs:
return

provider._resource = provider.resource.merge(Resource.create(sap_attrs))
logger.info(
"Merged sap-cloud-sdk resource attrs onto wrapper-installed "
"TracerProvider (marker: telemetry.auto.version)"
)
83 changes: 83 additions & 0 deletions tests/core/unit/telemetry/test_auto_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from unittest.mock import patch, MagicMock, create_autospec
from contextlib import ExitStack

from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider

from sap_cloud_sdk.core.telemetry.auto_instrument import auto_instrument
Expand Down Expand Up @@ -232,3 +233,85 @@ def test_auto_instrument_passes_baggage_span_processor(self, mock_traceloop_comp

mock_traceloop_components['baggage_processor'].assert_called_once()
mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.assert_called_once_with(mock_processor_instance)

def test_auto_instrument_merges_resource_when_wrapper_installed(self, mock_traceloop_components):
"""When an OTel auto-instrumentation wrapper (e.g. an OpenTelemetry-Operator
init-container injection) has pre-installed a TracerProvider whose Resource
carries the standard `telemetry.auto.version` marker, auto_instrument merges
sap-cloud-sdk attrs onto that provider's existing Resource — preserving the
operator-supplied attrs and adding our SAP enrichment on top."""
mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app'
sap_attrs = {
'service.name': 'cloud-sdk-app',
'sap.cloud_sdk.name': 'SAP Cloud SDK for Python',
'sap.cloud_sdk.language': 'python',
'sap.solution_area': 'fina',
'mlflow.experiment_id': '1635264705567712',
}
mock_traceloop_components['create_resource'].return_value = sap_attrs

wrapper_provider = SDKTracerProvider(
resource=Resource.create({
'telemetry.auto.version': '0.62b1',
'k8s.deployment.name': 'cloud-sdk-app-deployment',
'service.name': 'cloud-sdk-app-deployment',
})
)
mock_traceloop_components['get_tracer_provider'].return_value = wrapper_provider

with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
auto_instrument()

merged_attrs = wrapper_provider.resource.attributes
# Operator-supplied attrs are preserved.
assert merged_attrs['telemetry.auto.version'] == '0.62b1'
assert merged_attrs['k8s.deployment.name'] == 'cloud-sdk-app-deployment'
# sap-cloud-sdk attrs are added.
assert merged_attrs['sap.cloud_sdk.name'] == 'SAP Cloud SDK for Python'
assert merged_attrs['sap.cloud_sdk.language'] == 'python'
assert merged_attrs['sap.solution_area'] == 'fina'
assert merged_attrs['mlflow.experiment_id'] == '1635264705567712'

def test_auto_instrument_skips_merge_when_no_wrapper_marker(self, mock_traceloop_components):
"""When the active TracerProvider's Resource lacks the
`telemetry.auto.version` marker (e.g. a self-installed provider, or no
wrapper at all), auto_instrument does not mutate the provider's Resource."""
mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app'
mock_traceloop_components['create_resource'].return_value = {
'sap.cloud_sdk.name': 'SAP Cloud SDK for Python',
'sap.solution_area': 'fina',
}

initial_resource = Resource.create({'service.name': 'self-installed'})
plain_provider = SDKTracerProvider(resource=initial_resource)
mock_traceloop_components['get_tracer_provider'].return_value = plain_provider

with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
auto_instrument()

# Resource is the original instance — no merge took place.
assert plain_provider.resource is initial_resource
assert 'sap.cloud_sdk.name' not in plain_provider.resource.attributes
assert 'sap.solution_area' not in plain_provider.resource.attributes

def test_auto_instrument_merge_overrides_colliding_service_name(self, mock_traceloop_components):
"""On a wrapper-installed provider, sap-cloud-sdk attrs override colliding
keys: service.name from APPFND_CONHOS_APP_NAME wins over the operator's
k8s-deployment-derived service.name."""
mock_traceloop_components['get_app_name'].return_value = 'cloud-sdk-app'
mock_traceloop_components['create_resource'].return_value = {
'service.name': 'cloud-sdk-app',
}

wrapper_provider = SDKTracerProvider(
resource=Resource.create({
'telemetry.auto.version': '0.62b1',
'service.name': 'operator-supplied-name',
})
)
mock_traceloop_components['get_tracer_provider'].return_value = wrapper_provider

with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
auto_instrument()

assert wrapper_provider.resource.attributes['service.name'] == 'cloud-sdk-app'
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