Telemetry has two layers that work together:
- Auto-instrumentation handles the what: LLM calls, token counts, latency, model names — automatically, by calling auto_instrument() once.
- Custom spans handle the who and why: which agent, which user, which operation — context that autoinstrumentation can't infer.
The primary pattern is to wrap autoinstrumented calls in a parent span that carries your business context:
invoke_agent span ← you create this (agent name, tenant, session, operation type)
└─ chat span ← autoinstrumentation creates this (model, tokens, latency)
Call before importing AI libraries:
from sap_cloud_sdk.core.telemetry import auto_instrument
auto_instrument()
from litellm import completion
# LLM calls are now automatically tracedWrap your LLM calls to add the context autoinstrumentation can't provide:
from sap_cloud_sdk.core.telemetry import invoke_agent_span
with invoke_agent_span(
provider="openai", agent_name="SupportBot", conversation_id="conv-123"
):
# autoinstrumented LLM call is a child of this span
response = client.chat.completions.create(...)auto_instrument() automatically instruments any supported library that is already installed in the service — no extra configuration needed. If a library is not installed, it is silently skipped.
Supported libraries:
| Library | What is traced |
|---|---|
httpx |
Outbound HTTP requests (sync and async) |
requests |
Outbound HTTP requests |
grpcio |
gRPC client and server calls |
starlette |
Inbound HTTP requests |
fastapi |
Inbound HTTP requests with route details |
aiohttp |
Outbound async HTTP requests |
django |
Inbound HTTP requests |
flask |
Inbound HTTP requests |
sqlalchemy |
Database queries |
logging |
Injects trace_id and span_id into every log record for log-trace correlation |
Instrumentation activates based on what is installed in the service, not on what extras were used to install the SDK. If your service has django in its own requirements, the SDK will instrument it automatically.
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]).
Use get_instrumented_libraries() to query which libraries were actually patched at runtime:
from sap_cloud_sdk.core.telemetry import Library, get_instrumented_libraries
get_instrumented_libraries() # -> [Library.HTTPX, Library.SQLALCHEMY, ...] after auto_instrument(), [] beforeOnly 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.
For operations following OpenTelemetry GenAI conventions:
from sap_cloud_sdk.core.telemetry import chat_span, execute_tool_span, invoke_agent_span
# Agent invocation — top-level parent span for an agent turn
with invoke_agent_span(
provider="openai", agent_name="SupportBot", conversation_id="cid"
):
response = client.beta.threads.runs.create(...)
# LLM chat call — use when autoinstrumentation is not available
with chat_span(model="gpt-4", provider="openai", conversation_id="cid") as span:
response = client.chat.completions.create(...)
# Tool execution
with execute_tool_span(
tool_name="get_weather", tool_type="mcp", tool_description="weather mcp server"
):
result = call_weather_api(location)Use context_overlay for operations without a dedicated function:
from sap_cloud_sdk.core.telemetry import context_overlay, GenAIOperation
with context_overlay(GenAIOperation.RETRIEVAL, attributes={"index": "knowledge-base"}):
documents = retrieve_documents(query)Thread-safe and async-safe. Automatic Propagation.
When calling extension tools (e.g., MCP servers), wrap the call in
extension_context() to propagate extension metadata via OTel baggage:
from sap_cloud_sdk.core.telemetry import (
extension_context,
ExtensionType,
)
# When calling an extension tool
with extension_context(
capability_id="default",
extension_name="ServiceNow Extension",
):
result = await mcp_client.call_tool("generate_offer_letter", args)
# HTTP request includes baggage header with extension metadataAvailable extension types:
ExtensionType.TOOL # MCP tool call (default)
ExtensionType.INSTRUCTION # Instruction/prompt injectionIn downstream services, read the propagated context:
from sap_cloud_sdk.core.telemetry import get_extension_context
ext_ctx = get_extension_context()
if ext_ctx:
print(ext_ctx["capability_id"]) # "default"
print(ext_ctx["extension_name"]) # "ServiceNow Extension"
print(ext_ctx["extension_type"]) # "tool"The extension baggage span processor (registered automatically by auto_instrument())
stamps sap.extension.* attributes on all spans created inside an
extension_context() block, including spans from third-party instrumentation.
It uses a built-in BaggageSpanProcessor under the hood to stamp baggage keys.
GenAIOperation.CHAT
GenAIOperation.TEXT_COMPLETION
GenAIOperation.EMBEDDINGS
GenAIOperation.GENERATE_CONTENT
GenAIOperation.RETRIEVAL
GenAIOperation.EXECUTE_TOOL
GenAIOperation.CREATE_AGENT
GenAIOperation.INVOKE_AGENTauto_instrument() sets up OTel logs alongside traces and metrics. It installs a handler on the root stdlib logger so all existing logging.getLogger(...) calls in your app automatically ship log records to the OTel backend with the same resource attributes (service name, region, subaccount, etc.).
No changes to your logging code are needed:
import logging
logger = logging.getLogger(__name__)
logger.info("Destination fetched")
logger.warning("Retrying request, attempt %d", attempt)
logger.error("Failed to connect", exc_info=True)sap.tenancy.tenant_id and user.id are automatically stamped on every log record when a request context is active — no extra code needed. The same identity used for traces is used for logs.
Resolution priority:
- Runtime context populated by
bootstrap()(GLOBAL_TENANT_ID,USER_IDfromIASContextProvider) - IAS auth context set by
StarletteIASTelemetryMiddleware(sap_gtid,user_uuidclaims) - Omitted when no identity is available (e.g. log lines emitted at startup)
Use extra={} to attach structured attributes to a log record:
logger.info("Request completed", extra={"tenant_id": tid, "duration_ms": 120})By default all levels (DEBUG and above) flow through OTel. To restrict what gets exported, set the level on the root logger or any specific logger:
# Only WARNING and above to OTel
logging.getLogger().setLevel(logging.WARNING)
# Or scope it to your app's logger tree
logging.getLogger("my_app").setLevel(logging.INFO)OTel logs emitted inside an active span are automatically correlated — the trace_id and span_id are injected into the log record. No extra work needed.
The OTel handler is installed on the root stdlib logging logger. Any library that propagates to stdlib works automatically.
Libraries that bypass stdlib entirely need a custom sink that forwards records to logging.getLogger(...).log(...). The OTel handler then picks them up from there.
Add attributes to whichever span is currently active — including autoinstrumented ones:
from sap_cloud_sdk.core.telemetry import add_span_attribute
with invoke_agent_span(provider="openai", agent_name="SupportBot"):
response = client.chat.completions.create(...)
add_span_attribute("response.length", len(response.choices[0].message.content))Every span function yields the span for direct access:
with invoke_agent_span(provider="openai", agent_name="SupportBot") as span:
span.add_event("tool_selected", attributes={"tool": "search"})
response = client.chat.completions.create(...)By default, attributes set on a parent span stay on that span. If you need attributes to also appear on child spans — for example, to filter by user.id at the LLM span level in your observability backend — use propagate=True:
with invoke_agent_span(
provider="openai",
agent_name="SupportBot",
attributes={"user.id": "u-456"},
propagate=True,
):
# child spans automatically receive user.id
with execute_tool_span("search"):
...
with chat_span("gpt-4", "openai"):
...Note:
propagate=Trueis specific for backends that require attributes to appear on every span individually. In most cases, querying by the parent span is sufficient and preferred.
Priority rules — child span values always win (highest to lowest):
- Required semantic keys set by the span function (e.g.
gen_ai.operation.name) - User-provided
attributeson the child span - Propagated attributes from ancestors
Propagation is scoped: once the parent span exits, its attributes stop propagating to subsequent spans.
import logging
from sap_cloud_sdk.core.telemetry import (
auto_instrument,
invoke_agent_span,
execute_tool_span,
set_tenant_id,
add_span_attribute,
)
auto_instrument()
from litellm import completion
logger = logging.getLogger(__name__)
async def handle_request(query: str, user_id: str):
set_tenant_id("bh7sjh...")
logger.info("Handling request", extra={"user_id": user_id})
# Parent span carries business context for the whole agent turn.
# Autoinstrumentation creates the child LLM span automatically.
with invoke_agent_span(
provider="openai", agent_name="SupportBot", attributes={"user.id": user_id}
):
documents = await retrieve_knowledge_base(query)
add_span_attribute("documents.retrieved", len(documents))
logger.debug("Retrieved %d documents", len(documents))
response = completion(
model="gpt-4",
messages=[
{"role": "system", "content": f"Context: {documents}"},
{"role": "user", "content": query},
],
)
return responseauto_instrument accepts a middlewares list for injecting per-request attributes into spans — things like tenant ID and user ID that live in the incoming request but aren't visible to autoinstrumentation.
Each middleware implements two methods:
register()— called once at startup to hook into the web frameworkget_attributes()— called on every span to retrieve the current request's attributes
You can write your own by subclassing TelemetryMiddleware:
from sap_cloud_sdk.core.telemetry.middleware.base import TelemetryMiddleware
class MyMiddleware(TelemetryMiddleware):
def register(self) -> None:
# hook into your framework here
...
def get_attributes(self) -> dict:
# return attributes for the current request
return {"my.attribute": ...}Pass it to auto_instrument:
auto_instrument(middlewares=[MyMiddleware(app=app)])For Starlette/FastAPI apps with IAS authentication, the SDK ships a ready-to-use middleware that reads the Authorization: Bearer <token> header on each request, parses it as an IAS JWT, and injects:
sap.tenancy.tenant_idfrom thesap_gtidclaimuser.idfrom theuser_uuidclaim
If the header is absent or the token cannot be parsed, no attributes are set and the request continues normally.
from starlette.applications import Starlette
from sap_cloud_sdk.core.telemetry import auto_instrument
from sap_cloud_sdk.core.telemetry.middleware import StarletteIASTelemetryMiddleware
app = Starlette(...)
auto_instrument(middlewares=[StarletteIASTelemetryMiddleware(app=app)])Ensure OTEL_EXPORTER_OTLP_ENDPOINT points to your OTLP endpoint.
Print traces to console:
export OTEL_TRACES_EXPORTER=consoleUse an OTLP collector:
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.example.com"Traces, metrics, and logs all use gRPC by default. Switch to HTTP/protobuf by setting:
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"Supported values: grpc (default), http/protobuf.
By default, auto_instrument uses BatchSpanProcessor, which exports spans asynchronously in a background thread and is recommended for production workloads. If you need synchronous span processing (e.g. in short-lived scripts or tests where the process may exit before the batch is flushed), pass disable_batch=True:
auto_instrument(disable_batch=True)export APPFND_CONHOS_SYSTEM_ROLE="S4HC"export SAP_SOLUTION_AREA="AFND"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.
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.
For modules that live inside this package, use the Module and Operation enums:
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): ...External packages that depend on sap-cloud-sdk can pass plain strings directly without contributing to the enums in this repo:
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.
When one SDK module creates a client from another internally, set _telemetry_source so the metric reflects the originating module:
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.
export ORD_DOCUMENT_ID="sap.foo:ord-doc:v1"