# VibePod Logging & Datasette Specification

---

## Overview

VibePod logs all API requests to a SQLite database, viewable via Datasette.

```
┌─────────────────────────────────────────────────────────────────┐
│                     Agent Container                              │
│  ┌──────────────┐        ┌──────────────┐        ┌───────────┐ │
│  │    Agent     │───────►│   mitmproxy  │───────►│    API    │ │
│  │              │        │              │        │           │ │
│  └──────────────┘        └──────┬───────┘        └───────────┘ │
└─────────────────────────────────┼───────────────────────────────┘
                                  │
                                  ▼ (write via volume)
                         ~/.config/vibepod/logs.db
                                  │
                                  ▼
                         ┌──────────────────┐
                         │    Datasette     │ :8001
                         │    Container     │
                         └──────────────────┘
```

---

## Database Schema

### `logs.db`

```sql
-- API Requests
CREATE TABLE IF NOT EXISTS requests (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    -- Agent info
    agent TEXT NOT NULL,           -- claude, gemini, opencode, devstral, auggie
    session_id TEXT,               -- Container session ID
    workspace TEXT,                -- Workspace path
    
    -- Provider info
    provider TEXT NOT NULL,        -- anthropic, google, openai, mistral, augment
    model TEXT,                    -- claude-sonnet-4-20250514, gemini-2.5-pro, etc.
    
    -- Request
    endpoint TEXT NOT NULL,        -- /v1/messages, /v1/chat/completions
    method TEXT NOT NULL,          -- POST, GET
    request_headers TEXT,          -- JSON (optional)
    request_body TEXT,             -- JSON (optional, configurable)
    
    -- Response
    response_status INTEGER,
    response_headers TEXT,         -- JSON (optional)
    response_body TEXT,            -- JSON (optional, configurable)
    
    -- Metrics
    input_tokens INTEGER,
    output_tokens INTEGER,
    total_tokens INTEGER,
    latency_ms INTEGER,
    
    -- Cost (calculated)
    cost_usd REAL,
    
    -- Error info
    error_type TEXT,
    error_message TEXT
);

-- Indexes
CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp);
CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent);
CREATE INDEX IF NOT EXISTS idx_requests_provider ON requests(provider);
CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model);
CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id);

-- Sessions
CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    agent TEXT NOT NULL,
    workspace TEXT,
    started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    ended_at DATETIME,
    total_requests INTEGER DEFAULT 0,
    total_tokens INTEGER DEFAULT 0,
    total_cost_usd REAL DEFAULT 0
);

CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);

-- Cost rates (for calculation)
CREATE TABLE IF NOT EXISTS cost_rates (
    provider TEXT NOT NULL,
    model TEXT NOT NULL,
    input_cost_per_1m REAL,        -- Cost per 1M input tokens
    output_cost_per_1m REAL,       -- Cost per 1M output tokens
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (provider, model)
);

-- Default cost rates (approximate, update as needed)
INSERT OR REPLACE INTO cost_rates (provider, model, input_cost_per_1m, output_cost_per_1m) VALUES
    ('anthropic', 'claude-sonnet-4-20250514', 3.00, 15.00),
    ('anthropic', 'claude-opus-4-20250514', 15.00, 75.00),
    ('anthropic', 'claude-haiku-4-20250514', 0.25, 1.25),
    ('google', 'gemini-2.5-pro', 1.25, 5.00),
    ('google', 'gemini-2.5-flash', 0.075, 0.30),
    ('openai', 'gpt-4o', 2.50, 10.00),
    ('openai', 'gpt-4o-mini', 0.15, 0.60),
    ('mistral', 'codestral-latest', 0.30, 0.90);

-- Views for common queries
CREATE VIEW IF NOT EXISTS v_daily_usage AS
SELECT 
    DATE(timestamp) as date,
    agent,
    COUNT(*) as requests,
    SUM(total_tokens) as tokens,
    ROUND(SUM(cost_usd), 4) as cost_usd
FROM requests
GROUP BY DATE(timestamp), agent
ORDER BY date DESC;

CREATE VIEW IF NOT EXISTS v_model_usage AS
SELECT 
    provider,
    model,
    COUNT(*) as requests,
    SUM(input_tokens) as input_tokens,
    SUM(output_tokens) as output_tokens,
    ROUND(SUM(cost_usd), 4) as cost_usd,
    ROUND(AVG(latency_ms), 0) as avg_latency_ms
FROM requests
GROUP BY provider, model
ORDER BY requests DESC;
```

---

## Logging Module

### `src/vibepod/core/logging.py`

```python
"""API request logging."""

from __future__ import annotations

import json
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

from vibepod.constants import LOGS_DB_FILE


class LogDatabase:
    """SQLite logging database."""
    
    def __init__(self, db_path: Optional[Path] = None):
        """Initialize database."""
        self.db_path = db_path or LOGS_DB_FILE
        self.db_path.parent.mkdir(parents=True, exist_ok=True)
        self._init_db()
    
    def _init_db(self) -> None:
        """Initialize database schema."""
        with sqlite3.connect(self.db_path) as conn:
            conn.executescript(SCHEMA_SQL)
    
    def _get_connection(self) -> sqlite3.Connection:
        """Get database connection."""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        return conn
    
    def log_request(
        self,
        agent: str,
        provider: str,
        endpoint: str,
        method: str,
        session_id: Optional[str] = None,
        workspace: Optional[str] = None,
        model: Optional[str] = None,
        request_body: Optional[dict] = None,
        response_status: Optional[int] = None,
        response_body: Optional[dict] = None,
        input_tokens: Optional[int] = None,
        output_tokens: Optional[int] = None,
        latency_ms: Optional[int] = None,
        error_type: Optional[str] = None,
        error_message: Optional[str] = None,
    ) -> int:
        """Log an API request."""
        total_tokens = None
        if input_tokens is not None and output_tokens is not None:
            total_tokens = input_tokens + output_tokens
        
        cost_usd = self._calculate_cost(provider, model, input_tokens, output_tokens)
        
        with self._get_connection() as conn:
            cursor = conn.execute(
                """
                INSERT INTO requests (
                    agent, session_id, workspace, provider, model,
                    endpoint, method, request_body, response_status, response_body,
                    input_tokens, output_tokens, total_tokens, latency_ms, cost_usd,
                    error_type, error_message
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    agent, session_id, workspace, provider, model,
                    endpoint, method,
                    json.dumps(request_body) if request_body else None,
                    response_status,
                    json.dumps(response_body) if response_body else None,
                    input_tokens, output_tokens, total_tokens, latency_ms, cost_usd,
                    error_type, error_message,
                ),
            )
            
            # Update session
            if session_id:
                conn.execute(
                    """
                    UPDATE sessions
                    SET total_requests = total_requests + 1,
                        total_tokens = total_tokens + COALESCE(?, 0),
                        total_cost_usd = total_cost_usd + COALESCE(?, 0)
                    WHERE id = ?
                    """,
                    (total_tokens, cost_usd, session_id),
                )
            
            return cursor.lastrowid
    
    def _calculate_cost(
        self,
        provider: Optional[str],
        model: Optional[str],
        input_tokens: Optional[int],
        output_tokens: Optional[int],
    ) -> Optional[float]:
        """Calculate cost based on model rates."""
        if not all([provider, model, input_tokens, output_tokens]):
            return None
        
        with self._get_connection() as conn:
            row = conn.execute(
                "SELECT input_cost_per_1m, output_cost_per_1m FROM cost_rates WHERE provider = ? AND model = ?",
                (provider, model),
            ).fetchone()
            
            if not row:
                return None
            
            input_cost = (input_tokens / 1_000_000) * row["input_cost_per_1m"]
            output_cost = (output_tokens / 1_000_000) * row["output_cost_per_1m"]
            return round(input_cost + output_cost, 6)
    
    def start_session(self, session_id: str, agent: str, workspace: Optional[str] = None) -> None:
        """Start a new session."""
        with self._get_connection() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO sessions (id, agent, workspace) VALUES (?, ?, ?)",
                (session_id, agent, workspace),
            )
    
    def end_session(self, session_id: str) -> None:
        """End a session."""
        with self._get_connection() as conn:
            conn.execute(
                "UPDATE sessions SET ended_at = CURRENT_TIMESTAMP WHERE id = ?",
                (session_id,),
            )
    
    def query(self, sql: str, params: tuple = ()) -> list[dict]:
        """Execute SQL query."""
        with self._get_connection() as conn:
            cursor = conn.execute(sql, params)
            return [dict(row) for row in cursor.fetchall()]
    
    def get_stats(self, period: str = "week", agent: Optional[str] = None) -> dict[str, Any]:
        """Get usage statistics."""
        period_map = {
            "day": "-1 day",
            "week": "-7 days",
            "month": "-30 days",
        }
        since = period_map.get(period, "-7 days")
        
        base_query = f"timestamp > datetime('now', '{since}')"
        if agent:
            base_query += f" AND agent = '{agent}'"
        
        with self._get_connection() as conn:
            # Overall stats
            overall = conn.execute(f"""
                SELECT 
                    COUNT(*) as requests,
                    SUM(total_tokens) as tokens,
                    ROUND(SUM(cost_usd), 4) as cost_usd
                FROM requests
                WHERE {base_query}
            """).fetchone()
            
            # By agent
            by_agent = conn.execute(f"""
                SELECT 
                    agent,
                    COUNT(*) as requests,
                    SUM(total_tokens) as tokens,
                    ROUND(SUM(cost_usd), 4) as cost_usd
                FROM requests
                WHERE {base_query}
                GROUP BY agent
                ORDER BY cost_usd DESC
            """).fetchall()
            
            return {
                "period": period,
                "total_requests": overall["requests"] or 0,
                "total_tokens": overall["tokens"] or 0,
                "total_cost_usd": overall["cost_usd"] or 0,
                "by_agent": [dict(row) for row in by_agent],
            }
    
    def export(
        self,
        format: str = "csv",
        since: Optional[datetime] = None,
        agent: Optional[str] = None,
    ) -> str:
        """Export logs."""
        query = "SELECT * FROM requests WHERE 1=1"
        params = []
        
        if since:
            query += " AND timestamp > ?"
            params.append(since.isoformat())
        if agent:
            query += " AND agent = ?"
            params.append(agent)
        
        query += " ORDER BY timestamp DESC"
        
        rows = self.query(query, tuple(params))
        
        if format == "json":
            return json.dumps(rows, indent=2)
        elif format == "csv":
            import csv
            import io
            output = io.StringIO()
            if rows:
                writer = csv.DictWriter(output, fieldnames=rows[0].keys())
                writer.writeheader()
                writer.writerows(rows)
            return output.getvalue()
        else:
            raise ValueError(f"Unknown format: {format}")
    
    def clear(self, before: Optional[datetime] = None) -> int:
        """Clear logs."""
        with self._get_connection() as conn:
            if before:
                cursor = conn.execute(
                    "DELETE FROM requests WHERE timestamp < ?",
                    (before.isoformat(),),
                )
            else:
                cursor = conn.execute("DELETE FROM requests")
            return cursor.rowcount
    
    def cleanup_old_logs(self, retention_days: int) -> int:
        """Remove logs older than retention period."""
        if retention_days <= 0:
            return 0
        
        with self._get_connection() as conn:
            cursor = conn.execute(
                f"DELETE FROM requests WHERE timestamp < datetime('now', '-{retention_days} days')"
            )
            return cursor.rowcount


# Schema SQL
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS requests (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
    agent TEXT NOT NULL,
    session_id TEXT,
    workspace TEXT,
    provider TEXT NOT NULL,
    model TEXT,
    endpoint TEXT NOT NULL,
    method TEXT NOT NULL,
    request_headers TEXT,
    request_body TEXT,
    response_status INTEGER,
    response_headers TEXT,
    response_body TEXT,
    input_tokens INTEGER,
    output_tokens INTEGER,
    total_tokens INTEGER,
    latency_ms INTEGER,
    cost_usd REAL,
    error_type TEXT,
    error_message TEXT
);

CREATE INDEX IF NOT EXISTS idx_requests_timestamp ON requests(timestamp);
CREATE INDEX IF NOT EXISTS idx_requests_agent ON requests(agent);
CREATE INDEX IF NOT EXISTS idx_requests_provider ON requests(provider);
CREATE INDEX IF NOT EXISTS idx_requests_model ON requests(model);
CREATE INDEX IF NOT EXISTS idx_requests_session ON requests(session_id);

CREATE TABLE IF NOT EXISTS sessions (
    id TEXT PRIMARY KEY,
    agent TEXT NOT NULL,
    workspace TEXT,
    started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    ended_at DATETIME,
    total_requests INTEGER DEFAULT 0,
    total_tokens INTEGER DEFAULT 0,
    total_cost_usd REAL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS cost_rates (
    provider TEXT NOT NULL,
    model TEXT NOT NULL,
    input_cost_per_1m REAL,
    output_cost_per_1m REAL,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (provider, model)
);

INSERT OR IGNORE INTO cost_rates (provider, model, input_cost_per_1m, output_cost_per_1m) VALUES
    ('anthropic', 'claude-sonnet-4-20250514', 3.00, 15.00),
    ('anthropic', 'claude-opus-4-20250514', 15.00, 75.00),
    ('google', 'gemini-2.5-pro', 1.25, 5.00),
    ('openai', 'gpt-4o', 2.50, 10.00),
    ('mistral', 'codestral-latest', 0.30, 0.90);
"""
```

---

## Datasette Integration

### Starting Datasette

```python
"""Datasette management."""

from vibepod.core.docker import DockerClient
from vibepod.core.config import get_config


def start_datasette(port: int = 8001) -> str:
    """Start Datasette container."""
    config = get_config()
    docker = DockerClient()
    
    container = docker.client.containers.run(
        config["logging"]["image"],
        name="vibepod-datasette",
        ports={"8001/tcp": port},
        volumes={
            str(config["logging"]["db_path"]): {"bind": "/data/logs.db", "mode": "ro"}
        },
        detach=True,
        remove=True,
    )
    
    return f"http://localhost:{port}"


def stop_datasette() -> None:
    """Stop Datasette container."""
    docker = DockerClient()
    try:
        container = docker.client.containers.get("vibepod-datasette")
        container.stop()
    except Exception:
        pass
```

### Datasette Container

#### `datasette/Dockerfile`

```dockerfile
FROM python:3.12-slim

RUN pip install --no-cache-dir datasette datasette-vega

WORKDIR /data

EXPOSE 8001

CMD ["datasette", "/data/logs.db", "--host", "0.0.0.0", "--port", "8001", "--metadata", "/data/metadata.yaml"]
```

#### `datasette/metadata.yaml`

```yaml
title: VibePod API Logs
description: API request logs for AI coding agents
databases:
  logs:
    tables:
      requests:
        label_column: timestamp
        description: Individual API requests
        sort_desc: timestamp
      sessions:
        label_column: id
        description: Agent sessions
        sort_desc: started_at
      cost_rates:
        description: Cost rates per model
        
    queries:
      daily_usage:
        title: Daily Usage
        sql: |
          SELECT 
            DATE(timestamp) as date,
            agent,
            COUNT(*) as requests,
            SUM(total_tokens) as tokens,
            ROUND(SUM(cost_usd), 4) as cost_usd
          FROM requests
          WHERE timestamp > datetime('now', '-30 days')
          GROUP BY DATE(timestamp), agent
          ORDER BY date DESC
      
      top_models:
        title: Top Models
        sql: |
          SELECT 
            provider,
            model,
            COUNT(*) as requests,
            ROUND(SUM(cost_usd), 4) as cost_usd
          FROM requests
          GROUP BY provider, model
          ORDER BY requests DESC
          LIMIT 20
      
      recent_errors:
        title: Recent Errors
        sql: |
          SELECT 
            timestamp,
            agent,
            endpoint,
            error_type,
            error_message
          FROM requests
          WHERE error_type IS NOT NULL
          ORDER BY timestamp DESC
          LIMIT 50
```

---

## Logs Commands

### `src/vibepod/commands/logs.py`

```python
"""Logs commands."""

import webbrowser
from typing import Optional

import typer
from rich.console import Console
from rich.table import Table
from typing_extensions import Annotated

from vibepod.core.logging import LogDatabase
from vibepod.core.docker import DockerClient
from vibepod.core.config import get_config

console = Console()
app = typer.Typer()


@app.command()
def ui(
    port: Annotated[int, typer.Option("--port", help="Datasette port")] = 8001,
    no_open: Annotated[bool, typer.Option("--no-open", help="Don't open browser")] = False,
):
    """Open Datasette UI."""
    config = get_config()
    docker = DockerClient()
    
    console.print("[dim]Starting Datasette...[/dim]")
    
    # Check if already running
    try:
        container = docker.client.containers.get("vibepod-datasette")
        if container.status == "running":
            url = f"http://localhost:{port}"
            console.print(f"[green]Datasette running at {url}[/green]")
            if not no_open:
                webbrowser.open(url)
            return
    except Exception:
        pass
    
    # Start Datasette
    container = docker.client.containers.run(
        config["logging"]["image"],
        name="vibepod-datasette",
        ports={"8001/tcp": port},
        volumes={
            str(config["logging"]["db_path"]): {"bind": "/data/logs.db", "mode": "ro"}
        },
        detach=True,
        remove=True,
    )
    
    url = f"http://localhost:{port}"
    console.print(f"[green]Datasette started at {url}[/green]")
    
    if not no_open:
        webbrowser.open(url)


@app.command()
def query(
    sql: Annotated[str, typer.Argument(help="SQL query")],
    format: Annotated[str, typer.Option("--format", "-f", help="Output format")] = "table",
):
    """Run SQL query on logs."""
    db = LogDatabase()
    
    try:
        rows = db.query(sql)
    except Exception as e:
        console.print(f"[red]Query error: {e}[/red]")
        raise typer.Exit(1)
    
    if not rows:
        console.print("[dim]No results[/dim]")
        return
    
    if format == "json":
        import json
        console.print(json.dumps(rows, indent=2))
    elif format == "csv":
        import csv
        import sys
        writer = csv.DictWriter(sys.stdout, fieldnames=rows[0].keys())
        writer.writeheader()
        writer.writerows(rows)
    else:  # table
        table = Table()
        for col in rows[0].keys():
            table.add_column(col)
        for row in rows:
            table.add_row(*[str(v) for v in row.values()])
        console.print(table)


@app.command()
def stats(
    period: Annotated[str, typer.Option("--period", "-p", help="Period: day, week, month")] = "week",
    agent: Annotated[Optional[str], typer.Option("--agent", "-a", help="Filter by agent")] = None,
):
    """Show usage statistics."""
    db = LogDatabase()
    stats = db.get_stats(period=period, agent=agent)
    
    console.print(f"\n[bold]API Usage (Last {period})[/bold]\n")
    
    table = Table()
    table.add_column("Agent")
    table.add_column("Requests", justify="right")
    table.add_column("Tokens", justify="right")
    table.add_column("Cost", justify="right")
    
    for row in stats["by_agent"]:
        table.add_row(
            row["agent"],
            str(row["requests"]),
            f"{row['tokens']:,}" if row["tokens"] else "-",
            f"${row['cost_usd']:.4f}" if row["cost_usd"] else "-",
        )
    
    table.add_row(
        "[bold]TOTAL[/bold]",
        f"[bold]{stats['total_requests']}[/bold]",
        f"[bold]{stats['total_tokens']:,}[/bold]" if stats["total_tokens"] else "-",
        f"[bold]${stats['total_cost_usd']:.4f}[/bold]" if stats["total_cost_usd"] else "-",
    )
    
    console.print(table)


@app.command()
def export(
    output: Annotated[Optional[str], typer.Option("--output", "-o", help="Output file")] = None,
    format: Annotated[str, typer.Option("--format", "-f", help="Format: csv, json")] = "csv",
    agent: Annotated[Optional[str], typer.Option("--agent", "-a", help="Filter by agent")] = None,
):
    """Export logs."""
    db = LogDatabase()
    data = db.export(format=format, agent=agent)
    
    if output:
        with open(output, "w") as f:
            f.write(data)
        console.print(f"[green]Exported to {output}[/green]")
    else:
        console.print(data)


@app.command()
def clear(
    force: Annotated[bool, typer.Option("--force", "-f", help="Skip confirmation")] = False,
):
    """Clear log database."""
    if not force:
        confirm = typer.confirm("Are you sure you want to clear all logs?")
        if not confirm:
            raise typer.Abort()
    
    db = LogDatabase()
    count = db.clear()
    console.print(f"[green]Cleared {count} log entries[/green]")
```
