LangChain
Trace LangChain and LangGraph in Python and TypeScript.
Attach the Neatlogs callback handler to your LangChain/LangGraph calls and LLM calls, chains, tools, and retrievers all become spans — including LangGraph node boundaries and tool loops. A run self-roots, so a single call renders a complete trace with no extra wrapper.
Prerequisites
pip install -U neatlogs langchain-openaiUsage
import os
import neatlogs
neatlogs.init(
api_key=os.environ["NEATLOGS_API_KEY"],
workflow_name="langchain-demo",
)
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o")
# Attach the Neatlogs callback handler to your LangChain calls.
handler = neatlogs.langchain_handler()
print(llm.invoke("In one sentence, what is LangChain?", config={"callbacks": [handler]}).content)
neatlogs.flush()
neatlogs.shutdown()The instrumented run opens a WORKFLOW root automatically — even a bare llm.invoke() renders on its own. To group several calls (or your own @span functions) under one trace, wrap the entry point with a root; see Grouping calls into one trace.
LangGraph
For LangGraph, use the same callback handler — neatlogs.langchain_handler() in Python, langchainHandler() in TypeScript. LangGraph runs on LangChain's callback system, so no LangGraph-specific setup is needed.
LangGraph note (both languages): attach the handler at the graph invocation —
app.invoke(inputs, config={"callbacks": [handler]})in Python,app.invoke(inputs, { callbacks: [handler] })in TypeScript (and likewisestream/ainvoke/astream/astream_events/batch) — not on the per-nodellm.invoke(). LangGraph fires each node'son_chain_startonly on the graph-level callback manager, so a handler passed to a single node's model call never sees the node boundaries: you get no node spans and the LLM span orphans to the workflow root. Attach once at the graph invocation and every node gets its own span with the LLM nested under it. (This is the opposite of plain LangChain / LCEL, where you attach per model/chain call as shown above.)Use one handler instance for the whole run — build it once (
handler = neatlogs.langchain_handler()) and reuse it. Creating a fresh handler per call splits the trace: those LLM spans never see the node and orphan to the root.
Async LangGraph on Python 3.10 needs one extra step. With app.ainvoke() / astream(), a node's own llm.ainvoke() only inherits the graph handler if LangChain can propagate its callback context across the await — which relies on asyncio.create_task(context=...), added in Python 3.11. On Python 3.10 that propagation is lost, so nested LLM/tool/retriever calls fire no callbacks and you get node spans with no LLM spans under them. Two fixes:
- Python 3.11+ — nothing to do; attaching once at
ainvokeis enough. - Python 3.10 — accept the injected
configin each node and thread it into the call:
async def my_node(state, config=None): # LangGraph injects config
resp = await llm.ainvoke(msgs, config=config) # pass it down
return {"messages": [resp]}
# same single handler at the graph:
await app.ainvoke(inputs, config={"callbacks": [handler]})Sync (invoke / stream) is unaffected — callbacks propagate on the same thread regardless of Python version.
Sessions & end-user
Say you're running a chatbot or any back-and-forth flow and want every turn to land under one session. The instrumented run opens a WORKFLOW root automatically, so wrap each turn in identify() and that root — plus every LangChain span beneath it — picks up the session and end-user:
# Same session_id every turn → one session; end_user_id attributes it to your user.
with neatlogs.identify(session_id=f"conv_{conversation_id}", end_user_id=user_id):
llm.invoke(message, config={"callbacks": [handler]})The trick is simply reusing the same session_id across turns so they stitch together into one conversation.
Not every run is a conversation. A standalone workflow — a one-off job, not a back-and-forth — is a single trace with no turns to group. You still attribute it to the customer it ran for, sourcing the id and any metadata from your own user or request object:
A standalone workflow must not carry a session_id. Omit it entirely — Neatlogs sets session_id = trace_id, so the run is its own single-turn session, fully attributed to the end-user. Reusing one session_id across unrelated runs wrongly folds them into a single multi-turn conversation.
def run_chain(document, user):
# Standalone workflow — attribute to the end-user; do NOT pass session_id.
with neatlogs.identify(
end_user_id=str(user.id),
end_user_metadata={"plan": user.plan},
):
return chain.invoke({"document": document}, config={"callbacks": [handler]})For the full picture, head to Sessions and End-User Identity.
