Skip to content

Commit b5ac77f

Browse files
fix: pin opentelemetry-instrumentation-langchain and add telemetry integration tests
1 parent 4b50671 commit b5ac77f

10 files changed

Lines changed: 1635 additions & 16 deletions

File tree

.env_integration_tests.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,11 @@ CLOUD_SDK_CFG_HANA_AGENT_MEMORY_DEFAULT_UAA='{"url":"https://your-auth-url","cli
2929
APPFND_CONHOS_LANDSCAPE=your-landscape-here
3030
TENANT_SUBDOMAIN=your-tenant-subdomain-here
3131
AGW_USER_TOKEN=your-user-jwt-here
32+
33+
# AI Core — required for Traceloop/LangGraph integration tests
34+
AICORE_CLIENT_ID=your-aicore-client-id-here
35+
AICORE_CLIENT_SECRET=your-aicore-client-secret-here
36+
AICORE_AUTH_URL=https://your-aicore-auth-url-here/oauth/token
37+
AICORE_BASE_URL=https://your-aicore-api-url-here/v2
38+
AICORE_RESOURCE_GROUP=default
39+
AICORE_MODEL=anthropic--claude-3-5-haiku

.github/workflows/integration-tests.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,20 @@ jobs:
4444
echo "Setting up environment variables for integration tests..."
4545
4646
# Process GitHub secrets (all configuration stored as secrets)
47-
echo '${{ toJSON(secrets) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_")) | "\(.key)=\(.value)"' | while read line; do
47+
echo '${{ toJSON(secrets) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_") or startswith("AICORE_")) | "\(.key)=\(.value)"' | while read line; do
4848
echo "$line" >> $GITHUB_ENV
4949
var_name=$(echo "$line" | cut -d= -f1)
5050
echo "Set secret: $var_name"
5151
done
5252
5353
# Process GitHub variables (all configuration stored as variables)
54-
echo '${{ toJSON(vars) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_")) | "\(.key)=\(.value)"' | while read line; do
54+
echo '${{ toJSON(vars) }}' | jq -r 'to_entries[] | select(.key | startswith("CLOUD_SDK_CFG_") or startswith("AICORE_")) | "\(.key)=\(.value)"' | while read line; do
5555
echo "$line" >> $GITHUB_ENV
5656
var_name=$(echo "$line" | cut -d= -f1)
5757
echo "Set variable: $var_name"
5858
done
5959
60-
echo "Environment setup complete - automatically configured all CLOUD_SDK_CFG_* environment variables and secrets"
60+
echo "Environment setup complete - automatically configured all CLOUD_SDK_CFG_* and AICORE_* environment variables and secrets"
6161
6262
- name: Run integration tests
6363
run: uv run pytest tests/*/integration/ -v --tb=short

pyproject.toml

Lines changed: 8 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.23.1"
3+
version = "0.23.2"
44
description = "SAP Cloud SDK for Python"
55
readme = "README.md"
66
license = "Apache-2.0"
@@ -19,8 +19,9 @@ dependencies = [
1919
"opentelemetry-exporter-otlp-proto-http~=1.42.1",
2020
"opentelemetry-processor-baggage~=0.61b0",
2121
"traceloop-sdk~=0.61.0",
22+
"opentelemetry-instrumentation-langchain>=0.61.0",
2223
"httpx>=0.27.0",
23-
"PyJWT~=2.12.1",
24+
"PyJWT>=2.13.0",
2425
"protobuf>=4.25.0",
2526
"protovalidate>=0.13.0",
2627
"grpcio>=1.60.0",
@@ -56,11 +57,16 @@ dev = [
5657
"httpx>=0.27.0",
5758
"a2a-sdk>=0.2.0",
5859
"langchain-core>=1.2.7",
60+
"langgraph>=0.2.0",
61+
"langchain-community>=0.3.0",
62+
"litellm>=1.40.0",
63+
"langchain-litellm>=0.6.6",
5964
]
6065

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

6672
[tool.coverage.run]

src/sap_cloud_sdk/core/telemetry/auto_instrument.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def auto_instrument(
8282
_merge_resource_attrs_into_active_provider_if_wrapper_installed(resource)
8383

8484
_set_baggage_processor()
85+
_set_propagated_attributes_processor()
8586

8687
if middlewares:
8788
_register_middleware_processors(middlewares)
@@ -119,6 +120,15 @@ def _set_baggage_processor():
119120
provider.add_span_processor(BaggageSpanProcessor(ALLOW_ALL_BAGGAGE_KEYS))
120121
logger.info("Registered BaggageSpanProcessor for extension attribute propagation")
121122

123+
124+
def _set_propagated_attributes_processor():
125+
provider = trace.get_tracer_provider()
126+
if not isinstance(provider, TracerProvider):
127+
logger.warning(
128+
"Unknown TracerProvider type. Skipping PropagatedAttributesSpanProcessor"
129+
)
130+
return
131+
122132
provider.add_span_processor(PropagatedAttributesSpanProcessor())
123133
logger.info(
124134
"Registered PropagatedAttributesSpanProcessor for ContextVar attribute propagation"

tests/core/integration/telemetry/__init__.py

Whitespace-only changes.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""LangGraph test agent used by the telemetry integration tests."""
2+
3+
import os
4+
from dataclasses import dataclass
5+
from typing import Annotated
6+
7+
import pytest
8+
9+
from langchain_core.messages import BaseMessage
10+
from langgraph.graph.message import add_messages
11+
12+
13+
@dataclass
14+
class State:
15+
messages: Annotated[list[BaseMessage], add_messages]
16+
17+
18+
def build_langgraph_agent():
19+
"""Build a minimal single-node LangGraph agent backed by LiteLLM via AI Core.
20+
21+
Requires AICORE_MODEL env var (e.g. "anthropic--claude-3-5-haiku") in addition
22+
to the AICORE_* credentials set by set_aicore_config(). LiteLLM uses the
23+
"sap/<model>" prefix to route through the SAP AI Core provider.
24+
"""
25+
try:
26+
from langchain_litellm import ChatLiteLLM
27+
from langgraph.graph import END, StateGraph
28+
except ImportError:
29+
pytest.skip("langchain-litellm or langgraph not installed")
30+
31+
model_name = os.environ.get("AICORE_MODEL") or "anthropic--claude-4.5-sonnet"
32+
llm = ChatLiteLLM(model=f"sap/{model_name}")
33+
34+
def call_llm(state: State) -> State:
35+
return State(messages=[llm.invoke(state.messages)])
36+
37+
graph = StateGraph(State)
38+
graph.add_node("llm", call_llm)
39+
graph.set_entry_point("llm")
40+
graph.add_edge("llm", END)
41+
return graph.compile()
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Fixtures for telemetry integration tests."""
2+
3+
import os
4+
from pathlib import Path
5+
from unittest.mock import patch
6+
7+
import pytest
8+
from dotenv import load_dotenv
9+
from opentelemetry import trace
10+
from opentelemetry.sdk.trace import TracerProvider
11+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
12+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
13+
14+
from sap_cloud_sdk.aicore import set_aicore_config
15+
from sap_cloud_sdk.core.telemetry.auto_instrument import auto_instrument
16+
from sap_cloud_sdk.core.telemetry.genai_attribute_transformer import (
17+
GenAIAttributeTransformer,
18+
)
19+
20+
_env_file = Path(__file__).parent.parent.parent.parent.parent / ".env_integration_tests"
21+
if _env_file.exists():
22+
load_dotenv(_env_file, override=True)
23+
24+
25+
@pytest.fixture(scope="session")
26+
def memory_exporter() -> InMemorySpanExporter:
27+
"""Initialize auto_instrument once per session and inject an in-memory exporter.
28+
29+
Uses OTEL_TRACES_EXPORTER=console so Traceloop.init runs for real without
30+
needing a real collector endpoint. A second SimpleSpanProcessor backed by
31+
InMemorySpanExporter is added afterward for test assertions on raw spans.
32+
"""
33+
raw_exporter = InMemorySpanExporter()
34+
with patch.dict(os.environ, {"OTEL_TRACES_EXPORTER": "console"}, clear=False):
35+
auto_instrument(disable_batch=True)
36+
provider = trace.get_tracer_provider()
37+
assert isinstance(provider, TracerProvider)
38+
provider.add_span_processor(SimpleSpanProcessor(raw_exporter))
39+
return raw_exporter
40+
41+
42+
@pytest.fixture(scope="session")
43+
def transforming_exporter(memory_exporter: InMemorySpanExporter) -> InMemorySpanExporter:
44+
"""Inject a second in-memory exporter wrapped in GenAIAttributeTransformer.
45+
46+
Used by traceloop.feature tests to assert on transformer output
47+
(gen_ai.* attributes present, llm.usage.* and traceloop.* absent).
48+
"""
49+
transformed_sink = InMemorySpanExporter()
50+
provider = trace.get_tracer_provider()
51+
assert isinstance(provider, TracerProvider)
52+
provider.add_span_processor(
53+
SimpleSpanProcessor(GenAIAttributeTransformer(transformed_sink))
54+
)
55+
return transformed_sink
56+
57+
58+
@pytest.fixture(autouse=True)
59+
def clear_spans(memory_exporter: InMemorySpanExporter, transforming_exporter: InMemorySpanExporter):
60+
"""Clear both exporters before each test so spans don't bleed between scenarios."""
61+
memory_exporter.clear()
62+
transforming_exporter.clear()
63+
64+
65+
@pytest.fixture(scope="session")
66+
def aicore_configured():
67+
"""Call set_aicore_config() once per session.
68+
69+
Skips if AICORE_BASE_URL is absent — tests depending on this fixture are
70+
skipped automatically.
71+
"""
72+
if not os.environ.get("AICORE_BASE_URL"):
73+
pytest.skip("AICORE_BASE_URL not set — skipping AI Core integration tests")
74+
set_aicore_config()
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
Feature: SDK telemetry instrumentation
2+
3+
Background:
4+
Given auto_instrument is initialized
5+
6+
Scenario: invoke_agent_span emits a span with required GenAI attributes
7+
When I invoke an agent with provider "test" and name "bot" and conversation_id "c1"
8+
Then a span named "invoke_agent bot" is recorded
9+
And the span has attribute "gen_ai.operation.name" equal to "invoke_agent"
10+
And the span has attribute "gen_ai.provider.name" equal to "test"
11+
And the span has attribute "gen_ai.agent.name" equal to "bot"
12+
And the span has attribute "gen_ai.conversation.id" equal to "c1"
13+
14+
Scenario: invoke_agent_span records errors
15+
When I invoke an agent that raises an exception
16+
Then the span status is ERROR
17+
And the span has an exception event
18+
19+
Scenario: spans carry SDK resource attributes
20+
When I invoke an agent with provider "test" and name "sdk-resource-test"
21+
Then a span named "invoke_agent sdk-resource-test" is recorded
22+
And the span resource has attribute "sap.cloud_sdk.name" equal to "SAP Cloud SDK for Python"
23+
And the span resource has attribute "sap.cloud_sdk.language" equal to "python"
24+
And the span resource has attribute "sap.cloud_sdk.version" set
25+
26+
# Real LLM call scenarios — require AI Core credentials
27+
28+
@aicore
29+
Scenario: invoke_agent_span wrapping a real LLM call produces a complete trace
30+
Given AI Core is configured via set_aicore_config
31+
When I invoke an agent wrapping a direct LLM call
32+
Then a span named "invoke_agent llm-agent" is recorded
33+
And a span with operation "chat" is a child of "invoke_agent llm-agent"
34+
And that span has attribute "gen_ai.usage.input_tokens" set
35+
And that span has attribute "gen_ai.usage.output_tokens" set
36+
And the span "invoke_agent llm-agent" has resource attribute "sap.cloud_sdk.name" equal to "SAP Cloud SDK for Python"
37+
And the span "invoke_agent llm-agent" has resource attribute "sap.cloud_sdk.language" equal to "python"
38+
And the span "invoke_agent llm-agent" has resource attribute "sap.cloud_sdk.version" set
39+
40+
@aicore
41+
Scenario: invoke_agent_span wrapping LLM call then tool produces a full agentic trace
42+
Given AI Core is configured via set_aicore_config
43+
When I invoke an agent that calls an LLM then executes a tool
44+
Then a span named "invoke_agent agent-with-tool" is recorded
45+
And a span with operation "chat" is a child of "invoke_agent agent-with-tool"
46+
And that span has attribute "gen_ai.usage.input_tokens" set
47+
And the span "execute_tool search" is a child of "invoke_agent agent-with-tool"
48+
And the span "execute_tool search" has attribute "gen_ai.tool.name" equal to "search"
49+
50+
@aicore
51+
Scenario: propagate=True flows invoke_agent attributes to nested LLM span
52+
Given AI Core is configured via set_aicore_config
53+
When I invoke an agent with propagate=True wrapping a real LLM call
54+
Then a span with operation "chat" is a child of "invoke_agent propagate-llm-agent"
55+
And that span has attribute "custom.session" equal to "s42"
56+
And that span has attribute "gen_ai.usage.input_tokens" set
57+
58+
@aicore
59+
Scenario: propagate=False does not leak invoke_agent attributes to nested LLM span
60+
Given AI Core is configured via set_aicore_config
61+
When I invoke an agent with propagate=False wrapping a real LLM call
62+
Then a span with operation "chat" is a child of "invoke_agent no-propagate-llm-agent"
63+
And that span does not have attribute "custom.session"
64+
65+
@aicore
66+
Scenario: baggage attributes propagate to Traceloop-instrumented LLM spans
67+
Given baggage key "sap.extension.capabilityId" is set to "cap-traceloop"
68+
And AI Core is configured via set_aicore_config
69+
When I invoke an agent wrapping a direct LLM call with baggage
70+
Then a span with operation "chat" is a child of "invoke_agent baggage-llm-agent"
71+
And that span has attribute "sap.extension.capabilityId" equal to "cap-traceloop"
72+
73+
@aicore
74+
Scenario: LangGraph agent run produces an invoke_agent span with LangChain child spans
75+
Given AI Core is configured via set_aicore_config
76+
When I run a LangGraph agent with provider "sap-aicore" and name "test-agent"
77+
Then a span named "invoke_agent test-agent" is recorded
78+
And at least one descendant span with attribute "gen_ai.operation.name" equal to "chat" is recorded
79+
And at least one descendant span has attribute "gen_ai.request.model" set
80+
And at least one descendant span has attribute "gen_ai.usage.input_tokens" set
81+
And at least one descendant span has attribute "gen_ai.usage.output_tokens" set
82+
And no descendant span has an attribute starting with "llm.usage."
83+
And no descendant span has attribute "traceloop.association.properties.ls_model_name"
84+
And no descendant span has attribute "traceloop.association.properties.ls_provider"

0 commit comments

Comments
 (0)