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/.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..2c36068cd6 --- /dev/null +++ b/util/opentelemetry-util-genai/examples/toolcall/README.rst @@ -0,0 +1,127 @@ +OpenTelemetry LangChain Tool Call Example +========================================= + +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: + +- 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: agent_workflow + ├── Kind: Internal + ├── Attributes: + │ └── 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 + └── 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 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): + + :: + + 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 local packages (from repo root) + pip install -e ../../ # util-genai + pip install -e ../../../../instrumentation-genai/opentelemetry-instrumentation-langchain + +Run +--- + +:: + + dotenv run -- python main.py + +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 +variables (already in ``.env.example``): + +:: + + 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. + +How It Works +------------ + +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 +3. ``on_tool_start`` - Creates an ``execute_tool`` span when a tool runs +4. ``on_tool_end`` - Ends the tool span with the result + +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 new file mode 100644 index 0000000000..eb03a5f4a6 --- /dev/null +++ b/util/opentelemetry-util-genai/examples/toolcall/main.py @@ -0,0 +1,186 @@ +""" +Tool Call Demo - OpenTelemetry LangChain Instrumentation Example + +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) + +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, +) +from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter, +) +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, + ConsoleSpanExporter, +) + + +def setup_telemetry(): + """Configure OpenTelemetry SDK with OTLP exporters.""" + # 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()) + _logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) + ) + + # Metrics + metrics.set_meter_provider( + MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader(OTLPMetricExporter()), + ] + ) + ) + + +# ============================================================================= +# Define Tools using LangChain's @tool decorator +# ============================================================================= + + +@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". + + 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): + result = eval(expression) # noqa: S307 - demo only with sanitized input + return f"Result: {result}" + return f"Error: Invalid expression '{expression}'" + + +# ============================================================================= +# Main Demo +# ============================================================================= + + +def main(): + print("OpenTelemetry LangChain Tool Call Demo") + print("=" * 40) + + # Set up OpenTelemetry + setup_telemetry() + + # Instrument LangChain - pass providers explicitly to avoid singleton issues + LangChainInstrumentor().instrument( + tracer_provider=trace.get_tracer_provider(), + ) + + # Create LLM with tools bound + llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) + tools = [get_weather, calculate] + llm_with_tools = llm.bind_tools(tools) + + # 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?")] + + # Get tracer for agent span + tracer = trace.get_tracer(__name__) + + # 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") + + # Invoke the LLM - this creates a chat span (child of agent_workflow) + response = llm_with_tools.invoke(messages) + print(f"LLM Response: {response.content}") + + # 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']})") + + # 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)") + + # Clean up + LangChainInstrumentor().uninstrument() + + # 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("\nExpected span hierarchy:") + print(" agent_workflow") + print(" ├── chat gpt-4o-mini") + print(" └── execute_tool get_weather") + + +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..12adeb661f --- /dev/null +++ b/util/opentelemetry-util-genai/examples/toolcall/requirements.txt @@ -0,0 +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