schedule5 min read

Full Agent Observability in Your Own Database with LangChain, LangSmith SDK, and DeepAgents

How to trace every LLM call, tool invocation, and sub-agent in LangChain DeepAgents — and store it all in your own MongoDB instead of LangSmith cloud.

translate
Available in:

Every LangChain agent call produces a tree of runs: the root agent, sub-agents, tool invocations, LLM calls. LangSmith traces this automatically. But what if you need the trace data in your own database?

We built a production system that traces every LLM call in LangChain DeepAgents and stores it in MongoDB — using only official SDK types, no custom BaseTracer subclasses, no field redeclaration. Here’s how.


The Problem

LangSmith cloud gives you tracing out of the box. Set LANGSMITH_TRACING=true, and every model.invoke(), agent.stream(), and tool call is captured.

But we needed:

  1. Data stays in our MongoDB — prompts, responses, business logic never leave our infrastructure
  2. Business context on runs — tenant ID, agent type, entity ID linked to each trace
  3. No custom tracer code — reuse the SDK’s run tree management, metadata inheritance, token extraction
  4. Works with DeepAgents — sub-agents, tool calls, the full tree

The naive approach is subclassing BaseTracer and reimplementing persistRun / _endTrace. This misses dotted_order propagation, metadata inheritance, token extraction, and time-to-first-token tracking. Don’t do this.


The Solution: LangSmithTracingClientInterface

The langsmith SDK defines a 2-method interface for persistence:

interface LangSmithTracingClientInterface {
  createRun(run: RunCreate): Promise<void>;
  updateRun(runId: string, run: RunUpdate): Promise<void>;
}

LangChainTracer from @langchain/core handles everything — run tree management, dotted_order, trace_id propagation, metadata/tag inheritance, token extraction from LLM outputs — and delegates storage to this interface.

By implementing it for MongoDB, you get the full SDK for free.

graph TD
  A["Your agent code"] -->|"callbacks: [tracer]"| B["LangChainTracer"]
  B -->|"run tree management"| C["RunTree"]
  C -->|"flat DTOs"| D["MongoDBLangSmithClient"]
  D -->|"createRun(RunCreate)"| E["MongoDB"]
  D -->|"updateRun(RunUpdate)"| E
  B -->|"metadata inheritance"| F["All child runs get traceId, tags"]
  B -->|"token extraction"| G["usage_metadata on LLM runs"]

Step 1: The MongoDB Client

Implement LangSmithTracingClientInterface. The createRun method receives a flat RunCreate DTO — no circular references, no child_runs tree, no sanitizeForBson. Just insert it.

import type { LangSmithTracingClientInterface } from 'langsmith';
import type { RunCreate, RunUpdate } from 'langsmith/schemas';

class MongoDBLangSmithClient implements LangSmithTracingClientInterface {
  constructor(
    private readonly storage: ObservabilityStorage,
    private readonly businessContext?: { tenantId: ObjectId; entity: ObjectId },
  ) {}

  async createRun(run: RunCreate): Promise<void> {
    const { child_runs, ...doc } = run;
    await this.storage.insertLangChainRun({
      ...doc,
      businessContext: this.businessContext,
    });
  }

  async updateRun(runId: string, run: RunUpdate): Promise<void> {
    await this.storage.updateLangChainRun(runId, run);
  }
}

The businessContext is optional metadata you attach to every run — tenant, agent type, whatever your app needs. It’s not part of LangChain’s model; it’s yours.


Step 2: The Factory Function

Create a factory that returns a standard LangChainTracer backed by your MongoDB client:

import { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';

function createMongoDBTracer(
  traceId: string,
  storage: ObservabilityStorage,
  businessContext?: { tenantId: ObjectId; entity: ObjectId },
  metadata?: Record<string, unknown>,
): LangChainTracer {
  const client = new MongoDBLangSmithClient(storage, businessContext);
  return new LangChainTracer({
    client,
    projectName: 'default',
    metadata: { traceId, ...metadata },
  });
}

Use it anywhere you invoke a LangChain runnable:

const traceId = uuid7(); // time-ordered UUID from langsmith SDK
const tracer = createMongoDBTracer(traceId, storage, {
  tenantId, entityId, agentType: 'sales-assistant',
});

await agent.stream(
  { messages: [{ role: 'user', content: message }] },
  { callbacks: [tracer] },
);

That’s it. Every run in the tree — root agent, sub-agents, LLM calls, tool invocations — is traced with correct dotted_order, trace_id, metadata inheritance, and token counts.


Step 3: The Storage Schema

Extend RunCreate from langsmith/schemas directly. Don’t redeclare its fields:

import type { RunCreate } from 'langsmith/schemas';

interface LangChainRunDoc extends RunCreate {
  _id: ObjectId;
  businessContext?: {
    tenantId: ObjectId;
    entityId: ObjectId;
    agentType?: string;
  };
  createdAt: Date;
}

RunCreate already defines id, name, start_time, run_type, inputs, outputs, extra, tags, events, trace_id, dotted_order, parent_run_id. You add only your business fields.


Step 4: What You Get Automatically

Run Tree Management

LangChainTracer uses RunTree internally. When a run starts, it calls createRun with a flat DTO. When it ends, it calls updateRun with outputs, error, end_time, and extra metadata. You don’t manage the tree — the SDK does.

Metadata Inheritance

Pass metadata to the tracer, and it propagates to all child runs:

const tracer = createMongoDBTracer(traceId, storage, ctx, {
  thread_id: conversationId,  // for thread-level queries
  agentType: 'pricing',
});

Every child run (sub-agents, tools, LLM calls) gets thread_id in its extra.metadata. Query by thread in MongoDB:

db.langchain_runs.find({ 'extra.metadata.thread_id': conversationId })

Token Extraction

LangChainTracer.onLLMEnd extracts usage_metadata from chat model outputs and stores it in run.extra.metadata.usage_metadata. No custom extraction needed.

Time-to-First-Token

For streaming LLM runs, LangChainTracer tracks first_token_time via the RunTree. Your updateRun call receives this in the RunUpdate.


Step 5: Querying

The root run (where parent_run_id is absent) IS the trace session. Query it directly:

// Get all runs for a trace
async findRunsByTraceId(traceId: string) {
  return db.langchain_runs.find({ traceId }).sort({ dotted_order: 1 });
}

// Get root run (the session)
async findRootRun(traceId: string) {
  return db.langchain_runs.findOne({
    traceId,
    parent_run_id: { $exists: false },
  });
}

// Get runs by message ID (from extra.metadata)
async findRunsByMessageId(messageId: string) {
  return db.langchain_runs.find({
    'extra.metadata.message_id': messageId,
  });
}

No joins, no separate session collection. One collection, one query.


Step 6: What’s in the Data

Each document in langchain_runs looks like this:

{
  "_id": "...",
  "id": "run-uuid-7",
  "name": "ChatOpenAI",
  "run_type": "llm",
  "trace_id": "root-run-uuid",
  "parent_run_id": "parent-uuid",
  "dotted_order": "20260722T150322...1234.20260722T150322...5678",
  "start_time": 1753216202000,
  "end_time": 1753216203500,
  "inputs": { "messages": [...] },
  "outputs": { "generations": [...] },
  "extra": {
    "metadata": {
      "ls_provider": "openai",
      "ls_model_name": "gpt-4o",
      "thread_id": "conversation-123",
      "usage_metadata": {
        "input_tokens": 1250,
        "output_tokens": 340,
        "total_tokens": 1590
      }
    }
  },
  "tags": ["sales-assistant", "pricing"],
  "businessContext": {
    "tenantId": "...",
    "entityId": "...",
    "agentType": "pricing"
  },
  "createdAt": "2026-07-22T15:03:22.000Z"
}

LLM config (provider, model) comes from extra.metadata.ls_provider and extra.metadata.ls_model_name — no snapshotting needed.


DeepAgents Integration

DeepAgents builds on LangGraph, which uses LangChain runnables. The same callbacks: [tracer] pattern traces the full deep agent tree:

graph TD
  A["DeepAgent"] -->|"callbacks"| B["LangChainTracer"]
  B --> C["Root run: agent"]
  C --> D["Sub-agent: pricing"]
  C --> E["Sub-agent: scheduling"]
  D --> F["LLM call: ChatOpenAI"]
  D --> G["Tool: calculatePrice"]
  E --> H["LLM call: ChatOpenAI"]
  E --> I["Tool: checkAvailability"]
  F -->|"parent_run_id = D"| J["langchain_runs"]
  G -->|"parent_run_id = D"| J
  H -->|"parent_run_id = E"| J
  I -->|"parent_run_id = E"| J
  C -->|"parent_run_id absent"| J
  J -->|"dotted_order sorts tree"| K["Waterfall rendering"]

Each run has parent_run_id linking to its parent, and dotted_order for hierarchical sorting. The full tree is reconstructable from a single traceId query.


MongoDB Indexes

db.langchain_runs.createIndex({ runId: 1 }, { unique: true });
db.langchain_runs.createIndex({ traceId: 1, dotted_order: 1 });
db.langchain_runs.createIndex({ parent_run_id: 1 });
db.langchain_runs.createIndex({ 'extra.metadata.message_id': 1 }, { sparse: true });

The traceId + dotted_order index covers the most common query: get all runs for a trace in execution order.


What We Deleted

After switching to this approach, we removed:

What Why
Custom BaseTracer subclasses LangChainTracer handles everything
sanitizeForBson function Flat DTOs have no circular refs
Manual dotted_order / trace_id propagation SDK handles it
Separate trace_sessions collection Root run IS the session
LLM config snapshot fields Derived from extra.metadata at query time
13 session CRUD methods Replaced by root-run queries

Common Mistakes

Don’t subclass BaseTracer directly. It’s abstract and misses run tree management. Use LangChainTracer with a custom client.

Don’t store Run objects (with child_runs). They have circular references. LangChainTracer already flattens to RunCreate / RunUpdate DTOs.

Don’t redeclare BaseRun fields in custom types. Extend RunCreate from langsmith/schemas directly.

Don’t snapshot LLM config on every run. The provider and model are in extra.metadata.ls_provider and extra.metadata.ls_model_name. Derive at query time.

Don’t forget thread_id on all runs. Pass it in RunnableConfig.metadata. LangChainTracer propagates it to children. Without it, thread-level queries fail.



Frequently Asked Questions

Why not just use LangSmith cloud for tracing? Data sovereignty. Your LLM prompts, responses, and business logic leave your infrastructure. For regulated industries or competitive sensitivity, keeping trace data in your own database is non-negotiable. You also avoid per-trace pricing at scale.

What is the LangSmithTracingClientInterface? A 2-method interface from the langsmith SDK: createRun and updateRun. LangChainTracer delegates all persistence to this interface. By implementing it for MongoDB, you get the full SDK’s run tree management, metadata inheritance, and token extraction — while storing data locally.

Why extend RunCreate instead of creating custom types? RunCreate from langsmith/schemas is the official LangChain persistence DTO. It’s what LangChainTracer sends to the client. By extending it directly, you eliminate field redeclaration, avoid drift from SDK updates, and get type-safe persistence for free.

Does this work with DeepAgents sub-agents? Yes. DeepAgents builds on LangGraph, which uses LangChain runnables. The same LangChainTracer with callbacks traces the full tree: root agent, sub-agents, tool calls, LLM calls. Each sub-agent’s runs are linked via parent_run_id and dotted_order.

How do you handle thread-level grouping without LangSmith? Pass thread_id in the RunnableConfig metadata. LangChainTracer propagates it to all child runs. Query by thread_id in your MongoDB collection. This is identical to how LangSmith threads work — you just own the storage.


Tags: langchain langsmith deep-agents observability mongodb llm-tracing ai-agents typescript langsmith-tracing-client runcreate langchain-tracer

Found this useful? Share it.

Related articles

Thanks for reading!
Read more articles