magic-lab-observer-cli 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- magic_lab_observer_cli-0.1.1/.gitignore +68 -0
- magic_lab_observer_cli-0.1.1/PKG-INFO +46 -0
- magic_lab_observer_cli-0.1.1/README.md +32 -0
- magic_lab_observer_cli-0.1.1/cli/__init__.py +1 -0
- magic_lab_observer_cli-0.1.1/cli/db.py +188 -0
- magic_lab_observer_cli-0.1.1/cli/main.py +414 -0
- magic_lab_observer_cli-0.1.1/pyproject.toml +27 -0
- magic_lab_observer_cli-0.1.1/tests/__init__.py +0 -0
- magic_lab_observer_cli-0.1.1/tests/test_cli.py +526 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
ENV/
|
|
27
|
+
|
|
28
|
+
# IDE
|
|
29
|
+
.vscode/
|
|
30
|
+
.idea/
|
|
31
|
+
*.swp
|
|
32
|
+
*.swo
|
|
33
|
+
*~
|
|
34
|
+
|
|
35
|
+
# Testing
|
|
36
|
+
.coverage
|
|
37
|
+
htmlcov/
|
|
38
|
+
.pytest_cache/
|
|
39
|
+
.mypy_cache/
|
|
40
|
+
|
|
41
|
+
# Node.js
|
|
42
|
+
node_modules/
|
|
43
|
+
npm-debug.log*
|
|
44
|
+
yarn-debug.log*
|
|
45
|
+
yarn-error.log*
|
|
46
|
+
|
|
47
|
+
# Build
|
|
48
|
+
dist/
|
|
49
|
+
build/
|
|
50
|
+
*.tsbuildinfo
|
|
51
|
+
|
|
52
|
+
# Environment
|
|
53
|
+
.env
|
|
54
|
+
.env.local
|
|
55
|
+
.env.*.local
|
|
56
|
+
|
|
57
|
+
# Podman
|
|
58
|
+
podman-compose.override.yml
|
|
59
|
+
|
|
60
|
+
# Database
|
|
61
|
+
*.db
|
|
62
|
+
*.sqlite3
|
|
63
|
+
.observer-state/
|
|
64
|
+
|
|
65
|
+
# OS
|
|
66
|
+
.DS_Store
|
|
67
|
+
Thumbs.db
|
|
68
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magic-lab-observer-cli
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI for LLM Observatory
|
|
5
|
+
Project-URL: Homepage, https://github.com/Magic-Lab-Studio/observer
|
|
6
|
+
Project-URL: Repository, https://github.com/Magic-Lab-Studio/observer.git
|
|
7
|
+
Project-URL: Issues, https://github.com/Magic-Lab-Studio/observer/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: click>=8.1.0
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
Requires-Dist: rich>=13.0.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# LLM Observatory CLI
|
|
16
|
+
|
|
17
|
+
Command-line interface for LLM Observatory.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install magic-lab-observer-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Before the distribution is published, install this directory from the checked-out
|
|
26
|
+
repository with `python -m pip install -e ./cli`.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Start the server
|
|
32
|
+
llm-observatory serve
|
|
33
|
+
|
|
34
|
+
# Run an evaluation
|
|
35
|
+
llm-observatory evaluate --trace-id abc123 --evaluator llm_judge --criteria accuracy,safety
|
|
36
|
+
|
|
37
|
+
# Export traces
|
|
38
|
+
llm-observatory export --format json --output traces.json
|
|
39
|
+
|
|
40
|
+
# Check server status
|
|
41
|
+
llm-observatory status
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
Apache License 2.0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# LLM Observatory CLI
|
|
2
|
+
|
|
3
|
+
Command-line interface for LLM Observatory.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install magic-lab-observer-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Before the distribution is published, install this directory from the checked-out
|
|
12
|
+
repository with `python -m pip install -e ./cli`.
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# Start the server
|
|
18
|
+
llm-observatory serve
|
|
19
|
+
|
|
20
|
+
# Run an evaluation
|
|
21
|
+
llm-observatory evaluate --trace-id abc123 --evaluator llm_judge --criteria accuracy,safety
|
|
22
|
+
|
|
23
|
+
# Export traces
|
|
24
|
+
llm-observatory export --format json --output traces.json
|
|
25
|
+
|
|
26
|
+
# Check server status
|
|
27
|
+
llm-observatory status
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## License
|
|
31
|
+
|
|
32
|
+
Apache License 2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""LLM Observatory CLI - Command-line interface for LLM Observatory."""
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Database helper for local SQLite access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
DEFAULT_DB_PATH = Path("observatory.db")
|
|
10
|
+
|
|
11
|
+
# DDL to create tables if the database is fresh/empty
|
|
12
|
+
_SCHEMA_SQL = """
|
|
13
|
+
CREATE TABLE IF NOT EXISTS traces (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
name TEXT NOT NULL,
|
|
16
|
+
session_id TEXT,
|
|
17
|
+
start_time TIMESTAMP NOT NULL,
|
|
18
|
+
end_time TIMESTAMP,
|
|
19
|
+
status TEXT NOT NULL DEFAULT 'unset',
|
|
20
|
+
metadata JSON,
|
|
21
|
+
created_at TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS ix_traces_session_id ON traces (session_id);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS spans (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
trace_id TEXT NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
|
|
28
|
+
parent_span_id TEXT REFERENCES spans(id),
|
|
29
|
+
name TEXT NOT NULL,
|
|
30
|
+
span_type TEXT NOT NULL DEFAULT 'generic',
|
|
31
|
+
start_time TIMESTAMP NOT NULL,
|
|
32
|
+
end_time TIMESTAMP,
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'unset',
|
|
34
|
+
input JSON,
|
|
35
|
+
output JSON,
|
|
36
|
+
tokens_input INTEGER,
|
|
37
|
+
tokens_output INTEGER,
|
|
38
|
+
cost_usd NUMERIC(10, 6),
|
|
39
|
+
metadata JSON,
|
|
40
|
+
attributes JSON
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS ix_spans_trace_id ON spans (trace_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS ix_spans_parent_span_id ON spans (parent_span_id);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS evaluations (
|
|
46
|
+
id TEXT PRIMARY KEY,
|
|
47
|
+
trace_id TEXT NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
|
|
48
|
+
span_id TEXT REFERENCES spans(id) ON DELETE CASCADE,
|
|
49
|
+
evaluator_type TEXT NOT NULL,
|
|
50
|
+
score NUMERIC(5, 4),
|
|
51
|
+
criteria JSON,
|
|
52
|
+
result JSON,
|
|
53
|
+
created_at TIMESTAMP
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS ix_evaluations_trace_id ON evaluations (trace_id);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS ix_evaluations_span_id ON evaluations (span_id);
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_connection(db_path: Path | None = None) -> sqlite3.Connection:
|
|
61
|
+
"""Get a SQLite connection and ensure schema exists."""
|
|
62
|
+
path = db_path or DEFAULT_DB_PATH
|
|
63
|
+
conn = sqlite3.connect(str(path))
|
|
64
|
+
conn.row_factory = sqlite3.Row
|
|
65
|
+
conn.executescript(_SCHEMA_SQL)
|
|
66
|
+
return conn
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def list_traces(
|
|
70
|
+
db_path: Path | None = None,
|
|
71
|
+
limit: int = 100,
|
|
72
|
+
offset: int = 0,
|
|
73
|
+
status: str | None = None,
|
|
74
|
+
name_search: str | None = None,
|
|
75
|
+
) -> list[dict[str, Any]]:
|
|
76
|
+
"""List traces from local SQLite database."""
|
|
77
|
+
conn = get_connection(db_path)
|
|
78
|
+
try:
|
|
79
|
+
query = "SELECT * FROM traces WHERE 1=1"
|
|
80
|
+
params: list[Any] = []
|
|
81
|
+
|
|
82
|
+
if status:
|
|
83
|
+
query += " AND status = ?"
|
|
84
|
+
params.append(status)
|
|
85
|
+
|
|
86
|
+
if name_search:
|
|
87
|
+
query += " AND name LIKE ?"
|
|
88
|
+
params.append(f"%{name_search}%")
|
|
89
|
+
|
|
90
|
+
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
91
|
+
params.extend([limit, offset])
|
|
92
|
+
|
|
93
|
+
cursor = conn.execute(query, params)
|
|
94
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
95
|
+
finally:
|
|
96
|
+
conn.close()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_trace(trace_id: str, db_path: Path | None = None) -> dict[str, Any] | None:
|
|
100
|
+
"""Get a single trace by ID."""
|
|
101
|
+
conn = get_connection(db_path)
|
|
102
|
+
try:
|
|
103
|
+
cursor = conn.execute("SELECT * FROM traces WHERE id = ?", (trace_id,))
|
|
104
|
+
row = cursor.fetchone()
|
|
105
|
+
return dict(row) if row else None
|
|
106
|
+
finally:
|
|
107
|
+
conn.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def get_trace_spans(trace_id: str, db_path: Path | None = None) -> list[dict[str, Any]]:
|
|
111
|
+
"""Get all spans for a trace."""
|
|
112
|
+
conn = get_connection(db_path)
|
|
113
|
+
try:
|
|
114
|
+
cursor = conn.execute(
|
|
115
|
+
"SELECT * FROM spans WHERE trace_id = ? ORDER BY start_time",
|
|
116
|
+
(trace_id,),
|
|
117
|
+
)
|
|
118
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def list_evaluations(
|
|
124
|
+
db_path: Path | None = None,
|
|
125
|
+
trace_id: str | None = None,
|
|
126
|
+
limit: int = 100,
|
|
127
|
+
offset: int = 0,
|
|
128
|
+
) -> list[dict[str, Any]]:
|
|
129
|
+
"""List evaluations from local SQLite database."""
|
|
130
|
+
conn = get_connection(db_path)
|
|
131
|
+
try:
|
|
132
|
+
query = "SELECT * FROM evaluations WHERE 1=1"
|
|
133
|
+
params: list[Any] = []
|
|
134
|
+
|
|
135
|
+
if trace_id:
|
|
136
|
+
query += " AND trace_id = ?"
|
|
137
|
+
params.append(trace_id)
|
|
138
|
+
|
|
139
|
+
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
|
140
|
+
params.extend([limit, offset])
|
|
141
|
+
|
|
142
|
+
cursor = conn.execute(query, params)
|
|
143
|
+
return [dict(row) for row in cursor.fetchall()]
|
|
144
|
+
finally:
|
|
145
|
+
conn.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_stats(db_path: Path | None = None) -> dict[str, Any]:
|
|
149
|
+
"""Get summary statistics from the database."""
|
|
150
|
+
conn = get_connection(db_path)
|
|
151
|
+
try:
|
|
152
|
+
stats: dict[str, Any] = {}
|
|
153
|
+
|
|
154
|
+
# Trace counts
|
|
155
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces")
|
|
156
|
+
stats["total_traces"] = cursor.fetchone()[0]
|
|
157
|
+
|
|
158
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces WHERE status = 'ok'")
|
|
159
|
+
stats["ok_traces"] = cursor.fetchone()[0]
|
|
160
|
+
|
|
161
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces WHERE status = 'error'")
|
|
162
|
+
stats["error_traces"] = cursor.fetchone()[0]
|
|
163
|
+
|
|
164
|
+
# Span counts
|
|
165
|
+
cursor = conn.execute("SELECT COUNT(*) FROM spans")
|
|
166
|
+
stats["total_spans"] = cursor.fetchone()[0]
|
|
167
|
+
|
|
168
|
+
# Token totals
|
|
169
|
+
cursor = conn.execute(
|
|
170
|
+
"SELECT SUM(tokens_input), SUM(tokens_output) FROM spans WHERE tokens_input IS NOT NULL"
|
|
171
|
+
)
|
|
172
|
+
row = cursor.fetchone()
|
|
173
|
+
stats["total_input_tokens"] = row[0] or 0
|
|
174
|
+
stats["total_output_tokens"] = row[1] or 0
|
|
175
|
+
|
|
176
|
+
# Cost total
|
|
177
|
+
cursor = conn.execute(
|
|
178
|
+
"SELECT SUM(cost_usd) FROM spans WHERE cost_usd IS NOT NULL"
|
|
179
|
+
)
|
|
180
|
+
stats["total_cost_usd"] = cursor.fetchone()[0] or 0.0
|
|
181
|
+
|
|
182
|
+
# Evaluation counts
|
|
183
|
+
cursor = conn.execute("SELECT COUNT(*) FROM evaluations")
|
|
184
|
+
stats["total_evaluations"] = cursor.fetchone()[0]
|
|
185
|
+
|
|
186
|
+
return stats
|
|
187
|
+
finally:
|
|
188
|
+
conn.close()
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Main CLI application for LLM Observatory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option(version="0.1.1", prog_name="llm-observatory")
|
|
17
|
+
@click.option("--api-key", envvar="OBSERVATORY_API_KEY", default=None, help="API key for backend authentication")
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def cli(ctx: click.Context, api_key: str | None) -> None:
|
|
20
|
+
"""LLM Observatory CLI - Open-source LLM observability."""
|
|
21
|
+
ctx.ensure_object(dict)
|
|
22
|
+
ctx.obj["api_key"] = api_key
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Local Database Commands ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.command()
|
|
29
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
30
|
+
@click.option("--limit", default=20, help="Number of traces to show")
|
|
31
|
+
@click.option("--status", type=click.Choice(["ok", "error", "unset"]), help="Filter by status")
|
|
32
|
+
@click.option("--search", help="Search trace names")
|
|
33
|
+
def traces(db: str, limit: int, status: str | None, search: str | None) -> None:
|
|
34
|
+
"""List traces from local SQLite database."""
|
|
35
|
+
from cli.db import list_traces
|
|
36
|
+
|
|
37
|
+
rows = list_traces(Path(db), limit=limit, status=status, name_search=search)
|
|
38
|
+
|
|
39
|
+
if not rows:
|
|
40
|
+
console.print("[yellow]No traces found[/yellow]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
table = Table(title=f"Traces ({len(rows)} shown)")
|
|
44
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
45
|
+
table.add_column("Name", style="green")
|
|
46
|
+
table.add_column("Status", style="yellow")
|
|
47
|
+
table.add_column("Spans", justify="right")
|
|
48
|
+
table.add_column("Created", style="blue")
|
|
49
|
+
|
|
50
|
+
for row in rows:
|
|
51
|
+
table.add_row(
|
|
52
|
+
str(row["id"])[:8],
|
|
53
|
+
str(row["name"]),
|
|
54
|
+
str(row["status"]),
|
|
55
|
+
"",
|
|
56
|
+
str(row.get("created_at", "")),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
console.print(table)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@cli.command()
|
|
63
|
+
@click.argument("trace_id")
|
|
64
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
65
|
+
def inspect(trace_id: str, db: str) -> None:
|
|
66
|
+
"""Inspect a trace and its spans."""
|
|
67
|
+
from cli.db import get_trace, get_trace_spans
|
|
68
|
+
|
|
69
|
+
trace_data = get_trace(trace_id, Path(db))
|
|
70
|
+
if not trace_data:
|
|
71
|
+
console.print(f"[red]Trace {trace_id} not found[/red]")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# Print trace info
|
|
75
|
+
console.print(f"\n[bold cyan]Trace: {trace_data['name']}[/bold cyan]")
|
|
76
|
+
console.print(f" ID: {trace_data['id']}")
|
|
77
|
+
console.print(f" Status: {trace_data['status']}")
|
|
78
|
+
console.print(f" Created: {trace_data.get('created_at', 'N/A')}")
|
|
79
|
+
|
|
80
|
+
# Print spans
|
|
81
|
+
spans = get_trace_spans(trace_id, Path(db))
|
|
82
|
+
if spans:
|
|
83
|
+
console.print(f"\n[bold]Spans ({len(spans)}):[/bold]")
|
|
84
|
+
table = Table()
|
|
85
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
86
|
+
table.add_column("Name", style="green")
|
|
87
|
+
table.add_column("Type", style="yellow")
|
|
88
|
+
table.add_column("Status")
|
|
89
|
+
table.add_column("Tokens In", justify="right")
|
|
90
|
+
table.add_column("Tokens Out", justify="right")
|
|
91
|
+
table.add_column("Cost", justify="right")
|
|
92
|
+
|
|
93
|
+
for span in spans:
|
|
94
|
+
table.add_row(
|
|
95
|
+
str(span["id"])[:8],
|
|
96
|
+
str(span["name"]),
|
|
97
|
+
str(span["span_type"]),
|
|
98
|
+
str(span["status"]),
|
|
99
|
+
str(span.get("tokens_input") or ""),
|
|
100
|
+
str(span.get("tokens_output") or ""),
|
|
101
|
+
f"${span['cost_usd']:.6f}" if span.get("cost_usd") else "",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
console.print(table)
|
|
105
|
+
else:
|
|
106
|
+
console.print("[yellow]No spans found[/yellow]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@cli.command(name="import")
|
|
110
|
+
@click.argument("file", type=click.Path(exists=True))
|
|
111
|
+
@click.option("--db", type=click.Path(), default="observatory.db", help="SQLite database path")
|
|
112
|
+
@click.option("--dry-run", is_flag=True, help="Preview without writing")
|
|
113
|
+
def import_data(file: str, db: str, dry_run: bool) -> None:
|
|
114
|
+
"""Import traces from a JSON export file."""
|
|
115
|
+
import json
|
|
116
|
+
|
|
117
|
+
from cli.db import get_connection
|
|
118
|
+
|
|
119
|
+
raw = json.loads(Path(file).read_text())
|
|
120
|
+
traces = raw.get("traces", [])
|
|
121
|
+
|
|
122
|
+
if not traces:
|
|
123
|
+
console.print("[yellow]No traces in file[/yellow]")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
console.print(f"[blue]Importing {len(traces)} traces from {file}...[/blue]")
|
|
127
|
+
|
|
128
|
+
if dry_run:
|
|
129
|
+
for t in traces:
|
|
130
|
+
n = len(t.get("spans", []))
|
|
131
|
+
console.print(f" {t.get('name', '?')} — {n} spans")
|
|
132
|
+
console.print("[yellow]Dry run, no changes written[/yellow]")
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
conn = get_connection(Path(db))
|
|
136
|
+
try:
|
|
137
|
+
imported = 0
|
|
138
|
+
for trace in traces:
|
|
139
|
+
conn.execute(
|
|
140
|
+
"INSERT OR IGNORE INTO traces (id, name, session_id, status, metadata, start_time, end_time, created_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
141
|
+
(
|
|
142
|
+
trace["id"],
|
|
143
|
+
trace["name"],
|
|
144
|
+
trace.get("session_id"),
|
|
145
|
+
trace.get("status", "unset"),
|
|
146
|
+
json.dumps(trace.get("metadata")),
|
|
147
|
+
trace.get("start_time"),
|
|
148
|
+
trace.get("end_time"),
|
|
149
|
+
trace.get("created_at"),
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
for span in trace.get("spans", []):
|
|
153
|
+
conn.execute(
|
|
154
|
+
"INSERT OR IGNORE INTO spans (id, trace_id, parent_span_id, name, span_type, start_time, end_time, status, input, output, tokens_input, tokens_output, cost_usd, metadata, attributes) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
155
|
+
(
|
|
156
|
+
span["id"],
|
|
157
|
+
span.get("trace_id", trace["id"]),
|
|
158
|
+
span.get("parent_span_id"),
|
|
159
|
+
span["name"],
|
|
160
|
+
span.get("span_type", "generic"),
|
|
161
|
+
span.get("start_time"),
|
|
162
|
+
span.get("end_time"),
|
|
163
|
+
span.get("status", "unset"),
|
|
164
|
+
json.dumps(span.get("input")) if span.get("input") else None,
|
|
165
|
+
json.dumps(span.get("output")) if span.get("output") else None,
|
|
166
|
+
span.get("tokens_input"),
|
|
167
|
+
span.get("tokens_output"),
|
|
168
|
+
span.get("cost_usd"),
|
|
169
|
+
json.dumps(span.get("metadata")) if span.get("metadata") else None,
|
|
170
|
+
json.dumps(span.get("attributes")) if span.get("attributes") else None,
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
imported += 1
|
|
174
|
+
conn.commit()
|
|
175
|
+
console.print(f"[green]Imported {imported} traces ({sum(len(t.get('spans', [])) for t in traces)} spans)[/green]")
|
|
176
|
+
finally:
|
|
177
|
+
conn.close()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@cli.command()
|
|
181
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
182
|
+
def stats(db: str) -> None:
|
|
183
|
+
"""Show summary statistics."""
|
|
184
|
+
from cli.db import get_stats
|
|
185
|
+
|
|
186
|
+
data = get_stats(Path(db))
|
|
187
|
+
|
|
188
|
+
console.print("\n[bold cyan]LLM Observatory Statistics[/bold cyan]\n")
|
|
189
|
+
console.print(f" Total Traces: {data['total_traces']}")
|
|
190
|
+
console.print(f" OK: {data['ok_traces']}")
|
|
191
|
+
console.print(f" Error: {data['error_traces']}")
|
|
192
|
+
console.print(f" Total Spans: {data['total_spans']}")
|
|
193
|
+
console.print(f" Input Tokens: {data['total_input_tokens']:,}")
|
|
194
|
+
console.print(f" Output Tokens: {data['total_output_tokens']:,}")
|
|
195
|
+
console.print(f" Total Cost: ${data['total_cost_usd']:.6f}")
|
|
196
|
+
console.print(f" Evaluations: {data['total_evaluations']}")
|
|
197
|
+
console.print()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@cli.command()
|
|
201
|
+
@click.argument("trace_id")
|
|
202
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
203
|
+
@click.option("--format", "output_format", type=click.Choice(["json", "table"]), default="table")
|
|
204
|
+
def spans(trace_id: str, db: str, output_format: str) -> None:
|
|
205
|
+
"""List spans for a trace."""
|
|
206
|
+
from cli.db import get_trace_spans
|
|
207
|
+
|
|
208
|
+
rows = get_trace_spans(trace_id, Path(db))
|
|
209
|
+
|
|
210
|
+
if not rows:
|
|
211
|
+
console.print("[yellow]No spans found[/yellow]")
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
if output_format == "json":
|
|
215
|
+
import json
|
|
216
|
+
console.print(json.dumps(rows, indent=2, default=str))
|
|
217
|
+
else:
|
|
218
|
+
table = Table(title=f"Spans for {trace_id[:8]}")
|
|
219
|
+
table.add_column("ID", style="cyan", no_wrap=True)
|
|
220
|
+
table.add_column("Name", style="green")
|
|
221
|
+
table.add_column("Type", style="yellow")
|
|
222
|
+
table.add_column("Status")
|
|
223
|
+
table.add_column("Duration (ms)", justify="right")
|
|
224
|
+
table.add_column("Tokens", justify="right")
|
|
225
|
+
table.add_column("Cost", justify="right")
|
|
226
|
+
|
|
227
|
+
for row in rows:
|
|
228
|
+
duration = ""
|
|
229
|
+
if row.get("start_time") and row.get("end_time"):
|
|
230
|
+
from datetime import datetime
|
|
231
|
+
try:
|
|
232
|
+
start = datetime.fromisoformat(str(row["start_time"]))
|
|
233
|
+
end = datetime.fromisoformat(str(row["end_time"]))
|
|
234
|
+
duration = f"{(end - start).total_seconds() * 1000:.0f}"
|
|
235
|
+
except (ValueError, TypeError):
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
tokens = ""
|
|
239
|
+
if row.get("tokens_input") or row.get("tokens_output"):
|
|
240
|
+
tokens = f"{row.get('tokens_input', 0)}→{row.get('tokens_output', 0)}"
|
|
241
|
+
|
|
242
|
+
table.add_row(
|
|
243
|
+
str(row["id"])[:8],
|
|
244
|
+
str(row["name"]),
|
|
245
|
+
str(row["span_type"]),
|
|
246
|
+
str(row["status"]),
|
|
247
|
+
duration,
|
|
248
|
+
tokens,
|
|
249
|
+
f"${row['cost_usd']:.6f}" if row.get("cost_usd") else "",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
console.print(table)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ── Server Commands ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@cli.command()
|
|
259
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to")
|
|
260
|
+
@click.option("--port", default=8000, help="Port to bind to")
|
|
261
|
+
@click.option("--reload", is_flag=True, help="Enable auto-reload")
|
|
262
|
+
@click.option("--db", default="sqlite+aiosqlite:///./observatory.db", help="Database URL")
|
|
263
|
+
def serve(host: str, port: int, reload: bool, db: str) -> None:
|
|
264
|
+
"""Start the observatory backend server."""
|
|
265
|
+
try:
|
|
266
|
+
import uvicorn
|
|
267
|
+
except ImportError:
|
|
268
|
+
console.print("[red]Error: uvicorn is not installed.[/red]")
|
|
269
|
+
console.print("Install it with: pip install 'magic-lab-observer-backend[sqlite]'")
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
os.environ["DATABASE_URL"] = db
|
|
273
|
+
|
|
274
|
+
console.print(f"[green]Starting LLM Observatory server on {host}:{port}[/green]")
|
|
275
|
+
console.print(f"[green]Database: {db}[/green]")
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
from app.main import app as fastapi_app
|
|
279
|
+
uvicorn.run(fastapi_app, host=host, port=port, reload=reload)
|
|
280
|
+
except ImportError:
|
|
281
|
+
console.print("[red]Error: backend app module not found.[/red]")
|
|
282
|
+
console.print("Make sure the backend is installed: pip install -e ../backend")
|
|
283
|
+
except (RuntimeError, OSError) as e:
|
|
284
|
+
console.print(f"[red]Server error: {e}[/red]")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@cli.command()
|
|
288
|
+
@click.option("--trace-id", required=True, help="Trace ID to evaluate")
|
|
289
|
+
@click.option(
|
|
290
|
+
"--evaluator",
|
|
291
|
+
type=click.Choice(["llm_judge", "rule_based", "human"]),
|
|
292
|
+
required=True,
|
|
293
|
+
help="Evaluator type",
|
|
294
|
+
)
|
|
295
|
+
@click.option("--criteria", help="Comma-separated criteria for evaluation")
|
|
296
|
+
@click.option("--endpoint", default="http://localhost:8000", help="API endpoint")
|
|
297
|
+
@click.pass_context
|
|
298
|
+
def evaluate(
|
|
299
|
+
ctx: click.Context,
|
|
300
|
+
trace_id: str,
|
|
301
|
+
evaluator: str,
|
|
302
|
+
criteria: str | None,
|
|
303
|
+
endpoint: str,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Run an evaluation on a trace."""
|
|
306
|
+
import httpx
|
|
307
|
+
|
|
308
|
+
console.print(f"[blue]Evaluating trace {trace_id} with {evaluator}...[/blue]")
|
|
309
|
+
|
|
310
|
+
criteria_list = criteria.split(",") if criteria else []
|
|
311
|
+
|
|
312
|
+
headers: dict[str, str] = {}
|
|
313
|
+
api_key = ctx.obj.get("api_key")
|
|
314
|
+
if api_key:
|
|
315
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
response = httpx.post(
|
|
319
|
+
f"{endpoint}/v1/evaluations/run",
|
|
320
|
+
json={
|
|
321
|
+
"trace_id": trace_id,
|
|
322
|
+
"evaluator_type": evaluator,
|
|
323
|
+
"criteria": [{"name": c.strip(), "description": c.strip()} for c in criteria_list] if criteria_list else [],
|
|
324
|
+
},
|
|
325
|
+
headers=headers,
|
|
326
|
+
)
|
|
327
|
+
response.raise_for_status()
|
|
328
|
+
|
|
329
|
+
result = response.json()
|
|
330
|
+
console.print("[green]Evaluation completed successfully![/green]")
|
|
331
|
+
console.print(f"Score: {result.get('score', 'N/A')}")
|
|
332
|
+
console.print(f"Passed: {result.get('passed', 'N/A')}")
|
|
333
|
+
if result.get("criteria"):
|
|
334
|
+
console.print("Criteria scores:")
|
|
335
|
+
for k, v in result["criteria"].items():
|
|
336
|
+
console.print(f" {k}: {v}")
|
|
337
|
+
except httpx.HTTPError as e:
|
|
338
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@cli.command()
|
|
342
|
+
@click.option("--db", type=click.Path(exists=True), default="observatory.db", help="SQLite database path")
|
|
343
|
+
@click.option("--format", "output_format", type=click.Choice(["json"]), default="json", help="Export format")
|
|
344
|
+
@click.option("--output", "-o", type=click.Path(), help="Output file (default: stdout)")
|
|
345
|
+
@click.option("--status", type=click.Choice(["ok", "error", "unset"]), help="Filter by status")
|
|
346
|
+
@click.option("--limit", default=1000, help="Max traces to export")
|
|
347
|
+
def export(db: str, output_format: str, output: str | None, status: str | None, limit: int) -> None:
|
|
348
|
+
"""Export traces and spans to a file."""
|
|
349
|
+
import json
|
|
350
|
+
|
|
351
|
+
from cli.db import get_trace_spans, list_traces
|
|
352
|
+
|
|
353
|
+
rows = list_traces(Path(db), limit=limit, status=status)
|
|
354
|
+
|
|
355
|
+
if not rows:
|
|
356
|
+
console.print("[yellow]No traces to export[/yellow]")
|
|
357
|
+
return
|
|
358
|
+
|
|
359
|
+
traces_data = []
|
|
360
|
+
for row in rows:
|
|
361
|
+
trace_id = str(row["id"])
|
|
362
|
+
spans = get_trace_spans(trace_id, Path(db))
|
|
363
|
+
traces_data.append({
|
|
364
|
+
"id": trace_id,
|
|
365
|
+
"name": row["name"],
|
|
366
|
+
"status": row["status"],
|
|
367
|
+
"session_id": row.get("session_id"),
|
|
368
|
+
"start_time": row.get("start_time"),
|
|
369
|
+
"end_time": row.get("end_time"),
|
|
370
|
+
"metadata": json.loads(row["metadata"]) if row.get("metadata") else None,
|
|
371
|
+
"created_at": row.get("created_at"),
|
|
372
|
+
"spans": spans,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
export_data = {
|
|
376
|
+
"format": "llm-observatory-export",
|
|
377
|
+
"version": "0.1.1",
|
|
378
|
+
"trace_count": len(traces_data),
|
|
379
|
+
"traces": traces_data,
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
json_str = json.dumps(export_data, indent=2, default=str)
|
|
383
|
+
|
|
384
|
+
if output:
|
|
385
|
+
Path(output).write_text(json_str)
|
|
386
|
+
console.print(f"[green]Exported {len(traces_data)} traces to {output}[/green]")
|
|
387
|
+
else:
|
|
388
|
+
console.print(json_str)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@cli.command()
|
|
392
|
+
@click.option("--endpoint", default="http://localhost:8000", help="API endpoint")
|
|
393
|
+
@click.pass_context
|
|
394
|
+
def status(ctx: click.Context, endpoint: str) -> None:
|
|
395
|
+
"""Check the status of the observatory server."""
|
|
396
|
+
import httpx
|
|
397
|
+
|
|
398
|
+
headers: dict[str, str] = {}
|
|
399
|
+
api_key = ctx.obj.get("api_key")
|
|
400
|
+
if api_key:
|
|
401
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
response = httpx.get(f"{endpoint}/health", headers=headers)
|
|
405
|
+
response.raise_for_status()
|
|
406
|
+
|
|
407
|
+
console.print("[green]Server is healthy![/green]")
|
|
408
|
+
console.print(f"Endpoint: {endpoint}")
|
|
409
|
+
except httpx.HTTPError:
|
|
410
|
+
console.print("[red]Server is not responding[/red]")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
cli()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "magic-lab-observer-cli"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "CLI for LLM Observatory"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"click>=8.1.0",
|
|
14
|
+
"rich>=13.0.0",
|
|
15
|
+
"httpx>=0.27.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/Magic-Lab-Studio/observer"
|
|
20
|
+
Repository = "https://github.com/Magic-Lab-Studio/observer.git"
|
|
21
|
+
Issues = "https://github.com/Magic-Lab-Studio/observer/issues"
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
llm-observatory = "cli.main:cli"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["cli"]
|
|
File without changes
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Tests for LLM Observatory CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from cli.db import (
|
|
10
|
+
get_connection,
|
|
11
|
+
get_stats,
|
|
12
|
+
get_trace,
|
|
13
|
+
get_trace_spans,
|
|
14
|
+
list_evaluations,
|
|
15
|
+
list_traces,
|
|
16
|
+
)
|
|
17
|
+
from cli.main import cli
|
|
18
|
+
from click.testing import CliRunner
|
|
19
|
+
|
|
20
|
+
# ── Helpers ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def _seed_db(db: Path, *, traces: int = 3, spans_per_trace: int = 2) -> dict:
|
|
23
|
+
"""Insert test data and return summary."""
|
|
24
|
+
conn = get_connection(db)
|
|
25
|
+
trace_ids = []
|
|
26
|
+
for i in range(traces):
|
|
27
|
+
tid = f"trace-{i:04d}"
|
|
28
|
+
conn.execute(
|
|
29
|
+
"INSERT INTO traces (id, name, status, start_time, end_time, created_at, session_id) "
|
|
30
|
+
"VALUES (?, ?, 'ok', '2025-01-01T00:00:00', '2025-01-01T00:00:01', ?, ?)",
|
|
31
|
+
(tid, f"test-trace-{i}", f"2025-01-01T00:00:0{i}", f"session-{i}"),
|
|
32
|
+
)
|
|
33
|
+
trace_ids.append(tid)
|
|
34
|
+
for j in range(spans_per_trace):
|
|
35
|
+
span_id = f"span-{i:04d}-{j:02d}"
|
|
36
|
+
conn.execute(
|
|
37
|
+
"INSERT INTO spans (id, trace_id, name, span_type, start_time, end_time, "
|
|
38
|
+
"status, tokens_input, tokens_output, cost_usd) "
|
|
39
|
+
"VALUES (?, ?, ?, 'llm_call', '2025-01-01T00:00:00', '2025-01-01T00:00:01', 'ok', ?, ?, ?)",
|
|
40
|
+
(span_id, tid, f"span-{j}", 100 + j, 200 + j, 0.001 * (j + 1)),
|
|
41
|
+
)
|
|
42
|
+
# Add one error trace
|
|
43
|
+
conn.execute(
|
|
44
|
+
"INSERT INTO traces (id, name, status, start_time, end_time, created_at) "
|
|
45
|
+
"VALUES ('trace-err', 'error-trace', 'error', '2025-01-01T00:00:00', '2025-01-01T00:00:01', '2025-01-01T00:00:02')"
|
|
46
|
+
)
|
|
47
|
+
conn.execute(
|
|
48
|
+
"INSERT INTO spans (id, trace_id, name, span_type, start_time, end_time, status, tokens_input, tokens_output, cost_usd) "
|
|
49
|
+
"VALUES ('span-err', 'trace-err', 'fail-span', 'llm_call', '2025-01-01T00:00:00', '2025-01-01T00:00:01', 'error', 50, 10, 0.005)"
|
|
50
|
+
)
|
|
51
|
+
# Add evaluation
|
|
52
|
+
conn.execute(
|
|
53
|
+
"INSERT INTO evaluations (id, trace_id, evaluator_type, score, created_at) "
|
|
54
|
+
"VALUES ('eval-1', 'trace-0000', 'rule_based', 0.85, '2025-01-01T00:00:03')"
|
|
55
|
+
)
|
|
56
|
+
conn.commit()
|
|
57
|
+
conn.close()
|
|
58
|
+
return {"traces": traces + 1, "total_spans": traces * spans_per_trace + 1}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.fixture()
|
|
62
|
+
def db_file(tmp_path: Path) -> Path:
|
|
63
|
+
"""Create a seeded database in a temp directory."""
|
|
64
|
+
db = tmp_path / "test.db"
|
|
65
|
+
_seed_db(db)
|
|
66
|
+
return db
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture()
|
|
70
|
+
def empty_db(tmp_path: Path) -> Path:
|
|
71
|
+
"""Create an empty (but schema-initialized) database."""
|
|
72
|
+
db = tmp_path / "empty.db"
|
|
73
|
+
get_connection(db).close()
|
|
74
|
+
return db
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.fixture()
|
|
78
|
+
def runner() -> CliRunner:
|
|
79
|
+
return CliRunner()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ── db.py unit tests ─────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
class TestDBHelpers:
|
|
85
|
+
def test_get_connection_creates_schema(self, tmp_path: Path) -> None:
|
|
86
|
+
db = tmp_path / "new.db"
|
|
87
|
+
conn = get_connection(db)
|
|
88
|
+
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
89
|
+
tables = {row[0] for row in cursor.fetchall()}
|
|
90
|
+
conn.close()
|
|
91
|
+
assert "traces" in tables
|
|
92
|
+
assert "spans" in tables
|
|
93
|
+
assert "evaluations" in tables
|
|
94
|
+
|
|
95
|
+
def test_get_connection_idempotent(self, tmp_path: Path) -> None:
|
|
96
|
+
db = tmp_path / "idempotent.db"
|
|
97
|
+
get_connection(db).close()
|
|
98
|
+
get_connection(db).close()
|
|
99
|
+
|
|
100
|
+
def test_list_traces_empty(self, empty_db: Path) -> None:
|
|
101
|
+
assert list_traces(empty_db) == []
|
|
102
|
+
|
|
103
|
+
def test_list_traces_default(self, db_file: Path) -> None:
|
|
104
|
+
rows = list_traces(db_file)
|
|
105
|
+
assert len(rows) == 4 # 3 ok + 1 error
|
|
106
|
+
|
|
107
|
+
def test_list_traces_limit(self, db_file: Path) -> None:
|
|
108
|
+
rows = list_traces(db_file, limit=2)
|
|
109
|
+
assert len(rows) == 2
|
|
110
|
+
|
|
111
|
+
def test_list_traces_status_filter(self, db_file: Path) -> None:
|
|
112
|
+
rows = list_traces(db_file, status="error")
|
|
113
|
+
assert len(rows) == 1
|
|
114
|
+
assert rows[0]["name"] == "error-trace"
|
|
115
|
+
|
|
116
|
+
def test_list_traces_name_search(self, db_file: Path) -> None:
|
|
117
|
+
rows = list_traces(db_file, name_search="test-trace")
|
|
118
|
+
assert len(rows) == 3
|
|
119
|
+
|
|
120
|
+
def test_list_traces_name_search_no_match(self, db_file: Path) -> None:
|
|
121
|
+
rows = list_traces(db_file, name_search="nonexistent")
|
|
122
|
+
assert rows == []
|
|
123
|
+
|
|
124
|
+
def test_get_trace_found(self, db_file: Path) -> None:
|
|
125
|
+
t = get_trace("trace-0000", db_file)
|
|
126
|
+
assert t is not None
|
|
127
|
+
assert t["name"] == "test-trace-0"
|
|
128
|
+
assert t["status"] == "ok"
|
|
129
|
+
|
|
130
|
+
def test_get_trace_not_found(self, db_file: Path) -> None:
|
|
131
|
+
assert get_trace("no-such-id", db_file) is None
|
|
132
|
+
|
|
133
|
+
def test_get_trace_spans(self, db_file: Path) -> None:
|
|
134
|
+
spans = get_trace_spans("trace-0000", db_file)
|
|
135
|
+
assert len(spans) == 2
|
|
136
|
+
assert spans[0]["trace_id"] == "trace-0000"
|
|
137
|
+
|
|
138
|
+
def test_get_trace_spans_empty(self, db_file: Path) -> None:
|
|
139
|
+
spans = get_trace_spans("trace-err", db_file)
|
|
140
|
+
assert len(spans) == 1
|
|
141
|
+
|
|
142
|
+
def test_list_evaluations(self, db_file: Path) -> None:
|
|
143
|
+
evals = list_evaluations(db_file)
|
|
144
|
+
assert len(evals) == 1
|
|
145
|
+
assert evals[0]["evaluator_type"] == "rule_based"
|
|
146
|
+
|
|
147
|
+
def test_list_evaluations_filter_by_trace(self, db_file: Path) -> None:
|
|
148
|
+
evals = list_evaluations(db_file, trace_id="trace-0000")
|
|
149
|
+
assert len(evals) == 1
|
|
150
|
+
|
|
151
|
+
def test_list_evaluations_filter_no_match(self, db_file: Path) -> None:
|
|
152
|
+
evals = list_evaluations(db_file, trace_id="trace-nope")
|
|
153
|
+
assert evals == []
|
|
154
|
+
|
|
155
|
+
def test_get_stats(self, db_file: Path) -> None:
|
|
156
|
+
stats = get_stats(db_file)
|
|
157
|
+
assert stats["total_traces"] == 4
|
|
158
|
+
assert stats["ok_traces"] == 3
|
|
159
|
+
assert stats["error_traces"] == 1
|
|
160
|
+
assert stats["total_spans"] == 7
|
|
161
|
+
assert stats["total_evaluations"] == 1
|
|
162
|
+
assert stats["total_input_tokens"] > 0
|
|
163
|
+
assert stats["total_output_tokens"] > 0
|
|
164
|
+
assert stats["total_cost_usd"] > 0
|
|
165
|
+
|
|
166
|
+
def test_get_stats_empty(self, empty_db: Path) -> None:
|
|
167
|
+
stats = get_stats(empty_db)
|
|
168
|
+
assert stats["total_traces"] == 0
|
|
169
|
+
assert stats["total_spans"] == 0
|
|
170
|
+
assert stats["total_input_tokens"] == 0
|
|
171
|
+
assert stats["total_cost_usd"] == 0.0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── CLI command tests ────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
class TestCLIHelp:
|
|
177
|
+
def test_version(self, runner: CliRunner) -> None:
|
|
178
|
+
r = runner.invoke(cli, ["--version"])
|
|
179
|
+
assert r.exit_code == 0
|
|
180
|
+
assert "0.1.1" in r.output
|
|
181
|
+
|
|
182
|
+
def test_help(self, runner: CliRunner) -> None:
|
|
183
|
+
r = runner.invoke(cli, ["--help"])
|
|
184
|
+
assert r.exit_code == 0
|
|
185
|
+
assert "LLM Observatory CLI" in r.output
|
|
186
|
+
|
|
187
|
+
def test_traces_help(self, runner: CliRunner) -> None:
|
|
188
|
+
r = runner.invoke(cli, ["traces", "--help"])
|
|
189
|
+
assert r.exit_code == 0
|
|
190
|
+
assert "--limit" in r.output
|
|
191
|
+
|
|
192
|
+
def test_inspect_help(self, runner: CliRunner) -> None:
|
|
193
|
+
r = runner.invoke(cli, ["inspect", "--help"])
|
|
194
|
+
assert r.exit_code == 0
|
|
195
|
+
assert "TRACE_ID" in r.output
|
|
196
|
+
|
|
197
|
+
def test_stats_help(self, runner: CliRunner) -> None:
|
|
198
|
+
r = runner.invoke(cli, ["stats", "--help"])
|
|
199
|
+
assert r.exit_code == 0
|
|
200
|
+
|
|
201
|
+
def test_spans_help(self, runner: CliRunner) -> None:
|
|
202
|
+
r = runner.invoke(cli, ["spans", "--help"])
|
|
203
|
+
assert r.exit_code == 0
|
|
204
|
+
assert "--format" in r.output
|
|
205
|
+
|
|
206
|
+
def test_export_help(self, runner: CliRunner) -> None:
|
|
207
|
+
r = runner.invoke(cli, ["export", "--help"])
|
|
208
|
+
assert r.exit_code == 0
|
|
209
|
+
assert "--output" in r.output
|
|
210
|
+
|
|
211
|
+
def test_serve_help(self, runner: CliRunner) -> None:
|
|
212
|
+
r = runner.invoke(cli, ["serve", "--help"])
|
|
213
|
+
assert r.exit_code == 0
|
|
214
|
+
assert "--reload" in r.output
|
|
215
|
+
|
|
216
|
+
def test_evaluate_help(self, runner: CliRunner) -> None:
|
|
217
|
+
r = runner.invoke(cli, ["evaluate", "--help"])
|
|
218
|
+
assert r.exit_code == 0
|
|
219
|
+
assert "--trace-id" in r.output
|
|
220
|
+
|
|
221
|
+
def test_status_help(self, runner: CliRunner) -> None:
|
|
222
|
+
r = runner.invoke(cli, ["status", "--help"])
|
|
223
|
+
assert r.exit_code == 0
|
|
224
|
+
assert "--endpoint" in r.output
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class TestTracesCommand:
|
|
228
|
+
def test_traces_shows_table(self, runner: CliRunner, db_file: Path) -> None:
|
|
229
|
+
r = runner.invoke(cli, ["traces", "--db", str(db_file)])
|
|
230
|
+
assert r.exit_code == 0
|
|
231
|
+
assert "test-trace-0" in r.output
|
|
232
|
+
assert "Traces (4 shown)" in r.output
|
|
233
|
+
|
|
234
|
+
def test_traces_empty(self, runner: CliRunner, empty_db: Path) -> None:
|
|
235
|
+
r = runner.invoke(cli, ["traces", "--db", str(empty_db)])
|
|
236
|
+
assert r.exit_code == 0
|
|
237
|
+
assert "No traces found" in r.output
|
|
238
|
+
|
|
239
|
+
def test_traces_limit(self, runner: CliRunner, db_file: Path) -> None:
|
|
240
|
+
r = runner.invoke(cli, ["traces", "--db", str(db_file), "--limit", "1"])
|
|
241
|
+
assert r.exit_code == 0
|
|
242
|
+
assert "Traces (1 shown)" in r.output
|
|
243
|
+
|
|
244
|
+
def test_traces_filter_status(self, runner: CliRunner, db_file: Path) -> None:
|
|
245
|
+
r = runner.invoke(cli, ["traces", "--db", str(db_file), "--status", "error"])
|
|
246
|
+
assert r.exit_code == 0
|
|
247
|
+
assert "error-trace" in r.output
|
|
248
|
+
|
|
249
|
+
def test_traces_search(self, runner: CliRunner, db_file: Path) -> None:
|
|
250
|
+
r = runner.invoke(cli, ["traces", "--db", str(db_file), "--search", "error"])
|
|
251
|
+
assert r.exit_code == 0
|
|
252
|
+
assert "error-trace" in r.output
|
|
253
|
+
assert "Traces (1 shown)" in r.output
|
|
254
|
+
|
|
255
|
+
def test_traces_missing_db(self, runner: CliRunner) -> None:
|
|
256
|
+
r = runner.invoke(cli, ["traces", "--db", "/nonexistent/path.db"])
|
|
257
|
+
assert r.exit_code != 0
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TestInspectCommand:
|
|
261
|
+
def test_inspect_found(self, runner: CliRunner, db_file: Path) -> None:
|
|
262
|
+
r = runner.invoke(cli, ["inspect", "trace-0000", "--db", str(db_file)])
|
|
263
|
+
assert r.exit_code == 0
|
|
264
|
+
assert "test-trace-0" in r.output
|
|
265
|
+
assert "Spans (2)" in r.output
|
|
266
|
+
|
|
267
|
+
def test_inspect_not_found(self, runner: CliRunner, db_file: Path) -> None:
|
|
268
|
+
r = runner.invoke(cli, ["inspect", "no-such", "--db", str(db_file)])
|
|
269
|
+
assert r.exit_code == 0
|
|
270
|
+
assert "not found" in r.output
|
|
271
|
+
|
|
272
|
+
def test_inspect_shows_status(self, runner: CliRunner, db_file: Path) -> None:
|
|
273
|
+
r = runner.invoke(cli, ["inspect", "trace-err", "--db", str(db_file)])
|
|
274
|
+
assert r.exit_code == 0
|
|
275
|
+
assert "error" in r.output
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TestStatsCommand:
|
|
279
|
+
def test_stats_output(self, runner: CliRunner, db_file: Path) -> None:
|
|
280
|
+
r = runner.invoke(cli, ["stats", "--db", str(db_file)])
|
|
281
|
+
assert r.exit_code == 0
|
|
282
|
+
assert "Total Traces:" in r.output
|
|
283
|
+
assert "4" in r.output
|
|
284
|
+
assert "OK:" in r.output
|
|
285
|
+
assert "3" in r.output
|
|
286
|
+
assert "Error:" in r.output
|
|
287
|
+
|
|
288
|
+
def test_stats_empty(self, runner: CliRunner, empty_db: Path) -> None:
|
|
289
|
+
r = runner.invoke(cli, ["stats", "--db", str(empty_db)])
|
|
290
|
+
assert r.exit_code == 0
|
|
291
|
+
assert "Total Traces:" in r.output
|
|
292
|
+
assert "0" in r.output
|
|
293
|
+
|
|
294
|
+
def test_stats_shows_tokens(self, runner: CliRunner, db_file: Path) -> None:
|
|
295
|
+
r = runner.invoke(cli, ["stats", "--db", str(db_file)])
|
|
296
|
+
assert r.exit_code == 0
|
|
297
|
+
assert "Input Tokens:" in r.output
|
|
298
|
+
assert "Output Tokens:" in r.output
|
|
299
|
+
assert "Total Cost:" in r.output
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class TestSpansCommand:
|
|
303
|
+
def test_spans_table(self, runner: CliRunner, db_file: Path) -> None:
|
|
304
|
+
r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
|
|
305
|
+
assert r.exit_code == 0
|
|
306
|
+
assert "span-0" in r.output
|
|
307
|
+
assert "span-1" in r.output
|
|
308
|
+
|
|
309
|
+
def test_spans_json(self, runner: CliRunner, db_file: Path) -> None:
|
|
310
|
+
r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file), "--format", "json"])
|
|
311
|
+
assert r.exit_code == 0
|
|
312
|
+
data = json.loads(r.output)
|
|
313
|
+
assert len(data) == 2
|
|
314
|
+
assert data[0]["trace_id"] == "trace-0000"
|
|
315
|
+
|
|
316
|
+
def test_spans_empty(self, runner: CliRunner, db_file: Path) -> None:
|
|
317
|
+
r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
|
|
318
|
+
assert r.exit_code == 0
|
|
319
|
+
|
|
320
|
+
def test_spans_no_match(self, runner: CliRunner, db_file: Path) -> None:
|
|
321
|
+
r = runner.invoke(cli, ["spans", "no-such-trace", "--db", str(db_file)])
|
|
322
|
+
assert r.exit_code == 0
|
|
323
|
+
assert "No spans found" in r.output
|
|
324
|
+
|
|
325
|
+
def test_spans_shows_duration(self, runner: CliRunner, db_file: Path) -> None:
|
|
326
|
+
r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
|
|
327
|
+
assert r.exit_code == 0
|
|
328
|
+
assert "Duration (ms)" in r.output
|
|
329
|
+
|
|
330
|
+
def test_spans_shows_tokens(self, runner: CliRunner, db_file: Path) -> None:
|
|
331
|
+
r = runner.invoke(cli, ["spans", "trace-0000", "--db", str(db_file)])
|
|
332
|
+
assert r.exit_code == 0
|
|
333
|
+
assert "Tokens" in r.output
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class TestExportCommand:
|
|
337
|
+
def test_export_stdout(self, runner: CliRunner, db_file: Path) -> None:
|
|
338
|
+
r = runner.invoke(cli, ["export", "--db", str(db_file)])
|
|
339
|
+
assert r.exit_code == 0
|
|
340
|
+
data = json.loads(r.output)
|
|
341
|
+
assert data["format"] == "llm-observatory-export"
|
|
342
|
+
assert data["version"] == "0.1.1"
|
|
343
|
+
assert data["trace_count"] == 4
|
|
344
|
+
|
|
345
|
+
def test_export_to_file(self, runner: CliRunner, db_file: Path, tmp_path: Path) -> None:
|
|
346
|
+
out = tmp_path / "export.json"
|
|
347
|
+
r = runner.invoke(cli, ["export", "--db", str(db_file), "-o", str(out)])
|
|
348
|
+
assert r.exit_code == 0
|
|
349
|
+
assert out.exists()
|
|
350
|
+
data = json.loads(out.read_text())
|
|
351
|
+
assert data["trace_count"] == 4
|
|
352
|
+
|
|
353
|
+
def test_export_empty(self, runner: CliRunner, empty_db: Path) -> None:
|
|
354
|
+
r = runner.invoke(cli, ["export", "--db", str(empty_db)])
|
|
355
|
+
assert r.exit_code == 0
|
|
356
|
+
assert "No traces to export" in r.output
|
|
357
|
+
|
|
358
|
+
def test_export_filter_status(self, runner: CliRunner, db_file: Path) -> None:
|
|
359
|
+
r = runner.invoke(cli, ["export", "--db", str(db_file), "--status", "error"])
|
|
360
|
+
assert r.exit_code == 0
|
|
361
|
+
data = json.loads(r.output)
|
|
362
|
+
assert data["trace_count"] == 1
|
|
363
|
+
assert data["traces"][0]["status"] == "error"
|
|
364
|
+
|
|
365
|
+
def test_export_includes_spans(self, runner: CliRunner, db_file: Path) -> None:
|
|
366
|
+
r = runner.invoke(cli, ["export", "--db", str(db_file)])
|
|
367
|
+
data = json.loads(r.output)
|
|
368
|
+
trace_with_spans = next(t for t in data["traces"] if t["id"] == "trace-0000")
|
|
369
|
+
assert len(trace_with_spans["spans"]) == 2
|
|
370
|
+
|
|
371
|
+
def test_export_limit(self, runner: CliRunner, db_file: Path) -> None:
|
|
372
|
+
r = runner.invoke(cli, ["export", "--db", str(db_file), "--limit", "2"])
|
|
373
|
+
data = json.loads(r.output)
|
|
374
|
+
assert data["trace_count"] == 2
|
|
375
|
+
|
|
376
|
+
def test_export_roundtrip(self, runner: CliRunner, db_file: Path, tmp_path: Path) -> None:
|
|
377
|
+
"""Export then re-import preserves data structure."""
|
|
378
|
+
out = tmp_path / "roundtrip.json"
|
|
379
|
+
r = runner.invoke(cli, ["export", "--db", str(db_file), "-o", str(out)])
|
|
380
|
+
assert r.exit_code == 0
|
|
381
|
+
data = json.loads(out.read_text())
|
|
382
|
+
for trace in data["traces"]:
|
|
383
|
+
assert "id" in trace
|
|
384
|
+
assert "name" in trace
|
|
385
|
+
assert "spans" in trace
|
|
386
|
+
for span in trace["spans"]:
|
|
387
|
+
assert "trace_id" in span
|
|
388
|
+
assert "name" in span
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class TestEvaluateCommand:
|
|
392
|
+
def test_evaluate_help_shows_options(self, runner: CliRunner) -> None:
|
|
393
|
+
r = runner.invoke(cli, ["evaluate", "--help"])
|
|
394
|
+
assert r.exit_code == 0
|
|
395
|
+
assert "llm_judge" in r.output
|
|
396
|
+
assert "rule_based" in r.output
|
|
397
|
+
assert "human" in r.output
|
|
398
|
+
|
|
399
|
+
def test_evaluate_requires_trace_id(self, runner: CliRunner) -> None:
|
|
400
|
+
r = runner.invoke(cli, ["evaluate", "--evaluator", "rule_based"])
|
|
401
|
+
assert r.exit_code != 0
|
|
402
|
+
|
|
403
|
+
def test_evaluate_requires_evaluator(self, runner: CliRunner) -> None:
|
|
404
|
+
r = runner.invoke(cli, ["evaluate", "--trace-id", "t1"])
|
|
405
|
+
assert r.exit_code != 0
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class TestStatusCommand:
|
|
409
|
+
def test_status_server_down(self, runner: CliRunner) -> None:
|
|
410
|
+
r = runner.invoke(cli, ["status", "--endpoint", "http://127.0.0.1:19999"])
|
|
411
|
+
assert r.exit_code == 0
|
|
412
|
+
assert "not responding" in r.output
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class TestAPIKeyOption:
|
|
416
|
+
def test_api_key_from_option(self, runner: CliRunner) -> None:
|
|
417
|
+
r = runner.invoke(cli, ["--api-key", "test-key", "status", "--endpoint", "http://127.0.0.1:19999"])
|
|
418
|
+
assert r.exit_code == 0
|
|
419
|
+
assert "not responding" in r.output
|
|
420
|
+
|
|
421
|
+
def test_api_key_none_by_default(self, runner: CliRunner) -> None:
|
|
422
|
+
r = runner.invoke(cli, ["status", "--endpoint", "http://127.0.0.1:19999"])
|
|
423
|
+
assert r.exit_code == 0
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class TestImportCommand:
|
|
427
|
+
def test_import_help(self, runner: CliRunner) -> None:
|
|
428
|
+
r = runner.invoke(cli, ["import", "--help"])
|
|
429
|
+
assert r.exit_code == 0
|
|
430
|
+
assert "Dry run" in r.output or "dry-run" in r.output
|
|
431
|
+
|
|
432
|
+
def test_import_file_not_found(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
433
|
+
r = runner.invoke(cli, ["import", str(tmp_path / "nonexistent.json")])
|
|
434
|
+
assert r.exit_code != 0
|
|
435
|
+
|
|
436
|
+
def test_import_dry_run(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
437
|
+
export_file = tmp_path / "export.json"
|
|
438
|
+
export_data = {
|
|
439
|
+
"format": "llm-observatory-export",
|
|
440
|
+
"version": "0.1.0",
|
|
441
|
+
"trace_count": 2,
|
|
442
|
+
"traces": [
|
|
443
|
+
{
|
|
444
|
+
"id": "imp-001",
|
|
445
|
+
"name": "imported-trace-1",
|
|
446
|
+
"status": "ok",
|
|
447
|
+
"session_id": "s1",
|
|
448
|
+
"start_time": "2025-06-01T00:00:00",
|
|
449
|
+
"end_time": "2025-06-01T00:00:01",
|
|
450
|
+
"created_at": "2025-06-01T00:00:00",
|
|
451
|
+
"spans": [
|
|
452
|
+
{"id": "sp-001", "name": "llm-call", "span_type": "llm",
|
|
453
|
+
"start_time": "2025-06-01T00:00:00", "end_time": "2025-06-01T00:00:01",
|
|
454
|
+
"status": "ok", "tokens_input": 100, "tokens_output": 50,
|
|
455
|
+
"cost_usd": 0.001},
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
"id": "imp-002",
|
|
460
|
+
"name": "imported-trace-2",
|
|
461
|
+
"status": "error",
|
|
462
|
+
"spans": [],
|
|
463
|
+
},
|
|
464
|
+
],
|
|
465
|
+
}
|
|
466
|
+
export_file.write_text(json.dumps(export_data))
|
|
467
|
+
|
|
468
|
+
r = runner.invoke(cli, ["import", str(export_file), "--dry-run"])
|
|
469
|
+
assert r.exit_code == 0
|
|
470
|
+
assert "imported-trace-1" in r.output
|
|
471
|
+
assert "Dry run" in r.output
|
|
472
|
+
|
|
473
|
+
# Nothing should be written
|
|
474
|
+
db = tmp_path / "test.db"
|
|
475
|
+
conn = get_connection(db)
|
|
476
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces")
|
|
477
|
+
assert cursor.fetchone()[0] == 0
|
|
478
|
+
conn.close()
|
|
479
|
+
|
|
480
|
+
def test_import_writes_to_db(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
481
|
+
export_file = tmp_path / "export.json"
|
|
482
|
+
export_data = {
|
|
483
|
+
"format": "llm-observatory-export",
|
|
484
|
+
"version": "0.1.0",
|
|
485
|
+
"trace_count": 1,
|
|
486
|
+
"traces": [
|
|
487
|
+
{
|
|
488
|
+
"id": "imp-100",
|
|
489
|
+
"name": "imported-ok",
|
|
490
|
+
"status": "ok",
|
|
491
|
+
"session_id": "s1",
|
|
492
|
+
"start_time": "2025-06-01T00:00:00",
|
|
493
|
+
"end_time": "2025-06-01T00:00:01",
|
|
494
|
+
"created_at": "2025-06-01T00:00:00",
|
|
495
|
+
"spans": [
|
|
496
|
+
{"id": "sp-100", "name": "call-1", "span_type": "llm",
|
|
497
|
+
"start_time": "2025-06-01T00:00:00", "end_time": "2025-06-01T00:00:01",
|
|
498
|
+
"status": "ok", "tokens_input": 200, "tokens_output": 100,
|
|
499
|
+
"cost_usd": 0.005},
|
|
500
|
+
],
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
}
|
|
504
|
+
export_file.write_text(json.dumps(export_data))
|
|
505
|
+
|
|
506
|
+
db = tmp_path / "import.db"
|
|
507
|
+
r = runner.invoke(cli, ["import", str(export_file), "--db", str(db)])
|
|
508
|
+
assert r.exit_code == 0
|
|
509
|
+
assert "Imported 1 traces" in r.output
|
|
510
|
+
|
|
511
|
+
# Verify data was written
|
|
512
|
+
conn = get_connection(db)
|
|
513
|
+
cursor = conn.execute("SELECT COUNT(*) FROM traces")
|
|
514
|
+
assert cursor.fetchone()[0] == 1
|
|
515
|
+
cursor = conn.execute("SELECT name FROM traces WHERE id='imp-100'")
|
|
516
|
+
assert cursor.fetchone()[0] == "imported-ok"
|
|
517
|
+
cursor = conn.execute("SELECT COUNT(*) FROM spans WHERE trace_id='imp-100'")
|
|
518
|
+
assert cursor.fetchone()[0] == 1
|
|
519
|
+
conn.close()
|
|
520
|
+
|
|
521
|
+
def test_import_empty_file(self, runner: CliRunner, tmp_path: Path) -> None:
|
|
522
|
+
export_file = tmp_path / "empty.json"
|
|
523
|
+
export_file.write_text(json.dumps({"format": "llm-observatory-export", "traces": []}))
|
|
524
|
+
r = runner.invoke(cli, ["import", str(export_file)])
|
|
525
|
+
assert r.exit_code == 0
|
|
526
|
+
assert "No traces" in r.output
|