How do I trace a document processing pipeline built with LangChain?
Rius traces a LangChain document pipeline by hooking LangChain’s callbacks, so chains, tools, retrievers and model calls arrive as one nested trace. You add a few observe wrappers for the run root and each stage, a retrieval span for any search LangChain does not run, and a session and user scope for the run. Model calls carry tokens, and Rius computes cost from them.
How it works
Once init() runs, the LangChain integration records anything that runs through LangChain’s callback system: LCEL chains, agents and LangGraph nodes. Each step becomes a span typed by what it is. Chains are CHAIN spans, tool invocations are TOOL spans, retrievers are RETRIEVER spans, and model calls are LLM spans with model, token usage and messages. The nesting follows your chain structure. The full setup and blind spots are on the LangChain integration page, and the other integrations are listed under Integrations.
A document pipeline usually has code around the chains: loading a file, splitting it, searching an index, writing results somewhere. LangChain’s callbacks do not see that code, so you wrap it with the tools on the tracing page:
- A run root. An
observewrapper around the function that processes one document gives every stage a common parent, so one document reads as one trace. - A step per stage. Wrap parsing, chunking, extraction and write-back with named
observecalls. A defaultobservemakes a CHAIN span named after the function. - Retrieval you run yourself. A search that does not go through a LangChain retriever gets a RETRIEVER span from
start_as_current_span, with the data source andtop_k.set_retrieved_documents()records ids and scores, not document text. - Your own tool functions. A function that LangChain does not already record as a tool, such as an upload to an archive, uses
observewithkind=SpanKind.TOOL. A bareobservemakes a CHAIN span, which the Tool kind filter in the Spans tab and the Tool Loop alert never see.
Text-completion models (BaseLLM subclasses, such as langchain_openai.OpenAI) are counted twice in tokens and cost. Chat models are counted once.
Set it up
-
Install the LangChain instrumentation and call
init()once at startup. The install page covers this in full; this is the short form:pip install "glassflow-rius[langchain]" langchain -
Wrap the stages and scope the session and user. The
searchfunction,chain,rerank_docs,job_id,requester_idand the other helpers below are your own code. Rius needs no changes to your chains.import rius from rius import SpanKind rius.init(api_key="ri_...", service_name="doc-pipeline") @rius.observe(name="parse_document", capture_output=False) def parse(path: str) -> str: ... @rius.observe(name="extract_fields") def extract(text: str) -> dict: return chain.invoke({"text": text}) # your LangChain chain @rius.observe(name="rerank", kind=SpanKind.RERANKER, capture_output=False) def rerank_docs(docs: list[str]) -> list[str]: ... @rius.observe(kind=SpanKind.TOOL, tool_name="archive_upload") def archive(result: dict) -> str: ... @rius.observe(name="process_document") def process(path: str) -> dict: text = parse(path) with rius.start_as_current_span( kind=SpanKind.RETRIEVER, data_source_id="policy-kb", top_k=5 ) as obs: obs.set_input(text[:500]) docs = search(text) obs.set_retrieved_documents([{"id": d.id, "score": d.score} for d in docs]) result = extract(text) archive(result) return result with rius.session(job_id), rius.user(requester_id): process("contracts/acme.pdf") -
Choose the session. Use your own id, such as a job or batch id, so every document in that unit of work shares a
session.id. With no session scope, Rius groups each trace as its own session. For a script that handles one batch per process,RIUS_SESSION_IDsets one id for the whole process. In a server that handles many requests, scope each request instead. -
Choose the user.
rius.userstampsuser.idon every span in scope, including the spans the LangChain integration creates. Pass an opaque id rather than an email. Outside a user scope, spans are anonymous. There is no environment variable for the user. -
In TypeScript, install
@arizeai/openinference-instrumentation-langchain, wrap each stage withobservearound a named function, and usewithSessionandwithUserthe same way. A short script awaitsrius.shutdown()before it exits. -
Keep large documents out of span content where you do not need them.
capture_input=Falseandcapture_output=Falseskip the arguments and return value of a stage, and captured values are truncated at 32 KB.
What you get
- One trace per document, with the run root on top, a span per stage beneath it, and your LangChain chains, retrievers and model calls nested inside the stage that called them.
- Tokens on each model call, and a cost per call that Rius computes. Never send a cost value yourself.
- Retrieval spans that show which source was searched, how many documents were requested, and the ids and scores that came back.
- Sessions that group all documents of one job, and a Users view by the
user.idyou set. - Failures that land on the stage that raised them, because exceptions are recorded on the span with ERROR status and re-raised.
To check, run one document through, open the trace in the console, and confirm that your stages appear as nested spans with the model call carrying model, tokens and cost.
When something else fits
If your pipeline is a single chain with no code around it, the integration alone is enough and you need no wrappers. If you call a model outside LangChain, the tracing page covers manual generations. Turn off the matching integration first, or the call is counted twice.