prela 0.1.0__py3-none-any.whl
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.
- prela/__init__.py +394 -0
- prela/_version.py +3 -0
- prela/contrib/CLI.md +431 -0
- prela/contrib/README.md +118 -0
- prela/contrib/__init__.py +5 -0
- prela/contrib/cli.py +1063 -0
- prela/contrib/explorer.py +571 -0
- prela/core/__init__.py +64 -0
- prela/core/clock.py +98 -0
- prela/core/context.py +228 -0
- prela/core/replay.py +403 -0
- prela/core/sampler.py +178 -0
- prela/core/span.py +295 -0
- prela/core/tracer.py +498 -0
- prela/evals/__init__.py +94 -0
- prela/evals/assertions/README.md +484 -0
- prela/evals/assertions/__init__.py +78 -0
- prela/evals/assertions/base.py +90 -0
- prela/evals/assertions/multi_agent.py +625 -0
- prela/evals/assertions/semantic.py +223 -0
- prela/evals/assertions/structural.py +443 -0
- prela/evals/assertions/tool.py +380 -0
- prela/evals/case.py +370 -0
- prela/evals/n8n/__init__.py +69 -0
- prela/evals/n8n/assertions.py +450 -0
- prela/evals/n8n/runner.py +497 -0
- prela/evals/reporters/README.md +184 -0
- prela/evals/reporters/__init__.py +32 -0
- prela/evals/reporters/console.py +251 -0
- prela/evals/reporters/json.py +176 -0
- prela/evals/reporters/junit.py +278 -0
- prela/evals/runner.py +525 -0
- prela/evals/suite.py +316 -0
- prela/exporters/__init__.py +27 -0
- prela/exporters/base.py +189 -0
- prela/exporters/console.py +443 -0
- prela/exporters/file.py +322 -0
- prela/exporters/http.py +394 -0
- prela/exporters/multi.py +154 -0
- prela/exporters/otlp.py +388 -0
- prela/instrumentation/ANTHROPIC.md +297 -0
- prela/instrumentation/LANGCHAIN.md +480 -0
- prela/instrumentation/OPENAI.md +59 -0
- prela/instrumentation/__init__.py +49 -0
- prela/instrumentation/anthropic.py +1436 -0
- prela/instrumentation/auto.py +129 -0
- prela/instrumentation/base.py +436 -0
- prela/instrumentation/langchain.py +959 -0
- prela/instrumentation/llamaindex.py +719 -0
- prela/instrumentation/multi_agent/__init__.py +48 -0
- prela/instrumentation/multi_agent/autogen.py +357 -0
- prela/instrumentation/multi_agent/crewai.py +404 -0
- prela/instrumentation/multi_agent/langgraph.py +299 -0
- prela/instrumentation/multi_agent/models.py +203 -0
- prela/instrumentation/multi_agent/swarm.py +231 -0
- prela/instrumentation/n8n/__init__.py +68 -0
- prela/instrumentation/n8n/code_node.py +534 -0
- prela/instrumentation/n8n/models.py +336 -0
- prela/instrumentation/n8n/webhook.py +489 -0
- prela/instrumentation/openai.py +1198 -0
- prela/license.py +245 -0
- prela/replay/__init__.py +31 -0
- prela/replay/comparison.py +390 -0
- prela/replay/engine.py +1227 -0
- prela/replay/loader.py +231 -0
- prela/replay/result.py +196 -0
- prela-0.1.0.dist-info/METADATA +399 -0
- prela-0.1.0.dist-info/RECORD +71 -0
- prela-0.1.0.dist-info/WHEEL +4 -0
- prela-0.1.0.dist-info/entry_points.txt +2 -0
- prela-0.1.0.dist-info/licenses/LICENSE +190 -0
prela/contrib/cli.py
ADDED
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
"""Prela CLI - Command-line interface for AI agent observability.
|
|
2
|
+
|
|
3
|
+
This module provides a CLI for managing Prela configuration, viewing traces,
|
|
4
|
+
and running evaluations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import typer
|
|
17
|
+
import yaml
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
from rich.tree import Tree
|
|
21
|
+
except ImportError as e:
|
|
22
|
+
print(
|
|
23
|
+
f"CLI dependencies not installed: {e}\n"
|
|
24
|
+
"Install with: pip install prela[cli]",
|
|
25
|
+
file=sys.stderr,
|
|
26
|
+
)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
from prela.core.span import Span
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="prela",
|
|
33
|
+
help="Prela - AI Agent Observability Platform CLI",
|
|
34
|
+
no_args_is_help=True,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
# Default configuration
|
|
40
|
+
DEFAULT_CONFIG = {
|
|
41
|
+
"service_name": "my-agent",
|
|
42
|
+
"exporter": "file",
|
|
43
|
+
"trace_dir": "./traces",
|
|
44
|
+
"sample_rate": 1.0,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
CONFIG_FILE = ".prela.yaml"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load_config() -> dict[str, Any]:
|
|
51
|
+
"""Load configuration from .prela.yaml file."""
|
|
52
|
+
config_path = Path(CONFIG_FILE)
|
|
53
|
+
if not config_path.exists():
|
|
54
|
+
return DEFAULT_CONFIG.copy()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
with open(config_path) as f:
|
|
58
|
+
config = yaml.safe_load(f)
|
|
59
|
+
return {**DEFAULT_CONFIG, **config}
|
|
60
|
+
except Exception as e:
|
|
61
|
+
console.print(f"[yellow]Warning: Failed to load {CONFIG_FILE}: {e}[/yellow]")
|
|
62
|
+
return DEFAULT_CONFIG.copy()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_config(config: dict[str, Any]) -> None:
|
|
66
|
+
"""Save configuration to .prela.yaml file."""
|
|
67
|
+
try:
|
|
68
|
+
with open(CONFIG_FILE, "w") as f:
|
|
69
|
+
yaml.safe_dump(config, f, default_flow_style=False, sort_keys=False)
|
|
70
|
+
console.print(f"[green]✓ Configuration saved to {CONFIG_FILE}[/green]")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
console.print(f"[red]✗ Failed to save configuration: {e}[/red]")
|
|
73
|
+
raise typer.Exit(1)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_duration(duration: str) -> timedelta:
|
|
77
|
+
"""Parse duration string like '1h', '30m', '2d' into timedelta."""
|
|
78
|
+
duration = duration.strip().lower()
|
|
79
|
+
if not duration:
|
|
80
|
+
raise ValueError("Duration cannot be empty")
|
|
81
|
+
|
|
82
|
+
# Extract number and unit
|
|
83
|
+
unit = duration[-1]
|
|
84
|
+
try:
|
|
85
|
+
value = int(duration[:-1])
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise ValueError(f"Invalid duration format: {duration}")
|
|
88
|
+
|
|
89
|
+
if unit == "s":
|
|
90
|
+
return timedelta(seconds=value)
|
|
91
|
+
elif unit == "m":
|
|
92
|
+
return timedelta(minutes=value)
|
|
93
|
+
elif unit == "h":
|
|
94
|
+
return timedelta(hours=value)
|
|
95
|
+
elif unit == "d":
|
|
96
|
+
return timedelta(days=value)
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f"Unknown duration unit: {unit} (use s, m, h, or d)")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_traces_from_file(
|
|
102
|
+
trace_dir: Path, since: Optional[datetime] = None
|
|
103
|
+
) -> list[dict[str, Any]]:
|
|
104
|
+
"""Load traces from JSONL file(s) in trace directory."""
|
|
105
|
+
traces = []
|
|
106
|
+
|
|
107
|
+
if not trace_dir.exists():
|
|
108
|
+
return traces
|
|
109
|
+
|
|
110
|
+
# Find all .jsonl files
|
|
111
|
+
jsonl_files = sorted(trace_dir.glob("*.jsonl"))
|
|
112
|
+
|
|
113
|
+
for jsonl_file in jsonl_files:
|
|
114
|
+
try:
|
|
115
|
+
with open(jsonl_file) as f:
|
|
116
|
+
for line in f:
|
|
117
|
+
line = line.strip()
|
|
118
|
+
if not line:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
span_data = json.loads(line)
|
|
123
|
+
|
|
124
|
+
# Filter by time if requested
|
|
125
|
+
if since:
|
|
126
|
+
started_at = datetime.fromisoformat(
|
|
127
|
+
span_data.get("started_at", "")
|
|
128
|
+
)
|
|
129
|
+
if started_at < since:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
traces.append(span_data)
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
continue
|
|
135
|
+
except Exception as e:
|
|
136
|
+
console.print(
|
|
137
|
+
f"[yellow]Warning: Failed to read {jsonl_file}: {e}[/yellow]"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return traces
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def group_spans_by_trace(spans: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
144
|
+
"""Group spans by trace_id."""
|
|
145
|
+
traces: dict[str, list[dict[str, Any]]] = {}
|
|
146
|
+
for span in spans:
|
|
147
|
+
trace_id = span.get("trace_id", "unknown")
|
|
148
|
+
if trace_id not in traces:
|
|
149
|
+
traces[trace_id] = []
|
|
150
|
+
traces[trace_id].append(span)
|
|
151
|
+
return traces
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def find_root_span(spans: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
|
|
155
|
+
"""Find root span (parent_span_id is None) in a list of spans."""
|
|
156
|
+
for span in spans:
|
|
157
|
+
if span.get("parent_span_id") is None:
|
|
158
|
+
return span
|
|
159
|
+
return spans[0] if spans else None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_span_tree(
|
|
163
|
+
spans: list[dict[str, Any]], parent_id: Optional[str] = None
|
|
164
|
+
) -> list[dict[str, Any]]:
|
|
165
|
+
"""Build hierarchical tree of spans."""
|
|
166
|
+
tree = []
|
|
167
|
+
for span in spans:
|
|
168
|
+
if span.get("parent_span_id") == parent_id:
|
|
169
|
+
children = build_span_tree(spans, span.get("span_id"))
|
|
170
|
+
tree.append({"span": span, "children": children})
|
|
171
|
+
return tree
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def render_span_tree(
|
|
175
|
+
tree: list[dict[str, Any]], rich_tree: Optional[Tree] = None, is_root: bool = True
|
|
176
|
+
) -> Tree:
|
|
177
|
+
"""Render span tree using Rich Tree."""
|
|
178
|
+
if rich_tree is None:
|
|
179
|
+
# Create root tree
|
|
180
|
+
if tree:
|
|
181
|
+
root_span = tree[0]["span"]
|
|
182
|
+
root_label = format_span_label(root_span)
|
|
183
|
+
rich_tree = Tree(root_label)
|
|
184
|
+
|
|
185
|
+
# Render children
|
|
186
|
+
for child in tree[0].get("children", []):
|
|
187
|
+
render_span_tree([child], rich_tree, is_root=False)
|
|
188
|
+
|
|
189
|
+
# If there are more root spans, add them
|
|
190
|
+
for node in tree[1:]:
|
|
191
|
+
span = node["span"]
|
|
192
|
+
branch = rich_tree.add(format_span_label(span))
|
|
193
|
+
for child in node.get("children", []):
|
|
194
|
+
render_span_tree([child], branch, is_root=False)
|
|
195
|
+
else:
|
|
196
|
+
rich_tree = Tree("[dim]No spans[/dim]")
|
|
197
|
+
else:
|
|
198
|
+
# Add to existing tree
|
|
199
|
+
for node in tree:
|
|
200
|
+
span = node["span"]
|
|
201
|
+
branch = rich_tree.add(format_span_label(span))
|
|
202
|
+
for child in node.get("children", []):
|
|
203
|
+
render_span_tree([child], branch, is_root=False)
|
|
204
|
+
|
|
205
|
+
return rich_tree
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def format_span_label(span: dict[str, Any]) -> str:
|
|
209
|
+
"""Format span as a label for tree display."""
|
|
210
|
+
name = span.get("name", "unknown")
|
|
211
|
+
span_type = span.get("span_type", "unknown")
|
|
212
|
+
status = span.get("status", "unknown")
|
|
213
|
+
duration_ms = span.get("duration_ms", 0)
|
|
214
|
+
|
|
215
|
+
# Status color
|
|
216
|
+
status_color = "green" if status == "success" else "red" if status == "error" else "yellow"
|
|
217
|
+
|
|
218
|
+
# Format duration
|
|
219
|
+
if duration_ms > 1000:
|
|
220
|
+
duration_str = f"{duration_ms / 1000:.2f}s"
|
|
221
|
+
else:
|
|
222
|
+
duration_str = f"{duration_ms:.0f}ms"
|
|
223
|
+
|
|
224
|
+
return f"[bold]{name}[/bold] [dim]({span_type})[/dim] [{status_color}]{status}[/{status_color}] [dim]{duration_str}[/dim]"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def init() -> None:
|
|
229
|
+
"""Initialize Prela configuration with interactive prompts."""
|
|
230
|
+
console.print("[bold blue]Prela Configuration Setup[/bold blue]\n")
|
|
231
|
+
|
|
232
|
+
# Load existing config if available
|
|
233
|
+
existing_config = load_config()
|
|
234
|
+
|
|
235
|
+
# Prompt for service name
|
|
236
|
+
service_name = typer.prompt(
|
|
237
|
+
"Service name", default=existing_config.get("service_name", "my-agent")
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# Prompt for exporter
|
|
241
|
+
exporter = typer.prompt(
|
|
242
|
+
"Exporter (console/file)", default=existing_config.get("exporter", "file")
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Prompt for trace directory (if file exporter)
|
|
246
|
+
if exporter == "file":
|
|
247
|
+
trace_dir = typer.prompt(
|
|
248
|
+
"Trace directory", default=existing_config.get("trace_dir", "./traces")
|
|
249
|
+
)
|
|
250
|
+
else:
|
|
251
|
+
trace_dir = existing_config.get("trace_dir", "./traces")
|
|
252
|
+
|
|
253
|
+
# Prompt for sample rate
|
|
254
|
+
sample_rate_str = typer.prompt(
|
|
255
|
+
"Sample rate (0.0-1.0)", default=str(existing_config.get("sample_rate", 1.0))
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
sample_rate = float(sample_rate_str)
|
|
260
|
+
if not 0.0 <= sample_rate <= 1.0:
|
|
261
|
+
console.print("[red]Sample rate must be between 0.0 and 1.0[/red]")
|
|
262
|
+
raise typer.Exit(1)
|
|
263
|
+
except ValueError:
|
|
264
|
+
console.print("[red]Invalid sample rate[/red]")
|
|
265
|
+
raise typer.Exit(1)
|
|
266
|
+
|
|
267
|
+
# Build config
|
|
268
|
+
config = {
|
|
269
|
+
"service_name": service_name,
|
|
270
|
+
"exporter": exporter,
|
|
271
|
+
"trace_dir": trace_dir,
|
|
272
|
+
"sample_rate": sample_rate,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Save config
|
|
276
|
+
save_config(config)
|
|
277
|
+
|
|
278
|
+
# Create trace directory if needed
|
|
279
|
+
if exporter == "file":
|
|
280
|
+
trace_path = Path(trace_dir)
|
|
281
|
+
trace_path.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
console.print(f"[green]✓ Created trace directory: {trace_dir}[/green]")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.command(name="list")
|
|
286
|
+
def list_traces(
|
|
287
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of traces to show"),
|
|
288
|
+
since: Optional[str] = typer.Option(
|
|
289
|
+
None, "--since", "-s", help="Show traces since duration (e.g., '1h', '30m', '2d')"
|
|
290
|
+
),
|
|
291
|
+
interactive: bool = typer.Option(
|
|
292
|
+
False, "--interactive", "-i", help="Enable interactive selection (numbered list)"
|
|
293
|
+
),
|
|
294
|
+
) -> None:
|
|
295
|
+
"""List recent traces from file exporter.
|
|
296
|
+
|
|
297
|
+
With --interactive, displays a numbered list and prompts for selection,
|
|
298
|
+
then automatically shows the selected trace details.
|
|
299
|
+
"""
|
|
300
|
+
config = load_config()
|
|
301
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
302
|
+
|
|
303
|
+
# Parse since duration
|
|
304
|
+
since_dt = None
|
|
305
|
+
if since:
|
|
306
|
+
try:
|
|
307
|
+
duration = parse_duration(since)
|
|
308
|
+
since_dt = datetime.now(timezone.utc) - duration
|
|
309
|
+
except ValueError as e:
|
|
310
|
+
console.print(f"[red]Invalid duration: {e}[/red]")
|
|
311
|
+
raise typer.Exit(1)
|
|
312
|
+
|
|
313
|
+
# Load traces
|
|
314
|
+
spans = load_traces_from_file(trace_dir, since=since_dt)
|
|
315
|
+
|
|
316
|
+
if not spans:
|
|
317
|
+
console.print("[yellow]No traces found[/yellow]")
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
# Group by trace_id
|
|
321
|
+
traces = group_spans_by_trace(spans)
|
|
322
|
+
|
|
323
|
+
# Get root spans for each trace
|
|
324
|
+
trace_summaries = []
|
|
325
|
+
for trace_id, trace_spans in traces.items():
|
|
326
|
+
root_span = find_root_span(trace_spans)
|
|
327
|
+
if root_span:
|
|
328
|
+
trace_summaries.append(
|
|
329
|
+
{
|
|
330
|
+
"trace_id": trace_id,
|
|
331
|
+
"root_span": root_span.get("name", "unknown"),
|
|
332
|
+
"duration_ms": root_span.get("duration_ms", 0),
|
|
333
|
+
"status": root_span.get("status", "unknown"),
|
|
334
|
+
"started_at": root_span.get("started_at", ""),
|
|
335
|
+
"span_count": len(trace_spans),
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Sort by time (most recent first)
|
|
340
|
+
trace_summaries.sort(key=lambda x: x["started_at"], reverse=True)
|
|
341
|
+
|
|
342
|
+
# Limit results
|
|
343
|
+
trace_summaries = trace_summaries[:limit]
|
|
344
|
+
|
|
345
|
+
# Display table
|
|
346
|
+
if interactive:
|
|
347
|
+
# Interactive mode: numbered list
|
|
348
|
+
table = Table(title=f"Recent Traces ({len(trace_summaries)} of {len(traces)}) - Select by number")
|
|
349
|
+
table.add_column("#", style="bold yellow", justify="right", width=4)
|
|
350
|
+
else:
|
|
351
|
+
# Normal mode: standard table
|
|
352
|
+
table = Table(title=f"Recent Traces ({len(trace_summaries)} of {len(traces)})")
|
|
353
|
+
|
|
354
|
+
table.add_column("Trace ID", style="cyan", no_wrap=True)
|
|
355
|
+
table.add_column("Root Span", style="bold")
|
|
356
|
+
table.add_column("Duration", justify="right")
|
|
357
|
+
table.add_column("Status", justify="center")
|
|
358
|
+
table.add_column("Spans", justify="right")
|
|
359
|
+
table.add_column("Time", style="dim")
|
|
360
|
+
|
|
361
|
+
for idx, summary in enumerate(trace_summaries, start=1):
|
|
362
|
+
# Format duration
|
|
363
|
+
duration_ms = summary["duration_ms"]
|
|
364
|
+
if duration_ms > 1000:
|
|
365
|
+
duration_str = f"{duration_ms / 1000:.2f}s"
|
|
366
|
+
else:
|
|
367
|
+
duration_str = f"{duration_ms:.0f}ms"
|
|
368
|
+
|
|
369
|
+
# Status color
|
|
370
|
+
status = summary["status"]
|
|
371
|
+
status_color = "green" if status == "success" else "red" if status == "error" else "yellow"
|
|
372
|
+
|
|
373
|
+
# Format time
|
|
374
|
+
try:
|
|
375
|
+
started_at = datetime.fromisoformat(summary["started_at"])
|
|
376
|
+
time_str = started_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
377
|
+
except Exception:
|
|
378
|
+
time_str = summary["started_at"][:19]
|
|
379
|
+
|
|
380
|
+
if interactive:
|
|
381
|
+
# Add row number in interactive mode
|
|
382
|
+
table.add_row(
|
|
383
|
+
str(idx),
|
|
384
|
+
summary["trace_id"][:16],
|
|
385
|
+
summary["root_span"],
|
|
386
|
+
duration_str,
|
|
387
|
+
f"[{status_color}]{status}[/{status_color}]",
|
|
388
|
+
str(summary["span_count"]),
|
|
389
|
+
time_str,
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
# Normal row without number
|
|
393
|
+
table.add_row(
|
|
394
|
+
summary["trace_id"][:16],
|
|
395
|
+
summary["root_span"],
|
|
396
|
+
duration_str,
|
|
397
|
+
f"[{status_color}]{status}[/{status_color}]",
|
|
398
|
+
str(summary["span_count"]),
|
|
399
|
+
time_str,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
console.print(table)
|
|
403
|
+
|
|
404
|
+
# Interactive selection
|
|
405
|
+
if interactive:
|
|
406
|
+
console.print() # Blank line
|
|
407
|
+
try:
|
|
408
|
+
selection = typer.prompt(
|
|
409
|
+
f"Select trace (1-{len(trace_summaries)}), or 'q' to quit",
|
|
410
|
+
default="q",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Handle quit
|
|
414
|
+
if selection.lower() == "q":
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
# Validate selection
|
|
418
|
+
try:
|
|
419
|
+
selected_idx = int(selection)
|
|
420
|
+
if not 1 <= selected_idx <= len(trace_summaries):
|
|
421
|
+
console.print(f"[red]Invalid selection. Must be between 1 and {len(trace_summaries)}[/red]")
|
|
422
|
+
raise typer.Exit(1)
|
|
423
|
+
except ValueError:
|
|
424
|
+
console.print("[red]Invalid input. Please enter a number or 'q'[/red]")
|
|
425
|
+
raise typer.Exit(1)
|
|
426
|
+
|
|
427
|
+
# Get selected trace
|
|
428
|
+
selected_trace = trace_summaries[selected_idx - 1]
|
|
429
|
+
selected_trace_id = selected_trace["trace_id"]
|
|
430
|
+
|
|
431
|
+
# Automatically show the selected trace
|
|
432
|
+
console.print(f"\n[bold blue]→ Showing trace {selected_idx}: {selected_trace_id[:16]}...[/bold blue]\n")
|
|
433
|
+
show_trace(selected_trace_id)
|
|
434
|
+
|
|
435
|
+
except (KeyboardInterrupt, EOFError):
|
|
436
|
+
console.print("\n[yellow]Selection cancelled[/yellow]")
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@app.command(name="show")
|
|
441
|
+
def show_trace(
|
|
442
|
+
trace_id: str,
|
|
443
|
+
compact: bool = typer.Option(
|
|
444
|
+
False, "--compact", "-c", help="Show tree only (no detailed attributes)"
|
|
445
|
+
),
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Display full trace tree with all spans, attributes, and events.
|
|
448
|
+
|
|
449
|
+
Use --compact to show only the tree structure without detailed span information.
|
|
450
|
+
"""
|
|
451
|
+
config = load_config()
|
|
452
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
453
|
+
|
|
454
|
+
# Load all traces
|
|
455
|
+
spans = load_traces_from_file(trace_dir)
|
|
456
|
+
|
|
457
|
+
# Filter by trace_id (support partial match)
|
|
458
|
+
matching_spans = [s for s in spans if s.get("trace_id", "").startswith(trace_id)]
|
|
459
|
+
|
|
460
|
+
if not matching_spans:
|
|
461
|
+
console.print(f"[red]No trace found with ID: {trace_id}[/red]")
|
|
462
|
+
raise typer.Exit(1)
|
|
463
|
+
|
|
464
|
+
# Get full trace_id
|
|
465
|
+
full_trace_id = matching_spans[0].get("trace_id")
|
|
466
|
+
|
|
467
|
+
# Get all spans for this trace
|
|
468
|
+
trace_spans = [s for s in spans if s.get("trace_id") == full_trace_id]
|
|
469
|
+
|
|
470
|
+
console.print(f"\n[bold blue]Trace:[/bold blue] {full_trace_id}\n")
|
|
471
|
+
|
|
472
|
+
# Build and render tree
|
|
473
|
+
span_tree = build_span_tree(trace_spans, parent_id=None)
|
|
474
|
+
tree = render_span_tree(span_tree)
|
|
475
|
+
console.print(tree)
|
|
476
|
+
|
|
477
|
+
# Show detailed attributes for each span (unless compact mode)
|
|
478
|
+
if not compact:
|
|
479
|
+
console.print("\n[bold blue]Span Details:[/bold blue]\n")
|
|
480
|
+
|
|
481
|
+
for span in sorted(trace_spans, key=lambda s: s.get("started_at", "")):
|
|
482
|
+
console.print(f"[bold cyan]{span.get('name', 'unknown')}[/bold cyan]")
|
|
483
|
+
console.print(f" Span ID: {span.get('span_id', 'unknown')}")
|
|
484
|
+
console.print(f" Type: {span.get('span_type', 'unknown')}")
|
|
485
|
+
console.print(f" Status: {span.get('status', 'unknown')}")
|
|
486
|
+
|
|
487
|
+
# Attributes
|
|
488
|
+
attributes = span.get("attributes", {})
|
|
489
|
+
if attributes:
|
|
490
|
+
console.print(" Attributes:")
|
|
491
|
+
for key, value in sorted(attributes.items()):
|
|
492
|
+
# Truncate long values
|
|
493
|
+
value_str = str(value)
|
|
494
|
+
if len(value_str) > 100:
|
|
495
|
+
value_str = value_str[:97] + "..."
|
|
496
|
+
console.print(f" {key}: {value_str}")
|
|
497
|
+
|
|
498
|
+
# Events
|
|
499
|
+
events = span.get("events", [])
|
|
500
|
+
if events:
|
|
501
|
+
console.print(f" Events ({len(events)}):")
|
|
502
|
+
for event in events:
|
|
503
|
+
event_name = event.get("name", "unknown")
|
|
504
|
+
timestamp = event.get("timestamp", "")
|
|
505
|
+
console.print(f" - {event_name} @ {timestamp}")
|
|
506
|
+
|
|
507
|
+
console.print()
|
|
508
|
+
else:
|
|
509
|
+
# In compact mode, show helpful tip
|
|
510
|
+
console.print("\n[dim]💡 Tip: Run without --compact to see full span details[/dim]\n")
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@app.command(name="search")
|
|
514
|
+
def search_traces(query: str) -> None:
|
|
515
|
+
"""Search span names and attributes for matching traces."""
|
|
516
|
+
config = load_config()
|
|
517
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
518
|
+
|
|
519
|
+
# Load all traces
|
|
520
|
+
spans = load_traces_from_file(trace_dir)
|
|
521
|
+
|
|
522
|
+
if not spans:
|
|
523
|
+
console.print("[yellow]No traces found[/yellow]")
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
# Search spans
|
|
527
|
+
query_lower = query.lower()
|
|
528
|
+
matching_spans = []
|
|
529
|
+
|
|
530
|
+
for span in spans:
|
|
531
|
+
# Search in span name
|
|
532
|
+
if query_lower in span.get("name", "").lower():
|
|
533
|
+
matching_spans.append(span)
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
# Search in attributes
|
|
537
|
+
attributes = span.get("attributes", {})
|
|
538
|
+
for key, value in attributes.items():
|
|
539
|
+
if query_lower in key.lower() or query_lower in str(value).lower():
|
|
540
|
+
matching_spans.append(span)
|
|
541
|
+
break
|
|
542
|
+
|
|
543
|
+
if not matching_spans:
|
|
544
|
+
console.print(f"[yellow]No traces found matching: {query}[/yellow]")
|
|
545
|
+
return
|
|
546
|
+
|
|
547
|
+
# Group by trace
|
|
548
|
+
traces = group_spans_by_trace(matching_spans)
|
|
549
|
+
|
|
550
|
+
console.print(f"\n[bold green]Found {len(traces)} traces matching '{query}'[/bold green]\n")
|
|
551
|
+
|
|
552
|
+
# Display table
|
|
553
|
+
table = Table(title=f"Search Results")
|
|
554
|
+
table.add_column("Trace ID", style="cyan", no_wrap=True)
|
|
555
|
+
table.add_column("Root Span", style="bold")
|
|
556
|
+
table.add_column("Matching Spans", justify="right")
|
|
557
|
+
table.add_column("Status", justify="center")
|
|
558
|
+
|
|
559
|
+
for trace_id, trace_spans in traces.items():
|
|
560
|
+
root_span = find_root_span(trace_spans)
|
|
561
|
+
if root_span:
|
|
562
|
+
status = root_span.get("status", "unknown")
|
|
563
|
+
status_color = (
|
|
564
|
+
"green" if status == "success" else "red" if status == "error" else "yellow"
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
table.add_row(
|
|
568
|
+
trace_id[:16],
|
|
569
|
+
root_span.get("name", "unknown"),
|
|
570
|
+
str(len(trace_spans)),
|
|
571
|
+
f"[{status_color}]{status}[/{status_color}]",
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
console.print(table)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@app.command(name="replay")
|
|
578
|
+
def replay_trace(
|
|
579
|
+
trace_file: str = typer.Argument(..., help="Path to trace file (JSON or JSONL)"),
|
|
580
|
+
model: Optional[str] = typer.Option(None, "--model", "-m", help="Override model"),
|
|
581
|
+
temperature: Optional[float] = typer.Option(
|
|
582
|
+
None, "--temperature", "-t", help="Override temperature"
|
|
583
|
+
),
|
|
584
|
+
system_prompt: Optional[str] = typer.Option(
|
|
585
|
+
None, "--system-prompt", "-s", help="Override system prompt"
|
|
586
|
+
),
|
|
587
|
+
max_tokens: Optional[int] = typer.Option(
|
|
588
|
+
None, "--max-tokens", help="Override max_tokens"
|
|
589
|
+
),
|
|
590
|
+
compare: bool = typer.Option(
|
|
591
|
+
False, "--compare", "-c", help="Compare replay with original"
|
|
592
|
+
),
|
|
593
|
+
output: Optional[str] = typer.Option(
|
|
594
|
+
None, "--output", "-o", help="Save replay result to file"
|
|
595
|
+
),
|
|
596
|
+
stream: bool = typer.Option(
|
|
597
|
+
False, "--stream", help="Use streaming API and show real-time output"
|
|
598
|
+
),
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Replay a captured trace with optional modifications.
|
|
601
|
+
|
|
602
|
+
Examples:
|
|
603
|
+
prela replay trace.json
|
|
604
|
+
prela replay trace.json --model gpt-4o --compare
|
|
605
|
+
prela replay trace.json --temperature 0.7 --output result.json
|
|
606
|
+
prela replay trace.json --model claude-sonnet-4 --stream
|
|
607
|
+
"""
|
|
608
|
+
from prela.replay import ReplayEngine, compare_replays
|
|
609
|
+
from prela.replay.loader import TraceLoader
|
|
610
|
+
|
|
611
|
+
console.print(f"[cyan]Loading trace from {trace_file}...[/cyan]")
|
|
612
|
+
|
|
613
|
+
# Load trace
|
|
614
|
+
try:
|
|
615
|
+
trace_path = Path(trace_file)
|
|
616
|
+
if not trace_path.exists():
|
|
617
|
+
console.print(f"[red]✗ File not found: {trace_file}[/red]")
|
|
618
|
+
raise typer.Exit(1)
|
|
619
|
+
|
|
620
|
+
if trace_path.suffix == ".jsonl":
|
|
621
|
+
traces = TraceLoader.from_jsonl(trace_path)
|
|
622
|
+
if not traces:
|
|
623
|
+
console.print("[red]✗ No traces found in JSONL file[/red]")
|
|
624
|
+
raise typer.Exit(1)
|
|
625
|
+
trace = traces[0] # Use first trace
|
|
626
|
+
if len(traces) > 1:
|
|
627
|
+
console.print(
|
|
628
|
+
f"[yellow]Note: Found {len(traces)} traces, using first one[/yellow]"
|
|
629
|
+
)
|
|
630
|
+
else:
|
|
631
|
+
trace = TraceLoader.from_file(trace_path)
|
|
632
|
+
|
|
633
|
+
console.print(
|
|
634
|
+
f"[green]✓ Loaded trace {trace.trace_id} with {len(trace.spans)} spans[/green]"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
except Exception as e:
|
|
638
|
+
console.print(f"[red]✗ Failed to load trace: {e}[/red]")
|
|
639
|
+
raise typer.Exit(1)
|
|
640
|
+
|
|
641
|
+
# Create replay engine
|
|
642
|
+
try:
|
|
643
|
+
engine = ReplayEngine(trace)
|
|
644
|
+
except ValueError as e:
|
|
645
|
+
console.print(f"[red]✗ {e}[/red]")
|
|
646
|
+
raise typer.Exit(1)
|
|
647
|
+
|
|
648
|
+
# Execute replay
|
|
649
|
+
console.print("\n[cyan]Executing replay...[/cyan]")
|
|
650
|
+
|
|
651
|
+
# Create streaming callback if requested
|
|
652
|
+
stream_callback = None
|
|
653
|
+
if stream:
|
|
654
|
+
console.print("[dim]Streaming enabled - showing real-time output:[/dim]\n")
|
|
655
|
+
|
|
656
|
+
def streaming_callback(chunk_text: str) -> None:
|
|
657
|
+
"""Print streaming chunks in real-time."""
|
|
658
|
+
console.print(chunk_text, end="", highlight=False)
|
|
659
|
+
|
|
660
|
+
stream_callback = streaming_callback
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
# Check if modifications requested
|
|
664
|
+
has_modifications = any([model, temperature, system_prompt, max_tokens])
|
|
665
|
+
|
|
666
|
+
if has_modifications:
|
|
667
|
+
# Modified replay
|
|
668
|
+
result = engine.replay_with_modifications(
|
|
669
|
+
model=model,
|
|
670
|
+
temperature=temperature,
|
|
671
|
+
system_prompt=system_prompt,
|
|
672
|
+
max_tokens=max_tokens,
|
|
673
|
+
stream=stream,
|
|
674
|
+
stream_callback=stream_callback,
|
|
675
|
+
)
|
|
676
|
+
if stream:
|
|
677
|
+
console.print("\n") # New line after streaming output
|
|
678
|
+
console.print(
|
|
679
|
+
f"[green]✓ Modified replay completed ({result.modified_span_count} spans modified)[/green]"
|
|
680
|
+
)
|
|
681
|
+
else:
|
|
682
|
+
# Exact replay
|
|
683
|
+
result = engine.replay_exact()
|
|
684
|
+
console.print("[green]✓ Exact replay completed[/green]")
|
|
685
|
+
|
|
686
|
+
except NotImplementedError as e:
|
|
687
|
+
console.print(
|
|
688
|
+
f"[yellow]Warning: {e}[/yellow]\n"
|
|
689
|
+
f"[dim]Currently only exact replay is supported. "
|
|
690
|
+
f"Real API calls for modified replay coming soon.[/dim]"
|
|
691
|
+
)
|
|
692
|
+
# Fall back to exact replay
|
|
693
|
+
result = engine.replay_exact()
|
|
694
|
+
|
|
695
|
+
except Exception as e:
|
|
696
|
+
console.print(f"[red]✗ Replay failed: {e}[/red]")
|
|
697
|
+
raise typer.Exit(1)
|
|
698
|
+
|
|
699
|
+
# Display results
|
|
700
|
+
console.print("\n[bold]Replay Results:[/bold]")
|
|
701
|
+
console.print(f" Trace ID: {result.trace_id}")
|
|
702
|
+
console.print(f" Total Spans: {len(result.spans)}")
|
|
703
|
+
console.print(f" Duration: {result.total_duration_ms:.1f}ms")
|
|
704
|
+
console.print(f" Tokens: {result.total_tokens}")
|
|
705
|
+
console.print(f" Cost: ${result.total_cost_usd:.4f}")
|
|
706
|
+
console.print(f" Success: {'✓' if result.success else '✗'}")
|
|
707
|
+
|
|
708
|
+
if result.errors:
|
|
709
|
+
console.print(f"\n[red]Errors ({len(result.errors)}):[/red]")
|
|
710
|
+
for error in result.errors[:5]: # Show first 5
|
|
711
|
+
console.print(f" • {error}")
|
|
712
|
+
|
|
713
|
+
if result.final_output is not None:
|
|
714
|
+
console.print(f"\n[bold]Final Output:[/bold]")
|
|
715
|
+
if isinstance(result.final_output, str):
|
|
716
|
+
output_preview = result.final_output[:200]
|
|
717
|
+
if len(result.final_output) > 200:
|
|
718
|
+
output_preview += "..."
|
|
719
|
+
console.print(f" {output_preview}")
|
|
720
|
+
else:
|
|
721
|
+
console.print(f" {result.final_output}")
|
|
722
|
+
|
|
723
|
+
# Compare with original if requested
|
|
724
|
+
if compare and has_modifications:
|
|
725
|
+
console.print("\n[cyan]Comparing with original execution...[/cyan]")
|
|
726
|
+
|
|
727
|
+
try:
|
|
728
|
+
# Run exact replay for comparison
|
|
729
|
+
original_result = engine.replay_exact()
|
|
730
|
+
|
|
731
|
+
# Compare
|
|
732
|
+
comparison = compare_replays(original_result, result)
|
|
733
|
+
|
|
734
|
+
# Display comparison
|
|
735
|
+
console.print("\n" + comparison.generate_summary())
|
|
736
|
+
|
|
737
|
+
# Show top differences with semantic similarity
|
|
738
|
+
if comparison.differences:
|
|
739
|
+
console.print("\n[bold]Top Differences:[/bold]")
|
|
740
|
+
for diff in comparison.differences[:10]: # Show first 10
|
|
741
|
+
sim_text = ""
|
|
742
|
+
if diff.semantic_similarity is not None:
|
|
743
|
+
sim_text = f" (similarity: {diff.semantic_similarity:.1%})"
|
|
744
|
+
|
|
745
|
+
console.print(
|
|
746
|
+
f"\n • {diff.span_name} - {diff.field}{sim_text}"
|
|
747
|
+
)
|
|
748
|
+
console.print(f" Original: {str(diff.original_value)[:100]}")
|
|
749
|
+
console.print(f" Modified: {str(diff.modified_value)[:100]}")
|
|
750
|
+
|
|
751
|
+
except Exception as e:
|
|
752
|
+
console.print(f"[yellow]Warning: Comparison failed: {e}[/yellow]")
|
|
753
|
+
|
|
754
|
+
# Save output if requested
|
|
755
|
+
if output:
|
|
756
|
+
try:
|
|
757
|
+
output_path = Path(output)
|
|
758
|
+
with open(output_path, "w") as f:
|
|
759
|
+
json.dump(result.to_dict(), f, indent=2, default=str)
|
|
760
|
+
console.print(f"\n[green]✓ Results saved to {output}[/green]")
|
|
761
|
+
except Exception as e:
|
|
762
|
+
console.print(f"[yellow]Warning: Failed to save output: {e}[/yellow]")
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@app.command()
|
|
766
|
+
def explore() -> None:
|
|
767
|
+
"""Launch interactive trace explorer (TUI).
|
|
768
|
+
|
|
769
|
+
Opens an interactive terminal interface for browsing traces, navigating
|
|
770
|
+
span hierarchies, and inspecting details without copy/pasting trace IDs.
|
|
771
|
+
|
|
772
|
+
Keyboard shortcuts:
|
|
773
|
+
↑/k: Move up
|
|
774
|
+
↓/j: Move down
|
|
775
|
+
Enter: Select/drill down
|
|
776
|
+
Esc: Go back
|
|
777
|
+
q: Quit
|
|
778
|
+
|
|
779
|
+
Example:
|
|
780
|
+
$ prela explore
|
|
781
|
+
"""
|
|
782
|
+
try:
|
|
783
|
+
from prela.contrib.explorer import run_explorer
|
|
784
|
+
except ImportError:
|
|
785
|
+
console.print(
|
|
786
|
+
"[red]Textual dependency not installed[/red]\n"
|
|
787
|
+
"Install with: pip install prela[cli]"
|
|
788
|
+
)
|
|
789
|
+
raise typer.Exit(1)
|
|
790
|
+
|
|
791
|
+
config = load_config()
|
|
792
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
793
|
+
|
|
794
|
+
if not trace_dir.exists():
|
|
795
|
+
console.print(
|
|
796
|
+
f"[yellow]Trace directory not found: {trace_dir}[/yellow]\n"
|
|
797
|
+
f"Run 'prela init' to configure or use 'prela list' to see traces."
|
|
798
|
+
)
|
|
799
|
+
raise typer.Exit(1)
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
run_explorer(trace_dir)
|
|
803
|
+
except Exception as e:
|
|
804
|
+
console.print(f"[red]Explorer failed: {e}[/red]")
|
|
805
|
+
raise typer.Exit(1)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@app.command(name="last")
|
|
809
|
+
def last_trace(
|
|
810
|
+
compact: bool = typer.Option(
|
|
811
|
+
False, "--compact", "-c", help="Show tree only (no detailed attributes)"
|
|
812
|
+
),
|
|
813
|
+
) -> None:
|
|
814
|
+
"""Show the most recent trace.
|
|
815
|
+
|
|
816
|
+
Shortcut for viewing the latest trace without copy/pasting IDs.
|
|
817
|
+
"""
|
|
818
|
+
config = load_config()
|
|
819
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
820
|
+
|
|
821
|
+
# Load traces
|
|
822
|
+
spans = load_traces_from_file(trace_dir)
|
|
823
|
+
|
|
824
|
+
if not spans:
|
|
825
|
+
console.print("[yellow]No traces found[/yellow]")
|
|
826
|
+
return
|
|
827
|
+
|
|
828
|
+
# Group by trace_id
|
|
829
|
+
traces = group_spans_by_trace(spans)
|
|
830
|
+
|
|
831
|
+
# Get root spans for each trace
|
|
832
|
+
trace_summaries = []
|
|
833
|
+
for trace_id, trace_spans in traces.items():
|
|
834
|
+
root_span = find_root_span(trace_spans)
|
|
835
|
+
if root_span:
|
|
836
|
+
trace_summaries.append(
|
|
837
|
+
{
|
|
838
|
+
"trace_id": trace_id,
|
|
839
|
+
"started_at": root_span.get("started_at", ""),
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
# Sort by time (most recent first)
|
|
844
|
+
trace_summaries.sort(key=lambda x: x["started_at"], reverse=True)
|
|
845
|
+
|
|
846
|
+
if not trace_summaries:
|
|
847
|
+
console.print("[yellow]No valid traces found[/yellow]")
|
|
848
|
+
return
|
|
849
|
+
|
|
850
|
+
# Get most recent trace
|
|
851
|
+
most_recent = trace_summaries[0]
|
|
852
|
+
trace_id = most_recent["trace_id"]
|
|
853
|
+
|
|
854
|
+
console.print(f"[dim]Showing most recent trace ({trace_id[:16]}...)[/dim]\n")
|
|
855
|
+
show_trace(trace_id, compact=compact)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
@app.command(name="errors")
|
|
859
|
+
def error_traces(
|
|
860
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Maximum number of traces to show"),
|
|
861
|
+
) -> None:
|
|
862
|
+
"""Show only failed traces.
|
|
863
|
+
|
|
864
|
+
Shortcut for filtering error traces without manual searching.
|
|
865
|
+
"""
|
|
866
|
+
config = load_config()
|
|
867
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
868
|
+
|
|
869
|
+
# Load traces
|
|
870
|
+
spans = load_traces_from_file(trace_dir)
|
|
871
|
+
|
|
872
|
+
if not spans:
|
|
873
|
+
console.print("[yellow]No traces found[/yellow]")
|
|
874
|
+
return
|
|
875
|
+
|
|
876
|
+
# Group by trace_id
|
|
877
|
+
traces = group_spans_by_trace(spans)
|
|
878
|
+
|
|
879
|
+
# Get root spans for error traces
|
|
880
|
+
error_summaries = []
|
|
881
|
+
for trace_id, trace_spans in traces.items():
|
|
882
|
+
root_span = find_root_span(trace_spans)
|
|
883
|
+
if root_span and root_span.get("status") == "error":
|
|
884
|
+
error_summaries.append(
|
|
885
|
+
{
|
|
886
|
+
"trace_id": trace_id,
|
|
887
|
+
"root_span": root_span.get("name", "unknown"),
|
|
888
|
+
"duration_ms": root_span.get("duration_ms", 0),
|
|
889
|
+
"started_at": root_span.get("started_at", ""),
|
|
890
|
+
"span_count": len(trace_spans),
|
|
891
|
+
}
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
# Sort by time (most recent first)
|
|
895
|
+
error_summaries.sort(key=lambda x: x["started_at"], reverse=True)
|
|
896
|
+
|
|
897
|
+
# Limit results
|
|
898
|
+
error_summaries = error_summaries[:limit]
|
|
899
|
+
|
|
900
|
+
if not error_summaries:
|
|
901
|
+
console.print("[green]✓ No failed traces found - all systems nominal![/green]")
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
# Display table
|
|
905
|
+
table = Table(title=f"Failed Traces ({len(error_summaries)} errors)")
|
|
906
|
+
table.add_column("Trace ID", style="cyan", no_wrap=True)
|
|
907
|
+
table.add_column("Root Span", style="bold")
|
|
908
|
+
table.add_column("Duration", justify="right")
|
|
909
|
+
table.add_column("Spans", justify="right")
|
|
910
|
+
table.add_column("Time", style="dim")
|
|
911
|
+
|
|
912
|
+
for summary in error_summaries:
|
|
913
|
+
# Format duration
|
|
914
|
+
duration_ms = summary["duration_ms"]
|
|
915
|
+
if duration_ms > 1000:
|
|
916
|
+
duration_str = f"{duration_ms / 1000:.2f}s"
|
|
917
|
+
else:
|
|
918
|
+
duration_str = f"{duration_ms:.0f}ms"
|
|
919
|
+
|
|
920
|
+
# Format time
|
|
921
|
+
try:
|
|
922
|
+
started_at = datetime.fromisoformat(summary["started_at"])
|
|
923
|
+
time_str = started_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
924
|
+
except Exception:
|
|
925
|
+
time_str = summary["started_at"][:19]
|
|
926
|
+
|
|
927
|
+
table.add_row(
|
|
928
|
+
summary["trace_id"][:16],
|
|
929
|
+
summary["root_span"],
|
|
930
|
+
duration_str,
|
|
931
|
+
str(summary["span_count"]),
|
|
932
|
+
time_str,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
console.print(table)
|
|
936
|
+
console.print(f"\n[dim]💡 Tip: Use 'prela show <trace-id>' to inspect a specific error[/dim]\n")
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
@app.command(name="tail")
|
|
940
|
+
def tail_traces(
|
|
941
|
+
interval: int = typer.Option(2, "--interval", "-i", help="Polling interval in seconds"),
|
|
942
|
+
compact: bool = typer.Option(
|
|
943
|
+
False, "--compact", "-c", help="Show compact output (no details)"
|
|
944
|
+
),
|
|
945
|
+
) -> None:
|
|
946
|
+
"""Follow new traces in real-time.
|
|
947
|
+
|
|
948
|
+
Simple polling mode that shows new traces as they arrive.
|
|
949
|
+
Press Ctrl+C to stop.
|
|
950
|
+
"""
|
|
951
|
+
import time
|
|
952
|
+
|
|
953
|
+
config = load_config()
|
|
954
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
955
|
+
|
|
956
|
+
console.print(f"[cyan]Following traces in {trace_dir} (polling every {interval}s)[/cyan]")
|
|
957
|
+
console.print("[dim]Press Ctrl+C to stop[/dim]\n")
|
|
958
|
+
|
|
959
|
+
# Track seen trace IDs
|
|
960
|
+
seen_trace_ids: set[str] = set()
|
|
961
|
+
|
|
962
|
+
# Initial load
|
|
963
|
+
spans = load_traces_from_file(trace_dir)
|
|
964
|
+
traces = group_spans_by_trace(spans)
|
|
965
|
+
seen_trace_ids.update(traces.keys())
|
|
966
|
+
|
|
967
|
+
try:
|
|
968
|
+
while True:
|
|
969
|
+
time.sleep(interval)
|
|
970
|
+
|
|
971
|
+
# Load traces
|
|
972
|
+
spans = load_traces_from_file(trace_dir)
|
|
973
|
+
traces = group_spans_by_trace(spans)
|
|
974
|
+
|
|
975
|
+
# Find new traces
|
|
976
|
+
new_trace_ids = set(traces.keys()) - seen_trace_ids
|
|
977
|
+
|
|
978
|
+
if new_trace_ids:
|
|
979
|
+
for trace_id in sorted(new_trace_ids):
|
|
980
|
+
trace_spans = traces[trace_id]
|
|
981
|
+
root_span = find_root_span(trace_spans)
|
|
982
|
+
|
|
983
|
+
if root_span:
|
|
984
|
+
# Format timestamp
|
|
985
|
+
now = datetime.now()
|
|
986
|
+
timestamp = now.strftime("%H:%M:%S")
|
|
987
|
+
|
|
988
|
+
# Status color and icon
|
|
989
|
+
status = root_span.get("status", "unknown")
|
|
990
|
+
if status == "success":
|
|
991
|
+
status_icon = "✓"
|
|
992
|
+
status_color = "green"
|
|
993
|
+
elif status == "error":
|
|
994
|
+
status_icon = "✗"
|
|
995
|
+
status_color = "red"
|
|
996
|
+
else:
|
|
997
|
+
status_icon = "â—‹"
|
|
998
|
+
status_color = "yellow"
|
|
999
|
+
|
|
1000
|
+
# Format duration
|
|
1001
|
+
duration_ms = root_span.get("duration_ms", 0)
|
|
1002
|
+
if duration_ms > 1000:
|
|
1003
|
+
duration_str = f"{duration_ms / 1000:.2f}s"
|
|
1004
|
+
else:
|
|
1005
|
+
duration_str = f"{duration_ms:.0f}ms"
|
|
1006
|
+
|
|
1007
|
+
# Print compact or detailed
|
|
1008
|
+
if compact:
|
|
1009
|
+
console.print(
|
|
1010
|
+
f"[dim]{timestamp}[/dim] [{status_color}]{status_icon}[/{status_color}] "
|
|
1011
|
+
f"[cyan]{trace_id[:12]}[/cyan] "
|
|
1012
|
+
f"[bold]{root_span.get('name', 'unknown')}[/bold] "
|
|
1013
|
+
f"[dim]{duration_str}[/dim]"
|
|
1014
|
+
)
|
|
1015
|
+
else:
|
|
1016
|
+
console.print(
|
|
1017
|
+
f"[dim][{timestamp}][/dim] "
|
|
1018
|
+
f"[{status_color}]{status_icon} {status.upper()}[/{status_color}] | "
|
|
1019
|
+
f"Trace: [cyan]{trace_id[:16]}[/cyan] | "
|
|
1020
|
+
f"Name: [bold]{root_span.get('name', 'unknown')}[/bold] | "
|
|
1021
|
+
f"Duration: {duration_str}"
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
# Update seen set
|
|
1025
|
+
seen_trace_ids.add(trace_id)
|
|
1026
|
+
|
|
1027
|
+
except KeyboardInterrupt:
|
|
1028
|
+
console.print("\n[yellow]Stopped following traces[/yellow]")
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@app.command()
|
|
1032
|
+
def serve(
|
|
1033
|
+
port: int = typer.Option(8000, "--port", "-p", help="Port to run server on"),
|
|
1034
|
+
) -> None:
|
|
1035
|
+
"""Start local web dashboard (placeholder - not implemented)."""
|
|
1036
|
+
console.print(
|
|
1037
|
+
f"[yellow]Web dashboard not yet implemented[/yellow]\n"
|
|
1038
|
+
f"Planned: Start server on http://localhost:{port}\n"
|
|
1039
|
+
f"Will serve API endpoints + static frontend"
|
|
1040
|
+
)
|
|
1041
|
+
console.print("\n[dim]This feature is planned for Phase 1 (Months 4-8)[/dim]")
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
@app.command()
|
|
1045
|
+
def eval(
|
|
1046
|
+
suite_path: str = typer.Argument(..., help="Path to eval suite file"),
|
|
1047
|
+
) -> None:
|
|
1048
|
+
"""Run evaluation suite (placeholder - not implemented)."""
|
|
1049
|
+
console.print(
|
|
1050
|
+
f"[yellow]Eval runner not yet implemented[/yellow]\n"
|
|
1051
|
+
f"Planned: Run eval suite from {suite_path}\n"
|
|
1052
|
+
f"Will output results with pass/fail metrics"
|
|
1053
|
+
)
|
|
1054
|
+
console.print("\n[dim]This feature is planned for Phase 1 (Months 4-8)[/dim]")
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def main() -> None:
|
|
1058
|
+
"""Entry point for CLI."""
|
|
1059
|
+
app()
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
if __name__ == "__main__":
|
|
1063
|
+
main()
|