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 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/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/requirements.txt b/instrumentation/opentelemetry-instrumentation-langchain/examples/agent/requirements.txt new file mode 100644 index 00000000..d97c704f --- /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.42.0 +opentelemetry-exporter-otlp-proto-grpc>=1.42.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..92d928c4 --- /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_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 ( + 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_react_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/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/requirements.txt b/instrumentation/opentelemetry-instrumentation-langchain/examples/workflow/requirements.txt new file mode 100644 index 00000000..c0434855 --- /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.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 9ee7d199..e5989743 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.62b0", + "opentelemetry-util-genai >= 0.4b0", ] [project.optional-dependencies] 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..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 @@ -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.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.invoke_local_agent( + provider=metadata.get("ls_provider", "unknown") + if metadata + else "unknown", + 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.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 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..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 @@ -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) 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..bf72bb0f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/operation_mapping.py @@ -0,0 +1,220 @@ +# 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", +] + +# --------------------------------------------------------------------------- +# 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: str = GenAI.GenAiOperationNameValues.INVOKE_WORKFLOW.value + + +# --------------------------------------------------------------------------- +# 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) + ) + + +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 + + # 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 + + +# --------------------------------------------------------------------------- +# 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..1636a1ec --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/utils.py @@ -0,0 +1,94 @@ +# 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: 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: list[Any] | None = data_dict.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: 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/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/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/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/conftest.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/conftest.py index ebb3c5f4..6c311870 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 @@ -51,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, @@ -88,6 +95,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 { @@ -104,4 +143,5 @@ def vcr_config(): "Set-Cookie": "test_set_cookie", } ), + "ignore_hosts": ["169.254.169.254"], } 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 new file mode 100644 index 00000000..09238a06 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_callback_handler.py @@ -0,0 +1,1017 @@ +# 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_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() + + # 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.workflow.return_value = workflow_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 + + +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.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.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.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.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 + + 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.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.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 = _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"}, + inputs={}, + run_id=parent_run_id, + parent_run_id=None, + metadata={"agent_name": "math_agent"}, + ) + + # Second agent with a different name + 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"}, + 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 = _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"}, + inputs={}, + run_id=parent_run_id, + parent_run_id=None, + metadata={"agent_name": "Math_Agent"}, + ) + telemetry.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.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.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.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.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 + + +# --------------------------------------------------------------------------- +# 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_conformance.py b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_conformance.py index e493170c..3bdf216b 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 @@ -18,13 +20,17 @@ run_conformance, ) +from .conformance.agent import AgentScenario from .conformance.inference import InferenceScenario +from .conformance.workflow import WorkflowScenario @pytest.mark.parametrize( "scenario", [ InferenceScenario(), + AgentScenario(), + WorkflowScenario(), ], ids=lambda s: type(s).__name__, ) 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 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..d97e5fcd --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-langchain/tests/test_operation_mapping.py @@ -0,0 +1,273 @@ +# 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. +""" + +import uuid + +from opentelemetry.instrumentation.langchain.operation_mapping import ( + OperationName, + classify_chain_run, + resolve_agent_name, +) + +# --------------------------------------------------------------------------- +# 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) + + +# --------------------------------------------------------------------------- +# 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_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( + serialized={"name": "SomeInternalChain"}, + metadata=None, + kwargs={}, + parent_run_id=uuid.uuid4(), + ) + assert result is None 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/tox.ini b/tox.ini index 895c6934..2ba096cc 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,9 +118,14 @@ 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] langchain-conformance: langchain-openai langchain-conformance: langchain-aws @@ -162,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 --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" diff --git a/uv.lock b/uv.lock index 3704420e..66a38356 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]] @@ -1224,6 +1211,7 @@ name = "opentelemetry-instrumentation-langchain" source = { editable = "instrumentation/opentelemetry-instrumentation-langchain" } dependencies = [ { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-util-genai" }, ] [package.optional-dependencies] @@ -1234,7 +1222,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.62b0" }, + { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, ] provides-extras = ["instruments"] @@ -1368,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]] @@ -1422,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]] @@ -2900,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"