diff --git a/pyproject.toml b/pyproject.toml index 419e1792..4124dc96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/sap_cloud_sdk/__init__.py b/src/sap_cloud_sdk/__init__.py index f358d571..ab09f6d4 100644 --- a/src/sap_cloud_sdk/__init__.py +++ b/src/sap_cloud_sdk/__init__.py @@ -2,4 +2,7 @@ from sap_cloud_sdk.core.bootstrap import bootstrap, TelemetryConfig -__all__ = ["bootstrap", "TelemetryConfig"] +__all__ = [ + "bootstrap", + "TelemetryConfig", +] diff --git a/src/sap_cloud_sdk/core/bootstrap.py b/src/sap_cloud_sdk/core/bootstrap.py index 9ef0a4ec..f3649702 100644 --- a/src/sap_cloud_sdk/core/bootstrap.py +++ b/src/sap_cloud_sdk/core/bootstrap.py @@ -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, @@ -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( diff --git a/src/sap_cloud_sdk/core/runtime_context/__init__.py b/src/sap_cloud_sdk/core/runtime_context/__init__.py index 16d8ba29..520a237b 100644 --- a/src/sap_cloud_sdk/core/runtime_context/__init__.py +++ b/src/sap_cloud_sdk/core/runtime_context/__init__.py @@ -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, @@ -46,6 +51,7 @@ import sap_cloud_sdk.core.runtime_context.adapters # noqa: F401 __all__ = [ + "Adapter", "APP_TENANT_ID", "ContextKey", "ContextProvider", @@ -54,6 +60,7 @@ "DWCContextProvider", "FEATURE_TOGGLES", "FrameworkAdapter", + "get_attached_adapters", "GLOBAL_TENANT_ID", "IASContextProvider", "RuntimeContext", diff --git a/src/sap_cloud_sdk/core/runtime_context/_registry.py b/src/sap_cloud_sdk/core/runtime_context/_registry.py index 09e168fd..6b3c83e1 100644 --- a/src/sap_cloud_sdk/core/runtime_context/_registry.py +++ b/src/sap_cloud_sdk/core/runtime_context/_registry.py @@ -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: @@ -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. @@ -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) @@ -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: diff --git a/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py b/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py index 14df6f92..d85648e5 100644 --- a/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py +++ b/src/sap_cloud_sdk/core/runtime_context/adapters/_starlette.py @@ -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 diff --git a/src/sap_cloud_sdk/core/runtime_context/user-guide.md b/src/sap_cloud_sdk/core/runtime_context/user-guide.md index e9709d13..de1b41fd 100644 --- a/src/sap_cloud_sdk/core/runtime_context/user-guide.md +++ b/src/sap_cloud_sdk/core/runtime_context/user-guide.md @@ -203,6 +203,7 @@ editing `bootstrap`. ```python from sap_cloud_sdk.core.runtime_context import ( + Adapter, ContextProvider, FrameworkAdapter, register, @@ -210,6 +211,10 @@ from sap_cloud_sdk.core.runtime_context import ( class FlaskContextAdapter(FrameworkAdapter): + @property + def name(self) -> Adapter: + return "flask" + def _matches(self, app) -> bool: from flask import Flask @@ -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 diff --git a/src/sap_cloud_sdk/core/telemetry/__init__.py b/src/sap_cloud_sdk/core/telemetry/__init__.py index 1febaddd..37c28f02 100644 --- a/src/sap_cloud_sdk/core/telemetry/__init__.py +++ b/src/sap_cloud_sdk/core/telemetry/__init__.py @@ -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", @@ -103,6 +107,8 @@ "emit_extensions_summary_span", "ExtensionContextLogFilter", "TelemetryMiddleware", + "Library", + "get_instrumented_libraries", ] try: diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/_registry.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/_registry.py index d0aa9155..f787cddc 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/_registry.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/_registry.py @@ -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: @@ -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) diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/base.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/base.py index 6e05da63..3e99f470 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/base.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/base.py @@ -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__) @@ -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(): @@ -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: diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/aiohttp.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/aiohttp.py index 4d5c172e..419def07 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/aiohttp.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/aiohttp.py @@ -1,7 +1,7 @@ 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() @@ -9,7 +9,7 @@ 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 diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/django.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/django.py index e0ce5f2e..55da6f82 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/django.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/django.py @@ -1,7 +1,7 @@ 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() @@ -9,7 +9,7 @@ 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 diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/fastapi.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/fastapi.py index 7457aa94..b236bb2b 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/fastapi.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/fastapi.py @@ -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() @@ -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 diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/flask.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/flask.py index 9950438f..256b55f0 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/flask.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/flask.py @@ -1,7 +1,7 @@ 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() @@ -9,7 +9,7 @@ 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 diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/grpc.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/grpc.py index de95929a..d6b9d413 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/grpc.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/grpc.py @@ -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() @@ -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 ( diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py index 3b9ac7e7..cc85df47 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/httpx.py @@ -1,7 +1,7 @@ from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor 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 = HTTPXClientInstrumentor() @@ -9,7 +9,7 @@ class HttpxInstrumentor(LibraryInstrumentor): """Instruments httpx sync and async clients with OTel spans and W3C header propagation.""" - library_name = "httpx" + library_name = Library.HTTPX def is_instrumented(self) -> bool: return _instrumentor.is_instrumented_by_opentelemetry diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py index 97d11827..cc0aff81 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/logging.py @@ -6,7 +6,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 _instrumentor = LoggingInstrumentor() @@ -27,7 +27,7 @@ def _has_otel_handler_on_root() -> bool: class LoggingInstrumentorWrapper(LibraryInstrumentor): """Injects trace_id and span_id into every stdlib log record for log-trace correlation.""" - library_name = "logging" + library_name = Library.LOGGING def is_instrumented(self) -> bool: return _instrumentor.is_instrumented_by_opentelemetry diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/requests.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/requests.py index 72d8198d..aafea74a 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/requests.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/requests.py @@ -1,7 +1,7 @@ from opentelemetry.instrumentation.requests import RequestsInstrumentor 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 = RequestsInstrumentor() @@ -9,7 +9,7 @@ class RequestsInstrumentorWrapper(LibraryInstrumentor): """Instruments the requests library with OTel spans and W3C header propagation.""" - library_name = "requests" + library_name = Library.REQUESTS def is_instrumented(self) -> bool: return _instrumentor.is_instrumented_by_opentelemetry diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/sqlalchemy.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/sqlalchemy.py index cbc09aab..4ee02af5 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/sqlalchemy.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/sqlalchemy.py @@ -1,7 +1,7 @@ from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor 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 = SQLAlchemyInstrumentor() @@ -9,7 +9,7 @@ class SQLAlchemyInstrumentorWrapper(LibraryInstrumentor): """Instruments SQLAlchemy with OTel spans for database queries.""" - library_name = "sqlalchemy" + library_name = Library.SQLALCHEMY def is_instrumented(self) -> bool: return _instrumentor.is_instrumented_by_opentelemetry diff --git a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/starlette.py b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/starlette.py index 9e8378fc..15bfcd93 100644 --- a/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/starlette.py +++ b/src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/starlette.py @@ -1,7 +1,7 @@ from opentelemetry.instrumentation.starlette import StarletteInstrumentor 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 = StarletteInstrumentor() @@ -13,7 +13,7 @@ class StarletteInstrumentorWrapper(LibraryInstrumentor): instance via auto_instrument(app=app) from within a lifespan handler. """ - library_name = "starlette" + library_name = Library.STARLETTE def is_instrumented(self) -> bool: return _instrumentor.is_instrumented_by_opentelemetry diff --git a/src/sap_cloud_sdk/core/telemetry/user-guide.md b/src/sap_cloud_sdk/core/telemetry/user-guide.md index ae6ac4c6..5b9fbeed 100644 --- a/src/sap_cloud_sdk/core/telemetry/user-guide.md +++ b/src/sap_cloud_sdk/core/telemetry/user-guide.md @@ -78,6 +78,18 @@ Instrumentation activates based on what is installed in the service, not on what The SDK ships `opentelemetry-instrumentation-*` packages for all of the above as hard dependencies. The target frameworks themselves are optional — install them via your service's own requirements or via the SDK's convenience extras (e.g. `sap-cloud-sdk[django]`). +### Introspection + +Use `get_instrumented_libraries()` to query which libraries were actually patched at runtime: + +```python +from sap_cloud_sdk.core.telemetry import Library, get_instrumented_libraries + +get_instrumented_libraries() # -> [Library.HTTPX, Library.SQLALCHEMY, ...] after auto_instrument(), [] before +``` + +Only libraries that were installed **and** successfully instrumented appear in the list. Libraries skipped because they are not installed do not appear. Returns an empty list if `auto_instrument()` has not been called yet. + --- ## Span functions diff --git a/tests/core/unit/runtime_context/test_runtime_context.py b/tests/core/unit/runtime_context/test_runtime_context.py index ae417b1c..bbb40bc5 100644 --- a/tests/core/unit/runtime_context/test_runtime_context.py +++ b/tests/core/unit/runtime_context/test_runtime_context.py @@ -25,6 +25,11 @@ sdk_context, set_context, ) +from sap_cloud_sdk.core.runtime_context._registry import ( + Adapter, + get_attached_adapters, + record_attached, +) from sap_cloud_sdk.core.runtime_context.providers._ias import ( APP_TENANT_ID, GLOBAL_TENANT_ID, @@ -432,3 +437,38 @@ def test_single_context_passthrough(self): ctx = RuntimeContext({key: "v"}) merged = _merge([ctx]) assert merged.get(key) == "v" + + +# --------------------------------------------------------------------------- +# get_attached_adapters +# --------------------------------------------------------------------------- + + +class TestGetFrameworkAdapters: + def setup_method(self): + from sap_cloud_sdk.core.runtime_context import _registry as registry_mod + self._original = list(registry_mod._attached) + registry_mod._attached.clear() + + def teardown_method(self): + from sap_cloud_sdk.core.runtime_context import _registry as registry_mod + registry_mod._attached.clear() + registry_mod._attached.extend(self._original) + + def test_empty_before_bootstrap(self): + assert get_attached_adapters() == [] + + def test_records_name_after_record_attached(self): + record_attached(Adapter.STARLETTE) + assert get_attached_adapters() == [Adapter.STARLETTE] + + def test_multiple_calls_accumulate(self): + record_attached(Adapter.STARLETTE) + record_attached(Adapter.STARLETTE) # idempotent + assert get_attached_adapters() == [Adapter.STARLETTE] + + def test_returns_copy(self): + record_attached(Adapter.STARLETTE) + snapshot = get_attached_adapters() + snapshot.clear() + assert get_attached_adapters() == [Adapter.STARLETTE] diff --git a/tests/core/unit/telemetry/instrumentation/test_instrumentation.py b/tests/core/unit/telemetry/instrumentation/test_instrumentation.py index 9c198cf2..ff3d61a3 100644 --- a/tests/core/unit/telemetry/instrumentation/test_instrumentation.py +++ b/tests/core/unit/telemetry/instrumentation/test_instrumentation.py @@ -5,8 +5,11 @@ from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor from sap_cloud_sdk.core.telemetry.instrumentation._registry import ( + Library, _registry, get_registry, + get_instrumented_libraries, + record_instrumented, register, ) @@ -16,7 +19,7 @@ # --------------------------------------------------------------------------- class _ConcreteInstrumentor(LibraryInstrumentor): - library_name = "sys" # always installed + library_name = Library.HTTPX # always installed (hard SDK dependency) def __init__(self): self._instrumented = False @@ -258,3 +261,44 @@ def test_instrument_libraries_calls_all_registered(self): with patch.object(registry_mod, "_registry", [mock_inst]): _instrument_libraries() mock_inst.instrument.assert_called_once() + + +# --------------------------------------------------------------------------- +# get_instrumented_libraries +# --------------------------------------------------------------------------- + +class TestGetInstrumentedLibraries: + def setup_method(self): + from sap_cloud_sdk.core.telemetry.instrumentation import _registry as registry_mod + self._original = list(registry_mod._instrumented) + registry_mod._instrumented.clear() + + def teardown_method(self): + from sap_cloud_sdk.core.telemetry.instrumentation import _registry as registry_mod + registry_mod._instrumented.clear() + registry_mod._instrumented.extend(self._original) + + def test_empty_before_any_instrumentation(self): + assert get_instrumented_libraries() == [] + + def test_records_library_after_successful_instrument(self): + inst = _ConcreteInstrumentor() + inst.instrument() + assert Library.HTTPX in get_instrumented_libraries() + + def test_skipped_library_not_recorded(self): + inst = _MissingLibraryInstrumentor() + inst.instrument() + assert "_nonexistent_library_xyz" not in get_instrumented_libraries() + + def test_idempotent_instrument_records_only_once(self): + inst = _ConcreteInstrumentor() + inst.instrument() + inst.instrument() # is_instrumented() returns True, so _instrument() is skipped + assert get_instrumented_libraries().count(Library.HTTPX) == 1 + + def test_returns_copy(self): + record_instrumented(Library.HTTPX) + snapshot = get_instrumented_libraries() + snapshot.clear() + assert Library.HTTPX in get_instrumented_libraries() diff --git a/uv.lock b/uv.lock index c55004dc..ac0e3fb6 100644 --- a/uv.lock +++ b/uv.lock @@ -3925,7 +3925,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.46.1" +version = "0.47.0" source = { editable = "." } dependencies = [ { name = "cryptography" },