LangSmith Integration Without LangChain: Using the SDK Directly
There is a persistent myth in the LLM engineering world that LangSmith is a LangChain accessory — that if you did not build your application on chains, runnables, and LCEL, the observability platform is off-limits to you. It is completely wrong, and believing it costs teams real debugging power. LangSmith ships a standalone SDK for Python and TypeScript that works with raw OpenAI calls, raw Anthropic calls, plain functions, FastAPI handlers, background workers — anything. If your code makes an LLM call, you can trace it. In this guide, we will set up LangSmith without LangChain from scratch: environment configuration, the @traceable decorator, the wrap_openai client wrapper, nested trace trees, custom metadata, async and streaming support, manual RunTree construction for full control, and running evaluations against datasets — all with nothing but the langsmith package and your existing application code.
Why Use LangSmith Without LangChain at All
Let us address the obvious question first: if LangSmith comes from the LangChain team, why would you deliberately skip the framework?
The honest answer is that many production LLM applications are not built on LangChain, and for defensible reasons. Teams often prefer calling the OpenAI or Anthropic SDK directly because the abstraction layer is thinner, the failure modes are easier to reason about, and there are fewer dependencies to pin and audit. Some teams started with LangChain, hit a debugging wall inside deeply nested runnables, and rewrote their pipeline as plain Python. Others are integrating LLM calls into an existing codebase — a Django monolith, a Go service calling a Python sidecar, a data pipeline — where adopting a whole orchestration framework would be architectural overkill for what amounts to three prompt templates and a retry loop.
None of those decisions should lock you out of observability. And observability is not optional once you are past the demo stage. LLM applications fail in ways traditional software does not: the code path succeeds, the HTTP call returns 200, and the output is still wrong. Without traces, you are debugging by adding print statements around prompts and squinting at terminal output. With traces, you get a searchable, shareable record of every input, output, token count, latency measurement, and error — organized as a tree that mirrors your call stack.
The key insight is that LangSmith's tracing model is framework-agnostic by design. A trace is just a tree of runs. A run is just a named unit of work with inputs, outputs, timing, and a type (llm, chain, tool, retriever, and so on). LangChain happens to emit these runs automatically through its callback system, but the SDK exposes the exact same primitives to your own code. Everything the framework does, you can do with a decorator and a wrapper function — and you will understand your traces better because you named every span yourself.
Installation and Environment Setup
The entire integration starts with one package. Note that you install langsmith, not langchain — the tracing SDK has no dependency on the framework.
pip install langsmith
pip install openai # or anthropic, or whichever provider SDK you useNext, create an API key from the LangSmith settings page and export the environment variables that control tracing:
export LANGSMITH_TRACING=true
export LANGSMITH_API_KEY="lsv2_pt_your_key_here"
export LANGSMITH_PROJECT="my-app-production"Three things worth knowing about these variables:
LANGSMITH_TRACING=trueis the master switch. When it is unset or false, all the decorators and wrappers you are about to add become near-zero-cost no-ops. This means you can leave instrumentation in your code permanently and toggle tracing per environment — on in staging, sampled in production, off in unit tests.LANGSMITH_PROJECTnames the bucket your traces land in. If you skip it, everything goes to a project calleddefault, which gets messy fast. Use one project per service per environment, such assupport-bot-stagingandsupport-bot-prod.- You may see older tutorials using
LANGCHAIN_TRACING_V2andLANGCHAIN_API_KEY. Those legacy names still work, but theLANGSMITH_-prefixed versions are the current convention and make it clearer that no LangChain is involved.
If you are on a self-hosted or EU instance, also set LANGSMITH_ENDPOINT to point at your deployment. That is the entire setup — no callback managers, no handlers, no framework initialization. Your reward for those four lines is that every traced function in your process now reports home automatically.
Tracing OpenAI Calls with wrap_openai
The fastest way to get value is wrap_openai, which takes your existing OpenAI client and returns a proxied version that logs every completion as an LLM run. Your call sites do not change at all — same methods, same arguments, same return types.
from openai import OpenAI
from langsmith.wrappers import wrap_openai
client = wrap_openai(OpenAI())
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a concise technical assistant."},
{"role": "user", "content": "Explain idempotency in one paragraph."},
],
temperature=0.2,
)
print(response.choices[0].message.content)Run this with tracing enabled and open your LangSmith project. You will find a run containing the full message array that was sent, the completion that came back, the model name, the temperature, latency, and token usage split into prompt and completion tokens. Because token counts are captured, LangSmith can also compute cost estimates per call and aggregate them per project — which turns the perennial "how much is this feature costing us" question into a dashboard glance instead of a spreadsheet archaeology session.
The wrapper understands the OpenAI SDK deeply. It handles chat.completions.create, the newer responses API, tool calls, and streaming. When you stream with stream=True, the wrapper collects the chunks and logs the assembled completion once the stream finishes, along with time-to-first-token — a metric you badly want when users are staring at a blinking cursor.
If you use Anthropic instead, the pattern is identical with wrap_anthropic:
from anthropic import Anthropic
from langsmith.wrappers import wrap_anthropic
anthropic_client = wrap_anthropic(Anthropic())One wrapped client on its own gives you flat, isolated LLM runs. That is already useful, but the real power arrives when those runs nest inside a larger trace — which is what the decorator is for.
The @traceable Decorator: Turning Functions into Spans
@traceable is the workhorse of LangSmith without LangChain. Slap it on any function and every invocation becomes a run: arguments become inputs, the return value becomes outputs, exceptions get recorded with stack traces, and duration is measured automatically. Crucially, traceable functions know about each other — when one traced function calls another, the child nests under the parent, and wrapped clients called inside a traceable function attach their LLM runs to the active trace.
Here is a compact but realistic retrieval-augmented pipeline, traced end to end without a single framework import:
from langsmith import traceable
from langsmith.wrappers import wrap_openai
from openai import OpenAI
client = wrap_openai(OpenAI())
@traceable(run_type="retriever", name="pinecone-search")
def retrieve_docs(query: str, top_k: int = 4) -> list[dict]:
# your real vector store call goes here
results = vector_index.query(embed(query), top_k=top_k)
return [
{"page_content": r.text, "metadata": {"source": r.source_id}}
for r in results
]
@traceable(run_type="tool", name="format-context")
def build_context(docs: list[dict]) -> str:
return "\n\n".join(
f"[{d['metadata']['source']}] {d['page_content']}" for d in docs
)
@traceable(run_type="chain", name="answer-question")
def answer_question(question: str) -> str:
docs = retrieve_docs(question)
context = build_context(docs)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system",
"content": f"Answer using only this context:\n{context}"},
{"role": "user", "content": question},
],
)
return response.choices[0].message.content
answer_question("What is our refund window for annual plans?")Call answer_question once and LangSmith shows a single trace with four runs in a tree: the chain at the root, then the retriever, the formatting tool, and the LLM call as children, each with its own inputs, outputs, and timing. When an answer is wrong, you no longer guess whether retrieval fetched the wrong documents or the prompt mangled good ones — you click into the retriever run and look.
A few decorator details that matter in practice:
run_typecontrols how LangSmith renders and filters the run. Usellmfor model calls,retrieverfor anything fetching documents (the list-of-dicts shape above gets pretty document rendering in the UI),toolfor side-effecting utilities, andchain(the default) for orchestration logic.nameoverrides the function name in the UI. Stable, human-readable names make saved filters and dashboards far more pleasant.- Exceptions propagate normally — the decorator never swallows errors. The failed run is marked with error status and the traceback, so production incidents come with their own crime-scene photos.
- Return values and arguments must be serializable to be useful in the UI. For fat objects, consider returning a trimmed dict rather than dumping a 2 MB blob into every trace.
Nesting, Metadata, and Runtime Context
Static decoration covers structure, but production debugging lives on context: which user, which tenant, which prompt version, which experiment arm. The SDK gives you several ways to attach that.
You can bake static metadata and tags into the decorator itself:
@traceable(
run_type="chain",
name="summarize-ticket",
tags=["summarization", "v2-prompt"],
metadata={"prompt_version": "2.3.1"},
)
def summarize_ticket(ticket_text: str) -> str:
...More often you need per-call values, which is what the reserved langsmith_extra keyword argument is for. Any traceable function silently accepts it:
summarize_ticket(
ticket.body,
langsmith_extra={
"metadata": {"user_id": user.id, "plan": user.plan},
"tags": [f"region:{user.region}"],
},
)In the LangSmith UI you can then filter traces to plan = enterprise with errors in the last hour, or compare latency distributions between two prompt versions using tag filters. This is the difference between observability and mere logging: the attributes you attach become queryable dimensions.
Sometimes you want to trace a block of code that is not a function, or wrap a section of a legacy handler without refactoring. The trace context manager handles that:
from langsmith import trace
with trace(name="bulk-import", run_type="chain",
inputs={"file": filename}) as run:
rows = parse_file(filename)
enriched = [enrich_row(r) for r in rows] # traceable children nest here
run.end(outputs={"rows_processed": len(enriched)})And when you need to inspect or mutate the current run from deep inside the call stack — say, to record the run ID for later feedback — get_current_run_tree gives you a handle:
from langsmith import get_current_run_tree
@traceable
def answer(question: str) -> dict:
run = get_current_run_tree()
result = generate_answer(question)
return {"answer": result, "trace_id": str(run.trace_id)}Returning the trace ID to your frontend is a pattern worth stealing: when a user clicks the thumbs-down button, your feedback endpoint knows exactly which trace to annotate.
Async, Streaming, and Generators
Modern LLM backends are async and streaming, and the SDK meets you there without ceremony. @traceable works unchanged on coroutines:
from openai import AsyncOpenAI
from langsmith import traceable
from langsmith.wrappers import wrap_openai
aclient = wrap_openai(AsyncOpenAI())
@traceable(run_type="chain", name="async-answer")
async def answer_async(question: str) -> str:
response = await aclient.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": question}],
)
return response.choices[0].message.contentParent-child relationships survive asyncio.gather, so a fan-out that fires five model calls concurrently shows up as five parallel children under one parent, each with independent timing. That visualization alone has settled more than one argument about whether "we parallelized it" actually happened.
Generator functions are also first-class. If your handler yields tokens to the client, decorate it and LangSmith will aggregate everything yielded into the run's output once the generator is exhausted:
@traceable(run_type="llm", name="stream-tokens")
def stream_answer(question: str):
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": question}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
yield deltaAsync generators work the same way. The one operational caveat: in short-lived environments like AWS Lambda or Cloud Run jobs, the process can exit before the background trace uploader finishes its batch. Traces are sent asynchronously precisely so they never block your request path, so in serverless code you should flush explicitly before returning:
from langsmith import Client
ls_client = Client()
# ... traced work ...
ls_client.flush()For high-traffic services where tracing every request is unnecessary, set LANGSMITH_TRACING_SAMPLING_RATE to a value like 0.1 and the SDK will keep one trace in ten, applied at the trace level so you never get orphaned child runs.
Manual Control with RunTree
Decorators cover the overwhelming majority of use cases, but occasionally you need to construct runs by hand — stitching a trace across process boundaries, instrumenting code you cannot decorate, or building tracing into your own framework. RunTree is the low-level primitive everything else is built on:
from langsmith.run_trees import RunTree
pipeline = RunTree(
name="etl-classification",
run_type="chain",
inputs={"batch_id": batch_id},
)
pipeline.post() # send the opening state
llm_child = pipeline.create_child(
name="classify-record",
run_type="llm",
inputs={"messages": messages},
)
llm_child.post()
result = raw_openai_call(messages)
llm_child.end(outputs={"choices": [result]})
llm_child.patch() # update with outputs
pipeline.end(outputs={"status": "complete"})
pipeline.patch()Each run posts its creation and patches its completion, which means long-running work appears in the UI as pending while it executes — useful for jobs measured in minutes rather than milliseconds. RunTree also serializes headers for distributed tracing, so a parent run in your API gateway can hand its context to a worker process and the worker's runs will nest under it in a single coherent trace. You will rarely need this layer, but knowing it exists demystifies the decorator: @traceable is essentially automated RunTree bookkeeping tied to your call stack.
Evaluations and Feedback Without the Framework
Tracing tells you what happened; evaluation tells you whether it was any good. Both work framework-free. The Client manages datasets, and the evaluate entry point runs your plain function against them:
from langsmith import Client, evaluate
client = Client()
dataset = client.create_dataset("refund-questions")
client.create_examples(
dataset_id=dataset.id,
inputs=[{"question": "Can I get a refund after 30 days?"},
{"question": "Do annual plans have a trial period?"}],
outputs=[{"answer": "Refunds are available within 14 days."},
{"answer": "Annual plans include a 7-day trial."}],
)
def contains_expected(outputs: dict, reference_outputs: dict) -> bool:
return reference_outputs["answer"].split()[0].lower() \
in outputs["output"].lower()
results = evaluate(
lambda inputs: answer_question(inputs["question"]),
data="refund-questions",
evaluators=[contains_expected],
experiment_prefix="v2-prompt",
)Every evaluated example produces its own full trace, so a failing eval is one click away from the exact retrieval and prompt that produced the bad answer. Run the same dataset against a new prompt version with a different experiment_prefix and LangSmith renders a side-by-side comparison — a regression harness for behavior, not just code.
Production feedback closes the loop. Remember the trace ID we returned to the frontend earlier? Attach user reactions to it:
client.create_feedback(
run_id=trace_id,
key="user-rating",
score=0,
comment="Answer cited the wrong policy document.",
)Filter your project to traces with negative feedback, add the interesting ones to a dataset, and your evaluation suite grows out of real failures instead of imagined ones. That flywheel — trace, collect feedback, curate datasets, evaluate, ship — is the actual product, and none of it ever required a chain.
Common Pitfalls When Going Framework-Free
A few sharp edges show up repeatedly in teams adopting langsmith without langchain, and all of them are avoidable:
- Tracing silently off. If
LANGSMITH_TRACINGis unset in a container or CI environment, everything runs fine and nothing is recorded. Verify with a one-line smoke test at deploy time rather than discovering the gap during an incident. - Broken trace trees. If a plain, undecorated function sits between two traceable ones and spawns threads or new event loops, context propagation can break and children appear as separate root traces. Decorate the intermediate layer or pass the parent explicitly through
langsmith_extra={"parent": run}. - Oversized payloads. Dumping entire PDFs or base64 images into inputs bloats traces and slows the UI. Log references (IDs, URLs, hashes) for heavy artifacts and keep the semantic content.
- Secrets in traces. Inputs are recorded verbatim. Scrub API keys, PII, and tokens before they enter a traced function, or configure hide/anonymize hooks on the client. Compliance reviews go much better when you did this on day one.
- Forgetting to flush in serverless. Covered above, but it is the single most common "my traces are missing" report.
client.flush()before the handler returns. - One giant default project. Split projects by service and environment early; retrofitting filters onto six months of mixed traces is miserable.
Wrapping Up: Observability Is a Decorator Away
The framing to keep is this: LangChain is one client of LangSmith's tracing API, and your application can be another. With wrap_openai you instrument every model call in one line. With @traceable you shape traces around your own architecture instead of someone else's abstractions. With langsmith_extra, tags, and metadata you make traces queryable along the dimensions your on-call engineer actually cares about. With RunTree you can extend tracing anywhere the decorator cannot reach, and with datasets, evaluate, and feedback you turn those traces into a regression suite that guards every prompt change. None of it asked you to restructure your code, adopt runnables, or add a framework dependency you did not want.
If you want to go deeper — trace filtering strategies, LLM-as-judge evaluators, prompt experiments, annotation queues, and production monitoring dashboards built on exactly the SDK patterns covered here — the LangSmith Tutorial course on teachyou.ai walks through all of it with hands-on projects, taking you from your first traced function to a full evaluation-driven development workflow. Instrument one function today, look at the trace it produces, and you will wonder how you ever debugged prompts without it.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.
Related reading