From 5986701a8a6d1fa9678f96d065270e41c52a2943 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Sun, 17 May 2026 15:55:38 -0700 Subject: [PATCH 01/12] Add LangChain invoke workflow and invoke agent span support --- .../agent/custom_tool_calling_agent.py | 107 ++ .../examples/agent/human_in_the_loop_agent.py | 169 +++ .../examples/agent/main.py | 104 ++ .../examples/agent/multi_agent.py | 153 +++ .../agent/parallel_map_reduce_agent.py | 156 +++ .../examples/agent/plan_and_execute_agent.py | 254 +++++ .../examples/agent/requirements.txt | 5 + .../examples/agent/single_node_agent.py | 122 +++ .../examples/agent/supervisor_agent.py | 142 +++ .../examples/workflow/main.py | 144 +++ .../examples/workflow/main_without_nodes.py | 122 +++ .../examples/workflow/requirements.txt | 5 + .../pyproject.toml | 7 +- .../langchain/callback_handler.py | 204 +++- .../langchain/invocation_manager.py | 36 +- .../langchain/operation_mapping.py | 218 ++++ .../instrumentation/langchain/utils.py | 91 ++ .../tests/conftest.py | 5 +- .../tests/test_callback_handler.py | 995 ++++++++++++++++++ .../tests/test_invocation_manager.py | 133 ++- 20 files changed, 3116 insertions(+), 56 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/main.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py new file mode 100644 index 00000000..9f9466e7 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py @@ -0,0 +1,107 @@ +""" +Custom tool-calling agent built manually with StateGraph. + +Equivalent in behaviour to create_react_agent but assembled from primitives: + StateGraph + ToolNode + tools_condition + +The agent loops: model -> tools -> model until the model stops calling tools. +OpenTelemetry LangChain instrumentation traces every LLM call in the loop. +""" + +from typing import Annotated + +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.prebuilt import ToolNode, tools_condition + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +@tool +def multiply(a: float, b: float) -> float: + """Multiply two numbers together.""" + return a * b + + +@tool +def add(a: float, b: float) -> float: + """Add two numbers together.""" + return a + b + + +def build_agent(llm_with_tools): + """Build a ReAct-style agent graph manually from StateGraph primitives.""" + + def call_model(state: MessagesState) -> dict: + response = llm_with_tools.invoke(state["messages"]) + return {"messages": [response]} + + tool_node = ToolNode([multiply, add]) + + builder = StateGraph(MessagesState) + builder.add_node("agent", call_model) + builder.add_node("tools", tool_node) + + builder.add_edge(START, "agent") + # Route to tools if the model made tool calls, otherwise finish. + builder.add_conditional_edges("agent", tools_condition) + builder.add_edge("tools", "agent") + + return builder.compile() + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) + llm_with_tools = llm.bind_tools([multiply, add]) + + agent = build_agent(llm_with_tools) + + result = agent.invoke( + { + "messages": [ + SystemMessage(content="You are a helpful calculator assistant."), + HumanMessage(content="What is (5 * 6) + 3?"), + ] + } + ) + + print("Custom tool-calling agent output:") + for msg in result["messages"]: + print(f" {type(msg).__name__}: {msg.content}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py new file mode 100644 index 00000000..05b3fab1 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py @@ -0,0 +1,169 @@ +""" +Human-in-the-loop agent built with StateGraph. + +Before executing a tool call the graph pauses and asks the user for approval. +Uses LangGraph's `interrupt` function and `InMemorySaver` checkpointer so +graph state is persisted between the first run (pause) and the resumed run +(after human decision). + +Flow: + START -> agent -> approval_gate -> tools -> agent -> ... -> END + +The approval_gate node calls `interrupt()` to surface the pending tool call to +the human. When the graph is resumed the gate checks the human's decision: + - "approve" -> forward to tools + - anything else -> skip tools, return a cancellation message + +OpenTelemetry LangChain instrumentation traces all LLM calls. +""" + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.prebuilt import ToolNode, tools_condition +from langgraph.types import interrupt + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +@tool +def send_email(to: str, subject: str, body: str) -> str: + """Send an email (mock).""" + return f"Email sent to {to} with subject '{subject}'." + + +@tool +def delete_file(path: str) -> str: + """Delete a file (mock).""" + return f"File '{path}' deleted." + + +def build_graph(llm_with_tools): + + tool_node = ToolNode([send_email, delete_file]) + + def call_model(state: MessagesState) -> dict: + response = llm_with_tools.invoke(state["messages"]) + return {"messages": [response]} + + def approval_gate(state: MessagesState) -> dict: + """Pause and ask the human to approve any pending tool calls.""" + last = state["messages"][-1] + if not isinstance(last, AIMessage) or not last.tool_calls: + return {} + + tool_summary = ", ".join( + f"{tc['name']}({tc['args']})" for tc in last.tool_calls + ) + decision = interrupt( + f"Agent wants to call: {tool_summary}\nApprove? (approve/deny): " + ) + + if decision.strip().lower() != "approve": + # Replace the tool calls with a cancellation notice so the model + # can respond gracefully without a dangling tool call. + cancel_messages = [ + ToolMessage( + tool_call_id=tc["id"], + content="Action cancelled by user.", + ) + for tc in last.tool_calls + ] + return {"messages": cancel_messages} + + return {} + + def gate_route(state: MessagesState): + """After the gate, go to tools only if the last AI message still has tool calls.""" + last = state["messages"][-1] + if isinstance(last, AIMessage) and last.tool_calls: + return "tools" + return "agent" + + builder = StateGraph(MessagesState) + builder.add_node("agent", call_model) + builder.add_node("approval_gate", approval_gate) + builder.add_node("tools", tool_node) + + builder.add_edge(START, "agent") + builder.add_conditional_edges( + "agent", + tools_condition, + {"tools": "approval_gate", END: END}, + ) + builder.add_conditional_edges( + "approval_gate", + gate_route, + {"tools": "tools", "agent": "agent"}, + ) + builder.add_edge("tools", "agent") + + checkpointer = InMemorySaver() + return builder.compile(checkpointer=checkpointer) + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, seed=42) + llm_with_tools = llm.bind_tools([send_email, delete_file]) + graph = build_graph(llm_with_tools) + + config = {"configurable": {"thread_id": "hitl-demo-1"}} + initial_input = { + "messages": [ + SystemMessage(content="You are a helpful assistant with access to email and file tools."), + HumanMessage(content="Send an email to alice@example.com with subject 'Hello' and body 'Hi there!'"), + ] + } + + print("--- First run (will pause for approval) ---") + for event in graph.stream(initial_input, config=config, stream_mode="updates"): + for node, update in event.items(): + if node == "__interrupt__": + prompt = update[0].value + print(f"\n[INTERRUPT] {prompt}") + human_input = input("Your decision: ").strip() + + print("\n--- Resuming with human decision ---") + from langgraph.types import Command + result = graph.invoke(Command(resume=human_input), config=config) + + print("\nFinal conversation:") + for msg in result["messages"]: + print(f" {type(msg).__name__}: {msg.content}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/main.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/main.py new file mode 100644 index 00000000..bbbedf50 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/main.py @@ -0,0 +1,104 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +ReAct agent example built with LangGraph. + +Uses LangGraph's prebuilt create_react_agent with simple calculator tools. +OpenTelemetry LangChain instrumentation traces the LLM calls made by the agent. +""" + +from uuid import uuid4 + +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +span_processor = BatchSpanProcessor(OTLPSpanExporter()) +trace.get_tracer_provider().add_span_processor(span_processor) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[ + PeriodicExportingMetricReader( + OTLPMetricExporter(), + ), + ] + ) +) + + +@tool +def multiply(a: float, b: float) -> float: + """Multiply two numbers together.""" + return a * b + + +@tool +def add(a: float, b: float) -> float: + """Add two numbers together.""" + return a + b + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI( + model="gpt-3.5-turbo", + temperature=0.1, + max_tokens=100, + top_p=0.9, + seed=100, + ) + + session_id = str(uuid4()) + agent = create_react_agent(llm, tools=[multiply, add]).with_config( + { + "metadata": { + "agent_name": "coordinator", + "session_id": session_id, + }, + } + ) + + result = agent.invoke( + {"messages": [HumanMessage(content="What is (3 * 4) + 7?")]} + ) + + print("Agent output:") + for msg in result["messages"]: + print(f" {type(msg).__name__}: {msg.content}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py new file mode 100644 index 00000000..2381cbea --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py @@ -0,0 +1,153 @@ +""" +Multi-agent example built with StateGraph. + +An orchestrator graph wires two specialist sub-agents together: + - math_agent : handles arithmetic questions using calculator tools + - weather_agent: handles weather questions using a mock weather tool + +A router node inspects the user's question and dispatches to the right agent. +OpenTelemetry LangChain instrumentation traces all LLM calls in both agents. +""" + +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, MessagesState, StateGraph +from langchain.agents import create_agent + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +# --- Tools ---------------------------------------------------------------- + +@tool +def multiply(a: float, b: float) -> float: + """Multiply two numbers.""" + return a * b + + +@tool +def add(a: float, b: float) -> float: + """Add two numbers.""" + return a + b + + +@tool +def get_weather(city: str) -> str: + """Return a mock weather report for a city.""" + reports = { + "london": "Cloudy, 15°C", + "paris": "Sunny, 22°C", + "new york": "Partly cloudy, 18°C", + } + return reports.get(city.lower(), f"Weather data unavailable for {city}.") + + +# --- Router --------------------------------------------------------------- + +def route(state: MessagesState) -> str: + """Decide which sub-agent should handle the latest user message.""" + last_content = state["messages"][-1].content.lower() + if any(kw in last_content for kw in ("weather", "temperature", "forecast")): + return "weather_agent" + return "math_agent" + + +# --- Graph ---------------------------------------------------------------- + +def build_multi_agent_graph(llm: ChatOpenAI): + from uuid import uuid4 + session_id = str(uuid4()) + math_agent = create_agent( + llm, tools=[multiply, add], name="math_agent" + ).with_config( + { + "metadata": { + "agent_name": "math_agent", + "session_id": session_id, + }, + } + ) + weather_agent = create_agent( + llm, tools=[get_weather], name="weather_agent" + ).with_config( + { + "metadata": { + "agent_name": "weather_agent", + "session_id": session_id, + }, + } + ) + + def run_math_agent(state: MessagesState) -> dict: + result = math_agent.invoke({"messages": state["messages"]}) + return {"messages": result["messages"]} + + def run_weather_agent(state: MessagesState) -> dict: + result = weather_agent.invoke({"messages": state["messages"]}) + return {"messages": result["messages"]} + + builder = StateGraph(MessagesState) + builder.add_node("math_agent", run_math_agent) + builder.add_node("weather_agent", run_weather_agent) + + builder.add_conditional_edges( + START, + route, + {"math_agent": "math_agent", "weather_agent": "weather_agent"}, + ) + builder.add_edge("math_agent", END) + builder.add_edge("weather_agent", END) + + return builder.compile() + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) + graph = build_multi_agent_graph(llm) + + questions = [ + "What is 12 multiplied by 7?", + "What is the weather in Paris?", + ] + + for question in questions: + print(f"\nQuestion: {question}") + result = graph.invoke({"messages": [HumanMessage(content=question)]}) + last = result["messages"][-1] + print(f" Answer: {last.content}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py new file mode 100644 index 00000000..1c6c9233 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py @@ -0,0 +1,156 @@ +""" +Parallel / map-reduce agent built with StateGraph and the Send API. + +Pattern: + 1. A *splitter* node breaks the user request into sub-tasks. + 2. The Send API fans out — each sub-task is processed by a *worker* node + running in parallel. + 3. A *reducer* node collects all results and asks the LLM to synthesise a + final answer. + +OpenTelemetry LangChain instrumentation traces every LLM call. +""" + +from typing import Annotated, Any + +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from langgraph.types import Send +from typing_extensions import TypedDict + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +# --- State definitions ---------------------------------------------------- + +class OverallState(TypedDict): + topic: str + subtopics: list[str] + results: Annotated[list[str], lambda a, b: a + b] # reducer: accumulate + final_answer: str + + +class WorkerState(TypedDict): + topic: str + subtopic: str + results: Annotated[list[str], lambda a, b: a + b] + + +# --- Graph nodes ---------------------------------------------------------- + +def splitter(state: OverallState) -> dict: + """Hardcoded sub-tasks for the demo; in production ask the LLM to split.""" + subtopics = [ + f"{state['topic']} – history", + f"{state['topic']} – current applications", + f"{state['topic']} – future outlook", + ] + return {"subtopics": subtopics} + + +def fan_out(state: OverallState) -> list[Send]: + """Return one Send per subtopic to run workers in parallel.""" + return [ + Send("worker", {"topic": state["topic"], "subtopic": st, "results": []}) + for st in state["subtopics"] + ] + + +def worker(state: WorkerState, llm: ChatOpenAI) -> dict: + """Research a single subtopic and return a short summary.""" + response = llm.invoke( + [ + SystemMessage(content="You are a concise research assistant."), + HumanMessage( + content=f"Write 1–2 sentences about: {state['subtopic']}" + ), + ] + ) + return {"results": [f"• {state['subtopic']}: {response.content}"]} + + +def reducer(state: OverallState, llm: ChatOpenAI) -> dict: + """Synthesise the parallel results into a final answer.""" + combined = "\n".join(state["results"]) + response = llm.invoke( + [ + SystemMessage(content="You are a helpful assistant that summarises research."), + HumanMessage( + content=f"Combine these notes into a coherent 3-sentence summary:\n{combined}" + ), + ] + ) + return {"final_answer": response.content} + + +# --- Build graph ---------------------------------------------------------- + +def build_graph(llm: ChatOpenAI): + # Bind the llm into worker/reducer via closures + def _worker(state: WorkerState) -> dict: + return worker(state, llm) + + def _reducer(state: OverallState) -> dict: + return reducer(state, llm) + + builder = StateGraph(OverallState) + builder.add_node("splitter", splitter) + builder.add_node("worker", _worker) + builder.add_node("reducer", _reducer) + + builder.add_edge(START, "splitter") + builder.add_conditional_edges("splitter", fan_out, ["worker"]) + builder.add_edge("worker", "reducer") + builder.add_edge("reducer", END) + + return builder.compile() + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.2, seed=100) + graph = build_graph(llm) + + result = graph.invoke({"topic": "Artificial Intelligence", "subtopics": [], "results": [], "final_answer": ""}) + + print("Parallel map-reduce agent output:") + print("\nIndividual results:") + for r in result["results"]: + print(f" {r}") + print(f"\nFinal answer:\n {result['final_answer']}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py new file mode 100644 index 00000000..cd42dbf0 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py @@ -0,0 +1,254 @@ +""" +Plan-and-execute agent built with StateGraph. + +Pattern: + 1. *Planner* node: the LLM receives the user goal and produces an ordered + list of steps (the plan). + 2. *Executor* node: executes the current step using available tools and + appends the result. + 3. *Replanner* node: decides whether to continue with the next step, revise + the plan, or finish. + +State machine: + START -> planner -> executor -> replanner -> {executor | END} + +OpenTelemetry LangChain instrumentation traces all LLM calls. +""" + +import json +from typing import Annotated + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt import ToolNode +from typing_extensions import TypedDict + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +# --- Tools ---------------------------------------------------------------- + +@tool +def search_web(query: str) -> str: + """Search the web and return a mock result.""" + mock_results = { + "population of france": "France has a population of approximately 68 million people.", + "capital of france": "The capital of France is Paris.", + "area of france": "France covers an area of approximately 551,695 km².", + } + for key, value in mock_results.items(): + if key in query.lower(): + return value + return f"Search result for '{query}': No relevant data found." + + +@tool +def calculate(expression: str) -> str: + """Evaluate a simple arithmetic expression safely.""" + try: + allowed = set("0123456789+-*/(). ") + if not all(c in allowed for c in expression): + return "Error: invalid characters in expression." + return str(eval(expression)) # noqa: S307 — guarded above + except Exception as exc: + return f"Error: {exc}" + + +# --- State ---------------------------------------------------------------- + +class PlanExecuteState(TypedDict): + goal: str + plan: list[str] + current_step_index: int + step_results: list[str] + final_answer: str + + +# --- Nodes ---------------------------------------------------------------- + +PLANNER_SYSTEM = """You are a planner. Given a goal, produce a JSON list of +concrete, ordered steps to achieve it. Use only simple research or calculation +steps. Respond with JSON only: {"steps": ["step1", "step2", ...]}""" + +EXECUTOR_SYSTEM = """You are an executor with access to search_web and calculate tools. +Execute the given step and return the result. Be concise.""" + +REPLANNER_SYSTEM = """You are a replanner. Given the original goal, the plan, +and the results so far, decide: +- If all steps are done and the goal is achieved, respond: {"action": "finish", "answer": ""} +- If the next step should proceed, respond: {"action": "continue"} +- If the plan needs revision, respond: {"action": "revise", "new_steps": ["..."]} +Respond with JSON only.""" + + +def build_graph(llm: ChatOpenAI): + tools = [search_web, calculate] + llm_with_tools = llm.bind_tools(tools) + tool_node = ToolNode(tools) + + # --- Planner ---------------------------------------------------------- + + def planner(state: PlanExecuteState) -> dict: + response = llm.invoke( + [ + SystemMessage(content=PLANNER_SYSTEM), + HumanMessage(content=f"Goal: {state['goal']}"), + ] + ) + try: + data = json.loads(response.content) + steps = data.get("steps", []) + except (json.JSONDecodeError, AttributeError): + steps = [state["goal"]] + return {"plan": steps, "current_step_index": 0, "step_results": []} + + # --- Executor --------------------------------------------------------- + + def executor(state: PlanExecuteState) -> dict: + idx = state["current_step_index"] + step = state["plan"][idx] + + # Ask the model to execute this step (may use a tool). + messages = [ + SystemMessage(content=EXECUTOR_SYSTEM), + HumanMessage(content=f"Step: {step}"), + ] + response = llm_with_tools.invoke(messages) + + # If the model called a tool, run it and collect the result. + result_text = response.content or "" + if response.tool_calls: + tool_messages = [response] + for tc in response.tool_calls: + tool_result = tool_node.invoke( + {"messages": tool_messages + [ + AIMessage(content="", tool_calls=[tc]) + ]} + ) + last_tool_msg = tool_result["messages"][-1] + result_text = last_tool_msg.content + + updated_results = list(state["step_results"]) + [f"Step {idx + 1}: {result_text}"] + return { + "step_results": updated_results, + "current_step_index": idx + 1, + } + + # --- Replanner -------------------------------------------------------- + + def replanner(state: PlanExecuteState) -> dict: + results_text = "\n".join(state["step_results"]) + plan_text = "\n".join( + f"{i + 1}. {s}" for i, s in enumerate(state["plan"]) + ) + response = llm.invoke( + [ + SystemMessage(content=REPLANNER_SYSTEM), + HumanMessage( + content=( + f"Goal: {state['goal']}\n" + f"Plan:\n{plan_text}\n" + f"Results so far:\n{results_text}\n" + f"Steps completed: {state['current_step_index']} / {len(state['plan'])}" + ) + ), + ] + ) + try: + data = json.loads(response.content) + except (json.JSONDecodeError, AttributeError): + data = {"action": "finish", "answer": results_text} + + if data.get("action") == "finish": + return {"final_answer": data.get("answer", results_text)} + if data.get("action") == "revise": + new_steps = data.get("new_steps", []) + return {"plan": new_steps, "current_step_index": 0} + return {} # continue + + def should_continue(state: PlanExecuteState) -> str: + if state.get("final_answer"): + return END + if state["current_step_index"] >= len(state["plan"]): + return "replanner" + return "executor" + + # --- Assemble --------------------------------------------------------- + + builder = StateGraph(PlanExecuteState) + builder.add_node("planner", planner) + builder.add_node("executor", executor) + builder.add_node("replanner", replanner) + + builder.add_edge(START, "planner") + builder.add_edge("planner", "executor") + builder.add_conditional_edges( + "executor", + should_continue, + {"executor": "executor", "replanner": "replanner", END: END}, + ) + builder.add_conditional_edges( + "replanner", + should_continue, + {"executor": "executor", END: END}, + ) + + return builder.compile() + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) + graph = build_graph(llm) + + result = graph.invoke( + { + "goal": "Find the population and area of France, then calculate the population density.", + "plan": [], + "current_step_index": 0, + "step_results": [], + "final_answer": "", + } + ) + + print("Plan-and-execute agent output:") + print("\nSteps executed:") + for r in result["step_results"]: + print(f" {r}") + print(f"\nFinal answer:\n {result['final_answer']}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt new file mode 100644 index 00000000..88f3320d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt @@ -0,0 +1,5 @@ +langchain==0.3.21 +langchain_openai +langgraph +opentelemetry-sdk>=1.39.0 +opentelemetry-exporter-otlp-proto-grpc>=1.39.0 \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py new file mode 100644 index 00000000..72a9b5dc --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py @@ -0,0 +1,122 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +Single-node agent example built with StateGraph. + +A single ReAct agent answers arithmetic questions using calculator tools. +OpenTelemetry LangChain instrumentation traces all LLM calls. +""" + +from uuid import uuid4 + +from langchain.agents import create_agent +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, MessagesState, StateGraph + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) +) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +# --- Tools ---------------------------------------------------------------- + + +@tool +def multiply(a: float, b: float) -> float: + """Multiply two numbers.""" + return a * b + + +@tool +def add(a: float, b: float) -> float: + """Add two numbers.""" + return a + b + + +# --- Graph ---------------------------------------------------------------- + + +def build_single_node_graph(llm: ChatOpenAI): + session_id = str(uuid4()) + + agent = create_agent( + llm, tools=[multiply, add], name="math_agent" + ).with_config( + { + "metadata": { + "agent_name": "math_agent", + "session_id": session_id, + }, + } + ) + + def run_agent(state: MessagesState) -> dict: + result = agent.invoke({"messages": state["messages"]}) + return {"messages": result["messages"]} + + builder = StateGraph(MessagesState) + builder.add_node("math_agent", run_agent) + builder.add_edge(START, "math_agent") + builder.add_edge("math_agent", END) + + return builder.compile() + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) + graph = build_single_node_graph(llm) + + questions = [ + "What is 12 multiplied by 7?", + "What is 15 plus 27?", + ] + + for question in questions: + print(f"\nQuestion: {question}") + result = graph.invoke({"messages": [HumanMessage(content=question)]}) + last = result["messages"][-1] + print(f" Answer: {last.content}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py new file mode 100644 index 00000000..7bedae64 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py @@ -0,0 +1,142 @@ +""" +Supervisor agent pattern built with StateGraph. + +A supervisor LLM node decides which worker agent to call next (or when to +finish). Workers report back to the supervisor after each turn. + +Topology: + START -> supervisor -> {researcher | writer | END} + researcher -> supervisor + writer -> supervisor + +OpenTelemetry LangChain instrumentation traces all LLM calls. +""" + +import json +from typing import Literal + +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, MessagesState, StateGraph + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + +# Workers available to the supervisor. +WORKERS = ["researcher", "writer"] + +SUPERVISOR_SYSTEM = """You are a supervisor managing these workers: {workers}. +Given the conversation, decide who should act next, or reply FINISH if done. +Reply with JSON only: {{"next": "|FINISH"}}""" + + +def build_supervisor_graph(llm: ChatOpenAI): + + # --- Supervisor node -------------------------------------------------- + + supervisor_llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, seed=42) + + def supervisor(state: MessagesState) -> dict: + prompt = SUPERVISOR_SYSTEM.format(workers=", ".join(WORKERS)) + messages = [SystemMessage(content=prompt)] + state["messages"] + response = supervisor_llm.invoke(messages) + return {"messages": [response]} + + def supervisor_route( + state: MessagesState, + ) -> Literal["researcher", "writer", "__end__"]: + last = state["messages"][-1] + try: + data = json.loads(last.content) + nxt = data.get("next", "FINISH") + except (json.JSONDecodeError, AttributeError): + nxt = "FINISH" + return "__end__" if nxt == "FINISH" else nxt + + # --- Worker nodes ----------------------------------------------------- + + def researcher(state: MessagesState) -> dict: + """Mock researcher: summarises existing knowledge.""" + response = llm.invoke( + state["messages"] + + [SystemMessage(content="You are a researcher. Provide key facts concisely.")] + ) + return {"messages": [AIMessage(content=f"[Researcher] {response.content}")]} + + def writer(state: MessagesState) -> dict: + """Mock writer: drafts a short answer from the gathered facts.""" + response = llm.invoke( + state["messages"] + + [SystemMessage(content="You are a writer. Compose a clear, concise answer.")] + ) + return {"messages": [AIMessage(content=f"[Writer] {response.content}")]} + + # --- Build graph ------------------------------------------------------ + + builder = StateGraph(MessagesState) + builder.add_node("supervisor", supervisor) + builder.add_node("researcher", researcher) + builder.add_node("writer", writer) + + builder.add_edge(START, "supervisor") + builder.add_conditional_edges( + "supervisor", + supervisor_route, + {"researcher": "researcher", "writer": "writer", "__end__": END}, + ) + builder.add_edge("researcher", "supervisor") + builder.add_edge("writer", "supervisor") + + return builder.compile() + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) + graph = build_supervisor_graph(llm) + + result = graph.invoke( + { + "messages": [ + HumanMessage(content="Explain the water cycle in 2–3 sentences.") + ] + } + ) + + print("Supervisor agent output (full conversation):") + for msg in result["messages"]: + print(f" {type(msg).__name__}: {msg.content}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main.py new file mode 100644 index 00000000..b7de4c3b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main.py @@ -0,0 +1,144 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +LangGraph StateGraph example with two LLM nodes. + +Graph topology: + + START → researcher → summariser → END + +Steps: + 1. *researcher* – gathers factual background on the user's question. + 2. *summariser* – condenses the researcher's output into a concise answer. + +OpenTelemetry LangChain instrumentation traces both LLM calls. +""" + +from typing import Annotated + +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from typing_extensions import TypedDict + +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) +) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +class GraphState(TypedDict): + """State shared across all graph nodes.""" + + messages: Annotated[list, add_messages] + research: str + + +def build_graph(llm: ChatOpenAI): + """Build a StateGraph with a researcher node and a summariser node.""" + + def researcher(state: GraphState) -> dict: + """Gather factual background on the last user message.""" + response = llm.invoke( + [ + SystemMessage( + content="You are a research assistant. Provide 2-3 factual sentences." + ), + HumanMessage(content=state["messages"][-1].content), + ] + ) + return { + "research": response.content, + "messages": [response], + } + + def summariser(state: GraphState) -> dict: + """Condense the researcher's output into one concise sentence.""" + response = llm.invoke( + [ + SystemMessage( + content="You are an expert summariser. Condense the text below into one clear sentence." + ), + HumanMessage(content=state["research"]), + ] + ) + return {"messages": [response]} + + builder = StateGraph(GraphState) + builder.add_node("researcher", researcher) + builder.add_node("summariser", summariser) + + builder.add_edge(START, "researcher") + builder.add_edge("researcher", "summariser") + builder.add_edge("summariser", END) + + return builder.compile() + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI( + model="gpt-3.5-turbo", + temperature=0.1, + max_tokens=200, + seed=42, + ) + + graph = build_graph(llm) + + question = "What is the capital of France?" + print(f"Question: {question}\n") + + result = graph.invoke( + { + "messages": [HumanMessage(content=question)], + "research": "", + } + ) + + print("Research output:") + print(f" {result['research']}\n") + + print("Final summary:") + print(f" {result['messages'][-1].content}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py new file mode 100644 index 00000000..c41baf1b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py @@ -0,0 +1,122 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +LangChain chain-only example — no graph, no nodes. + +Pipeline: + + question → researcher_chain → summariser_chain → final answer + +Steps: + 1. *researcher_chain* – gathers factual background on the user's question. + 2. *summariser_chain* – condenses the researcher's output into a concise answer. + +Both chains are composed with the pipe operator (|) into a single sequential +chain. OpenTelemetry LangChain instrumentation traces both LLM calls. +""" + +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import RunnableLambda +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.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# Configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) +) + +# Configure logging +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# Configure metrics +metrics.set_meter_provider( + MeterProvider( + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] + ) +) + + +def build_chain(llm: ChatOpenAI): + """Build a sequential LCEL chain: researcher | summariser.""" + + researcher_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are a research assistant. Provide 2-3 factual sentences.", + ), + ("human", "{question}"), + ] + ) + + summariser_prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + "You are an expert summariser. Condense the text below into one clear sentence.", + ), + ("human", "{research}"), + ] + ) + + researcher_chain = researcher_prompt | llm | StrOutputParser() + + # Bridge: wrap the researcher output so the summariser receives {"research": ...} + def to_summariser_input(research: str) -> dict: + return {"research": research} + + summariser_chain = summariser_prompt | llm | StrOutputParser() + + pipeline = researcher_chain | RunnableLambda(to_summariser_input) | summariser_chain + + return pipeline + + +def main(): + LangChainInstrumentor().instrument() + + llm = ChatOpenAI( + model="gpt-3.5-turbo", + temperature=0.1, + max_tokens=200, + seed=42, + ) + + chain = build_chain(llm) + + question = "What is the capital of France?" + print(f"Question: {question}\n") + + answer = chain.invoke({"question": question}) + + print("Final summary:") + print(f" {answer}") + + LangChainInstrumentor().uninstrument() + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt new file mode 100644 index 00000000..656212ca --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt @@ -0,0 +1,5 @@ +langchain==0.3.21 +langchain_openai +langgraph +opentelemetry-sdk>=1.39.0 +opentelemetry-exporter-otlp-proto-grpc>=1.39.0 diff --git a/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml b/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml index 90cd6eec..a6c61cc0 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml @@ -25,7 +25,8 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.57b0", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-util-genai >= 0.4b0", ] [project.optional-dependencies] @@ -37,8 +38,8 @@ instruments = [ langchain = "opentelemetry.instrumentation.langchain:LangChainInstrumentor" [project.urls] -Homepage = "https://github.com/open-telemetry/opentelemetry-python-genai/tree/main/instrumentation/opentelemetry-instrumentation-langchain" -Repository = "https://github.com/open-telemetry/opentelemetry-python-genai" +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai/opentelemetry-instrumentation-langchain" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" [tool.hatch.version] path = "src/opentelemetry/instrumentation/langchain/version.py" diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py index a024ca53..caf018c5 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py @@ -13,11 +13,23 @@ from opentelemetry.instrumentation.langchain.invocation_manager import ( _InvocationManager, ) +from opentelemetry.instrumentation.langchain.operation_mapping import ( + OperationName, + classify_chain_run, + resolve_agent_name, +) +from opentelemetry.instrumentation.langchain.utils import ( + make_input_message, + make_last_output_message, +) from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import ( + AgentInvocation, + InferenceInvocation, + WorkflowInvocation, +) from opentelemetry.util.genai.types import ( - Error, InputMessage, - LLMInvocation, # TODO: migrate to InferenceInvocation MessagePart, OutputMessage, Text, @@ -34,6 +46,139 @@ def __init__(self, telemetry_handler: TelemetryHandler) -> None: self._telemetry_handler = telemetry_handler self._invocation_manager = _InvocationManager() + def on_chain_start( + self, + serialized: dict[str, Any], + inputs: dict[str, Any], + *, + run_id: UUID, + parent_run_id: Optional[UUID] = None, + tags: Optional[list[str]] = None, + metadata: Optional[dict[str, Any]] = None, + **kwargs: Any, + ) -> Any: + operation = classify_chain_run( + serialized, metadata, kwargs, parent_run_id + ) + + if operation == OperationName.INVOKE_WORKFLOW: + workflow_name = kwargs.get("name") or serialized.get("name") + workflow_name_override = ( + metadata.get("workflow_name") if metadata else None + ) + workflow = self._telemetry_handler.start_workflow( + name=workflow_name_override or workflow_name + ) + workflow.input_messages = make_input_message(inputs) + self._invocation_manager.add_invocation_state( + run_id, parent_run_id, workflow + ) + elif operation == OperationName.INVOKE_AGENT: + # agent name passed by the user + suggested_agent_name = resolve_agent_name( + serialized, metadata, kwargs + ) + # find if there is an agent already + agent_invocation = self._find_nearest_agent(parent_run_id) + agent_invocation_name = ( + agent_invocation.agent_name if agent_invocation else None + ) + if suggested_agent_name: + suggested_agent_name_lower = suggested_agent_name.lower() + agent_invocation_name_lower = ( + agent_invocation_name.lower() + if agent_invocation_name + else None + ) + if suggested_agent_name_lower != agent_invocation_name_lower: + agent = self._telemetry_handler.start_invoke_local_agent( + provider=metadata.get("ls_provider", "unknown") + if metadata + else "unknown", + ) + agent.agent_name = suggested_agent_name + agent.input_messages = make_input_message(inputs) + + if metadata: + agent.agent_id = metadata.get("agent_id") + agent.agent_description = metadata.get( + "agent_description" + ) + + for key in ( + "thread_id", + "session_id", + "conversation_id", + ): + conv_id = metadata.get(key) + if conv_id: + agent.conversation_id = conv_id + break + + self._invocation_manager.add_invocation_state( + run_id, parent_run_id, agent + ) + else: + # We create invoke_agent span for the initial chain for agent. All follow-up chains invoked for agent invocation will not create agent span. + self._invocation_manager.add_invocation_state( + run_id, parent_run_id, None + ) + else: + # No agent name could be resolved; still register the run_id so that + # parent-child traversal (e.g. _find_nearest_agent) is not broken for + # any children of this node. + self._invocation_manager.add_invocation_state( + run_id, parent_run_id, None + ) + else: + # For unclassified chains, we still want to track them in the invocation manager to maintain the parent-child relationships, even though we won't create spans for them. + self._invocation_manager.add_invocation_state( + run_id, parent_run_id, None + ) + + def on_chain_end( + self, + outputs: dict[str, Any], + *, + run_id: UUID, + parent_run_id: Optional[UUID] = None, + **kwargs: Any, + ) -> Any: + invocation = self._invocation_manager.get_invocation(run_id=run_id) + if invocation is None or not isinstance( + invocation, (WorkflowInvocation, AgentInvocation) + ): + # If the invocation does not exist, we cannot set attributes or end it + self._invocation_manager.delete_invocation_state(run_id) + return + + invocation.output_messages = make_last_output_message(outputs) + + invocation.stop() + + if not invocation.span.is_recording(): + self._invocation_manager.delete_invocation_state(run_id) + + def on_chain_error( + self, + error: BaseException, + *, + run_id: UUID, + parent_run_id: Optional[UUID] = None, + **kwargs: Any, + ) -> Any: + invocation = self._invocation_manager.get_invocation(run_id=run_id) + if invocation is None or not isinstance( + invocation, (WorkflowInvocation, AgentInvocation) + ): + # If the invocation does not exist, we cannot set attributes or end it + self._invocation_manager.delete_invocation_state(run_id) + return + + invocation.fail(error) + if not invocation.span.is_recording(): + self._invocation_manager.delete_invocation_state(run_id=run_id) + def on_chat_model_start( self, serialized: dict[str, Any], @@ -129,25 +274,22 @@ def on_chat_model_start( ) ) - llm_invocation = LLMInvocation( + llm_invocation = self._telemetry_handler.start_inference( + provider, request_model=request_model, - input_messages=input_messages, - provider=provider, - top_p=top_p, - frequency_penalty=frequency_penalty, - presence_penalty=presence_penalty, - stop_sequences=stop_sequences, - seed=seed, - temperature=temperature, - max_tokens=max_tokens, - ) - llm_invocation = self._telemetry_handler.start_llm( - invocation=llm_invocation ) + llm_invocation.input_messages = input_messages + llm_invocation.top_p = top_p + llm_invocation.frequency_penalty = frequency_penalty + llm_invocation.presence_penalty = presence_penalty + llm_invocation.stop_sequences = stop_sequences + llm_invocation.seed = seed + llm_invocation.temperature = temperature + llm_invocation.max_tokens = max_tokens self._invocation_manager.add_invocation_state( run_id=run_id, parent_run_id=parent_run_id, - invocation=llm_invocation, # pyright: ignore[reportArgumentType] + invocation=llm_invocation, ) def on_llm_end( @@ -161,7 +303,7 @@ def on_llm_end( llm_invocation = self._invocation_manager.get_invocation(run_id=run_id) if llm_invocation is None or not isinstance( llm_invocation, - LLMInvocation, + InferenceInvocation, ): # If the invocation does not exist, we cannot set attributes or end it return @@ -236,10 +378,8 @@ def on_llm_end( if response_id is not None: llm_invocation.response_id = str(response_id) - llm_invocation = self._telemetry_handler.stop_llm( - invocation=llm_invocation - ) - if llm_invocation.span and not llm_invocation.span.is_recording(): + llm_invocation.stop() + if not llm_invocation.span.is_recording(): self._invocation_manager.delete_invocation_state(run_id=run_id) def on_llm_error( @@ -253,14 +393,24 @@ def on_llm_error( llm_invocation = self._invocation_manager.get_invocation(run_id=run_id) if llm_invocation is None or not isinstance( llm_invocation, - LLMInvocation, + InferenceInvocation, ): # If the invocation does not exist, we cannot set attributes or end it return - error_otel = Error(message=str(error), type=type(error)) - llm_invocation = self._telemetry_handler.fail_llm( - invocation=llm_invocation, error=error_otel - ) - if llm_invocation.span and not llm_invocation.span.is_recording(): + llm_invocation.fail(error) + if not llm_invocation.span.is_recording(): self._invocation_manager.delete_invocation_state(run_id=run_id) + + def _find_nearest_agent( + self, run_id: Optional[UUID] + ) -> Optional[AgentInvocation]: + current = run_id + visited: set[UUID] = set() + while current is not None and current not in visited: + visited.add(current) + entity = self._invocation_manager.get_invocation(current) + if isinstance(entity, AgentInvocation): + return entity + current = self._invocation_manager.get_parent_run_id(current) + return None \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py index 996b5069..29caca85 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py @@ -12,8 +12,10 @@ @dataclass class _InvocationState: - invocation: GenAIInvocation + invocation: Optional[GenAIInvocation] children: List[UUID] = field(default_factory=lambda: list()) + parent_run_id: Optional[UUID] = None + ended: bool = False class _InvocationManager: @@ -28,23 +30,45 @@ def add_invocation_state( self, run_id: UUID, parent_run_id: Optional[UUID], - invocation: GenAIInvocation, - ): + invocation: Optional[GenAIInvocation], + ) -> None: invocation_state = _InvocationState(invocation=invocation) - self._invocations[run_id] = invocation_state if parent_run_id is not None and parent_run_id in self._invocations: + invocation_state.parent_run_id = parent_run_id + parent_invocation_state = self._invocations[parent_run_id] parent_invocation_state.children.append(run_id) + self._invocations[run_id] = invocation_state + def get_invocation(self, run_id: UUID) -> Optional[GenAIInvocation]: invocation_state = self._invocations.get(run_id) return invocation_state.invocation if invocation_state else None + def get_parent_run_id(self, run_id: UUID) -> Optional[UUID]: + invocation_state = self._invocations.get(run_id) + return invocation_state.parent_run_id if invocation_state else None + def delete_invocation_state(self, run_id: UUID) -> None: invocation_state = self._invocations.get(run_id) if not invocation_state: return - for child_id in list(invocation_state.children): - self._invocations.pop(child_id, None) + + invocation_state.ended = True + + # Defer removal if any children are still live, so upward traversal + # (e.g. _find_nearest_agent) can still walk through this node. + if any(c in self._invocations for c in invocation_state.children): + return + self._invocations.pop(run_id, None) + + # Propagate cleanup upward: if the parent has already ended and has no + # more live children, it can now be removed too. + if invocation_state.parent_run_id: + parent_state = self._invocations.get( + invocation_state.parent_run_id + ) + if parent_state is not None and parent_state.ended: + self.delete_invocation_state(invocation_state.parent_run_id) \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py new file mode 100644 index 00000000..ce169b56 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py @@ -0,0 +1,218 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Callback-to-semconv operation mapping for LangChain callbacks. + +Maps each LangChain callback to the correct GenAI semantic convention +operation name. Direct callbacks (``on_chat_model_start``, +``on_llm_start``, ``on_tool_start``, ``on_retriever_start``) have a +fixed 1-to-1 mapping. ``on_chain_start`` requires heuristic +classification because LangChain emits this callback for agents, +workflows, and internal plumbing alike. +""" + +from __future__ import annotations + +from typing import Any, Optional +from uuid import UUID + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) + +__all__ = [ + "OperationName", + "classify_chain_run", + "resolve_agent_name", + "should_ignore_chain", +] + +# --------------------------------------------------------------------------- +# Operation name constants (sourced from the GenAI semconv enum where +# available, with string fallbacks for values not yet in the enum). +# --------------------------------------------------------------------------- + + +class OperationName: + """Canonical GenAI semantic convention operation names.""" + + INVOKE_AGENT: str = GenAI.GenAiOperationNameValues.INVOKE_AGENT.value + # invoke_workflow is not yet in the semconv enum; use the expected + # string value so the mapping is forward-compatible. + INVOKE_WORKFLOW: str = "invoke_workflow" + + +# --------------------------------------------------------------------------- +# LangGraph markers – names and prefixes produced by LangGraph that must +# be recognized when classifying ``on_chain_start`` callbacks. +# --------------------------------------------------------------------------- + +LANGGRAPH_NODE_KEY = "langgraph_node" +LANGGRAPH_START_NODE = "__start__" +MIDDLEWARE_PREFIX = "Middleware." +LANGGRAPH_IDENTIFIER = "LangGraph" + +# Metadata keys used by callers to override classification. +_META_AGENT_SPAN = "otel_agent_span" +_META_WORKFLOW_SPAN = "otel_workflow_span" +_META_AGENT_NAME = "agent_name" +_META_AGENT_TYPE = "agent_type" +_META_OTEL_TRACE = "otel_trace" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def resolve_agent_name( + serialized: dict[str, Any], + metadata: Optional[dict[str, Any]], + kwargs: dict[str, Any], +) -> Optional[str]: + """Derive the best-effort agent name from callback arguments. + + Checks (in priority order): + 1. ``metadata["agent_name"]`` + 2. ``kwargs["name"]`` + 3. ``serialized["name"]`` + 4. ``metadata["langgraph_node"]`` (if present and not a start node) + """ + if metadata: + name = metadata.get(_META_AGENT_NAME) + if name: + return str(name) + + name = kwargs.get("name") + if name: + return str(name) + + name = serialized.get("name") if serialized else None + if name: + return str(name) + + if metadata: + node = metadata.get(LANGGRAPH_NODE_KEY) + if node and node != LANGGRAPH_START_NODE: + return str(node) + + return None + + +def _has_agent_signals(metadata: Optional[dict[str, Any]]) -> bool: + """Return True when metadata contains any signal that the chain is an agent.""" + if not metadata: + return False + return bool( + metadata.get(_META_AGENT_SPAN) + or metadata.get(_META_AGENT_NAME) + or metadata.get(_META_AGENT_TYPE) + or metadata.get("langgraph_node") + ) + + +def _looks_like_workflow( + serialized: dict[str, Any], + metadata: Optional[dict[str, Any]], + parent_run_id: Optional[UUID], +) -> bool: + """Return True if the chain looks like a top-level workflow/graph.""" + if parent_run_id is not None: + return False + + # An explicit workflow override is authoritative. + if metadata and metadata.get(_META_WORKFLOW_SPAN): + return True + + # Heuristic: check for LangGraph identifier in the serialized repr. + if serialized: + name = serialized.get("name", "") + graph_id = ( + serialized.get("graph", {}).get("id", "") + if isinstance(serialized.get("graph"), dict) + else "" + ) + return LANGGRAPH_IDENTIFIER in name or LANGGRAPH_IDENTIFIER in graph_id + + return True + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def should_ignore_chain( + metadata: Optional[dict[str, Any]], + agent_name: Optional[str], + kwargs: dict[str, Any], +) -> bool: + """Return True if the chain callback should be silently suppressed. + + Suppression happens when: + * The node is the LangGraph ``__start__`` node. + * The name carries the ``Middleware.`` prefix. + * ``metadata["otel_trace"]`` is explicitly ``False``. + * ``metadata["otel_agent_span"]`` is explicitly ``False`` and no other + agent signals are present. + """ + if metadata: + node = metadata.get(LANGGRAPH_NODE_KEY) + if node == LANGGRAPH_START_NODE: + return True + + if metadata.get(_META_OTEL_TRACE) is False: + return True + + if ( + metadata.get(_META_AGENT_SPAN) is False + and not metadata.get(_META_AGENT_NAME) + and not metadata.get(_META_AGENT_TYPE) + ): + return True + + if agent_name and agent_name.startswith(MIDDLEWARE_PREFIX): + return True + + name_from_kwargs = kwargs.get("name", "") + if isinstance(name_from_kwargs, str) and name_from_kwargs.startswith( + MIDDLEWARE_PREFIX + ): + return True + + return False + + +def classify_chain_run( + serialized: dict[str, Any], + metadata: Optional[dict[str, Any]], + kwargs: dict[str, Any], + parent_run_id: Optional[UUID] = None, +) -> Optional[str]: + """Classify a ``on_chain_start`` callback into a semconv operation. + + Returns one of the :class:`OperationName` constants, or ``None`` when + the chain should be suppressed (no span emitted). + + Classification order: + 1. Check for explicit suppression signals. + 2. Check for agent signals → ``invoke_agent``. + 3. Check for workflow signals → ``invoke_workflow``. + 4. Default: ``None`` (suppress – unclassified chains are not emitted). + """ + agent_name = resolve_agent_name(serialized, metadata, kwargs) + + # 1. Suppress known noise. + if should_ignore_chain(metadata, agent_name, kwargs): + return None + + # 2. Agent detection. + if _has_agent_signals(metadata): + return OperationName.INVOKE_AGENT + + # 3. Workflow / orchestration detection. + if _looks_like_workflow(serialized, metadata, parent_run_id): + return OperationName.INVOKE_WORKFLOW + + # 4. Default: suppress unclassified chains. + return None diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py new file mode 100644 index 00000000..b5de9323 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py @@ -0,0 +1,91 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import json +from typing import Any, Optional, cast + +from langchain_core.messages import AIMessage + +from opentelemetry.util.genai.types import ( + InputMessage, + OutputMessage, + Text, +) + + +def make_input_message(data: Any) -> list[InputMessage]: + """Create structured input message with full data as JSON.""" + if not isinstance(data, dict): + return [] + data_dict = cast(dict[str, Any], data) + input_messages: list[InputMessage] = [] + messages: Any = data_dict.get("messages") + if messages is not None: + for msg in messages: + content: Any = getattr(msg, "content", "") + if content and isinstance(content, str): + input_message = InputMessage( + role="user", parts=[Text(content)] + ) + input_messages.append(input_message) + return input_messages + # Fallback: serialize non-message state fields as input. + # Common in LangGraph where nodes use structured state fields + # (e.g., user_query) rather than a message list. + exclude_keys = {"messages", "intermediate_steps"} + input_data: dict[str, Any] = { + k: v + for k, v in data_dict.items() + if k not in exclude_keys and v is not None + } + if input_data: + serialized = serialize(input_data) + if serialized: + return [InputMessage(role="user", parts=[Text(serialized)])] + return input_messages + + +def make_output_message(data: dict[str, Any]) -> list[OutputMessage]: + """Create structured output message with full data as JSON.""" + output_messages: list[OutputMessage] = [] + messages: Any = data.get("messages") + if messages is None: + return [] + for msg in messages: + content: Any = getattr(msg, "content", "") + if content and isinstance(msg, AIMessage) and isinstance(content, str): + output_message = OutputMessage( + role="assistant", + parts=[Text(content)], + finish_reason="stop", + ) + output_messages.append(output_message) + return output_messages + + +def make_last_output_message(data: dict[str, Any]) -> list[OutputMessage]: + """Extract only the last AI message as the output. + + For Workflow and AgentInvocation spans, the final AI message best represents + the actual output. Intermediate AI messages (e.g., tool-call decisions) are + already captured in child LLM invocation spans. + """ + all_messages = make_output_message(data) + if all_messages: + return [all_messages[-1]] + return [] + + +def serialize(obj: Any) -> Optional[str]: + """Serialize object to JSON string. + + Uses default=str to handle non-JSON-serializable objects (like LangChain + message objects) by converting them to their string representation while + keeping the overall structure as valid JSON. + """ + if obj is None: + return None + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return None diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py index ceb4f247..6a9106ac 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py @@ -211,9 +211,10 @@ def deserialize(cassette_string): return yaml.load(cassette_string, Loader=yaml.Loader) -@pytest.fixture(scope="module", autouse=True) +@pytest.fixture(scope="function", autouse=True) def fixture_vcr(vcr): - vcr.register_serializer("yaml", PrettyPrintJSONBody) + if vcr is not None: + vcr.register_serializer("yaml", PrettyPrintJSONBody) return vcr diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py new file mode 100644 index 00000000..26bbcffd --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py @@ -0,0 +1,995 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for on_chain_start / on_chain_end / on_chain_error in +OpenTelemetryLangChainCallbackHandler. + +All TelemetryHandler interactions are mocked so that these tests exercise only +the callback-handler logic and the invocation-manager bookkeeping. +""" + +import uuid +from unittest import mock + +from langchain_core.messages import AIMessage, HumanMessage + +from opentelemetry.instrumentation.langchain.callback_handler import ( + OpenTelemetryLangChainCallbackHandler, +) +from opentelemetry.instrumentation.langchain.utils import ( + make_input_message, + make_last_output_message, + make_output_message, + serialize, +) +from opentelemetry.util.genai.invocation import ( + AgentInvocation, + WorkflowInvocation, +) +from opentelemetry.util.genai.types import InputMessage, OutputMessage, Text + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_handler(): + """Return a handler wired to a MagicMock TelemetryHandler.""" + telemetry = mock.MagicMock() + + # start_workflow returns a mock WorkflowInvocation + workflow_inv = mock.MagicMock(spec=WorkflowInvocation) + workflow_inv.span = mock.MagicMock() + workflow_inv.span.is_recording.return_value = False + telemetry.start_workflow.return_value = workflow_inv + + # start_invoke_local_agent returns a mock AgentInvocation + agent_inv = mock.MagicMock(spec=AgentInvocation) + agent_inv.span = mock.MagicMock() + agent_inv.span.is_recording.return_value = False + telemetry.start_invoke_local_agent.return_value = agent_inv + + handler = OpenTelemetryLangChainCallbackHandler(telemetry) + return handler, telemetry, workflow_inv, agent_inv + + +def _run_id(): + return uuid.uuid4() + + +# --------------------------------------------------------------------------- +# on_chain_start – INVOKE_WORKFLOW +# --------------------------------------------------------------------------- + + +class TestOnChainStartWorkflow: + def test_workflow_span_created(self): + handler, telemetry, workflow_inv, _ = _make_handler() + run_id = _run_id() + + # LangGraph graph serialized dict triggers workflow classification + handler.on_chain_start( + serialized={"name": "LangGraph", "id": ["langgraph"]}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + telemetry.start_workflow.assert_called_once() + assert ( + handler._invocation_manager.get_invocation(run_id) is workflow_inv + ) + + def test_workflow_name_from_serialized(self): + handler, telemetry, _, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "MyLangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + telemetry.start_workflow.assert_called_once_with(name="MyLangGraph") + + def test_workflow_name_overridden_by_metadata(self): + handler, telemetry, _, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "MyLangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={"workflow_name": "custom_workflow"}, + ) + + telemetry.start_workflow.assert_called_once_with( + name="custom_workflow" + ) + + def test_workflow_registered_in_invocation_manager(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + assert ( + handler._invocation_manager.get_invocation(run_id) is workflow_inv + ) + + +# --------------------------------------------------------------------------- +# on_chain_start – INVOKE_AGENT +# --------------------------------------------------------------------------- + + +class TestOnChainStartAgent: + def test_new_agent_span_created(self): + handler, telemetry, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent", "ls_provider": "openai"}, + ) + + telemetry.start_invoke_local_agent.assert_called_once_with( + provider="openai" + ) + assert agent_inv.agent_name == "math_agent" + assert handler._invocation_manager.get_invocation(run_id) is agent_inv + + def test_agent_metadata_set(self): + handler, _, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={ + "agent_name": "math_agent", + "agent_id": "agent-123", + "agent_description": "does math", + "thread_id": "thread-abc", + }, + ) + + assert agent_inv.agent_id == "agent-123" + assert agent_inv.agent_description == "does math" + assert agent_inv.conversation_id == "thread-abc" + + def test_conversation_id_prefers_thread_id_over_session_id(self): + handler, _, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={ + "agent_name": "math_agent", + "thread_id": "t1", + "session_id": "s1", + }, + ) + + assert agent_inv.conversation_id == "t1" + + def test_duplicate_agent_name_does_not_create_new_span(self): + """When the nearest ancestor already has the same agent name, no new + AgentInvocation span is created; the run is still tracked with None.""" + handler, telemetry, _, agent_inv = _make_handler() + parent_run_id = _run_id() + child_run_id = _run_id() + + # Register the parent agent + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=parent_run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + telemetry.start_invoke_local_agent.reset_mock() + + # A child chain with the same agent name should NOT create a new span + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=child_run_id, + parent_run_id=parent_run_id, + metadata={"agent_name": "math_agent"}, + ) + + telemetry.start_invoke_local_agent.assert_not_called() + assert handler._invocation_manager.get_invocation(child_run_id) is None + + def test_different_agent_name_creates_new_span(self): + """A child chain with a different agent name creates a new AgentInvocation.""" + handler, telemetry, _, _ = _make_handler() + parent_run_id = _run_id() + child_run_id = _run_id() + + # First agent + first_agent_inv = mock.MagicMock(spec=AgentInvocation) + first_agent_inv.span = mock.MagicMock() + first_agent_inv.span.is_recording.return_value = False + telemetry.start_invoke_local_agent.return_value = first_agent_inv + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=parent_run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + # Second agent with a different name + second_agent_inv = mock.MagicMock(spec=AgentInvocation) + second_agent_inv.span = mock.MagicMock() + second_agent_inv.span.is_recording.return_value = False + telemetry.start_invoke_local_agent.return_value = second_agent_inv + + handler.on_chain_start( + serialized={"name": "weather_agent"}, + inputs={}, + run_id=child_run_id, + parent_run_id=parent_run_id, + metadata={"agent_name": "weather_agent"}, + ) + + assert ( + handler._invocation_manager.get_invocation(child_run_id) + is second_agent_inv + ) + assert second_agent_inv.agent_name == "weather_agent" + + def test_agent_name_comparison_is_case_insensitive(self): + handler, telemetry, _, _ = _make_handler() + parent_run_id = _run_id() + child_run_id = _run_id() + + parent_agent_inv = mock.MagicMock(spec=AgentInvocation) + parent_agent_inv.span = mock.MagicMock() + parent_agent_inv.span.is_recording.return_value = False + telemetry.start_invoke_local_agent.return_value = parent_agent_inv + + handler.on_chain_start( + serialized={"name": "Math_Agent"}, + inputs={}, + run_id=parent_run_id, + parent_run_id=None, + metadata={"agent_name": "Math_Agent"}, + ) + telemetry.start_invoke_local_agent.reset_mock() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=child_run_id, + parent_run_id=parent_run_id, + metadata={"agent_name": "math_agent"}, + ) + + # Same name (case-insensitive) → no new span + telemetry.start_invoke_local_agent.assert_not_called() + + def test_no_agent_name_registers_none_invocation(self): + """When resolve_agent_name returns None the run_id must still be + registered so that child traversal works.""" + handler, telemetry, _, _ = _make_handler() + run_id = _run_id() + + # metadata has otel_agent_span=True so classify_chain_run → INVOKE_AGENT, + # but no agent_name / kwargs name / serialized name, so resolve_agent_name + # returns None. + handler.on_chain_start( + serialized={}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={"otel_agent_span": True}, + ) + + telemetry.start_invoke_local_agent.assert_not_called() + # run_id must still be registered (with None invocation) so traversal works + assert run_id in handler._invocation_manager._invocations + assert handler._invocation_manager.get_invocation(run_id) is None + + def test_no_agent_name_child_can_still_find_ancestor_agent(self): + """Even when an intermediate node has no agent name, a deeper child + must still be able to walk up and find a grandparent AgentInvocation.""" + handler, telemetry, _, agent_inv = _make_handler() + grandparent_id = _run_id() + parent_id = _run_id() + + # Grandparent: a known agent + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=grandparent_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + # Parent: INVOKE_AGENT but no resolvable name → registers None + telemetry.start_invoke_local_agent.reset_mock() + handler.on_chain_start( + serialized={}, + inputs={}, + run_id=parent_id, + parent_run_id=grandparent_id, + metadata={"otel_agent_span": True}, + ) + + # Child: should find the grandparent agent via _find_nearest_agent + found = handler._find_nearest_agent(parent_id) + assert found is agent_inv + + +# --------------------------------------------------------------------------- +# on_chain_start – unclassified +# --------------------------------------------------------------------------- + + +class TestOnChainStartUnclassified: + def test_unclassified_chain_registers_none_and_no_span(self): + handler, telemetry, _, _ = _make_handler() + run_id = _run_id() + parent_run_id = _run_id() + + # Register the parent first so that the child links correctly + handler._invocation_manager.add_invocation_state( + parent_run_id, None, None + ) + + handler.on_chain_start( + serialized={"name": "SomeInternalChain"}, + inputs={}, + run_id=run_id, + parent_run_id=parent_run_id, + ) + + telemetry.start_workflow.assert_not_called() + telemetry.start_invoke_local_agent.assert_not_called() + assert run_id in handler._invocation_manager._invocations + assert handler._invocation_manager.get_invocation(run_id) is None + + +# --------------------------------------------------------------------------- +# on_chain_end +# --------------------------------------------------------------------------- + + +class TestOnChainEnd: + def test_workflow_invocation_stopped_on_chain_end(self): + handler, telemetry, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + handler.on_chain_end(outputs={}, run_id=run_id) + + workflow_inv.stop.assert_called_once() + + def test_agent_invocation_stopped_on_chain_end(self): + handler, telemetry, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + handler.on_chain_end(outputs={}, run_id=run_id) + + agent_inv.stop.assert_called_once() + + def test_none_invocation_on_chain_end_does_not_raise(self): + """on_chain_end for a run registered with None invocation (unclassified + or duplicate agent) must silently do nothing.""" + handler, _, _, _ = _make_handler() + run_id = _run_id() + + handler._invocation_manager.add_invocation_state(run_id, None, None) + + # Must not raise + handler.on_chain_end(outputs={}, run_id=run_id) + + def test_unknown_run_id_on_chain_end_does_not_raise(self): + handler, _, _, _ = _make_handler() + handler.on_chain_end(outputs={}, run_id=_run_id()) + + def test_invocation_state_cleaned_up_after_chain_end(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + # span.is_recording() returns False → should be cleaned up + workflow_inv.span.is_recording.return_value = False + handler.on_chain_end(outputs={}, run_id=run_id) + + assert run_id not in handler._invocation_manager._invocations + + +# --------------------------------------------------------------------------- +# on_chain_error +# --------------------------------------------------------------------------- + + +class TestOnChainError: + def test_workflow_invocation_failed_on_chain_error(self): + handler, telemetry, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + err = RuntimeError("something went wrong") + handler.on_chain_error(error=err, run_id=run_id) + + workflow_inv.fail.assert_called_once_with(err) + + def test_agent_invocation_failed_on_chain_error(self): + handler, telemetry, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + err = ValueError("agent failed") + handler.on_chain_error(error=err, run_id=run_id) + + agent_inv.fail.assert_called_once_with(err) + + def test_none_invocation_on_chain_error_does_not_raise(self): + handler, _, _, _ = _make_handler() + run_id = _run_id() + + handler._invocation_manager.add_invocation_state(run_id, None, None) + + handler.on_chain_error(error=RuntimeError("boom"), run_id=run_id) + + def test_unknown_run_id_on_chain_error_does_not_raise(self): + handler, _, _, _ = _make_handler() + handler.on_chain_error(error=RuntimeError("boom"), run_id=_run_id()) + + def test_invocation_state_cleaned_up_after_chain_error(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + workflow_inv.span.is_recording.return_value = False + handler.on_chain_error(error=RuntimeError("boom"), run_id=run_id) + + assert run_id not in handler._invocation_manager._invocations + + +# --------------------------------------------------------------------------- +# _find_nearest_agent +# --------------------------------------------------------------------------- + + +class TestFindNearestAgent: + def test_returns_none_when_no_agent_in_ancestry(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + assert handler._find_nearest_agent(run_id) is None + + def test_finds_direct_parent_agent(self): + handler, telemetry, _, agent_inv = _make_handler() + parent_id = _run_id() + child_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=parent_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + # Register the child as unclassified so it links to the parent + handler._invocation_manager.add_invocation_state( + child_id, parent_id, None + ) + + found = handler._find_nearest_agent(child_id) + assert found is agent_inv + + def test_finds_grandparent_agent(self): + handler, telemetry, _, agent_inv = _make_handler() + grandparent_id = _run_id() + parent_id = _run_id() + child_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=grandparent_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + handler._invocation_manager.add_invocation_state( + parent_id, grandparent_id, None + ) + handler._invocation_manager.add_invocation_state( + child_id, parent_id, None + ) + + found = handler._find_nearest_agent(child_id) + assert found is agent_inv + + +# --------------------------------------------------------------------------- +# utils.make_input_message +# --------------------------------------------------------------------------- + + +class TestMakeInputMessage: + def test_returns_empty_list_for_non_dict(self): + assert make_input_message("not a dict") == [] + assert make_input_message(None) == [] + assert make_input_message(42) == [] + + def test_empty_dict_returns_empty_list(self): + assert make_input_message({}) == [] + + def test_messages_key_with_human_message(self): + msg = HumanMessage(content="Hello") + result = make_input_message({"messages": [msg]}) + + assert len(result) == 1 + assert isinstance(result[0], InputMessage) + assert result[0].role == "user" + assert len(result[0].parts) == 1 + assert isinstance(result[0].parts[0], Text) + assert result[0].parts[0].content == "Hello" + + def test_messages_key_skips_empty_content(self): + msg_empty = HumanMessage(content="") + msg_valid = HumanMessage(content="Hi") + result = make_input_message({"messages": [msg_empty, msg_valid]}) + + assert len(result) == 1 + assert result[0].parts[0].content == "Hi" + + def test_messages_key_multiple_messages(self): + msgs = [HumanMessage(content="First"), HumanMessage(content="Second")] + result = make_input_message({"messages": msgs}) + + assert len(result) == 2 + assert result[0].parts[0].content == "First" + assert result[1].parts[0].content == "Second" + + def test_messages_key_takes_priority_over_other_fields(self): + msg = HumanMessage(content="hello") + result = make_input_message( + {"messages": [msg], "user_query": "should be ignored"} + ) + + assert len(result) == 1 + assert result[0].parts[0].content == "hello" + + def test_fallback_serializes_non_message_state_fields(self): + result = make_input_message({"user_query": "what is 2+2?"}) + + assert len(result) == 1 + assert result[0].role == "user" + # The content should be a JSON serialization of the dict + assert "user_query" in result[0].parts[0].content + assert "what is 2+2?" in result[0].parts[0].content + + def test_fallback_excludes_intermediate_steps_key(self): + # messages key absent → fallback path runs; intermediate_steps excluded + result = make_input_message( + { + "user_query": "hi", + "intermediate_steps": [("tool", "result")], + } + ) + + assert len(result) == 1 + content = result[0].parts[0].content + assert "intermediate_steps" not in content + assert "user_query" in content + + def test_messages_key_present_skips_fallback_even_with_other_fields(self): + # messages key present (even empty list) → early return, fallback not reached + result = make_input_message( + { + "user_query": "ignored", + "messages": [], + "intermediate_steps": [("tool", "result")], + } + ) + + assert result == [] + + def test_fallback_returns_empty_when_all_values_are_none(self): + result = make_input_message({"user_query": None, "context": None}) + assert result == [] + + def test_fallback_returns_empty_when_only_excluded_keys(self): + result = make_input_message( + {"messages": None, "intermediate_steps": None} + ) + assert result == [] + + def test_messages_key_with_empty_list(self): + # messages key present but empty → return empty list (no fallback) + result = make_input_message({"messages": [], "user_query": "ignored"}) + assert result == [] + + +# --------------------------------------------------------------------------- +# utils.make_output_message / make_last_output_message +# --------------------------------------------------------------------------- + + +class TestMakeOutputMessage: + def test_returns_empty_list_for_non_dict(self): + assert make_output_message("not a dict") == [] + assert make_output_message(None) == [] + + def test_returns_empty_list_when_no_messages_key(self): + assert make_output_message({"output": "hi"}) == [] + + def test_returns_empty_list_when_messages_is_none(self): + assert make_output_message({"messages": None}) == [] + + def test_ai_message_produces_assistant_output(self): + ai_msg = AIMessage(content="The answer is 42") + result = make_output_message({"messages": [ai_msg]}) + + assert len(result) == 1 + assert isinstance(result[0], OutputMessage) + assert result[0].role == "assistant" + assert result[0].finish_reason == "stop" + assert result[0].parts[0].content == "The answer is 42" + + def test_non_ai_message_skipped(self): + human_msg = HumanMessage(content="Hello") + result = make_output_message({"messages": [human_msg]}) + assert result == [] + + def test_ai_message_with_empty_content_skipped(self): + ai_msg = AIMessage(content="") + result = make_output_message({"messages": [ai_msg]}) + assert result == [] + + def test_multiple_ai_messages_all_returned(self): + msgs = [ + AIMessage(content="First response"), + AIMessage(content="Second response"), + ] + result = make_output_message({"messages": msgs}) + + assert len(result) == 2 + assert result[0].parts[0].content == "First response" + assert result[1].parts[0].content == "Second response" + + def test_mixed_messages_only_ai_returned(self): + msgs = [ + HumanMessage(content="question"), + AIMessage(content="answer"), + HumanMessage(content="follow-up"), + ] + result = make_output_message({"messages": msgs}) + + assert len(result) == 1 + assert result[0].parts[0].content == "answer" + + +class TestMakeLastOutputMessage: + def test_returns_only_last_ai_message(self): + msgs = [ + AIMessage(content="intermediate"), + AIMessage(content="final answer"), + ] + result = make_last_output_message({"messages": msgs}) + + assert len(result) == 1 + assert result[0].parts[0].content == "final answer" + + def test_returns_empty_when_no_ai_messages(self): + result = make_last_output_message( + {"messages": [HumanMessage(content="hi")]} + ) + assert result == [] + + def test_returns_empty_for_empty_outputs(self): + assert make_last_output_message({}) == [] + + def test_single_ai_message_returned(self): + ai_msg = AIMessage(content="only response") + result = make_last_output_message({"messages": [ai_msg]}) + + assert len(result) == 1 + assert result[0].parts[0].content == "only response" + + +# --------------------------------------------------------------------------- +# utils.serialize +# --------------------------------------------------------------------------- + + +class TestSerialize: + def test_none_returns_none(self): + assert serialize(None) is None + + def test_dict_serialized_to_json(self): + result = serialize({"key": "value"}) + assert result == '{"key": "value"}' + + def test_list_serialized_to_json(self): + result = serialize([1, 2, 3]) + assert result == "[1, 2, 3]" + + def test_non_serializable_falls_back_to_str(self): + class Custom: + def __str__(self): + return "custom_repr" + + result = serialize({"obj": Custom()}) + assert result is not None + assert "custom_repr" in result + + def test_string_value(self): + result = serialize("hello") + assert result == '"hello"' + + +# --------------------------------------------------------------------------- +# input_messages / output_messages set on invocations via callback handler +# --------------------------------------------------------------------------- + + +class TestInputMessagesOnInvocations: + def test_workflow_input_messages_set_from_messages_key(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + msg = HumanMessage(content="What is the weather?") + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={"messages": [msg]}, + run_id=run_id, + parent_run_id=None, + ) + + assigned = workflow_inv.input_messages + assert len(assigned) == 1 + assert assigned[0].role == "user" + assert assigned[0].parts[0].content == "What is the weather?" + + def test_workflow_input_messages_set_from_state_fallback(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={"user_query": "plan a trip"}, + run_id=run_id, + parent_run_id=None, + ) + + assigned = workflow_inv.input_messages + assert len(assigned) == 1 + assert "user_query" in assigned[0].parts[0].content + assert "plan a trip" in assigned[0].parts[0].content + + def test_workflow_input_messages_empty_for_empty_inputs(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + assert workflow_inv.input_messages == [] + + def test_agent_input_messages_set_from_messages_key(self): + handler, _, _, agent_inv = _make_handler() + run_id = _run_id() + msg = HumanMessage(content="Solve x+2=5") + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={"messages": [msg]}, + run_id=run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + assigned = agent_inv.input_messages + assert len(assigned) == 1 + assert assigned[0].parts[0].content == "Solve x+2=5" + + def test_agent_input_messages_set_from_state_fallback(self): + handler, _, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={"problem": "integrate x^2"}, + run_id=run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + assigned = agent_inv.input_messages + assert len(assigned) == 1 + assert "integrate x^2" in assigned[0].parts[0].content + + +class TestOutputMessagesOnInvocations: + def test_workflow_output_messages_set_on_chain_end(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + ai_msg = AIMessage(content="The final answer is 42") + handler.on_chain_end( + outputs={"messages": [ai_msg]}, + run_id=run_id, + ) + + assigned = workflow_inv.output_messages + assert len(assigned) == 1 + assert assigned[0].role == "assistant" + assert assigned[0].parts[0].content == "The final answer is 42" + + def test_workflow_output_messages_only_last_ai_message(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + msgs = [ + AIMessage(content="intermediate tool call"), + AIMessage(content="final answer"), + ] + handler.on_chain_end(outputs={"messages": msgs}, run_id=run_id) + + assigned = workflow_inv.output_messages + assert len(assigned) == 1 + assert assigned[0].parts[0].content == "final answer" + + def test_workflow_output_messages_empty_when_no_ai_messages(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + handler.on_chain_end( + outputs={"messages": [HumanMessage(content="hi")]}, + run_id=run_id, + ) + + assert workflow_inv.output_messages == [] + + def test_workflow_output_messages_empty_for_empty_outputs(self): + handler, _, workflow_inv, _ = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "LangGraph"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + ) + + handler.on_chain_end(outputs={}, run_id=run_id) + + assert workflow_inv.output_messages == [] + + def test_agent_output_messages_set_on_chain_end(self): + handler, _, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + ai_msg = AIMessage(content="x = 3") + handler.on_chain_end(outputs={"messages": [ai_msg]}, run_id=run_id) + + assigned = agent_inv.output_messages + assert len(assigned) == 1 + assert assigned[0].parts[0].content == "x = 3" + + def test_agent_output_messages_only_last_ai_message(self): + handler, _, _, agent_inv = _make_handler() + run_id = _run_id() + + handler.on_chain_start( + serialized={"name": "math_agent"}, + inputs={}, + run_id=run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + msgs = [ + AIMessage(content="let me think..."), + AIMessage(content="the answer is 7"), + ] + handler.on_chain_end(outputs={"messages": msgs}, run_id=run_id) + + assigned = agent_inv.output_messages + assert len(assigned) == 1 + assert assigned[0].parts[0].content == "the answer is 7" diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_invocation_manager.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_invocation_manager.py index c120af10..8e977ae6 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_invocation_manager.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_invocation_manager.py @@ -104,7 +104,73 @@ def test_delete_invocation_state(invocation_manager, mock_invocation): assert run_id not in invocation_manager._invocations -def test_delete_invocation_state_with_children(invocation_manager): +def test_delete_invocation_state_deferred_while_children_live( + invocation_manager, +): + """Deleting a parent while children are still live defers its removal.""" + parent_id = uuid.uuid4() + child_id = uuid.uuid4() + + parent_invocation = mock.Mock(spec=GenAIInvocation) + child_invocation = mock.Mock(spec=GenAIInvocation) + + invocation_manager.add_invocation_state( + run_id=parent_id, parent_run_id=None, invocation=parent_invocation + ) + invocation_manager.add_invocation_state( + run_id=child_id, parent_run_id=parent_id, invocation=child_invocation + ) + + # Delete the parent while the child is still live + invocation_manager.delete_invocation_state(parent_id) + + # Parent should still be present (deferred) because child is live + assert parent_id in invocation_manager._invocations + assert invocation_manager._invocations[parent_id].ended is True + + # After the child is deleted, the parent should also be cleaned up + invocation_manager.delete_invocation_state(child_id) + + assert child_id not in invocation_manager._invocations + assert parent_id not in invocation_manager._invocations + + +def test_delete_invocation_state_propagates_upward(invocation_manager): + """When the last child is removed, an already-ended parent is cleaned up.""" + grandparent_id = uuid.uuid4() + parent_id = uuid.uuid4() + child_id = uuid.uuid4() + + for run_id, parent in [ + (grandparent_id, None), + (parent_id, grandparent_id), + (child_id, parent_id), + ]: + invocation_manager.add_invocation_state( + run_id=run_id, + parent_run_id=parent, + invocation=mock.Mock(spec=GenAIInvocation), + ) + + # Mark grandparent and parent as ended (deferred) + invocation_manager.delete_invocation_state(grandparent_id) + invocation_manager.delete_invocation_state(parent_id) + + assert grandparent_id in invocation_manager._invocations # deferred + assert parent_id in invocation_manager._invocations # deferred + + # Removing the last live node should cascade upward + invocation_manager.delete_invocation_state(child_id) + + assert child_id not in invocation_manager._invocations + assert parent_id not in invocation_manager._invocations + assert grandparent_id not in invocation_manager._invocations + + +def test_delete_invocation_state_with_multiple_children_defers_until_last( + invocation_manager, +): + """Parent removal is deferred until all children are gone.""" parent_id = uuid.uuid4() child1_id = uuid.uuid4() child2_id = uuid.uuid4() @@ -113,32 +179,63 @@ def test_delete_invocation_state_with_children(invocation_manager): child1_invocation = mock.Mock(spec=GenAIInvocation) child2_invocation = mock.Mock(spec=GenAIInvocation) - # Add parent and children + invocation_manager.add_invocation_state( + run_id=parent_id, parent_run_id=None, invocation=parent_invocation + ) + invocation_manager.add_invocation_state( + run_id=child1_id, parent_run_id=parent_id, invocation=child1_invocation + ) + invocation_manager.add_invocation_state( + run_id=child2_id, parent_run_id=parent_id, invocation=child2_invocation + ) + + # Delete parent while both children live → deferred + invocation_manager.delete_invocation_state(parent_id) + assert parent_id in invocation_manager._invocations + + # Remove first child → parent still deferred (child2 is live) + invocation_manager.delete_invocation_state(child1_id) + assert parent_id in invocation_manager._invocations + + # Remove last child → parent is now cleaned up + invocation_manager.delete_invocation_state(child2_id) + assert parent_id not in invocation_manager._invocations + + +def test_get_parent_run_id_returns_none_for_unknown(invocation_manager): + assert invocation_manager.get_parent_run_id(uuid.uuid4()) is None + + +def test_get_parent_run_id_returns_registered_parent(invocation_manager): + parent_id = uuid.uuid4() + child_id = uuid.uuid4() + invocation_manager.add_invocation_state( run_id=parent_id, parent_run_id=None, - invocation=parent_invocation, + invocation=mock.Mock(spec=GenAIInvocation), ) invocation_manager.add_invocation_state( - run_id=child1_id, + run_id=child_id, parent_run_id=parent_id, - invocation=child1_invocation, + invocation=mock.Mock(spec=GenAIInvocation), ) + + assert invocation_manager.get_parent_run_id(child_id) == parent_id + assert invocation_manager.get_parent_run_id(parent_id) is None + + +def test_none_invocation_can_be_stored_and_retrieved(invocation_manager): + """Nodes with no associated span (None invocation) must still be tracked.""" + run_id = uuid.uuid4() + invocation_manager.add_invocation_state( - run_id=child2_id, - parent_run_id=parent_id, - invocation=child2_invocation, + run_id=run_id, parent_run_id=None, invocation=None ) - # Verify initial state - assert len(invocation_manager._invocations) == 3 - assert len(invocation_manager._invocations[parent_id].children) == 2 + assert run_id in invocation_manager._invocations + assert invocation_manager.get_invocation(run_id) is None - # Delete parent - invocation_manager.delete_invocation_state(parent_id) - # Verify parent and all children were removed - assert parent_id not in invocation_manager._invocations - assert child1_id not in invocation_manager._invocations - assert child2_id not in invocation_manager._invocations - assert len(invocation_manager._invocations) == 0 +def test_delete_nonexistent_run_id_does_not_raise(invocation_manager): + invocation_manager.delete_invocation_state(uuid.uuid4()) # must not raise From ef5fc95137718c9f96fc4393b6174c98b3a5a7d6 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Sun, 17 May 2026 16:10:06 -0700 Subject: [PATCH 02/12] Added change log --- .../CHANGELOG.md | 2 + .../agent/custom_tool_calling_agent.py | 107 -------- .../examples/agent/human_in_the_loop_agent.py | 169 ------------ .../examples/agent/multi_agent.py | 153 ----------- .../agent/parallel_map_reduce_agent.py | 156 ----------- .../examples/agent/plan_and_execute_agent.py | 254 ------------------ .../examples/agent/supervisor_agent.py | 142 ---------- .../examples/workflow/main_without_nodes.py | 122 --------- .../tests/conftest.py | 2 +- 9 files changed, 3 insertions(+), 1104 deletions(-) delete mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py delete mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py delete mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py delete mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py delete mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py delete mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py delete mode 100644 instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py diff --git a/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md b/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md index 767dfcc7..3e6b69a0 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md +++ b/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Add LangChain workflow and agent span support. Also refactor LLM invocation. + ([#25](https://github.com/open-telemetry/opentelemetry-python-genai/pull/25)) - Fix compatibility with wrapt 2.x by using positional arguments in `wrap_function_wrapper()` calls ([#4445](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4445)) - Added span support for genAI langchain llm invocation. diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py deleted file mode 100644 index 9f9466e7..00000000 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/custom_tool_calling_agent.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Custom tool-calling agent built manually with StateGraph. - -Equivalent in behaviour to create_react_agent but assembled from primitives: - StateGraph + ToolNode + tools_condition - -The agent loops: model -> tools -> model until the model stops calling tools. -OpenTelemetry LangChain instrumentation traces every LLM call in the loop. -""" - -from typing import Annotated - -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_core.tools import tool -from langchain_openai import ChatOpenAI -from langgraph.graph import END, START, MessagesState, StateGraph -from langgraph.prebuilt import ToolNode, tools_condition - -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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Configure tracing -trace.set_tracer_provider(TracerProvider()) -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - -# Configure logging -_logs.set_logger_provider(LoggerProvider()) -_logs.get_logger_provider().add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) -) - -# Configure metrics -metrics.set_meter_provider( - MeterProvider( - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] - ) -) - - -@tool -def multiply(a: float, b: float) -> float: - """Multiply two numbers together.""" - return a * b - - -@tool -def add(a: float, b: float) -> float: - """Add two numbers together.""" - return a + b - - -def build_agent(llm_with_tools): - """Build a ReAct-style agent graph manually from StateGraph primitives.""" - - def call_model(state: MessagesState) -> dict: - response = llm_with_tools.invoke(state["messages"]) - return {"messages": [response]} - - tool_node = ToolNode([multiply, add]) - - builder = StateGraph(MessagesState) - builder.add_node("agent", call_model) - builder.add_node("tools", tool_node) - - builder.add_edge(START, "agent") - # Route to tools if the model made tool calls, otherwise finish. - builder.add_conditional_edges("agent", tools_condition) - builder.add_edge("tools", "agent") - - return builder.compile() - - -def main(): - LangChainInstrumentor().instrument() - - llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) - llm_with_tools = llm.bind_tools([multiply, add]) - - agent = build_agent(llm_with_tools) - - result = agent.invoke( - { - "messages": [ - SystemMessage(content="You are a helpful calculator assistant."), - HumanMessage(content="What is (5 * 6) + 3?"), - ] - } - ) - - print("Custom tool-calling agent output:") - for msg in result["messages"]: - print(f" {type(msg).__name__}: {msg.content}") - - LangChainInstrumentor().uninstrument() - - -if __name__ == "__main__": - main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py deleted file mode 100644 index 05b3fab1..00000000 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/human_in_the_loop_agent.py +++ /dev/null @@ -1,169 +0,0 @@ -""" -Human-in-the-loop agent built with StateGraph. - -Before executing a tool call the graph pauses and asks the user for approval. -Uses LangGraph's `interrupt` function and `InMemorySaver` checkpointer so -graph state is persisted between the first run (pause) and the resumed run -(after human decision). - -Flow: - START -> agent -> approval_gate -> tools -> agent -> ... -> END - -The approval_gate node calls `interrupt()` to surface the pending tool call to -the human. When the graph is resumed the gate checks the human's decision: - - "approve" -> forward to tools - - anything else -> skip tools, return a cancellation message - -OpenTelemetry LangChain instrumentation traces all LLM calls. -""" - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from langchain_core.tools import tool -from langchain_openai import ChatOpenAI -from langgraph.checkpoint.memory import InMemorySaver -from langgraph.graph import END, START, MessagesState, StateGraph -from langgraph.prebuilt import ToolNode, tools_condition -from langgraph.types import interrupt - -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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Configure tracing -trace.set_tracer_provider(TracerProvider()) -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - -# Configure logging -_logs.set_logger_provider(LoggerProvider()) -_logs.get_logger_provider().add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) -) - -# Configure metrics -metrics.set_meter_provider( - MeterProvider( - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] - ) -) - - -@tool -def send_email(to: str, subject: str, body: str) -> str: - """Send an email (mock).""" - return f"Email sent to {to} with subject '{subject}'." - - -@tool -def delete_file(path: str) -> str: - """Delete a file (mock).""" - return f"File '{path}' deleted." - - -def build_graph(llm_with_tools): - - tool_node = ToolNode([send_email, delete_file]) - - def call_model(state: MessagesState) -> dict: - response = llm_with_tools.invoke(state["messages"]) - return {"messages": [response]} - - def approval_gate(state: MessagesState) -> dict: - """Pause and ask the human to approve any pending tool calls.""" - last = state["messages"][-1] - if not isinstance(last, AIMessage) or not last.tool_calls: - return {} - - tool_summary = ", ".join( - f"{tc['name']}({tc['args']})" for tc in last.tool_calls - ) - decision = interrupt( - f"Agent wants to call: {tool_summary}\nApprove? (approve/deny): " - ) - - if decision.strip().lower() != "approve": - # Replace the tool calls with a cancellation notice so the model - # can respond gracefully without a dangling tool call. - cancel_messages = [ - ToolMessage( - tool_call_id=tc["id"], - content="Action cancelled by user.", - ) - for tc in last.tool_calls - ] - return {"messages": cancel_messages} - - return {} - - def gate_route(state: MessagesState): - """After the gate, go to tools only if the last AI message still has tool calls.""" - last = state["messages"][-1] - if isinstance(last, AIMessage) and last.tool_calls: - return "tools" - return "agent" - - builder = StateGraph(MessagesState) - builder.add_node("agent", call_model) - builder.add_node("approval_gate", approval_gate) - builder.add_node("tools", tool_node) - - builder.add_edge(START, "agent") - builder.add_conditional_edges( - "agent", - tools_condition, - {"tools": "approval_gate", END: END}, - ) - builder.add_conditional_edges( - "approval_gate", - gate_route, - {"tools": "tools", "agent": "agent"}, - ) - builder.add_edge("tools", "agent") - - checkpointer = InMemorySaver() - return builder.compile(checkpointer=checkpointer) - - -def main(): - LangChainInstrumentor().instrument() - - llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, seed=42) - llm_with_tools = llm.bind_tools([send_email, delete_file]) - graph = build_graph(llm_with_tools) - - config = {"configurable": {"thread_id": "hitl-demo-1"}} - initial_input = { - "messages": [ - SystemMessage(content="You are a helpful assistant with access to email and file tools."), - HumanMessage(content="Send an email to alice@example.com with subject 'Hello' and body 'Hi there!'"), - ] - } - - print("--- First run (will pause for approval) ---") - for event in graph.stream(initial_input, config=config, stream_mode="updates"): - for node, update in event.items(): - if node == "__interrupt__": - prompt = update[0].value - print(f"\n[INTERRUPT] {prompt}") - human_input = input("Your decision: ").strip() - - print("\n--- Resuming with human decision ---") - from langgraph.types import Command - result = graph.invoke(Command(resume=human_input), config=config) - - print("\nFinal conversation:") - for msg in result["messages"]: - print(f" {type(msg).__name__}: {msg.content}") - - LangChainInstrumentor().uninstrument() - - -if __name__ == "__main__": - main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py deleted file mode 100644 index 2381cbea..00000000 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/multi_agent.py +++ /dev/null @@ -1,153 +0,0 @@ -""" -Multi-agent example built with StateGraph. - -An orchestrator graph wires two specialist sub-agents together: - - math_agent : handles arithmetic questions using calculator tools - - weather_agent: handles weather questions using a mock weather tool - -A router node inspects the user's question and dispatches to the right agent. -OpenTelemetry LangChain instrumentation traces all LLM calls in both agents. -""" - -from langchain_core.messages import HumanMessage -from langchain_core.tools import tool -from langchain_openai import ChatOpenAI -from langgraph.graph import END, START, MessagesState, StateGraph -from langchain.agents import create_agent - -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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Configure tracing -trace.set_tracer_provider(TracerProvider()) -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - -# Configure logging -_logs.set_logger_provider(LoggerProvider()) -_logs.get_logger_provider().add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) -) - -# Configure metrics -metrics.set_meter_provider( - MeterProvider( - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] - ) -) - - -# --- Tools ---------------------------------------------------------------- - -@tool -def multiply(a: float, b: float) -> float: - """Multiply two numbers.""" - return a * b - - -@tool -def add(a: float, b: float) -> float: - """Add two numbers.""" - return a + b - - -@tool -def get_weather(city: str) -> str: - """Return a mock weather report for a city.""" - reports = { - "london": "Cloudy, 15°C", - "paris": "Sunny, 22°C", - "new york": "Partly cloudy, 18°C", - } - return reports.get(city.lower(), f"Weather data unavailable for {city}.") - - -# --- Router --------------------------------------------------------------- - -def route(state: MessagesState) -> str: - """Decide which sub-agent should handle the latest user message.""" - last_content = state["messages"][-1].content.lower() - if any(kw in last_content for kw in ("weather", "temperature", "forecast")): - return "weather_agent" - return "math_agent" - - -# --- Graph ---------------------------------------------------------------- - -def build_multi_agent_graph(llm: ChatOpenAI): - from uuid import uuid4 - session_id = str(uuid4()) - math_agent = create_agent( - llm, tools=[multiply, add], name="math_agent" - ).with_config( - { - "metadata": { - "agent_name": "math_agent", - "session_id": session_id, - }, - } - ) - weather_agent = create_agent( - llm, tools=[get_weather], name="weather_agent" - ).with_config( - { - "metadata": { - "agent_name": "weather_agent", - "session_id": session_id, - }, - } - ) - - def run_math_agent(state: MessagesState) -> dict: - result = math_agent.invoke({"messages": state["messages"]}) - return {"messages": result["messages"]} - - def run_weather_agent(state: MessagesState) -> dict: - result = weather_agent.invoke({"messages": state["messages"]}) - return {"messages": result["messages"]} - - builder = StateGraph(MessagesState) - builder.add_node("math_agent", run_math_agent) - builder.add_node("weather_agent", run_weather_agent) - - builder.add_conditional_edges( - START, - route, - {"math_agent": "math_agent", "weather_agent": "weather_agent"}, - ) - builder.add_edge("math_agent", END) - builder.add_edge("weather_agent", END) - - return builder.compile() - - -def main(): - LangChainInstrumentor().instrument() - - llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) - graph = build_multi_agent_graph(llm) - - questions = [ - "What is 12 multiplied by 7?", - "What is the weather in Paris?", - ] - - for question in questions: - print(f"\nQuestion: {question}") - result = graph.invoke({"messages": [HumanMessage(content=question)]}) - last = result["messages"][-1] - print(f" Answer: {last.content}") - - LangChainInstrumentor().uninstrument() - - -if __name__ == "__main__": - main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py deleted file mode 100644 index 1c6c9233..00000000 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/parallel_map_reduce_agent.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Parallel / map-reduce agent built with StateGraph and the Send API. - -Pattern: - 1. A *splitter* node breaks the user request into sub-tasks. - 2. The Send API fans out — each sub-task is processed by a *worker* node - running in parallel. - 3. A *reducer* node collects all results and asks the LLM to synthesise a - final answer. - -OpenTelemetry LangChain instrumentation traces every LLM call. -""" - -from typing import Annotated, Any - -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_openai import ChatOpenAI -from langgraph.graph import END, START, StateGraph -from langgraph.graph.message import add_messages -from langgraph.types import Send -from typing_extensions import TypedDict - -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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Configure tracing -trace.set_tracer_provider(TracerProvider()) -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - -# Configure logging -_logs.set_logger_provider(LoggerProvider()) -_logs.get_logger_provider().add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) -) - -# Configure metrics -metrics.set_meter_provider( - MeterProvider( - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] - ) -) - - -# --- State definitions ---------------------------------------------------- - -class OverallState(TypedDict): - topic: str - subtopics: list[str] - results: Annotated[list[str], lambda a, b: a + b] # reducer: accumulate - final_answer: str - - -class WorkerState(TypedDict): - topic: str - subtopic: str - results: Annotated[list[str], lambda a, b: a + b] - - -# --- Graph nodes ---------------------------------------------------------- - -def splitter(state: OverallState) -> dict: - """Hardcoded sub-tasks for the demo; in production ask the LLM to split.""" - subtopics = [ - f"{state['topic']} – history", - f"{state['topic']} – current applications", - f"{state['topic']} – future outlook", - ] - return {"subtopics": subtopics} - - -def fan_out(state: OverallState) -> list[Send]: - """Return one Send per subtopic to run workers in parallel.""" - return [ - Send("worker", {"topic": state["topic"], "subtopic": st, "results": []}) - for st in state["subtopics"] - ] - - -def worker(state: WorkerState, llm: ChatOpenAI) -> dict: - """Research a single subtopic and return a short summary.""" - response = llm.invoke( - [ - SystemMessage(content="You are a concise research assistant."), - HumanMessage( - content=f"Write 1–2 sentences about: {state['subtopic']}" - ), - ] - ) - return {"results": [f"• {state['subtopic']}: {response.content}"]} - - -def reducer(state: OverallState, llm: ChatOpenAI) -> dict: - """Synthesise the parallel results into a final answer.""" - combined = "\n".join(state["results"]) - response = llm.invoke( - [ - SystemMessage(content="You are a helpful assistant that summarises research."), - HumanMessage( - content=f"Combine these notes into a coherent 3-sentence summary:\n{combined}" - ), - ] - ) - return {"final_answer": response.content} - - -# --- Build graph ---------------------------------------------------------- - -def build_graph(llm: ChatOpenAI): - # Bind the llm into worker/reducer via closures - def _worker(state: WorkerState) -> dict: - return worker(state, llm) - - def _reducer(state: OverallState) -> dict: - return reducer(state, llm) - - builder = StateGraph(OverallState) - builder.add_node("splitter", splitter) - builder.add_node("worker", _worker) - builder.add_node("reducer", _reducer) - - builder.add_edge(START, "splitter") - builder.add_conditional_edges("splitter", fan_out, ["worker"]) - builder.add_edge("worker", "reducer") - builder.add_edge("reducer", END) - - return builder.compile() - - -def main(): - LangChainInstrumentor().instrument() - - llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.2, seed=100) - graph = build_graph(llm) - - result = graph.invoke({"topic": "Artificial Intelligence", "subtopics": [], "results": [], "final_answer": ""}) - - print("Parallel map-reduce agent output:") - print("\nIndividual results:") - for r in result["results"]: - print(f" {r}") - print(f"\nFinal answer:\n {result['final_answer']}") - - LangChainInstrumentor().uninstrument() - - -if __name__ == "__main__": - main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py deleted file mode 100644 index cd42dbf0..00000000 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/plan_and_execute_agent.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Plan-and-execute agent built with StateGraph. - -Pattern: - 1. *Planner* node: the LLM receives the user goal and produces an ordered - list of steps (the plan). - 2. *Executor* node: executes the current step using available tools and - appends the result. - 3. *Replanner* node: decides whether to continue with the next step, revise - the plan, or finish. - -State machine: - START -> planner -> executor -> replanner -> {executor | END} - -OpenTelemetry LangChain instrumentation traces all LLM calls. -""" - -import json -from typing import Annotated - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage -from langchain_core.tools import tool -from langchain_openai import ChatOpenAI -from langgraph.graph import END, START, StateGraph -from langgraph.prebuilt import ToolNode -from typing_extensions import TypedDict - -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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Configure tracing -trace.set_tracer_provider(TracerProvider()) -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - -# Configure logging -_logs.set_logger_provider(LoggerProvider()) -_logs.get_logger_provider().add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) -) - -# Configure metrics -metrics.set_meter_provider( - MeterProvider( - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] - ) -) - - -# --- Tools ---------------------------------------------------------------- - -@tool -def search_web(query: str) -> str: - """Search the web and return a mock result.""" - mock_results = { - "population of france": "France has a population of approximately 68 million people.", - "capital of france": "The capital of France is Paris.", - "area of france": "France covers an area of approximately 551,695 km².", - } - for key, value in mock_results.items(): - if key in query.lower(): - return value - return f"Search result for '{query}': No relevant data found." - - -@tool -def calculate(expression: str) -> str: - """Evaluate a simple arithmetic expression safely.""" - try: - allowed = set("0123456789+-*/(). ") - if not all(c in allowed for c in expression): - return "Error: invalid characters in expression." - return str(eval(expression)) # noqa: S307 — guarded above - except Exception as exc: - return f"Error: {exc}" - - -# --- State ---------------------------------------------------------------- - -class PlanExecuteState(TypedDict): - goal: str - plan: list[str] - current_step_index: int - step_results: list[str] - final_answer: str - - -# --- Nodes ---------------------------------------------------------------- - -PLANNER_SYSTEM = """You are a planner. Given a goal, produce a JSON list of -concrete, ordered steps to achieve it. Use only simple research or calculation -steps. Respond with JSON only: {"steps": ["step1", "step2", ...]}""" - -EXECUTOR_SYSTEM = """You are an executor with access to search_web and calculate tools. -Execute the given step and return the result. Be concise.""" - -REPLANNER_SYSTEM = """You are a replanner. Given the original goal, the plan, -and the results so far, decide: -- If all steps are done and the goal is achieved, respond: {"action": "finish", "answer": ""} -- If the next step should proceed, respond: {"action": "continue"} -- If the plan needs revision, respond: {"action": "revise", "new_steps": ["..."]} -Respond with JSON only.""" - - -def build_graph(llm: ChatOpenAI): - tools = [search_web, calculate] - llm_with_tools = llm.bind_tools(tools) - tool_node = ToolNode(tools) - - # --- Planner ---------------------------------------------------------- - - def planner(state: PlanExecuteState) -> dict: - response = llm.invoke( - [ - SystemMessage(content=PLANNER_SYSTEM), - HumanMessage(content=f"Goal: {state['goal']}"), - ] - ) - try: - data = json.loads(response.content) - steps = data.get("steps", []) - except (json.JSONDecodeError, AttributeError): - steps = [state["goal"]] - return {"plan": steps, "current_step_index": 0, "step_results": []} - - # --- Executor --------------------------------------------------------- - - def executor(state: PlanExecuteState) -> dict: - idx = state["current_step_index"] - step = state["plan"][idx] - - # Ask the model to execute this step (may use a tool). - messages = [ - SystemMessage(content=EXECUTOR_SYSTEM), - HumanMessage(content=f"Step: {step}"), - ] - response = llm_with_tools.invoke(messages) - - # If the model called a tool, run it and collect the result. - result_text = response.content or "" - if response.tool_calls: - tool_messages = [response] - for tc in response.tool_calls: - tool_result = tool_node.invoke( - {"messages": tool_messages + [ - AIMessage(content="", tool_calls=[tc]) - ]} - ) - last_tool_msg = tool_result["messages"][-1] - result_text = last_tool_msg.content - - updated_results = list(state["step_results"]) + [f"Step {idx + 1}: {result_text}"] - return { - "step_results": updated_results, - "current_step_index": idx + 1, - } - - # --- Replanner -------------------------------------------------------- - - def replanner(state: PlanExecuteState) -> dict: - results_text = "\n".join(state["step_results"]) - plan_text = "\n".join( - f"{i + 1}. {s}" for i, s in enumerate(state["plan"]) - ) - response = llm.invoke( - [ - SystemMessage(content=REPLANNER_SYSTEM), - HumanMessage( - content=( - f"Goal: {state['goal']}\n" - f"Plan:\n{plan_text}\n" - f"Results so far:\n{results_text}\n" - f"Steps completed: {state['current_step_index']} / {len(state['plan'])}" - ) - ), - ] - ) - try: - data = json.loads(response.content) - except (json.JSONDecodeError, AttributeError): - data = {"action": "finish", "answer": results_text} - - if data.get("action") == "finish": - return {"final_answer": data.get("answer", results_text)} - if data.get("action") == "revise": - new_steps = data.get("new_steps", []) - return {"plan": new_steps, "current_step_index": 0} - return {} # continue - - def should_continue(state: PlanExecuteState) -> str: - if state.get("final_answer"): - return END - if state["current_step_index"] >= len(state["plan"]): - return "replanner" - return "executor" - - # --- Assemble --------------------------------------------------------- - - builder = StateGraph(PlanExecuteState) - builder.add_node("planner", planner) - builder.add_node("executor", executor) - builder.add_node("replanner", replanner) - - builder.add_edge(START, "planner") - builder.add_edge("planner", "executor") - builder.add_conditional_edges( - "executor", - should_continue, - {"executor": "executor", "replanner": "replanner", END: END}, - ) - builder.add_conditional_edges( - "replanner", - should_continue, - {"executor": "executor", END: END}, - ) - - return builder.compile() - - -def main(): - LangChainInstrumentor().instrument() - - llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) - graph = build_graph(llm) - - result = graph.invoke( - { - "goal": "Find the population and area of France, then calculate the population density.", - "plan": [], - "current_step_index": 0, - "step_results": [], - "final_answer": "", - } - ) - - print("Plan-and-execute agent output:") - print("\nSteps executed:") - for r in result["step_results"]: - print(f" {r}") - print(f"\nFinal answer:\n {result['final_answer']}") - - LangChainInstrumentor().uninstrument() - - -if __name__ == "__main__": - main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py deleted file mode 100644 index 7bedae64..00000000 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/supervisor_agent.py +++ /dev/null @@ -1,142 +0,0 @@ -""" -Supervisor agent pattern built with StateGraph. - -A supervisor LLM node decides which worker agent to call next (or when to -finish). Workers report back to the supervisor after each turn. - -Topology: - START -> supervisor -> {researcher | writer | END} - researcher -> supervisor - writer -> supervisor - -OpenTelemetry LangChain instrumentation traces all LLM calls. -""" - -import json -from typing import Literal - -from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage -from langchain_core.tools import tool -from langchain_openai import ChatOpenAI -from langgraph.graph import END, START, MessagesState, StateGraph - -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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Configure tracing -trace.set_tracer_provider(TracerProvider()) -trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) - -# Configure logging -_logs.set_logger_provider(LoggerProvider()) -_logs.get_logger_provider().add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) -) - -# Configure metrics -metrics.set_meter_provider( - MeterProvider( - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] - ) -) - -# Workers available to the supervisor. -WORKERS = ["researcher", "writer"] - -SUPERVISOR_SYSTEM = """You are a supervisor managing these workers: {workers}. -Given the conversation, decide who should act next, or reply FINISH if done. -Reply with JSON only: {{"next": "|FINISH"}}""" - - -def build_supervisor_graph(llm: ChatOpenAI): - - # --- Supervisor node -------------------------------------------------- - - supervisor_llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0, seed=42) - - def supervisor(state: MessagesState) -> dict: - prompt = SUPERVISOR_SYSTEM.format(workers=", ".join(WORKERS)) - messages = [SystemMessage(content=prompt)] + state["messages"] - response = supervisor_llm.invoke(messages) - return {"messages": [response]} - - def supervisor_route( - state: MessagesState, - ) -> Literal["researcher", "writer", "__end__"]: - last = state["messages"][-1] - try: - data = json.loads(last.content) - nxt = data.get("next", "FINISH") - except (json.JSONDecodeError, AttributeError): - nxt = "FINISH" - return "__end__" if nxt == "FINISH" else nxt - - # --- Worker nodes ----------------------------------------------------- - - def researcher(state: MessagesState) -> dict: - """Mock researcher: summarises existing knowledge.""" - response = llm.invoke( - state["messages"] - + [SystemMessage(content="You are a researcher. Provide key facts concisely.")] - ) - return {"messages": [AIMessage(content=f"[Researcher] {response.content}")]} - - def writer(state: MessagesState) -> dict: - """Mock writer: drafts a short answer from the gathered facts.""" - response = llm.invoke( - state["messages"] - + [SystemMessage(content="You are a writer. Compose a clear, concise answer.")] - ) - return {"messages": [AIMessage(content=f"[Writer] {response.content}")]} - - # --- Build graph ------------------------------------------------------ - - builder = StateGraph(MessagesState) - builder.add_node("supervisor", supervisor) - builder.add_node("researcher", researcher) - builder.add_node("writer", writer) - - builder.add_edge(START, "supervisor") - builder.add_conditional_edges( - "supervisor", - supervisor_route, - {"researcher": "researcher", "writer": "writer", "__end__": END}, - ) - builder.add_edge("researcher", "supervisor") - builder.add_edge("writer", "supervisor") - - return builder.compile() - - -def main(): - LangChainInstrumentor().instrument() - - llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.1, seed=100) - graph = build_supervisor_graph(llm) - - result = graph.invoke( - { - "messages": [ - HumanMessage(content="Explain the water cycle in 2–3 sentences.") - ] - } - ) - - print("Supervisor agent output (full conversation):") - for msg in result["messages"]: - print(f" {type(msg).__name__}: {msg.content}") - - LangChainInstrumentor().uninstrument() - - -if __name__ == "__main__": - main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py deleted file mode 100644 index c41baf1b..00000000 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/main_without_nodes.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright The OpenTelemetry Authors -# SPDX-License-Identifier: Apache-2.0 - -""" -LangChain chain-only example — no graph, no nodes. - -Pipeline: - - question → researcher_chain → summariser_chain → final answer - -Steps: - 1. *researcher_chain* – gathers factual background on the user's question. - 2. *summariser_chain* – condenses the researcher's output into a concise answer. - -Both chains are composed with the pipe operator (|) into a single sequential -chain. OpenTelemetry LangChain instrumentation traces both LLM calls. -""" - -from langchain_core.output_parsers import StrOutputParser -from langchain_core.prompts import ChatPromptTemplate -from langchain_core.runnables import RunnableLambda -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.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor - -# Configure tracing -trace.set_tracer_provider(TracerProvider()) -trace.get_tracer_provider().add_span_processor( - BatchSpanProcessor(OTLPSpanExporter()) -) - -# Configure logging -_logs.set_logger_provider(LoggerProvider()) -_logs.get_logger_provider().add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter()) -) - -# Configure metrics -metrics.set_meter_provider( - MeterProvider( - metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())] - ) -) - - -def build_chain(llm: ChatOpenAI): - """Build a sequential LCEL chain: researcher | summariser.""" - - researcher_prompt = ChatPromptTemplate.from_messages( - [ - ( - "system", - "You are a research assistant. Provide 2-3 factual sentences.", - ), - ("human", "{question}"), - ] - ) - - summariser_prompt = ChatPromptTemplate.from_messages( - [ - ( - "system", - "You are an expert summariser. Condense the text below into one clear sentence.", - ), - ("human", "{research}"), - ] - ) - - researcher_chain = researcher_prompt | llm | StrOutputParser() - - # Bridge: wrap the researcher output so the summariser receives {"research": ...} - def to_summariser_input(research: str) -> dict: - return {"research": research} - - summariser_chain = summariser_prompt | llm | StrOutputParser() - - pipeline = researcher_chain | RunnableLambda(to_summariser_input) | summariser_chain - - return pipeline - - -def main(): - LangChainInstrumentor().instrument() - - llm = ChatOpenAI( - model="gpt-3.5-turbo", - temperature=0.1, - max_tokens=200, - seed=42, - ) - - chain = build_chain(llm) - - question = "What is the capital of France?" - print(f"Question: {question}\n") - - answer = chain.invoke({"question": question}) - - print("Final summary:") - print(f" {answer}") - - LangChainInstrumentor().uninstrument() - - -if __name__ == "__main__": - main() diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py index 6a9106ac..79f05edd 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py @@ -224,4 +224,4 @@ def scrub_response_headers(response): """ response["headers"]["openai-organization"] = "test_openai_org_id" response["headers"]["Set-Cookie"] = "test_set_cookie" - return response + return response \ No newline at end of file From 47444371ab327772414ee9afc429490094b83837 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Sun, 17 May 2026 17:41:34 -0700 Subject: [PATCH 03/12] fixed errors --- .../examples/agent/single_node_agent.py | 4 +- .../langchain/callback_handler.py | 2 +- .../langchain/invocation_manager.py | 2 +- .../langchain/operation_mapping.py | 1 - .../instrumentation/langchain/utils.py | 9 +- .../tests/conftest.py | 76 ---- .../tests/test_operation_mapping.py | 354 ++++++++++++++++++ uv.lock | 4 +- 8 files changed, 367 insertions(+), 85 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py index 72a9b5dc..92d928c4 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/single_node_agent.py @@ -10,11 +10,11 @@ from uuid import uuid4 -from langchain.agents import create_agent from langchain_core.messages import HumanMessage from langchain_core.tools import tool from langchain_openai import ChatOpenAI from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph.prebuilt import create_react_agent from opentelemetry import _logs, metrics, trace from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( @@ -75,7 +75,7 @@ def add(a: float, b: float) -> float: def build_single_node_graph(llm: ChatOpenAI): session_id = str(uuid4()) - agent = create_agent( + agent = create_react_agent( llm, tools=[multiply, add], name="math_agent" ).with_config( { diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py index caf018c5..e181739c 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py @@ -413,4 +413,4 @@ def _find_nearest_agent( if isinstance(entity, AgentInvocation): return entity current = self._invocation_manager.get_parent_run_id(current) - return None \ No newline at end of file + return None diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py index 29caca85..9a340c0f 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/invocation_manager.py @@ -71,4 +71,4 @@ def delete_invocation_state(self, run_id: UUID) -> None: invocation_state.parent_run_id ) if parent_state is not None and parent_state.ended: - self.delete_invocation_state(invocation_state.parent_run_id) \ No newline at end of file + self.delete_invocation_state(invocation_state.parent_run_id) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py index ce169b56..45f0a879 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py @@ -107,7 +107,6 @@ def _has_agent_signals(metadata: Optional[dict[str, Any]]) -> bool: metadata.get(_META_AGENT_SPAN) or metadata.get(_META_AGENT_NAME) or metadata.get(_META_AGENT_TYPE) - or metadata.get("langgraph_node") ) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py index b5de9323..1636a1ec 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py @@ -45,10 +45,13 @@ def make_input_message(data: Any) -> list[InputMessage]: return input_messages -def make_output_message(data: dict[str, Any]) -> list[OutputMessage]: +def make_output_message(data: Any) -> list[OutputMessage]: """Create structured output message with full data as JSON.""" + if not isinstance(data, dict): + return [] + data_dict = cast(dict[str, Any], data) output_messages: list[OutputMessage] = [] - messages: Any = data.get("messages") + messages: list[Any] | None = data_dict.get("messages") if messages is None: return [] for msg in messages: @@ -63,7 +66,7 @@ def make_output_message(data: dict[str, Any]) -> list[OutputMessage]: return output_messages -def make_last_output_message(data: dict[str, Any]) -> list[OutputMessage]: +def make_last_output_message(data: Any) -> list[OutputMessage]: """Extract only the last AI message as the output. For Workflow and AgentInvocation spans, the final AI message best represents diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py index a20e4abd..ebb3c5f4 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py @@ -105,79 +105,3 @@ def vcr_config(): } ), } - -class LiteralBlockScalar(str): - """Formats the string as a literal block scalar, preserving whitespace and - without interpreting escape characters""" - - -def literal_block_scalar_presenter(dumper, data): - """Represents a scalar string as a literal block, via '|' syntax""" - return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") - - -yaml.add_representer(LiteralBlockScalar, literal_block_scalar_presenter) - - -def process_string_value(string_value): - """Pretty-prints JSON or returns long strings as a LiteralBlockScalar""" - try: - json_data = json.loads(string_value) - return LiteralBlockScalar(json.dumps(json_data, indent=2)) - except (ValueError, TypeError): - if len(string_value) > 80: - return LiteralBlockScalar(string_value) - return string_value - - -def convert_body_to_literal(data): - """Searches the data for body strings, attempting to pretty-print JSON""" - if isinstance(data, dict): - for key, value in data.items(): - # Handle response body case (e.g., response.body.string) - if key == "body" and isinstance(value, dict) and "string" in value: - value["string"] = process_string_value(value["string"]) - - # Handle request body case (e.g., request.body) - elif key == "body" and isinstance(value, str): - data[key] = process_string_value(value) - - else: - convert_body_to_literal(value) - - elif isinstance(data, list): - for idx, choice in enumerate(data): - data[idx] = convert_body_to_literal(choice) - - return data - - -class PrettyPrintJSONBody: - """This makes request and response body recordings more readable.""" - - @staticmethod - def serialize(cassette_dict): - cassette_dict = convert_body_to_literal(cassette_dict) - return yaml.dump( - cassette_dict, default_flow_style=False, allow_unicode=True - ) - - @staticmethod - def deserialize(cassette_string): - return yaml.load(cassette_string, Loader=yaml.Loader) - - -@pytest.fixture(scope="function", autouse=True) -def fixture_vcr(vcr): - if vcr is not None: - vcr.register_serializer("yaml", PrettyPrintJSONBody) - return vcr - - -def scrub_response_headers(response): - """ - This scrubs sensitive response headers. Note they are case-sensitive! - """ - response["headers"]["openai-organization"] = "test_openai_org_id" - response["headers"]["Set-Cookie"] = "test_set_cookie" - return response \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py new file mode 100644 index 00000000..9c96a916 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py @@ -0,0 +1,354 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for operation_mapping module. + +Tests the public API: classify_chain_run, resolve_agent_name, should_ignore_chain. +""" + +import uuid + +from opentelemetry.instrumentation.langchain.operation_mapping import ( + OperationName, + classify_chain_run, + resolve_agent_name, + should_ignore_chain, +) + +# --------------------------------------------------------------------------- +# resolve_agent_name +# --------------------------------------------------------------------------- + + +class TestResolveAgentName: + def test_metadata_agent_name_takes_highest_priority(self): + result = resolve_agent_name( + serialized={"name": "serialized_name"}, + metadata={"agent_name": "meta_name"}, + kwargs={"name": "kwargs_name"}, + ) + assert result == "meta_name" + + def test_kwargs_name_used_when_no_metadata_agent_name(self): + result = resolve_agent_name( + serialized={"name": "serialized_name"}, + metadata={}, + kwargs={"name": "kwargs_name"}, + ) + assert result == "kwargs_name" + + def test_serialized_name_used_as_fallback(self): + result = resolve_agent_name( + serialized={"name": "serialized_name"}, + metadata={}, + kwargs={}, + ) + assert result == "serialized_name" + + def test_langgraph_node_used_as_last_resort(self): + result = resolve_agent_name( + serialized={}, + metadata={"langgraph_node": "my_node"}, + kwargs={}, + ) + assert result == "my_node" + + def test_langgraph_start_node_not_returned(self): + result = resolve_agent_name( + serialized={}, + metadata={"langgraph_node": "__start__"}, + kwargs={}, + ) + assert result is None + + def test_returns_none_when_nothing_available(self): + result = resolve_agent_name( + serialized={}, + metadata=None, + kwargs={}, + ) + assert result is None + + def test_none_metadata_falls_through_to_serialized(self): + result = resolve_agent_name( + serialized={"name": "from_serialized"}, + metadata=None, + kwargs={}, + ) + assert result == "from_serialized" + + def test_result_is_always_str(self): + # metadata value that is not already a string + result = resolve_agent_name( + serialized={}, + metadata={"agent_name": 42}, + kwargs={}, + ) + assert result == "42" + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# should_ignore_chain +# --------------------------------------------------------------------------- + + +class TestShouldIgnoreChain: + def test_langgraph_start_node_is_suppressed(self): + assert ( + should_ignore_chain( + metadata={"langgraph_node": "__start__"}, + agent_name=None, + kwargs={}, + ) + is True + ) + + def test_otel_trace_false_is_suppressed(self): + assert ( + should_ignore_chain( + metadata={"otel_trace": False}, + agent_name=None, + kwargs={}, + ) + is True + ) + + def test_otel_agent_span_false_without_other_signals_is_suppressed(self): + assert ( + should_ignore_chain( + metadata={"otel_agent_span": False}, + agent_name=None, + kwargs={}, + ) + is True + ) + + def test_otel_agent_span_false_with_agent_name_is_not_suppressed(self): + # agent_name present overrides the False flag + assert ( + should_ignore_chain( + metadata={"otel_agent_span": False, "agent_name": "my_agent"}, + agent_name="my_agent", + kwargs={}, + ) + is False + ) + + def test_otel_agent_span_false_with_agent_type_is_not_suppressed(self): + assert ( + should_ignore_chain( + metadata={"otel_agent_span": False, "agent_type": "react"}, + agent_name=None, + kwargs={}, + ) + is False + ) + + def test_middleware_prefix_in_agent_name_is_suppressed(self): + assert ( + should_ignore_chain( + metadata=None, + agent_name="Middleware.SomeThing", + kwargs={}, + ) + is True + ) + + def test_middleware_prefix_in_kwargs_name_is_suppressed(self): + assert ( + should_ignore_chain( + metadata=None, + agent_name=None, + kwargs={"name": "Middleware.Router"}, + ) + is True + ) + + def test_normal_chain_is_not_suppressed(self): + assert ( + should_ignore_chain( + metadata=None, + agent_name="my_agent", + kwargs={}, + ) + is False + ) + + def test_none_metadata_is_not_suppressed(self): + assert ( + should_ignore_chain( + metadata=None, + agent_name=None, + kwargs={}, + ) + is False + ) + + +# --------------------------------------------------------------------------- +# classify_chain_run +# --------------------------------------------------------------------------- + + +class TestClassifyChainRun: + # --- invoke_workflow --- + + def test_langgraph_name_at_root_is_workflow(self): + result = classify_chain_run( + serialized={"name": "LangGraph"}, + metadata=None, + kwargs={}, + parent_run_id=None, + ) + assert result == OperationName.INVOKE_WORKFLOW + + def test_langgraph_in_graph_id_at_root_is_workflow(self): + result = classify_chain_run( + serialized={"name": "MyGraph", "graph": {"id": "LangGraph-abc"}}, + metadata=None, + kwargs={}, + parent_run_id=None, + ) + assert result == OperationName.INVOKE_WORKFLOW + + def test_explicit_workflow_override_at_root(self): + result = classify_chain_run( + serialized={"name": "SomeName"}, + metadata={"otel_workflow_span": True}, + kwargs={}, + parent_run_id=None, + ) + assert result == OperationName.INVOKE_WORKFLOW + + def test_root_chain_with_no_signals_is_workflow(self): + # A root chain (no parent) with no special names defaults to workflow. + result = classify_chain_run( + serialized={}, + metadata=None, + kwargs={}, + parent_run_id=None, + ) + assert result == OperationName.INVOKE_WORKFLOW + + def test_langgraph_name_with_parent_is_not_workflow(self): + # Having a parent disqualifies it from being a top-level workflow. + result = classify_chain_run( + serialized={"name": "LangGraph"}, + metadata=None, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + # Not a workflow; no agent signals → suppressed + assert result is None + + # --- invoke_agent --- + + def test_agent_name_metadata_is_agent(self): + result = classify_chain_run( + serialized={}, + metadata={"agent_name": "my_agent"}, + kwargs={}, + parent_run_id=None, + ) + assert result == OperationName.INVOKE_AGENT + + def test_agent_type_metadata_is_agent(self): + result = classify_chain_run( + serialized={}, + metadata={"agent_type": "react"}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result == OperationName.INVOKE_AGENT + + def test_otel_agent_span_true_is_agent(self): + result = classify_chain_run( + serialized={}, + metadata={"otel_agent_span": True}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result == OperationName.INVOKE_AGENT + + def test_langgraph_node_metadata_with_parent_is_suppressed(self): + # langgraph_node alone is no longer an agent signal in _has_agent_signals; + # it is only used by resolve_agent_name for name resolution. + # A child chain with only langgraph_node metadata and no other agent + # signals (otel_agent_span, agent_name, agent_type) is suppressed. + result = classify_chain_run( + serialized={}, + metadata={"langgraph_node": "my_node"}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result is None + + def test_langgraph_node_with_agent_name_is_agent(self): + # langgraph_node combined with agent_name still produces INVOKE_AGENT + # because agent_name triggers _has_agent_signals. + result = classify_chain_run( + serialized={}, + metadata={"langgraph_node": "my_node", "agent_name": "my_agent"}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result == OperationName.INVOKE_AGENT + + # Agent signals take priority over workflow signals. + def test_agent_signals_beat_workflow_signals(self): + result = classify_chain_run( + serialized={"name": "LangGraph"}, + metadata={"agent_name": "my_agent"}, + kwargs={}, + parent_run_id=None, + ) + assert result == OperationName.INVOKE_AGENT + + # --- suppressed --- + + def test_start_node_is_suppressed(self): + result = classify_chain_run( + serialized={}, + metadata={"langgraph_node": "__start__"}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result is None + + def test_otel_trace_false_is_suppressed(self): + result = classify_chain_run( + serialized={"name": "LangGraph"}, + metadata={"otel_trace": False}, + kwargs={}, + parent_run_id=None, + ) + assert result is None + + def test_middleware_name_is_suppressed(self): + result = classify_chain_run( + serialized={"name": "Middleware.Router"}, + metadata=None, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result is None + + def test_otel_agent_span_false_with_no_other_signals_suppressed(self): + result = classify_chain_run( + serialized={}, + metadata={"otel_agent_span": False}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result is None + + def test_non_langgraph_child_chain_suppressed(self): + # Child chain with no agent or workflow signals → suppressed. + result = classify_chain_run( + serialized={"name": "SomeInternalChain"}, + metadata=None, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result is None diff --git a/uv.lock b/uv.lock index bc79288e..b6cfd430 100644 --- a/uv.lock +++ b/uv.lock @@ -1122,6 +1122,7 @@ name = "opentelemetry-instrumentation-langchain" source = { editable = "instrumentation/opentelemetry-instrumentation-langchain" } dependencies = [ { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-util-genai" }, ] [package.optional-dependencies] @@ -1132,7 +1133,8 @@ instruments = [ [package.metadata] requires-dist = [ { name = "langchain", marker = "extra == 'instruments'", specifier = ">=0.3.21" }, - { name = "opentelemetry-instrumentation", specifier = "~=0.57b0" }, + { name = "opentelemetry-instrumentation", specifier = "~=0.60b0" }, + { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, ] provides-extras = ["instruments"] From c4fd6f9775e5415b661165b0f596a8af10bb0e3f Mon Sep 17 00:00:00 2001 From: Wrisa Date: Mon, 18 May 2026 11:28:43 -0700 Subject: [PATCH 04/12] updated changelog --- .../.changelog/.gitignore | 1 + .../opentelemetry-instrumentation-langchain/.changelog/25.added | 1 + .../opentelemetry-instrumentation-langchain/CHANGELOG.md | 2 -- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/.changelog/.gitignore create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/.changelog/25.added diff --git a/instrumentation/opentelemetry-instrumentation-langchain/.changelog/.gitignore b/instrumentation/opentelemetry-instrumentation-langchain/.changelog/.gitignore new file mode 100644 index 00000000..f935021a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/.changelog/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/instrumentation/opentelemetry-instrumentation-langchain/.changelog/25.added b/instrumentation/opentelemetry-instrumentation-langchain/.changelog/25.added new file mode 100644 index 00000000..db02fe17 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/.changelog/25.added @@ -0,0 +1 @@ +Add LangChain workflow and agent span support diff --git a/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md b/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md index 3e6b69a0..767dfcc7 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md +++ b/instrumentation/opentelemetry-instrumentation-langchain/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -- Add LangChain workflow and agent span support. Also refactor LLM invocation. - ([#25](https://github.com/open-telemetry/opentelemetry-python-genai/pull/25)) - Fix compatibility with wrapt 2.x by using positional arguments in `wrap_function_wrapper()` calls ([#4445](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4445)) - Added span support for genAI langchain llm invocation. From e8e2826b760da9cbbc993020ac8819fdee992f88 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Tue, 19 May 2026 12:59:06 -0700 Subject: [PATCH 05/12] Addressed lzchen comments --- .../examples/workflow/requirements.txt | 4 ++-- .../pyproject.toml | 6 +++--- .../instrumentation/langchain/operation_mapping.py | 10 +++++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt index 656212ca..c0434855 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt @@ -1,5 +1,5 @@ langchain==0.3.21 langchain_openai langgraph -opentelemetry-sdk>=1.39.0 -opentelemetry-exporter-otlp-proto-grpc>=1.39.0 +opentelemetry-sdk>=1.42.0 +opentelemetry-exporter-otlp-proto-grpc>=1.42.0 diff --git a/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml b/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml index 7d4a1340..ca35b80d 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-instrumentation ~= 0.63b0", "opentelemetry-util-genai >= 0.4b0", ] @@ -38,8 +38,8 @@ instruments = [ langchain = "opentelemetry.instrumentation.langchain:LangChainInstrumentor" [project.urls] -Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai/opentelemetry-instrumentation-langchain" -Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" +Homepage = "https://github.com/open-telemetry/opentelemetry-python-genai/tree/main/instrumentation/opentelemetry-instrumentation-langchain" +Repository = "https://github.com/open-telemetry/opentelemetry-python-genai" [tool.hatch.version] path = "src/opentelemetry/instrumentation/langchain/version.py" diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py index 45f0a879..8d3e0af5 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py @@ -37,9 +37,7 @@ class OperationName: """Canonical GenAI semantic convention operation names.""" INVOKE_AGENT: str = GenAI.GenAiOperationNameValues.INVOKE_AGENT.value - # invoke_workflow is not yet in the semconv enum; use the expected - # string value so the mapping is forward-compatible. - INVOKE_WORKFLOW: str = "invoke_workflow" + INVOKE_WORKFLOW: str = GenAI.GenAiOperationNameValues.INVOKE_WORKFLOW.value # --------------------------------------------------------------------------- @@ -133,6 +131,12 @@ def _looks_like_workflow( ) return LANGGRAPH_IDENTIFIER in name or LANGGRAPH_IDENTIFIER in graph_id + # No serialized data to inspect, but this is a top-level chain + # (parent_run_id is None). When we have zero information about a root-level + # chain we prefer to emit a span rather than silently drop it — more data + # is better than missing the outermost invocation entirely. Treat it as a + # workflow so the outermost operation always gets a span even when the + # chain didn't populate its serialized representation. return True From 1ba5ef2da10f6a6394a4b4f3688c7b599f286da5 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Tue, 19 May 2026 13:01:40 -0700 Subject: [PATCH 06/12] updated uv --- uv.lock | 55 +++++++++++++++++-------------------------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/uv.lock b/uv.lock index 3a6590ef..ae80c313 100644 --- a/uv.lock +++ b/uv.lock @@ -641,18 +641,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, ] -[[package]] -name = "importlib-metadata" -version = "8.7.1" -source = { registry = "https://pypi.org/simple/" } -dependencies = [ - { name = "zipp" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -1118,20 +1106,19 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.41.1" +version = "1.42.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ - { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/ca/25288069c399be6769159d9fb7b1190b603537d82aad2fa2746a0cc2c8c6/opentelemetry_api-1.42.0.tar.gz", hash = "sha256:ea84c893ad177791d138e0349d6ceebd8d3bf006440900400ce220008dafc372", size = 72300, upload-time = "2026-05-19T09:46:29.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0b/be5daf659b82b525338fde371dfcfab09b606a19bb5620c37076964710ec/opentelemetry_api-1.42.0-py3-none-any.whl", hash = "sha256:558d88f88192a973579910ef6f2c13db47a268d5ec2e53e83e50e74a39a02922", size = 61310, upload-time = "2026-05-19T09:46:06.561Z" }, ] [[package]] name = "opentelemetry-instrumentation" -version = "0.62b1" +version = "0.63b0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "opentelemetry-api" }, @@ -1139,9 +1126,9 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/2d/322d464f4105966fb8555f871a84f43e821ce9aaf64ecae9586e9691c6a2/opentelemetry_instrumentation-0.63b0.tar.gz", hash = "sha256:80a339ef030a8d0fd1962375a9801dd31954e5063d74c00bc3d4e6581f43bab1", size = 41083, upload-time = "2026-05-19T09:47:06.194Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, + { url = "https://files.pythonhosted.org/packages/ae/45/a38e74da3f1b5c82c97289da91d978caa04321877f0ab170fc620a0753f2/opentelemetry_instrumentation-0.63b0-py3-none-any.whl", hash = "sha256:984b18763b652a881ac5a596098d89923f74cf53a658c2dde660387e018147ca", size = 35574, upload-time = "2026-05-19T09:46:07.257Z" }, ] [[package]] @@ -1235,7 +1222,7 @@ instruments = [ [package.metadata] requires-dist = [ { name = "langchain", marker = "extra == 'instruments'", specifier = ">=0.3.21" }, - { name = "opentelemetry-instrumentation", specifier = "~=0.60b0" }, + { name = "opentelemetry-instrumentation", specifier = "~=0.63b0" }, { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, ] provides-extras = ["instruments"] @@ -1370,29 +1357,29 @@ dev = [ [[package]] name = "opentelemetry-sdk" -version = "1.41.1" +version = "1.42.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/c9/dabaaf1c754a57b82b5a36aeca3806d92c1877ccfb12a697b65f88bf027c/opentelemetry_sdk-1.42.0.tar.gz", hash = "sha256:2479e462cc69357825c2c847ce4a601bc1b17e1279aa7f80d3490f0ae614d0e5", size = 239072, upload-time = "2026-05-19T09:46:42.992Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7d/16bf9a9d42ebbd1679e0cda018d57a0712f3b6f6f1e7ae5ef3c7ee5927c0/opentelemetry_sdk-1.42.0-py3-none-any.whl", hash = "sha256:ec4a4f69e15220b3d7bccd93217aac745682bb6435b9381f7bb44cb7e07b4f2b", size = 170879, upload-time = "2026-05-19T09:46:25.871Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.62b1" +version = "0.63b0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/f8/be4625838aae098c2f9fbdc062a1b3128ebb9e799b891b654ee8cad94897/opentelemetry_semantic_conventions-0.63b0.tar.gz", hash = "sha256:cfea295264654fa324fcef24aa56fb1836fdc0da27db128645dc6aa76115cc6c", size = 148333, upload-time = "2026-05-19T09:46:44.01Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6f/8d0ce225b8fdbb72c97cf4130107d861eafcb3d8e5c3f5891e8556177316/opentelemetry_semantic_conventions-0.63b0-py3-none-any.whl", hash = "sha256:1f3962732b04f43e4fef28173c9a3615b8847b4b2d6386fdc085361b29875ab9", size = 203712, upload-time = "2026-05-19T09:46:27.569Z" }, ] [[package]] @@ -1424,16 +1411,17 @@ requires-dist = [ [[package]] name = "opentelemetry-test-utils" -version = "0.62b1" +version = "0.63b0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "asgiref" }, { name = "opentelemetry-api" }, { name = "opentelemetry-sdk" }, + { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/0d/555b86a209da4b6ed716320359a38c88ac2981f258ea44303ea84297e346/opentelemetry_test_utils-0.62b1.tar.gz", hash = "sha256:26dc0bcbb6ba953ee964a9c23f3d28f979af52c2bbf7cf49cf0c8548634c40cd", size = 8845, upload-time = "2026-04-24T13:15:53.412Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/fb/1ab9f9f84492bdc49c0fdfba8ba2f35e9e34925e13f74460a64217036171/opentelemetry_test_utils-0.63b0.tar.gz", hash = "sha256:357dcd88c3dc33a46cf0f87324d182be1a406b001a80143a49a854ab6a0df4f0", size = 13365, upload-time = "2026-05-19T09:46:44.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/65/2f365677d1ded6fabfe563ddb03d619b16dcb6b6a5d26035911c3a7ad420/opentelemetry_test_utils-0.62b1-py3-none-any.whl", hash = "sha256:060b4bf85e6175f99e340a78d99b80a51286786296f9e584c5f99de2a35bc7ff", size = 15406, upload-time = "2026-04-24T13:15:36.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/7f/91f962e388c915feeaf30db6e3a418f72d717e760608114ffb5f74b5f0fe/opentelemetry_test_utils-0.63b0-py3-none-any.whl", hash = "sha256:9f6e05daa87792d65ee2f342d15c13353e1d973a2a18aa1db3f97604ddda6e1c", size = 17844, upload-time = "2026-05-19T09:46:28.876Z" }, ] [[package]] @@ -2902,15 +2890,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" }, ] -[[package]] -name = "zipp" -version = "3.23.1" -source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, -] - [[package]] name = "zstandard" version = "0.25.0" From 4dea716c840f8da70e1b0a6470414480239eea37 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Tue, 19 May 2026 17:49:19 -0700 Subject: [PATCH 07/12] addressed --- .../examples/agent/requirements.txt | 4 +- .../pyproject.toml | 2 +- .../langchain/callback_handler.py | 8 +- .../langchain/operation_mapping.py | 5 +- .../tests/cassettes/agent_conformance.yaml | 769 ++++++++++++++++++ .../tests/cassettes/workflow_conformance.yaml | 386 +++++++++ .../tests/conformance/agent.py | 108 +++ .../tests/conformance/workflow.py | 130 +++ .../tests/test_callback_handler.py | 43 +- .../tests/test_conformance.py | 4 + .../tests/test_operation_mapping.py | 119 +-- policies/genai_span_validation.rego | 2 - uv.lock | 2 +- 13 files changed, 1447 insertions(+), 135 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/agent_conformance.yaml create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/workflow_conformance.yaml create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/agent.py create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/workflow.py diff --git a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt index 88f3320d..d97c704f 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt +++ b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt @@ -1,5 +1,5 @@ langchain==0.3.21 langchain_openai langgraph -opentelemetry-sdk>=1.39.0 -opentelemetry-exporter-otlp-proto-grpc>=1.39.0 \ No newline at end of file +opentelemetry-sdk>=1.42.0 +opentelemetry-exporter-otlp-proto-grpc>=1.42.0 \ No newline at end of file diff --git a/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml b/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml index ca35b80d..e5989743 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml +++ b/instrumentation/opentelemetry-instrumentation-langchain/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "opentelemetry-instrumentation ~= 0.63b0", + "opentelemetry-instrumentation >= 0.62b0", "opentelemetry-util-genai >= 0.4b0", ] diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py index e181739c..3c57973b 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py @@ -66,7 +66,7 @@ def on_chain_start( workflow_name_override = ( metadata.get("workflow_name") if metadata else None ) - workflow = self._telemetry_handler.start_workflow( + workflow = self._telemetry_handler.workflow( name=workflow_name_override or workflow_name ) workflow.input_messages = make_input_message(inputs) @@ -91,12 +91,12 @@ def on_chain_start( else None ) if suggested_agent_name_lower != agent_invocation_name_lower: - agent = self._telemetry_handler.start_invoke_local_agent( + agent = self._telemetry_handler.invoke_local_agent( provider=metadata.get("ls_provider", "unknown") if metadata else "unknown", + agent_name=suggested_agent_name, ) - agent.agent_name = suggested_agent_name agent.input_messages = make_input_message(inputs) if metadata: @@ -274,7 +274,7 @@ def on_chat_model_start( ) ) - llm_invocation = self._telemetry_handler.start_inference( + llm_invocation = self._telemetry_handler.inference( provider, request_model=request_model, ) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py index 8d3e0af5..bf72bb0f 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py @@ -24,7 +24,6 @@ "OperationName", "classify_chain_run", "resolve_agent_name", - "should_ignore_chain", ] # --------------------------------------------------------------------------- @@ -145,7 +144,7 @@ def _looks_like_workflow( # --------------------------------------------------------------------------- -def should_ignore_chain( +def _should_ignore_chain( metadata: Optional[dict[str, Any]], agent_name: Optional[str], kwargs: dict[str, Any], @@ -206,7 +205,7 @@ def classify_chain_run( agent_name = resolve_agent_name(serialized, metadata, kwargs) # 1. Suppress known noise. - if should_ignore_chain(metadata, agent_name, kwargs): + if _should_ignore_chain(metadata, agent_name, kwargs): return None # 2. Agent detection. diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/agent_conformance.yaml b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/agent_conformance.yaml new file mode 100644 index 00000000..464f9232 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/agent_conformance.yaml @@ -0,0 +1,769 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "content": "What is (3 * 4) + 7?", + "role": "user" + } + ], + "model": "gpt-3.5-turbo", + "max_completion_tokens": 100, + "seed": 100, + "stream": false, + "temperature": 0.1, + "tools": [ + { + "type": "function", + "function": { + "name": "multiply", + "description": "Multiply two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + } + ], + "top_p": 0.9 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.37.0 + X-Stainless-Arch: + - arm64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - MacOS + X-Stainless-Package-Version: + - 2.37.0 + X-Stainless-Raw-Response: + - 'true' + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.11 + authorization: + - Bearer test_openai_api_key + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-agent001toolcall", + "object": "chat.completion", + "created": 1771535200, + "model": "gpt-3.5-turbo-0125", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "refusal": null, + "tool_calls": [ + { + "id": "call_multiply001", + "type": "function", + "function": { + "name": "multiply", + "arguments": "{\"a\": 3, \"b\": 4}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 80, + "completion_tokens": 20, + "total_tokens": 100, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null + } + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 20 May 2026 00:07:10 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + content-length: + - '700' + openai-organization: test_openai_org_id + openai-processing-ms: + - '200' + openai-version: + - '2020-10-01' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '200000' + x-ratelimit-remaining-requests: + - '9999' + x-ratelimit-remaining-tokens: + - '199900' + x-ratelimit-reset-requests: + - 8.64s + x-ratelimit-reset-tokens: + - 5ms + x-request-id: + - req_agent001toolcall + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "content": "What is (3 * 4) + 7?", + "role": "user" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_multiply001", + "type": "function", + "function": { + "name": "multiply", + "arguments": "{\"a\": 3, \"b\": 4}" + } + } + ] + }, + { + "role": "tool", + "content": "12.0", + "tool_call_id": "call_multiply001" + } + ], + "model": "gpt-3.5-turbo", + "max_completion_tokens": 100, + "seed": 100, + "stream": false, + "temperature": 0.1, + "tools": [ + { + "type": "function", + "function": { + "name": "multiply", + "description": "Multiply two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + } + ], + "top_p": 0.9 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.37.0 + X-Stainless-Arch: + - arm64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - MacOS + X-Stainless-Package-Version: + - 2.37.0 + X-Stainless-Raw-Response: + - 'true' + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.11 + authorization: + - Bearer test_openai_api_key + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-agent001addcall", + "object": "chat.completion", + "created": 1771535201, + "model": "gpt-3.5-turbo-0125", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "refusal": null, + "tool_calls": [ + { + "id": "call_add001", + "type": "function", + "function": { + "name": "add", + "arguments": "{\"a\": 12.0, \"b\": 7}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 110, + "completion_tokens": 20, + "total_tokens": 130, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null + } + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 20 May 2026 00:07:11 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + content-length: + - '700' + openai-organization: test_openai_org_id + openai-processing-ms: + - '200' + openai-version: + - '2020-10-01' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '200000' + x-ratelimit-remaining-requests: + - '9998' + x-ratelimit-remaining-tokens: + - '199800' + x-ratelimit-reset-requests: + - 8.64s + x-ratelimit-reset-tokens: + - 5ms + x-request-id: + - req_agent001addcall + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "content": "What is (3 * 4) + 7?", + "role": "user" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_multiply001", + "type": "function", + "function": { + "name": "multiply", + "arguments": "{\"a\": 3, \"b\": 4}" + } + } + ] + }, + { + "role": "tool", + "content": "12.0", + "tool_call_id": "call_multiply001" + }, + { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_add001", + "type": "function", + "function": { + "name": "add", + "arguments": "{\"a\": 12.0, \"b\": 7}" + } + } + ] + }, + { + "role": "tool", + "content": "19.0", + "tool_call_id": "call_add001" + } + ], + "model": "gpt-3.5-turbo", + "max_completion_tokens": 100, + "seed": 100, + "stream": false, + "temperature": 0.1, + "tools": [ + { + "type": "function", + "function": { + "name": "multiply", + "description": "Multiply two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + } + ], + "top_p": 0.9 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.37.0 + X-Stainless-Arch: + - arm64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - MacOS + X-Stainless-Package-Version: + - 2.37.0 + X-Stainless-Raw-Response: + - 'true' + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.11 + authorization: + - Bearer test_openai_api_key + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-agent001final", + "object": "chat.completion", + "created": 1771535202, + "model": "gpt-3.5-turbo-0125", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The result of (3 * 4) + 7 is 19.", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 140, + "completion_tokens": 14, + "total_tokens": 154, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null + } + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 20 May 2026 00:07:12 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + content-length: + - '600' + openai-organization: test_openai_org_id + openai-processing-ms: + - '150' + openai-version: + - '2020-10-01' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '200000' + x-ratelimit-remaining-requests: + - '9997' + x-ratelimit-remaining-tokens: + - '199700' + x-ratelimit-reset-requests: + - 8.64s + x-ratelimit-reset-tokens: + - 5ms + x-request-id: + - req_agent001final + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "content": "What is (3 * 4) + 7?", + "role": "user" + } + ], + "model": "gpt-3.5-turbo", + "max_completion_tokens": 100, + "seed": 100, + "stream": false, + "temperature": 0.1, + "tools": [ + { + "type": "function", + "function": { + "name": "multiply", + "description": "Multiply two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "type": "object" + } + } + } + ], + "top_p": 0.9 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '589' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.37.0 + X-Stainless-Arch: + - arm64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - MacOS + X-Stainless-Package-Version: + - 2.37.0 + X-Stainless-Raw-Response: + - 'true' + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.11 + authorization: + - Bearer test_openai_api_key + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "error": { + "message": "Incorrect API key provided: test_ope*******_key. You can find your API key at https://platform.openai.com/account/api-keys.", + "type": "invalid_request_error", + "param": null, + "code": "invalid_api_key" + } + } + headers: + Access-Control-Expose-Headers: + - CF-Ray + - CF-Ray + CF-Cache-Status: + - DYNAMIC + CF-Ray: + - 9fe744ddbfdaefbe-PDX + Connection: + - keep-alive + Content-Length: + - '269' + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 20 May 2026 00:36:46 GMT + Server: + - cloudflare + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Vary: + - Origin + Via: + - HTTP/1.1 m_proxy_prod_aws_us-west-2_1_1n + X-Content-Type-Options: + - nosniff + alt-svc: + - clear + openai-organization: test_openai_org_id + set-cookie: test_set_cookie + x-openai-proxy-wasm: + - v0.1 + x-request-id: + - req_6939bc5c4a814ff18a6c231db4a2b339 + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/workflow_conformance.yaml b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/workflow_conformance.yaml new file mode 100644 index 00000000..47738b35 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/workflow_conformance.yaml @@ -0,0 +1,386 @@ +interactions: +- request: + body: |- + { + "messages": [ + { + "content": "You are a research assistant. Provide 2-3 factual sentences.", + "role": "system" + }, + { + "content": "What is the capital of France?", + "role": "user" + } + ], + "model": "gpt-3.5-turbo", + "max_completion_tokens": 200, + "seed": 42, + "stream": false, + "temperature": 0.1 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.37.0 + X-Stainless-Arch: + - arm64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - MacOS + X-Stainless-Package-Version: + - 2.37.0 + X-Stainless-Raw-Response: + - 'true' + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.11 + authorization: + - Bearer test_openai_api_key + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-workflow001researcher", + "object": "chat.completion", + "created": 1771535300, + "model": "gpt-3.5-turbo-0125", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The capital of France is Paris. Paris is one of the most populous cities in Europe and serves as the country's political, economic, and cultural center. The city has been the capital of France since the 10th century.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 35, + "completion_tokens": 47, + "total_tokens": 82, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null + } + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 20 May 2026 00:07:12 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + content-length: + - '750' + openai-organization: test_openai_org_id + openai-processing-ms: + - '180' + openai-version: + - '2020-10-01' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '200000' + x-ratelimit-remaining-requests: + - '9999' + x-ratelimit-remaining-tokens: + - '199918' + x-ratelimit-reset-requests: + - 8.64s + x-ratelimit-reset-tokens: + - 5ms + x-request-id: + - req_workflow001researcher + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "content": "You are an expert summariser. Condense the text below into one clear sentence.", + "role": "system" + }, + { + "content": "The capital of France is Paris. Paris is one of the most populous cities in Europe and serves as the country's political, economic, and cultural center. The city has been the capital of France since the 10th century.", + "role": "user" + } + ], + "model": "gpt-3.5-turbo", + "max_completion_tokens": 200, + "seed": 42, + "stream": false, + "temperature": 0.1 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.37.0 + X-Stainless-Arch: + - arm64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - MacOS + X-Stainless-Package-Version: + - 2.37.0 + X-Stainless-Raw-Response: + - 'true' + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.11 + authorization: + - Bearer test_openai_api_key + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "id": "chatcmpl-workflow001summariser", + "object": "chat.completion", + "created": 1771535301, + "model": "gpt-3.5-turbo-0125", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Paris, France's capital since the 10th century, is a major European city and the country's political, economic, and cultural hub.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 68, + "completion_tokens": 30, + "total_tokens": 98, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null + } + headers: + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 20 May 2026 00:07:13 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + access-control-expose-headers: + - X-Request-ID + alt-svc: + - h3=":443"; ma=86400 + cf-cache-status: + - DYNAMIC + content-length: + - '680' + openai-organization: test_openai_org_id + openai-processing-ms: + - '160' + openai-version: + - '2020-10-01' + x-openai-proxy-wasm: + - v0.1 + x-ratelimit-limit-requests: + - '10000' + x-ratelimit-limit-tokens: + - '200000' + x-ratelimit-remaining-requests: + - '9998' + x-ratelimit-remaining-tokens: + - '199820' + x-ratelimit-reset-requests: + - 8.64s + x-ratelimit-reset-tokens: + - 5ms + x-request-id: + - req_workflow001summariser + status: + code: 200 + message: OK +- request: + body: |- + { + "messages": [ + { + "content": "You are a research assistant. Provide 2-3 factual sentences.", + "role": "system" + }, + { + "content": "What is the capital of France?", + "role": "user" + } + ], + "model": "gpt-3.5-turbo", + "max_completion_tokens": 200, + "seed": 42, + "stream": false, + "temperature": 0.1 + } + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Length: + - '259' + Content-Type: + - application/json + Host: + - api.openai.com + User-Agent: + - OpenAI/Python 2.37.0 + X-Stainless-Arch: + - arm64 + X-Stainless-Async: + - 'false' + X-Stainless-Lang: + - python + X-Stainless-OS: + - MacOS + X-Stainless-Package-Version: + - 2.37.0 + X-Stainless-Raw-Response: + - 'true' + X-Stainless-Runtime: + - CPython + X-Stainless-Runtime-Version: + - 3.12.11 + authorization: + - Bearer test_openai_api_key + cookie: + - test_cookie + x-stainless-retry-count: + - '0' + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: |- + { + "error": { + "message": "Incorrect API key provided: test_ope*******_key. You can find your API key at https://platform.openai.com/account/api-keys.", + "type": "invalid_request_error", + "param": null, + "code": "invalid_api_key" + } + } + headers: + Access-Control-Expose-Headers: + - CF-Ray + - CF-Ray + CF-Cache-Status: + - DYNAMIC + CF-Ray: + - 9fe744ea6ac0a32d-PDX + Connection: + - keep-alive + Content-Length: + - '269' + Content-Type: + - application/json; charset=utf-8 + Date: + - Wed, 20 May 2026 00:36:48 GMT + Server: + - cloudflare + Set-Cookie: test_set_cookie + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Vary: + - Origin + Via: + - HTTP/1.1 m_proxy_prod_aws_us-west-2_1_1n + X-Content-Type-Options: + - nosniff + alt-svc: + - clear + openai-organization: test_openai_org_id + x-openai-proxy-wasm: + - v0.1 + x-request-id: + - req_d26d8ff9a16d4299acffccc168091030 + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/agent.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/agent.py new file mode 100644 index 00000000..57f2f39f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/agent.py @@ -0,0 +1,108 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: langchain ReAct agent via LangGraph create_react_agent.""" + +from __future__ import annotations + +import os +from typing import Any +from unittest import mock + +from langchain_core.messages import HumanMessage +from langchain_core.tools import tool +from langchain_openai import ChatOpenAI +from langgraph.prebuilt import create_react_agent + +from opentelemetry.instrumentation.langchain import LangChainInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test.weaver_live_check import LiveCheckReport +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + + +@tool +def multiply(a: float, b: float) -> float: + """Multiply two numbers together.""" + return a * b + + +@tool +def add(a: float, b: float) -> float: + """Add two numbers together.""" + return a + b + + +class AgentScenario(Scenario): + expected_spans = ("invoke_agent", "chat", "chat") + expected_metrics = ( + "gen_ai.client.operation.duration", + "gen_ai.client.token.usage", + ) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + key_override = ( + {} + if os.getenv("OPENAI_API_KEY") + else {"OPENAI_API_KEY": "test_openai_api_key"} + ) + with mock.patch.dict(os.environ, key_override): + with instrument( + LangChainInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + semconv="gen_ai_latest_experimental", + content_capture="SPAN_ONLY", + ): + llm = ChatOpenAI( + model="gpt-3.5-turbo", + temperature=0.1, + max_tokens=100, + top_p=0.9, + seed=100, + ) + agent = create_react_agent( + llm, tools=[multiply, add] + ).with_config( + { + "metadata": { + "agent_name": "math_agent", + "session_id": "test-session-conformance", + }, + } + ) + with vcr.use_cassette("agent_conformance.yaml"): + agent.invoke( + { + "messages": [ + HumanMessage(content="What is (3 * 4) + 7?") + ] + } + ) + + def validate(self, report: LiveCheckReport) -> None: + super().validate(report) + operations = [ + attr["value"] + for entry in report["samples"] + if "span" in entry + for attr in entry["span"]["attributes"] + if attr["name"] == "gen_ai.operation.name" + ] + assert "invoke_agent" in operations, ( + f"Expected an invoke_agent span; saw operations {operations}" + ) + assert operations.count("chat") >= 2, ( + "ReAct agent exercises at least two chat completions " + f"(initial tool-call request and follow-up); saw {operations}" + ) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/workflow.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/workflow.py new file mode 100644 index 00000000..7b7e7411 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/conformance/workflow.py @@ -0,0 +1,130 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: langchain two-node LangGraph workflow (researcher → summariser).""" + +from __future__ import annotations + +import os +from typing import Annotated, Any +from unittest import mock + +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from typing_extensions import TypedDict + +from opentelemetry.instrumentation.langchain import LangChainInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test.weaver_live_check import LiveCheckReport +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + + +class GraphState(TypedDict): + messages: Annotated[list, add_messages] + research: str + + +class WorkflowScenario(Scenario): + expected_spans = ("invoke_workflow", "chat", "chat") + expected_metrics = ( + "gen_ai.client.operation.duration", + "gen_ai.client.token.usage", + ) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + key_override = ( + {} + if os.getenv("OPENAI_API_KEY") + else {"OPENAI_API_KEY": "test_openai_api_key"} + ) + with mock.patch.dict(os.environ, key_override): + with instrument( + LangChainInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + semconv="gen_ai_latest_experimental", + content_capture="SPAN_ONLY", + ): + llm = ChatOpenAI( + model="gpt-3.5-turbo", + temperature=0.1, + max_tokens=200, + seed=42, + ) + + def researcher(state: GraphState) -> dict: + response = llm.invoke( + [ + SystemMessage( + content="You are a research assistant. Provide 2-3 factual sentences." + ), + HumanMessage( + content=state["messages"][-1].content + ), + ] + ) + return { + "research": response.content, + "messages": [response], + } + + def summariser(state: GraphState) -> dict: + response = llm.invoke( + [ + SystemMessage( + content="You are an expert summariser. Condense the text below into one clear sentence." + ), + HumanMessage(content=state["research"]), + ] + ) + return {"messages": [response]} + + builder = StateGraph(GraphState) + builder.add_node("researcher", researcher) + builder.add_node("summariser", summariser) + builder.add_edge(START, "researcher") + builder.add_edge("researcher", "summariser") + builder.add_edge("summariser", END) + graph = builder.compile() + + with vcr.use_cassette("workflow_conformance.yaml"): + graph.invoke( + { + "messages": [ + HumanMessage( + content="What is the capital of France?" + ) + ], + "research": "", + } + ) + + def validate(self, report: LiveCheckReport) -> None: + super().validate(report) + operations = [ + attr["value"] + for entry in report["samples"] + if "span" in entry + for attr in entry["span"]["attributes"] + if attr["name"] == "gen_ai.operation.name" + ] + assert "invoke_workflow" in operations, ( + f"Expected an invoke_workflow span; saw operations {operations}" + ) + assert operations.count("chat") >= 2, ( + "Two-node workflow exercises two chat completions " + f"(researcher and summariser); saw {operations}" + ) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py index 26bbcffd..737784c7 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py @@ -37,17 +37,17 @@ def _make_handler(): """Return a handler wired to a MagicMock TelemetryHandler.""" telemetry = mock.MagicMock() - # start_workflow returns a mock WorkflowInvocation + # workflow returns a mock WorkflowInvocation workflow_inv = mock.MagicMock(spec=WorkflowInvocation) workflow_inv.span = mock.MagicMock() workflow_inv.span.is_recording.return_value = False - telemetry.start_workflow.return_value = workflow_inv + telemetry.workflow.return_value = workflow_inv - # start_invoke_local_agent returns a mock AgentInvocation + # invoke_local_agent returns a mock AgentInvocation agent_inv = mock.MagicMock(spec=AgentInvocation) agent_inv.span = mock.MagicMock() agent_inv.span.is_recording.return_value = False - telemetry.start_invoke_local_agent.return_value = agent_inv + telemetry.invoke_local_agent.return_value = agent_inv handler = OpenTelemetryLangChainCallbackHandler(telemetry) return handler, telemetry, workflow_inv, agent_inv @@ -75,7 +75,7 @@ def test_workflow_span_created(self): parent_run_id=None, ) - telemetry.start_workflow.assert_called_once() + telemetry.workflow.assert_called_once() assert ( handler._invocation_manager.get_invocation(run_id) is workflow_inv ) @@ -91,7 +91,7 @@ def test_workflow_name_from_serialized(self): parent_run_id=None, ) - telemetry.start_workflow.assert_called_once_with(name="MyLangGraph") + telemetry.workflow.assert_called_once_with(name="MyLangGraph") def test_workflow_name_overridden_by_metadata(self): handler, telemetry, _, _ = _make_handler() @@ -105,9 +105,7 @@ def test_workflow_name_overridden_by_metadata(self): metadata={"workflow_name": "custom_workflow"}, ) - telemetry.start_workflow.assert_called_once_with( - name="custom_workflow" - ) + telemetry.workflow.assert_called_once_with(name="custom_workflow") def test_workflow_registered_in_invocation_manager(self): handler, _, workflow_inv, _ = _make_handler() @@ -143,8 +141,9 @@ def test_new_agent_span_created(self): metadata={"agent_name": "math_agent", "ls_provider": "openai"}, ) - telemetry.start_invoke_local_agent.assert_called_once_with( - provider="openai" + telemetry.invoke_local_agent.assert_called_once_with( + provider="openai", + agent_name="math_agent", ) assert agent_inv.agent_name == "math_agent" assert handler._invocation_manager.get_invocation(run_id) is agent_inv @@ -203,7 +202,7 @@ def test_duplicate_agent_name_does_not_create_new_span(self): parent_run_id=None, metadata={"agent_name": "math_agent"}, ) - telemetry.start_invoke_local_agent.reset_mock() + telemetry.invoke_local_agent.reset_mock() # A child chain with the same agent name should NOT create a new span handler.on_chain_start( @@ -214,7 +213,7 @@ def test_duplicate_agent_name_does_not_create_new_span(self): metadata={"agent_name": "math_agent"}, ) - telemetry.start_invoke_local_agent.assert_not_called() + telemetry.invoke_local_agent.assert_not_called() assert handler._invocation_manager.get_invocation(child_run_id) is None def test_different_agent_name_creates_new_span(self): @@ -227,7 +226,7 @@ def test_different_agent_name_creates_new_span(self): first_agent_inv = mock.MagicMock(spec=AgentInvocation) first_agent_inv.span = mock.MagicMock() first_agent_inv.span.is_recording.return_value = False - telemetry.start_invoke_local_agent.return_value = first_agent_inv + telemetry.invoke_local_agent.return_value = first_agent_inv handler.on_chain_start( serialized={"name": "math_agent"}, @@ -241,7 +240,7 @@ def test_different_agent_name_creates_new_span(self): second_agent_inv = mock.MagicMock(spec=AgentInvocation) second_agent_inv.span = mock.MagicMock() second_agent_inv.span.is_recording.return_value = False - telemetry.start_invoke_local_agent.return_value = second_agent_inv + telemetry.invoke_local_agent.return_value = second_agent_inv handler.on_chain_start( serialized={"name": "weather_agent"}, @@ -265,7 +264,7 @@ def test_agent_name_comparison_is_case_insensitive(self): parent_agent_inv = mock.MagicMock(spec=AgentInvocation) parent_agent_inv.span = mock.MagicMock() parent_agent_inv.span.is_recording.return_value = False - telemetry.start_invoke_local_agent.return_value = parent_agent_inv + telemetry.invoke_local_agent.return_value = parent_agent_inv handler.on_chain_start( serialized={"name": "Math_Agent"}, @@ -274,7 +273,7 @@ def test_agent_name_comparison_is_case_insensitive(self): parent_run_id=None, metadata={"agent_name": "Math_Agent"}, ) - telemetry.start_invoke_local_agent.reset_mock() + telemetry.invoke_local_agent.reset_mock() handler.on_chain_start( serialized={"name": "math_agent"}, @@ -285,7 +284,7 @@ def test_agent_name_comparison_is_case_insensitive(self): ) # Same name (case-insensitive) → no new span - telemetry.start_invoke_local_agent.assert_not_called() + telemetry.invoke_local_agent.assert_not_called() def test_no_agent_name_registers_none_invocation(self): """When resolve_agent_name returns None the run_id must still be @@ -304,7 +303,7 @@ def test_no_agent_name_registers_none_invocation(self): metadata={"otel_agent_span": True}, ) - telemetry.start_invoke_local_agent.assert_not_called() + telemetry.invoke_local_agent.assert_not_called() # run_id must still be registered (with None invocation) so traversal works assert run_id in handler._invocation_manager._invocations assert handler._invocation_manager.get_invocation(run_id) is None @@ -326,7 +325,7 @@ def test_no_agent_name_child_can_still_find_ancestor_agent(self): ) # Parent: INVOKE_AGENT but no resolvable name → registers None - telemetry.start_invoke_local_agent.reset_mock() + telemetry.invoke_local_agent.reset_mock() handler.on_chain_start( serialized={}, inputs={}, @@ -363,8 +362,8 @@ def test_unclassified_chain_registers_none_and_no_span(self): parent_run_id=parent_run_id, ) - telemetry.start_workflow.assert_not_called() - telemetry.start_invoke_local_agent.assert_not_called() + telemetry.workflow.assert_not_called() + telemetry.invoke_local_agent.assert_not_called() assert run_id in handler._invocation_manager._invocations assert handler._invocation_manager.get_invocation(run_id) is None diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py index 77128a53..d6475ac0 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py @@ -18,7 +18,9 @@ run_conformance, ) +from .conformance.agent import AgentScenario from .conformance.inference import InferenceScenario +from .conformance.workflow import WorkflowScenario pytestmark = pytest.mark.conformance @@ -27,6 +29,8 @@ "scenario", [ InferenceScenario(), + AgentScenario(), + WorkflowScenario(), ], ids=lambda s: type(s).__name__, ) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py index 9c96a916..d97e5fcd 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py @@ -3,7 +3,7 @@ """Unit tests for operation_mapping module. -Tests the public API: classify_chain_run, resolve_agent_name, should_ignore_chain. +Tests the public API: classify_chain_run, resolve_agent_name. """ import uuid @@ -12,7 +12,6 @@ OperationName, classify_chain_run, resolve_agent_name, - should_ignore_chain, ) # --------------------------------------------------------------------------- @@ -88,104 +87,6 @@ def test_result_is_always_str(self): assert isinstance(result, str) -# --------------------------------------------------------------------------- -# should_ignore_chain -# --------------------------------------------------------------------------- - - -class TestShouldIgnoreChain: - def test_langgraph_start_node_is_suppressed(self): - assert ( - should_ignore_chain( - metadata={"langgraph_node": "__start__"}, - agent_name=None, - kwargs={}, - ) - is True - ) - - def test_otel_trace_false_is_suppressed(self): - assert ( - should_ignore_chain( - metadata={"otel_trace": False}, - agent_name=None, - kwargs={}, - ) - is True - ) - - def test_otel_agent_span_false_without_other_signals_is_suppressed(self): - assert ( - should_ignore_chain( - metadata={"otel_agent_span": False}, - agent_name=None, - kwargs={}, - ) - is True - ) - - def test_otel_agent_span_false_with_agent_name_is_not_suppressed(self): - # agent_name present overrides the False flag - assert ( - should_ignore_chain( - metadata={"otel_agent_span": False, "agent_name": "my_agent"}, - agent_name="my_agent", - kwargs={}, - ) - is False - ) - - def test_otel_agent_span_false_with_agent_type_is_not_suppressed(self): - assert ( - should_ignore_chain( - metadata={"otel_agent_span": False, "agent_type": "react"}, - agent_name=None, - kwargs={}, - ) - is False - ) - - def test_middleware_prefix_in_agent_name_is_suppressed(self): - assert ( - should_ignore_chain( - metadata=None, - agent_name="Middleware.SomeThing", - kwargs={}, - ) - is True - ) - - def test_middleware_prefix_in_kwargs_name_is_suppressed(self): - assert ( - should_ignore_chain( - metadata=None, - agent_name=None, - kwargs={"name": "Middleware.Router"}, - ) - is True - ) - - def test_normal_chain_is_not_suppressed(self): - assert ( - should_ignore_chain( - metadata=None, - agent_name="my_agent", - kwargs={}, - ) - is False - ) - - def test_none_metadata_is_not_suppressed(self): - assert ( - should_ignore_chain( - metadata=None, - agent_name=None, - kwargs={}, - ) - is False - ) - - # --------------------------------------------------------------------------- # classify_chain_run # --------------------------------------------------------------------------- @@ -343,6 +244,24 @@ def test_otel_agent_span_false_with_no_other_signals_suppressed(self): ) assert result is None + def test_otel_agent_span_false_with_agent_name_is_agent(self): + result = classify_chain_run( + serialized={}, + metadata={"otel_agent_span": False, "agent_name": "my_agent"}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result == OperationName.INVOKE_AGENT + + def test_otel_agent_span_false_with_agent_type_is_agent(self): + result = classify_chain_run( + serialized={}, + metadata={"otel_agent_span": False, "agent_type": "react"}, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result == OperationName.INVOKE_AGENT + def test_non_langgraph_child_chain_suppressed(self): # Child chain with no agent or workflow signals → suppressed. result = classify_chain_run( diff --git a/policies/genai_span_validation.rego b/policies/genai_span_validation.rego index e85c8453..51b6bdab 100644 --- a/policies/genai_span_validation.rego +++ b/policies/genai_span_validation.rego @@ -158,11 +158,9 @@ _execute_tool_expected := { # Invoke agent. # Required: gen_ai.operation.name, gen_ai.provider.name. -# Most instrumentations should have agent.id; flag it as always-emit. _invoke_agent_expected := { "gen_ai.operation.name", "gen_ai.provider.name", - "gen_ai.agent.id", } # Create agent. After creation completes the provider returns an agent.id; diff --git a/uv.lock b/uv.lock index ae80c313..66a38356 100644 --- a/uv.lock +++ b/uv.lock @@ -1222,7 +1222,7 @@ instruments = [ [package.metadata] requires-dist = [ { name = "langchain", marker = "extra == 'instruments'", specifier = ">=0.3.21" }, - { name = "opentelemetry-instrumentation", specifier = "~=0.63b0" }, + { name = "opentelemetry-instrumentation", specifier = ">=0.62b0" }, { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, ] provides-extras = ["instruments"] From 255e3eff362d8d7582da7d71654e21b2b892d070 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Tue, 19 May 2026 17:59:15 -0700 Subject: [PATCH 08/12] fixed tests instrumentation-langchain-conformance --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index e7a4081f..02964f00 100644 --- a/tox.ini +++ b/tox.ini @@ -122,6 +122,7 @@ deps = ; Langchain has no oldest/latest matrix yet (TODO above); pin the client ; libs the conftest imports inline. langchain-conformance: {[testenv]pytest_deps} + langchain-conformance: {toxinidir}/util/opentelemetry-util-genai langchain-conformance: {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain[instruments] langchain-conformance: langchain-openai langchain-conformance: langchain-aws From e67602d6d3e7474f45a9627134832afad11a470a Mon Sep 17 00:00:00 2001 From: Wrisa Date: Wed, 20 May 2026 09:32:31 -0700 Subject: [PATCH 09/12] fixed tests --- .../tests/cassettes/test_gemini.yaml | 32 +++++++++++ ...mazon_nova_lite_v1_0_bedrock_llm_call.yaml | 2 +- .../tests/conftest.py | 36 ++++++++++++ .../tests/requirements.txt | 24 ++++++++ .../tests/test_callback_handler.py | 57 +++++++++++++------ .../tests/test_conformance.py | 4 +- tox.ini | 14 +++-- 7 files changed, 145 insertions(+), 24 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_gemini.yaml create mode 100644 instrumentation/opentelemetry-instrumentation-langchain/tests/requirements.txt diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_gemini.yaml b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_gemini.yaml new file mode 100644 index 00000000..6c20ba8b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_gemini.yaml @@ -0,0 +1,32 @@ +interactions: +- request: + body: '{"contents": [{"parts": [{"text": "What is the capital of France?"}], "role": + "user"}], "systemInstruction": {"parts": [{"text": "You are a helpful assistant!"}]}, + "safetySettings": [], "generationConfig": {"temperature": 0.7, "candidateCount": + 1}}' + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate, zstd + Connection: + - keep-alive + Content-Type: + - application/json + User-Agent: + - python-httpx/0.28.1 + method: POST + uri: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent + response: + body: + string: '{"candidates": [{"content": {"parts": [{"text": "The capital of France + is **Paris**."}], "role": "model"}, "finishReason": "STOP", "index": 0}], + "usageMetadata": {"promptTokenCount": 15, "candidatesTokenCount": 10, "totalTokenCount": + 25}, "modelVersion": "gemini-2.5-pro"}' + headers: + Content-Type: + - application/json; charset=UTF-8 + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml index a6dc912a..8a7b6d6e 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml @@ -46,7 +46,7 @@ interactions: authorization: - Bearer test_openai_api_key method: POST - uri: https://bedrock-runtime.us-west-2.amazonaws.com/model/arn%3Aaws%3Abedrock%3Aus-west-2%3A906383545488%3Ainference-profile%2Fus.amazon.nova-lite-v1%3A0/converse + uri: https://bedrock-runtime.us-west-2.amazonaws.com/model/us.amazon.nova-lite-v1%3A0/converse response: body: string: |- diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py index ebb3c5f4..7d55dd84 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py @@ -11,6 +11,10 @@ from langchain_google_genai import ChatGoogleGenerativeAI from langchain_openai import ChatOpenAI +from opentelemetry.instrumentation._semconv import ( + _OpenTelemetrySemanticConventionStability, + _StabilityMode, +) from opentelemetry.instrumentation.langchain import LangChainInstrumentor from opentelemetry.test_util_genai.vcr import scrub_response_headers_overwrite @@ -88,6 +92,38 @@ def environment(): os.environ["OPENAI_API_KEY"] = "test_openai_api_key" +@pytest.fixture(autouse=True) +def reset_semconv_stability(monkeypatch: pytest.MonkeyPatch): + """Ensure the semconv stability singleton re-reads env vars for each test. + + _get_opentelemetry_stability_opt_in_mode does not call _initialize() itself, + so we patch it to call _initialize() first, making it pick up any env var + changes applied via monkeypatch.setenv within the test body. + """ + original = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode + + @classmethod # type: ignore[misc] + def _reinitializing_get(cls, signal_type): + cls._initialized = False + cls._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING = {} + cls._initialize() + return cls._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING.get( + signal_type, _StabilityMode.DEFAULT + ) + + monkeypatch.setattr( + _OpenTelemetrySemanticConventionStability, + "_get_opentelemetry_stability_opt_in_mode", + _reinitializing_get, + ) + yield + monkeypatch.setattr( + _OpenTelemetrySemanticConventionStability, + "_get_opentelemetry_stability_opt_in_mode", + original, + ) + + @pytest.fixture(scope="module") def vcr_config(): return { diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/requirements.txt b/instrumentation/opentelemetry-instrumentation-langchain/tests/requirements.txt new file mode 100644 index 00000000..b2de8837 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/requirements.txt @@ -0,0 +1,24 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Single pinned requirements for langchain unit tests. +# TODO: split into oldest/latest once the version matrix is defined. + +langchain-openai +langchain-aws +langchain-google-genai +boto3 + +-e util/opentelemetry-util-genai +-e instrumentation/opentelemetry-instrumentation-langchain[instruments] diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py index 737784c7..09238a06 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py @@ -33,6 +33,28 @@ # --------------------------------------------------------------------------- +def _make_agent_inv_mock() -> mock.MagicMock: + """Return a spec'd AgentInvocation mock with agent_name pre-configured.""" + agent_inv = mock.MagicMock(spec=AgentInvocation) + agent_inv.span = mock.MagicMock() + agent_inv.span.is_recording.return_value = False + # agent_name is an instance attribute set in AgentInvocation.__init__ via the + # constructor arg; pre-configure it so spec-restricted attribute access works. + agent_inv.agent_name = None + return agent_inv + + +def _make_invoke_local_agent_side_effect(inv: mock.MagicMock): + """Return a side_effect for invoke_local_agent that mirrors what the real + AgentInvocation constructor does: set agent_name from the kwarg.""" + + def _side_effect(*args, **kwargs): + inv.agent_name = kwargs.get("agent_name") + return inv + + return _side_effect + + def _make_handler(): """Return a handler wired to a MagicMock TelemetryHandler.""" telemetry = mock.MagicMock() @@ -43,11 +65,12 @@ def _make_handler(): workflow_inv.span.is_recording.return_value = False telemetry.workflow.return_value = workflow_inv - # invoke_local_agent returns a mock AgentInvocation - agent_inv = mock.MagicMock(spec=AgentInvocation) - agent_inv.span = mock.MagicMock() - agent_inv.span.is_recording.return_value = False - telemetry.invoke_local_agent.return_value = agent_inv + # invoke_local_agent returns a mock AgentInvocation whose agent_name is set + # to match whatever agent_name kwarg was passed (mirrors real constructor). + agent_inv = _make_agent_inv_mock() + telemetry.invoke_local_agent.side_effect = ( + _make_invoke_local_agent_side_effect(agent_inv) + ) handler = OpenTelemetryLangChainCallbackHandler(telemetry) return handler, telemetry, workflow_inv, agent_inv @@ -223,10 +246,10 @@ def test_different_agent_name_creates_new_span(self): child_run_id = _run_id() # First agent - first_agent_inv = mock.MagicMock(spec=AgentInvocation) - first_agent_inv.span = mock.MagicMock() - first_agent_inv.span.is_recording.return_value = False - telemetry.invoke_local_agent.return_value = first_agent_inv + first_agent_inv = _make_agent_inv_mock() + telemetry.invoke_local_agent.side_effect = ( + _make_invoke_local_agent_side_effect(first_agent_inv) + ) handler.on_chain_start( serialized={"name": "math_agent"}, @@ -237,10 +260,10 @@ def test_different_agent_name_creates_new_span(self): ) # Second agent with a different name - second_agent_inv = mock.MagicMock(spec=AgentInvocation) - second_agent_inv.span = mock.MagicMock() - second_agent_inv.span.is_recording.return_value = False - telemetry.invoke_local_agent.return_value = second_agent_inv + second_agent_inv = _make_agent_inv_mock() + telemetry.invoke_local_agent.side_effect = ( + _make_invoke_local_agent_side_effect(second_agent_inv) + ) handler.on_chain_start( serialized={"name": "weather_agent"}, @@ -261,10 +284,10 @@ def test_agent_name_comparison_is_case_insensitive(self): parent_run_id = _run_id() child_run_id = _run_id() - parent_agent_inv = mock.MagicMock(spec=AgentInvocation) - parent_agent_inv.span = mock.MagicMock() - parent_agent_inv.span.is_recording.return_value = False - telemetry.invoke_local_agent.return_value = parent_agent_inv + parent_agent_inv = _make_agent_inv_mock() + telemetry.invoke_local_agent.side_effect = ( + _make_invoke_local_agent_side_effect(parent_agent_inv) + ) handler.on_chain_start( serialized={"name": "Math_Agent"}, diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py index d6475ac0..0f5478ba 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py @@ -9,8 +9,10 @@ import pytest -# Skip collection when weaver_live_check isn't installed (non-conformance envs). +# Skip collection when weaver_live_check or OTLP exporters aren't installed +# (non-conformance envs). pytest.importorskip("opentelemetry.test.weaver_live_check") +pytest.importorskip("opentelemetry.exporter.otlp.proto.grpc") from opentelemetry.test.weaver_live_check import WeaverLiveCheck # noqa: E402 from opentelemetry.test_util_genai.conformance import ( # noqa: E402 diff --git a/tox.ini b/tox.ini index 02964f00..28cd3721 100644 --- a/tox.ini +++ b/tox.ini @@ -35,9 +35,8 @@ envlist = lint-instrumentation-claude-agent-sdk ; instrumentation-langchain - ; TODO: add tests/requirements.{oldest,latest}.txt and langchain-{oldest,latest} - ; factors below; declare opentelemetry-util-genai in the package pyproject.toml - ; (latent bug — code imports it but it's not in runtime deps). + ; TODO: split into oldest/latest once a version matrix is defined. + py3{12,13}-test-instrumentation-langchain py3{12,13}-test-instrumentation-langchain-conformance lint-instrumentation-langchain @@ -119,8 +118,12 @@ deps = claude-agent-sdk-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.latest.txt lint-instrumentation-claude-agent-sdk: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt - ; Langchain has no oldest/latest matrix yet (TODO above); pin the client - ; libs the conftest imports inline. + ; Langchain unit tests: single pinned requirements until oldest/latest is defined. + langchain: {[testenv]test_deps} + langchain: {[testenv]pytest_deps} + langchain: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests/requirements.txt + + ; Langchain conformance tests langchain-conformance: {[testenv]pytest_deps} langchain-conformance: {toxinidir}/util/opentelemetry-util-genai langchain-conformance: {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain[instruments] @@ -163,6 +166,7 @@ commands = test-instrumentation-claude-agent-sdk: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-claude-agent-sdk/tests --vcr-record=none {posargs} lint-instrumentation-claude-agent-sdk: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-claude-agent-sdk" + test-instrumentation-langchain: pytest -m "not conformance" {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests --vcr-record=none {posargs} test-instrumentation-langchain-conformance: pytest -m conformance {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests --vcr-record=none {posargs} lint-instrumentation-langchain: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-langchain" From 23e3f2096aa2f405e6c7780a9c8d3db74f875ef1 Mon Sep 17 00:00:00 2001 From: Wrisa Date: Wed, 20 May 2026 12:41:33 -0700 Subject: [PATCH 10/12] fixed test --- ...s_amazon_nova_lite_v1_0_bedrock_llm_call.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml index 8a7b6d6e..c6ec20ae 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml @@ -1,4 +1,20 @@ interactions: +- request: + body: '' + headers: + X-aws-ec2-metadata-token-ttl-seconds: + - '21600' + method: PUT + uri: http://169.254.169.254/latest/api/token + response: + body: + string: fake-imds-token + headers: + Content-Type: + - text/plain + status: + code: 200 + message: OK - request: body: |- { From ca9a67b619f2915180b78395bccc6b66f19174ae Mon Sep 17 00:00:00 2001 From: Wrisa Date: Wed, 20 May 2026 13:18:41 -0700 Subject: [PATCH 11/12] fixed tests --- ...s_amazon_nova_lite_v1_0_bedrock_llm_call.yaml | 16 ---------------- .../tests/conftest.py | 4 ++++ tox.ini | 4 ++-- 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml index c6ec20ae..8a7b6d6e 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/cassettes/test_us_amazon_nova_lite_v1_0_bedrock_llm_call.yaml @@ -1,20 +1,4 @@ interactions: -- request: - body: '' - headers: - X-aws-ec2-metadata-token-ttl-seconds: - - '21600' - method: PUT - uri: http://169.254.169.254/latest/api/token - response: - body: - string: fake-imds-token - headers: - Content-Type: - - text/plain - status: - code: 200 - message: OK - request: body: |- { diff --git a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py index 7d55dd84..6c311870 100644 --- a/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py @@ -55,6 +55,9 @@ def fixture_us_amazon_nova_lite_v1_0(): region_name="us-west-2", aws_account_id="test_account", ), + aws_access_key_id="test_key", + aws_secret_access_key="test_secret", + region_name="us-west-2", provider="amazon", temperature=0.1, max_tokens=100, @@ -140,4 +143,5 @@ def vcr_config(): "Set-Cookie": "test_set_cookie", } ), + "ignore_hosts": ["169.254.169.254"], } diff --git a/tox.ini b/tox.ini index 5f7bb410..2ba096cc 100644 --- a/tox.ini +++ b/tox.ini @@ -166,8 +166,8 @@ commands = test-instrumentation-claude-agent-sdk: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-claude-agent-sdk/tests --vcr-record=none {posargs} lint-instrumentation-claude-agent-sdk: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-claude-agent-sdk" - test-instrumentation-langchain: pytest -m "not conformance" {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests --vcr-record=none {posargs} - test-instrumentation-langchain-conformance: pytest -m conformance {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests --vcr-record=none {posargs} + test-instrumentation-langchain: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests --vcr-record=none {posargs} + test-instrumentation-langchain-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-langchain: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-langchain" lint-instrumentation-weaviate: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-weaviate" From 1b4638edcda4eb6a97047b638403a2f20aa3def7 Mon Sep 17 00:00:00 2001 From: wrisa Date: Wed, 20 May 2026 13:27:27 -0700 Subject: [PATCH 12/12] Generate updated CI workflows for langchain instrumentation Add missing py312/py313 test jobs for instrumentation-langchain. Assisted-by: Claude Opus 4.6 --- .github/workflows/test.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 73a94b4a..dbc4701b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1114,6 +1114,44 @@ jobs: - name: Run tests run: uvx --with tox-uv tox -e py313-test-instrumentation-claude-agent-sdk-latest -- -ra + py312-test-instrumentation-langchain_ubuntu-latest: + name: instrumentation-langchain 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python 3.12 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Run tests + run: uvx --with tox-uv tox -e py312-test-instrumentation-langchain -- -ra + + py313-test-instrumentation-langchain_ubuntu-latest: + name: instrumentation-langchain 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python 3.13 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + + - name: Run tests + run: uvx --with tox-uv tox -e py313-test-instrumentation-langchain -- -ra + py312-test-instrumentation-langchain-conformance_ubuntu-latest: name: instrumentation-langchain-conformance 3.12 Ubuntu runs-on: ubuntu-latest