-
Notifications
You must be signed in to change notification settings - Fork 931
Add LangChain workflow span support and refactor LLM invocation #4449
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
912b784
9d4876f
1ce4cf5
e9fff12
6e34620
c17607d
a63f271
e8be641
a9dc188
3605631
4b7808f
e6d9f03
a503105
dc068fb
2ee7792
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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:"], | ||
| seed=100, | ||
| ) | ||
|
|
||
| graph = build_graph(llm) | ||
|
|
||
| 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 on lines
+7
to
+8
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can delete this if not needed anymore |
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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] | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
wrisa marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+79
to
+86
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| else: | |
| # TODO: For agent invocation | |
| self._invocation_manager.add_invocation_state( | |
| run_id, | |
| parent_run_id, | |
| None, # type: ignore[arg-type] | |
| ) | |
| parent_invocation = self._invocation_manager.get_invocation( | |
| run_id=parent_run_id | |
| ) | |
| if parent_invocation is None or not isinstance( | |
| parent_invocation, WorkflowInvocation | |
| ): | |
| # Do not record nested chain state when the parent is unknown or | |
| # not a workflow; otherwise we can create orphaned entries that | |
| # on_chain_end/on_chain_error cannot clean up. | |
| return | |
| # TODO: For agent invocation | |
| self._invocation_manager.add_invocation_state( | |
| run_id, | |
| parent_run_id, | |
| None, # type: ignore[arg-type] | |
| ) |
Copilot
AI
Apr 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nested chains are recorded in the invocation manager with invocation=None, but on_chain_end() returns early when the invocation is missing/non-WorkflowInvocation. If parent_run_id is not present in the manager (e.g., out-of-order callbacks or partial instrumentation), this creates orphaned entries that will never be cleaned up. Consider deleting the invocation state on on_chain_end() / on_chain_error() when get_invocation() returns None (or when it’s not a WorkflowInvocation), or avoid storing state at all when the parent is unknown.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,7 @@ | |
|
|
||
| @dataclass | ||
| class _InvocationState: | ||
| invocation: GenAIInvocation | ||
| invocation: Optional[GenAIInvocation] | ||
|
||
| children: List[UUID] = field(default_factory=lambda: list()) | ||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ChatOpenAItypically expectsstop(notstop_sequences) for stop tokens; using an unsupported constructor kwarg will raise at runtime and make the example fail. Update the example to use the correct parameter name(s) supported bylangchain_openai.ChatOpenAIfor the pinnedlangchain==0.3.21.