diff --git a/pyproject.toml b/pyproject.toml index d1ce3d06..98ebd1b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/sap_cloud_sdk/core/telemetry/_provider.py b/src/sap_cloud_sdk/core/telemetry/_provider.py index 05851ab1..9c902971 100644 --- a/src/sap_cloud_sdk/core/telemetry/_provider.py +++ b/src/sap_cloud_sdk/core/telemetry/_provider.py @@ -43,6 +43,7 @@ ) 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__) @@ -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. @@ -143,7 +150,7 @@ 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) @@ -151,7 +158,7 @@ def setup_log_provider() -> Optional[LoggerProvider]: 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( diff --git a/src/sap_cloud_sdk/core/telemetry/log_filters/__init__.py b/src/sap_cloud_sdk/core/telemetry/log_filters/__init__.py new file mode 100644 index 00000000..9273143c --- /dev/null +++ b/src/sap_cloud_sdk/core/telemetry/log_filters/__init__.py @@ -0,0 +1,3 @@ +from sap_cloud_sdk.core.telemetry.log_filters.identity import IdentityLogFilter + +__all__ = ["IdentityLogFilter"] diff --git a/src/sap_cloud_sdk/core/telemetry/log_filters/identity.py b/src/sap_cloud_sdk/core/telemetry/log_filters/identity.py new file mode 100644 index 00000000..14c0df6b --- /dev/null +++ b/src/sap_cloud_sdk/core/telemetry/log_filters/identity.py @@ -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 diff --git a/src/sap_cloud_sdk/core/telemetry/span_processors/__init__.py b/src/sap_cloud_sdk/core/telemetry/span_processors/__init__.py new file mode 100644 index 00000000..c9d36a43 --- /dev/null +++ b/src/sap_cloud_sdk/core/telemetry/span_processors/__init__.py @@ -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", +] diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 5b9fbeed..f0b0f5df 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -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 @@ -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: @@ -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 diff --git a/tests/core/unit/telemetry/log_filters/__init__.py b/tests/core/unit/telemetry/log_filters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/core/unit/telemetry/log_filters/test_identity.py b/tests/core/unit/telemetry/log_filters/test_identity.py new file mode 100644 index 00000000..563f7fb3 --- /dev/null +++ b/tests/core/unit/telemetry/log_filters/test_identity.py @@ -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 diff --git a/tests/core/unit/telemetry/test_provider.py b/tests/core/unit/telemetry/test_provider.py index 7d0d49af..9118eff9 100644 --- a/tests/core/unit/telemetry/test_provider.py +++ b/tests/core/unit/telemetry/test_provider.py @@ -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 = { @@ -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) diff --git a/uv.lock b/uv.lock index 7a7caec5..3636353d 100644 --- a/uv.lock +++ b/uv.lock @@ -163,9 +163,9 @@ name = "aiologic" version = "0.17.1" 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/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -808,8 +808,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 = [ @@ -4152,7 +4152,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.50.0" +version = "0.51.0" source = { editable = "." } dependencies = [ { name = "cryptography" },