logler 1.1.0__cp311-cp311-macosx_10_12_x86_64.whl → 1.1.2__cp311-cp311-macosx_10_12_x86_64.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 +106 -1
- logler/investigate.py +5 -1
- logler/llm_cli.py +491 -0
- logler/models.py +523 -0
- logler/tree_formatter.py +20 -9
- {logler-1.1.0.dist-info → logler-1.1.2.dist-info}/METADATA +27 -75
- {logler-1.1.0.dist-info → logler-1.1.2.dist-info}/RECORD +11 -10
- logler_rs/logler_rs.cpython-311-darwin.so +0 -0
- {logler-1.1.0.dist-info → logler-1.1.2.dist-info}/WHEEL +0 -0
- {logler-1.1.0.dist-info → logler-1.1.2.dist-info}/entry_points.txt +0 -0
- {logler-1.1.0.dist-info → logler-1.1.2.dist-info}/licenses/LICENSE +0 -0
logler/__init__.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Logler - Beautiful local log viewer with thread tracking and real-time updates.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
__version__ = "1.
|
|
5
|
+
__version__ = "1.1.2"
|
|
6
6
|
__author__ = "Logler Contributors"
|
|
7
7
|
|
|
8
8
|
from .parser import LogParser, LogEntry
|
|
@@ -10,7 +10,62 @@ from .tracker import ThreadTracker
|
|
|
10
10
|
from .log_reader import LogReader
|
|
11
11
|
from .tree_formatter import format_tree, format_waterfall, print_tree, print_waterfall
|
|
12
12
|
|
|
13
|
+
# Pydantic models for type-safe log analysis
|
|
14
|
+
from .models import (
|
|
15
|
+
# Core entry models
|
|
16
|
+
LogEntry as TypedLogEntry,
|
|
17
|
+
LogLevel,
|
|
18
|
+
LogFormat,
|
|
19
|
+
# Search models
|
|
20
|
+
SearchResult,
|
|
21
|
+
SearchResults,
|
|
22
|
+
SearchSummary,
|
|
23
|
+
SearchCount,
|
|
24
|
+
# Timeline models
|
|
25
|
+
ThreadTimeline,
|
|
26
|
+
# Hierarchy models
|
|
27
|
+
SpanNode,
|
|
28
|
+
ThreadHierarchy,
|
|
29
|
+
BottleneckInfo,
|
|
30
|
+
NodeType,
|
|
31
|
+
DetectionMethod,
|
|
32
|
+
# Pattern models
|
|
33
|
+
PatternMatch,
|
|
34
|
+
PatternResults,
|
|
35
|
+
# Sampling
|
|
36
|
+
SamplingResult,
|
|
37
|
+
# Error analysis
|
|
38
|
+
ErrorAnalysis,
|
|
39
|
+
RootCause,
|
|
40
|
+
PropagationChain,
|
|
41
|
+
ImpactSummary,
|
|
42
|
+
# File/context
|
|
43
|
+
FileMetadata,
|
|
44
|
+
ContextResult,
|
|
45
|
+
# Cross-service
|
|
46
|
+
TimelineEntry,
|
|
47
|
+
CrossServiceTimeline,
|
|
48
|
+
# Correlation
|
|
49
|
+
CorrelationLink,
|
|
50
|
+
CorrelationChains,
|
|
51
|
+
# Export
|
|
52
|
+
TraceSpan,
|
|
53
|
+
TraceExport,
|
|
54
|
+
# Insights
|
|
55
|
+
Insight,
|
|
56
|
+
InsightsResult,
|
|
57
|
+
# Schema
|
|
58
|
+
SchemaField,
|
|
59
|
+
LogSchema,
|
|
60
|
+
# Helper functions
|
|
61
|
+
parse_log_entry,
|
|
62
|
+
parse_search_results,
|
|
63
|
+
parse_thread_hierarchy,
|
|
64
|
+
parse_error_analysis,
|
|
65
|
+
)
|
|
66
|
+
|
|
13
67
|
__all__ = [
|
|
68
|
+
# Original exports
|
|
14
69
|
"LogParser",
|
|
15
70
|
"LogEntry",
|
|
16
71
|
"ThreadTracker",
|
|
@@ -19,4 +74,54 @@ __all__ = [
|
|
|
19
74
|
"format_waterfall",
|
|
20
75
|
"print_tree",
|
|
21
76
|
"print_waterfall",
|
|
77
|
+
# Pydantic models - Core
|
|
78
|
+
"TypedLogEntry",
|
|
79
|
+
"LogLevel",
|
|
80
|
+
"LogFormat",
|
|
81
|
+
# Pydantic models - Search
|
|
82
|
+
"SearchResult",
|
|
83
|
+
"SearchResults",
|
|
84
|
+
"SearchSummary",
|
|
85
|
+
"SearchCount",
|
|
86
|
+
# Pydantic models - Timeline
|
|
87
|
+
"ThreadTimeline",
|
|
88
|
+
# Pydantic models - Hierarchy
|
|
89
|
+
"SpanNode",
|
|
90
|
+
"ThreadHierarchy",
|
|
91
|
+
"BottleneckInfo",
|
|
92
|
+
"NodeType",
|
|
93
|
+
"DetectionMethod",
|
|
94
|
+
# Pydantic models - Patterns
|
|
95
|
+
"PatternMatch",
|
|
96
|
+
"PatternResults",
|
|
97
|
+
# Pydantic models - Sampling
|
|
98
|
+
"SamplingResult",
|
|
99
|
+
# Pydantic models - Error analysis
|
|
100
|
+
"ErrorAnalysis",
|
|
101
|
+
"RootCause",
|
|
102
|
+
"PropagationChain",
|
|
103
|
+
"ImpactSummary",
|
|
104
|
+
# Pydantic models - File/context
|
|
105
|
+
"FileMetadata",
|
|
106
|
+
"ContextResult",
|
|
107
|
+
# Pydantic models - Cross-service
|
|
108
|
+
"TimelineEntry",
|
|
109
|
+
"CrossServiceTimeline",
|
|
110
|
+
# Pydantic models - Correlation
|
|
111
|
+
"CorrelationLink",
|
|
112
|
+
"CorrelationChains",
|
|
113
|
+
# Pydantic models - Export
|
|
114
|
+
"TraceSpan",
|
|
115
|
+
"TraceExport",
|
|
116
|
+
# Pydantic models - Insights
|
|
117
|
+
"Insight",
|
|
118
|
+
"InsightsResult",
|
|
119
|
+
# Pydantic models - Schema
|
|
120
|
+
"SchemaField",
|
|
121
|
+
"LogSchema",
|
|
122
|
+
# Helper functions
|
|
123
|
+
"parse_log_entry",
|
|
124
|
+
"parse_search_results",
|
|
125
|
+
"parse_thread_hierarchy",
|
|
126
|
+
"parse_error_analysis",
|
|
22
127
|
]
|
logler/investigate.py
CHANGED
|
@@ -2197,7 +2197,11 @@ def cross_service_timeline(
|
|
|
2197
2197
|
|
|
2198
2198
|
for service_name, service_files in files.items():
|
|
2199
2199
|
if correlation_id:
|
|
2200
|
-
|
|
2200
|
+
# WORKAROUND: Only pass correlation_id OR trace_id, not both, to avoid
|
|
2201
|
+
# Rust-side deduplication bug that causes duplicate entries when multiple
|
|
2202
|
+
# IDs match the same log entry. Prefer correlation_id when both are provided.
|
|
2203
|
+
# TODO: Remove this workaround when Rust deduplication is fixed (Phase 2)
|
|
2204
|
+
result = follow_thread(service_files, correlation_id=correlation_id)
|
|
2201
2205
|
entries = result.get("entries", [])
|
|
2202
2206
|
elif trace_id:
|
|
2203
2207
|
result = follow_thread(service_files, trace_id=trace_id)
|
logler/llm_cli.py
CHANGED
|
@@ -1424,3 +1424,494 @@ def session_conclude(
|
|
|
1424
1424
|
|
|
1425
1425
|
except Exception as e:
|
|
1426
1426
|
_error_json(f"Internal error: {str(e)}", EXIT_INTERNAL_ERROR)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
# =============================================================================
|
|
1430
|
+
# SQL Query Command - High value for LLM log analysis
|
|
1431
|
+
# =============================================================================
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
@llm.command()
|
|
1435
|
+
@click.argument("query", required=False)
|
|
1436
|
+
@click.option("--files", "-f", multiple=True, help="Files to load (supports globs)")
|
|
1437
|
+
@click.option("--stdin", is_flag=True, help="Read SQL query from stdin")
|
|
1438
|
+
@click.option("--pretty", is_flag=True, help="Pretty-print JSON output")
|
|
1439
|
+
def sql(query: Optional[str], files: tuple, stdin: bool, pretty: bool):
|
|
1440
|
+
"""
|
|
1441
|
+
Execute SQL queries on log files using DuckDB.
|
|
1442
|
+
|
|
1443
|
+
Loads log files into a 'logs' table with columns:
|
|
1444
|
+
- line_number, timestamp, level, message, thread_id,
|
|
1445
|
+
- correlation_id, trace_id, span_id, file, raw
|
|
1446
|
+
|
|
1447
|
+
Supports all DuckDB SQL including:
|
|
1448
|
+
- Aggregations (COUNT, GROUP BY, HAVING)
|
|
1449
|
+
- Window functions
|
|
1450
|
+
- CTEs (WITH clauses)
|
|
1451
|
+
- JOINs (if loading multiple file groups)
|
|
1452
|
+
|
|
1453
|
+
Examples:
|
|
1454
|
+
# Count errors by level
|
|
1455
|
+
logler llm sql "SELECT level, COUNT(*) FROM logs GROUP BY level" -f "*.log"
|
|
1456
|
+
|
|
1457
|
+
# Find top error messages
|
|
1458
|
+
logler llm sql "SELECT message, COUNT(*) as cnt FROM logs WHERE level='ERROR'
|
|
1459
|
+
GROUP BY message ORDER BY cnt DESC LIMIT 10" -f app.log
|
|
1460
|
+
|
|
1461
|
+
# Query from stdin
|
|
1462
|
+
echo "SELECT * FROM logs LIMIT 5" | logler llm sql --stdin -f "*.log"
|
|
1463
|
+
|
|
1464
|
+
# Complex analysis
|
|
1465
|
+
logler llm sql "
|
|
1466
|
+
WITH error_threads AS (
|
|
1467
|
+
SELECT DISTINCT thread_id FROM logs WHERE level = 'ERROR'
|
|
1468
|
+
)
|
|
1469
|
+
SELECT l.* FROM logs l
|
|
1470
|
+
JOIN error_threads e ON l.thread_id = e.thread_id
|
|
1471
|
+
ORDER BY l.timestamp
|
|
1472
|
+
" -f "*.log"
|
|
1473
|
+
"""
|
|
1474
|
+
import duckdb
|
|
1475
|
+
|
|
1476
|
+
try:
|
|
1477
|
+
# Get query from argument or stdin
|
|
1478
|
+
if stdin:
|
|
1479
|
+
import sys as _sys
|
|
1480
|
+
|
|
1481
|
+
query = _sys.stdin.read().strip()
|
|
1482
|
+
elif not query:
|
|
1483
|
+
_error_json("SQL query required. Provide as argument or use --stdin.")
|
|
1484
|
+
|
|
1485
|
+
file_list = _expand_globs(list(files)) if files else _expand_globs(["*.log"])
|
|
1486
|
+
if not file_list:
|
|
1487
|
+
_error_json(f"No files found matching: {files or ['*.log']}")
|
|
1488
|
+
|
|
1489
|
+
# Parse log files
|
|
1490
|
+
from .parser import LogParser
|
|
1491
|
+
|
|
1492
|
+
parser = LogParser()
|
|
1493
|
+
entries = []
|
|
1494
|
+
|
|
1495
|
+
for file_path in file_list:
|
|
1496
|
+
try:
|
|
1497
|
+
with open(file_path, "r", errors="replace") as f:
|
|
1498
|
+
for i, line in enumerate(f):
|
|
1499
|
+
line = line.rstrip()
|
|
1500
|
+
if not line:
|
|
1501
|
+
continue
|
|
1502
|
+
|
|
1503
|
+
entry = parser.parse_line(i + 1, line)
|
|
1504
|
+
entries.append(
|
|
1505
|
+
{
|
|
1506
|
+
"line_number": i + 1,
|
|
1507
|
+
"timestamp": str(entry.timestamp) if entry.timestamp else None,
|
|
1508
|
+
"level": str(entry.level).upper() if entry.level else None,
|
|
1509
|
+
"message": entry.message,
|
|
1510
|
+
"thread_id": entry.thread_id,
|
|
1511
|
+
"correlation_id": entry.correlation_id,
|
|
1512
|
+
"trace_id": getattr(entry, "trace_id", None),
|
|
1513
|
+
"span_id": getattr(entry, "span_id", None),
|
|
1514
|
+
"file": file_path,
|
|
1515
|
+
"raw": line,
|
|
1516
|
+
}
|
|
1517
|
+
)
|
|
1518
|
+
except (FileNotFoundError, PermissionError) as e:
|
|
1519
|
+
_error_json(f"Cannot read file {file_path}: {e}")
|
|
1520
|
+
|
|
1521
|
+
if not entries:
|
|
1522
|
+
_output_json(
|
|
1523
|
+
{
|
|
1524
|
+
"query": query,
|
|
1525
|
+
"files": file_list,
|
|
1526
|
+
"total_entries": 0,
|
|
1527
|
+
"results": [],
|
|
1528
|
+
"error": "No log entries found",
|
|
1529
|
+
},
|
|
1530
|
+
pretty,
|
|
1531
|
+
)
|
|
1532
|
+
sys.exit(EXIT_NO_RESULTS)
|
|
1533
|
+
|
|
1534
|
+
# Create DuckDB connection and load data
|
|
1535
|
+
conn = duckdb.connect(":memory:")
|
|
1536
|
+
|
|
1537
|
+
# Create table from entries
|
|
1538
|
+
conn.execute(
|
|
1539
|
+
"""
|
|
1540
|
+
CREATE TABLE logs (
|
|
1541
|
+
line_number INTEGER,
|
|
1542
|
+
timestamp VARCHAR,
|
|
1543
|
+
level VARCHAR,
|
|
1544
|
+
message VARCHAR,
|
|
1545
|
+
thread_id VARCHAR,
|
|
1546
|
+
correlation_id VARCHAR,
|
|
1547
|
+
trace_id VARCHAR,
|
|
1548
|
+
span_id VARCHAR,
|
|
1549
|
+
file VARCHAR,
|
|
1550
|
+
raw VARCHAR
|
|
1551
|
+
)
|
|
1552
|
+
"""
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
# Insert entries
|
|
1556
|
+
conn.executemany(
|
|
1557
|
+
"""
|
|
1558
|
+
INSERT INTO logs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1559
|
+
""",
|
|
1560
|
+
[
|
|
1561
|
+
(
|
|
1562
|
+
e["line_number"],
|
|
1563
|
+
e["timestamp"],
|
|
1564
|
+
e["level"],
|
|
1565
|
+
e["message"],
|
|
1566
|
+
e["thread_id"],
|
|
1567
|
+
e["correlation_id"],
|
|
1568
|
+
e["trace_id"],
|
|
1569
|
+
e["span_id"],
|
|
1570
|
+
e["file"],
|
|
1571
|
+
e["raw"],
|
|
1572
|
+
)
|
|
1573
|
+
for e in entries
|
|
1574
|
+
],
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
# Execute the user's query
|
|
1578
|
+
try:
|
|
1579
|
+
result = conn.execute(query).fetchall()
|
|
1580
|
+
columns = [desc[0] for desc in conn.description]
|
|
1581
|
+
except duckdb.Error as e:
|
|
1582
|
+
_error_json(f"SQL error: {e}", EXIT_USER_ERROR)
|
|
1583
|
+
|
|
1584
|
+
# Convert results to list of dicts
|
|
1585
|
+
rows = [dict(zip(columns, row)) for row in result]
|
|
1586
|
+
|
|
1587
|
+
output = {
|
|
1588
|
+
"query": query,
|
|
1589
|
+
"files": file_list,
|
|
1590
|
+
"total_entries": len(entries),
|
|
1591
|
+
"columns": columns,
|
|
1592
|
+
"row_count": len(rows),
|
|
1593
|
+
"results": rows,
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
_output_json(output, pretty)
|
|
1597
|
+
sys.exit(EXIT_SUCCESS if rows else EXIT_NO_RESULTS)
|
|
1598
|
+
|
|
1599
|
+
except Exception as e:
|
|
1600
|
+
_error_json(f"Internal error: {str(e)}", EXIT_INTERNAL_ERROR)
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
# =============================================================================
|
|
1604
|
+
# Bottleneck Analysis Command
|
|
1605
|
+
# =============================================================================
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
@llm.command()
|
|
1609
|
+
@click.argument("identifier")
|
|
1610
|
+
@click.option("--files", "-f", multiple=True, help="Files to search (supports globs)")
|
|
1611
|
+
@click.option("--threshold-ms", type=int, default=100, help="Minimum duration to consider (ms)")
|
|
1612
|
+
@click.option("--top-n", type=int, default=10, help="Number of top bottlenecks to return")
|
|
1613
|
+
@click.option("--pretty", is_flag=True, help="Pretty-print JSON output")
|
|
1614
|
+
def bottleneck(identifier: str, files: tuple, threshold_ms: int, top_n: int, pretty: bool):
|
|
1615
|
+
"""
|
|
1616
|
+
Analyze performance bottlenecks for a trace/correlation ID.
|
|
1617
|
+
|
|
1618
|
+
Identifies the slowest operations and shows where time is spent.
|
|
1619
|
+
|
|
1620
|
+
Example:
|
|
1621
|
+
logler llm bottleneck trace-abc123 --files "*.log" --top-n 5
|
|
1622
|
+
"""
|
|
1623
|
+
from . import investigate
|
|
1624
|
+
|
|
1625
|
+
try:
|
|
1626
|
+
file_list = _expand_globs(list(files)) if files else _expand_globs(["*.log"])
|
|
1627
|
+
if not file_list:
|
|
1628
|
+
_error_json(f"No files found matching: {files or ['*.log']}")
|
|
1629
|
+
|
|
1630
|
+
# Get hierarchy to analyze
|
|
1631
|
+
hierarchy = investigate.follow_thread_hierarchy(
|
|
1632
|
+
files=file_list,
|
|
1633
|
+
root_identifier=identifier,
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
if not hierarchy.get("roots"):
|
|
1637
|
+
_output_json(
|
|
1638
|
+
{
|
|
1639
|
+
"identifier": identifier,
|
|
1640
|
+
"error": "No hierarchy found for identifier",
|
|
1641
|
+
},
|
|
1642
|
+
pretty,
|
|
1643
|
+
)
|
|
1644
|
+
sys.exit(EXIT_NO_RESULTS)
|
|
1645
|
+
|
|
1646
|
+
# Collect all nodes with durations
|
|
1647
|
+
nodes_with_duration = []
|
|
1648
|
+
|
|
1649
|
+
def collect_nodes(node: Dict[str, Any], path: List[str]):
|
|
1650
|
+
node_id = node.get("id", "unknown")
|
|
1651
|
+
duration = node.get("duration_ms", 0) or 0
|
|
1652
|
+
current_path = path + [node_id]
|
|
1653
|
+
|
|
1654
|
+
if duration >= threshold_ms:
|
|
1655
|
+
nodes_with_duration.append(
|
|
1656
|
+
{
|
|
1657
|
+
"node_id": node_id,
|
|
1658
|
+
"name": node.get("name") or node.get("operation_name"),
|
|
1659
|
+
"duration_ms": duration,
|
|
1660
|
+
"depth": node.get("depth", 0),
|
|
1661
|
+
"entry_count": node.get("entry_count", 0),
|
|
1662
|
+
"error_count": node.get("error_count", 0),
|
|
1663
|
+
"path": current_path,
|
|
1664
|
+
"children_count": len(node.get("children", [])),
|
|
1665
|
+
}
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
for child in node.get("children", []):
|
|
1669
|
+
collect_nodes(child, current_path)
|
|
1670
|
+
|
|
1671
|
+
for root in hierarchy.get("roots", []):
|
|
1672
|
+
collect_nodes(root, [])
|
|
1673
|
+
|
|
1674
|
+
# Sort by duration descending
|
|
1675
|
+
nodes_with_duration.sort(key=lambda x: -x["duration_ms"])
|
|
1676
|
+
top_bottlenecks = nodes_with_duration[:top_n]
|
|
1677
|
+
|
|
1678
|
+
# Calculate percentages
|
|
1679
|
+
total_duration = hierarchy.get("total_duration_ms", 0) or 1
|
|
1680
|
+
for node in top_bottlenecks:
|
|
1681
|
+
node["percentage"] = round(node["duration_ms"] / total_duration * 100, 1)
|
|
1682
|
+
|
|
1683
|
+
output = {
|
|
1684
|
+
"identifier": identifier,
|
|
1685
|
+
"total_duration_ms": hierarchy.get("total_duration_ms"),
|
|
1686
|
+
"total_nodes": hierarchy.get("total_nodes", 0),
|
|
1687
|
+
"analysis": {
|
|
1688
|
+
"threshold_ms": threshold_ms,
|
|
1689
|
+
"nodes_above_threshold": len(nodes_with_duration),
|
|
1690
|
+
},
|
|
1691
|
+
"bottlenecks": top_bottlenecks,
|
|
1692
|
+
"hierarchy_bottleneck": hierarchy.get("bottleneck"),
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
_output_json(output, pretty)
|
|
1696
|
+
sys.exit(EXIT_SUCCESS)
|
|
1697
|
+
|
|
1698
|
+
except Exception as e:
|
|
1699
|
+
_error_json(f"Internal error: {str(e)}", EXIT_INTERNAL_ERROR)
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
# =============================================================================
|
|
1703
|
+
# Context Command
|
|
1704
|
+
# =============================================================================
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
@llm.command()
|
|
1708
|
+
@click.argument("file")
|
|
1709
|
+
@click.argument("line", type=int)
|
|
1710
|
+
@click.option("--before", "-B", type=int, default=10, help="Lines before")
|
|
1711
|
+
@click.option("--after", "-A", type=int, default=10, help="Lines after")
|
|
1712
|
+
@click.option("--pretty", is_flag=True, help="Pretty-print JSON output")
|
|
1713
|
+
def context(file: str, line: int, before: int, after: int, pretty: bool):
|
|
1714
|
+
"""
|
|
1715
|
+
Get context lines around a specific log entry.
|
|
1716
|
+
|
|
1717
|
+
Returns parsed entries with context, useful for understanding
|
|
1718
|
+
what happened before and after a specific log line.
|
|
1719
|
+
|
|
1720
|
+
Example:
|
|
1721
|
+
logler llm context app.log 1523 --before 20 --after 10
|
|
1722
|
+
"""
|
|
1723
|
+
from . import investigate
|
|
1724
|
+
|
|
1725
|
+
try:
|
|
1726
|
+
if not Path(file).exists():
|
|
1727
|
+
_error_json(f"File not found: {file}")
|
|
1728
|
+
|
|
1729
|
+
result = investigate.get_context(
|
|
1730
|
+
file=file,
|
|
1731
|
+
line_number=line,
|
|
1732
|
+
lines_before=before,
|
|
1733
|
+
lines_after=after,
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
# Transform to cleaner output
|
|
1737
|
+
output = {
|
|
1738
|
+
"file": file,
|
|
1739
|
+
"line_number": line,
|
|
1740
|
+
"context_lines": {"before": before, "after": after},
|
|
1741
|
+
"target": result.get("target"),
|
|
1742
|
+
"context_before": result.get("context_before", []),
|
|
1743
|
+
"context_after": result.get("context_after", []),
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
_output_json(output, pretty)
|
|
1747
|
+
sys.exit(EXIT_SUCCESS)
|
|
1748
|
+
|
|
1749
|
+
except Exception as e:
|
|
1750
|
+
_error_json(f"Internal error: {str(e)}", EXIT_INTERNAL_ERROR)
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
# =============================================================================
|
|
1754
|
+
# Trace Export Command
|
|
1755
|
+
# =============================================================================
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
@llm.command("export")
|
|
1759
|
+
@click.argument("identifier")
|
|
1760
|
+
@click.option("--files", "-f", multiple=True, help="Files to search (supports globs)")
|
|
1761
|
+
@click.option(
|
|
1762
|
+
"--format",
|
|
1763
|
+
"export_format",
|
|
1764
|
+
type=click.Choice(["jaeger", "zipkin", "otlp"]),
|
|
1765
|
+
default="jaeger",
|
|
1766
|
+
help="Export format",
|
|
1767
|
+
)
|
|
1768
|
+
@click.option("--pretty", is_flag=True, help="Pretty-print JSON output")
|
|
1769
|
+
def export_trace(identifier: str, files: tuple, export_format: str, pretty: bool):
|
|
1770
|
+
"""
|
|
1771
|
+
Export traces to Jaeger/Zipkin/OTLP format.
|
|
1772
|
+
|
|
1773
|
+
Converts log-based traces to standard distributed tracing formats
|
|
1774
|
+
that can be imported into Jaeger, Zipkin, or other tracing systems.
|
|
1775
|
+
|
|
1776
|
+
Example:
|
|
1777
|
+
logler llm export trace-abc123 --files "*.log" --format jaeger
|
|
1778
|
+
"""
|
|
1779
|
+
from . import investigate
|
|
1780
|
+
|
|
1781
|
+
try:
|
|
1782
|
+
file_list = _expand_globs(list(files)) if files else _expand_globs(["*.log"])
|
|
1783
|
+
if not file_list:
|
|
1784
|
+
_error_json(f"No files found matching: {files or ['*.log']}")
|
|
1785
|
+
|
|
1786
|
+
# Get hierarchy for the trace
|
|
1787
|
+
hierarchy = investigate.follow_thread_hierarchy(
|
|
1788
|
+
files=file_list,
|
|
1789
|
+
root_identifier=identifier,
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
if not hierarchy.get("roots"):
|
|
1793
|
+
_output_json(
|
|
1794
|
+
{
|
|
1795
|
+
"identifier": identifier,
|
|
1796
|
+
"format": export_format,
|
|
1797
|
+
"error": "No trace data found for identifier",
|
|
1798
|
+
},
|
|
1799
|
+
pretty,
|
|
1800
|
+
)
|
|
1801
|
+
sys.exit(EXIT_NO_RESULTS)
|
|
1802
|
+
|
|
1803
|
+
# Convert hierarchy to spans
|
|
1804
|
+
spans = []
|
|
1805
|
+
|
|
1806
|
+
def node_to_span(node: Dict[str, Any], parent_span_id: Optional[str] = None):
|
|
1807
|
+
node_id = node.get("id", "unknown")
|
|
1808
|
+
|
|
1809
|
+
# Generate span ID if not present
|
|
1810
|
+
span_id = node.get("span_id") or f"span-{hash(node_id) & 0xFFFFFFFF:08x}"
|
|
1811
|
+
|
|
1812
|
+
span = {
|
|
1813
|
+
"traceId": identifier.replace("trace-", "").replace("-", "")[:32].ljust(32, "0"),
|
|
1814
|
+
"spanId": span_id.replace("-", "")[:16].ljust(16, "0"),
|
|
1815
|
+
"operationName": node.get("name") or node.get("operation_name") or node_id,
|
|
1816
|
+
"serviceName": node.get("service_name", "unknown"),
|
|
1817
|
+
"startTime": node.get("start_time"),
|
|
1818
|
+
"duration": (node.get("duration_ms", 0) or 0) * 1000, # Convert to microseconds
|
|
1819
|
+
"tags": [],
|
|
1820
|
+
"logs": [],
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
if parent_span_id:
|
|
1824
|
+
span["parentSpanId"] = parent_span_id.replace("-", "")[:16].ljust(16, "0")
|
|
1825
|
+
|
|
1826
|
+
# Add tags
|
|
1827
|
+
if node.get("error_count", 0) > 0:
|
|
1828
|
+
span["tags"].append({"key": "error", "value": True})
|
|
1829
|
+
|
|
1830
|
+
if node.get("entry_count"):
|
|
1831
|
+
span["tags"].append({"key": "log.entry_count", "value": node["entry_count"]})
|
|
1832
|
+
|
|
1833
|
+
spans.append(span)
|
|
1834
|
+
|
|
1835
|
+
for child in node.get("children", []):
|
|
1836
|
+
node_to_span(child, span_id)
|
|
1837
|
+
|
|
1838
|
+
for root in hierarchy.get("roots", []):
|
|
1839
|
+
node_to_span(root)
|
|
1840
|
+
|
|
1841
|
+
# Format output based on target format
|
|
1842
|
+
if export_format == "jaeger":
|
|
1843
|
+
trace_output = {
|
|
1844
|
+
"data": [
|
|
1845
|
+
{
|
|
1846
|
+
"traceID": identifier.replace("trace-", "")
|
|
1847
|
+
.replace("-", "")[:32]
|
|
1848
|
+
.ljust(32, "0"),
|
|
1849
|
+
"spans": spans,
|
|
1850
|
+
"processes": {
|
|
1851
|
+
"p1": {
|
|
1852
|
+
"serviceName": "logler-export",
|
|
1853
|
+
"tags": [],
|
|
1854
|
+
}
|
|
1855
|
+
},
|
|
1856
|
+
}
|
|
1857
|
+
],
|
|
1858
|
+
"total": 1,
|
|
1859
|
+
"limit": 0,
|
|
1860
|
+
"offset": 0,
|
|
1861
|
+
"errors": None,
|
|
1862
|
+
}
|
|
1863
|
+
elif export_format == "zipkin":
|
|
1864
|
+
trace_output = [
|
|
1865
|
+
{
|
|
1866
|
+
"traceId": span["traceId"],
|
|
1867
|
+
"id": span["spanId"],
|
|
1868
|
+
"name": span["operationName"],
|
|
1869
|
+
"timestamp": span.get("startTime"),
|
|
1870
|
+
"duration": span["duration"],
|
|
1871
|
+
"localEndpoint": {"serviceName": span.get("serviceName", "unknown")},
|
|
1872
|
+
"parentId": span.get("parentSpanId"),
|
|
1873
|
+
"tags": {t["key"]: str(t["value"]) for t in span.get("tags", [])},
|
|
1874
|
+
}
|
|
1875
|
+
for span in spans
|
|
1876
|
+
]
|
|
1877
|
+
else: # otlp
|
|
1878
|
+
trace_output = {
|
|
1879
|
+
"resourceSpans": [
|
|
1880
|
+
{
|
|
1881
|
+
"resource": {
|
|
1882
|
+
"attributes": [
|
|
1883
|
+
{"key": "service.name", "value": {"stringValue": "logler-export"}}
|
|
1884
|
+
]
|
|
1885
|
+
},
|
|
1886
|
+
"scopeSpans": [
|
|
1887
|
+
{
|
|
1888
|
+
"scope": {"name": "logler"},
|
|
1889
|
+
"spans": [
|
|
1890
|
+
{
|
|
1891
|
+
"traceId": span["traceId"],
|
|
1892
|
+
"spanId": span["spanId"],
|
|
1893
|
+
"name": span["operationName"],
|
|
1894
|
+
"startTimeUnixNano": span.get("startTime"),
|
|
1895
|
+
"endTimeUnixNano": None,
|
|
1896
|
+
"parentSpanId": span.get("parentSpanId"),
|
|
1897
|
+
}
|
|
1898
|
+
for span in spans
|
|
1899
|
+
],
|
|
1900
|
+
}
|
|
1901
|
+
],
|
|
1902
|
+
}
|
|
1903
|
+
]
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
output = {
|
|
1907
|
+
"identifier": identifier,
|
|
1908
|
+
"format": export_format,
|
|
1909
|
+
"span_count": len(spans),
|
|
1910
|
+
"export": trace_output,
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
_output_json(output, pretty)
|
|
1914
|
+
sys.exit(EXIT_SUCCESS)
|
|
1915
|
+
|
|
1916
|
+
except Exception as e:
|
|
1917
|
+
_error_json(f"Internal error: {str(e)}", EXIT_INTERNAL_ERROR)
|