Skip to content
Draft
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
7 changes: 3 additions & 4 deletions src/sap_cloud_sdk/core/telemetry/metrics_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
7 changes: 3 additions & 4 deletions src/sap_cloud_sdk/core/telemetry/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
ATTR_SOURCE,
ATTR_DEPRECATED,
)
from sap_cloud_sdk.core.telemetry.module import Module

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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.

Expand All @@ -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.

Expand All @@ -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.

Expand Down
59 changes: 58 additions & 1 deletion src/sap_cloud_sdk/core/telemetry/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
75 changes: 75 additions & 0 deletions tests/core/unit/telemetry/test_metrics_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading