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
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Interactive trace explorer using Textual TUI framework.
|
|
2
|
+
|
|
3
|
+
This module provides an interactive terminal user interface for browsing
|
|
4
|
+
traces, navigating span hierarchies, and inspecting span details without
|
|
5
|
+
needing to copy/paste trace IDs.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
$ prela explore
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from textual.app import App, ComposeResult
|
|
19
|
+
from textual.containers import Container, ScrollableContainer, VerticalScroll
|
|
20
|
+
from textual.widgets import DataTable, Footer, Header, Static, Tree
|
|
21
|
+
from textual.widgets.tree import TreeNode
|
|
22
|
+
|
|
23
|
+
# Import from existing cli module
|
|
24
|
+
from prela.contrib.cli import (
|
|
25
|
+
build_span_tree,
|
|
26
|
+
find_root_span,
|
|
27
|
+
group_spans_by_trace,
|
|
28
|
+
load_config,
|
|
29
|
+
load_traces_from_file,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TraceExplorer(App):
|
|
34
|
+
"""Interactive trace explorer with keyboard navigation.
|
|
35
|
+
|
|
36
|
+
Features:
|
|
37
|
+
- Trace list view (default)
|
|
38
|
+
- Trace detail view (expandable span tree)
|
|
39
|
+
- Span detail view (attributes and events)
|
|
40
|
+
|
|
41
|
+
Keyboard shortcuts:
|
|
42
|
+
- ↑/k: Move up
|
|
43
|
+
- ↓/j: Move down
|
|
44
|
+
- Enter: Select/drill down
|
|
45
|
+
- Esc: Go back
|
|
46
|
+
- q: Quit
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
CSS = """
|
|
50
|
+
Screen {
|
|
51
|
+
layout: vertical;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
#header {
|
|
55
|
+
dock: top;
|
|
56
|
+
height: 3;
|
|
57
|
+
background: $boost;
|
|
58
|
+
color: $text;
|
|
59
|
+
content-align: center middle;
|
|
60
|
+
text-style: bold;
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
text-overflow: ellipsis;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#content {
|
|
66
|
+
height: 1fr;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
DataTable {
|
|
70
|
+
height: 100%;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
Tree {
|
|
74
|
+
height: 100%;
|
|
75
|
+
background: $surface;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ScrollableContainer {
|
|
79
|
+
height: 1fr;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
VerticalScroll {
|
|
83
|
+
height: 100%;
|
|
84
|
+
background: $surface;
|
|
85
|
+
padding: 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.span-detail-section {
|
|
89
|
+
margin-bottom: 1;
|
|
90
|
+
padding: 1;
|
|
91
|
+
background: $panel;
|
|
92
|
+
border: solid $primary;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.span-detail-label {
|
|
96
|
+
text-style: bold;
|
|
97
|
+
color: $accent;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.loading {
|
|
101
|
+
padding: 2;
|
|
102
|
+
text-align: center;
|
|
103
|
+
color: $accent;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.error-message {
|
|
107
|
+
padding: 2;
|
|
108
|
+
background: $error;
|
|
109
|
+
color: $text;
|
|
110
|
+
border: heavy $error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
Footer {
|
|
114
|
+
dock: bottom;
|
|
115
|
+
}
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
BINDINGS = [
|
|
119
|
+
("up,k", "cursor_up", "Up"),
|
|
120
|
+
("down,j", "cursor_down", "Down"),
|
|
121
|
+
("enter", "select_item", "Select"),
|
|
122
|
+
("escape", "go_back", "Back"),
|
|
123
|
+
("q", "quit", "Quit"),
|
|
124
|
+
("?", "show_help", "Help"),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def __init__(self, trace_dir: Path):
|
|
128
|
+
"""Initialize trace explorer.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
trace_dir: Directory containing trace JSONL files
|
|
132
|
+
"""
|
|
133
|
+
super().__init__()
|
|
134
|
+
self.trace_dir = trace_dir
|
|
135
|
+
self.traces_data: dict[str, list[dict[str, Any]]] = {}
|
|
136
|
+
self.view_stack: list[str] = [] # Stack for back navigation
|
|
137
|
+
self.current_trace_id: Optional[str] = None
|
|
138
|
+
self.current_span: Optional[dict[str, Any]] = None
|
|
139
|
+
|
|
140
|
+
def compose(self) -> ComposeResult:
|
|
141
|
+
"""Create child widgets."""
|
|
142
|
+
yield Header()
|
|
143
|
+
yield Static("Loading traces...", id="header")
|
|
144
|
+
yield Container(
|
|
145
|
+
DataTable(id="trace-table", zebra_stripes=True),
|
|
146
|
+
id="content",
|
|
147
|
+
)
|
|
148
|
+
yield Footer()
|
|
149
|
+
|
|
150
|
+
def on_mount(self) -> None:
|
|
151
|
+
"""Load traces when app starts."""
|
|
152
|
+
header = self.query_one("#header", Static)
|
|
153
|
+
header.update("Prela Trace Explorer")
|
|
154
|
+
|
|
155
|
+
# Load traces from directory
|
|
156
|
+
self._load_and_display_traces()
|
|
157
|
+
|
|
158
|
+
def _load_and_display_traces(self) -> None:
|
|
159
|
+
"""Load traces from file system and display in table."""
|
|
160
|
+
try:
|
|
161
|
+
table = self.query_one("#trace-table", DataTable)
|
|
162
|
+
|
|
163
|
+
# Clear existing data
|
|
164
|
+
table.clear(columns=True)
|
|
165
|
+
|
|
166
|
+
# Show loading state for large directories
|
|
167
|
+
header = self.query_one("#header", Static)
|
|
168
|
+
header.update("Loading traces...")
|
|
169
|
+
|
|
170
|
+
# Load spans from file
|
|
171
|
+
try:
|
|
172
|
+
spans = load_traces_from_file(self.trace_dir)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
header.update(f"Error loading traces: {str(e)[:50]}")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
if not spans:
|
|
178
|
+
# No traces found - show empty state
|
|
179
|
+
header.update("No traces found - Try 'prela init' to configure")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Group spans by trace_id
|
|
183
|
+
self.traces_data = group_spans_by_trace(spans)
|
|
184
|
+
|
|
185
|
+
# Set up table columns
|
|
186
|
+
table.add_columns("Trace ID", "Name", "Status", "Duration", "Spans", "Time")
|
|
187
|
+
|
|
188
|
+
# Add rows for each trace
|
|
189
|
+
trace_summaries = []
|
|
190
|
+
for trace_id, trace_spans in self.traces_data.items():
|
|
191
|
+
root_span = find_root_span(trace_spans)
|
|
192
|
+
if root_span:
|
|
193
|
+
trace_summaries.append(
|
|
194
|
+
{
|
|
195
|
+
"trace_id": trace_id,
|
|
196
|
+
"root_span": root_span.get("name", "unknown"),
|
|
197
|
+
"duration_ms": root_span.get("duration_ms", 0),
|
|
198
|
+
"status": root_span.get("status", "unknown"),
|
|
199
|
+
"started_at": root_span.get("started_at", ""),
|
|
200
|
+
"span_count": len(trace_spans),
|
|
201
|
+
}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Sort by time (most recent first)
|
|
205
|
+
trace_summaries.sort(key=lambda x: x["started_at"], reverse=True)
|
|
206
|
+
|
|
207
|
+
# Add rows to table
|
|
208
|
+
for summary in trace_summaries:
|
|
209
|
+
# Format duration
|
|
210
|
+
duration_ms = summary["duration_ms"]
|
|
211
|
+
if duration_ms > 1000:
|
|
212
|
+
duration_str = f"{duration_ms / 1000:.2f}s"
|
|
213
|
+
else:
|
|
214
|
+
duration_str = f"{duration_ms:.0f}ms"
|
|
215
|
+
|
|
216
|
+
# Format time
|
|
217
|
+
try:
|
|
218
|
+
started_at = datetime.fromisoformat(summary["started_at"])
|
|
219
|
+
time_str = started_at.strftime("%H:%M:%S")
|
|
220
|
+
except Exception:
|
|
221
|
+
time_str = summary["started_at"][:8] if summary["started_at"] else ""
|
|
222
|
+
|
|
223
|
+
# Truncate trace ID for display
|
|
224
|
+
trace_id_display = summary["trace_id"][:16]
|
|
225
|
+
|
|
226
|
+
table.add_row(
|
|
227
|
+
trace_id_display,
|
|
228
|
+
summary["root_span"],
|
|
229
|
+
summary["status"],
|
|
230
|
+
duration_str,
|
|
231
|
+
str(summary["span_count"]),
|
|
232
|
+
time_str,
|
|
233
|
+
key=summary["trace_id"], # Full ID as key for lookup
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Update header with count
|
|
237
|
+
header = self.query_one("#header", Static)
|
|
238
|
+
trace_count = len(trace_summaries)
|
|
239
|
+
if trace_count == 0:
|
|
240
|
+
header.update("No valid traces found")
|
|
241
|
+
else:
|
|
242
|
+
header.update(f"Traces ({trace_count} found)")
|
|
243
|
+
except Exception as e:
|
|
244
|
+
# Handle any unexpected errors gracefully
|
|
245
|
+
header = self.query_one("#header", Static)
|
|
246
|
+
header.update(f"Error: {str(e)[:60]}")
|
|
247
|
+
import traceback
|
|
248
|
+
traceback.print_exc()
|
|
249
|
+
|
|
250
|
+
def action_select_item(self) -> None:
|
|
251
|
+
"""Handle Enter key - drill into trace or span."""
|
|
252
|
+
# Check if we're in trace list view
|
|
253
|
+
try:
|
|
254
|
+
table = self.query_one("#trace-table", DataTable)
|
|
255
|
+
# Get selected row key (which is the full trace_id)
|
|
256
|
+
if table.cursor_row is None:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
row_key = table.get_row_key_at(table.cursor_row)
|
|
260
|
+
if row_key is None:
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
# Convert RowKey to trace_id string
|
|
264
|
+
trace_id = str(row_key)
|
|
265
|
+
|
|
266
|
+
# Show trace detail view
|
|
267
|
+
self._show_trace_detail(trace_id)
|
|
268
|
+
except Exception:
|
|
269
|
+
# Not in trace list, check if we're in tree view
|
|
270
|
+
try:
|
|
271
|
+
tree = self.query_one("#span-tree", Tree)
|
|
272
|
+
# Get selected node
|
|
273
|
+
if tree.cursor_node is None:
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
# Get span data from node
|
|
277
|
+
span_data = tree.cursor_node.data
|
|
278
|
+
if span_data:
|
|
279
|
+
self._show_span_detail(span_data)
|
|
280
|
+
except Exception:
|
|
281
|
+
# Not in tree view either, ignore
|
|
282
|
+
pass
|
|
283
|
+
|
|
284
|
+
def _show_trace_detail(self, trace_id: str) -> None:
|
|
285
|
+
"""Show detailed view of a single trace.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
trace_id: Trace ID to display
|
|
289
|
+
"""
|
|
290
|
+
# Update header
|
|
291
|
+
header = self.query_one("#header", Static)
|
|
292
|
+
header.update(f"Trace Detail: {trace_id[:16]} (Press Esc to go back)")
|
|
293
|
+
|
|
294
|
+
# Store current view in stack
|
|
295
|
+
self.view_stack.append("list")
|
|
296
|
+
self.current_trace_id = trace_id
|
|
297
|
+
|
|
298
|
+
# Get trace spans
|
|
299
|
+
trace_spans = self.traces_data.get(trace_id, [])
|
|
300
|
+
if not trace_spans:
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
# Remove the table and add tree
|
|
304
|
+
container = self.query_one("#content", Container)
|
|
305
|
+
container.remove_children()
|
|
306
|
+
|
|
307
|
+
# Build span tree using existing cli function
|
|
308
|
+
span_tree = build_span_tree(trace_spans)
|
|
309
|
+
|
|
310
|
+
# Create Tree widget
|
|
311
|
+
tree = Tree(f"Trace: {trace_id[:16]}", id="span-tree")
|
|
312
|
+
tree.root.expand()
|
|
313
|
+
|
|
314
|
+
# Populate tree with spans
|
|
315
|
+
span_count = len(trace_spans)
|
|
316
|
+
self._add_spans_to_tree(tree.root, span_tree, max_depth=0 if span_count > 50 else None)
|
|
317
|
+
|
|
318
|
+
# Add tree to container
|
|
319
|
+
container.mount(tree)
|
|
320
|
+
|
|
321
|
+
# Update header with span count info
|
|
322
|
+
if span_count > 50:
|
|
323
|
+
header.update(
|
|
324
|
+
f"Trace: {trace_id[:16]} ({span_count} spans - Use arrows to expand nodes)"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def _add_spans_to_tree(
|
|
328
|
+
self,
|
|
329
|
+
parent_node: TreeNode,
|
|
330
|
+
spans: list[dict[str, Any]],
|
|
331
|
+
max_depth: int | None = None,
|
|
332
|
+
current_depth: int = 0,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Recursively add spans to tree widget.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
parent_node: Parent tree node
|
|
338
|
+
spans: List of span dicts with 'span' and 'children' keys
|
|
339
|
+
max_depth: Maximum depth to auto-expand (None = unlimited)
|
|
340
|
+
current_depth: Current depth in tree (for tracking)
|
|
341
|
+
"""
|
|
342
|
+
for span_data in spans:
|
|
343
|
+
span = span_data["span"]
|
|
344
|
+
children = span_data.get("children", [])
|
|
345
|
+
|
|
346
|
+
# Format span info
|
|
347
|
+
name = span.get("name", "unknown")
|
|
348
|
+
duration_ms = span.get("duration_ms", 0)
|
|
349
|
+
status = span.get("status", "unknown")
|
|
350
|
+
|
|
351
|
+
# Format duration
|
|
352
|
+
if duration_ms > 1000:
|
|
353
|
+
duration_str = f"{duration_ms / 1000:.2f}s"
|
|
354
|
+
else:
|
|
355
|
+
duration_str = f"{duration_ms:.0f}ms"
|
|
356
|
+
|
|
357
|
+
# Create node label with status emoji
|
|
358
|
+
status_icon = "✓" if status == "success" else "✗" if status == "error" else "○"
|
|
359
|
+
label = f"{status_icon} {name} ({duration_str})"
|
|
360
|
+
|
|
361
|
+
# Add node
|
|
362
|
+
node = parent_node.add(label, data=span)
|
|
363
|
+
|
|
364
|
+
# Recursively add children
|
|
365
|
+
if children:
|
|
366
|
+
self._add_spans_to_tree(
|
|
367
|
+
node, children, max_depth=max_depth, current_depth=current_depth + 1
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# Collapse nodes beyond max_depth to improve performance
|
|
371
|
+
if max_depth is not None and current_depth >= max_depth:
|
|
372
|
+
node.collapse()
|
|
373
|
+
|
|
374
|
+
def _show_span_detail(self, span: dict[str, Any]) -> None:
|
|
375
|
+
"""Show detailed view of a single span.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
span: Span dictionary with all attributes
|
|
379
|
+
"""
|
|
380
|
+
# Update header
|
|
381
|
+
span_name = span.get("name", "unknown")
|
|
382
|
+
header = self.query_one("#header", Static)
|
|
383
|
+
header.update(f"Span: {span_name} (Press Esc to go back)")
|
|
384
|
+
|
|
385
|
+
# Store current view in stack
|
|
386
|
+
self.view_stack.append("tree")
|
|
387
|
+
self.current_span = span
|
|
388
|
+
|
|
389
|
+
# Remove the tree and add span detail view
|
|
390
|
+
container = self.query_one("#content", Container)
|
|
391
|
+
container.remove_children()
|
|
392
|
+
|
|
393
|
+
# Create scrollable container for span details
|
|
394
|
+
scroll = VerticalScroll()
|
|
395
|
+
|
|
396
|
+
# Add span information sections
|
|
397
|
+
sections = []
|
|
398
|
+
|
|
399
|
+
# Basic info section
|
|
400
|
+
basic_info = f"""[span-detail-label]Span Information[/span-detail-label]
|
|
401
|
+
Name: {span.get('name', 'unknown')}
|
|
402
|
+
Type: {span.get('span_type', 'unknown')}
|
|
403
|
+
Status: {span.get('status', 'unknown')}
|
|
404
|
+
Duration: {self._format_duration(span.get('duration_ms', 0))}
|
|
405
|
+
Started: {span.get('started_at', 'unknown')}
|
|
406
|
+
Ended: {span.get('ended_at', 'unknown')}"""
|
|
407
|
+
sections.append(Static(basic_info, classes="span-detail-section"))
|
|
408
|
+
|
|
409
|
+
# Attributes section
|
|
410
|
+
attributes = span.get("attributes", {})
|
|
411
|
+
if attributes:
|
|
412
|
+
attrs_json = json.dumps(attributes, indent=2, default=str)
|
|
413
|
+
attrs_text = f"""[span-detail-label]Attributes[/span-detail-label]
|
|
414
|
+
{attrs_json}"""
|
|
415
|
+
sections.append(Static(attrs_text, classes="span-detail-section"))
|
|
416
|
+
|
|
417
|
+
# Events section
|
|
418
|
+
events = span.get("events", [])
|
|
419
|
+
if events:
|
|
420
|
+
events_text = "[span-detail-label]Events[/span-detail-label]\n"
|
|
421
|
+
for idx, event in enumerate(events, 1):
|
|
422
|
+
event_name = event.get("name", "unknown")
|
|
423
|
+
event_time = event.get("timestamp", "unknown")
|
|
424
|
+
events_text += f"\n{idx}. {event_name} at {event_time}"
|
|
425
|
+
event_attrs = event.get("attributes", {})
|
|
426
|
+
if event_attrs:
|
|
427
|
+
events_text += f"\n {json.dumps(event_attrs, indent=2, default=str)}"
|
|
428
|
+
sections.append(Static(events_text, classes="span-detail-section"))
|
|
429
|
+
|
|
430
|
+
# Add all sections to scroll container
|
|
431
|
+
for section in sections:
|
|
432
|
+
scroll.mount(section)
|
|
433
|
+
|
|
434
|
+
# Add scroll container to main container
|
|
435
|
+
container.mount(scroll)
|
|
436
|
+
|
|
437
|
+
def _format_duration(self, duration_ms: float) -> str:
|
|
438
|
+
"""Format duration in ms or seconds.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
duration_ms: Duration in milliseconds
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Formatted duration string
|
|
445
|
+
"""
|
|
446
|
+
if duration_ms > 1000:
|
|
447
|
+
return f"{duration_ms / 1000:.2f}s"
|
|
448
|
+
return f"{duration_ms:.0f}ms"
|
|
449
|
+
|
|
450
|
+
def action_show_help(self) -> None:
|
|
451
|
+
"""Show help screen with keyboard shortcuts."""
|
|
452
|
+
# Store current view
|
|
453
|
+
help_text = """
|
|
454
|
+
[bold cyan]Prela Trace Explorer - Keyboard Shortcuts[/bold cyan]
|
|
455
|
+
|
|
456
|
+
[bold]Navigation:[/bold]
|
|
457
|
+
↑ / k Move up
|
|
458
|
+
↓ / j Move down
|
|
459
|
+
Enter Select item / Drill down
|
|
460
|
+
Esc Go back to previous view
|
|
461
|
+
|
|
462
|
+
[bold]Actions:[/bold]
|
|
463
|
+
q Quit application
|
|
464
|
+
? Show this help
|
|
465
|
+
|
|
466
|
+
[bold]Views:[/bold]
|
|
467
|
+
1. Trace List Browse all traces
|
|
468
|
+
2. Trace Detail View span hierarchy (tree)
|
|
469
|
+
3. Span Detail View span attributes & events
|
|
470
|
+
|
|
471
|
+
[bold cyan]Press any key to return[/bold cyan]
|
|
472
|
+
"""
|
|
473
|
+
|
|
474
|
+
# Update header
|
|
475
|
+
header = self.query_one("#header", Static)
|
|
476
|
+
header.update("Help - Press any key to return")
|
|
477
|
+
|
|
478
|
+
# Replace content with help text
|
|
479
|
+
container = self.query_one("#content", Container)
|
|
480
|
+
container.remove_children()
|
|
481
|
+
|
|
482
|
+
help_display = Static(help_text, classes="loading")
|
|
483
|
+
container.mount(help_display)
|
|
484
|
+
|
|
485
|
+
# Store in view stack so Esc will work
|
|
486
|
+
self.view_stack.append("help")
|
|
487
|
+
|
|
488
|
+
def action_go_back(self) -> None:
|
|
489
|
+
"""Handle Escape key - go back to previous view."""
|
|
490
|
+
if not self.view_stack:
|
|
491
|
+
# Already at top level - do nothing
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
# Pop previous view from stack
|
|
495
|
+
previous_view = self.view_stack.pop()
|
|
496
|
+
|
|
497
|
+
if previous_view == "help":
|
|
498
|
+
# Go back from help to trace list
|
|
499
|
+
container = self.query_one("#content", Container)
|
|
500
|
+
container.remove_children()
|
|
501
|
+
|
|
502
|
+
# Re-create table
|
|
503
|
+
table = DataTable(id="trace-table", zebra_stripes=True)
|
|
504
|
+
container.mount(table)
|
|
505
|
+
|
|
506
|
+
# Reload trace list
|
|
507
|
+
self._load_and_display_traces()
|
|
508
|
+
|
|
509
|
+
elif previous_view == "list":
|
|
510
|
+
# Go back to trace list
|
|
511
|
+
# Remove current view and restore table
|
|
512
|
+
container = self.query_one("#content", Container)
|
|
513
|
+
container.remove_children()
|
|
514
|
+
|
|
515
|
+
# Re-create table
|
|
516
|
+
table = DataTable(id="trace-table", zebra_stripes=True)
|
|
517
|
+
container.mount(table)
|
|
518
|
+
|
|
519
|
+
# Reload trace list
|
|
520
|
+
self._load_and_display_traces()
|
|
521
|
+
self.current_trace_id = None
|
|
522
|
+
|
|
523
|
+
elif previous_view == "tree":
|
|
524
|
+
# Go back to trace tree view
|
|
525
|
+
if self.current_trace_id:
|
|
526
|
+
# Remove span detail and restore tree
|
|
527
|
+
container = self.query_one("#content", Container)
|
|
528
|
+
container.remove_children()
|
|
529
|
+
|
|
530
|
+
# Re-show trace detail (which will recreate the tree)
|
|
531
|
+
trace_spans = self.traces_data.get(self.current_trace_id, [])
|
|
532
|
+
if trace_spans:
|
|
533
|
+
# Build span tree
|
|
534
|
+
span_tree = build_span_tree(trace_spans)
|
|
535
|
+
|
|
536
|
+
# Create Tree widget
|
|
537
|
+
tree = Tree(f"Trace: {self.current_trace_id[:16]}", id="span-tree")
|
|
538
|
+
tree.root.expand()
|
|
539
|
+
|
|
540
|
+
# Populate tree with spans
|
|
541
|
+
self._add_spans_to_tree(tree.root, span_tree)
|
|
542
|
+
|
|
543
|
+
# Add tree to container
|
|
544
|
+
container.mount(tree)
|
|
545
|
+
|
|
546
|
+
# Update header
|
|
547
|
+
header = self.query_one("#header", Static)
|
|
548
|
+
header.update(
|
|
549
|
+
f"Trace Detail: {self.current_trace_id[:16]} (Press Esc to go back)"
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
self.current_span = None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def run_explorer(trace_dir: Optional[Path] = None) -> None:
|
|
556
|
+
"""Run the interactive trace explorer.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
trace_dir: Directory containing traces (default: from config)
|
|
560
|
+
"""
|
|
561
|
+
if trace_dir is None:
|
|
562
|
+
config = load_config()
|
|
563
|
+
trace_dir = Path(config.get("trace_dir", "./traces"))
|
|
564
|
+
|
|
565
|
+
app = TraceExplorer(trace_dir)
|
|
566
|
+
app.run()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
if __name__ == "__main__":
|
|
570
|
+
# For testing purposes
|
|
571
|
+
run_explorer()
|
prela/core/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Core observability primitives for Prela SDK."""
|
|
2
|
+
|
|
3
|
+
from prela.core.clock import (
|
|
4
|
+
duration_ms,
|
|
5
|
+
format_timestamp,
|
|
6
|
+
monotonic_ns,
|
|
7
|
+
now,
|
|
8
|
+
parse_timestamp,
|
|
9
|
+
)
|
|
10
|
+
from prela.core.context import (
|
|
11
|
+
TraceContext,
|
|
12
|
+
copy_context_to_thread,
|
|
13
|
+
get_current_context,
|
|
14
|
+
get_current_span,
|
|
15
|
+
new_trace_context,
|
|
16
|
+
reset_context,
|
|
17
|
+
set_context,
|
|
18
|
+
)
|
|
19
|
+
from prela.core.replay import (
|
|
20
|
+
ReplayCapture,
|
|
21
|
+
ReplaySnapshot,
|
|
22
|
+
estimate_replay_storage,
|
|
23
|
+
serialize_replay_data,
|
|
24
|
+
)
|
|
25
|
+
from prela.core.sampler import (
|
|
26
|
+
AlwaysOffSampler,
|
|
27
|
+
AlwaysOnSampler,
|
|
28
|
+
BaseSampler,
|
|
29
|
+
ProbabilitySampler,
|
|
30
|
+
RateLimitingSampler,
|
|
31
|
+
)
|
|
32
|
+
from prela.core.span import Span, SpanEvent, SpanStatus, SpanType
|
|
33
|
+
from prela.core.tracer import Tracer, get_tracer, set_global_tracer
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Span",
|
|
37
|
+
"SpanEvent",
|
|
38
|
+
"SpanStatus",
|
|
39
|
+
"SpanType",
|
|
40
|
+
"TraceContext",
|
|
41
|
+
"get_current_context",
|
|
42
|
+
"get_current_span",
|
|
43
|
+
"set_context",
|
|
44
|
+
"reset_context",
|
|
45
|
+
"new_trace_context",
|
|
46
|
+
"copy_context_to_thread",
|
|
47
|
+
"now",
|
|
48
|
+
"monotonic_ns",
|
|
49
|
+
"duration_ms",
|
|
50
|
+
"format_timestamp",
|
|
51
|
+
"parse_timestamp",
|
|
52
|
+
"BaseSampler",
|
|
53
|
+
"AlwaysOnSampler",
|
|
54
|
+
"AlwaysOffSampler",
|
|
55
|
+
"ProbabilitySampler",
|
|
56
|
+
"RateLimitingSampler",
|
|
57
|
+
"Tracer",
|
|
58
|
+
"get_tracer",
|
|
59
|
+
"set_global_tracer",
|
|
60
|
+
"ReplayCapture",
|
|
61
|
+
"ReplaySnapshot",
|
|
62
|
+
"estimate_replay_storage",
|
|
63
|
+
"serialize_replay_data",
|
|
64
|
+
]
|