Skip to content
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
912b784
Add workflow and refactor LLM for langchain
wrisa Apr 16, 2026
9d4876f
Merge branch 'open-telemetry:main' into langchain-workflow-type
wrisa Apr 20, 2026
1ce4cf5
add workflow support and genai dependancy
wrisa Apr 16, 2026
e9fff12
fixed errors
wrisa Apr 20, 2026
6e34620
fixed changelog
wrisa Apr 20, 2026
c17607d
fixed error
wrisa Apr 20, 2026
a63f271
fixed type error
wrisa Apr 20, 2026
e8be641
removed optional
wrisa Apr 21, 2026
a9dc188
fixed error
wrisa Apr 21, 2026
3605631
ignore
wrisa Apr 21, 2026
4b7808f
fixed requests
wrisa Apr 21, 2026
e6d9f03
Merge branch 'main' into langchain-workflow-type
wrisa Apr 22, 2026
a503105
Merge branch 'main' into langchain-workflow-type
wrisa Apr 23, 2026
dc068fb
Merge branch 'main' into langchain-workflow-type
wrisa Apr 23, 2026
2ee7792
Merge branch 'main' into langchain-workflow-type
wrisa Apr 24, 2026
8ed66a4
Merge branch 'main' into langchain-workflow-type
wrisa Apr 27, 2026
1b5f2a3
Added delete and removed optional
wrisa Apr 24, 2026
93e806b
removed non workflow
wrisa Apr 24, 2026
16ee93a
removed line
wrisa Apr 27, 2026
eee467a
added new line
wrisa Apr 27, 2026
3f668b1
Merge branch 'main' into langchain-workflow-type
wrisa Apr 30, 2026
af5deaf
Merge branch 'main' into langchain-workflow-type
wrisa May 1, 2026
3df798a
Merge branch 'open-telemetry:main' into langchain-workflow-type
wrisa May 4, 2026
86d3a64
classify operation
wrisa May 1, 2026
06e438a
add invoke agent
wrisa May 4, 2026
5594a72
fixed errors and added tests
wrisa May 4, 2026
53dbd7b
fixed precommit error
wrisa May 4, 2026
0cd6b28
fixed error
wrisa May 4, 2026
5ede64d
fixed test and example
wrisa May 4, 2026
3844025
removed unused
wrisa May 5, 2026
546eab8
fixed test
wrisa May 5, 2026
e6b9b68
updated changelog
wrisa May 5, 2026
b5d0b3b
Merge branch 'main' into langchain-workflow-type
wrisa May 7, 2026
785f463
Merge branch 'main' into langchain-workflow-type
wrisa May 7, 2026
4f37393
Merge branch 'main' into langchain-workflow-type
wrisa May 7, 2026
8ae8b98
Merge branch 'open-telemetry:main' into langchain-workflow-type
wrisa May 7, 2026
c547813
added SPDX license
wrisa May 7, 2026
02c0f37
Merge branch 'main' into langchain-workflow-type
wrisa May 8, 2026
79f6814
Merge branch 'main' into langchain-workflow-type
wrisa May 11, 2026
3067e5f
input output messages and updated example
wrisa May 12, 2026
0f3dce2
fixed typecheck
wrisa May 12, 2026
debafbe
add cast
wrisa May 12, 2026
6652b43
Merge branch 'main' into langchain-workflow-type
wrisa May 12, 2026
2242323
Merge branch 'main' into langchain-workflow-type
wrisa May 12, 2026
cd9f32e
Merge branch 'main' into langchain-workflow-type
wrisa May 12, 2026
aa15e92
Merge branch 'main' into langchain-workflow-type
wrisa May 12, 2026
1553f2e
Merge branch 'main' into langchain-workflow-type
wrisa May 17, 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
Expand Up @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- Add LangChain workflow span support and refactor LLM invocation
([#4449](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4449))
- Fix compatibility with wrapt 2.x by using positional arguments in `wrap_function_wrapper()` calls
([#4445](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4445))
- Added span support for genAI langchain llm invocation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""
LangGraph StateGraph example with an LLM node.

Similar to the manual example (../manual/main.py) but uses LangGraph's StateGraph
with a node that calls ChatOpenAI. OpenTelemetry LangChain instrumentation traces
the LLM calls made from within the graph node.
"""

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())
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(),
),
]
)
)


class GraphState(TypedDict):
"""State for the graph; messages are accumulated with add_messages."""

messages: Annotated[list, add_messages]


def build_graph(llm: ChatOpenAI):
"""Build a StateGraph with a single LLM node."""

def llm_node(state: GraphState) -> dict:
"""Node that invokes the LLM with the current messages."""
response = llm.invoke(state["messages"])
return {"messages": [response]}

builder = StateGraph(GraphState)
builder.add_node("llm", llm_node)
builder.add_edge(START, "llm")
builder.add_edge("llm", END)
return builder.compile()


def main():
# Set up instrumentation (traces LLM calls from within graph nodes)
LangChainInstrumentor().instrument()

# ChatOpenAI setup
llm = ChatOpenAI(
model="gpt-3.5-turbo",
temperature=0.1,
max_tokens=100,
top_p=0.9,
frequency_penalty=0.5,
presence_penalty=0.5,
stop_sequences=["\n", "Human:", "AI:"],
Comment thread
wrisa marked this conversation as resolved.
Outdated
seed=100,
)

graph = build_graph(llm)
Comment thread
wrisa marked this conversation as resolved.

initial_messages = [
SystemMessage(content="You are a helpful assistant!"),
HumanMessage(content="What is the capital of France?"),
]

result = graph.invoke({"messages": initial_messages})

print("LangGraph output (messages):")
for msg in result.get("messages", []):
print(f" {type(msg).__name__}: {msg.content}")

# Un-instrument after use
LangChainInstrumentor().uninstrument()


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
langchain==0.3.21
langchain_openai
langgraph
opentelemetry-sdk>=1.39.0
opentelemetry-exporter-otlp-proto-grpc>=1.39.0

# Uncomment after langchain instrumentation is released
# opentelemetry-instrumentation-langchain~=2.0b0.dev
Comment thread
wrisa marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"opentelemetry-instrumentation ~= 0.57b0",
"opentelemetry-instrumentation ~= 0.60b0",
"opentelemetry-util-genai >= 0.4b0.dev",
Comment thread
keith-decker marked this conversation as resolved.
Outdated
Comment thread
wrisa marked this conversation as resolved.
Outdated
]

[project.optional-dependencies]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@
_InvocationManager,
)
from opentelemetry.util.genai.handler import TelemetryHandler
from opentelemetry.util.genai.invocation import (
InferenceInvocation,
WorkflowInvocation,
)
from opentelemetry.util.genai.types import (
Error,
InputMessage,
LLMInvocation, # TODO: migrate to InferenceInvocation
MessagePart,
OutputMessage,
Text,
Expand All @@ -45,6 +47,82 @@ 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:
payload = serialized or {}
name_source = (
payload.get("name")
or payload.get("id")
or kwargs.get("name")
or (metadata.get("langgraph_node") if metadata else None)
)
name = str(name_source or "chain")

if parent_run_id is None:
workflow_name_override = (
metadata.get("workflow_name") if metadata else None
)
wf = self._telemetry_handler.start_workflow(
name=workflow_name_override or name
)
self._invocation_manager.add_invocation_state(run_id, None, wf)
return
else:
# TODO: For agent invocation
self._invocation_manager.add_invocation_state(
run_id,
parent_run_id,
None, # type: ignore[arg-type]
)
Comment thread
wrisa marked this conversation as resolved.

Comment thread
wrisa marked this conversation as resolved.
def on_chain_end(

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.

I don't see any content attributes on the workflow span (input/output/instructions) - is it intentional?

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.

Added input and output messages on workflow and invoke agent spans,

invoke_workflow
Screenshot 2026-05-11 at 4 26 54 PM

invoke_agent
Screenshot 2026-05-11 at 4 51 34 PM

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
):
# If the invocation does not exist, we cannot set attributes or end it
return
Comment thread
wrisa marked this conversation as resolved.

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
):
# If the invocation does not exist, we cannot set attributes or end it
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],
Expand Down Expand Up @@ -140,25 +218,22 @@ def on_chat_model_start(
)
)

llm_invocation = LLMInvocation(
llm_invocation = self._telemetry_handler.start_inference(
provider,
request_model=request_model,
input_messages=input_messages,
provider=provider,
top_p=top_p,
frequency_penalty=frequency_penalty,
presence_penalty=presence_penalty,
stop_sequences=stop_sequences,
seed=seed,
temperature=temperature,
max_tokens=max_tokens,
)
llm_invocation = self._telemetry_handler.start_llm(
invocation=llm_invocation
)
llm_invocation.input_messages = input_messages
llm_invocation.top_p = top_p
llm_invocation.frequency_penalty = frequency_penalty
llm_invocation.presence_penalty = presence_penalty
llm_invocation.stop_sequences = stop_sequences
llm_invocation.seed = seed
llm_invocation.temperature = temperature
llm_invocation.max_tokens = max_tokens
self._invocation_manager.add_invocation_state(
run_id=run_id,
parent_run_id=parent_run_id,
invocation=llm_invocation, # pyright: ignore[reportArgumentType]
invocation=llm_invocation,
)

def on_llm_end(
Expand All @@ -172,7 +247,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
Expand Down Expand Up @@ -247,10 +322,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(
Expand All @@ -264,14 +337,11 @@ 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)
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

@dataclass
class _InvocationState:
invocation: GenAIInvocation
invocation: Optional[GenAIInvocation]
Comment thread
wrisa marked this conversation as resolved.
children: List[UUID] = field(default_factory=lambda: list())


Expand Down
Loading
Loading