diff --git a/pyproject.toml b/pyproject.toml index 9baab7b9..55e8e297 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.43.2" +version = "0.44.0" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/src/sap_cloud_sdk/core/telemetry/_provider.py b/src/sap_cloud_sdk/core/telemetry/_provider.py index 420c3a46..05851ab1 100644 --- a/src/sap_cloud_sdk/core/telemetry/_provider.py +++ b/src/sap_cloud_sdk/core/telemetry/_provider.py @@ -1,16 +1,26 @@ -"""Internal module for setting up OpenTelemetry meter provider.""" +"""Internal module for setting up OpenTelemetry meter and logger providers.""" import logging import os -from typing import Optional +from typing import Optional, cast from opentelemetry import metrics +from opentelemetry._logs import get_logger_provider, set_logger_provider +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter as GRPCLogExporter, +) from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( OTLPMetricExporter as GRPCMetricExporter, ) +from opentelemetry.exporter.otlp.proto.http._log_exporter import ( + OTLPLogExporter as HTTPLogExporter, +) from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( OTLPMetricExporter as HTTPMetricExporter, ) +from opentelemetry.instrumentation.logging.handler import LoggingHandler +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.metrics import ( MeterProvider, Counter, @@ -36,10 +46,38 @@ logger = logging.getLogger(__name__) + +def _merge_sdk_resource_into_log_provider( + provider: LoggerProvider, sdk_resource: Resource +) -> None: + """OTel SDK has no public API to swap a LoggerProvider's Resource after construction.""" + provider._resource = provider.resource.merge(sdk_resource) + with provider._active_loggers_lock: + for logger_instance in provider._active_loggers: + logger_instance._resource = provider._resource + logger.info( + "Merged sap-cloud-sdk resource attrs onto wrapper-installed LoggerProvider" + ) + + +def _root_logger_has_otel_handler() -> bool: + # sitecustomize.py installs sdk._logs.LoggingHandler, not the instrumentation-layer one — check both. + try: + from opentelemetry.sdk._logs import LoggingHandler as _SDKLoggingHandler + + handler_types: tuple[type, ...] = (LoggingHandler, _SDKLoggingHandler) + except ImportError: + handler_types = (LoggingHandler,) + return any(isinstance(h, handler_types) for h in logging.getLogger().handlers) + + # Global meter provider _meter_provider: Optional[MeterProvider] = None _meter: Optional[metrics.Meter] = None +# Global logger provider +_log_provider: Optional[LoggerProvider] = None + def get_meter() -> metrics.Meter: """Get or create the global meter instance. @@ -76,6 +114,70 @@ def shutdown() -> None: _meter_provider = None +def setup_log_provider() -> Optional[LoggerProvider]: + """Set up the global OTel LoggerProvider using the shared resource attributes. + + Installs a LoggingHandler on the root stdlib logger so all existing + logging.getLogger(...) calls in the app flow through OTel automatically. + No-op when telemetry is disabled. + """ + global _log_provider + + if _log_provider is not None: + return _log_provider + + config = get_config() + if not config.enabled: + return None + + try: + resource = Resource.create(create_resource_attributes_from_env()) + existing = cast(LoggerProvider, get_logger_provider()) + + if isinstance(existing, LoggerProvider): + # Never add a second BatchLogRecordProcessor — the platform's provider + # already has one, and a second processor doubles every exported log record. + logger.warning( + "Global LoggerProvider was already set by another library. " + "Merging sap.cloud_sdk.* resource attributes into the existing provider." + ) + _merge_sdk_resource_into_log_provider(existing, resource) + if not _root_logger_has_otel_handler(): + logging.getLogger().addHandler(LoggingHandler(logger_provider=existing)) + _log_provider = existing + else: + provider = LoggerProvider(resource=resource) + provider.add_log_record_processor( + BatchLogRecordProcessor(_create_log_exporter()) + ) + set_logger_provider(provider) + logging.getLogger().addHandler(LoggingHandler(logger_provider=provider)) + _log_provider = provider + + logger.info( + f"OpenTelemetry log provider initialized. " + f"Service: {config.service_name}, " + f"Endpoint: {config.otlp_endpoint}" + ) + return _log_provider + + except Exception as e: + logger.error(f"Failed to initialize OpenTelemetry log provider: {e}") + return None + + +def _create_log_exporter(): + protocol = os.getenv(ENV_OTLP_PROTOCOL, "grpc").lower() + exporter_classes = {"grpc": GRPCLogExporter, "http/protobuf": HTTPLogExporter} + + if protocol not in exporter_classes: + raise ValueError( + f"Unsupported OTEL_EXPORTER_OTLP_PROTOCOL: '{protocol}'. " + "Supported values are 'grpc' and 'http/protobuf'." + ) + return exporter_classes[protocol]() + + def _create_metric_exporter(): protocol = os.getenv(ENV_OTLP_PROTOCOL, "grpc").lower() exporter_classes = {"grpc": GRPCMetricExporter, "http/protobuf": HTTPMetricExporter} diff --git a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py index 7bd434a8..14f1ec3e 100644 --- a/src/sap_cloud_sdk/core/telemetry/auto_instrument.py +++ b/src/sap_cloud_sdk/core/telemetry/auto_instrument.py @@ -20,6 +20,7 @@ from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SpanExporter from traceloop.sdk import Traceloop +from sap_cloud_sdk.core.telemetry._provider import setup_log_provider from sap_cloud_sdk.core.telemetry.module import Module from sap_cloud_sdk.core.telemetry.operation import Operation from sap_cloud_sdk.core.telemetry.config import ( @@ -97,6 +98,8 @@ def auto_instrument( _set_propagated_attributes_processor() _set_runtime_context_processor() + setup_log_provider() + if middlewares: _register_middleware_processors(middlewares) diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py index 72c93995..97d11827 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py @@ -1,4 +1,9 @@ +import logging as stdlib_logging + from opentelemetry.instrumentation.logging import LoggingInstrumentor +from opentelemetry.instrumentation.logging.handler import ( + LoggingHandler as _InstrumentationHandler, +) from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor from sap_cloud_sdk.core.telemetry.instrumentation._registry import register @@ -6,6 +11,19 @@ _instrumentor = LoggingInstrumentor() +def _has_otel_handler_on_root() -> bool: + # sitecustomize.py installs sdk._logs.LoggingHandler, not the instrumentation-layer one — check both. + try: + from opentelemetry.sdk._logs import LoggingHandler as _SDKHandler + + handler_types: tuple[type, ...] = (_InstrumentationHandler, _SDKHandler) + except ImportError: + handler_types = (_InstrumentationHandler,) + return any( + isinstance(h, handler_types) for h in stdlib_logging.getLogger().handlers + ) + + class LoggingInstrumentorWrapper(LibraryInstrumentor): """Injects trace_id and span_id into every stdlib log record for log-trace correlation.""" @@ -15,7 +33,11 @@ def is_instrumented(self) -> bool: return _instrumentor.is_instrumented_by_opentelemetry def _instrument(self, **kwargs) -> None: - _instrumentor.instrument(set_logging_format=True) + kwargs.setdefault("set_logging_format", True) + if _has_otel_handler_on_root(): + # Already have a handler — adding another duplicates every log record. + kwargs = {**kwargs, "enable_log_auto_instrumentation": False} + _instrumentor.instrument(**kwargs) def _uninstrument(self) -> None: _instrumentor.uninstrument() diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index c593920d..4af91c47 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -176,6 +176,54 @@ GenAIOperation.INVOKE_AGENT --- +## Logging + +`auto_instrument()` sets up OTel logs alongside traces and metrics. It installs a handler on the root stdlib logger so all existing `logging.getLogger(...)` calls in your app automatically ship log records to the OTel backend with the same resource attributes (service name, region, subaccount, etc.). + +No changes to your logging code are needed: + +```python +import logging + +logger = logging.getLogger(__name__) + +logger.info("Destination fetched") +logger.warning("Retrying request, attempt %d", attempt) +logger.error("Failed to connect", exc_info=True) +``` + +### Structured fields + +Use `extra={}` to attach structured attributes to a log record: + +```python +logger.info("Request completed", extra={"tenant_id": tid, "duration_ms": 120}) +``` + +### Log level filtering + +By default all levels (`DEBUG` and above) flow through OTel. To restrict what gets exported, set the level on the root logger or any specific logger: + +```python +# Only WARNING and above to OTel +logging.getLogger().setLevel(logging.WARNING) + +# Or scope it to your app's logger tree +logging.getLogger("my_app").setLevel(logging.INFO) +``` + +### Correlation with traces + +OTel logs emitted inside an active span are automatically correlated — the `trace_id` and `span_id` are injected into the log record. No extra work needed. + +### Third-party logging libraries + +The OTel handler is installed on the root stdlib `logging` logger. Any library that propagates to stdlib works automatically. + +Libraries that bypass stdlib entirely need a custom sink that forwards records to `logging.getLogger(...).log(...)`. The OTel handler then picks them up from there. + +--- + ## Adding attributes ### To the current span @@ -232,6 +280,7 @@ Propagation is scoped: once the parent span exits, its attributes stop propagati ## Complete example ```python +import logging from sap_cloud_sdk.core.telemetry import ( auto_instrument, invoke_agent_span, @@ -244,10 +293,13 @@ auto_instrument() from litellm import completion +logger = logging.getLogger(__name__) async def handle_request(query: str, user_id: str): set_tenant_id("bh7sjh...") + logger.info("Handling request", extra={"user_id": user_id}) + # Parent span carries business context for the whole agent turn. # Autoinstrumentation creates the child LLM span automatically. with invoke_agent_span( @@ -255,6 +307,7 @@ async def handle_request(query: str, user_id: str): ): documents = await retrieve_knowledge_base(query) add_span_attribute("documents.retrieved", len(documents)) + logger.debug("Retrieved %d documents", len(documents)) response = completion( model="gpt-4", @@ -345,7 +398,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.example.com" ### Transport protocol -Both traces and metrics use gRPC by default. Switch to HTTP/protobuf by setting: +Traces, metrics, and logs all use gRPC by default. Switch to HTTP/protobuf by setting: ```bash export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf" diff --git a/tests/core/unit/telemetry/test_auto_instrument.py b/tests/core/unit/telemetry/test_auto_instrument.py index a6d023bc..e95674b3 100644 --- a/tests/core/unit/telemetry/test_auto_instrument.py +++ b/tests/core/unit/telemetry/test_auto_instrument.py @@ -25,6 +25,7 @@ def mock_traceloop_components(): 'get_tracer_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=create_autospec(SDKTracerProvider))), 'create_resource': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env')), 'get_app_name': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name')), + 'setup_log_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.setup_log_provider')), } yield mocks @@ -405,3 +406,29 @@ def test_baggage_and_middleware_processors_both_added(self, mock_traceloop_compo # add_span_processor called 4 times: baggage, propagated attributes, runtime context, middleware assert mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.call_count == 4 + + +class TestAutoInstrumentLogging: + def test_setup_log_provider_called_on_instrument(self, mock_traceloop_components): + mock_traceloop_components['get_app_name'].return_value = 'test-app' + mock_traceloop_components['create_resource'].return_value = {} + + with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True): + auto_instrument() + + mock_traceloop_components['setup_log_provider'].assert_called_once() + + def test_setup_log_provider_not_called_when_no_endpoint(self): + with patch.dict('os.environ', {}, clear=True): + with patch('sap_cloud_sdk.core.telemetry.auto_instrument.setup_log_provider') as mock_log: + auto_instrument() + mock_log.assert_not_called() + + def test_setup_log_provider_called_with_console_exporter(self, mock_traceloop_components): + mock_traceloop_components['get_app_name'].return_value = 'test-app' + mock_traceloop_components['create_resource'].return_value = {} + + with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True): + auto_instrument() + + mock_traceloop_components['setup_log_provider'].assert_called_once() diff --git a/tests/core/unit/telemetry/test_log_provider_e2e.py b/tests/core/unit/telemetry/test_log_provider_e2e.py new file mode 100644 index 00000000..04a7ada4 --- /dev/null +++ b/tests/core/unit/telemetry/test_log_provider_e2e.py @@ -0,0 +1,203 @@ +"""End-to-end tests for OTel log provider using a real in-memory exporter. + +These tests exercise the full log pipeline — LoggingHandler → LoggerProvider → +processor → exporter — without mocking. They verify that logs emitted via the +standard stdlib logging API arrive with the correct resource attributes and, +when inside an active span, carry trace/span correlation IDs. +""" + +import logging +import pytest + +from opentelemetry import trace +from opentelemetry._logs import _internal as _logs_internal +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.instrumentation.logging.handler import LoggingHandler + +from sap_cloud_sdk.core.telemetry._provider import setup_log_provider +from sap_cloud_sdk.core.telemetry.config import InstrumentationConfig + + +@pytest.fixture() +def log_exporter(monkeypatch): + """Set up a real LoggerProvider backed by an in-memory exporter. + + Resets the OTel logger provider singleton before and after each test so + tests are fully isolated. Uses SimpleLogRecordProcessor so records flush + synchronously without needing a flush() call. + """ + # Reset the OTel logger provider singleton so set_logger_provider() works + _logs_internal._LOGGER_PROVIDER_SET_ONCE._done = False + _logs_internal._LOGGER_PROVIDER = None + + import sap_cloud_sdk.core.telemetry._provider as provider_module + provider_module._log_provider = None + + # Remove any stale LoggingHandlers left by previous tests + root = logging.getLogger() + for h in list(root.handlers): + if isinstance(h, LoggingHandler): + root.removeHandler(h) + + exporter = InMemoryLogRecordExporter() + + monkeypatch.setattr( + "sap_cloud_sdk.core.telemetry._provider._create_log_exporter", + lambda: exporter, + ) + monkeypatch.setattr( + "sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor", + SimpleLogRecordProcessor, + ) + monkeypatch.setattr( + "sap_cloud_sdk.core.telemetry._provider.get_config", + lambda: InstrumentationConfig( + enabled=True, + service_name="test-svc", + otlp_endpoint="http://localhost:4317", + ), + ) + monkeypatch.setenv("APPFND_CONHOS_APP_NAME", "test-svc") + monkeypatch.setenv("APPFND_CONHOS_REGION", "eu10") + monkeypatch.setenv("APPFND_CONHOS_SUBACCOUNTID", "sub-123") + monkeypatch.setenv("APPFND_CONHOS_SYSTEM_ROLE", "TEST") + monkeypatch.setenv("SAP_SOLUTION_AREA", "AFND") + + provider = setup_log_provider() + assert provider is not None + + root = logging.getLogger() + original_level = root.level + root.setLevel(logging.DEBUG) + + yield exporter + + root.setLevel(original_level) + for h in list(root.handlers): + if isinstance(h, LoggingHandler): + root.removeHandler(h) + + # Reset singleton again so the next test starts clean + _logs_internal._LOGGER_PROVIDER_SET_ONCE._done = False + _logs_internal._LOGGER_PROVIDER = None + provider_module._log_provider = None + + +@pytest.fixture() +def tracer(): + provider = TracerProvider() + trace.set_tracer_provider(provider) + return provider.get_tracer("test") + + +class TestLogProviderEndToEnd: + def test_log_record_reaches_exporter(self, log_exporter): + logging.getLogger("test.basic").warning("hello from sdk") + + records = log_exporter.get_finished_logs() + assert len(records) == 1 + assert records[0].log_record.body == "hello from sdk" + + def test_severity_mapped_correctly(self, log_exporter): + logger = logging.getLogger("test.severity") + logger.info("info msg") + logger.warning("warn msg") + logger.error("error msg") + + records = log_exporter.get_finished_logs() + severities = [r.log_record.severity_text for r in records] + assert severities == ["INFO", "WARN", "ERROR"] + + def test_resource_attributes_on_record(self, log_exporter): + logging.getLogger("test.resource").info("check resource") + + r = log_exporter.get_finished_logs()[0] + attrs = r.resource.attributes + assert attrs.get("service.name") == "test-svc" + assert attrs.get("sap.cloud_sdk.language") == "python" + assert attrs.get("cloud.region") == "eu10" + assert attrs.get("sap.cloud.provider.subaccount_id") == "sub-123" + + def test_extra_fields_become_log_attributes(self, log_exporter): + logging.getLogger("test.extra").warning( + "structured log", extra={"tenant_id": "t-abc", "duration_ms": 42} + ) + + r = log_exporter.get_finished_logs()[0] + assert r.log_record.attributes.get("tenant_id") == "t-abc" + assert r.log_record.attributes.get("duration_ms") == 42 + + def test_trace_correlation_inside_span(self, log_exporter, tracer): + with tracer.start_as_current_span("test-span") as span: + logging.getLogger("test.trace").info("inside span") + expected_trace_id = span.get_span_context().trace_id + expected_span_id = span.get_span_context().span_id + + r = log_exporter.get_finished_logs()[0] + assert r.log_record.trace_id == expected_trace_id + assert r.log_record.span_id == expected_span_id + + def test_no_trace_correlation_outside_span(self, log_exporter): + logging.getLogger("test.no_trace").info("outside span") + + r = log_exporter.get_finished_logs()[0] + assert r.log_record.trace_id == 0 + assert r.log_record.span_id == 0 + + +class TestLogProviderClashingProvider: + """When the platform pre-claims the global LoggerProvider, we merge our resource + attributes into it and leave its processor chain intact.""" + + def test_logs_flow_through_existing_provider_with_sdk_resource(self, monkeypatch): + from opentelemetry._logs import _internal as _logs_internal + from opentelemetry.sdk._logs.export import InMemoryLogRecordExporter, SimpleLogRecordProcessor + import sap_cloud_sdk.core.telemetry._provider as provider_module + + _logs_internal._LOGGER_PROVIDER_SET_ONCE._done = False + _logs_internal._LOGGER_PROVIDER = None + provider_module._log_provider = None + + root = logging.getLogger() + for h in list(root.handlers): + if isinstance(h, LoggingHandler): + root.removeHandler(h) + + external_exporter = InMemoryLogRecordExporter() + external_provider = LoggerProvider() + external_provider.add_log_record_processor(SimpleLogRecordProcessor(external_exporter)) + from opentelemetry._logs import set_logger_provider + set_logger_provider(external_provider) + + monkeypatch.setattr( + "sap_cloud_sdk.core.telemetry._provider.get_config", + lambda: __import__( + "sap_cloud_sdk.core.telemetry.config", fromlist=["InstrumentationConfig"] + ).InstrumentationConfig(enabled=True, service_name="test-svc", otlp_endpoint="http://localhost:4317"), + ) + monkeypatch.setenv("APPFND_CONHOS_APP_NAME", "test-svc") + monkeypatch.setenv("APPFND_CONHOS_REGION", "eu10") + monkeypatch.setenv("APPFND_CONHOS_SUBACCOUNTID", "sub-123") + monkeypatch.setenv("APPFND_CONHOS_SYSTEM_ROLE", "TEST") + monkeypatch.setenv("SAP_SOLUTION_AREA", "AFND") + + provider = setup_log_provider() + assert provider is external_provider + + root.setLevel(logging.DEBUG) + logging.getLogger("test.clash").warning("hello from sdk") + + records = external_exporter.get_finished_logs() + assert len(records) == 1 + assert records[0].log_record.body == "hello from sdk" + # Resource merge must have happened — sap.cloud_sdk.* attrs prove it + assert records[0].resource.attributes.get("sap.cloud_sdk.language") == "python" + + for h in list(root.handlers): + if isinstance(h, LoggingHandler): + root.removeHandler(h) + _logs_internal._LOGGER_PROVIDER_SET_ONCE._done = False + _logs_internal._LOGGER_PROVIDER = None + provider_module._log_provider = None diff --git a/tests/core/unit/telemetry/test_provider.py b/tests/core/unit/telemetry/test_provider.py index 409e8880..7d0d49af 100644 --- a/tests/core/unit/telemetry/test_provider.py +++ b/tests/core/unit/telemetry/test_provider.py @@ -1,6 +1,8 @@ """Tests for telemetry meter provider.""" -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, call +import logging +import pytest from opentelemetry.sdk.metrics import ( Counter, @@ -12,11 +14,17 @@ ) from opentelemetry.sdk.metrics.export import AggregationTemporality +from opentelemetry.sdk.resources import Resource + from sap_cloud_sdk.core.telemetry._provider import ( get_meter, shutdown, _setup_meter_provider, _create_metric_exporter, + setup_log_provider, + _create_log_exporter, + _merge_sdk_resource_into_log_provider, + _root_logger_has_otel_handler, ) from sap_cloud_sdk.core.telemetry.config import InstrumentationConfig @@ -187,3 +195,207 @@ def test_returns_configured_provider(self): with patch("sap_cloud_sdk.core.telemetry._provider.MeterProvider", return_value=mock_provider): with patch("opentelemetry.metrics.set_meter_provider"): assert _setup_meter_provider() is mock_provider + + +_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" + + +class TestCreateLogExporter: + def test_grpc_by_default(self): + with patch(_GRPC_LOG_EXPORTER) as mock_grpc: + with patch(_HTTP_LOG_EXPORTER) as mock_http: + _create_log_exporter() + mock_grpc.assert_called_once_with() + mock_http.assert_not_called() + + def test_grpc_explicit(self): + with patch(_GRPC_LOG_EXPORTER) as mock_grpc: + with patch(_HTTP_LOG_EXPORTER) as mock_http: + with patch.dict("os.environ", {"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc"}): + _create_log_exporter() + mock_grpc.assert_called_once_with() + mock_http.assert_not_called() + + def test_http_protobuf(self): + with patch(_GRPC_LOG_EXPORTER) as mock_grpc: + with patch(_HTTP_LOG_EXPORTER) as mock_http: + with patch.dict("os.environ", {"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"}): + _create_log_exporter() + mock_http.assert_called_once_with() + mock_grpc.assert_not_called() + + def test_unsupported_protocol_raises(self): + import pytest + with patch.dict("os.environ", {"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"}): + with pytest.raises(ValueError, match="Unsupported OTEL_EXPORTER_OTLP_PROTOCOL"): + _create_log_exporter() + + +class TestSetupLogProvider: + @pytest.fixture(autouse=True) + def reset_log_provider(self): + import sap_cloud_sdk.core.telemetry._provider as provider_module + provider_module._log_provider = None + yield + provider_module._log_provider = None + + def test_disabled_returns_none(self): + config = InstrumentationConfig(enabled=False) + with patch("sap_cloud_sdk.core.telemetry._provider.get_config", return_value=config): + assert setup_log_provider() is None + + def test_normal_path_sets_our_provider(self): + """No pre-installed provider — we create ours, set it globally, add handler.""" + 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_log_exporter"): + with patch("sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor"): + # plain MagicMock fails isinstance(x, LoggerProvider) → normal path + with patch("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=MagicMock()): + with patch("sap_cloud_sdk.core.telemetry._provider.set_logger_provider") as mock_set: + with patch(_LOGGING_HANDLER): + with patch("logging.getLogger"): + result = setup_log_provider() + assert result is not None + mock_set.assert_called_once_with(result) + + def test_normal_path_uses_sdk_resource(self): + with patch("sap_cloud_sdk.core.telemetry._provider.get_config", return_value=_ENABLED_CONFIG): + with patch("sap_cloud_sdk.core.telemetry._provider.create_resource_attributes_from_env", return_value={"service.name": "svc"}) as mock_attrs: + with patch("sap_cloud_sdk.core.telemetry._provider.Resource") as mock_resource: + with patch("sap_cloud_sdk.core.telemetry._provider._create_log_exporter"): + with patch("sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor"): + with patch("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=MagicMock()): + with patch("sap_cloud_sdk.core.telemetry._provider.LoggerProvider"): + with patch("sap_cloud_sdk.core.telemetry._provider.set_logger_provider"): + with patch(_LOGGING_HANDLER): + with patch("logging.getLogger"): + setup_log_provider() + mock_attrs.assert_called_once() + mock_resource.create.assert_called_once_with({"service.name": "svc"}) + + def test_normal_path_installs_handler_on_root_logger(self): + mock_handler = MagicMock() + mock_root = 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_log_exporter"): + with patch("sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor"): + with patch("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=MagicMock()): + with patch("sap_cloud_sdk.core.telemetry._provider.set_logger_provider"): + with patch(_LOGGING_HANDLER, return_value=mock_handler): + with patch("logging.getLogger", return_value=mock_root): + setup_log_provider() + mock_root.addHandler.assert_called_once_with(mock_handler) + + def test_exception_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("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=MagicMock()): + with patch("sap_cloud_sdk.core.telemetry._provider._create_log_exporter", side_effect=Exception("boom")): + assert setup_log_provider() is None + + def test_platform_path_merges_resource_no_extra_handler(self): + """Platform pre-installed provider with a handler — merge resource, add nothing.""" + from opentelemetry.sdk._logs import LoggerProvider as _LP + external = MagicMock(spec=_LP) + 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_log_exporter"): + with patch("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=external): + with patch("sap_cloud_sdk.core.telemetry._provider._merge_sdk_resource_into_log_provider") as mock_merge: + with patch("sap_cloud_sdk.core.telemetry._provider._root_logger_has_otel_handler", return_value=True): + result = setup_log_provider() + assert result is external + mock_merge.assert_called_once() + external.add_log_record_processor.assert_not_called() + + def test_platform_path_adds_handler_when_none_present(self): + """Platform set provider but no LoggingHandler — we add handler, no extra processor.""" + from opentelemetry.sdk._logs import LoggerProvider as _LP + external = MagicMock(spec=_LP) + 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.get_logger_provider", return_value=external): + with patch("sap_cloud_sdk.core.telemetry._provider._merge_sdk_resource_into_log_provider"): + with patch("sap_cloud_sdk.core.telemetry._provider._root_logger_has_otel_handler", return_value=False): + with patch(_LOGGING_HANDLER) as mock_handler_cls: + with patch("logging.getLogger"): + setup_log_provider() + external.add_log_record_processor.assert_not_called() + mock_handler_cls.assert_called_once_with(logger_provider=external) + + +class TestMergeSdkResourceIntoLogProvider: + def test_updates_provider_resource(self): + from opentelemetry.sdk._logs import LoggerProvider as _LP + from opentelemetry.sdk.resources import Resource as _R + + sdk_resource = _R({"sap.cloud_sdk.language": "python"}) + provider = _LP(resource=_R({"service.name": "svc"})) + + _merge_sdk_resource_into_log_provider(provider, sdk_resource) + + assert provider._resource.attributes["sap.cloud_sdk.language"] == "python" + assert provider._resource.attributes["service.name"] == "svc" + + def test_updates_active_logger_resources(self): + from opentelemetry.sdk._logs import LoggerProvider as _LP + from opentelemetry.sdk.resources import Resource as _R + + sdk_resource = _R({"sap.cloud_sdk.language": "python"}) + provider = _LP(resource=_R({"service.name": "svc"})) + logger_instance = provider.get_logger("test.module") + + _merge_sdk_resource_into_log_provider(provider, sdk_resource) + + # The logger already in the active set gets the updated resource + assert logger_instance._resource.attributes["sap.cloud_sdk.language"] == "python" # ty: ignore[unresolved-attribute] + + def test_sdk_attrs_win_on_collision(self): + from opentelemetry.sdk._logs import LoggerProvider as _LP + from opentelemetry.sdk.resources import Resource as _R + + sdk_resource = _R({"service.name": "sdk-name"}) + provider = _LP(resource=_R({"service.name": "platform-name"})) + + _merge_sdk_resource_into_log_provider(provider, sdk_resource) + + assert provider._resource.attributes["service.name"] == "sdk-name" + + +class TestRootLoggerHasOtelHandler: + def test_returns_false_when_no_handler(self): + root = logging.getLogger() + original = root.handlers[:] + root.handlers = [] + try: + assert _root_logger_has_otel_handler() is False + finally: + root.handlers = original + + def test_returns_true_when_handler_present(self): + from opentelemetry.instrumentation.logging.handler import LoggingHandler as OtelHandler + root = logging.getLogger() + original = root.handlers[:] + mock_provider = MagicMock() + handler = OtelHandler(logger_provider=mock_provider) + root.handlers = [handler] + try: + assert _root_logger_has_otel_handler() is True + finally: + root.handlers = original + + def test_returns_true_when_sdk_level_handler_present(self): + from opentelemetry.sdk._logs import LoggingHandler as SDKHandler + root = logging.getLogger() + original = root.handlers[:] + mock_provider = MagicMock() + handler = SDKHandler(logger_provider=mock_provider) + root.handlers = [handler] + try: + assert _root_logger_has_otel_handler() is True + finally: + root.handlers = original diff --git a/uv.lock b/uv.lock index 643d657f..f09b53da 100644 --- a/uv.lock +++ b/uv.lock @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.43.2" +version = "0.44.0" source = { editable = "." } dependencies = [ { name = "cryptography" },