diff --git a/src/sap_cloud_sdk/core/telemetry/metrics_decorator.py b/src/sap_cloud_sdk/core/telemetry/metrics_decorator.py index 810db63e..c43280a7 100644 --- a/src/sap_cloud_sdk/core/telemetry/metrics_decorator.py +++ b/src/sap_cloud_sdk/core/telemetry/metrics_decorator.py @@ -4,7 +4,6 @@ from typing import Callable, Optional, TypeVar, ParamSpec from sap_cloud_sdk.core.telemetry import ( - Module, record_request_metric, record_error_metric, ) @@ -14,7 +13,7 @@ def record_metrics( - module: Module, operation: str, deprecated: bool = False + module: str, operation: str, deprecated: bool = False ) -> Callable[[Callable[P, R]], Callable[P, R]]: """Decorator to automatically record request and error metrics for SDK operations. @@ -64,12 +63,12 @@ def decorator(func: Callable[P, R]) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: # Extract source from the client instance (self is the first argument) # or from constructor kwargs before self._telemetry_source exists. - source: Optional[Module] = None + source: Optional[str] = None if args: source = getattr(args[0], "_telemetry_source", None) if source is None: source_kwarg = kwargs.get("_telemetry_source") - if isinstance(source_kwarg, Module): + if isinstance(source_kwarg, str): source = source_kwarg try: diff --git a/src/sap_cloud_sdk/core/telemetry/telemetry.py b/src/sap_cloud_sdk/core/telemetry/telemetry.py index c1051097..9b914eb9 100644 --- a/src/sap_cloud_sdk/core/telemetry/telemetry.py +++ b/src/sap_cloud_sdk/core/telemetry/telemetry.py @@ -19,7 +19,6 @@ ATTR_SOURCE, ATTR_DEPRECATED, ) -from sap_cloud_sdk.core.telemetry.module import Module logger = logging.getLogger(__name__) @@ -94,7 +93,7 @@ def get_propagated_attributes() -> Dict[str, Any]: def record_request_metric( - module: Module, source: Optional[Module], operation: str, deprecated: bool = False + module: str, source: Optional[str], operation: str, deprecated: bool = False ) -> None: """Record a request metric for an SDK operation. @@ -120,7 +119,7 @@ def record_request_metric( def record_error_metric( - module: Module, source: Optional[Module], operation: str, deprecated: bool = False + module: str, source: Optional[str], operation: str, deprecated: bool = False ) -> None: """Record an error metric for an SDK operation. @@ -146,7 +145,7 @@ def record_error_metric( def default_attributes( - module: Module, source: Optional[Module], operation: str, deprecated: bool = False + module: str, source: Optional[str], operation: str, deprecated: bool = False ) -> Dict[str, Any]: """Get default attributes for an SDK operation. diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index 019bc1ac..72e93bfe 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -334,7 +334,64 @@ export APPFND_CONHOS_SYSTEM_ROLE="S4HC" export SAP_SOLUTION_AREA="AFND" ``` -### ORD document ID +--- + +## Instrumenting SDK modules with `record_metrics` + +The `record_metrics` decorator records request and error counters for any SDK module operation. It is the standard way to add usage telemetry to a client method. + +```python +from sap_cloud_sdk.core.telemetry import record_metrics + +class MyClient: + @record_metrics("my_module", "my_operation") + def my_method(self): + ... +``` + +Each call to the decorated method increments `sap.cloud_sdk.capability.requests`. On exception it increments `sap.cloud_sdk.capability.errors` and re-raises. Metrics are emitted only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set — no-op otherwise. + +### Using the built-in enums + +For modules that live inside this package, use the `Module` and `Operation` enums: + +```python +from sap_cloud_sdk.core.telemetry import record_metrics, Module, Operation + +class DestinationClient: + @record_metrics(Module.DESTINATION, Operation.DESTINATION_GET_DESTINATION) + def get_destination(self, name: str): + ... +``` + +### Using plain strings (external packages) + +External packages that depend on `sap-cloud-sdk` can pass plain strings directly without contributing to the enums in this repo: + +```python +from sap_cloud_sdk.core.telemetry import record_metrics + +class MyExternalClient: + @record_metrics("my_module", "my_operation") + def my_method(self): + ... +``` + +The `Module` enum values are still the canonical form for OSS modules. Plain strings are the extension point for packages that have their own release lifecycle. + +### Source attribution + +When one SDK module creates a client from another internally, set `_telemetry_source` so the metric reflects the originating module: + +```python +auditlog_client = AuditLogClient(_telemetry_source=Module.OBJECTSTORE) +``` + +The decorator reads `_telemetry_source` from `self` (or from `__init__` kwargs) and passes it as the `source` attribute on the metric. + +--- + +## ORD document ID ```bash export ORD_DOCUMENT_ID="sap.foo:ord-doc:v1" diff --git a/tests/core/unit/telemetry/test_metrics_decorator.py b/tests/core/unit/telemetry/test_metrics_decorator.py index 968f9069..8b152109 100644 --- a/tests/core/unit/telemetry/test_metrics_decorator.py +++ b/tests/core/unit/telemetry/test_metrics_decorator.py @@ -324,3 +324,78 @@ def static_method(): # Should use None for source since there's no self call_args = mock_metric.call_args[0] assert call_args[1] is None + + +class TestRecordMetricsDecoratorPlainStrings: + """Test that record_metrics accepts plain strings for module and operation.""" + + def test_decorator_accepts_plain_string_module_and_operation(self): + """External packages can pass plain strings instead of enum values.""" + + class ExternalClient: + def __init__(self): + self._telemetry_source = None + + @record_metrics("spii", "handle_tenant_mapping") + def handle(self): + return "ok" + + client = ExternalClient() + + with patch('sap_cloud_sdk.core.telemetry.metrics_decorator.record_request_metric') as mock_metric: + result = client.handle() + + assert result == "ok" + mock_metric.assert_called_once_with("spii", None, "handle_tenant_mapping", False) + + def test_decorator_accepts_plain_string_source_from_kwargs(self): + """Plain string _telemetry_source in kwargs is accepted.""" + + class ExternalClient: + @record_metrics("spii", "create_client") + def __init__(self, _telemetry_source=None): + self._telemetry_source = _telemetry_source + + with patch('sap_cloud_sdk.core.telemetry.metrics_decorator.record_request_metric') as mock_metric: + ExternalClient(_telemetry_source="other_module") + + mock_metric.assert_called_once_with("spii", "other_module", "create_client", False) + + def test_decorator_accepts_plain_string_source_from_self(self): + """Plain string _telemetry_source on self is accepted.""" + + class ExternalClient: + def __init__(self): + self._telemetry_source = "other_module" + + @record_metrics("spii", "handle_tenant_mapping") + def handle(self): + return "ok" + + client = ExternalClient() + + with patch('sap_cloud_sdk.core.telemetry.metrics_decorator.record_request_metric') as mock_metric: + client.handle() + + call_args = mock_metric.call_args[0] + assert call_args[1] == "other_module" + + def test_enum_values_still_work_unchanged(self): + """Existing enum usage continues to work after the change.""" + + class InternalClient: + def __init__(self): + self._telemetry_source = None + + @record_metrics(Module.DESTINATION, Operation.DESTINATION_GET_DESTINATION) + def get_destination(self): + return "dest" + + client = InternalClient() + + with patch('sap_cloud_sdk.core.telemetry.metrics_decorator.record_request_metric') as mock_metric: + client.get_destination() + + call_args = mock_metric.call_args[0] + assert call_args[0] == Module.DESTINATION + assert call_args[2] == Operation.DESTINATION_GET_DESTINATION