Building AI Agents with Open Source Models
Track 6: Quality & Observability OS Track · Module 17 of 20
⏱ 80 min 📊 Intermediate
🔎
Open Source Track — M19 LangSmith's @traceable decorator works with any OpenAI-compatible client — including Ollama. Your model:"mistral" appears in the LangSmith dashboard exactly like cloud model runs. Filter by model name to separate local Ollama traces from any cloud runs you may be testing alongside them.

M19: Tracing & Logging

Your agent works — until it doesn't. And when it breaks at 2 AM on a 50,000-record batch job, the difference between a 10-minute fix and a 4-hour archaeology session is whether you built structured tracing in from the start. This module gives you the full toolkit: a dataclass schema for capturing every agent event, structured JSON logging with structlog/pino, LangSmith traces for your Ollama-backed agent, and OpenTelemetry spans exportable to Jaeger.

Learning Objectives

  • Understand why structured traces beat print() debugging by 10x in time-to-resolution
  • Define the four trace categories: tool calls, LLM turns, agent loop iterations, and errors
  • Implement a Python dataclass that captures all four categories in a single trace event
  • Configure structlog for JSON-line output and wrap an agent with structured event emission
  • Connect a local Ollama-backed agent to LangSmith using @traceable and the OpenAI-compatible client
  • Set up OpenTelemetry with a Jaeger backend for span-based distributed tracing
  • Build a zero-dependency @trace_agent decorator that logs to a JSON Lines file
  • Visualize trace files with a CLI pretty-printer and Pandas DataFrame loader
  • Instrument the Capstone C3 entity resolution agent end-to-end with LangSmith

Why Tracing Matters

The Flight Data Recorder Analogy

Before the pain: Commercial aircraft have two black boxes that record every sensor reading, control input, and system state at 25 times per second — altitude, engine thrust, control surface angles, hydraulic pressure, cockpit audio. This data is always running, always capturing, regardless of whether anything goes wrong.

The pain without it: Imagine if instead of black boxes, pilots simply described what happened after a crash from memory: "Something felt off around 30,000 feet and then everything went sideways." Investigators would have no way to distinguish instrument failure from pilot error from a structural problem. Every investigation would start from zero, relying on guesswork and anecdote. This is exactly what debugging an untraced agent looks like — you have symptoms but no timeline, no causal chain, no data.

The mapping: Your agent's structured traceA trace is a complete record of one agent run: every LLM call, every tool invocation, every loop iteration, and every error, timestamped and linked into a causal sequence. Think of it as the black box recording for one execution of your agent. is the flight data recorder. Every LLM call, tool invocation, token count, and latency measurement is a sensor reading written to durable storage the moment it happens. When something fails, you replay the recording frame-by-frame and find the exact moment the agent made the wrong decision.

Why It Matters: The 10x Number

Teams that add structured tracing to their agents report finding and fixing bugs 10x faster than teams relying on print() statements or log files with plain text messages. The mechanism: a structured trace eliminates the two most expensive debugging activities — reproducing the exact failure conditions (you have the exact inputs) and narrowing down which component failed (you have latency per step, not total wall time). A bug that takes 4 hours to pin down with print debugging takes 20 minutes when you can filter traces by exit_reason: "tool_error" and see the exact tool call that failed with its exact arguments.

10x
faster debug time with structured traces vs print()
5k
LangSmith free-tier traces per month
<1ms
overhead per event with structlog async handler
Now that the case is made, let's define what exactly we need to capture. There are exactly four categories of events in any agent run — and if you miss any of them, you will have a blind spot exactly when you need visibility most.

What to Trace: The Four Categories

Definition: Trace Categories

A trace category is a class of agent event that has a distinct set of relevant fields. Tool calls need name, arguments, output, and latency. LLM turns need token counts, model name, finish reason, and latency. Agent loop iterations need iteration number, tools called this turn, and exit reason. Errors need exception type, stack trace, and whether a retry was attempted. Capturing all four gives you a complete causal picture of any run.

The Four Categories in Detail

  • Tool calls: name, args (serialized), output (truncated to 2 KB for storage), latency_ms, success flag, error message if any
  • LLM turns: prompt tokens, completion tokens, model name, finish_reason (stop / tool_calls / length / error), latency_ms, turn index within the current loop iteration
  • Agent loop iterations: iteration number, list of tools invoked this iteration, exit reason (max_iterations / goal_achieved / tool_error / no_tool_calls), wall time for the iteration
  • Errors: exception type, message, abbreviated stack trace (last 3 frames), retry attempted (bool), retry count
WHAT: A single TraceEvent dataclass that covers all four trace categories
WHY: One schema means one log destination, one filter syntax, and one replay format — rather than four separate logging patterns scattered across your codebase
GOTCHA: Serialize args with json.dumps(default=str) to handle non-serializable types like datetime without crashing
from dataclasses import dataclass, field, asdict
from typing import Any, Optional
import time, json, traceback

# WHAT: Unified trace event covering all four agent event categories.
# WHY: One dataclass means one JSON schema, one destination, one filter.
# GOTCHA: category must be one of the four defined strings; TraceRecorder
#         enforces this at emit time.

@dataclass
class TraceEvent:
    # ── Common fields (all events) ─────────────────────────────────────
    category: str          # "tool_call" | "llm_turn" | "loop_iter" | "error"
    run_id:   str          # UUID linking all events in one agent run
    ts:       float = field(default_factory=time.time)   # Unix epoch seconds

    # ── Tool call fields ──────────────────────────────────────────────
    tool_name:   Optional[str] = None
    tool_args:   Optional[str] = None   # json.dumps(args, default=str)
    tool_output: Optional[str] = None   # truncated to 2 048 chars
    tool_ok:     Optional[bool] = None
    tool_error:  Optional[str] = None

    # ── LLM turn fields ───────────────────────────────────────────────
    model:             Optional[str] = None
    prompt_tokens:     Optional[int] = None
    completion_tokens: Optional[int] = None
    finish_reason:     Optional[str] = None  # stop | tool_calls | length | error
    turn_index:        Optional[int] = None

    # ── Agent loop iteration fields ───────────────────────────────────
    iteration:     Optional[int] = None
    tools_invoked: Optional[list] = None
    exit_reason:   Optional[str] = None  # max_iterations | goal_achieved | ...

    # ── Error fields ──────────────────────────────────────────────────
    exc_type:    Optional[str] = None
    exc_msg:     Optional[str] = None
    stack_tail:  Optional[str] = None   # last 3 frames only
    retried:     Optional[bool] = None
    retry_count: Optional[int] = None

    # ── Latency (shared across tool calls, LLM turns, loop iters) ────
    latency_ms: Optional[float] = None

    def to_json(self) -> str:
        return json.dumps(
            {k: v for k, v in asdict(self).items() if v is not None},
            default=str
        )


class TraceRecorder:
    """Thin wrapper: build events, write to JSON Lines file."""

    VALID_CATEGORIES = {"tool_call", "llm_turn", "loop_iter", "error"}

    def __init__(self, run_id: str, filepath: str = "agent_trace.jsonl"):
        self.run_id = run_id
        self.filepath = filepath

    def emit(self, event: TraceEvent) -> None:
        if event.category not in self.VALID_CATEGORIES:
            raise ValueError(f"Unknown category: {event.category}")
        with open(self.filepath, "a") as f:
            f.write(event.to_json() + "\n")

    # ── Convenience builders ──────────────────────────────────────────

    def tool_call(self, name: str, args: dict, output: Any,
                  latency_ms: float, ok: bool = True,
                  error: Optional[str] = None) -> None:
        self.emit(TraceEvent(
            category="tool_call", run_id=self.run_id,
            tool_name=name,
            tool_args=json.dumps(args, default=str),
            tool_output=str(output)[:2048],
            tool_ok=ok, tool_error=error,
            latency_ms=latency_ms
        ))

    def llm_turn(self, model: str, prompt_tokens: int,
                 completion_tokens: int, finish_reason: str,
                 latency_ms: float, turn_index: int) -> None:
        self.emit(TraceEvent(
            category="llm_turn", run_id=self.run_id,
            model=model, prompt_tokens=prompt_tokens,
            completion_tokens=completion_tokens,
            finish_reason=finish_reason,
            latency_ms=latency_ms, turn_index=turn_index
        ))

    def loop_iter(self, iteration: int, tools: list,
                  exit_reason: str, latency_ms: float) -> None:
        self.emit(TraceEvent(
            category="loop_iter", run_id=self.run_id,
            iteration=iteration, tools_invoked=tools,
            exit_reason=exit_reason, latency_ms=latency_ms
        ))

    def error(self, exc: Exception, retried: bool = False,
              retry_count: int = 0) -> None:
        tb = "".join(traceback.format_tb(exc.__traceback__)[-3:])
        self.emit(TraceEvent(
            category="error", run_id=self.run_id,
            exc_type=type(exc).__name__,
            exc_msg=str(exc),
            stack_tail=tb,
            retried=retried, retry_count=retry_count
        ))
// WHAT: TypeScript interface + TraceRecorder covering all four categories
// WHY: One schema, append-only JSON Lines — mirrors the Python version exactly
// GOTCHA: Use JSON.stringify(args, (k, v) =>
//         typeof v === 'bigint' ? v.toString() : v) to handle BigInt

import fs from 'fs';

type Category = 'tool_call' | 'llm_turn' | 'loop_iter' | 'error';

interface TraceEvent {
  category:           Category;
  run_id:             string;
  ts:                 number;         // Date.now() / 1000
  tool_name?:         string;
  tool_args?:         string;         // JSON string
  tool_output?:       string;         // truncated 2 048 chars
  tool_ok?:           boolean;
  tool_error?:        string;
  model?:             string;
  prompt_tokens?:     number;
  completion_tokens?: number;
  finish_reason?:     string;
  turn_index?:        number;
  iteration?:         number;
  tools_invoked?:     string[];
  exit_reason?:       string;
  exc_type?:          string;
  exc_msg?:           string;
  stack_tail?:        string;
  retried?:           boolean;
  retry_count?:       number;
  latency_ms?:        number;
}

class TraceRecorder {
  constructor(
    private runId: string,
    private filepath: string = 'agent_trace.jsonl'
  ) {}

  emit(event: TraceEvent): void {
    const line = JSON.stringify(
      Object.fromEntries(Object.entries(event).filter(([, v]) => v !== undefined))
    ) + '\n';
    fs.appendFileSync(this.filepath, line);
  }

  toolCall(name: string, args: unknown, output: unknown,
           latencyMs: number, ok = true, error?: string): void {
    this.emit({
      category: 'tool_call', run_id: this.runId, ts: Date.now() / 1000,
      tool_name: name,
      tool_args: JSON.stringify(args),
      tool_output: String(output).slice(0, 2048),
      tool_ok: ok, tool_error: error, latency_ms: latencyMs
    });
  }

  llmTurn(model: string, promptTokens: number, completionTokens: number,
          finishReason: string, latencyMs: number, turnIndex: number): void {
    this.emit({
      category: 'llm_turn', run_id: this.runId, ts: Date.now() / 1000,
      model, prompt_tokens: promptTokens,
      completion_tokens: completionTokens,
      finish_reason: finishReason,
      latency_ms: latencyMs, turn_index: turnIndex
    });
  }

  loopIter(iteration: number, tools: string[],
           exitReason: string, latencyMs: number): void {
    this.emit({
      category: 'loop_iter', run_id: this.runId, ts: Date.now() / 1000,
      iteration, tools_invoked: tools, exit_reason: exitReason, latency_ms: latencyMs
    });
  }

  error(exc: Error, retried = false, retryCount = 0): void {
    this.emit({
      category: 'error', run_id: this.runId, ts: Date.now() / 1000,
      exc_type: exc.name, exc_msg: exc.message,
      stack_tail: (exc.stack ?? '').split('\n').slice(-3).join('\n'),
      retried, retry_count: retryCount
    });
  }
}
What Just Happened?

You now have a single data model that covers every agent event type. The key design decision: one file, one schema, one query syntax. Downstream — whether you're filtering in Python, Pandas, or LangSmith's UI — you always know the field names.

The dataclass gives us the schema. Next we need a logging transport that writes those events as structured JSON — not as human-readable strings that are impossible to parse programmatically. Enter structlog.

Structured Logging with structlog / pino

Definition: Structured Logging

Structured loggingA logging approach where every log event is emitted as a machine-readable data structure (JSON) rather than a human-readable string. Each field — timestamp, log level, message, and any custom fields — is a key-value pair. This makes logs queryable with standard tools like jq, Pandas, or any log aggregation service. means every log line is valid JSON with consistent fields, not a free-text string like "Tool called: fuzzy_match with args ['Acme', 'ACME']". With structured logs you can query: cat agent.log | jq 'select(.category=="tool_call" and .latency_ms > 2000)' to instantly find every slow tool call across millions of lines, without writing any parsing code.

Trace Data Flow — Agent Loop → Log Events → Backend → Dashboard
🤖
Agent Loop
ReAct iterations
📝
TraceRecorder
emit() per event
📄
structlog / pino
JSON Lines output
🏠
Backend
LangSmith / Jaeger / file
📊
Dashboard
filter • alert • replay
Data flow: Agent Loop emits events via TraceRecorder → structlog/pino formats as JSON Lines → shipped to LangSmith/Jaeger/file → visualized in dashboard.

Configuring structlog for Agent Logging

WHAT: structlog configured for JSON output + an AgentLogger wrapper that emits one structured event per LLM call and per tool call
WHY: structlog adds timestamps, log levels, and context binding automatically — you don't need to format these yourself
GOTCHA: Call structlog.configure() exactly once at application start, before any logging. Calling it twice produces duplicate processors.
import structlog, logging, time, uuid
from openai import OpenAI  # Ollama's OpenAI-compatible endpoint

# ── Step 1: Configure structlog once at startup ──────────────────────
# WHAT: Wire structlog to emit JSON with timestamps and level fields
# WHY: JSON output is queryable; plain text requires regex parsing
# GOTCHA: processors run left-to-right; JSONRenderer must be last

def configure_agent_logging(log_file: str = "agent.log") -> None:
    logging.basicConfig(
        format="%(message)s",
        level=logging.INFO,
        handlers=[
            logging.StreamHandler(),
            logging.FileHandler(log_file, mode="a")
        ]
    )
    structlog.configure(
        processors=[
            structlog.stdlib.add_log_level,
            structlog.stdlib.add_logger_name,
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.JSONRenderer()
        ],
        wrapper_class=structlog.stdlib.BoundLogger,
        context_class=dict,
        logger_factory=structlog.stdlib.LoggerFactory()
    )


# ── Step 2: Agent wrapper that emits structured events ────────────────
# WHAT: Wraps every Ollama LLM call and tool call with structured logging
# WHY: One consistent event schema across all events in every run
# GOTCHA: Always log BEFORE raising exceptions so the error event is
#         written even when the caller swallows the exception

class AgentLogger:
    def __init__(self, run_id: str | None = None):
        self.run_id = run_id or str(uuid.uuid4())
        self.log = structlog.get_logger().bind(run_id=self.run_id)
        self.client = OpenAI(
            base_url="http://localhost:11434/v1",
            api_key="ollama"
        )

    def llm_call(self, messages: list, model: str = "mistral") -> str:
        t0 = time.perf_counter()
        try:
            response = self.client.chat.completions.create(
                model=model, messages=messages
            )
            latency_ms = (time.perf_counter() - t0) * 1000
            usage = response.usage
            self.log.info("llm_turn",
                event_type="llm_turn", model=model,
                prompt_tokens=usage.prompt_tokens if usage else None,
                completion_tokens=usage.completion_tokens if usage else None,
                finish_reason=response.choices[0].finish_reason,
                latency_ms=round(latency_ms, 2)
            )
            return response.choices[0].message.content or ""
        except Exception as exc:
            latency_ms = (time.perf_counter() - t0) * 1000
            self.log.error("llm_error",
                event_type="error",
                exc_type=type(exc).__name__,
                exc_msg=str(exc),
                latency_ms=round(latency_ms, 2)
            )
            raise

    def tool_call(self, name: str, args: dict,
                  fn, *fn_args, **fn_kwargs):
        t0 = time.perf_counter()
        try:
            result = fn(*fn_args, **fn_kwargs)
            latency_ms = (time.perf_counter() - t0) * 1000
            self.log.info("tool_call",
                event_type="tool_call", tool_name=name,
                tool_args=str(args)[:512], tool_output=str(result)[:512],
                tool_ok=True, latency_ms=round(latency_ms, 2)
            )
            return result
        except Exception as exc:
            latency_ms = (time.perf_counter() - t0) * 1000
            self.log.warning("tool_error",
                event_type="tool_call", tool_name=name,
                tool_ok=False, tool_error=str(exc),
                latency_ms=round(latency_ms, 2)
            )
            raise
// WHAT: pino logger configured for JSON output + AgentLogger wrapper
// WHY: pino is the fastest Node.js structured logger; same JSON schema as Python
// GOTCHA: pino writes to stdout by default. Use pino.destination() to redirect to
//         a file, or pino-multi-stream for both simultaneously.

import pino from 'pino';
import OpenAI from 'openai';
import { randomUUID } from 'crypto';

const createLogger = (logFile = 'agent.log') =>
  pino(
    { level: 'info', timestamp: pino.stdTimeFunctions.isoTime },
    pino.destination({ dest: logFile, sync: false })
  );

class AgentLogger {
  private runId: string;
  private log: pino.Logger;
  private client: OpenAI;

  constructor(runId?: string, logFile = 'agent.log') {
    this.runId = runId ?? randomUUID();
    this.log = createLogger(logFile).child({ run_id: this.runId });
    this.client = new OpenAI({
      baseURL: 'http://localhost:11434/v1', apiKey: 'ollama'
    });
  }

  async llmCall(messages: OpenAI.ChatCompletionMessageParam[],
                model = 'mistral'): Promise {
    const t0 = performance.now();
    try {
      const response = await this.client.chat.completions.create({ model, messages });
      const latencyMs = performance.now() - t0;
      const usage = response.usage;
      this.log.info({
        event_type: 'llm_turn', model,
        prompt_tokens: usage?.prompt_tokens,
        completion_tokens: usage?.completion_tokens,
        finish_reason: response.choices[0].finish_reason,
        latency_ms: Math.round(latencyMs * 100) / 100
      });
      return response.choices[0].message.content ?? '';
    } catch (err: unknown) {
      this.log.error({
        event_type: 'error',
        exc_type: err instanceof Error ? err.constructor.name : 'UnknownError',
        exc_msg: err instanceof Error ? err.message : String(err),
        latency_ms: Math.round((performance.now() - t0) * 100) / 100
      });
      throw err;
    }
  }

  async toolCall<T>(name: string, args: Record<string, unknown>,
                    fn: () => Promise<T>): Promise<T> {
    const t0 = performance.now();
    try {
      const result = await fn();
      this.log.info({
        event_type: 'tool_call', tool_name: name,
        tool_args: JSON.stringify(args).slice(0, 512),
        tool_output: String(result).slice(0, 512),
        tool_ok: true,
        latency_ms: Math.round((performance.now() - t0) * 100) / 100
      });
      return result;
    } catch (err: unknown) {
      this.log.warn({
        event_type: 'tool_call', tool_name: name,
        tool_ok: false,
        tool_error: err instanceof Error ? err.message : String(err),
        latency_ms: Math.round((performance.now() - t0) * 100) / 100
      });
      throw err;
    }
  }
}
Expected log output (one line per event)
{"event": "llm_turn", "level": "info", "timestamp": "2025-11-12T09:34:21.018Z", "run_id": "a3f7b2...", "event_type": "llm_turn", "model": "mistral", "prompt_tokens": 312, "completion_tokens": 87, "finish_reason": "tool_calls", "latency_ms": 1243.7} {"event": "tool_call", "level": "info", "timestamp": "2025-11-12T09:34:22.291Z", "run_id": "a3f7b2...", "event_type": "tool_call", "tool_name": "fuzzy_match_score", "tool_args": "{\"a\": \"Acme LLC\", \"b\": \"ACME LLC\"}", "tool_output": "0.97", "tool_ok": true, "latency_ms": 4.1}
Structured logs give you queryable data in files. But to get a visual trace tree, span nesting, and a team-shareable dashboard, you need a purpose-built trace backend. LangSmith is the fastest to add — and it works with your Ollama client without any changes to the underlying call.

LangSmith Tracing with Ollama

Definition: LangSmith

LangSmithA tracing and observability platform for LLM applications, built by LangChain. It captures functions decorated with @traceable as named spans, automatically extracts inputs/outputs/token counts, and provides a web UI for viewing trace trees, comparing runs, and setting up evaluation datasets. Free tier: 5,000 traces/month. is a tracing backend that captures decorated Python functions as named spans and renders them as a nested call tree in a web UI. The @traceable decorator intercepts the function's inputs and outputs, wraps any LLM calls made inside it, and ships everything to LangSmith's API. Because it wraps the OpenAI client interface, it works with any OpenAI-compatible endpoint — including Ollama.

Open Source Note: @traceable + Ollama

LangSmith's @traceable decorator works by patching the OpenAI Python client. Since Ollama exposes a fully OpenAI-compatible REST API, the decorator intercepts your client.chat.completions.create() calls transparently. Your model="mistral" parameter shows up verbatim in the LangSmith dashboard — you can filter by model name to separate local Ollama runs from any cloud model runs in the same project. No code change is required on the LLM call itself.

LangSmith Trace Tree — Nested Spans Animate In
▷ entity-resolution-agent run_id: a3f7b2 3 241ms
🤖 ChatOllama [mistral] 312+87 tok 1 244ms
🔧 fuzzy_match_score args: {a, b} 4ms
🔍 search_filings n=3 results 823ms
🤖 ChatOllama [mistral] 412+61 tok 987ms
🔧 merge_entity_profiles confidence: 0.94 12ms
LangSmith trace tree: entity-resolution-agent root span contains two ChatOllama [mistral] turns, each with tool call children (fuzzy_match_score, search_filings, merge_entity_profiles).

Installation and Environment

WHAT: Install LangSmith and set the three required environment variables
WHY: Without LANGCHAIN_TRACING_V2=true, @traceable is a no-op — no data is sent
GOTCHA: LANGCHAIN_API_KEY is the LangSmith API key (not the LangChain hub key). Get it at smith.langchain.com → Settings → API Keys.
pip install langsmith

export LANGCHAIN_API_KEY="lsv2_pt_..."        # your LangSmith API key
export LANGCHAIN_TRACING_V2="true"            # enable trace shipping
export LANGCHAIN_PROJECT="ollama-c3-agent"    # project name in dashboard
npm install langsmith

# .env file
LANGCHAIN_API_KEY=lsv2_pt_...
LANGCHAIN_TRACING_V2=true
LANGCHAIN_PROJECT=ollama-c3-agent

Wrapping the Agent with @traceable

WHAT: @traceable wraps run_agent() as the root span and each tool handler as a child span; LangSmith auto-captures inputs, outputs, and token counts
WHY: The decorator requires zero changes to the underlying Ollama call — its OpenAI-compatible endpoint is intercepted transparently
GOTCHA: Wrap tool handlers with @traceable too, or they appear as untraced black boxes inside the parent span's timeline
from langsmith import traceable
from openai import OpenAI
import json

# WHAT: OpenAI client pointed at Ollama — @traceable intercepts it
# WHY: LangSmith patches the openai library, so any client call made
#      inside a @traceable function is captured as a child span
# GOTCHA: api_key must be a non-empty string (Ollama ignores the value,
#         but the OpenAI client validates that the field is present)

client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama"
)

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "fuzzy_match_score",
            "description": "Compute fuzzy similarity between two entity names",
            "parameters": {
                "type": "object",
                "properties": {
                    "a": {"type": "string"},
                    "b": {"type": "string"}
                },
                "required": ["a", "b"]
            }
        }
    }
]

# ── Tool handlers as child spans ──────────────────────────────────────
@traceable(name="fuzzy-match-score")
def fuzzy_match_score(a: str, b: str) -> float:
    from rapidfuzz import fuzz
    return fuzz.token_sort_ratio(a.lower(), b.lower()) / 100.0

@traceable(name="search-filings")
def search_filings(query: str, limit: int = 3) -> list[dict]:
    # Replace with your actual vector store / SQLite query
    return [{"filing_id": "UCC-001", "debtor": query, "score": 0.91}]

# ── Root span ─────────────────────────────────────────────────────────
@traceable(name="entity-resolution-agent")
def run_agent(query: str) -> str:
    """Full ReAct agent loop, traced end-to-end via LangSmith."""
    messages = [
        {"role": "system", "content": "You are an entity resolution specialist."},
        {"role": "user",   "content": query}
    ]
    tool_map = {
        "fuzzy_match_score": fuzzy_match_score,
        "search_filings": search_filings
    }
    for _ in range(10):
        response = client.chat.completions.create(
            model="mistral", messages=messages,
            tools=TOOLS, tool_choice="auto"
        )
        choice = response.choices[0]
        messages.append(choice.message)
        if choice.finish_reason == "stop":
            return choice.message.content or "No answer"
        if choice.finish_reason == "tool_calls":
            for tc in (choice.message.tool_calls or []):
                fn_name = tc.function.name
                fn_args = json.loads(tc.function.arguments)
                result = tool_map[fn_name](**fn_args)
                messages.append({
                    "role": "tool",
                    "tool_call_id": tc.id,
                    "content": json.dumps(result)
                })
    return "Max iterations reached"


if __name__ == "__main__":
    answer = run_agent(
        "Are 'Acme Logistics LLC' and 'ACME LOGISTICS, L.L.C.' the same entity?"
    )
    print(answer)
    # View trace at: https://smith.langchain.com
    # Projects -> ollama-c3-agent -> Filter: model = mistral
// WHAT: Node.js equivalent using langsmith's traceable wrapper
// WHY: Same decorator pattern, same span hierarchy in LangSmith UI
// GOTCHA: Import traceable from 'langsmith/traceable' (not root 'langsmith')

import { traceable } from 'langsmith/traceable';
import OpenAI from 'openai';

const client = new OpenAI({
  baseURL: 'http://localhost:11434/v1',
  apiKey: 'ollama'
});

const fuzzyMatchScore = traceable(
  async (a: string, b: string): Promise<number> => {
    // replace with actual fuzzy match
    return a.toLowerCase() === b.toLowerCase() ? 1.0 : 0.6;
  },
  { name: 'fuzzy-match-score' }
);

const searchFilings = traceable(
  async (query: string, limit = 3) => {
    return [{ filing_id: 'UCC-001', debtor: query, score: 0.91 }];
  },
  { name: 'search-filings' }
);

export const runAgent = traceable(
  async (query: string): Promise<string> => {
    const messages: OpenAI.ChatCompletionMessageParam[] = [
      { role: 'system', content: 'You are an entity resolution specialist.' },
      { role: 'user',   content: query }
    ];
    const toolMap: Record<string, Function> = {
      fuzzy_match_score: fuzzyMatchScore,
      search_filings: searchFilings
    };
    for (let i = 0; i < 10; i++) {
      const response = await client.chat.completions.create({
        model: 'mistral', messages, tool_choice: 'auto', tools: []
      });
      const choice = response.choices[0];
      messages.push(choice.message as OpenAI.ChatCompletionMessageParam);
      if (choice.finish_reason === 'stop') return choice.message.content ?? 'No answer';
      if (choice.finish_reason === 'tool_calls') {
        for (const tc of choice.message.tool_calls ?? []) {
          const args = JSON.parse(tc.function.arguments);
          const result = await toolMap[tc.function.name]?.(args);
          messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) });
        }
      }
    }
    return 'Max iterations reached';
  },
  { name: 'entity-resolution-agent' }
);

runAgent("Are 'Acme LLC' and 'ACME LLC' the same entity?").then(console.log);
LangSmith dashboard after first run
Project: ollama-c3-agent Run: entity-resolution-agent | 3 241ms | model: mistral | PASS ├── ChatOllama [mistral] 1 244ms | 312 → 87 tokens | finish: tool_calls │ ├── fuzzy-match-score 4ms | {a: "Acme Logistics LLC", b: "ACME LOGISTICS L.L.C."} | 0.97 │ └── search-filings 823ms | {query: "Acme Logistics LLC", limit: 3} | 3 results └── ChatOllama [mistral] 987ms | 412 → 61 tokens | finish: stop └── merge-entity-profiles 12ms | confidence: 0.94
LangSmith is cloud-hosted and fast to set up, but if you need fully local tracing with no data leaving your machine — or if you need distributed tracing across multiple services — OpenTelemetry with a local Jaeger backend is the production-grade answer.

OpenTelemetry + Jaeger

Definition: OpenTelemetry

OpenTelemetryAn open-source observability framework that provides vendor-neutral APIs and SDKs for generating traces, metrics, and logs from applications. OTEL traces are composed of spans (named, timed operations) connected in a parent-child hierarchy. The data is exported via OTLP (OpenTelemetry Protocol) to any compatible backend — Jaeger, Zipkin, Grafana, Datadog, etc. (OTEL) is the industry standard for distributed tracing. Your agent creates a root spanA span is a single named, timed operation in a distributed trace — like one LLM call, one tool invocation, or one iteration of the agent loop. Spans can be nested (parent-child) to represent causality: the parent span covers the whole agent run; child spans represent individual steps inside it. for each run, then child spans for each LLM call and tool call. The OTLP exporterOTLP (OpenTelemetry Protocol) is the wire format for sending telemetry data from your application to a backend. The OTLP exporter serializes your spans into OTLP format and sends them over gRPC to the backend endpoint (default: localhost:4317). ships spans to Jaeger over gRPC. Everything runs locally — no data leaves your machine.

OTEL Span Hierarchy — Parent → Children with Latency Bars
entity-resolution-run
3 241ms
↳ llm-turn-1
1 244ms
↳ tool:fuzzy_match
4ms
↳ tool:search_filings
823ms
↳ llm-turn-2
987ms
↳ tool:merge_profiles
12ms
OTEL span hierarchy: root span (3241ms) → llm-turn-1 (1244ms) → tool:fuzzy_match (4ms), tool:search_filings (823ms, red/slow), llm-turn-2 (987ms) → tool:merge_profiles (12ms).

Installation and Jaeger Setup

WHAT: Install OTEL packages and start a local Jaeger all-in-one container as the trace backend
WHY: Jaeger provides a web UI at localhost:16686 for viewing span hierarchies and filtering by service or operation name
GOTCHA: Port 4317 is the OTLP gRPC endpoint. Port 16686 is the Jaeger UI. Make sure Docker is running before executing the docker run command.
pip install \
  opentelemetry-api \
  opentelemetry-sdk \
  opentelemetry-exporter-otlp

# Start Jaeger all-in-one (traces stored in memory)
docker run -d --name jaeger \
  -p 4317:4317 \
  -p 16686:16686 \
  jaegertracing/all-in-one:latest

# View traces at: http://localhost:16686
npm install \
  @opentelemetry/sdk-node \
  @opentelemetry/api \
  @opentelemetry/exporter-trace-otlp-grpc

# Start Jaeger (same Docker command as Python)
docker run -d --name jaeger \
  -p 4317:4317 \
  -p 16686:16686 \
  jaegertracing/all-in-one:latest

Instrumenting the Agent Loop

WHAT: Create a tracer + OTLP exporter; wrap the agent loop as a root span and each LLM call / tool call as child spans
WHY: Span attributes let you query by model name, tool name, or latency in the Jaeger UI across any number of runs
GOTCHA: Always call span.end() in a finally block. If an exception escapes before end(), the span is orphaned and never exported.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.trace import StatusCode
import time, json
from openai import OpenAI

# ── Bootstrap OTEL — run once at app start ───────────────────────────
# WHAT: Wire TracerProvider to Jaeger via OTLP gRPC
# WHY: BatchSpanProcessor buffers + flushes every 5s, adding <1ms overhead
# GOTCHA: OTLPSpanExporter silently drops spans when Jaeger is not running

def setup_otel(service_name: str = "entity-resolution-agent") -> trace.Tracer:
    exporter = OTLPSpanExporter(endpoint="http://localhost:4317")
    provider = TracerProvider()
    provider.add_span_processor(BatchSpanProcessor(exporter))
    trace.set_tracer_provider(provider)
    return trace.get_tracer(service_name)


tracer = setup_otel()
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

def run_agent_otel(query: str) -> str:
    with tracer.start_as_current_span("entity-resolution-run") as root:
        root.set_attribute("query", query[:256])
        messages = [
            {"role": "system", "content": "You are an entity resolution specialist."},
            {"role": "user",   "content": query}
        ]
        try:
            for iteration in range(10):
                root.set_attribute("iterations", iteration + 1)
                t0 = time.perf_counter()
                with tracer.start_as_current_span(f"llm-turn-{iteration+1}") as llm_span:
                    response = client.chat.completions.create(
                        model="mistral", messages=messages,
                        tools=TOOLS, tool_choice="auto"
                    )
                    latency_ms = (time.perf_counter() - t0) * 1000
                    usage = response.usage
                    llm_span.set_attribute("model", "mistral")
                    llm_span.set_attribute("latency_ms", round(latency_ms, 1))
                    if usage:
                        llm_span.set_attribute("prompt_tokens", usage.prompt_tokens)
                        llm_span.set_attribute("completion_tokens", usage.completion_tokens)
                    llm_span.set_attribute("finish_reason", response.choices[0].finish_reason)

                choice = response.choices[0]
                messages.append(choice.message)

                if choice.finish_reason == "stop":
                    root.set_attribute("exit_reason", "goal_achieved")
                    return choice.message.content or "No answer"

                if choice.finish_reason == "tool_calls":
                    for tc in (choice.message.tool_calls or []):
                        fn_name = tc.function.name
                        fn_args = json.loads(tc.function.arguments)
                        t1 = time.perf_counter()
                        with tracer.start_as_current_span(f"tool:{fn_name}") as ts:
                            ts.set_attribute("tool.name", fn_name)
                            ts.set_attribute("tool.args", str(fn_args)[:256])
                            try:
                                result = TOOL_MAP[fn_name](**fn_args)
                                ts.set_attribute("tool.ok", True)
                                ts.set_attribute("tool.output", str(result)[:256])
                            except Exception as e:
                                ts.set_attribute("tool.ok", False)
                                ts.set_attribute("tool.error", str(e))
                                ts.set_status(StatusCode.ERROR, str(e))
                                raise
                            finally:
                                ts.set_attribute("latency_ms",
                                    round((time.perf_counter() - t1) * 1000, 1))
                        messages.append({
                            "role": "tool",
                            "tool_call_id": tc.id,
                            "content": json.dumps(result)
                        })
            root.set_attribute("exit_reason", "max_iterations")
            return "Max iterations reached"
        except Exception as e:
            root.set_status(StatusCode.ERROR, str(e))
            raise
// tracing.ts — import BEFORE all other modules
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc';
import { trace, SpanStatusCode } from '@opentelemetry/api';

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({ url: 'http://localhost:4317' }),
  serviceName: 'entity-resolution-agent'
});
sdk.start();
process.on('SIGTERM', () => sdk.shutdown().then(() => process.exit(0)));

import OpenAI from 'openai';
const tracer = trace.getTracer('entity-resolution-agent');
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });

async function runAgentOtel(query: string): Promise<string> {
  return tracer.startActiveSpan('entity-resolution-run', async (root) => {
    root.setAttribute('query', query.slice(0, 256));
    const messages: OpenAI.ChatCompletionMessageParam[] = [
      { role: 'system', content: 'You are an entity resolution specialist.' },
      { role: 'user', content: query }
    ];
    try {
      for (let i = 0; i < 10; i++) {
        const response = await tracer.startActiveSpan(`llm-turn-${i+1}`, async (llmSpan) => {
          const t0 = performance.now();
          try {
            const res = await client.chat.completions.create({
              model: 'mistral', messages, tool_choice: 'auto', tools: []
            });
            llmSpan.setAttribute('model', 'mistral');
            llmSpan.setAttribute('latency_ms', Math.round(performance.now() - t0));
            llmSpan.setAttribute('finish_reason', res.choices[0].finish_reason ?? '');
            return res;
          } catch (e: unknown) {
            llmSpan.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
            throw e;
          } finally { llmSpan.end(); }
        });
        const choice = response.choices[0];
        messages.push(choice.message as OpenAI.ChatCompletionMessageParam);
        if (choice.finish_reason === 'stop') {
          root.setAttribute('exit_reason', 'goal_achieved');
          root.end();
          return choice.message.content ?? 'No answer';
        }
      }
      root.setAttribute('exit_reason', 'max_iterations');
      root.end();
      return 'Max iterations reached';
    } catch (e: unknown) {
      root.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
      root.end();
      throw e;
    }
  });
}

Custom Tracer: Zero-Dependency @trace_agent

Definition: JSON Lines

A JSON LinesJSON Lines (also called JSONL or newline-delimited JSON) is a file format where each line is a valid, complete JSON object. Because each line is independent, files can be appended to without reading existing content, making them ideal for streaming log output. They are trivially readable with jq, Pandas read_json(lines=True), or any streaming processor. file contains one JSON object per line, with no comma separators. This makes it ideal for append-only log streams: each emit() call appends one line atomically. To read all events, open the file and json.loads() each line.

WHAT: @trace_agent decorator logs run start/end + injected sub-events to JSONL; replay() loads a trace file back into a dict
WHY: Zero dependencies — drop into any project in 30 seconds with no pip install
GOTCHA: The decorator injects an _emit kwarg only when the function signature accepts it. Add _emit=None to your function signature to enable sub-event logging from inside the function.
import functools, inspect, json, time, uuid
from pathlib import Path
from typing import Callable

# WHAT: @trace_agent wraps any agent function, writing run_start,
#       sub-events, and run_end to a JSONL file
# WHY: Zero deps — works anywhere Python 3.9+ runs
# GOTCHA: Non-serializable arguments are converted to str() automatically

def trace_agent(
    output_dir: str = "traces",
    log_llm_calls: bool = True,
    log_tool_calls: bool = True
) -> Callable:
    Path(output_dir).mkdir(parents=True, exist_ok=True)

    def decorator(fn: Callable) -> Callable:
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            run_id = str(uuid.uuid4())[:8]
            trace_file = Path(output_dir) / f"trace_{run_id}.jsonl"

            def emit(event: dict) -> None:
                event["run_id"] = run_id
                event["ts"] = time.time()
                with open(trace_file, "a") as f:
                    f.write(json.dumps(event, default=str) + "\n")

            emit({"category": "run_start", "fn_name": fn.__name__,
                  "fn_args": str(args)[:512], "fn_kwargs": str(kwargs)[:512]})
            t0 = time.perf_counter()
            sig = inspect.signature(fn)
            if "_emit" in sig.parameters:
                kwargs["_emit"] = emit
            try:
                result = fn(*args, **kwargs)
                emit({"category": "run_end", "exit_reason": "success",
                      "latency_ms": round((time.perf_counter() - t0) * 1000, 2),
                      "output_preview": str(result)[:256]})
                return result
            except Exception as exc:
                emit({"category": "run_end", "exit_reason": "error",
                      "latency_ms": round((time.perf_counter() - t0) * 1000, 2),
                      "exc_type": type(exc).__name__, "exc_msg": str(exc)})
                raise
        return wrapper
    return decorator


@trace_agent(output_dir="traces")
def run_agent(query: str, _emit=None) -> str:
    from openai import OpenAI
    client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
    t_llm = time.perf_counter()
    response = client.chat.completions.create(
        model="mistral",
        messages=[{"role": "user", "content": query}]
    )
    if _emit:
        _emit({
            "category": "llm_turn", "model": "mistral",
            "latency_ms": round((time.perf_counter() - t_llm) * 1000, 2),
            "finish_reason": response.choices[0].finish_reason
        })
    return response.choices[0].message.content or ""


def replay_trace(trace_file: str) -> dict:
    """Load JSONL trace file into a summary dict."""
    events = []
    with open(trace_file) as f:
        for line in f:
            line = line.strip()
            if line:
                events.append(json.loads(line))
    start = next((e for e in events if e["category"] == "run_start"), None)
    end   = next((e for e in events if e["category"] == "run_end"),   None)
    return {
        "run_id":      events[0].get("run_id") if events else None,
        "fn_name":     start.get("fn_name") if start else None,
        "total_ms":    end.get("latency_ms") if end else None,
        "exit_reason": end.get("exit_reason") if end else "incomplete",
        "events":      events
    }
// WHAT: Zero-dep traceAgent higher-order function + replay utility
// WHY: No external packages — works in Node 18+ with built-in fs
// GOTCHA: Unlike Python decorators, JS wraps the function manually

import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';

type EmitFn = (event: Record<string, unknown>) => void;

function traceAgent<T extends unknown[], R>(
  fn: (...args: [...T, EmitFn?]) => Promise<R>,
  outputDir = 'traces'
): (...args: T) => Promise<R> {
  fs.mkdirSync(outputDir, { recursive: true });
  return async (...args: T): Promise<R> => {
    const runId = randomUUID().slice(0, 8);
    const traceFile = path.join(outputDir, `trace_${runId}.jsonl`);
    const emit: EmitFn = (event) => {
      fs.appendFileSync(traceFile,
        JSON.stringify({ ...event, run_id: runId, ts: Date.now() / 1000 }) + '\n');
    };
    emit({ category: 'run_start', fn_name: fn.name,
           fn_args: JSON.stringify(args).slice(0, 512) });
    const t0 = performance.now();
    try {
      const result = await (fn as Function)(...args, emit);
      emit({ category: 'run_end', exit_reason: 'success',
             latency_ms: Math.round(performance.now() - t0),
             output_preview: String(result).slice(0, 256) });
      return result;
    } catch (e: unknown) {
      emit({ category: 'run_end', exit_reason: 'error',
             latency_ms: Math.round(performance.now() - t0),
             exc_type: e instanceof Error ? e.constructor.name : 'UnknownError',
             exc_msg: e instanceof Error ? e.message : String(e) });
      throw e;
    }
  };
}

async function replayTrace(traceFile: string) {
  const lines = fs.readFileSync(traceFile, 'utf-8').split('\n').filter(Boolean);
  const events = lines.map(l => JSON.parse(l));
  const start = events.find(e => e.category === 'run_start');
  const end   = events.find(e => e.category === 'run_end');
  return {
    run_id:      events[0]?.run_id ?? null,
    fn_name:     start?.fn_name ?? null,
    total_ms:    end?.latency_ms ?? null,
    exit_reason: end?.exit_reason ?? 'incomplete',
    events
  };
}

Trace Visualization CLI

Raw JSON Lines are queryable but not human-readable at a glance. This trace_viewer.py script pretty-prints a trace file in your terminal: it shows the full call tree, highlights any step slower than 2 s in red, and loads everything into a Pandas DataFrame for ad-hoc analysis.

WHAT: CLI that loads a JSONL trace, prints a human-readable call tree, and optionally loads a Pandas DataFrame for analysis
WHY: df[df.latency_ms > 2000] finds all slow steps in one line; no need to write a parser from scratch
GOTCHA: Pandas is optional — the viewer degrades gracefully if not installed, printing raw dicts instead
#!/usr/bin/env python3
"""trace_viewer.py — CLI pretty-printer for agent JSONL trace files.

Usage:
    python trace_viewer.py traces/trace_a3f7b2.jsonl
    python trace_viewer.py traces/trace_a3f7b2.jsonl --slow-threshold 1000
"""
import json, sys, argparse
from pathlib import Path

RED = "\033[91m"; YELLOW = "\033[93m"; GREEN = "\033[92m"
CYAN = "\033[96m"; DIM = "\033[2m"; RESET = "\033[0m"; BOLD = "\033[1m"

CATEGORY_COLOR = {
    "run_start": CYAN, "llm_turn": YELLOW,
    "tool_call": GREEN, "error": RED, "run_end": CYAN,
}

def load_events(filepath: str) -> list[dict]:
    events = []
    with open(filepath) as f:
        for line in f:
            line = line.strip()
            if line:
                try:
                    events.append(json.loads(line))
                except json.JSONDecodeError:
                    pass
    return events

def print_call_tree(events: list[dict], slow_ms: float = 2000) -> None:
    print(f"\n{BOLD}{'─'*60}{RESET}")
    print(f"{BOLD}TRACE: {events[0].get('run_id','unknown')}{RESET}")
    print('─'*60)
    indent = 0
    for ev in events:
        cat     = ev.get("category", "unknown")
        color   = CATEGORY_COLOR.get(cat, RESET)
        latency = ev.get("latency_ms", 0) or 0
        slow    = f" {RED}⚠ SLOW{RESET}" if latency > slow_ms else ""

        if cat == "run_start":
            print(f"\n{color}▶ {ev.get('fn_name','?')}(){RESET}")
            indent = 2
        elif cat == "llm_turn":
            print(f"{' '*indent}{color}LLM [{ev.get('model','?')}]{RESET}  "
                  f"{DIM}{ev.get('prompt_tokens','?')}→{ev.get('completion_tokens','?')} tok  "
                  f"{ev.get('finish_reason','?')}{RESET}  "
                  f"{YELLOW}{latency:.0f}ms{RESET}{slow}")
        elif cat == "tool_call":
            ok     = ev.get("tool_ok", True)
            status = f"{GREEN}✓{RESET}" if ok else f"{RED}✗ {ev.get('tool_error','')}{RESET}"
            print(f"{' '*(indent+2)}{color}TOOL {ev.get('tool_name','?')}{RESET}  "
                  f"{status}  {YELLOW}{latency:.0f}ms{RESET}{slow}")
        elif cat == "error":
            print(f"{' '*indent}{RED}ERROR {ev.get('exc_type','?')}: "
                  f"{str(ev.get('exc_msg',''))[:80]}{RESET}")
        elif cat == "run_end":
            reason = ev.get("exit_reason", "?")
            col    = GREEN if reason == "success" else RED
            print(f"\n{col}{reason.upper()}  {YELLOW}{latency:.0f}ms total{RESET}\n")
    print('─'*60 + '\n')

def load_trace(filepath: str):
    events = load_events(filepath)
    try:
        import pandas as pd
        df = pd.DataFrame(events)
        df["latency_ms"] = pd.to_numeric(df.get("latency_ms"), errors="coerce")
        return df
    except ImportError:
        print("pandas not installed — returning list of dicts")
        return events

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("tracefile")
    parser.add_argument("--slow-threshold", type=float, default=2000)
    args = parser.parse_args()

    if not Path(args.tracefile).exists():
        print(f"File not found: {args.tracefile}", file=sys.stderr)
        sys.exit(1)

    events = load_events(args.tracefile)
    if not events:
        print("Trace file is empty.", file=sys.stderr)
        sys.exit(1)

    print_call_tree(events, slow_ms=args.slow_threshold)

    df = load_trace(args.tracefile)
    if hasattr(df, "groupby"):
        print(f"{BOLD}Category breakdown:{RESET}")
        summary = df.groupby("category").agg(
            count=("category","count"),
            avg_latency_ms=("latency_ms","mean")
        ).round(1)
        print(summary.to_string())
        slow = df[df["latency_ms"] > args.slow_threshold]
        if not slow.empty:
            print(f"\n{RED}Slow steps (>{args.slow_threshold:.0f}ms):{RESET}")
            cols = [c for c in ["category","tool_name","model","latency_ms"] if c in slow.columns]
            print(slow[cols].to_string(index=False))

if __name__ == "__main__":
    main()
#!/usr/bin/env node
// trace-viewer.mjs — CLI pretty-printer for JSONL traces
// Usage: node trace-viewer.mjs traces/trace_a3f7b2.jsonl [--slow-threshold 2000]
// chalk is optional — falls back to plain text if not installed

import fs from 'fs';

let c = { red: s => s, yellow: s => s, green: s => s, cyan: s => s, dim: s => s, bold: s => s };
try { const { default: chalk } = await import('chalk'); c = chalk; } catch {}

const CAT_FMT = { run_start: s => c.cyan(s), llm_turn: s => c.yellow(s),
                  tool_call: s => c.green(s), error: s => c.red(s), run_end: s => c.cyan(s) };

function loadEvents(filepath) {
  return fs.readFileSync(filepath, 'utf-8').split('\n').filter(Boolean)
    .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
}

function printCallTree(events, slowMs = 2000) {
  console.log('\n' + c.bold('─'.repeat(60)));
  console.log(c.bold(`TRACE: ${events[0]?.run_id ?? 'unknown'}`));
  console.log('─'.repeat(60));
  let indent = 0;
  for (const ev of events) {
    const cat     = ev.category ?? 'unknown';
    const fmt     = CAT_FMT[cat] ?? (s => s);
    const latency = ev.latency_ms ?? 0;
    const slow    = latency > slowMs ? ' ' + c.red('⚠ SLOW') : '';
    if (cat === 'run_start') {
      console.log('\n' + fmt('▶ ' + (ev.fn_name ?? '?') + '()'));
      indent = 2;
    } else if (cat === 'llm_turn') {
      console.log(' '.repeat(indent) + fmt(`LLM [${ev.model ?? '?'}]`) + '  ' +
        c.dim(`${ev.prompt_tokens ?? '?'}→${ev.completion_tokens ?? '?'} tok  ${ev.finish_reason ?? '?'}`) +
        '  ' + c.yellow(latency.toFixed(0) + 'ms') + slow);
    } else if (cat === 'tool_call') {
      const status = ev.tool_ok ? c.green('✓') : c.red('✗ ' + (ev.tool_error ?? ''));
      console.log(' '.repeat(indent + 2) + fmt('TOOL ' + (ev.tool_name ?? '?')) +
        '  ' + status + '  ' + c.yellow(latency.toFixed(0) + 'ms') + slow);
    } else if (cat === 'error') {
      console.log(' '.repeat(indent) + c.red(`ERROR ${ev.exc_type ?? '?'}: ${(ev.exc_msg ?? '').slice(0, 80)}`));
    } else if (cat === 'run_end') {
      const col = ev.exit_reason === 'success' ? c.green : c.red;
      console.log('\n' + col((ev.exit_reason ?? '?').toUpperCase()) + '  ' + c.yellow(latency.toFixed(0) + 'ms total') + '\n');
    }
  }
  console.log('─'.repeat(60) + '\n');
}

const [,, tracefile, ...flags] = process.argv;
if (!tracefile) { console.error('Usage: node trace-viewer.mjs <tracefile.jsonl>'); process.exit(1); }
const slowIdx = flags.indexOf('--slow-threshold');
const slowThreshold = slowIdx >= 0 ? parseFloat(flags[slowIdx + 1] ?? '2000') : 2000;
if (!fs.existsSync(tracefile)) { console.error(`File not found: ${tracefile}`); process.exit(1); }
const events = loadEvents(tracefile);
if (!events.length) { console.error('Trace file is empty.'); process.exit(1); }
printCallTree(events, slowThreshold);
Sample terminal output
──────────────────────────────────────────────────────────── TRACE: a3f7b2 ▶ run_agent() LLM [mistral] 312→87 tok tool_calls 1244ms TOOL fuzzy_match_score ✓ 4ms TOOL search_filings ✓ 823ms LLM [mistral] 412→61 tok stop 987ms TOOL merge_entity_profiles ✓ 12ms SUCCESS 3241ms total ──────────────────────────────────────────────────────────── Category breakdown: count avg_latency_ms category llm_turn 2 1115.5 run_end 1 3241.0 run_start 1 0.0 tool_call 3 279.7

Lab: Instrument the Capstone C3 Agent

Lab Target: Capstone C3 Entity Resolution Agent

Instrument the entity resolution agent from CAPSTONE-C3 with LangSmith. After this lab, you'll have a LangSmith project named ollama-c3-agent with at least one trace visible in the dashboard — showing model (mistral), token counts, tool call spans, and per-step latencies. Filter by model=mistral to isolate local Ollama runs from any cloud runs in the same project.

  1. Step 1 — Install LangSmith and configure environment

    Install the client and set the three required environment variables. Get your API key at smith.langchain.com → Settings → API Keys → Create API Key.

    pip install langsmith openai
    
    export LANGCHAIN_API_KEY="lsv2_pt_YOUR_KEY_HERE"
    export LANGCHAIN_TRACING_V2="true"
    export LANGCHAIN_PROJECT="ollama-c3-agent"
    npm install langsmith openai
    
    # Add to .env:
    LANGCHAIN_API_KEY=lsv2_pt_YOUR_KEY_HERE
    LANGCHAIN_TRACING_V2=true
    LANGCHAIN_PROJECT=ollama-c3-agent
    Verify install
    $ python -c "import langsmith; print(langsmith.__version__)" 0.1.x
    Checkpoint 1

    If you see a version number, the install succeeded. If you see ModuleNotFoundError, confirm your virtual environment is activated.

  2. Step 2 — Wrap run_agent() with @traceable

    Add the @traceable decorator to your agent's entry-point function. No changes needed inside the function body.

    from langsmith import traceable
    from openai import OpenAI
    
    client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
    
    @traceable(name="entity-resolution-agent")   # <-- ADD THIS LINE
    def run_agent(query: str) -> str:
        # Your existing agent code — no changes needed inside
        messages = [
            {"role": "system", "content": "You are an entity resolution specialist."},
            {"role": "user", "content": query}
        ]
        response = client.chat.completions.create(
            model="mistral", messages=messages, tools=TOOLS, tool_choice="auto"
        )
        return response.choices[0].message.content or ""
    import { traceable } from 'langsmith/traceable';
    import OpenAI from 'openai';
    
    const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
    
    export const runAgent = traceable(
      async (query: string): Promise<string> => {
        // Your existing agent code unchanged
        const response = await client.chat.completions.create({
          model: 'mistral',
          messages: [{ role: 'user', content: query }]
        });
        return response.choices[0].message.content ?? '';
      },
      { name: 'entity-resolution-agent' }
    );
    Checkpoint 2

    @traceable is a no-op when LANGCHAIN_TRACING_V2 is not set to true. Confirm with: python -c "import os; print(os.getenv('LANGCHAIN_TRACING_V2'))". It should print true.

  3. Step 3 — Wrap tool handlers with @traceable

    Add @traceable to each tool function so they appear as named child spans rather than undifferentiated time inside the LLM span.

    from langsmith import traceable
    
    @traceable(name="fuzzy-match-score")
    def fuzzy_match_score(a: str, b: str) -> float:
        from rapidfuzz import fuzz
        return fuzz.token_sort_ratio(a.lower(), b.lower()) / 100.0
    
    @traceable(name="search-filings")
    def search_filings(query: str, limit: int = 3) -> list[dict]:
        return []  # your implementation
    
    @traceable(name="merge-entity-profiles")
    def merge_entity_profiles(profile_a: dict, profile_b: dict,
                               confidence: float) -> dict:
        return {}  # your implementation
    import { traceable } from 'langsmith/traceable';
    
    export const fuzzyMatchScore = traceable(
      async (a: string, b: string) => { /* your implementation */ return 0.97; },
      { name: 'fuzzy-match-score' }
    );
    
    export const searchFilings = traceable(
      async (query: string, limit = 3) => { /* your implementation */ return []; },
      { name: 'search-filings' }
    );
    
    export const mergeEntityProfiles = traceable(
      async (profileA: object, profileB: object, confidence: number) => {
        /* your implementation */ return {};
      },
      { name: 'merge-entity-profiles' }
    );
    Checkpoint 3

    Every function decorated with @traceable appears as a named span. Functions called inside a @traceable function but NOT decorated appear as untraced time inside the parent span's timeline.

  4. Step 4 — Run the agent and view the trace

    Execute one agent query, then open the LangSmith dashboard to confirm the trace appears.

    python -c "
    from capstone_c3.agent import run_agent
    result = run_agent(\"Are 'Acme Logistics LLC' and 'ACME LOGISTICS, L.L.C.' the same entity?\")
    print('Answer:', result)
    "
    npx ts-node -e "
    import { runAgent } from './capstone_c3/agent.js';
    runAgent(\"Are 'Acme LLC' and 'ACME LLC' the same entity?\").then(console.log);
    "
    Expected output
    Answer: Based on my analysis, 'Acme Logistics LLC' and 'ACME LOGISTICS, L.L.C.' refer to the same entity with confidence 0.97. # In LangSmith: smith.langchain.com # Projects → ollama-c3-agent → (your run) # Spans: entity-resolution-agent > ChatOllama [mistral] > fuzzy-match-score / search-filings # Filter: model = mistral (separates Ollama runs from cloud runs)
    Checkpoint 4 — Success Criteria
    • Run appears in LangSmith under project ollama-c3-agent
    • Model field shows mistral (your Ollama model)
    • Token counts visible per LLM turn
    • Tool call durations visible as separate child spans
    • Filtering project by model: mistral isolates your local Ollama runs

Knowledge Check

Six questions. Immediate feedback on each answer.

1. You want to find every LLM call in your trace file where finish_reason was length (output truncated). Which approach works in a single shell command with a structured JSON Lines log?

A
grep "length" agent.log — searches for the string "length" anywhere in each line
B
cat agent.log | jq 'select(.finish_reason == "length")' — uses the structured key directly
C
You must write a Python script — shell tools cannot parse JSON
D
Neither approach works — you need to check the model provider's dashboard

2. You add @traceable to run_agent() but tool call spans do NOT appear in the LangSmith trace tree. What is the most likely cause?

A
Ollama is not supported — you must use a cloud model
B
LANGCHAIN_PROJECT env var is missing, so spans have nowhere to go
C
Tool handler functions are not decorated with @traceable, so they show as untraced time inside the parent span
D
@traceable only captures the function's direct return value, not nested calls

3. In OpenTelemetry, how do you mark a span as failed so Jaeger shows it in red?

A
Raise an exception inside the span — OTEL automatically detects uncaught exceptions
B
Call span.set_status(StatusCode.ERROR, description) before span.end()
C
Set span.set_attribute("error", True) — the exporter interprets this as an error
D
Delete the span — Jaeger marks missing spans as errors by default

4. Which statement about JSON Lines (JSONL) format is correct?

A
Each line is a complete, independent JSON object — files can be appended to without reading existing content
B
JSONL files wrap all objects in a top-level array, with comma-separated objects on separate lines
C
JSONL is proprietary to Pandas — jq cannot parse it
D
JSONL requires an index file alongside the log file to support random access

5. In the OTEL span hierarchy shown in this module, which tool call has the highest latency?

A
tool:fuzzy_match at 4ms
B
tool:merge_profiles at 12ms
C
llm-turn-1 at 1 244ms (LLM calls are always slowest)
D
tool:search_filings at 823ms — the highest latency among tool calls (not LLM calls)

6. What are the correct Docker port mappings for the Jaeger all-in-one container used in this module?

A
-p 6831:6831 (Thrift UDP) -p 16686:16686 (UI)
B
-p 14268:14268 (HTTP collector) -p 8080:8080 (UI)
C
-p 4317:4317 (OTLP gRPC) -p 16686:16686 (Jaeger UI)
D
-p 4318:4318 (OTLP HTTP) -p 3000:3000 (Grafana)
0 / 6

Answer all questions to see your final score.