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.46.1"
version = "0.47.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
5 changes: 4 additions & 1 deletion src/sap_cloud_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@

from sap_cloud_sdk.core.bootstrap import bootstrap, TelemetryConfig

__all__ = ["bootstrap", "TelemetryConfig"]
__all__ = [
"bootstrap",
"TelemetryConfig",
]
3 changes: 2 additions & 1 deletion src/sap_cloud_sdk/core/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Any, List, Optional

from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
from sap_cloud_sdk.core.runtime_context._registry import get_registry
from sap_cloud_sdk.core.runtime_context._registry import get_registry, record_attached
from sap_cloud_sdk.core.runtime_context import (
DWCContextProvider,
IASContextProvider,
Expand Down Expand Up @@ -89,6 +89,7 @@ def bootstrap(
for adapter in get_registry():
if adapter.matches(app):
adapter.attach(app, providers)
record_attached(adapter.name)
return

raise TypeError(
Expand Down
9 changes: 8 additions & 1 deletion src/sap_cloud_sdk/core/runtime_context/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@
TRIGGER_TYPE,
)
from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register
from sap_cloud_sdk.core.runtime_context._registry import (
Adapter,
FrameworkAdapter,
get_attached_adapters,
register,
)
from sap_cloud_sdk.core.runtime_context.providers import (
DWCContextProvider,
IASContextProvider,
Expand All @@ -46,6 +51,7 @@
import sap_cloud_sdk.core.runtime_context.adapters # noqa: F401

__all__ = [
"Adapter",
"APP_TENANT_ID",
"ContextKey",
"ContextProvider",
Expand All @@ -54,6 +60,7 @@
"DWCContextProvider",
"FEATURE_TOGGLES",
"FrameworkAdapter",
"get_attached_adapters",
"GLOBAL_TENANT_ID",
"IASContextProvider",
"RuntimeContext",
Expand Down
33 changes: 33 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,22 @@

import logging
from abc import ABC, abstractmethod
from enum import StrEnum
from typing import List

from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider

logger = logging.getLogger(__name__)


class Adapter(StrEnum):
"""Known framework adapters that can be attached via :func:`~sap_cloud_sdk.bootstrap`."""

STARLETTE = "starlette"


_registry: List[FrameworkAdapter] = []
_attached: List[Adapter] = []


def register(adapter: FrameworkAdapter) -> None:
Expand All @@ -22,6 +31,22 @@ def get_registry() -> List[FrameworkAdapter]:
return list(_registry)


def record_attached(name: Adapter) -> None:
"""Record a framework adapter as attached. Called by bootstrap()."""
if name not in _attached:
_attached.append(name)


def get_attached_adapters() -> List[Adapter]:
"""Return the adapters attached via bootstrap().

Each entry corresponds to one :func:`~sap_cloud_sdk.bootstrap` call that
successfully matched and attached an adapter (e.g. :attr:`Adapter.STARLETTE`).
Returns an empty list if bootstrap() has not been called yet.
"""
return list(_attached)


class FrameworkAdapter(ABC):
"""Connects a framework or invocation source to the SDK runtime context.

Expand All @@ -33,6 +58,10 @@ class FrameworkAdapter(ABC):
Example::

class FlaskContextAdapter(FrameworkAdapter):
@property
def name(self) -> str:
return "flask"

def _matches(self, app) -> bool:
from flask import Flask
return isinstance(app, Flask)
Expand All @@ -43,6 +72,10 @@ def attach(self, app, providers) -> None:
register(FlaskContextAdapter())
"""

@property
@abstractmethod
def name(self) -> Adapter: ...

def matches(self, app) -> bool:
"""Return True if this adapter handles *app*'s framework type."""
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,18 @@
from typing import List

from sap_cloud_sdk.core.runtime_context._protocol import ContextProvider
from sap_cloud_sdk.core.runtime_context._registry import FrameworkAdapter, register
from sap_cloud_sdk.core.runtime_context._registry import (
Adapter,
FrameworkAdapter,
register,
)


class _StarletteContextAdapter(FrameworkAdapter):
@property
def name(self) -> Adapter:
return Adapter.STARLETTE

def _matches(self, app) -> bool:
from starlette.applications import Starlette

Expand Down
29 changes: 29 additions & 0 deletions src/sap_cloud_sdk/core/runtime_context/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,13 +203,18 @@ editing `bootstrap`.

```python
from sap_cloud_sdk.core.runtime_context import (
Adapter,
ContextProvider,
FrameworkAdapter,
register,
)


class FlaskContextAdapter(FrameworkAdapter):
@property
def name(self) -> Adapter:
return "flask"

def _matches(self, app) -> bool:
from flask import Flask

Expand All @@ -226,6 +231,30 @@ register(FlaskContextAdapter())

---

## Introspection

Use `get_attached_adapters()` to check which framework adapters have been attached at runtime:

```python
from sap_cloud_sdk.core.runtime_context import Adapter, get_attached_adapters

get_attached_adapters() # -> [Adapter.STARLETTE] after bootstrap(app), [] before
```

This is useful for modules that need to fail fast if their required framework was never bootstrapped:

```python
if Adapter.STARLETTE not in get_attached_adapters():
raise RuntimeError(
"This client requires Starlette to be bootstrapped. "
"Call bootstrap(app) with your Starlette/FastAPI app."
)
```

Returns an empty list if `bootstrap()` has not been called yet. Each entry corresponds to one successful `bootstrap(app)` call.

---

## Running the tests

```bash
Expand Down
6 changes: 6 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
ExtensionContextLogFilter,
)
from sap_cloud_sdk.core.telemetry.middleware import TelemetryMiddleware
from sap_cloud_sdk.core.telemetry.instrumentation._registry import (
Library,
get_instrumented_libraries,
)

__all__ = [
"Module",
Expand Down Expand Up @@ -103,6 +107,8 @@
"emit_extensions_summary_span",
"ExtensionContextLogFilter",
"TelemetryMiddleware",
"Library",
"get_instrumented_libraries",
]

try:
Expand Down
36 changes: 36 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/instrumentation/_registry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,25 @@
from enum import StrEnum

from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor


class Library(StrEnum):
"""Known libraries that can be instrumented via :func:`~sap_cloud_sdk.core.telemetry.auto_instrument`."""

AIOHTTP = "aiohttp"
DJANGO = "django"
FASTAPI = "fastapi"
FLASK = "flask"
GRPC = "grpc"
HTTPX = "httpx"
LOGGING = "logging"
REQUESTS = "requests"
SQLALCHEMY = "sqlalchemy"
STARLETTE = "starlette"


_registry: list[LibraryInstrumentor] = []
_instrumented: list[Library] = []


def register(instrumentor: LibraryInstrumentor) -> None:
Expand All @@ -14,3 +33,20 @@ def register(instrumentor: LibraryInstrumentor) -> None:

def get_registry() -> list[LibraryInstrumentor]:
return list(_registry)


def record_instrumented(name: Library) -> None:
"""Record a library as successfully instrumented. Called by LibraryInstrumentor."""
if name not in _instrumented:
_instrumented.append(name)


def get_instrumented_libraries() -> list[Library]:
"""Return the libraries successfully instrumented via auto_instrument().

Each entry corresponds to a library that was installed and patched with OTel
(e.g. :attr:`Library.HTTPX`, :attr:`Library.SQLALCHEMY`). Libraries that were
skipped because they are not installed do not appear in this list. Returns an
empty list if auto_instrument() has not been called yet.
"""
return list(_instrumented)
14 changes: 11 additions & 3 deletions src/sap_cloud_sdk/core/telemetry/instrumentation/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import importlib.util
import logging
from abc import ABC, abstractmethod
from typing import Any
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from sap_cloud_sdk.core.telemetry.instrumentation._registry import Library

logger = logging.getLogger(__name__)

Expand All @@ -18,8 +21,8 @@ class LibraryInstrumentor(ABC):
subclasses to accept optional arguments (e.g. app= for framework instrumentors).
"""

#: Import name of the library being instrumented (e.g. "httpx").
library_name: str
#: Library enum member identifying the library being instrumented.
library_name: "Library"

def instrument(self, **kwargs: Any) -> None:
if not self._is_library_installed():
Expand All @@ -37,6 +40,11 @@ def instrument(self, **kwargs: Any) -> None:
"%s instrumentation skipped — library not importable", self.library_name
)
return
from sap_cloud_sdk.core.telemetry.instrumentation._registry import (
record_instrumented,
)

record_instrumented(self.library_name)
logger.debug("Instrumented %s", self.library_name)

def uninstrument(self) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor

from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
from sap_cloud_sdk.core.telemetry.instrumentation._registry import Library, register

_instrumentor = AioHttpClientInstrumentor()


class AiohttpInstrumentor(LibraryInstrumentor):
"""Instruments aiohttp client sessions with OTel spans and W3C header propagation."""

library_name = "aiohttp"
library_name = Library.AIOHTTP

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from opentelemetry.instrumentation.django import DjangoInstrumentor

from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
from sap_cloud_sdk.core.telemetry.instrumentation._registry import Library, register

_instrumentor = DjangoInstrumentor()


class DjangoInstrumentorWrapper(LibraryInstrumentor):
"""Instruments Django with OTel spans for inbound HTTP requests and baggage extraction."""

library_name = "django"
library_name = Library.DJANGO

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor

from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
from sap_cloud_sdk.core.telemetry.instrumentation._registry import Library, register

_instrumentor = FastAPIInstrumentor()

Expand All @@ -13,7 +13,7 @@ class FastAPIInstrumentorWrapper(LibraryInstrumentor):
instance via auto_instrument(app=app) from within a lifespan handler.
"""

library_name = "fastapi"
library_name = Library.FASTAPI

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
from opentelemetry.instrumentation.flask import FlaskInstrumentor

from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
from sap_cloud_sdk.core.telemetry.instrumentation._registry import Library, register

_instrumentor = FlaskInstrumentor()


class FlaskInstrumentorWrapper(LibraryInstrumentor):
"""Instruments Flask with OTel spans for inbound HTTP requests and baggage extraction."""

library_name = "flask"
library_name = Library.FLASK

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
)

from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
from sap_cloud_sdk.core.telemetry.instrumentation._registry import Library, register

_client_instrumentor = GrpcInstrumentorClient()
_server_instrumentor = GrpcInstrumentorServer()
Expand All @@ -13,7 +13,7 @@
class GrpcInstrumentorWrapper(LibraryInstrumentor):
"""Instruments gRPC client and server interceptors with OTel spans."""

library_name = "grpc"
library_name = Library.GRPC

def is_instrumented(self) -> bool:
return (
Expand Down
Loading
Loading