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.
@@ -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))