mech.app
Dev Tools

The 71-Line Black Box: How DuckDB Turns Agent Crash Logs Into Queryable Incident Reports

Practical pattern for recording agent tool calls, sanitizing traces, and querying failures with embedded DuckDB without vendor platforms.

Source: dev.to
The 71-Line Black Box: How DuckDB Turns Agent Crash Logs Into Queryable Incident Reports

The $200 crash did not happen. It almost happened. A support automation agent got stuck in a retry loop during a test run. The projection was clear: same bad loop, same document search, same model calls, left running overnight. The estimate landed near $200 for one avoidable failure.

The trace was a mess. The agent called the right tool with the wrong input, retried against stale context, summarized old results, and kept paying for each turn. The final answer looked polished enough to pass a sleepy review. The execution behind it was not polished at all.

That is when the agent stopped being a chat feature and became a system that needs a black box. Not a dashboard. Not a full observability stack. Not another hosted service. Just one local file that can answer: What did the agent try? Which tool did it call? What input did the tool receive? Did the tool fail? How long did it take? Did the run cross a cost or turn limit? Can I query the run after everything is over?

This is the 71-line implementation that captures tool calls, stops runaway loops, and enables SQL queries over crash data without external services.

The Observability Gap in Agent Workflows

Most agent frameworks give you logs. Logs are fine for happy paths. They fall apart when you need to reconstruct a multi-turn failure three days later.

The questions you need to answer are relational:

  • Which tool calls happened before the timeout?
  • Did the agent retry the same input twice?
  • What was the cumulative cost before the circuit breaker fired?
  • Which turn reused stale context?

Logs are append-only text. You need structured data and a query layer.

The standard answer is OpenTelemetry spans or a vendor observability SDK. Both work. Both add dependencies, configuration, and external services. For a single-agent workflow running on a developer laptop or a small batch server, that is overhead you do not need yet.

The alternative is a local black box: a Python decorator that intercepts tool calls, writes structured records to a DuckDB file, and lets you query failures with SQL.

Architecture: Interception, Recording, and Query

The black box sits between the agent framework and the tool functions. It does not modify framework code. It wraps tool functions with a decorator that records invocations.

Flow:

  1. Agent decides to call a tool.
  2. Decorator intercepts the call.
  3. Decorator records: tool name, input, timestamp, run ID.
  4. Tool executes.
  5. Decorator records: output, error, duration, token count (if available).
  6. Decorator checks circuit breaker: turn limit, cost limit, timeout.
  7. If breaker trips, raise exception and stop the run.
  8. Write all records to DuckDB file.

Storage schema:

CREATE TABLE tool_calls (
    run_id TEXT,
    turn_number INTEGER,
    tool_name TEXT,
    input_json TEXT,
    output_json TEXT,
    error_message TEXT,
    duration_ms REAL,
    timestamp TIMESTAMP,
    token_count INTEGER,
    cost_estimate REAL
);

DuckDB is embedded. No server. No network. The file lives next to your agent script. You query it with the DuckDB CLI or Python client.

Implementation: The 71-Line Decorator

The decorator wraps any tool function. It uses Python’s functools.wraps to preserve the original signature. It uses a global context object to track the current run ID and turn number.

Core logic:

import functools
import time
import json
import duckdb
from datetime import datetime

class AgentBlackBox:
    def __init__(self, db_path="agent_trace.duckdb", max_turns=50, max_cost=10.0):
        self.db_path = db_path
        self.max_turns = max_turns
        self.max_cost = max_cost
        self.current_run_id = None
        self.turn_counter = 0
        self.total_cost = 0.0
        self._init_db()
    
    def _init_db(self):
        conn = duckdb.connect(self.db_path)
        conn.execute("""
            CREATE TABLE IF NOT EXISTS tool_calls (
                run_id TEXT,
                turn_number INTEGER,
                tool_name TEXT,
                input_json TEXT,
                output_json TEXT,
                error_message TEXT,
                duration_ms REAL,
                timestamp TIMESTAMP,
                token_count INTEGER,
                cost_estimate REAL
            )
        """)
        conn.close()
    
    def start_run(self, run_id):
        self.current_run_id = run_id
        self.turn_counter = 0
        self.total_cost = 0.0
    
    def record_tool(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            if self.turn_counter >= self.max_turns:
                raise RuntimeError(f"Circuit breaker: exceeded {self.max_turns} turns")
            
            self.turn_counter += 1
            start = time.perf_counter()
            error_msg = None
            output = None
            token_count = 0
            cost = 0.0
            
            try:
                output = func(*args, **kwargs)
                # Extract token count if tool returns metadata
                if isinstance(output, dict) and "tokens" in output:
                    token_count = output["tokens"]
                    cost = token_count * 0.00002  # Example pricing
                    self.total_cost += cost
            except Exception as e:
                error_msg = str(e)
                raise
            finally:
                duration = (time.perf_counter() - start) * 1000
                self._write_record(
                    tool_name=func.__name__,
                    input_data={"args": args, "kwargs": kwargs},
                    output_data=output,
                    error_message=error_msg,
                    duration_ms=duration,
                    token_count=token_count,
                    cost_estimate=cost
                )
                
                if self.total_cost > self.max_cost:
                    raise RuntimeError(f"Circuit breaker: exceeded ${self.max_cost} cost")
            
            return output
        return wrapper
    
    def _write_record(self, tool_name, input_data, output_data, error_message, duration_ms, token_count, cost_estimate):
        conn = duckdb.connect(self.db_path)
        conn.execute("""
            INSERT INTO tool_calls VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, [
            self.current_run_id,
            self.turn_counter,
            tool_name,
            json.dumps(input_data),
            json.dumps(output_data) if output_data else None,
            error_message,
            duration_ms,
            datetime.now(),
            token_count,
            cost_estimate
        ])
        conn.close()

# Usage
black_box = AgentBlackBox(max_turns=20, max_cost=5.0)

@black_box.record_tool
def search_docs(query):
    # Simulate document search
    time.sleep(0.15)
    return {"results": ["doc1", "doc2"], "tokens": 500}

@black_box.record_tool
def summarize(text):
    # Simulate LLM call
    time.sleep(0.3)
    return {"summary": "...", "tokens": 1200}

The decorator is 71 lines with whitespace and comments removed. It handles:

  • Turn counting
  • Cost tracking
  • Duration measurement
  • Error capture
  • Structured logging to DuckDB

Sanitization: Stripping Secrets Before Persistence

Agent tool calls often include API keys, user emails, or document content. You cannot write raw inputs to a local file without sanitization.

Sanitization strategies:

Data TypeSanitization MethodTrade-off
API keysRedact entirelyLose ability to debug auth failures
User emailsHash with saltPreserve uniqueness, lose readability
Document contentStore first 100 chars + hashPreserve context, lose full text
Tool outputsRedact PII fieldsRequires schema knowledge
Error messagesRegex filter for secretsFragile, misses novel patterns

The black box adds a sanitization hook before writing:

def _sanitize(self, data):
    if isinstance(data, dict):
        sanitized = {}
        for key, value in data.items():
            if key in ["api_key", "password", "token"]:
                sanitized[key] = "[REDACTED]"
            elif key == "email":
                sanitized[key] = hashlib.sha256(value.encode()).hexdigest()[:16]
            elif key == "content" and isinstance(value, str):
                sanitized[key] = value[:100] + "..." if len(value) > 100 else value
            else:
                sanitized[key] = self._sanitize(value)
        return sanitized
    elif isinstance(data, list):
        return [self._sanitize(item) for item in data]
    return data

This runs before json.dumps() in _write_record(). It is not perfect. It misses secrets in nested structures or non-standard field names. For production use, combine with a secret scanning library like detect-secrets or truffleHog.

Querying the Crash: SQL Over Agent Traces

Once the black box writes to DuckDB, you query it like any relational database.

Find all failed tool calls:

SELECT run_id, turn_number, tool_name, error_message, duration_ms
FROM tool_calls
WHERE error_message IS NOT NULL
ORDER BY timestamp DESC;

Find runs that exceeded 10 turns:

SELECT run_id, COUNT(*) as turn_count, SUM(cost_estimate) as total_cost
FROM tool_calls
GROUP BY run_id
HAVING COUNT(*) > 10
ORDER BY total_cost DESC;

Find duplicate tool calls (retry loops):

SELECT run_id, tool_name, input_json, COUNT(*) as call_count
FROM tool_calls
GROUP BY run_id, tool_name, input_json
HAVING COUNT(*) > 1;

Find the slowest tool calls:

SELECT tool_name, AVG(duration_ms) as avg_duration, MAX(duration_ms) as max_duration
FROM tool_calls
GROUP BY tool_name
ORDER BY avg_duration DESC;

DuckDB supports window functions, CTEs, and JSON extraction. You can query nested tool inputs without flattening the schema:

SELECT run_id, turn_number, 
       json_extract_string(input_json, '$.kwargs.query') as query
FROM tool_calls
WHERE tool_name = 'search_docs';

Circuit Breaker: Stopping Runaway Runs

The black box enforces two limits: max turns and max cost. Both are configurable at initialization.

Turn limit: Prevents infinite loops. If the agent calls tools more than max_turns times in one run, the decorator raises RuntimeError and stops execution.

Cost limit: Prevents budget overruns. If cumulative cost_estimate exceeds max_cost, the decorator raises RuntimeError.

Both limits are checked after the tool executes but before returning to the agent. This ensures the tool call is recorded even if the breaker trips.

Why not check before execution? Because you want the trace to show the exact call that triggered the limit. If you check before, the trace stops one turn early and you lose context.

Comparison: Black Box vs. OpenTelemetry vs. Vendor SDKs

ApproachSetup ComplexityQuery FlexibilityCostDependency Count
71-line black box1 file, 1 decoratorFull SQL via DuckDBFree1 (DuckDB)
OpenTelemetryCollector + exporter configLimited (trace UI)Free (self-hosted)3+ (SDK, exporter, backend)
Langfuse / HeliconeSDK + API keyVendor UI + APIPaid after free tier1 (vendor SDK)

The black box wins on simplicity and local control. It loses on distributed tracing, real-time dashboards, and team collaboration. If you are running one agent on one machine, the black box is enough. If you are running 50 agents across 10 servers, you need OpenTelemetry or a vendor platform.

Failure Modes and Mitigations

Failure mode: DuckDB file grows unbounded.

Mitigation: Add a retention policy. Delete records older than 30 days or runs older than 100 entries.

def _prune_old_runs(self):
    conn = duckdb.connect(self.db_path)
    conn.execute("""
        DELETE FROM tool_calls
        WHERE timestamp < NOW() - INTERVAL 30 DAYS
    """)
    conn.close()

Failure mode: Sanitization misses a secret.

Mitigation: Run a post-write scan with detect-secrets or a regex filter. Log warnings but do not block writes.

Failure mode: Circuit breaker trips too early.

Mitigation: Make limits configurable per run. Pass max_turns and max_cost to start_run() instead of hardcoding at initialization.

Failure mode: DuckDB write fails (disk full, permission error).

Mitigation: Wrap _write_record() in a try-except. Log the error but do not crash the agent. The tool call still executes.

Technical Verdict

Use this pattern when:

  • You are running a single agent or small batch of agents on one machine.
  • You need to debug failures after the fact, not in real time.
  • You want zero external dependencies beyond DuckDB.
  • You need SQL-level query flexibility over agent traces.
  • You are prototyping observability before committing to a vendor platform.

Avoid this pattern when:

  • You are running distributed agents across multiple servers.
  • You need real-time alerting or dashboards.
  • You need team-wide trace sharing and collaboration.
  • You are already using OpenTelemetry for other services.
  • You need compliance-grade audit logs (the black box is not tamper-proof).

The 71-line black box is not a replacement for production observability. It is a stepping stone. It gives you structured traces, cost control, and query power without the overhead of a full platform. When you outgrow it, the migration path is clear: export the DuckDB schema to OpenTelemetry spans or a vendor SDK. The data model is already relational. The hard part is already done.

Tags

agentic-ai orchestration infrastructure

Primary Source

dev.to