diff --git a/pyproject.toml b/pyproject.toml index b61fb716..aea91d99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.40.0" +version = "0.41.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..9ee149c8 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, @@ -40,6 +50,9 @@ _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 +89,66 @@ 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()) + exporter = _create_log_exporter() + candidate = LoggerProvider(resource=resource) + candidate.add_log_record_processor(BatchLogRecordProcessor(exporter)) + set_logger_provider(candidate) + + provider = cast(LoggerProvider, get_logger_provider()) + + if provider is not candidate: + logger.warning( + "Global LoggerProvider was already set by another library. " + "Attaching SAP log processor to the existing provider." + ) + provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) + + handler = LoggingHandler(logger_provider=provider) + logging.getLogger().addHandler(handler) + + _log_provider = provider + logger.info( + f"OpenTelemetry log provider initialized. " + f"Service: {config.service_name}, " + f"Endpoint: {config.otlp_endpoint}" + ) + return 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 a665f580..79feb54d 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 ( @@ -93,6 +94,8 @@ def auto_instrument( _set_baggage_processor() _set_propagated_attributes_processor() + setup_log_provider() + if middlewares: _register_middleware_processors(middlewares) 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 98ed6328..35d9c1b0 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 3 times: baggage, propagated attributes, middleware assert mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.call_count == 3 + + +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..0b14aa14 --- /dev/null +++ b/tests/core/unit/telemetry/test_log_provider_e2e.py @@ -0,0 +1,209 @@ +"""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.cld.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 another library claims the global LoggerProvider first, our processor + must still be attached so logs reach the SAP OTLP endpoint.""" + + def test_logs_reach_our_exporter_when_provider_already_set(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 + + # Reset OTel singleton + _logs_internal._LOGGER_PROVIDER_SET_ONCE._done = False + _logs_internal._LOGGER_PROVIDER = None + provider_module._log_provider = None + + # Remove stale LoggingHandlers + root = logging.getLogger() + for h in list(root.handlers): + if isinstance(h, LoggingHandler): + root.removeHandler(h) + + # Simulate another library claiming the provider first + 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) + + # Now our SDK runs setup_log_provider + our_exporter = InMemoryLogRecordExporter() + monkeypatch.setattr("sap_cloud_sdk.core.telemetry._provider._create_log_exporter", lambda: our_exporter) + monkeypatch.setattr("sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor", SimpleLogRecordProcessor) + 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") + + our_records = our_exporter.get_finished_logs() + assert len(our_records) == 1 + assert our_records[0].log_record.body == "hello from sdk" + + # Cleanup + 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..cddbeb3f 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, @@ -17,6 +19,8 @@ shutdown, _setup_meter_provider, _create_metric_exporter, + setup_log_provider, + _create_log_exporter, ) from sap_cloud_sdk.core.telemetry.config import InstrumentationConfig @@ -187,3 +191,140 @@ 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_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_log_exporter"): + with patch("sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor"): + with patch("sap_cloud_sdk.core.telemetry._provider.LoggerProvider", return_value=mock_provider): + with patch("sap_cloud_sdk.core.telemetry._provider.set_logger_provider"): + with patch("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=mock_provider): + with patch(_LOGGING_HANDLER): + with patch("logging.getLogger"): + result = setup_log_provider() + assert result is mock_provider + + def test_uses_shared_resource_attributes(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.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_calls_set_logger_provider(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._create_log_exporter"): + with patch("sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor"): + mock_provider = MagicMock() + with patch("sap_cloud_sdk.core.telemetry._provider.LoggerProvider", return_value=mock_provider): + with patch("sap_cloud_sdk.core.telemetry._provider.set_logger_provider") as mock_set: + with patch(_LOGGING_HANDLER): + with patch("logging.getLogger"): + setup_log_provider() + mock_set.assert_called_once_with(mock_provider) + + def test_installs_handler_on_root_logger(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._create_log_exporter"): + with patch("sap_cloud_sdk.core.telemetry._provider.BatchLogRecordProcessor"): + mock_provider = MagicMock() + with patch("sap_cloud_sdk.core.telemetry._provider.LoggerProvider", return_value=mock_provider): + with patch("sap_cloud_sdk.core.telemetry._provider.set_logger_provider"): + with patch("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=mock_provider): + mock_handler = MagicMock() + with patch(_LOGGING_HANDLER, return_value=mock_handler): + mock_root = MagicMock() + with patch("logging.getLogger", return_value=mock_root) as mock_get_logger: + setup_log_provider() + mock_get_logger.assert_called_once_with() + 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._create_log_exporter", side_effect=Exception("boom")): + assert setup_log_provider() is None + + def test_external_provider_gets_our_processor_attached(self): + external_provider = MagicMock() + candidate = 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") as mock_proc: + with patch("sap_cloud_sdk.core.telemetry._provider.LoggerProvider", return_value=candidate): + with patch("sap_cloud_sdk.core.telemetry._provider.set_logger_provider"): + with patch("sap_cloud_sdk.core.telemetry._provider.get_logger_provider", return_value=external_provider): + with patch(_LOGGING_HANDLER) as mock_handler_cls: + with patch("logging.getLogger"): + result = setup_log_provider() + + # returns the external provider, not our candidate + assert result is external_provider + # BatchLogRecordProcessor called twice: once for our candidate, + # once to attach our processor to the external provider + assert mock_proc.call_count == 2 + external_provider.add_log_record_processor.assert_called_once() + # handler wired to external provider + mock_handler_cls.assert_called_once_with(logger_provider=external_provider) diff --git a/uv.lock b/uv.lock index ef0a7cbc..2cd74460 100644 --- a/uv.lock +++ b/uv.lock @@ -161,9 +161,9 @@ name = "aiologic" version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/13/50b91a3ea6b030d280d2654be97c48b6ed81753a50286ee43c646ba36d3c/aiologic-0.16.0.tar.gz", hash = "sha256:c267ccbd3ff417ec93e78d28d4d577ccca115d5797cdbd16785a551d9658858f", size = 225952, upload-time = "2025-11-27T23:48:41.195Z" } wheels = [ @@ -615,8 +615,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -665,9 +665,9 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" } wheels = [ @@ -685,9 +685,9 @@ resolution-markers = [ "python_full_version == '3.12.*'", ] dependencies = [ - { name = "asgiref" }, - { name = "sqlparse" }, - { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } wheels = [ @@ -3925,9 +3925,10 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.39.1" +version = "0.41.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "grpcio" }, { name = "hatchling" }, { name = "httpx" }, @@ -4016,6 +4017,7 @@ dev = [ requires-dist = [ { name = "a2a-sdk", marker = "extra == 'extensibility'", specifier = ">=0.2.0" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.9.0" }, + { name = "cryptography", specifier = ">=46.0.3" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0" }, { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, { name = "flask", marker = "extra == 'flask'", specifier = ">=3.0" },