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.50.0"
version = "0.51.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
11 changes: 9 additions & 2 deletions src/sap_cloud_sdk/core/telemetry/_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
)
Comment thread
LucasAlvesSoares marked this conversation as resolved.
from sap_cloud_sdk.core._version import get_version
from sap_cloud_sdk.core.telemetry.constants import SDK_PACKAGE_NAME
from sap_cloud_sdk.core.telemetry.log_filters.identity import IdentityLogFilter

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -114,6 +115,12 @@ def shutdown() -> None:
_meter_provider = None


def _make_logging_handler(provider: LoggerProvider) -> LoggingHandler:
handler = LoggingHandler(logger_provider=provider)
handler.addFilter(IdentityLogFilter())
return handler


def setup_log_provider() -> Optional[LoggerProvider]:
"""Set up the global OTel LoggerProvider using the shared resource attributes.

Expand Down Expand Up @@ -143,15 +150,15 @@ def setup_log_provider() -> Optional[LoggerProvider]:
)
_merge_sdk_resource_into_log_provider(existing, resource)
if not _root_logger_has_otel_handler():
logging.getLogger().addHandler(LoggingHandler(logger_provider=existing))
logging.getLogger().addHandler(_make_logging_handler(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))
logging.getLogger().addHandler(_make_logging_handler(provider))
_log_provider = provider

logger.info(
Expand Down
3 changes: 3 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/log_filters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from sap_cloud_sdk.core.telemetry.log_filters.identity import IdentityLogFilter

__all__ = ["IdentityLogFilter"]
40 changes: 40 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/log_filters/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Log filter that stamps identity attributes (tenant ID, user ID) onto every log record."""

import logging

from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TENANT_ID, ATTR_USER_ID


def _resolve_log_attributes() -> dict:
try:
from sap_cloud_sdk.core.runtime_context import (
get_context,
GLOBAL_TENANT_ID,
USER_ID,
)
from sap_cloud_sdk.ias import get_auth_context

ctx = get_context()
claims = get_auth_context()
candidates = {
ATTR_SAP_TENANT_ID: ctx.get(GLOBAL_TENANT_ID)
or (claims and claims.sap_gtid),
ATTR_USER_ID: ctx.get(USER_ID) or (claims and claims.user_uuid),
}
return {k: v for k, v in candidates.items() if v}
except Exception:
return {}


class IdentityLogFilter(logging.Filter):
"""Stamps ``sap.tenancy.tenant_id`` and ``user.id`` onto every log record.

Reads from the SDK runtime context first (populated by ``bootstrap()``),
then falls back to the IAS auth context set by the Starlette middleware.
Attributes are omitted when no identity is available (e.g. outside a request).
"""

def filter(self, record: logging.LogRecord) -> bool:
for attr, value in _resolve_log_attributes().items():
setattr(record, attr, value)
return True
15 changes: 15 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/span_processors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from sap_cloud_sdk.core.telemetry.span_processors.baggage_span_processor import (
BaggageSpanProcessor,
)
from sap_cloud_sdk.core.telemetry.span_processors.propagated_attributes_processor import (
PropagatedAttributesSpanProcessor,
)
from sap_cloud_sdk.core.telemetry.span_processors.runtime_context_processor import (
RuntimeContextSpanProcessor,
)

__all__ = [
"BaggageSpanProcessor",
"PropagatedAttributesSpanProcessor",
"RuntimeContextSpanProcessor",
]
27 changes: 9 additions & 18 deletions src/sap_cloud_sdk/core/telemetry/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,6 @@ with invoke_agent_span(
# autoinstrumented LLM call is a child of this span
response = client.chat.completions.create(...)
```

### 3. Set tenant ID at the request boundary

```python
from sap_cloud_sdk.core.telemetry import set_tenant_id


def handle_request(request):
set_tenant_id(extract_tenant_from_jwt(request))
```

---

## Library instrumentation
Expand Down Expand Up @@ -204,6 +193,15 @@ logger.warning("Retrying request, attempt %d", attempt)
logger.error("Failed to connect", exc_info=True)
```

### Identity attributes

`sap.tenancy.tenant_id` and `user.id` are automatically stamped on every log record when a request context is active — no extra code needed. The same identity used for traces is used for logs.

Resolution priority:
1. Runtime context populated by `bootstrap()` (`GLOBAL_TENANT_ID`, `USER_ID` from `IASContextProvider`)
2. IAS auth context set by `StarletteIASTelemetryMiddleware` (`sap_gtid`, `user_uuid` claims)
3. Omitted when no identity is available (e.g. log lines emitted at startup)

### Structured fields

Use `extra={}` to attach structured attributes to a log record:
Expand Down Expand Up @@ -382,13 +380,6 @@ auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app)])

---

## Multi-tenancy

- **Supported:** N/A
- **Authentication:** N/A
- **How to use:** This is an infrastructure module. `set_tenant_id()` and `StarletteIASTelemetryMiddleware` allow attaching a tenant identifier to OpenTelemetry spans as metadata, but this is observability context.
- **Further reading:** N/A

## Configuration

### Production
Expand Down
Empty file.
89 changes: 89 additions & 0 deletions tests/core/unit/telemetry/log_filters/test_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Tests for log_filters.identity."""

import logging
from unittest.mock import patch

import pytest

from sap_cloud_sdk.core.telemetry.log_filters.identity import (
IdentityLogFilter,
_resolve_log_attributes,
)
from sap_cloud_sdk.core.telemetry.constants import ATTR_SAP_TENANT_ID, ATTR_USER_ID


def _make_record() -> logging.LogRecord:
return logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)


class TestResolveLogAttributes:
def test_returns_tenant_and_user_from_runtime_context(self):
from sap_cloud_sdk.core.runtime_context.providers._ias import GLOBAL_TENANT_ID, USER_ID
from sap_cloud_sdk.core.runtime_context._context import RuntimeContext, sdk_context

ctx = RuntimeContext({GLOBAL_TENANT_ID: "t-1", USER_ID: "u-1"})
with sdk_context(ctx):
attrs = _resolve_log_attributes()

assert attrs == {ATTR_SAP_TENANT_ID: "t-1", ATTR_USER_ID: "u-1"}

def test_falls_back_to_auth_context(self):
from sap_cloud_sdk.core.runtime_context._context import RuntimeContext, sdk_context
from sap_cloud_sdk.ias._context import _auth_context_var
from sap_cloud_sdk.ias._token import IASClaims

claims = IASClaims(sap_gtid="gtid-1", user_uuid="uuid-1")
token = _auth_context_var.set(claims)
try:
with sdk_context(RuntimeContext()):
attrs = _resolve_log_attributes()
finally:
_auth_context_var.reset(token)

assert attrs == {ATTR_SAP_TENANT_ID: "gtid-1", ATTR_USER_ID: "uuid-1"}

def test_returns_empty_when_nothing_set(self):
from sap_cloud_sdk.core.runtime_context._context import RuntimeContext, sdk_context

with sdk_context(RuntimeContext()):
assert _resolve_log_attributes() == {}

def test_returns_empty_on_exception(self):
with patch("sap_cloud_sdk.core.runtime_context.get_context", side_effect=Exception("boom")):
assert _resolve_log_attributes() == {}


class TestIdentityLogFilter:
def test_stamps_all_resolved_attributes(self):
from sap_cloud_sdk.core.runtime_context.providers._ias import GLOBAL_TENANT_ID, USER_ID
from sap_cloud_sdk.core.runtime_context._context import RuntimeContext, sdk_context

ctx = RuntimeContext({GLOBAL_TENANT_ID: "t-1", USER_ID: "u-1"})
record = _make_record()
filt = IdentityLogFilter()

with sdk_context(ctx):
result = filt.filter(record)

assert result is True
assert getattr(record, ATTR_SAP_TENANT_ID) == "t-1"
assert getattr(record, ATTR_USER_ID) == "u-1"

def test_skips_attributes_when_nothing_set(self):
from sap_cloud_sdk.core.runtime_context._context import RuntimeContext, sdk_context

record = _make_record()
filt = IdentityLogFilter()

with sdk_context(RuntimeContext()):
result = filt.filter(record)

assert result is True
assert not hasattr(record, ATTR_SAP_TENANT_ID)
assert not hasattr(record, ATTR_USER_ID)

def test_always_returns_true(self):
record = _make_record()
filt = IdentityLogFilter()
with patch("sap_cloud_sdk.core.telemetry.log_filters.identity._resolve_log_attributes", return_value={}):
assert filt.filter(record) is True
73 changes: 73 additions & 0 deletions tests/core/unit/telemetry/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
_create_log_exporter,
_merge_sdk_resource_into_log_provider,
_root_logger_has_otel_handler,
_make_logging_handler,
)
from sap_cloud_sdk.core.telemetry.log_filters.identity import IdentityLogFilter
from sap_cloud_sdk.core.telemetry.config import InstrumentationConfig

_DELTA_TEMPORALITY = {
Expand Down Expand Up @@ -399,3 +401,74 @@ def test_returns_true_when_sdk_level_handler_present(self):
assert _root_logger_has_otel_handler() is True
finally:
root.handlers = original


class TestMakeLoggingHandler:
def test_returns_logging_handler(self):
from opentelemetry.instrumentation.logging.handler import LoggingHandler

mock_provider = MagicMock()
handler = _make_logging_handler(mock_provider)
assert isinstance(handler, LoggingHandler)

def test_installs_identity_filter(self):
mock_provider = MagicMock()
handler = _make_logging_handler(mock_provider)
assert any(isinstance(f, IdentityLogFilter) for f in handler.filters)


class TestSetupLogProviderInstallsFilter:
@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_normal_path_installs_tenant_id_filter(self):
from opentelemetry.instrumentation.logging.handler import LoggingHandler

installed_handlers = []

def capture_add_handler(handler):
installed_handlers.append(handler)

mock_root = MagicMock()
mock_root.addHandler.side_effect = capture_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"):
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.getLogger", return_value=mock_root):
setup_log_provider()

assert len(installed_handlers) == 1
handler = installed_handlers[0]
assert isinstance(handler, LoggingHandler)
assert any(isinstance(f, IdentityLogFilter) for f in handler.filters)

def test_merge_path_installs_tenant_id_filter(self):
from opentelemetry.sdk._logs import LoggerProvider as _LP
from opentelemetry.instrumentation.logging.handler import LoggingHandler

external = MagicMock(spec=_LP)
installed_handlers = []

mock_root = MagicMock()
mock_root.addHandler.side_effect = lambda h: installed_handlers.append(h)

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.getLogger", return_value=mock_root):
setup_log_provider()

assert len(installed_handlers) == 1
handler = installed_handlers[0]
assert isinstance(handler, LoggingHandler)
assert any(isinstance(f, IdentityLogFilter) for f in handler.filters)
12 changes: 6 additions & 6 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading