Skip to content

Commit 460a978

Browse files
authored
Fix/langgraph manual instrumentation context (#261)
fix: use run_inline=True to fix ContextVar propagation in LangGraph async nodes
1 parent 1db45f0 commit 460a978

7 files changed

Lines changed: 582 additions & 42 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,16 @@ VS Code launch configurations are in `.vscode/launch.json` for debugging example
223223
- Always keep backward compatibility in mind when refactoring existing
224224
- Follow DRY and SOLID software engineering principles when readability and maintainability is not compromised.
225225

226+
## Git Commit Rules
227+
228+
- **Never add `Co-Authored-By` trailers** (or any similar attribution trailers such as `Co-authored-by`, `Signed-off-by`, etc.) that reference AI assistants, bots, or automated tools in commit messages. This includes but is not limited to Claude, Copilot, ChatGPT, or any `noreply@` addresses from AI vendors.
229+
- Commit messages should only attribute human contributors.
230+
226231
## Common Pitfalls to Avoid
227232

228233
- Do not try to mock libraries if import in the current env fail. If in doubt - clearly communicate the problem to user
229234
- Always refer to `README.md` and `README.packages.architecture.md` for context.
230235
- avoid creating multiple copies of example apps, when can introduce parameters and reuse the same demo app
236+
- **Do not use `try/except` guards around test imports.** Test dependencies must be declared in `pyproject.toml` `[project.optional-dependencies] test` and are expected to be present at test time. If they are missing, the test should fail with an `ImportError`, not silently skip.
237+
- **Do not use `sys.path` hacks** (e.g., inserting `src/` into `sys.path`) in test files. Packages should be installed in editable mode (`pip install -e .`) and imports should work without path manipulation.
238+
- **Do not use `pytest.mark.skipif` to guard against missing test dependencies.** If a dependency is required for a test, add it to the test requirements and import it directly.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
#!/usr/bin/env python3
2+
"""LangGraph + manual handler instrumentation example.
3+
4+
Demonstrates combining auto-instrumented LangGraph agent spans with manual
5+
handler.start_tool_call() / handler.start_step() calls inside async node
6+
bodies. All spans end up on the same trace with correct parent-child
7+
relationships.
8+
9+
Usage:
10+
# Start an OTel collector on localhost:4317, then:
11+
export OTEL_SERVICE_NAME=langgraph-manual-demo
12+
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
13+
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
14+
export OTEL_INSTRUMENTATION_GENAI_EMITTERS=span_metric_event
15+
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
16+
python langgraph_manual_tool_calls.py
17+
"""
18+
19+
import asyncio
20+
21+
from opentelemetry import _events, _logs, metrics, trace
22+
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
23+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
24+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
25+
from opentelemetry.sdk._events import EventLoggerProvider
26+
from opentelemetry.sdk._logs import LoggerProvider
27+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
28+
from opentelemetry.sdk.metrics import MeterProvider
29+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
30+
from opentelemetry.sdk.trace import TracerProvider
31+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
32+
33+
34+
def configure_instrumentation():
35+
"""Set up OTel providers and SDOT instrumentors."""
36+
trace.set_tracer_provider(TracerProvider())
37+
trace.get_tracer_provider().add_span_processor(
38+
BatchSpanProcessor(OTLPSpanExporter())
39+
)
40+
reader = PeriodicExportingMetricReader(OTLPMetricExporter())
41+
metrics.set_meter_provider(MeterProvider(metric_readers=[reader]))
42+
_logs.set_logger_provider(LoggerProvider())
43+
_logs.get_logger_provider().add_log_record_processor(
44+
BatchLogRecordProcessor(OTLPLogExporter())
45+
)
46+
_events.set_event_logger_provider(EventLoggerProvider())
47+
48+
from opentelemetry.instrumentation.langchain import LangchainInstrumentor
49+
50+
LangchainInstrumentor().instrument()
51+
52+
53+
# Must be called before importing LangGraph / LangChain
54+
configure_instrumentation()
55+
56+
from typing import TypedDict # noqa: E402
57+
58+
from langgraph.graph import StateGraph # noqa: E402
59+
from opentelemetry.util.genai.handler import get_telemetry_handler # noqa: E402
60+
from opentelemetry.util.genai.types import ToolCall, Step # noqa: E402
61+
62+
63+
class State(TypedDict):
64+
query: str
65+
result: str
66+
67+
68+
# -- Async node functions with manual tool/step instrumentation --
69+
70+
71+
async def lookup_node(state: State) -> State:
72+
"""Async node: looks up data using a manual ToolCall span."""
73+
handler = get_telemetry_handler()
74+
75+
# Create a manual ToolCall — the handler automatically parents it
76+
# to the current agent span (even inside LangGraph async nodes).
77+
tool = ToolCall(
78+
name="database_lookup",
79+
tool_type="datastore",
80+
tool_description="Look up customer record in database",
81+
arguments={"query": state["query"]},
82+
agent_name="lookup-agent",
83+
)
84+
handler.start_tool_call(tool)
85+
86+
# Simulate database query
87+
await asyncio.sleep(0.05)
88+
result = f"Found record for: {state['query']}"
89+
90+
tool.tool_result = result
91+
handler.stop_tool_call(tool)
92+
93+
return {"query": state["query"], "result": result}
94+
95+
96+
async def analysis_node(state: State) -> State:
97+
"""Async node: analyses data using a manual Step span."""
98+
handler = get_telemetry_handler()
99+
100+
step = Step(
101+
name="sentiment_analysis",
102+
objective="Analyse sentiment of the query",
103+
step_type="processing",
104+
source="agent",
105+
assigned_agent="analysis-agent",
106+
)
107+
handler.start_step(step)
108+
109+
await asyncio.sleep(0.03)
110+
analysis = f"Analysis complete for: {state['result']}"
111+
112+
handler.stop_step(step)
113+
114+
return {"query": state["query"], "result": analysis}
115+
116+
117+
async def main():
118+
builder = StateGraph(State)
119+
120+
# metadata={"agent_name": ...} makes the auto-instrumented span carry
121+
# gen_ai.agent.name, and enables the handler's agent context stack.
122+
builder.add_node("lookup", lookup_node, metadata={"agent_name": "lookup-agent"})
123+
builder.add_node(
124+
"analysis", analysis_node, metadata={"agent_name": "analysis-agent"}
125+
)
126+
builder.add_edge("__start__", "lookup")
127+
builder.add_edge("lookup", "analysis")
128+
builder.add_edge("analysis", "__end__")
129+
130+
graph = builder.compile()
131+
132+
result = await graph.ainvoke({"query": "customer 42 order status", "result": ""})
133+
print(f"Result: {result['result']}")
134+
135+
136+
if __name__ == "__main__":
137+
try:
138+
asyncio.run(main())
139+
finally:
140+
# Flush all providers before exit
141+
try:
142+
trace.get_tracer_provider().force_flush(timeout_millis=5000)
143+
except Exception:
144+
pass
145+
try:
146+
metrics.get_meter_provider().force_flush(timeout_millis=5000)
147+
except Exception:
148+
pass
149+
try:
150+
_logs.get_logger_provider().force_flush(timeout_millis=5000)
151+
except Exception:
152+
pass

instrumentation-genai/opentelemetry-instrumentation-langchain/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ instruments = ["langchain >= 0.3.21"]
3535
test = [
3636
"langchain-core >= 1.0.0",
3737
"langchain-openai >= 1.0.0",
38+
"langgraph >= 0.4.0",
39+
"opentelemetry-sdk >= 1.20.0",
40+
"pytest-asyncio >= 0.23.0",
3841
"pytest-recording >= 0.13.0",
3942
"vcrpy >= 7.0.0",
4043
"pyyaml >= 6.0.0",

instrumentation-genai/opentelemetry-instrumentation-langchain/src/opentelemetry/instrumentation/langchain/callback_handler.py

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -131,23 +131,40 @@ def _make_command_input_message(command: Any) -> list[InputMessage]:
131131
return [InputMessage(role="user", parts=[Text(_safe_str(command))])]
132132

133133

134-
def _make_input_message(data: dict[str, Any]) -> list[InputMessage]:
134+
def _make_input_message(data: Any) -> list[InputMessage]:
135135
"""Create structured input message with full data as JSON."""
136+
if not isinstance(data, dict):
137+
return []
136138
input_messages: list[InputMessage] = []
137139
messages = data.get("messages")
138-
if messages is None:
139-
return []
140-
for msg in messages:
141-
content = getattr(msg, "content", "")
142-
if content:
143-
# TODO: for invoke_agent type invocation, when system_messages is added, can filter SystemMessage separately if needed and only add here HumanMessage, currently all messages are added
144-
input_message = InputMessage(role="user", parts=[Text(_safe_str(content))])
145-
input_messages.append(input_message)
140+
if messages is not None:
141+
for msg in messages:
142+
content = getattr(msg, "content", "")
143+
if content:
144+
# TODO: for invoke_agent type invocation, when system_messages is added, can filter SystemMessage separately if needed and only add here HumanMessage, currently all messages are added
145+
input_message = InputMessage(
146+
role="user", parts=[Text(_safe_str(content))]
147+
)
148+
input_messages.append(input_message)
149+
return input_messages
150+
# Fallback: serialize non-message state fields as input.
151+
# Common in LangGraph where nodes use structured state fields
152+
# (e.g., user_query) rather than a message list.
153+
exclude_keys = {"messages", "intermediate_steps"}
154+
input_data = {
155+
k: v for k, v in data.items() if k not in exclude_keys and v is not None
156+
}
157+
if input_data:
158+
serialized = _serialize(input_data)
159+
if serialized:
160+
return [InputMessage(role="user", parts=[Text(serialized)])]
146161
return input_messages
147162

148163

149-
def _make_output_message(data: dict[str, Any]) -> list[OutputMessage]:
164+
def _make_output_message(data: Any) -> list[OutputMessage]:
150165
"""Create structured output message with full data as JSON."""
166+
if not isinstance(data, dict):
167+
return []
151168
output_messages: list[OutputMessage] = []
152169
messages = data.get("messages")
153170
if messages is None:
@@ -176,14 +193,14 @@ def _make_last_output_message(data: dict[str, Any]) -> list[OutputMessage]:
176193
return []
177194

178195

179-
def _make_workflow_output_fallback(data: dict[str, Any]) -> Optional[str]:
196+
def _make_workflow_output_fallback(data: Any) -> Optional[str]:
180197
"""Create output summary from non-message state fields.
181198
182199
Fallback for when workflow output doesn't contain AI messages.
183200
This is common in LangGraph where agent nodes update structured
184201
state fields rather than the message list.
185202
"""
186-
if not data:
203+
if not isinstance(data, dict):
187204
return None
188205
# Exclude messages and internal fields that don't represent output
189206
exclude_keys = {"messages", "intermediate_steps"}
@@ -356,6 +373,14 @@ def remove(self, run_id: UUID) -> None:
356373

357374

358375
class LangchainCallbackHandler(BaseCallbackHandler):
376+
# Run callback methods directly in the caller's context instead of
377+
# dispatching to a thread pool via copy_context().run(). Without this,
378+
# LangChain Core's callback manager isolates each handler invocation in
379+
# a copied context, making ContextVar modifications (e.g. the
380+
# _current_genai_span set by _push_current_span) invisible to the node
381+
# body where user code may call handler.start_tool_call() manually.
382+
run_inline = True
383+
359384
def __init__(
360385
self,
361386
telemetry_handler: Optional[TelemetryHandler] = None,

instrumentation-genai/opentelemetry-instrumentation-langchain/tests/test_langgraph_interrupt_resume.py

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,20 @@
77
from __future__ import annotations
88

99
import os
10-
import sys
11-
from pathlib import Path
1210
from typing import TypedDict
1311

1412
import pytest
13+
from langgraph.checkpoint.memory import MemorySaver
14+
from langgraph.graph import StateGraph
15+
from langgraph.types import Command, interrupt
16+
from opentelemetry.sdk.trace import TracerProvider
17+
from opentelemetry.sdk.trace.export import (
18+
SimpleSpanProcessor,
19+
SpanExporter,
20+
SpanExportResult,
21+
)
1522

16-
_PACKAGE_SRC = Path(__file__).resolve().parents[1] / "src"
17-
if _PACKAGE_SRC.exists():
18-
sys.path.insert(0, str(_PACKAGE_SRC))
19-
20-
try:
21-
from langgraph.graph import StateGraph
22-
from langgraph.types import Command, interrupt
23-
from langgraph.checkpoint.memory import MemorySaver
24-
except ModuleNotFoundError:
25-
StateGraph = None # type: ignore[assignment]
26-
27-
try:
28-
from opentelemetry.sdk.trace import TracerProvider
29-
from opentelemetry.sdk.trace.export import (
30-
SimpleSpanProcessor,
31-
SpanExporter,
32-
SpanExportResult,
33-
)
34-
except ModuleNotFoundError:
35-
TracerProvider = None # type: ignore[assignment]
36-
37-
from opentelemetry.instrumentation.langchain import LangchainInstrumentor # noqa: E402
38-
39-
LANGGRAPH_AVAILABLE = StateGraph is not None
40-
OTEL_SDK_AVAILABLE = TracerProvider is not None
41-
DEPS_AVAILABLE = LANGGRAPH_AVAILABLE and OTEL_SDK_AVAILABLE
23+
from opentelemetry.instrumentation.langchain import LangchainInstrumentor
4224

4325

4426
class _CollectingExporter(SpanExporter):
@@ -126,7 +108,6 @@ def instrumented_graph():
126108
_handler_mod.TelemetryHandler._reset_for_testing()
127109

128110

129-
@pytest.mark.skipif(not DEPS_AVAILABLE, reason="langgraph or otel sdk not available")
130111
class TestLangGraphInterruptResume:
131112
"""End-to-end tests for interrupt/resume with real LangGraph execution."""
132113

0 commit comments

Comments
 (0)