Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5a82654
.env example
LucasAlvesSoares May 29, 2026
f1c3854
Added langgraph and langchain as dev dependencies for telemetry integ…
LucasAlvesSoares May 29, 2026
67699e2
Added langgraph and langchain as dev dependencies for telemetry integ…
LucasAlvesSoares May 29, 2026
8d3952c
Init
LucasAlvesSoares Jun 1, 2026
e7ce59d
Exporter config and setup
LucasAlvesSoares Jun 1, 2026
1ebd6e7
Feature file
LucasAlvesSoares Jun 1, 2026
600b0dd
test drill
LucasAlvesSoares Jun 1, 2026
8a2aeac
resource attribute checks and more traceloop scenarios
LucasAlvesSoares Jun 1, 2026
16ea043
More traceloop scenarios
LucasAlvesSoares Jun 1, 2026
6705c9c
Less but more meaningful scenarios
LucasAlvesSoares Jun 1, 2026
57eba52
Removed verbose
LucasAlvesSoares Jun 2, 2026
9a15857
Linting and logging
LucasAlvesSoares Jun 2, 2026
5a41f3d
Added AICORE* pattern to CI
LucasAlvesSoares Jun 5, 2026
6bd88b6
Upgrade uv for compatibility
LucasAlvesSoares Jun 5, 2026
0e8c68e
Regenerate uv.lock from scratch to fix parse error
LucasAlvesSoares Jun 5, 2026
9ea291c
Revert "Upgrade uv for compatibility"
LucasAlvesSoares Jun 5, 2026
68f1a6a
Merge branch 'main' into telemetry-integration-tests
LucasAlvesSoares Jun 5, 2026
f898534
merge conflicts
LucasAlvesSoares Jun 5, 2026
d54b9c1
Fix ty type errors in integration tests
LucasAlvesSoares Jun 5, 2026
cd1409c
Use dataclass instead of TypedDict for LangGraph State
LucasAlvesSoares Jun 5, 2026
227796e
merge conflicts
LucasAlvesSoares Jun 5, 2026
3ece6c6
Pinned opentelemetry-instrumentation-langchain to >= 0.61.0
LucasAlvesSoares Jun 5, 2026
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
8 changes: 8 additions & 0 deletions .env_integration_tests.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@ CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA='{"url":"https://your-auth-url","cli
APPFND_CONHOS_LANDSCAPE=your-landscape-here
TENANT_SUBDOMAIN=your-tenant-subdomain-here
AGW_USER_TOKEN=your-user-jwt-here

# AI Core — required for Traceloop/LangGraph integration tests
AICORE_CLIENT_ID=your-aicore-client-id-here
AICORE_CLIENT_SECRET=your-aicore-client-secret-here
AICORE_AUTH_URL=https://your-aicore-auth-url-here/oauth/token
AICORE_BASE_URL=https://your-aicore-api-url-here/v2
AICORE_RESOURCE_GROUP=default
AICORE_MODEL=anthropic--claude-3-5-haiku
6 changes: 3 additions & 3 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,20 @@ jobs:
echo "Setting up environment variables for integration tests..."

# Process GitHub secrets (all configuration stored as secrets)
echo '${{ toJSON(secrets) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_")) | "\(.key)=\(.value)"' | while read line; do
echo '${{ toJSON(secrets) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_") or startswith("AICORE_")) | "\(.key)=\(.value)"' | while read line; do
echo "$line" >> $GITHUB_ENV
var_name=$(echo "$line" | cut -d= -f1)
echo "Set secret: $var_name"
done

# Process GitHub variables (all configuration stored as variables)
echo '${{ toJSON(vars) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_")) | "\(.key)=\(.value)"' | while read line; do
echo '${{ toJSON(vars) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_") or startswith("AICORE_")) | "\(.key)=\(.value)"' | while read line; do
echo "$line" >> $GITHUB_ENV
var_name=$(echo "$line" | cut -d= -f1)
echo "Set variable: $var_name"
done

echo "Environment setup complete - automatically configured all CLOUD_SDK_CFG_* environment variables and secrets"
echo "Environment setup complete - automatically configured all CLOUD_SDK_CFG_* and AICORE_* environment variables and secrets"

- name: Run integration tests
run: uv run pytest tests/*/integration/ -v --tb=short
8 changes: 7 additions & 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.23.1"
version = "0.23.2"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand All @@ -19,6 +19,7 @@ dependencies = [
"opentelemetry-exporter-otlp-proto-http~=1.42.1",
"opentelemetry-processor-baggage~=0.61b0",
"traceloop-sdk~=0.61.0",
"opentelemetry-instrumentation-langchain>=0.61.0",
"httpx>=0.27.0",
"PyJWT~=2.12.1",
"protobuf>=4.25.0",
Expand Down Expand Up @@ -56,11 +57,16 @@ dev = [
"httpx>=0.27.0",
"a2a-sdk>=0.2.0",
"langchain-core>=1.2.7",
"langgraph>=0.2.0",
"langchain-community>=0.3.0",
"litellm>=1.40.0",
"langchain-litellm>=0.6.6",
]

[tool.pytest.ini_options]
markers = [
"integration: marks tests as integration tests (requires Docker)",
"aicore: marks tests that require AI Core credentials (AICORE_* env vars)",
]

[tool.coverage.run]
Expand Down
10 changes: 10 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/auto_instrument.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def auto_instrument(
_merge_resource_attrs_into_active_provider_if_wrapper_installed(resource)

_set_baggage_processor()
_set_propagated_attributes_processor()

if middlewares:
_register_middleware_processors(middlewares)
Expand Down Expand Up @@ -119,6 +120,15 @@ def _set_baggage_processor():
provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
logger.info("Registered BaggageSpanProcessor for extension attribute propagation")


def _set_propagated_attributes_processor():
provider = trace.get_tracer_provider()
if not isinstance(provider, TracerProvider):
logger.warning(
"Unknown TracerProvider type. Skipping PropagatedAttributesSpanProcessor"
)
return

provider.add_span_processor(PropagatedAttributesSpanProcessor())
logger.info(
"Registered PropagatedAttributesSpanProcessor for ContextVar attribute propagation"
Expand Down
Empty file.
41 changes: 41 additions & 0 deletions tests/core/integration/telemetry/_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""LangGraph test agent used by the telemetry integration tests."""

import os
from dataclasses import dataclass
from typing import Annotated

import pytest

from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages


@dataclass
class State:
messages: Annotated[list[BaseMessage], add_messages]


def build_langgraph_agent():
"""Build a minimal single-node LangGraph agent backed by LiteLLM via AI Core.

Requires AICORE_MODEL env var (e.g. "anthropic--claude-3-5-haiku") in addition
to the AICORE_* credentials set by set_aicore_config(). LiteLLM uses the
"sap/<model>" prefix to route through the SAP AI Core provider.
"""
try:
from langchain_litellm import ChatLiteLLM
from langgraph.graph import END, StateGraph
except ImportError:
pytest.skip("langchain-litellm or langgraph not installed")

model_name = os.environ.get("AICORE_MODEL") or "anthropic--claude-4.5-sonnet"
llm = ChatLiteLLM(model=f"sap/{model_name}")

def call_llm(state: State) -> State:
return State(messages=[llm.invoke(state.messages)])

graph = StateGraph(State)
graph.add_node("llm", call_llm)
graph.set_entry_point("llm")
graph.add_edge("llm", END)
return graph.compile()
74 changes: 74 additions & 0 deletions tests/core/integration/telemetry/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Fixtures for telemetry integration tests."""

import os
from pathlib import Path
from unittest.mock import patch

import pytest
from dotenv import load_dotenv
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from sap_cloud_sdk.aicore import set_aicore_config
from sap_cloud_sdk.core.telemetry.auto_instrument import auto_instrument
from sap_cloud_sdk.core.telemetry.genai_attribute_transformer import (
GenAIAttributeTransformer,
)

_env_file = Path(__file__).parent.parent.parent.parent.parent / ".env_integration_tests"
if _env_file.exists():
load_dotenv(_env_file, override=True)


@pytest.fixture(scope="session")
def memory_exporter() -> InMemorySpanExporter:
"""Initialize auto_instrument once per session and inject an in-memory exporter.

Uses OTEL_TRACES_EXPORTER=console so Traceloop.init runs for real without
needing a real collector endpoint. A second SimpleSpanProcessor backed by
InMemorySpanExporter is added afterward for test assertions on raw spans.
"""
raw_exporter = InMemorySpanExporter()
with patch.dict(os.environ, {"OTEL_TRACES_EXPORTER": "console"}, clear=False):
auto_instrument(disable_batch=True)
provider = trace.get_tracer_provider()
assert isinstance(provider, TracerProvider)
provider.add_span_processor(SimpleSpanProcessor(raw_exporter))
return raw_exporter


@pytest.fixture(scope="session")
def transforming_exporter(memory_exporter: InMemorySpanExporter) -> InMemorySpanExporter:
"""Inject a second in-memory exporter wrapped in GenAIAttributeTransformer.

Used by traceloop.feature tests to assert on transformer output
(gen_ai.* attributes present, llm.usage.* and traceloop.* absent).
"""
transformed_sink = InMemorySpanExporter()
provider = trace.get_tracer_provider()
assert isinstance(provider, TracerProvider)
provider.add_span_processor(
SimpleSpanProcessor(GenAIAttributeTransformer(transformed_sink))
)
return transformed_sink


@pytest.fixture(autouse=True)
def clear_spans(memory_exporter: InMemorySpanExporter, transforming_exporter: InMemorySpanExporter):
"""Clear both exporters before each test so spans don't bleed between scenarios."""
memory_exporter.clear()
transforming_exporter.clear()


@pytest.fixture(scope="session")
def aicore_configured():
"""Call set_aicore_config() once per session.

Skips if AICORE_BASE_URL is absent — tests depending on this fixture are
skipped automatically.
"""
if not os.environ.get("AICORE_BASE_URL"):
pytest.skip("AICORE_BASE_URL not set — skipping AI Core integration tests")
set_aicore_config()
84 changes: 84 additions & 0 deletions tests/core/integration/telemetry/telemetry.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
Feature: SDK telemetry instrumentation
Comment thread
cassiofariasmachado marked this conversation as resolved.

Background:
Given auto_instrument is initialized

Scenario: invoke_agent_span emits a span with required GenAI attributes
When I invoke an agent with provider "test" and name "bot" and conversation_id "c1"
Then a span named "invoke_agent bot" is recorded
And the span has attribute "gen_ai.operation.name" equal to "invoke_agent"
And the span has attribute "gen_ai.provider.name" equal to "test"
And the span has attribute "gen_ai.agent.name" equal to "bot"
And the span has attribute "gen_ai.conversation.id" equal to "c1"

Scenario: invoke_agent_span records errors
When I invoke an agent that raises an exception
Then the span status is ERROR
And the span has an exception event

Scenario: spans carry SDK resource attributes
When I invoke an agent with provider "test" and name "sdk-resource-test"
Then a span named "invoke_agent sdk-resource-test" is recorded
And the span resource has attribute "sap.cloud_sdk.name" equal to "SAP Cloud SDK for Python"
And the span resource has attribute "sap.cloud_sdk.language" equal to "python"
And the span resource has attribute "sap.cloud_sdk.version" set

# Real LLM call scenarios — require AI Core credentials

@aicore
Scenario: invoke_agent_span wrapping a real LLM call produces a complete trace
Given AI Core is configured via set_aicore_config
When I invoke an agent wrapping a direct LLM call
Then a span named "invoke_agent llm-agent" is recorded
And a span with operation "chat" is a child of "invoke_agent llm-agent"
And that span has attribute "gen_ai.usage.input_tokens" set
And that span has attribute "gen_ai.usage.output_tokens" set
And the span "invoke_agent llm-agent" has resource attribute "sap.cloud_sdk.name" equal to "SAP Cloud SDK for Python"
And the span "invoke_agent llm-agent" has resource attribute "sap.cloud_sdk.language" equal to "python"
And the span "invoke_agent llm-agent" has resource attribute "sap.cloud_sdk.version" set

@aicore
Scenario: invoke_agent_span wrapping LLM call then tool produces a full agentic trace
Given AI Core is configured via set_aicore_config
When I invoke an agent that calls an LLM then executes a tool
Then a span named "invoke_agent agent-with-tool" is recorded
And a span with operation "chat" is a child of "invoke_agent agent-with-tool"
And that span has attribute "gen_ai.usage.input_tokens" set
And the span "execute_tool search" is a child of "invoke_agent agent-with-tool"
And the span "execute_tool search" has attribute "gen_ai.tool.name" equal to "search"

@aicore
Scenario: propagate=True flows invoke_agent attributes to nested LLM span
Given AI Core is configured via set_aicore_config
When I invoke an agent with propagate=True wrapping a real LLM call
Then a span with operation "chat" is a child of "invoke_agent propagate-llm-agent"
And that span has attribute "custom.session" equal to "s42"
And that span has attribute "gen_ai.usage.input_tokens" set

@aicore
Scenario: propagate=False does not leak invoke_agent attributes to nested LLM span
Given AI Core is configured via set_aicore_config
When I invoke an agent with propagate=False wrapping a real LLM call
Then a span with operation "chat" is a child of "invoke_agent no-propagate-llm-agent"
And that span does not have attribute "custom.session"

@aicore
Scenario: baggage attributes propagate to Traceloop-instrumented LLM spans
Given baggage key "sap.extension.capabilityId" is set to "cap-traceloop"
And AI Core is configured via set_aicore_config
When I invoke an agent wrapping a direct LLM call with baggage
Then a span with operation "chat" is a child of "invoke_agent baggage-llm-agent"
And that span has attribute "sap.extension.capabilityId" equal to "cap-traceloop"

@aicore
Scenario: LangGraph agent run produces an invoke_agent span with LangChain child spans
Given AI Core is configured via set_aicore_config
When I run a LangGraph agent with provider "sap-aicore" and name "test-agent"
Then a span named "invoke_agent test-agent" is recorded
And at least one descendant span with attribute "gen_ai.operation.name" equal to "chat" is recorded
And at least one descendant span has attribute "gen_ai.request.model" set
And at least one descendant span has attribute "gen_ai.usage.input_tokens" set
And at least one descendant span has attribute "gen_ai.usage.output_tokens" set
And no descendant span has an attribute starting with "llm.usage."
And no descendant span has attribute "traceloop.association.properties.ls_model_name"
And no descendant span has attribute "traceloop.association.properties.ls_provider"
Loading
Loading