From 86cccfb369dd63585173275ee6091a1c2d26422b Mon Sep 17 00:00:00 2001 From: Keith Decker Date: Tue, 24 Mar 2026 10:59:29 -0600 Subject: [PATCH 1/3] Demo Toolcall App --- .../examples/toolcall/.env.example | 9 + .../examples/toolcall/README.rst | 118 +++++++++ .../examples/toolcall/main.py | 236 ++++++++++++++++++ .../examples/toolcall/requirements.txt | 3 + 4 files changed, 366 insertions(+) create mode 100644 util/opentelemetry-util-genai/examples/toolcall/.env.example create mode 100644 util/opentelemetry-util-genai/examples/toolcall/README.rst create mode 100644 util/opentelemetry-util-genai/examples/toolcall/main.py create mode 100644 util/opentelemetry-util-genai/examples/toolcall/requirements.txt diff --git a/util/opentelemetry-util-genai/examples/toolcall/.env.example b/util/opentelemetry-util-genai/examples/toolcall/.env.example new file mode 100644 index 0000000000..2ff3d76355 --- /dev/null +++ b/util/opentelemetry-util-genai/examples/toolcall/.env.example @@ -0,0 +1,9 @@ +# OTLP endpoint for trace/metric/log export +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 + +# Enable experimental semantic conventions for GenAI +OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental + +# Capture tool arguments and results in spans (optional) +# Options: SPAN_ONLY, EVENT_ONLY, SPAN_AND_EVENT, OFF +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY diff --git a/util/opentelemetry-util-genai/examples/toolcall/README.rst b/util/opentelemetry-util-genai/examples/toolcall/README.rst new file mode 100644 index 0000000000..f612ff53ee --- /dev/null +++ b/util/opentelemetry-util-genai/examples/toolcall/README.rst @@ -0,0 +1,118 @@ +OpenTelemetry GenAI Tool Call Example +===================================== + +This example demonstrates the ``ToolCall`` and ``TelemetryHandler`` APIs from +``opentelemetry-util-genai``. It shows how to create properly instrumented +tool call spans with nested hierarchy, simulating an AI agent that calls tools. + +When ``main.py`` runs, it exports traces to an OTLP-compatible endpoint showing: + +- Tool call spans with proper semantic convention attributes +- Nested span hierarchy: ``workflow → llm → tool_call`` +- Error handling for failed tool executions + +Sample Trace Output +------------------- + +:: + + Span: invoke_agent SimpleAgent + ├── Attributes: + │ └── gen_ai.operation.name: invoke_agent + │ + └── Span: chat gpt-4 + ├── Attributes: + │ ├── gen_ai.operation.name: chat + │ ├── gen_ai.request.model: gpt-4 + │ └── gen_ai.provider.name: openai + │ + ├── Span: execute_tool get_weather + │ └── Attributes: + │ ├── gen_ai.operation.name: execute_tool + │ ├── gen_ai.tool.name: get_weather + │ ├── gen_ai.tool.call.id: call_100 + │ └── gen_ai.tool.type: function + │ + └── Span: execute_tool calculate + └── Attributes: + ├── gen_ai.operation.name: execute_tool + ├── gen_ai.tool.name: calculate + ├── gen_ai.tool.call.id: call_101 + └── gen_ai.tool.type: function + +Setup +----- + +1. Copy ``.env.example`` to ``.env`` and configure your OTLP endpoint: + + :: + + cp .env.example .env + +2. Start an OTLP-compatible collector (e.g., Jaeger): + + :: + + docker run -d --name jaeger \ + -p 16686:16686 \ + -p 4317:4317 \ + jaegertracing/jaeger:2.6 + +3. Set up a virtual environment and install dependencies: + + :: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + + # Install the local util-genai package + pip install -e ../../ + +Run +--- + +:: + + dotenv run -- python main.py + +You should see console output showing the tool executions, and traces will +appear in your OTLP endpoint (e.g., Jaeger UI at http://localhost:16686). + +Content Capturing +----------------- + +To capture tool arguments and results in span attributes, set the environment +variable: + +:: + + export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY + +This adds ``gen_ai.tool.call.arguments`` and ``gen_ai.tool.call.result`` +attributes to tool call spans. + +API Overview +------------ + +The example demonstrates these key APIs: + +``TelemetryHandler.tool_call(ToolCall)`` + Context manager for tool call spans. Automatically handles errors. + +``ToolCall`` + Dataclass representing a tool invocation with: + - ``name``: Tool name (required) + - ``arguments``: Parameters passed to tool + - ``id``: Unique call identifier + - ``tool_type``: "function", "extension", or "datastore" + - ``tool_description``: Human-readable description + - ``tool_result``: Set inside context with execution result + +``TelemetryHandler.llm(LLMInvocation)`` + Context manager for LLM spans (parent for tool calls). + +``tracer.start_as_current_span()`` + Standard OpenTelemetry tracer for root/agent spans. diff --git a/util/opentelemetry-util-genai/examples/toolcall/main.py b/util/opentelemetry-util-genai/examples/toolcall/main.py new file mode 100644 index 0000000000..ded5dd2e7f --- /dev/null +++ b/util/opentelemetry-util-genai/examples/toolcall/main.py @@ -0,0 +1,236 @@ +""" +Tool Call Demo - OpenTelemetry GenAI Utility Example + +Demonstrates the ToolCall and TelemetryHandler APIs from opentelemetry-util-genai. +Shows how to create properly instrumented tool call spans with nested hierarchy: + + invoke_agent SimpleAgent + └── chat gpt-4 + ├── execute_tool get_weather + └── execute_tool calculate + +Run with: dotenv run -- python main.py +""" + +from opentelemetry import _logs, metrics, trace +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.types import ( + LLMInvocation, + OutputMessage, + Text, + ToolCall, + ToolCallRequest, +) + + +def setup_telemetry(): + """Configure OpenTelemetry SDK with OTLP exporters.""" + # Tracing + trace.set_tracer_provider(TracerProvider()) + trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) + ) + + # Logging + _logs.set_logger_provider(LoggerProvider()) + _logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) + ) + + # Metrics + metrics.set_meter_provider( + MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader(OTLPMetricExporter()), + ] + ) + ) + + +# ============================================================================= +# Mock Tools - Simulating real tool implementations +# ============================================================================= + + +def get_weather(location: str) -> dict: + """Get current weather for a location.""" + # Simulate API call + return { + "location": location, + "temperature": 22, + "condition": "sunny", + "humidity": 45, + } + + +def calculate(expression: str) -> float: + """Evaluate a mathematical expression.""" + # Simulate safe calculation (in reality, use a proper parser) + allowed = set("0123456789+-*/(). ") + if all(c in allowed for c in expression): + return eval(expression) # noqa: S307 - demo only + raise ValueError(f"Invalid expression: {expression}") + + +# ============================================================================= +# Demo: Tool Call with Context Manager +# ============================================================================= + + +def demo_tool_call_context_manager(handler: TelemetryHandler): + """Demonstrate the context manager pattern for tool calls. + + This is the recommended approach - clean, handles errors automatically. + """ + print("\n=== Demo: Tool Call Context Manager ===") + + # Successful tool call + tool = ToolCall( + name="get_weather", + arguments={"location": "Paris"}, + id="call_001", + tool_type="function", + tool_description="Get current weather for a location", + ) + + with handler.tool_call(tool) as tc: + # Execute the actual tool + result = get_weather(tc.arguments["location"]) + tc.tool_result = result + print(f"Weather result: {result}") + + # Tool call with error (exception auto-handled) + print("\nDemonstrating error handling...") + error_tool = ToolCall( + name="calculate", + arguments={"expression": "invalid_expr"}, + id="call_002", + tool_type="function", + ) + + try: + with handler.tool_call(error_tool) as tc: + result = calculate(tc.arguments["expression"]) + tc.tool_result = result + except ValueError as e: + print(f"Tool failed (expected): {e}") + + +# ============================================================================= +# Demo: Nested Span Hierarchy (Workflow -> LLM -> Tool) +# ============================================================================= + + +def demo_nested_hierarchy(handler: TelemetryHandler): + """Demonstrate proper span nesting: agent -> llm -> tool calls. + + This shows how tool calls appear as children of the LLM span that + triggered them, all within an agent/workflow span. + """ + print("\n=== Demo: Nested Span Hierarchy ===") + + # Simulated LLM response with tool calls + mock_tool_calls = [ + ToolCallRequest( + name="get_weather", arguments={"location": "Tokyo"}, id="call_100" + ), + ToolCallRequest( + name="calculate", arguments={"expression": "25 * 4"}, id="call_101" + ), + ] + + # Create a root span to represent the agent/workflow + # (In a real app, this might come from a workflow handler or framework) + tracer = trace.get_tracer(__name__) + + with tracer.start_as_current_span( + "invoke_agent SimpleAgent" + ) as agent_span: + agent_span.set_attribute("gen_ai.operation.name", "invoke_agent") + print("Started agent: SimpleAgent") + + # Create LLM span + llm = LLMInvocation( + request_model="gpt-4", + provider="openai", + ) + + with handler.llm(llm) as llm_inv: + print(" LLM call: gpt-4") + + # Simulate LLM deciding to call tools + llm_inv.output_messages = [ + OutputMessage( + role="assistant", + parts=[ + Text("I'll check the weather and do a calculation."), + ], + finish_reason="tool_calls", + ) + ] + + # Execute each tool call as child span + for tool_request in mock_tool_calls: + tool = ToolCall( + name=tool_request.name, + arguments=tool_request.arguments, + id=tool_request.id, + tool_type="function", + ) + + with handler.tool_call(tool) as tc: + print(f" Executing tool: {tc.name}") + if tc.name == "get_weather": + tc.tool_result = get_weather(tc.arguments["location"]) + elif tc.name == "calculate": + tc.tool_result = calculate(tc.arguments["expression"]) + print(f" Result: {tc.tool_result}") + + print("Agent completed") + + +# ============================================================================= +# Main +# ============================================================================= + + +def main(): + print("OpenTelemetry GenAI Tool Call Demo") + print("=" * 40) + + # Set up OpenTelemetry + setup_telemetry() + + # Get the telemetry handler + handler = TelemetryHandler() + + # Run demos + demo_tool_call_context_manager(handler) + demo_nested_hierarchy(handler) + + print("\n" + "=" * 40) + print("Demo complete! Check your OTLP endpoint for traces.") + print("Expected span hierarchy:") + print(" invoke_agent SimpleAgent") + print(" └── chat gpt-4") + print(" ├── execute_tool get_weather") + print(" └── execute_tool calculate") + + +if __name__ == "__main__": + main() diff --git a/util/opentelemetry-util-genai/examples/toolcall/requirements.txt b/util/opentelemetry-util-genai/examples/toolcall/requirements.txt new file mode 100644 index 0000000000..efbeb63199 --- /dev/null +++ b/util/opentelemetry-util-genai/examples/toolcall/requirements.txt @@ -0,0 +1,3 @@ +opentelemetry-sdk>=1.31.0 +opentelemetry-exporter-otlp-proto-grpc>=1.31.0 +opentelemetry-semantic-conventions>=0.52b0 From 667545186f7ea9e98040d29cd37d6bb3eaea810f Mon Sep 17 00:00:00 2001 From: Keith Decker Date: Tue, 24 Mar 2026 15:08:35 -0600 Subject: [PATCH 2/3] update example to use langchain --- .../langchain/callback_handler.py | 74 +++++ .../examples/toolcall/README.rst | 107 +++---- .../examples/toolcall/main.py | 260 +++++++----------- .../examples/toolcall/requirements.txt | 2 + 4 files changed, 235 insertions(+), 208 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py b/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py index d694857da4..19e20d7cc4 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py @@ -32,6 +32,7 @@ MessagePart, OutputMessage, Text, + ToolCall, ) @@ -273,3 +274,76 @@ def on_llm_error( ) if llm_invocation.span and not llm_invocation.span.is_recording(): self._invocation_manager.delete_invocation_state(run_id=run_id) + + def on_tool_start( + self, + serialized: dict[str, Any], + input_str: str, + *, + run_id: UUID, + parent_run_id: Optional[UUID] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + inputs: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> None: + """Start a tool call span when LangChain invokes a tool.""" + tool_name = serialized.get("name", "unknown") + tool_description = serialized.get("description") + + # Parse arguments - prefer structured inputs over input_str + arguments: Any = inputs if inputs is not None else input_str + + tool_call = ToolCall( + name=tool_name, + arguments=arguments, + id=str(run_id), + tool_type="function", + tool_description=tool_description, + ) + tool_call = self._telemetry_handler.start_tool_call(tool_call) + self._invocation_manager.add_invocation_state( + run_id=run_id, + parent_run_id=parent_run_id, + invocation=tool_call, + ) + + def on_tool_end( + self, + output: Any, + *, + run_id: UUID, + parent_run_id: Optional[UUID] = None, + **kwargs: Any, + ) -> None: + """End a tool call span successfully.""" + tool_call = self._invocation_manager.get_invocation(run_id=run_id) + if tool_call is None or not isinstance(tool_call, ToolCall): + return + + # Store the tool result + tool_call.tool_result = output + + tool_call = self._telemetry_handler.stop_tool_call(tool_call) + if tool_call.span and not tool_call.span.is_recording(): + self._invocation_manager.delete_invocation_state(run_id=run_id) + + def on_tool_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: Optional[UUID] = None, + **kwargs: Any, + ) -> None: + """End a tool call span with error.""" + tool_call = self._invocation_manager.get_invocation(run_id=run_id) + if tool_call is None or not isinstance(tool_call, ToolCall): + return + + error_otel = Error(message=str(error), type=type(error)) + tool_call = self._telemetry_handler.fail_tool_call( + tool_call, error_otel + ) + if tool_call.span and not tool_call.span.is_recording(): + self._invocation_manager.delete_invocation_state(run_id=run_id) diff --git a/util/opentelemetry-util-genai/examples/toolcall/README.rst b/util/opentelemetry-util-genai/examples/toolcall/README.rst index f612ff53ee..aa815cc47d 100644 --- a/util/opentelemetry-util-genai/examples/toolcall/README.rst +++ b/util/opentelemetry-util-genai/examples/toolcall/README.rst @@ -1,53 +1,47 @@ -OpenTelemetry GenAI Tool Call Example -===================================== +OpenTelemetry LangChain Tool Call Example +========================================= -This example demonstrates the ``ToolCall`` and ``TelemetryHandler`` APIs from -``opentelemetry-util-genai``. It shows how to create properly instrumented -tool call spans with nested hierarchy, simulating an AI agent that calls tools. +This example demonstrates automatic tool call instrumentation with LangChain. +The ``LangChainInstrumentor`` automatically creates spans for: + +- LLM calls (chat completions) +- Tool executions (via ``on_tool_start``/``on_tool_end`` callbacks) When ``main.py`` runs, it exports traces to an OTLP-compatible endpoint showing: -- Tool call spans with proper semantic convention attributes -- Nested span hierarchy: ``workflow → llm → tool_call`` -- Error handling for failed tool executions +- Chat span for the LLM invocation +- Tool call span as a child of the chat span +- Proper semantic convention attributes on both spans Sample Trace Output ------------------- :: - Span: invoke_agent SimpleAgent + Span: chat gpt-4o-mini + ├── Kind: Client ├── Attributes: - │ └── gen_ai.operation.name: invoke_agent + │ ├── gen_ai.operation.name: chat + │ ├── gen_ai.request.model: gpt-4o-mini + │ └── gen_ai.provider.name: openai │ - └── Span: chat gpt-4 - ├── Attributes: - │ ├── gen_ai.operation.name: chat - │ ├── gen_ai.request.model: gpt-4 - │ └── gen_ai.provider.name: openai - │ - ├── Span: execute_tool get_weather - │ └── Attributes: - │ ├── gen_ai.operation.name: execute_tool - │ ├── gen_ai.tool.name: get_weather - │ ├── gen_ai.tool.call.id: call_100 - │ └── gen_ai.tool.type: function - │ - └── Span: execute_tool calculate - └── Attributes: - ├── gen_ai.operation.name: execute_tool - ├── gen_ai.tool.name: calculate - ├── gen_ai.tool.call.id: call_101 - └── gen_ai.tool.type: function + └── Span: execute_tool get_weather + ├── Kind: Internal + └── Attributes: + ├── gen_ai.operation.name: execute_tool + ├── gen_ai.tool.name: get_weather + ├── gen_ai.tool.call.id: + └── gen_ai.tool.type: function Setup ----- -1. Copy ``.env.example`` to ``.env`` and configure your OTLP endpoint: +1. Copy ``.env.example`` to ``.env`` and add your OpenAI API key: :: cp .env.example .env + # Edit .env and set OPENAI_API_KEY=sk-... 2. Start an OTLP-compatible collector (e.g., Jaeger): @@ -67,8 +61,9 @@ Setup pip install "python-dotenv[cli]" pip install -r requirements.txt - # Install the local util-genai package - pip install -e ../../ + # Install local packages (from repo root) + pip install -e ../../ # util-genai + pip install -e ../../../../instrumentation-genai/opentelemetry-instrumentation-langchain Run --- @@ -77,14 +72,30 @@ Run dotenv run -- python main.py -You should see console output showing the tool executions, and traces will -appear in your OTLP endpoint (e.g., Jaeger UI at http://localhost:16686). +You should see console output like: + +:: + + OpenTelemetry LangChain Tool Call Demo + ======================================== + + Sending query: 'What is the weather in Paris?' + LLM Response: + + Tool calls requested: 1 + - get_weather({'location': 'Paris'}) + Result: Weather in Paris: 18°C, cloudy + + ======================================== + Demo complete! Check your OTLP endpoint for traces. + +Traces will appear in Jaeger UI at http://localhost:16686. Content Capturing ----------------- To capture tool arguments and results in span attributes, set the environment -variable: +variables (already in ``.env.example``): :: @@ -94,25 +105,15 @@ variable: This adds ``gen_ai.tool.call.arguments`` and ``gen_ai.tool.call.result`` attributes to tool call spans. -API Overview +How It Works ------------ -The example demonstrates these key APIs: - -``TelemetryHandler.tool_call(ToolCall)`` - Context manager for tool call spans. Automatically handles errors. - -``ToolCall`` - Dataclass representing a tool invocation with: - - ``name``: Tool name (required) - - ``arguments``: Parameters passed to tool - - ``id``: Unique call identifier - - ``tool_type``: "function", "extension", or "datastore" - - ``tool_description``: Human-readable description - - ``tool_result``: Set inside context with execution result +The ``LangChainInstrumentor`` uses callback handlers to intercept LangChain +operations: -``TelemetryHandler.llm(LLMInvocation)`` - Context manager for LLM spans (parent for tool calls). +1. ``on_chat_model_start`` - Creates a chat span when the LLM is invoked +2. ``on_llm_end`` - Ends the chat span with token usage and response data +3. ``on_tool_start`` - Creates an ``execute_tool`` span when a tool runs +4. ``on_tool_end`` - Ends the tool span with the result -``tracer.start_as_current_span()`` - Standard OpenTelemetry tracer for root/agent spans. +All spans follow the OpenTelemetry GenAI semantic conventions. diff --git a/util/opentelemetry-util-genai/examples/toolcall/main.py b/util/opentelemetry-util-genai/examples/toolcall/main.py index ded5dd2e7f..eb03a5f4a6 100644 --- a/util/opentelemetry-util-genai/examples/toolcall/main.py +++ b/util/opentelemetry-util-genai/examples/toolcall/main.py @@ -1,17 +1,25 @@ """ -Tool Call Demo - OpenTelemetry GenAI Utility Example +Tool Call Demo - OpenTelemetry LangChain Instrumentation Example -Demonstrates the ToolCall and TelemetryHandler APIs from opentelemetry-util-genai. -Shows how to create properly instrumented tool call spans with nested hierarchy: +Demonstrates automatic tool call instrumentation with LangChain. +The LangChain instrumentor automatically creates spans for: +- LLM calls (chat completions) +- Tool executions (via on_tool_start/on_tool_end callbacks) - invoke_agent SimpleAgent - └── chat gpt-4 - ├── execute_tool get_weather - └── execute_tool calculate +Expected trace hierarchy: + agent_workflow + ├── chat gpt-4o-mini + └── execute_tool get_weather Run with: dotenv run -- python main.py + +Requires: OPENAI_API_KEY environment variable """ +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI + from opentelemetry import _logs, metrics, trace from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( OTLPLogExporter, @@ -22,29 +30,29 @@ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( OTLPSpanExporter, ) +from opentelemetry.instrumentation.langchain import LangChainInstrumentor from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs.export import BatchLogRecordProcessor from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import SERVICE_NAME, Resource from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor -from opentelemetry.util.genai.handler import TelemetryHandler -from opentelemetry.util.genai.types import ( - LLMInvocation, - OutputMessage, - Text, - ToolCall, - ToolCallRequest, +from opentelemetry.sdk.trace.export import ( + BatchSpanProcessor, + ConsoleSpanExporter, ) def setup_telemetry(): """Configure OpenTelemetry SDK with OTLP exporters.""" - # Tracing - trace.set_tracer_provider(TracerProvider()) - trace.get_tracer_provider().add_span_processor( - BatchSpanProcessor(OTLPSpanExporter()) - ) + # Create resource with custom service name + resource = Resource.create({SERVICE_NAME: "toolcall-demo"}) + + # Tracing - add ConsoleSpanExporter for debugging + provider = TracerProvider(resource=resource) + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) + trace.set_tracer_provider(provider) # Logging _logs.set_logger_provider(LoggerProvider()) @@ -63,173 +71,115 @@ def setup_telemetry(): # ============================================================================= -# Mock Tools - Simulating real tool implementations +# Define Tools using LangChain's @tool decorator # ============================================================================= -def get_weather(location: str) -> dict: - """Get current weather for a location.""" - # Simulate API call - return { - "location": location, - "temperature": 22, - "condition": "sunny", - "humidity": 45, +@tool +def get_weather(location: str) -> str: + """Get current weather for a location. + + Args: + location: The city name to get weather for. + + Returns: + Weather information as a string. + """ + # Simulated weather data + weather_data = { + "Paris": {"temp": 18, "condition": "cloudy"}, + "Tokyo": {"temp": 24, "condition": "sunny"}, + "New York": {"temp": 15, "condition": "rainy"}, } + data = weather_data.get(location, {"temp": 20, "condition": "unknown"}) + return f"Weather in {location}: {data['temp']}°C, {data['condition']}" + + +@tool +def calculate(expression: str) -> str: + """Evaluate a mathematical expression safely. + Args: + expression: A mathematical expression like "2 + 2" or "10 * 5". -def calculate(expression: str) -> float: - """Evaluate a mathematical expression.""" - # Simulate safe calculation (in reality, use a proper parser) + Returns: + The result of the calculation. + """ + # Simple safe evaluation (only digits and basic operators) allowed = set("0123456789+-*/(). ") if all(c in allowed for c in expression): - return eval(expression) # noqa: S307 - demo only - raise ValueError(f"Invalid expression: {expression}") + result = eval(expression) # noqa: S307 - demo only with sanitized input + return f"Result: {result}" + return f"Error: Invalid expression '{expression}'" # ============================================================================= -# Demo: Tool Call with Context Manager +# Main Demo # ============================================================================= -def demo_tool_call_context_manager(handler: TelemetryHandler): - """Demonstrate the context manager pattern for tool calls. +def main(): + print("OpenTelemetry LangChain Tool Call Demo") + print("=" * 40) - This is the recommended approach - clean, handles errors automatically. - """ - print("\n=== Demo: Tool Call Context Manager ===") - - # Successful tool call - tool = ToolCall( - name="get_weather", - arguments={"location": "Paris"}, - id="call_001", - tool_type="function", - tool_description="Get current weather for a location", - ) + # Set up OpenTelemetry + setup_telemetry() - with handler.tool_call(tool) as tc: - # Execute the actual tool - result = get_weather(tc.arguments["location"]) - tc.tool_result = result - print(f"Weather result: {result}") - - # Tool call with error (exception auto-handled) - print("\nDemonstrating error handling...") - error_tool = ToolCall( - name="calculate", - arguments={"expression": "invalid_expr"}, - id="call_002", - tool_type="function", + # Instrument LangChain - pass providers explicitly to avoid singleton issues + LangChainInstrumentor().instrument( + tracer_provider=trace.get_tracer_provider(), ) - try: - with handler.tool_call(error_tool) as tc: - result = calculate(tc.arguments["expression"]) - tc.tool_result = result - except ValueError as e: - print(f"Tool failed (expected): {e}") - - -# ============================================================================= -# Demo: Nested Span Hierarchy (Workflow -> LLM -> Tool) -# ============================================================================= - + # Create LLM with tools bound + llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) + tools = [get_weather, calculate] + llm_with_tools = llm.bind_tools(tools) -def demo_nested_hierarchy(handler: TelemetryHandler): - """Demonstrate proper span nesting: agent -> llm -> tool calls. + # Create a query that should trigger tool use + print("\nSending query: 'What is the weather in Paris?'") + messages = [HumanMessage(content="What is the weather in Paris?")] - This shows how tool calls appear as children of the LLM span that - triggered them, all within an agent/workflow span. - """ - print("\n=== Demo: Nested Span Hierarchy ===") - - # Simulated LLM response with tool calls - mock_tool_calls = [ - ToolCallRequest( - name="get_weather", arguments={"location": "Tokyo"}, id="call_100" - ), - ToolCallRequest( - name="calculate", arguments={"expression": "25 * 4"}, id="call_101" - ), - ] - - # Create a root span to represent the agent/workflow - # (In a real app, this might come from a workflow handler or framework) + # Get tracer for agent span tracer = trace.get_tracer(__name__) - with tracer.start_as_current_span( - "invoke_agent SimpleAgent" - ) as agent_span: + # Wrap the entire agent loop in a parent span + # This ensures LLM and tool calls share the same trace + with tracer.start_as_current_span("agent_workflow") as agent_span: agent_span.set_attribute("gen_ai.operation.name", "invoke_agent") - print("Started agent: SimpleAgent") - - # Create LLM span - llm = LLMInvocation( - request_model="gpt-4", - provider="openai", - ) - - with handler.llm(llm) as llm_inv: - print(" LLM call: gpt-4") - - # Simulate LLM deciding to call tools - llm_inv.output_messages = [ - OutputMessage( - role="assistant", - parts=[ - Text("I'll check the weather and do a calculation."), - ], - finish_reason="tool_calls", - ) - ] - - # Execute each tool call as child span - for tool_request in mock_tool_calls: - tool = ToolCall( - name=tool_request.name, - arguments=tool_request.arguments, - id=tool_request.id, - tool_type="function", - ) - with handler.tool_call(tool) as tc: - print(f" Executing tool: {tc.name}") - if tc.name == "get_weather": - tc.tool_result = get_weather(tc.arguments["location"]) - elif tc.name == "calculate": - tc.tool_result = calculate(tc.arguments["expression"]) - print(f" Result: {tc.tool_result}") + # Invoke the LLM - this creates a chat span (child of agent_workflow) + response = llm_with_tools.invoke(messages) + print(f"LLM Response: {response.content}") - print("Agent completed") + # Check if the LLM wants to call tools + if response.tool_calls: + print(f"\nTool calls requested: {len(response.tool_calls)}") + for tool_call in response.tool_calls: + print(f" - {tool_call['name']}({tool_call['args']})") -# ============================================================================= -# Main -# ============================================================================= - - -def main(): - print("OpenTelemetry GenAI Tool Call Demo") - print("=" * 40) - - # Set up OpenTelemetry - setup_telemetry() + # Execute the tool - creates execute_tool span (child of agent_workflow) + tool_func = { + "get_weather": get_weather, + "calculate": calculate, + }[tool_call["name"]] + result = tool_func.invoke(tool_call["args"]) + print(f" Result: {result}") + else: + print("\nNo tool calls in response (LLM answered directly)") - # Get the telemetry handler - handler = TelemetryHandler() + # Clean up + LangChainInstrumentor().uninstrument() - # Run demos - demo_tool_call_context_manager(handler) - demo_nested_hierarchy(handler) + # Force flush spans before exit (BatchSpanProcessor buffers them) + trace.get_tracer_provider().force_flush() print("\n" + "=" * 40) print("Demo complete! Check your OTLP endpoint for traces.") - print("Expected span hierarchy:") - print(" invoke_agent SimpleAgent") - print(" └── chat gpt-4") - print(" ├── execute_tool get_weather") - print(" └── execute_tool calculate") + print("\nExpected span hierarchy:") + print(" agent_workflow") + print(" ├── chat gpt-4o-mini") + print(" └── execute_tool get_weather") if __name__ == "__main__": diff --git a/util/opentelemetry-util-genai/examples/toolcall/requirements.txt b/util/opentelemetry-util-genai/examples/toolcall/requirements.txt index efbeb63199..12adeb661f 100644 --- a/util/opentelemetry-util-genai/examples/toolcall/requirements.txt +++ b/util/opentelemetry-util-genai/examples/toolcall/requirements.txt @@ -1,3 +1,5 @@ +langchain>=0.3.0 +langchain-openai>=0.3.0 opentelemetry-sdk>=1.31.0 opentelemetry-exporter-otlp-proto-grpc>=1.31.0 opentelemetry-semantic-conventions>=0.52b0 From f5738764833440363214143ddd6022d70df085d5 Mon Sep 17 00:00:00 2001 From: Keith Decker Date: Tue, 24 Mar 2026 15:11:06 -0600 Subject: [PATCH 3/3] update readme --- .../examples/toolcall/README.rst | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/util/opentelemetry-util-genai/examples/toolcall/README.rst b/util/opentelemetry-util-genai/examples/toolcall/README.rst index aa815cc47d..2c36068cd6 100644 --- a/util/opentelemetry-util-genai/examples/toolcall/README.rst +++ b/util/opentelemetry-util-genai/examples/toolcall/README.rst @@ -1,29 +1,36 @@ OpenTelemetry LangChain Tool Call Example ========================================= -This example demonstrates automatic tool call instrumentation with LangChain. +This example demonstrates tool call instrumentation with LangChain. The ``LangChainInstrumentor`` automatically creates spans for: - LLM calls (chat completions) - Tool executions (via ``on_tool_start``/``on_tool_end`` callbacks) +The demo wraps both in an ``agent_workflow`` parent span to maintain trace continuity. + When ``main.py`` runs, it exports traces to an OTLP-compatible endpoint showing: -- Chat span for the LLM invocation -- Tool call span as a child of the chat span -- Proper semantic convention attributes on both spans +- Agent workflow span as the root +- Chat span and tool call span as children of the workflow +- Proper semantic convention attributes on all spans Sample Trace Output ------------------- :: - Span: chat gpt-4o-mini - ├── Kind: Client + Span: agent_workflow + ├── Kind: Internal ├── Attributes: - │ ├── gen_ai.operation.name: chat - │ ├── gen_ai.request.model: gpt-4o-mini - │ └── gen_ai.provider.name: openai + │ └── gen_ai.operation.name: invoke_agent + │ + ├── Span: chat gpt-4o-mini + │ ├── Kind: Client + │ └── Attributes: + │ ├── gen_ai.operation.name: chat + │ ├── gen_ai.request.model: gpt-4o-mini + │ └── gen_ai.provider.name: openai │ └── Span: execute_tool get_weather ├── Kind: Internal @@ -108,8 +115,9 @@ attributes to tool call spans. How It Works ------------ -The ``LangChainInstrumentor`` uses callback handlers to intercept LangChain -operations: +The demo wraps the LLM call and tool execution in an ``agent_workflow`` span +to ensure they share the same trace. The ``LangChainInstrumentor`` uses callback +handlers to intercept LangChain operations: 1. ``on_chat_model_start`` - Creates a chat span when the LLM is invoked 2. ``on_llm_end`` - Ends the chat span with token usage and response data