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.39.0"
version = "0.40.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
70 changes: 69 additions & 1 deletion src/sap_cloud_sdk/core/telemetry/_provider.py
Original file line number Diff line number Diff line change
@@ -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 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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -76,6 +89,61 @@ 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 = get_logger_provider()
if provider is not candidate:
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}
Expand Down
3 changes: 3 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/auto_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -93,6 +94,8 @@ def auto_instrument(
_set_baggage_processor()
_set_propagated_attributes_processor()

setup_log_provider()

if middlewares:
_register_middleware_processors(middlewares)

Expand Down
56 changes: 55 additions & 1 deletion src/sap_cloud_sdk/core/telemetry/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,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
Expand Down Expand Up @@ -225,6 +273,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,
Expand All @@ -237,9 +286,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(
Expand All @@ -249,6 +302,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",
Expand Down Expand Up @@ -338,7 +392,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"
Expand Down
27 changes: 27 additions & 0 deletions tests/core/unit/telemetry/test_auto_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Loading
Loading