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
23 changes: 21 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.38.0"
version = "0.39.0"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -26,13 +26,27 @@ dependencies = [
"grpcio>=1.60.0",
"opentelemetry-api>=1.42.1",
"opentelemetry-sdk>=1.42.1",
"opentelemetry-instrumentation-httpx~=0.63b1",
"opentelemetry-instrumentation-requests~=0.63b1",
"opentelemetry-instrumentation-grpc~=0.63b1",
"opentelemetry-instrumentation-logging~=0.63b1",
"opentelemetry-instrumentation-starlette~=0.63b1",
"opentelemetry-instrumentation-fastapi~=0.63b1",
"opentelemetry-instrumentation-aiohttp-client~=0.63b1",
"opentelemetry-instrumentation-sqlalchemy~=0.63b1",
"opentelemetry-instrumentation-django~=0.63b1",
"opentelemetry-instrumentation-flask~=0.63b1",
"mcp>=1.1.0",
]

[project.optional-dependencies]
extensibility = ["a2a-sdk>=0.2.0"]
starlette = ["starlette>=0.40.0"]
langchain = ["langchain-core>=1.2.7"]
fastapi = ["fastapi>=0.100.0"]
aiohttp = ["aiohttp>=3.9.0"]
sqlalchemy = ["sqlalchemy>=2.0.0"]
django = ["django>=4.0"]
flask = ["flask>=3.0"]
langgraph = ["langgraph>=1.0.0"]

[build-system]
Expand All @@ -55,6 +69,11 @@ dev = [
"starlette>=0.40.0",
"anyio>=3.6.2",
"httpx>=0.27.0",
"fastapi>=0.100.0",
"aiohttp>=3.9.0",
"sqlalchemy>=2.0.0",
"django>=4.0",
"flask>=3.0",
"a2a-sdk>=0.2.0",
"langchain-core>=1.2.7",
"langgraph>=0.2.0",
Expand Down
13 changes: 13 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/auto_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from sap_cloud_sdk.core.telemetry.span_processors.propagated_attributes_processor import (
PropagatedAttributesSpanProcessor,
)
from sap_cloud_sdk.core.telemetry.instrumentation import get_registry

logger = logging.getLogger(__name__)

Expand All @@ -44,6 +45,7 @@
def auto_instrument(
disable_batch: bool = False,
middlewares: list[TelemetryMiddleware] | None = None,
app=None,
):
"""
Initialize meta-instrumentation for GenAI tracing. Should be initialized before any AI frameworks.
Expand All @@ -61,6 +63,10 @@ def auto_instrument(
the middlewares appear as attributes on every span.
Must be called before the ASGI application begins serving
requests so that register() runs before the first request.
app: Optional ASGI app instance (Starlette, FastAPI). When provided, framework
instrumentors call instrument_app(app) instead of the global instrument(),
which is required when the app is already constructed before auto_instrument()
is called. Use from within a lifespan handler to ensure correct ordering.
"""
otel_endpoint = os.getenv(ENV_OTLP_ENDPOINT, "")
console_traces = os.getenv(ENV_TRACES_EXPORTER, "").lower() == "console"
Expand Down Expand Up @@ -90,6 +96,8 @@ def auto_instrument(
if middlewares:
_register_middleware_processors(middlewares)

_instrument_libraries(app=app)

logger.info("Cloud auto instrumentation initialized successfully")


Expand Down Expand Up @@ -159,6 +167,11 @@ def _register_middleware_processors(middlewares: list[TelemetryMiddleware]) -> N
)


def _instrument_libraries(**kwargs) -> None:
for instrumentor in get_registry():
instrumentor.instrument(**kwargs)


def _merge_resource_attrs_into_active_provider_if_wrapper_installed(
sap_attrs: dict,
) -> None:
Expand Down
55 changes: 55 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/instrumentation/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
from sap_cloud_sdk.core.telemetry.instrumentation._registry import (
register,
get_registry,
)

# Import concrete instrumentors to trigger their register() calls.
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import ( # noqa: F401
httpx,
requests,
grpc,
logging,
)

# Optional — guarded so missing extras don't break the import.
try:
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import starlette # noqa: F401
except ImportError:
pass

try:
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import fastapi # noqa: F401
except ImportError:
pass

try:
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import aiohttp # noqa: F401
except ImportError:
pass

try:
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import sqlalchemy # noqa: F401
except ImportError:
pass

try:
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import redis # noqa: F401 # ty: ignore[unresolved-import]
except ImportError:
pass

try:
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import django # noqa: F401
except ImportError:
pass

try:
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import flask # noqa: F401
except ImportError:
pass

__all__ = [
"LibraryInstrumentor",
"register",
"get_registry",
]
16 changes: 16 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/instrumentation/_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor

_registry: list[LibraryInstrumentor] = []


def register(instrumentor: LibraryInstrumentor) -> None:
"""Add an instrumentor to the registry.

Call this at module level in each concrete instrumentor file, or from
third-party code that wants to plug in additional library coverage.
"""
_registry.append(instrumentor)


def get_registry() -> list[LibraryInstrumentor]:
return list(_registry)
58 changes: 58 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/instrumentation/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import importlib.util
import logging
from abc import ABC, abstractmethod
from typing import Any

logger = logging.getLogger(__name__)


class LibraryInstrumentor(ABC):
"""Base class for optional library instrumentors.

Subclasses wrap a single opentelemetry-instrumentation-* package.
The target library (e.g. httpx) is checked at runtime via find_spec so
that missing optional dependencies are silently skipped rather than
raising ImportError.

kwargs passed to instrument() are forwarded to _instrument(), allowing
subclasses to accept optional arguments (e.g. app= for framework instrumentors).
"""

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

def instrument(self, **kwargs: Any) -> None:
if not self._is_library_installed():
logger.debug(
"%s not installed, skipping instrumentation", self.library_name
)
return
if self.is_instrumented():
logger.debug("%s already instrumented", self.library_name)
return
try:
self._instrument(**kwargs)
except ImportError:
logger.debug(
"%s instrumentation skipped — library not importable", self.library_name
)
return
logger.debug("Instrumented %s", self.library_name)

def uninstrument(self) -> None:
if not self.is_instrumented():
return
self._uninstrument()
logger.debug("Uninstrumented %s", self.library_name)

@abstractmethod
def is_instrumented(self) -> bool: ...

@abstractmethod
def _instrument(self, **kwargs: Any) -> None: ...

@abstractmethod
def _uninstrument(self) -> None: ...

def _is_library_installed(self) -> bool:
return importlib.util.find_spec(self.library_name) is not None
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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

_instrumentor = AioHttpClientInstrumentor()


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

library_name = "aiohttp"

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry

def _instrument(self, **kwargs) -> None:
_instrumentor.instrument()

def _uninstrument(self) -> None:
_instrumentor.uninstrument()


register(AiohttpInstrumentor())
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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

_instrumentor = DjangoInstrumentor()


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

library_name = "django"

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry

def _instrument(self, **kwargs) -> None:
_instrumentor.instrument()

def _uninstrument(self) -> None:
_instrumentor.uninstrument()


register(DjangoInstrumentorWrapper())
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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

_instrumentor = FastAPIInstrumentor()


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

Must be called before the FastAPI app instance is constructed, or pass the app
instance via auto_instrument(app=app) from within a lifespan handler.
"""

library_name = "fastapi"

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry

def _instrument(self, **kwargs) -> None:
from fastapi import FastAPI

app = kwargs.get("app")
if app is None or not isinstance(app, FastAPI):
_instrumentor.instrument()
return
FastAPIInstrumentor.instrument_app(app)

def _uninstrument(self) -> None:
_instrumentor.uninstrument()


register(FastAPIInstrumentorWrapper())
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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

_instrumentor = FlaskInstrumentor()


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

library_name = "flask"

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry

def _instrument(self, **kwargs) -> None:
_instrumentor.instrument()

def _uninstrument(self) -> None:
_instrumentor.uninstrument()


register(FlaskInstrumentorWrapper())
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from opentelemetry.instrumentation.grpc import (
GrpcInstrumentorClient,
GrpcInstrumentorServer,
)

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

_client_instrumentor = GrpcInstrumentorClient()
_server_instrumentor = GrpcInstrumentorServer()


class GrpcInstrumentorWrapper(LibraryInstrumentor):
"""Instruments gRPC client and server interceptors with OTel spans."""

library_name = "grpc"

def is_instrumented(self) -> bool:
return (
_client_instrumentor.is_instrumented_by_opentelemetry
or _server_instrumentor.is_instrumented_by_opentelemetry
)

def _instrument(self, **kwargs) -> None:
_client_instrumentor.instrument()
_server_instrumentor.instrument()

def _uninstrument(self) -> None:
_client_instrumentor.uninstrument()
_server_instrumentor.uninstrument()


register(GrpcInstrumentorWrapper())
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
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

_instrumentor = HTTPXClientInstrumentor()


class HttpxInstrumentor(LibraryInstrumentor):
"""Instruments httpx sync and async clients with OTel spans and W3C header propagation."""

library_name = "httpx"

def is_instrumented(self) -> bool:
return _instrumentor.is_instrumented_by_opentelemetry

def _instrument(self, **kwargs) -> None:
_instrumentor.instrument()

def _uninstrument(self) -> None:
_instrumentor.uninstrument()


register(HttpxInstrumentor())
Loading
Loading