Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3d60029
Added tools support
wrisa May 17, 2026
2fb9234
Add tools support in langchain
wrisa May 18, 2026
f36d295
Merge branch 'tools-langchain' of github.com:wrisa/opentelemetry-pyth…
wrisa May 18, 2026
93cf06a
added changelog
wrisa May 18, 2026
0badaae
updated changelog
wrisa May 18, 2026
cdc1e86
fixed errors
wrisa May 18, 2026
070c81e
fix: move imports to top-level to fix PLC0415 lint errors
wrisa May 18, 2026
7d32fdd
Merge branch 'main' into tools-langchain
wrisa May 18, 2026
08ff1c3
resolved conflicts
wrisa Jun 5, 2026
436c230
Merge branch 'tools-langchain' of github.com:wrisa/opentelemetry-pyth…
wrisa Jun 5, 2026
0ed8921
fixed errors
wrisa Jun 5, 2026
8c5adad
fixed error
wrisa Jun 5, 2026
efb8264
fixed tool.type
wrisa Jun 5, 2026
2a95a90
added conformance tests
wrisa Jun 5, 2026
e8194a9
Merge branch 'main' into tools-langchain
wrisa Jun 8, 2026
a89f6c7
addressed review comments
wrisa Jun 11, 2026
2f87cdb
Merge branch 'tools-langchain' of github.com:wrisa/opentelemetry-pyth…
wrisa Jun 11, 2026
459b1bb
fixed error
wrisa Jun 11, 2026
4946d98
Merge branch 'main' into tools-langchain
wrisa Jun 11, 2026
265ef8a
Merge branch 'main' into tools-langchain
wrisa Jun 12, 2026
9bf1906
Merge branch 'main' into tools-langchain
wrisa Jun 15, 2026
f3c3627
Merge branch 'main' into tools-langchain
wrisa Jun 15, 2026
dcf64f2
Merge branch 'tools-langchain' of github.com:wrisa/opentelemetry-pyth…
wrisa Jun 15, 2026
f17dde6
Merge branch 'main' into tools-langchain
wrisa Jun 15, 2026
72e7724
fixed conformance tests
wrisa Jun 15, 2026
97772f0
Merge branch 'tools-langchain' of github.com:wrisa/opentelemetry-pyth…
wrisa Jun 15, 2026
ed6339c
Merge branch 'main' into tools-langchain
wrisa Jun 15, 2026
bd79d8c
fixed conformance tests and reverted change
wrisa Jun 15, 2026
b8ae7b6
Merge branch 'tools-langchain' of github.com:wrisa/opentelemetry-pyth…
wrisa Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added tool spans and captured tool definitions on inference spans.
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

"""
Tool-calling example without agents, built with LangChain.

Uses ChatOpenAI with bind_tools to let the model call calculator tools directly,
then manually dispatches tool calls and feeds results back to the model.
OpenTelemetry LangChain instrumentation traces the LLM calls.
"""

from __future__ import annotations

import json

from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI

from opentelemetry import _logs, metrics, trace
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
OTLPLogExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
OTLPMetricExporter,
)
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
from opentelemetry.instrumentation.genai.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


TOOLS = [multiply, add]
TOOLS_BY_NAME = {t.name: t for t in TOOLS}


def main() -> None:
instrumentor = LangChainInstrumentor()
instrumentor.instrument()

llm = ChatOpenAI(
model="gpt-3.5-turbo",
temperature=0.1,
max_tokens=100,
top_p=0.9,
seed=100,
)
llm_with_tools = llm.bind_tools(TOOLS)

messages = [HumanMessage(content="What is (3 * 4) + 7?")]

# First LLM call — model may request tool calls
response = llm_with_tools.invoke(messages)
messages.append(response)

# Dispatch tool calls until the model stops requesting them
while response.tool_calls:
for tool_call in response.tool_calls:
selected_tool = TOOLS_BY_NAME[tool_call["name"]]
tool_output = selected_tool.invoke(tool_call["args"])
messages.append(
ToolMessage(
content=json.dumps(tool_output),
tool_call_id=tool_call["id"],
)
)

response = llm_with_tools.invoke(messages)
messages.append(response)

print("Final answer:", response.content)

instrumentor.uninstrument()


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
langchain==0.3.21
langchain_openai
langgraph
opentelemetry-sdk>=1.31.0
opentelemetry-exporter-otlp-proto-grpc>=1.31.0
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import json
from typing import Any, Optional, cast
from uuid import UUID

Expand All @@ -21,18 +22,21 @@
from opentelemetry.instrumentation.genai.langchain.utils import (
make_input_message,
make_last_output_message,
prepare_tool_definitions,
)
from opentelemetry.util.genai.handler import TelemetryHandler
from opentelemetry.util.genai.invocation import (
AgentInvocation,
InferenceInvocation,
ToolInvocation,
WorkflowInvocation,
)
from opentelemetry.util.genai.types import (
InputMessage,
MessagePart,
OutputMessage,
Text,
ToolCallRequest,
)


Expand Down Expand Up @@ -286,6 +290,11 @@ def on_chat_model_start(
llm_invocation.seed = seed
llm_invocation.temperature = temperature
llm_invocation.max_tokens = max_tokens
if params is not None:
tools = params.get("tools") or params.get("functions")
if tools:
tool_definitions = prepare_tool_definitions(tools)
llm_invocation.tool_definitions = tool_definitions
Comment thread
wrisa marked this conversation as resolved.
self._invocation_manager.add_invocation_state(
run_id=run_id,
parent_run_id=parent_run_id,
Expand Down Expand Up @@ -333,19 +342,33 @@ def on_llm_end(
)
)

# Get message content
parts = [
Text(
content=chat_generation.message.content,
type="text",
if finish_reason == "tool_calls":
tool_calls: list[ToolCallRequest] = []
for tool_call in chat_generation.message.tool_calls:
tool_call_request = ToolCallRequest(
name=tool_call["name"],
id=tool_call["id"],
arguments=tool_call["args"],
)
tool_calls.append(tool_call_request)
output_message = OutputMessage(
role=chat_generation.message.type,
parts=cast(list[MessagePart], tool_calls),
finish_reason=finish_reason,
)
else:
parts = [
Text(
content=chat_generation.message.content,
type="text",
)
]
role = chat_generation.message.type
output_message = OutputMessage(
role=role,
parts=cast(list[MessagePart], parts),
finish_reason=finish_reason,
)
]
role = chat_generation.message.type
output_message = OutputMessage(
role=role,
parts=cast(list[MessagePart], parts),
finish_reason=finish_reason,
)
output_messages.append(output_message)

# Get token usage if available
Expand Down Expand Up @@ -402,6 +425,72 @@ def on_llm_error(
if not llm_invocation.span.is_recording():
self._invocation_manager.delete_invocation_state(run_id=run_id)

def on_tool_start(
self,
serialized: Optional[dict[str, Any]],
input_str: str,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
tags: Optional[list[str]] = None,
metadata: Optional[dict[str, Any]] = None,
inputs: Optional[dict[str, Any]] = None,
**kwargs: Any,
) -> None:
name = "unknown"
description = None
if serialized is not None:
name = serialized.get("name") or "unknown"
description = serialized.get("description")

raw_arguments: Any = inputs if inputs is not None else input_str
arguments: str | None
if isinstance(raw_arguments, dict):
arguments = json.dumps(raw_arguments)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this necessary? can we pass Any ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's necessary. invocation.arguments is typed AttributeValue | None, which doesn't accept dict. The block converts the structured inputs: dict to a JSON string, falling back to the
plain input_str: str. Passing Any directly would be a type violation and would break span attribute setting at runtime when inputs is a dict.

elif isinstance(raw_arguments, str):
arguments = raw_arguments
else:
arguments = None
tool_invocation = self._telemetry_handler.tool(
name=name, tool_description=description, tool_type="function"
)
tool_invocation.arguments = arguments
self._invocation_manager.add_invocation_state(
run_id, parent_run_id, tool_invocation
)

def on_tool_end(
self,
output: Any,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**_kwargs: Any,
) -> None:
tool_invocation = self._invocation_manager.get_invocation(run_id)
if not isinstance(tool_invocation, ToolInvocation):
return
tool_invocation.tool_call_id = getattr(output, "tool_call_id", None)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is getattr necessary ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is defensive pattern here given the output:Any - typed parameter

tool_invocation.tool_result = getattr(output, "content", None)
tool_invocation.stop()
if not tool_invocation.span.is_recording():
self._invocation_manager.delete_invocation_state(run_id=run_id)

def on_tool_error(
self,
error: BaseException,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**_: Any,
) -> None:
tool_invocation = self._invocation_manager.get_invocation(run_id)
if not isinstance(tool_invocation, ToolInvocation):
return
tool_invocation.fail(error)
if not tool_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]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,45 @@
from langchain_core.messages import AIMessage

from opentelemetry.util.genai.types import (
FunctionToolDefinition,
InputMessage,
OutputMessage,
Text,
ToolDefinition,
)


def _get_property_value(obj: Any, property_name: str) -> Any:
if isinstance(obj, dict):
return cast(dict[str, Any], obj).get(property_name)
Comment thread
wrisa marked this conversation as resolved.

return getattr(obj, property_name, None)


def prepare_tool_definitions(tools: list[Any]) -> list[ToolDefinition] | None:
if not tools:
return None

definitions: list[ToolDefinition] = []
for tool in tools:
tool_type = _get_property_value(tool, "type")
if tool_type == "function":
func = _get_property_value(tool, "function")
if func:
func_name = _get_property_value(func, "name")
func_description = _get_property_value(func, "description")
definitions.append(
FunctionToolDefinition(
name=str(func_name) if func_name is not None else "",
description=str(func_description)
if func_description is not None
else None,
parameters=_get_property_value(func, "parameters"),
)
)
return definitions or None


def make_input_message(data: Any) -> list[InputMessage]:
"""Create structured input message with full data as JSON."""
if not isinstance(data, dict):
Expand Down
Loading