logler 1.0.7__cp311-cp311-win_amd64.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.
- logler/__init__.py +22 -0
- logler/bootstrap.py +57 -0
- logler/cache.py +75 -0
- logler/cli.py +589 -0
- logler/helpers.py +282 -0
- logler/investigate.py +3962 -0
- logler/llm_cli.py +1426 -0
- logler/log_reader.py +267 -0
- logler/parser.py +207 -0
- logler/safe_regex.py +124 -0
- logler/terminal.py +252 -0
- logler/tracker.py +138 -0
- logler/tree_formatter.py +807 -0
- logler/watcher.py +55 -0
- logler/web/__init__.py +3 -0
- logler/web/app.py +810 -0
- logler/web/static/css/tailwind.css +1 -0
- logler/web/static/css/tailwind.input.css +3 -0
- logler/web/static/logler-logo.png +0 -0
- logler/web/tailwind.config.cjs +9 -0
- logler/web/templates/index.html +1454 -0
- logler-1.0.7.dist-info/METADATA +584 -0
- logler-1.0.7.dist-info/RECORD +28 -0
- logler-1.0.7.dist-info/WHEEL +4 -0
- logler-1.0.7.dist-info/entry_points.txt +2 -0
- logler-1.0.7.dist-info/licenses/LICENSE +21 -0
- logler_rs/__init__.py +5 -0
- logler_rs/logler_rs.cp311-win_amd64.pyd +0 -0
logler/tree_formatter.py
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tree Formatter - Beautiful CLI visualization for hierarchical data
|
|
3
|
+
|
|
4
|
+
Renders thread hierarchies and nested structures as ASCII trees with:
|
|
5
|
+
- Unicode box-drawing characters
|
|
6
|
+
- Color support (via Rich when available)
|
|
7
|
+
- Error highlighting
|
|
8
|
+
- Duration annotations
|
|
9
|
+
- Compact and detailed modes
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Dict, Any, List, Optional
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from rich.console import Console # noqa: F401
|
|
18
|
+
from rich.text import Text # noqa: F401
|
|
19
|
+
from rich.tree import Tree as RichTree # noqa: F401
|
|
20
|
+
|
|
21
|
+
RICH_AVAILABLE = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
RICH_AVAILABLE = False
|
|
24
|
+
Text = None # Placeholder when Rich is not available
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_tree(
|
|
28
|
+
hierarchy: Dict[str, Any],
|
|
29
|
+
mode: str = "compact",
|
|
30
|
+
show_duration: bool = True,
|
|
31
|
+
show_errors: bool = True,
|
|
32
|
+
show_confidence: bool = False,
|
|
33
|
+
max_depth: Optional[int] = None,
|
|
34
|
+
use_colors: bool = True,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""
|
|
37
|
+
Format a hierarchy as an ASCII tree.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
hierarchy: Hierarchy dictionary from follow_thread_hierarchy()
|
|
41
|
+
mode: Display mode - "compact", "detailed", or "full"
|
|
42
|
+
show_duration: Show duration annotations
|
|
43
|
+
show_errors: Highlight errors
|
|
44
|
+
show_confidence: Show confidence scores
|
|
45
|
+
max_depth: Maximum depth to display (None = unlimited)
|
|
46
|
+
use_colors: Use ANSI colors (requires Rich)
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Formatted tree string
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
hierarchy = follow_thread_hierarchy(files=["app.log"], root_identifier="req-123")
|
|
53
|
+
tree = format_tree(hierarchy, mode="compact", show_duration=True)
|
|
54
|
+
print(tree)
|
|
55
|
+
"""
|
|
56
|
+
if use_colors and RICH_AVAILABLE:
|
|
57
|
+
return _format_rich_tree(
|
|
58
|
+
hierarchy, mode, show_duration, show_errors, show_confidence, max_depth
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
return _format_ascii_tree(
|
|
62
|
+
hierarchy, mode, show_duration, show_errors, show_confidence, max_depth
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_ascii_tree(
|
|
67
|
+
hierarchy: Dict[str, Any],
|
|
68
|
+
mode: str,
|
|
69
|
+
show_duration: bool,
|
|
70
|
+
show_errors: bool,
|
|
71
|
+
show_confidence: bool,
|
|
72
|
+
max_depth: Optional[int],
|
|
73
|
+
) -> str:
|
|
74
|
+
"""Format tree using plain ASCII (no colors)"""
|
|
75
|
+
lines = []
|
|
76
|
+
|
|
77
|
+
# Header
|
|
78
|
+
lines.append("=" * 70)
|
|
79
|
+
lines.append("THREAD HIERARCHY")
|
|
80
|
+
lines.append("=" * 70)
|
|
81
|
+
lines.append(f"Total nodes: {hierarchy.get('total_nodes', 0)}")
|
|
82
|
+
lines.append(f"Max depth: {hierarchy.get('max_depth', 0)}")
|
|
83
|
+
lines.append(f"Detection: {hierarchy.get('detection_method', 'Unknown')}")
|
|
84
|
+
|
|
85
|
+
total_duration = hierarchy.get("total_duration_ms")
|
|
86
|
+
if total_duration and show_duration:
|
|
87
|
+
lines.append(f"Total duration: {_format_duration(total_duration)}")
|
|
88
|
+
|
|
89
|
+
# Bottleneck
|
|
90
|
+
bottleneck = hierarchy.get("bottleneck")
|
|
91
|
+
if bottleneck:
|
|
92
|
+
lines.append("")
|
|
93
|
+
lines.append(
|
|
94
|
+
f"⚠️ BOTTLENECK: {bottleneck.get('node_id')} ({_format_duration(bottleneck.get('duration_ms', 0))}, {bottleneck.get('percentage', 0):.1f}%)"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Errors
|
|
98
|
+
error_nodes = hierarchy.get("error_nodes", [])
|
|
99
|
+
if error_nodes and show_errors:
|
|
100
|
+
lines.append("")
|
|
101
|
+
lines.append(f"❌ {len(error_nodes)} node(s) with errors")
|
|
102
|
+
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.append("-" * 70)
|
|
105
|
+
lines.append("")
|
|
106
|
+
|
|
107
|
+
# Tree
|
|
108
|
+
roots = hierarchy.get("roots", [])
|
|
109
|
+
for i, root in enumerate(roots):
|
|
110
|
+
is_last_root = i == len(roots) - 1
|
|
111
|
+
_append_node_ascii(
|
|
112
|
+
root,
|
|
113
|
+
lines,
|
|
114
|
+
"",
|
|
115
|
+
is_last_root,
|
|
116
|
+
mode,
|
|
117
|
+
show_duration,
|
|
118
|
+
show_errors,
|
|
119
|
+
show_confidence,
|
|
120
|
+
max_depth,
|
|
121
|
+
0,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
lines.append("")
|
|
125
|
+
lines.append("=" * 70)
|
|
126
|
+
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _append_node_ascii(
|
|
131
|
+
node: Dict[str, Any],
|
|
132
|
+
lines: List[str],
|
|
133
|
+
prefix: str,
|
|
134
|
+
is_last: bool,
|
|
135
|
+
mode: str,
|
|
136
|
+
show_duration: bool,
|
|
137
|
+
show_errors: bool,
|
|
138
|
+
show_confidence: bool,
|
|
139
|
+
max_depth: Optional[int],
|
|
140
|
+
current_depth: int,
|
|
141
|
+
):
|
|
142
|
+
"""Recursively append node to ASCII tree"""
|
|
143
|
+
if max_depth is not None and current_depth >= max_depth:
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Node connector
|
|
147
|
+
connector = "└── " if is_last else "├── "
|
|
148
|
+
|
|
149
|
+
# Node ID and type - prefer 'name' over 'id' for display
|
|
150
|
+
node_id = node.get("name") or node.get("id", "unknown")
|
|
151
|
+
node_type = node.get("node_type", "Unknown")
|
|
152
|
+
|
|
153
|
+
# Error marker
|
|
154
|
+
error_marker = ""
|
|
155
|
+
if show_errors and node.get("error_count", 0) > 0:
|
|
156
|
+
error_marker = f"❌ [{node.get('error_count')} errors] "
|
|
157
|
+
|
|
158
|
+
# Build node line
|
|
159
|
+
node_line = f"{prefix}{connector}{error_marker}{node_id}"
|
|
160
|
+
|
|
161
|
+
# Add metadata based on mode
|
|
162
|
+
metadata = []
|
|
163
|
+
|
|
164
|
+
if mode == "detailed" or mode == "full":
|
|
165
|
+
metadata.append(f"type={node_type}")
|
|
166
|
+
metadata.append(f"entries={node.get('entry_count', 0)}")
|
|
167
|
+
|
|
168
|
+
if show_duration:
|
|
169
|
+
duration_ms = node.get("duration_ms")
|
|
170
|
+
if duration_ms is not None:
|
|
171
|
+
metadata.append(f"duration={_format_duration(duration_ms)}")
|
|
172
|
+
|
|
173
|
+
if show_confidence:
|
|
174
|
+
confidence = node.get("confidence", 0.0)
|
|
175
|
+
metadata.append(f"confidence={confidence:.2f}")
|
|
176
|
+
|
|
177
|
+
elif mode == "compact":
|
|
178
|
+
# Compact mode: just entry count and duration
|
|
179
|
+
metadata.append(f"{node.get('entry_count', 0)} entries")
|
|
180
|
+
if show_duration:
|
|
181
|
+
duration_ms = node.get("duration_ms")
|
|
182
|
+
if duration_ms is not None:
|
|
183
|
+
metadata.append(_format_duration(duration_ms))
|
|
184
|
+
|
|
185
|
+
if metadata:
|
|
186
|
+
node_line += f" ({', '.join(metadata)})"
|
|
187
|
+
|
|
188
|
+
lines.append(node_line)
|
|
189
|
+
|
|
190
|
+
# Full mode: show additional details
|
|
191
|
+
if mode == "full":
|
|
192
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
193
|
+
level_counts = node.get("level_counts", {})
|
|
194
|
+
if level_counts:
|
|
195
|
+
level_str = ", ".join([f"{level}: {count}" for level, count in level_counts.items()])
|
|
196
|
+
lines.append(f"{child_prefix} Levels: {level_str}")
|
|
197
|
+
|
|
198
|
+
evidence = node.get("relationship_evidence", [])
|
|
199
|
+
if evidence and show_confidence:
|
|
200
|
+
for ev in evidence[:2]: # Show first 2
|
|
201
|
+
lines.append(f"{child_prefix} 📋 {ev}")
|
|
202
|
+
|
|
203
|
+
# Process children
|
|
204
|
+
children = node.get("children", [])
|
|
205
|
+
if children:
|
|
206
|
+
# Sort children by start time if available
|
|
207
|
+
sorted_children = sorted(
|
|
208
|
+
children, key=lambda c: c.get("start_time") or "9999-12-31T23:59:59Z"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
child_prefix = prefix + (" " if is_last else "│ ")
|
|
212
|
+
for i, child in enumerate(sorted_children):
|
|
213
|
+
is_last_child = i == len(sorted_children) - 1
|
|
214
|
+
_append_node_ascii(
|
|
215
|
+
child,
|
|
216
|
+
lines,
|
|
217
|
+
child_prefix,
|
|
218
|
+
is_last_child,
|
|
219
|
+
mode,
|
|
220
|
+
show_duration,
|
|
221
|
+
show_errors,
|
|
222
|
+
show_confidence,
|
|
223
|
+
max_depth,
|
|
224
|
+
current_depth + 1,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _format_rich_tree(
|
|
229
|
+
hierarchy: Dict[str, Any],
|
|
230
|
+
mode: str,
|
|
231
|
+
show_duration: bool,
|
|
232
|
+
show_errors: bool,
|
|
233
|
+
show_confidence: bool,
|
|
234
|
+
max_depth: Optional[int],
|
|
235
|
+
) -> str:
|
|
236
|
+
"""Format tree using Rich library with colors"""
|
|
237
|
+
from rich.console import Console
|
|
238
|
+
from rich.tree import Tree as RichTree
|
|
239
|
+
from rich.text import Text
|
|
240
|
+
from io import StringIO
|
|
241
|
+
|
|
242
|
+
console = Console(file=StringIO(), width=100)
|
|
243
|
+
|
|
244
|
+
# Create root tree
|
|
245
|
+
header = Text()
|
|
246
|
+
header.append("Thread Hierarchy", style="bold cyan")
|
|
247
|
+
header.append(f" ({hierarchy.get('total_nodes', 0)} nodes, ", style="dim")
|
|
248
|
+
header.append(f"max depth: {hierarchy.get('max_depth', 0)}", style="dim")
|
|
249
|
+
if show_duration:
|
|
250
|
+
total_duration = hierarchy.get("total_duration_ms")
|
|
251
|
+
if total_duration:
|
|
252
|
+
header.append(f", {_format_duration(total_duration)}", style="yellow")
|
|
253
|
+
header.append(")", style="dim")
|
|
254
|
+
|
|
255
|
+
tree = RichTree(header)
|
|
256
|
+
|
|
257
|
+
# Add bottleneck warning
|
|
258
|
+
bottleneck = hierarchy.get("bottleneck")
|
|
259
|
+
if bottleneck:
|
|
260
|
+
warning = Text()
|
|
261
|
+
warning.append("⚠️ BOTTLENECK: ", style="bold yellow")
|
|
262
|
+
warning.append(bottleneck.get("node_id", ""), style="red")
|
|
263
|
+
warning.append(
|
|
264
|
+
f" ({_format_duration(bottleneck.get('duration_ms', 0))}, {bottleneck.get('percentage', 0):.1f}%)",
|
|
265
|
+
style="yellow",
|
|
266
|
+
)
|
|
267
|
+
tree.add(warning)
|
|
268
|
+
|
|
269
|
+
# Add error summary
|
|
270
|
+
error_nodes = hierarchy.get("error_nodes", [])
|
|
271
|
+
if error_nodes and show_errors:
|
|
272
|
+
error_text = Text()
|
|
273
|
+
error_text.append(f"❌ {len(error_nodes)} node(s) with errors", style="bold red")
|
|
274
|
+
tree.add(error_text)
|
|
275
|
+
|
|
276
|
+
# Add roots
|
|
277
|
+
roots = hierarchy.get("roots", [])
|
|
278
|
+
for root in roots:
|
|
279
|
+
root_node = _create_rich_node(root, mode, show_duration, show_errors, show_confidence)
|
|
280
|
+
root_tree = tree.add(root_node)
|
|
281
|
+
_add_rich_children(
|
|
282
|
+
root_tree, root, mode, show_duration, show_errors, show_confidence, max_depth, 0
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# Render to string
|
|
286
|
+
output = StringIO()
|
|
287
|
+
console = Console(file=output, width=100)
|
|
288
|
+
console.print(tree)
|
|
289
|
+
return output.getvalue()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _create_rich_node(
|
|
293
|
+
node: Dict[str, Any],
|
|
294
|
+
mode: str,
|
|
295
|
+
show_duration: bool,
|
|
296
|
+
show_errors: bool,
|
|
297
|
+
show_confidence: bool,
|
|
298
|
+
):
|
|
299
|
+
"""Create a Rich Text object for a node"""
|
|
300
|
+
from rich.text import Text
|
|
301
|
+
|
|
302
|
+
text = Text()
|
|
303
|
+
|
|
304
|
+
# Error marker
|
|
305
|
+
if show_errors and node.get("error_count", 0) > 0:
|
|
306
|
+
text.append("❌ ", style="bold red")
|
|
307
|
+
|
|
308
|
+
# Node ID
|
|
309
|
+
node_id = node.get("id", "unknown")
|
|
310
|
+
text.append(node_id, style="bold green")
|
|
311
|
+
|
|
312
|
+
# Metadata
|
|
313
|
+
metadata = []
|
|
314
|
+
|
|
315
|
+
if mode == "detailed" or mode == "full":
|
|
316
|
+
node_type = node.get("node_type", "Unknown")
|
|
317
|
+
metadata.append(f"type={node_type}")
|
|
318
|
+
metadata.append(f"entries={node.get('entry_count', 0)}")
|
|
319
|
+
|
|
320
|
+
if show_duration:
|
|
321
|
+
duration_ms = node.get("duration_ms")
|
|
322
|
+
if duration_ms is not None:
|
|
323
|
+
metadata.append(f"duration={_format_duration(duration_ms)}")
|
|
324
|
+
|
|
325
|
+
if show_confidence:
|
|
326
|
+
confidence = node.get("confidence", 0.0)
|
|
327
|
+
metadata.append(f"confidence={confidence:.2f}")
|
|
328
|
+
|
|
329
|
+
elif mode == "compact":
|
|
330
|
+
metadata.append(f"{node.get('entry_count', 0)} entries")
|
|
331
|
+
if show_duration:
|
|
332
|
+
duration_ms = node.get("duration_ms")
|
|
333
|
+
if duration_ms is not None:
|
|
334
|
+
metadata.append(_format_duration(duration_ms))
|
|
335
|
+
|
|
336
|
+
if metadata:
|
|
337
|
+
text.append(" (", style="dim")
|
|
338
|
+
text.append(", ".join(metadata), style="cyan")
|
|
339
|
+
text.append(")", style="dim")
|
|
340
|
+
|
|
341
|
+
# Error count
|
|
342
|
+
if show_errors and node.get("error_count", 0) > 0:
|
|
343
|
+
text.append(f" [{node.get('error_count')} errors]", style="bold red")
|
|
344
|
+
|
|
345
|
+
return text
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _add_rich_children(
|
|
349
|
+
parent_tree,
|
|
350
|
+
node: Dict[str, Any],
|
|
351
|
+
mode: str,
|
|
352
|
+
show_duration: bool,
|
|
353
|
+
show_errors: bool,
|
|
354
|
+
show_confidence: bool,
|
|
355
|
+
max_depth: Optional[int],
|
|
356
|
+
current_depth: int,
|
|
357
|
+
):
|
|
358
|
+
"""Recursively add children to Rich tree"""
|
|
359
|
+
if max_depth is not None and current_depth >= max_depth:
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
children = node.get("children", [])
|
|
363
|
+
if not children:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
# Sort children by start time
|
|
367
|
+
sorted_children = sorted(children, key=lambda c: c.get("start_time") or "9999-12-31T23:59:59Z")
|
|
368
|
+
|
|
369
|
+
for child in sorted_children:
|
|
370
|
+
child_text = _create_rich_node(child, mode, show_duration, show_errors, show_confidence)
|
|
371
|
+
child_tree = parent_tree.add(child_text)
|
|
372
|
+
|
|
373
|
+
# Add detailed info in full mode
|
|
374
|
+
if mode == "full":
|
|
375
|
+
level_counts = child.get("level_counts", {})
|
|
376
|
+
if level_counts:
|
|
377
|
+
level_text = Text()
|
|
378
|
+
level_text.append("Levels: ", style="dim")
|
|
379
|
+
level_parts = []
|
|
380
|
+
for level, count in level_counts.items():
|
|
381
|
+
color = "red" if level == "ERROR" else "yellow" if level == "WARN" else "white"
|
|
382
|
+
level_parts.append(f"{level}: {count}")
|
|
383
|
+
level_text.append(", ".join(level_parts), style=color)
|
|
384
|
+
child_tree.add(level_text)
|
|
385
|
+
|
|
386
|
+
evidence = child.get("relationship_evidence", [])
|
|
387
|
+
if evidence and show_confidence:
|
|
388
|
+
for ev in evidence[:2]:
|
|
389
|
+
ev_text = Text()
|
|
390
|
+
ev_text.append("📋 ", style="dim")
|
|
391
|
+
ev_text.append(ev, style="dim italic")
|
|
392
|
+
child_tree.add(ev_text)
|
|
393
|
+
|
|
394
|
+
_add_rich_children(
|
|
395
|
+
child_tree,
|
|
396
|
+
child,
|
|
397
|
+
mode,
|
|
398
|
+
show_duration,
|
|
399
|
+
show_errors,
|
|
400
|
+
show_confidence,
|
|
401
|
+
max_depth,
|
|
402
|
+
current_depth + 1,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _format_duration(ms: Optional[int]) -> str:
|
|
407
|
+
"""Format duration in human-readable form"""
|
|
408
|
+
if ms is None:
|
|
409
|
+
return "N/A"
|
|
410
|
+
if ms < 1000:
|
|
411
|
+
return f"{ms}ms"
|
|
412
|
+
elif ms < 60000:
|
|
413
|
+
return f"{ms/1000:.2f}s"
|
|
414
|
+
else:
|
|
415
|
+
minutes = ms // 60000
|
|
416
|
+
seconds = (ms % 60000) / 1000
|
|
417
|
+
return f"{minutes}m{seconds:.0f}s"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def print_tree(
|
|
421
|
+
hierarchy: Dict[str, Any],
|
|
422
|
+
mode: str = "compact",
|
|
423
|
+
show_duration: bool = True,
|
|
424
|
+
show_errors: bool = True,
|
|
425
|
+
show_confidence: bool = False,
|
|
426
|
+
max_depth: Optional[int] = None,
|
|
427
|
+
):
|
|
428
|
+
"""
|
|
429
|
+
Print hierarchy tree to console.
|
|
430
|
+
|
|
431
|
+
Convenience function that formats and prints in one call.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
hierarchy: Hierarchy dictionary
|
|
435
|
+
mode: Display mode - "compact", "detailed", or "full"
|
|
436
|
+
show_duration: Show duration annotations
|
|
437
|
+
show_errors: Highlight errors
|
|
438
|
+
show_confidence: Show confidence scores
|
|
439
|
+
max_depth: Maximum depth to display
|
|
440
|
+
|
|
441
|
+
Example:
|
|
442
|
+
hierarchy = follow_thread_hierarchy(files=["app.log"], root_identifier="req-123")
|
|
443
|
+
print_tree(hierarchy, mode="detailed", show_duration=True)
|
|
444
|
+
"""
|
|
445
|
+
tree_str = format_tree(hierarchy, mode, show_duration, show_errors, show_confidence, max_depth)
|
|
446
|
+
print(tree_str)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def format_waterfall(
|
|
450
|
+
hierarchy: Dict[str, Any],
|
|
451
|
+
width: int = 80,
|
|
452
|
+
show_labels: bool = True,
|
|
453
|
+
show_errors: bool = True,
|
|
454
|
+
) -> str:
|
|
455
|
+
"""
|
|
456
|
+
Format hierarchy as a waterfall timeline (horizontal bars).
|
|
457
|
+
|
|
458
|
+
Shows temporal overlap and identifies bottlenecks visually.
|
|
459
|
+
|
|
460
|
+
Args:
|
|
461
|
+
hierarchy: Hierarchy dictionary
|
|
462
|
+
width: Width of timeline in characters (default: 80)
|
|
463
|
+
show_labels: Show node labels
|
|
464
|
+
show_errors: Highlight errors in red
|
|
465
|
+
|
|
466
|
+
Returns:
|
|
467
|
+
Formatted waterfall string
|
|
468
|
+
|
|
469
|
+
Example:
|
|
470
|
+
hierarchy = follow_thread_hierarchy(files=["app.log"], root_identifier="req-123")
|
|
471
|
+
waterfall = format_waterfall(hierarchy, width=100)
|
|
472
|
+
print(waterfall)
|
|
473
|
+
|
|
474
|
+
Output:
|
|
475
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
476
|
+
│ Timeline: req-123 (5000ms) │
|
|
477
|
+
├─────────────────────────────────────────────────────────────┤
|
|
478
|
+
│ main-thread ████████████████████████████████████ 5000ms│
|
|
479
|
+
│ ├─ db-query ██████████ 2000ms│
|
|
480
|
+
│ └─ api-call ████████████████ 3000ms│
|
|
481
|
+
└─────────────────────────────────────────────────────────────┘
|
|
482
|
+
"""
|
|
483
|
+
lines = []
|
|
484
|
+
|
|
485
|
+
# Calculate total duration and time bounds
|
|
486
|
+
total_duration = hierarchy.get("total_duration_ms", 0)
|
|
487
|
+
if total_duration == 0:
|
|
488
|
+
return "No timing information available"
|
|
489
|
+
|
|
490
|
+
# Ensure minimum width for rendering
|
|
491
|
+
min_width = 40 # Minimum useful width
|
|
492
|
+
effective_width = max(width, min_width)
|
|
493
|
+
|
|
494
|
+
# Header
|
|
495
|
+
lines.append("┌" + "─" * (effective_width - 2) + "┐")
|
|
496
|
+
header = f"Timeline: {hierarchy.get('detection_method', 'Hierarchy')} ({_format_duration(total_duration)})"
|
|
497
|
+
if len(header) > effective_width - 4:
|
|
498
|
+
header = header[: effective_width - 7] + "..."
|
|
499
|
+
lines.append(f"│ {header:<{effective_width-4}} │")
|
|
500
|
+
lines.append("├" + "─" * (effective_width - 2) + "┤")
|
|
501
|
+
|
|
502
|
+
# Collect all nodes in order
|
|
503
|
+
nodes_flat = []
|
|
504
|
+
roots = hierarchy.get("roots", [])
|
|
505
|
+
for root in roots:
|
|
506
|
+
_collect_nodes_flat(root, nodes_flat, 0)
|
|
507
|
+
|
|
508
|
+
# Find earliest start time
|
|
509
|
+
earliest = None
|
|
510
|
+
for node_info in nodes_flat:
|
|
511
|
+
node = node_info["node"]
|
|
512
|
+
start_str = node.get("start_time")
|
|
513
|
+
if start_str:
|
|
514
|
+
try:
|
|
515
|
+
start_time = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
|
516
|
+
if earliest is None or start_time < earliest:
|
|
517
|
+
earliest = start_time
|
|
518
|
+
except (ValueError, TypeError):
|
|
519
|
+
pass # Skip invalid timestamps
|
|
520
|
+
|
|
521
|
+
if earliest is None:
|
|
522
|
+
return "No timing information available"
|
|
523
|
+
|
|
524
|
+
# Render each node (effective_width already set in header section)
|
|
525
|
+
label_width = min(20, effective_width // 3) # Adaptive label width
|
|
526
|
+
bar_width = max(1, effective_width - label_width - 12) # Ensure positive bar width
|
|
527
|
+
|
|
528
|
+
for node_info in nodes_flat:
|
|
529
|
+
node = node_info["node"]
|
|
530
|
+
depth = node_info["depth"]
|
|
531
|
+
|
|
532
|
+
node_id = node.get("id", "unknown")
|
|
533
|
+
start_str = node.get("start_time")
|
|
534
|
+
duration_ms = node.get("duration_ms", 0)
|
|
535
|
+
|
|
536
|
+
if not start_str or duration_ms == 0:
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
start_time = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
|
541
|
+
offset_ms = int((start_time - earliest).total_seconds() * 1000)
|
|
542
|
+
except (ValueError, TypeError):
|
|
543
|
+
continue # Skip nodes with invalid timestamps
|
|
544
|
+
|
|
545
|
+
# Calculate bar position and length
|
|
546
|
+
bar_start = int((offset_ms / total_duration) * bar_width)
|
|
547
|
+
bar_length = max(1, int((duration_ms / total_duration) * bar_width))
|
|
548
|
+
|
|
549
|
+
# Truncate bar if it exceeds width
|
|
550
|
+
if bar_start + bar_length > bar_width:
|
|
551
|
+
bar_length = bar_width - bar_start
|
|
552
|
+
|
|
553
|
+
# Build label with indentation
|
|
554
|
+
indent = " " * depth
|
|
555
|
+
if depth > 0:
|
|
556
|
+
indent = " " * (depth - 1) + "├─ "
|
|
557
|
+
|
|
558
|
+
label = f"{indent}{node_id}"
|
|
559
|
+
if len(label) > label_width:
|
|
560
|
+
label = label[: label_width - 3] + "..."
|
|
561
|
+
label = label.ljust(label_width)
|
|
562
|
+
|
|
563
|
+
# Build bar
|
|
564
|
+
bar = " " * bar_start
|
|
565
|
+
error_marker = "❌" if show_errors and node.get("error_count", 0) > 0 else ""
|
|
566
|
+
bar += "█" * bar_length
|
|
567
|
+
bar += error_marker
|
|
568
|
+
|
|
569
|
+
# Duration label
|
|
570
|
+
duration_label = _format_duration(duration_ms)
|
|
571
|
+
|
|
572
|
+
line = f"│ {label} {bar:<{bar_width}} {duration_label:>7}│"
|
|
573
|
+
# Ensure line fits within effective_width
|
|
574
|
+
if len(line) > effective_width:
|
|
575
|
+
line = line[: effective_width - 1] + "│"
|
|
576
|
+
elif len(line) < effective_width:
|
|
577
|
+
line = line[:-1] + " " * (effective_width - len(line)) + "│"
|
|
578
|
+
lines.append(line)
|
|
579
|
+
|
|
580
|
+
# Footer
|
|
581
|
+
lines.append("└" + "─" * (effective_width - 2) + "┘")
|
|
582
|
+
|
|
583
|
+
# Add bottleneck info (constrained to effective_width)
|
|
584
|
+
bottleneck = hierarchy.get("bottleneck")
|
|
585
|
+
if bottleneck:
|
|
586
|
+
lines.append("")
|
|
587
|
+
bn_text = f"Bottleneck: {bottleneck.get('node_id')} ({_format_duration(bottleneck.get('duration_ms', 0))}, {bottleneck.get('percentage', 0):.1f}%)"
|
|
588
|
+
if len(bn_text) > effective_width - 4: # Leave room for emoji
|
|
589
|
+
bn_text = bn_text[: effective_width - 7] + "..."
|
|
590
|
+
lines.append(f"⚠️ {bn_text}")
|
|
591
|
+
|
|
592
|
+
return "\n".join(lines)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _collect_nodes_flat(node: Dict[str, Any], result: List[Dict], depth: int):
|
|
596
|
+
"""Flatten hierarchy to list with depth info"""
|
|
597
|
+
result.append({"node": node, "depth": depth})
|
|
598
|
+
for child in node.get("children", []):
|
|
599
|
+
_collect_nodes_flat(child, result, depth + 1)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def print_waterfall(
|
|
603
|
+
hierarchy: Dict[str, Any],
|
|
604
|
+
width: int = 80,
|
|
605
|
+
show_labels: bool = True,
|
|
606
|
+
show_errors: bool = True,
|
|
607
|
+
):
|
|
608
|
+
"""
|
|
609
|
+
Print waterfall timeline to console.
|
|
610
|
+
|
|
611
|
+
Convenience function that formats and prints in one call.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
hierarchy: Hierarchy dictionary
|
|
615
|
+
width: Width of timeline in characters
|
|
616
|
+
show_labels: Show node labels
|
|
617
|
+
show_errors: Highlight errors
|
|
618
|
+
|
|
619
|
+
Example:
|
|
620
|
+
hierarchy = follow_thread_hierarchy(files=["app.log"], root_identifier="req-123")
|
|
621
|
+
print_waterfall(hierarchy, width=100)
|
|
622
|
+
"""
|
|
623
|
+
waterfall_str = format_waterfall(hierarchy, width, show_labels, show_errors)
|
|
624
|
+
print(waterfall_str)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def format_flamegraph(
|
|
628
|
+
hierarchy: Dict[str, Any],
|
|
629
|
+
width: int = 100,
|
|
630
|
+
use_colors: bool = True,
|
|
631
|
+
min_width: int = 3,
|
|
632
|
+
) -> str:
|
|
633
|
+
"""
|
|
634
|
+
Format hierarchy as a flamegraph-style visualization.
|
|
635
|
+
|
|
636
|
+
Flamegraphs show call stacks where:
|
|
637
|
+
- Width represents time spent in that span
|
|
638
|
+
- Each layer shows a different depth level
|
|
639
|
+
- You can see which operations take the most time
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
hierarchy: Hierarchy dictionary from follow_thread_hierarchy()
|
|
643
|
+
width: Width of the flamegraph in characters
|
|
644
|
+
use_colors: Use ANSI colors
|
|
645
|
+
min_width: Minimum width for a span to be shown
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
Formatted flamegraph string
|
|
649
|
+
|
|
650
|
+
Example:
|
|
651
|
+
hierarchy = follow_thread_hierarchy(files=["app.log"], root_identifier="req-123")
|
|
652
|
+
print(format_flamegraph(hierarchy, width=100))
|
|
653
|
+
|
|
654
|
+
# Output:
|
|
655
|
+
# ┌─────────────────────────────────────────────────────────────────────────────────────┐
|
|
656
|
+
# │ api-gateway (500ms) │
|
|
657
|
+
# ├─────────────────────────────┬─────────────────────────────────────────────────────────┤
|
|
658
|
+
# │ auth-service (50ms) │ product-service (400ms) │
|
|
659
|
+
# │ ├──────────────────┬──────────────────────────────────────┤
|
|
660
|
+
# │ │ db-query (100ms) │ cache-update (250ms) │
|
|
661
|
+
# └─────────────────────────────┴──────────────────┴──────────────────────────────────────┘
|
|
662
|
+
"""
|
|
663
|
+
if not hierarchy or not hierarchy.get("roots"):
|
|
664
|
+
return "No hierarchy data"
|
|
665
|
+
|
|
666
|
+
total_duration = hierarchy.get("total_duration_ms", 0)
|
|
667
|
+
if total_duration <= 0:
|
|
668
|
+
# Calculate from roots
|
|
669
|
+
total_duration = sum(root.get("duration_ms", 0) or 0 for root in hierarchy.get("roots", []))
|
|
670
|
+
if total_duration <= 0:
|
|
671
|
+
total_duration = 1 # Avoid division by zero
|
|
672
|
+
|
|
673
|
+
lines = []
|
|
674
|
+
colors = [
|
|
675
|
+
"\033[44m", # Blue
|
|
676
|
+
"\033[42m", # Green
|
|
677
|
+
"\033[43m", # Yellow
|
|
678
|
+
"\033[45m", # Magenta
|
|
679
|
+
"\033[46m", # Cyan
|
|
680
|
+
"\033[41m", # Red (for errors)
|
|
681
|
+
]
|
|
682
|
+
reset = "\033[0m"
|
|
683
|
+
|
|
684
|
+
def get_color(depth: int, has_error: bool) -> str:
|
|
685
|
+
if not use_colors:
|
|
686
|
+
return ""
|
|
687
|
+
if has_error:
|
|
688
|
+
return colors[5] # Red for errors
|
|
689
|
+
return colors[depth % 5]
|
|
690
|
+
|
|
691
|
+
def format_duration(ms: Optional[float]) -> str:
|
|
692
|
+
if ms is None or ms <= 0:
|
|
693
|
+
return ""
|
|
694
|
+
if ms < 1000:
|
|
695
|
+
return f"{ms:.0f}ms"
|
|
696
|
+
return f"{ms/1000:.2f}s"
|
|
697
|
+
|
|
698
|
+
# Build layers by depth
|
|
699
|
+
max_depth = hierarchy.get("max_depth", 0)
|
|
700
|
+
layers: List[List[Dict[str, Any]]] = [[] for _ in range(max_depth + 1)]
|
|
701
|
+
|
|
702
|
+
def collect_by_depth(node: Dict[str, Any], offset: float = 0):
|
|
703
|
+
depth = node.get("depth", 0)
|
|
704
|
+
duration = node.get("duration_ms", 0) or 0
|
|
705
|
+
node_info = {
|
|
706
|
+
"id": node.get("id", "unknown"),
|
|
707
|
+
"duration": duration,
|
|
708
|
+
"offset": offset,
|
|
709
|
+
"has_error": node.get("error_count", 0) > 0,
|
|
710
|
+
"is_bottleneck": node.get("id") == hierarchy.get("bottleneck", {}).get("node_id"),
|
|
711
|
+
}
|
|
712
|
+
layers[depth].append(node_info)
|
|
713
|
+
|
|
714
|
+
child_offset = offset
|
|
715
|
+
for child in node.get("children", []):
|
|
716
|
+
collect_by_depth(child, child_offset)
|
|
717
|
+
child_offset += child.get("duration_ms", 0) or 0
|
|
718
|
+
|
|
719
|
+
# Collect all nodes
|
|
720
|
+
for root in hierarchy.get("roots", []):
|
|
721
|
+
collect_by_depth(root)
|
|
722
|
+
|
|
723
|
+
# Header
|
|
724
|
+
lines.append("=" * width)
|
|
725
|
+
lines.append("🔥 FLAMEGRAPH VISUALIZATION")
|
|
726
|
+
lines.append("=" * width)
|
|
727
|
+
lines.append(f"Total Duration: {format_duration(total_duration)}")
|
|
728
|
+
lines.append("")
|
|
729
|
+
|
|
730
|
+
# Draw each layer
|
|
731
|
+
for depth, layer in enumerate(layers):
|
|
732
|
+
if not layer:
|
|
733
|
+
continue
|
|
734
|
+
|
|
735
|
+
layer_line = []
|
|
736
|
+
|
|
737
|
+
for node in layer:
|
|
738
|
+
# Calculate width proportional to duration
|
|
739
|
+
proportion = node["duration"] / total_duration if total_duration > 0 else 0
|
|
740
|
+
span_width = max(min_width, int(proportion * (width - 2)))
|
|
741
|
+
|
|
742
|
+
# Truncate label if needed
|
|
743
|
+
label = node["id"]
|
|
744
|
+
duration_str = format_duration(node["duration"])
|
|
745
|
+
full_label = f"{label} ({duration_str})" if duration_str else label
|
|
746
|
+
|
|
747
|
+
if len(full_label) > span_width - 2:
|
|
748
|
+
full_label = full_label[: span_width - 4] + ".."
|
|
749
|
+
|
|
750
|
+
# Create the span block
|
|
751
|
+
color = get_color(depth, node["has_error"])
|
|
752
|
+
end_color = reset if use_colors else ""
|
|
753
|
+
|
|
754
|
+
# Bottleneck indicator
|
|
755
|
+
if node["is_bottleneck"]:
|
|
756
|
+
full_label = f"⚠ {full_label}"
|
|
757
|
+
|
|
758
|
+
# Center the label
|
|
759
|
+
padding = span_width - len(full_label) - 2
|
|
760
|
+
left_pad = padding // 2
|
|
761
|
+
right_pad = padding - left_pad
|
|
762
|
+
|
|
763
|
+
block = f"{color}│{' ' * left_pad}{full_label}{' ' * right_pad}│{end_color}"
|
|
764
|
+
layer_line.append(block)
|
|
765
|
+
|
|
766
|
+
if layer_line:
|
|
767
|
+
# Top border for first layer
|
|
768
|
+
if depth == 0:
|
|
769
|
+
lines.append("┌" + "─" * (width - 2) + "┐")
|
|
770
|
+
|
|
771
|
+
lines.append("".join(layer_line))
|
|
772
|
+
|
|
773
|
+
# Add separator if there's a next layer
|
|
774
|
+
if depth < len(layers) - 1 and layers[depth + 1]:
|
|
775
|
+
sep_line = "├" + "─" * (width - 2) + "┤"
|
|
776
|
+
lines.append(sep_line)
|
|
777
|
+
|
|
778
|
+
# Bottom border
|
|
779
|
+
lines.append("└" + "─" * (width - 2) + "┘")
|
|
780
|
+
|
|
781
|
+
# Legend
|
|
782
|
+
lines.append("")
|
|
783
|
+
lines.append("Legend:")
|
|
784
|
+
lines.append(" ⚠ = Bottleneck Red = Error")
|
|
785
|
+
lines.append(" Width proportional to duration")
|
|
786
|
+
|
|
787
|
+
return "\n".join(lines)
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def print_flamegraph(
|
|
791
|
+
hierarchy: Dict[str, Any],
|
|
792
|
+
width: int = 100,
|
|
793
|
+
use_colors: bool = True,
|
|
794
|
+
):
|
|
795
|
+
"""
|
|
796
|
+
Print flamegraph to console.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
hierarchy: Hierarchy dictionary
|
|
800
|
+
width: Width in characters
|
|
801
|
+
use_colors: Use ANSI colors
|
|
802
|
+
|
|
803
|
+
Example:
|
|
804
|
+
hierarchy = follow_thread_hierarchy(files=["app.log"], root_identifier="req-123")
|
|
805
|
+
print_flamegraph(hierarchy)
|
|
806
|
+
"""
|
|
807
|
+
print(format_flamegraph(hierarchy, width, use_colors))
|