logler 1.1.0__cp311-cp311-win_amd64.whl → 1.1.2__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 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.0.7"
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
- result = follow_thread(service_files, correlation_id=correlation_id, trace_id=trace_id)
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)