Skip to content

Commit 67a0a54

Browse files
feat: app-level instrumentation
1 parent cf61b6b commit 67a0a54

20 files changed

Lines changed: 1031 additions & 7 deletions

File tree

‎pyproject.toml‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sap-cloud-sdk"
3-
version = "0.38.0"
3+
version = "0.39.0"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -26,13 +26,27 @@ dependencies = [
2626
"grpcio>=1.60.0",
2727
"opentelemetry-api>=1.42.1",
2828
"opentelemetry-sdk>=1.42.1",
29+
"opentelemetry-instrumentation-httpx~=0.63b1",
30+
"opentelemetry-instrumentation-requests~=0.63b1",
31+
"opentelemetry-instrumentation-grpc~=0.63b1",
32+
"opentelemetry-instrumentation-logging~=0.63b1",
33+
"opentelemetry-instrumentation-starlette~=0.63b1",
34+
"opentelemetry-instrumentation-fastapi~=0.63b1",
35+
"opentelemetry-instrumentation-aiohttp-client~=0.63b1",
36+
"opentelemetry-instrumentation-sqlalchemy~=0.63b1",
37+
"opentelemetry-instrumentation-django~=0.63b1",
38+
"opentelemetry-instrumentation-flask~=0.63b1",
2939
"mcp>=1.1.0",
3040
]
3141

3242
[project.optional-dependencies]
3343
extensibility = ["a2a-sdk>=0.2.0"]
3444
starlette = ["starlette>=0.40.0"]
35-
langchain = ["langchain-core>=1.2.7"]
45+
fastapi = ["fastapi>=0.100.0"]
46+
aiohttp = ["aiohttp>=3.9.0"]
47+
sqlalchemy = ["sqlalchemy>=2.0.0"]
48+
django = ["django>=4.0"]
49+
flask = ["flask>=3.0"]
3650
langgraph = ["langgraph>=1.0.0"]
3751

3852
[build-system]
@@ -55,6 +69,11 @@ dev = [
5569
"starlette>=0.40.0",
5670
"anyio>=3.6.2",
5771
"httpx>=0.27.0",
72+
"fastapi>=0.100.0",
73+
"aiohttp>=3.9.0",
74+
"sqlalchemy>=2.0.0",
75+
"django>=4.0",
76+
"flask>=3.0",
5877
"a2a-sdk>=0.2.0",
5978
"langchain-core>=1.2.7",
6079
"langgraph>=0.2.0",

‎src/sap_cloud_sdk/core/telemetry/auto_instrument.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from sap_cloud_sdk.core.telemetry.span_processors.propagated_attributes_processor import (
3737
PropagatedAttributesSpanProcessor,
3838
)
39+
from sap_cloud_sdk.core.telemetry.instrumentation import get_registry
3940

4041
logger = logging.getLogger(__name__)
4142

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

99+
_instrument_libraries(app=app)
100+
93101
logger.info("Cloud auto instrumentation initialized successfully")
94102

95103

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

161169

170+
def _instrument_libraries(**kwargs) -> None:
171+
for instrumentor in get_registry():
172+
instrumentor.instrument(**kwargs)
173+
174+
162175
def _merge_resource_attrs_into_active_provider_if_wrapper_installed(
163176
sap_attrs: dict,
164177
) -> None:
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
2+
from sap_cloud_sdk.core.telemetry.instrumentation._registry import (
3+
register,
4+
get_registry,
5+
)
6+
7+
# Import concrete instrumentors to trigger their register() calls.
8+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import ( # noqa: F401
9+
httpx,
10+
requests,
11+
grpc,
12+
logging,
13+
)
14+
15+
# Optional — guarded so missing extras don't break the import.
16+
try:
17+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import starlette # noqa: F401
18+
except ImportError:
19+
pass
20+
21+
try:
22+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import fastapi # noqa: F401
23+
except ImportError:
24+
pass
25+
26+
try:
27+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import aiohttp # noqa: F401
28+
except ImportError:
29+
pass
30+
31+
try:
32+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import sqlalchemy # noqa: F401
33+
except ImportError:
34+
pass
35+
36+
try:
37+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import redis # noqa: F401 # ty: ignore[unresolved-import]
38+
except ImportError:
39+
pass
40+
41+
try:
42+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import django # noqa: F401
43+
except ImportError:
44+
pass
45+
46+
try:
47+
from sap_cloud_sdk.core.telemetry.instrumentation.instrumentors import flask # noqa: F401
48+
except ImportError:
49+
pass
50+
51+
__all__ = [
52+
"LibraryInstrumentor",
53+
"register",
54+
"get_registry",
55+
]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
2+
3+
_registry: list[LibraryInstrumentor] = []
4+
5+
6+
def register(instrumentor: LibraryInstrumentor) -> None:
7+
"""Add an instrumentor to the registry.
8+
9+
Call this at module level in each concrete instrumentor file, or from
10+
third-party code that wants to plug in additional library coverage.
11+
"""
12+
_registry.append(instrumentor)
13+
14+
15+
def get_registry() -> list[LibraryInstrumentor]:
16+
return list(_registry)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import importlib.util
2+
import logging
3+
from abc import ABC, abstractmethod
4+
from typing import Any
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class LibraryInstrumentor(ABC):
10+
"""Base class for optional library instrumentors.
11+
12+
Subclasses wrap a single opentelemetry-instrumentation-* package.
13+
The target library (e.g. httpx) is checked at runtime via find_spec so
14+
that missing optional dependencies are silently skipped rather than
15+
raising ImportError.
16+
17+
kwargs passed to instrument() are forwarded to _instrument(), allowing
18+
subclasses to accept optional arguments (e.g. app= for framework instrumentors).
19+
"""
20+
21+
#: Import name of the library being instrumented (e.g. "httpx").
22+
library_name: str
23+
24+
def instrument(self, **kwargs: Any) -> None:
25+
if not self._is_library_installed():
26+
logger.debug(
27+
"%s not installed, skipping instrumentation", self.library_name
28+
)
29+
return
30+
if self.is_instrumented():
31+
logger.debug("%s already instrumented", self.library_name)
32+
return
33+
try:
34+
self._instrument(**kwargs)
35+
except ImportError:
36+
logger.debug(
37+
"%s instrumentation skipped — library not importable", self.library_name
38+
)
39+
return
40+
logger.debug("Instrumented %s", self.library_name)
41+
42+
def uninstrument(self) -> None:
43+
if not self.is_instrumented():
44+
return
45+
self._uninstrument()
46+
logger.debug("Uninstrumented %s", self.library_name)
47+
48+
@abstractmethod
49+
def is_instrumented(self) -> bool: ...
50+
51+
@abstractmethod
52+
def _instrument(self, **kwargs: Any) -> None: ...
53+
54+
@abstractmethod
55+
def _uninstrument(self) -> None: ...
56+
57+
def _is_library_installed(self) -> bool:
58+
return importlib.util.find_spec(self.library_name) is not None

‎src/sap_cloud_sdk/core/telemetry/instrumentation/instrumentors/__init__.py‎

Whitespace-only changes.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor
2+
3+
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
4+
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
5+
6+
_instrumentor = AioHttpClientInstrumentor()
7+
8+
9+
class AiohttpInstrumentor(LibraryInstrumentor):
10+
"""Instruments aiohttp client sessions with OTel spans and W3C header propagation."""
11+
12+
library_name = "aiohttp"
13+
14+
def is_instrumented(self) -> bool:
15+
return _instrumentor.is_instrumented_by_opentelemetry
16+
17+
def _instrument(self, **kwargs) -> None:
18+
_instrumentor.instrument()
19+
20+
def _uninstrument(self) -> None:
21+
_instrumentor.uninstrument()
22+
23+
24+
register(AiohttpInstrumentor())
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from opentelemetry.instrumentation.django import DjangoInstrumentor
2+
3+
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
4+
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
5+
6+
_instrumentor = DjangoInstrumentor()
7+
8+
9+
class DjangoInstrumentorWrapper(LibraryInstrumentor):
10+
"""Instruments Django with OTel spans for inbound HTTP requests and baggage extraction."""
11+
12+
library_name = "django"
13+
14+
def is_instrumented(self) -> bool:
15+
return _instrumentor.is_instrumented_by_opentelemetry
16+
17+
def _instrument(self, **kwargs) -> None:
18+
_instrumentor.instrument()
19+
20+
def _uninstrument(self) -> None:
21+
_instrumentor.uninstrument()
22+
23+
24+
register(DjangoInstrumentorWrapper())
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
2+
3+
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
4+
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
5+
6+
_instrumentor = FastAPIInstrumentor()
7+
8+
9+
class FastAPIInstrumentorWrapper(LibraryInstrumentor):
10+
"""Instruments FastAPI with OTel spans for inbound HTTP requests and baggage extraction.
11+
12+
Must be called before the FastAPI app instance is constructed, or pass the app
13+
instance via auto_instrument(app=app) from within a lifespan handler.
14+
"""
15+
16+
library_name = "fastapi"
17+
18+
def is_instrumented(self) -> bool:
19+
return _instrumentor.is_instrumented_by_opentelemetry
20+
21+
def _instrument(self, **kwargs) -> None:
22+
from fastapi import FastAPI
23+
24+
app = kwargs.get("app")
25+
if app is None or not isinstance(app, FastAPI):
26+
_instrumentor.instrument()
27+
return
28+
FastAPIInstrumentor.instrument_app(app)
29+
30+
def _uninstrument(self) -> None:
31+
_instrumentor.uninstrument()
32+
33+
34+
register(FastAPIInstrumentorWrapper())
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from opentelemetry.instrumentation.flask import FlaskInstrumentor
2+
3+
from sap_cloud_sdk.core.telemetry.instrumentation.base import LibraryInstrumentor
4+
from sap_cloud_sdk.core.telemetry.instrumentation._registry import register
5+
6+
_instrumentor = FlaskInstrumentor()
7+
8+
9+
class FlaskInstrumentorWrapper(LibraryInstrumentor):
10+
"""Instruments Flask with OTel spans for inbound HTTP requests and baggage extraction."""
11+
12+
library_name = "flask"
13+
14+
def is_instrumented(self) -> bool:
15+
return _instrumentor.is_instrumented_by_opentelemetry
16+
17+
def _instrument(self, **kwargs) -> None:
18+
_instrumentor.instrument()
19+
20+
def _uninstrument(self) -> None:
21+
_instrumentor.uninstrument()
22+
23+
24+
register(FlaskInstrumentorWrapper())

0 commit comments

Comments
 (0)