Skip to content

Commit 3ec44e8

Browse files
feat(telemetry): add OTel standard logs as third signal (#239)
Co-authored-by: Lucas Soares <lucas.soares@sap.com>
1 parent fd22fec commit 3ec44e8

9 files changed

Lines changed: 629 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.43.2"
3+
version = "0.44.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"

src/sap_cloud_sdk/core/telemetry/_provider.py

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1-
"""Internal module for setting up OpenTelemetry meter provider."""
1+
"""Internal module for setting up OpenTelemetry meter and logger providers."""
22

33
import logging
44
import os
5-
from typing import Optional
5+
from typing import Optional, cast
66

77
from opentelemetry import metrics
8+
from opentelemetry._logs import get_logger_provider, set_logger_provider
9+
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
10+
OTLPLogExporter as GRPCLogExporter,
11+
)
812
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
913
OTLPMetricExporter as GRPCMetricExporter,
1014
)
15+
from opentelemetry.exporter.otlp.proto.http._log_exporter import (
16+
OTLPLogExporter as HTTPLogExporter,
17+
)
1118
from opentelemetry.exporter.otlp.proto.http.metric_exporter import (
1219
OTLPMetricExporter as HTTPMetricExporter,
1320
)
21+
from opentelemetry.instrumentation.logging.handler import LoggingHandler
22+
from opentelemetry.sdk._logs import LoggerProvider
23+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
1424
from opentelemetry.sdk.metrics import (
1525
MeterProvider,
1626
Counter,
@@ -36,10 +46,38 @@
3646

3747
logger = logging.getLogger(__name__)
3848

49+
50+
def _merge_sdk_resource_into_log_provider(
51+
provider: LoggerProvider, sdk_resource: Resource
52+
) -> None:
53+
"""OTel SDK has no public API to swap a LoggerProvider's Resource after construction."""
54+
provider._resource = provider.resource.merge(sdk_resource)
55+
with provider._active_loggers_lock:
56+
for logger_instance in provider._active_loggers:
57+
logger_instance._resource = provider._resource
58+
logger.info(
59+
"Merged sap-cloud-sdk resource attrs onto wrapper-installed LoggerProvider"
60+
)
61+
62+
63+
def _root_logger_has_otel_handler() -> bool:
64+
# sitecustomize.py installs sdk._logs.LoggingHandler, not the instrumentation-layer one — check both.
65+
try:
66+
from opentelemetry.sdk._logs import LoggingHandler as _SDKLoggingHandler
67+
68+
handler_types: tuple[type, ...] = (LoggingHandler, _SDKLoggingHandler)
69+
except ImportError:
70+
handler_types = (LoggingHandler,)
71+
return any(isinstance(h, handler_types) for h in logging.getLogger().handlers)
72+
73+
3974
# Global meter provider
4075
_meter_provider: Optional[MeterProvider] = None
4176
_meter: Optional[metrics.Meter] = None
4277

78+
# Global logger provider
79+
_log_provider: Optional[LoggerProvider] = None
80+
4381

4482
def get_meter() -> metrics.Meter:
4583
"""Get or create the global meter instance.
@@ -76,6 +114,70 @@ def shutdown() -> None:
76114
_meter_provider = None
77115

78116

117+
def setup_log_provider() -> Optional[LoggerProvider]:
118+
"""Set up the global OTel LoggerProvider using the shared resource attributes.
119+
120+
Installs a LoggingHandler on the root stdlib logger so all existing
121+
logging.getLogger(...) calls in the app flow through OTel automatically.
122+
No-op when telemetry is disabled.
123+
"""
124+
global _log_provider
125+
126+
if _log_provider is not None:
127+
return _log_provider
128+
129+
config = get_config()
130+
if not config.enabled:
131+
return None
132+
133+
try:
134+
resource = Resource.create(create_resource_attributes_from_env())
135+
existing = cast(LoggerProvider, get_logger_provider())
136+
137+
if isinstance(existing, LoggerProvider):
138+
# Never add a second BatchLogRecordProcessor — the platform's provider
139+
# already has one, and a second processor doubles every exported log record.
140+
logger.warning(
141+
"Global LoggerProvider was already set by another library. "
142+
"Merging sap.cloud_sdk.* resource attributes into the existing provider."
143+
)
144+
_merge_sdk_resource_into_log_provider(existing, resource)
145+
if not _root_logger_has_otel_handler():
146+
logging.getLogger().addHandler(LoggingHandler(logger_provider=existing))
147+
_log_provider = existing
148+
else:
149+
provider = LoggerProvider(resource=resource)
150+
provider.add_log_record_processor(
151+
BatchLogRecordProcessor(_create_log_exporter())
152+
)
153+
set_logger_provider(provider)
154+
logging.getLogger().addHandler(LoggingHandler(logger_provider=provider))
155+
_log_provider = provider
156+
157+
logger.info(
158+
f"OpenTelemetry log provider initialized. "
159+
f"Service: {config.service_name}, "
160+
f"Endpoint: {config.otlp_endpoint}"
161+
)
162+
return _log_provider
163+
164+
except Exception as e:
165+
logger.error(f"Failed to initialize OpenTelemetry log provider: {e}")
166+
return None
167+
168+
169+
def _create_log_exporter():
170+
protocol = os.getenv(ENV_OTLP_PROTOCOL, "grpc").lower()
171+
exporter_classes = {"grpc": GRPCLogExporter, "http/protobuf": HTTPLogExporter}
172+
173+
if protocol not in exporter_classes:
174+
raise ValueError(
175+
f"Unsupported OTEL_EXPORTER_OTLP_PROTOCOL: '{protocol}'. "
176+
"Supported values are 'grpc' and 'http/protobuf'."
177+
)
178+
return exporter_classes[protocol]()
179+
180+
79181
def _create_metric_exporter():
80182
protocol = os.getenv(ENV_OTLP_PROTOCOL, "grpc").lower()
81183
exporter_classes = {"grpc": GRPCMetricExporter, "http/protobuf": HTTPMetricExporter}

src/sap_cloud_sdk/core/telemetry/auto_instrument.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SpanExporter
2121
from traceloop.sdk import Traceloop
2222

23+
from sap_cloud_sdk.core.telemetry._provider import setup_log_provider
2324
from sap_cloud_sdk.core.telemetry.module import Module
2425
from sap_cloud_sdk.core.telemetry.operation import Operation
2526
from sap_cloud_sdk.core.telemetry.config import (
@@ -97,6 +98,8 @@ def auto_instrument(
9798
_set_propagated_attributes_processor()
9899
_set_runtime_context_processor()
99100

101+
setup_log_provider()
102+
100103
if middlewares:
101104
_register_middleware_processors(middlewares)
102105

src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
1+
import logging as stdlib_logging
2+
13
from opentelemetry.instrumentation.logging import LoggingInstrumentor
4+
from opentelemetry.instrumentation.logging.handler import (
5+
LoggingHandler as _InstrumentationHandler,
6+
)
27

38
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
49
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
510

611
_instrumentor = LoggingInstrumentor()
712

813

14+
def _has_otel_handler_on_root() -> bool:
15+
# sitecustomize.py installs sdk._logs.LoggingHandler, not the instrumentation-layer one — check both.
16+
try:
17+
from opentelemetry.sdk._logs import LoggingHandler as _SDKHandler
18+
19+
handler_types: tuple[type, ...] = (_InstrumentationHandler, _SDKHandler)
20+
except ImportError:
21+
handler_types = (_InstrumentationHandler,)
22+
return any(
23+
isinstance(h, handler_types) for h in stdlib_logging.getLogger().handlers
24+
)
25+
26+
927
class LoggingInstrumentorWrapper(LibraryInstrumentor):
1028
"""Injects trace_id and span_id into every stdlib log record for log-trace correlation."""
1129

@@ -15,7 +33,11 @@ def is_instrumented(self) -> bool:
1533
return _instrumentor.is_instrumented_by_opentelemetry
1634

1735
def _instrument(self, **kwargs) -> None:
18-
_instrumentor.instrument(set_logging_format=True)
36+
kwargs.setdefault("set_logging_format", True)
37+
if _has_otel_handler_on_root():
38+
# Already have a handler — adding another duplicates every log record.
39+
kwargs = {**kwargs, "enable_log_auto_instrumentation": False}
40+
_instrumentor.instrument(**kwargs)
1941

2042
def _uninstrument(self) -> None:
2143
_instrumentor.uninstrument()

src/sap_cloud_sdk/core/telemetry/user-guide.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,54 @@ GenAIOperation.INVOKE_AGENT
176176

177177
---
178178

179+
## Logging
180+
181+
`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.).
182+
183+
No changes to your logging code are needed:
184+
185+
```python
186+
import logging
187+
188+
logger = logging.getLogger(__name__)
189+
190+
logger.info("Destination fetched")
191+
logger.warning("Retrying request, attempt %d", attempt)
192+
logger.error("Failed to connect", exc_info=True)
193+
```
194+
195+
### Structured fields
196+
197+
Use `extra={}` to attach structured attributes to a log record:
198+
199+
```python
200+
logger.info("Request completed", extra={"tenant_id": tid, "duration_ms": 120})
201+
```
202+
203+
### Log level filtering
204+
205+
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:
206+
207+
```python
208+
# Only WARNING and above to OTel
209+
logging.getLogger().setLevel(logging.WARNING)
210+
211+
# Or scope it to your app's logger tree
212+
logging.getLogger("my_app").setLevel(logging.INFO)
213+
```
214+
215+
### Correlation with traces
216+
217+
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.
218+
219+
### Third-party logging libraries
220+
221+
The OTel handler is installed on the root stdlib `logging` logger. Any library that propagates to stdlib works automatically.
222+
223+
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.
224+
225+
---
226+
179227
## Adding attributes
180228

181229
### To the current span
@@ -232,6 +280,7 @@ Propagation is scoped: once the parent span exits, its attributes stop propagati
232280
## Complete example
233281

234282
```python
283+
import logging
235284
from sap_cloud_sdk.core.telemetry import (
236285
auto_instrument,
237286
invoke_agent_span,
@@ -244,17 +293,21 @@ auto_instrument()
244293

245294
from litellm import completion
246295

296+
logger = logging.getLogger(__name__)
247297

248298
async def handle_request(query: str, user_id: str):
249299
set_tenant_id("bh7sjh...")
250300

301+
logger.info("Handling request", extra={"user_id": user_id})
302+
251303
# Parent span carries business context for the whole agent turn.
252304
# Autoinstrumentation creates the child LLM span automatically.
253305
with invoke_agent_span(
254306
provider="openai", agent_name="SupportBot", attributes={"user.id": user_id}
255307
):
256308
documents = await retrieve_knowledge_base(query)
257309
add_span_attribute("documents.retrieved", len(documents))
310+
logger.debug("Retrieved %d documents", len(documents))
258311

259312
response = completion(
260313
model="gpt-4",
@@ -345,7 +398,7 @@ export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.example.com"
345398

346399
### Transport protocol
347400

348-
Both traces and metrics use gRPC by default. Switch to HTTP/protobuf by setting:
401+
Traces, metrics, and logs all use gRPC by default. Switch to HTTP/protobuf by setting:
349402

350403
```bash
351404
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"

tests/core/unit/telemetry/test_auto_instrument.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def mock_traceloop_components():
2525
'get_tracer_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.trace.get_tracer_provider', return_value=create_autospec(SDKTracerProvider))),
2626
'create_resource': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env')),
2727
'get_app_name': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name')),
28+
'setup_log_provider': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.setup_log_provider')),
2829
}
2930
yield mocks
3031

@@ -405,3 +406,29 @@ def test_baggage_and_middleware_processors_both_added(self, mock_traceloop_compo
405406

406407
# add_span_processor called 4 times: baggage, propagated attributes, runtime context, middleware
407408
assert mock_traceloop_components['get_tracer_provider'].return_value.add_span_processor.call_count == 4
409+
410+
411+
class TestAutoInstrumentLogging:
412+
def test_setup_log_provider_called_on_instrument(self, mock_traceloop_components):
413+
mock_traceloop_components['get_app_name'].return_value = 'test-app'
414+
mock_traceloop_components['create_resource'].return_value = {}
415+
416+
with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
417+
auto_instrument()
418+
419+
mock_traceloop_components['setup_log_provider'].assert_called_once()
420+
421+
def test_setup_log_provider_not_called_when_no_endpoint(self):
422+
with patch.dict('os.environ', {}, clear=True):
423+
with patch('sap_cloud_sdk.core.telemetry.auto_instrument.setup_log_provider') as mock_log:
424+
auto_instrument()
425+
mock_log.assert_not_called()
426+
427+
def test_setup_log_provider_called_with_console_exporter(self, mock_traceloop_components):
428+
mock_traceloop_components['get_app_name'].return_value = 'test-app'
429+
mock_traceloop_components['create_resource'].return_value = {}
430+
431+
with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True):
432+
auto_instrument()
433+
434+
mock_traceloop_components['setup_log_provider'].assert_called_once()

0 commit comments

Comments
 (0)