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.
Files changed (71) hide show
  1. prela/__init__.py +394 -0
  2. prela/_version.py +3 -0
  3. prela/contrib/CLI.md +431 -0
  4. prela/contrib/README.md +118 -0
  5. prela/contrib/__init__.py +5 -0
  6. prela/contrib/cli.py +1063 -0
  7. prela/contrib/explorer.py +571 -0
  8. prela/core/__init__.py +64 -0
  9. prela/core/clock.py +98 -0
  10. prela/core/context.py +228 -0
  11. prela/core/replay.py +403 -0
  12. prela/core/sampler.py +178 -0
  13. prela/core/span.py +295 -0
  14. prela/core/tracer.py +498 -0
  15. prela/evals/__init__.py +94 -0
  16. prela/evals/assertions/README.md +484 -0
  17. prela/evals/assertions/__init__.py +78 -0
  18. prela/evals/assertions/base.py +90 -0
  19. prela/evals/assertions/multi_agent.py +625 -0
  20. prela/evals/assertions/semantic.py +223 -0
  21. prela/evals/assertions/structural.py +443 -0
  22. prela/evals/assertions/tool.py +380 -0
  23. prela/evals/case.py +370 -0
  24. prela/evals/n8n/__init__.py +69 -0
  25. prela/evals/n8n/assertions.py +450 -0
  26. prela/evals/n8n/runner.py +497 -0
  27. prela/evals/reporters/README.md +184 -0
  28. prela/evals/reporters/__init__.py +32 -0
  29. prela/evals/reporters/console.py +251 -0
  30. prela/evals/reporters/json.py +176 -0
  31. prela/evals/reporters/junit.py +278 -0
  32. prela/evals/runner.py +525 -0
  33. prela/evals/suite.py +316 -0
  34. prela/exporters/__init__.py +27 -0
  35. prela/exporters/base.py +189 -0
  36. prela/exporters/console.py +443 -0
  37. prela/exporters/file.py +322 -0
  38. prela/exporters/http.py +394 -0
  39. prela/exporters/multi.py +154 -0
  40. prela/exporters/otlp.py +388 -0
  41. prela/instrumentation/ANTHROPIC.md +297 -0
  42. prela/instrumentation/LANGCHAIN.md +480 -0
  43. prela/instrumentation/OPENAI.md +59 -0
  44. prela/instrumentation/__init__.py +49 -0
  45. prela/instrumentation/anthropic.py +1436 -0
  46. prela/instrumentation/auto.py +129 -0
  47. prela/instrumentation/base.py +436 -0
  48. prela/instrumentation/langchain.py +959 -0
  49. prela/instrumentation/llamaindex.py +719 -0
  50. prela/instrumentation/multi_agent/__init__.py +48 -0
  51. prela/instrumentation/multi_agent/autogen.py +357 -0
  52. prela/instrumentation/multi_agent/crewai.py +404 -0
  53. prela/instrumentation/multi_agent/langgraph.py +299 -0
  54. prela/instrumentation/multi_agent/models.py +203 -0
  55. prela/instrumentation/multi_agent/swarm.py +231 -0
  56. prela/instrumentation/n8n/__init__.py +68 -0
  57. prela/instrumentation/n8n/code_node.py +534 -0
  58. prela/instrumentation/n8n/models.py +336 -0
  59. prela/instrumentation/n8n/webhook.py +489 -0
  60. prela/instrumentation/openai.py +1198 -0
  61. prela/license.py +245 -0
  62. prela/replay/__init__.py +31 -0
  63. prela/replay/comparison.py +390 -0
  64. prela/replay/engine.py +1227 -0
  65. prela/replay/loader.py +231 -0
  66. prela/replay/result.py +196 -0
  67. prela-0.1.0.dist-info/METADATA +399 -0
  68. prela-0.1.0.dist-info/RECORD +71 -0
  69. prela-0.1.0.dist-info/WHEEL +4 -0
  70. prela-0.1.0.dist-info/entry_points.txt +2 -0
  71. 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()