@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
@traceableand the OpenAI-compatible client - Set up OpenTelemetry with a Jaeger backend for span-based distributed tracing
- Build a zero-dependency
@trace_agentdecorator 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
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.
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.
What to Trace: The Four 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
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 crashingfrom 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
});
}
}
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.
Structured Logging with structlog / pino
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.
Configuring structlog for Agent Logging
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;
}
}
}
LangSmith Tracing with Ollama
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.
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.
Installation and Environment
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
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);
OpenTelemetry + Jaeger
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.
Installation and Jaeger Setup
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
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
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.
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.
WHY:
df[df.latency_ms > 2000] finds all slow steps in one line; no need to write a parser from scratchGOTCHA: 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);
Lab: Instrument the Capstone C3 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.
-
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-agentVerify install$ python -c "import langsmith; print(langsmith.__version__)" 0.1.xCheckpoint 1If you see a version number, the install succeeded. If you see
ModuleNotFoundError, confirm your virtual environment is activated. -
Step 2 — Wrap run_agent() with @traceable
Add the
@traceabledecorator 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@traceableis a no-op whenLANGCHAIN_TRACING_V2is not set totrue. Confirm with:python -c "import os; print(os.getenv('LANGCHAIN_TRACING_V2'))". It should printtrue. -
Step 3 — Wrap tool handlers with @traceable
Add
@traceableto 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 implementationimport { 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 3Every function decorated with
@traceableappears as a named span. Functions called inside a@traceablefunction but NOT decorated appear as untraced time inside the parent span's timeline. -
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 outputAnswer: 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: mistralisolates your local Ollama runs
- Run appears in LangSmith under project
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?
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?
3. In OpenTelemetry, how do you mark a span as failed so Jaeger shows it in red?
4. Which statement about JSON Lines (JSONL) format is correct?
5. In the OTEL span hierarchy shown in this module, which tool call has the highest latency?
6. What are the correct Docker port mappings for the Jaeger all-in-one container used in this module?
Answer all questions to see your final score.