logreducer 3.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
logreducer/sources.py ADDED
@@ -0,0 +1,78 @@
1
+ """Input sources for logreducer.
2
+
3
+ logreducer is a pure reduction engine over an abstraction: its input is any
4
+ RE-ITERABLE iterable of ``str`` lines - a "source". ``__iter__`` must return a
5
+ FRESH iterator each call, because some reduction modes make more than one pass
6
+ over the input (hybrid reads it twice). A ``list[str]``, or a class whose
7
+ ``__iter__`` re-opens the underlying stream, satisfies this; a bare generator
8
+ does not.
9
+
10
+ The package does not own connections or the loading path. An application can
11
+ hand the reducer its own iterable - a database cursor, a Kafka consumer, an
12
+ open file - and the reducer never manages that IO. The adapters here are thin
13
+ conveniences for the CLI and quick use: ``FileSource`` is built in; SQL /
14
+ ClickHouse / Kafka sources live behind optional extras (see the sql, clickhouse
15
+ and kafka submodules).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from collections.abc import Iterable, Iterator, Sequence
22
+ from typing import Any, Protocol, runtime_checkable
23
+
24
+ from .memory import MemoryMonitor, StreamingProcessor
25
+
26
+
27
+ def rows_to_lines(rows: Iterable[Sequence[Any]]) -> Iterator[str]:
28
+ """Yield the first column of each row as a stripped, non-empty line.
29
+
30
+ The shared row -> line convention for the DB adapters (SQL, ClickHouse):
31
+ NULLs and blank lines are skipped, matching FileSource semantics, so the
32
+ same data reduces identically whichever source it arrives through.
33
+ """
34
+ for row in rows:
35
+ value = row[0]
36
+ if value is None:
37
+ continue
38
+ line = str(value).strip()
39
+ if line:
40
+ yield line
41
+
42
+
43
+ @runtime_checkable
44
+ class Source(Protocol):
45
+ """A re-iterable stream of log lines.
46
+
47
+ Any object whose ``__iter__`` yields ``str`` and can be iterated more than
48
+ once is a valid source - a plain ``list[str]`` included.
49
+ """
50
+
51
+ def __iter__(self) -> Iterator[str]: ...
52
+
53
+
54
+ class FileSource:
55
+ """Stream stripped, non-empty lines from a text file.
56
+
57
+ Re-iterable and memory-aware: each iteration re-opens the file and streams
58
+ it through the constant-memory strategy (full / chunked / reservoir-sampled
59
+ by size), so the reducer can make multiple passes without loading the file.
60
+ """
61
+
62
+ def __init__(self, path: str | os.PathLike[str], *, max_memory_gb: float = 1.0) -> None:
63
+ self.path = os.fspath(path)
64
+ self._processor = StreamingProcessor(MemoryMonitor(max_memory_gb))
65
+
66
+ def __iter__(self) -> Iterator[str]:
67
+ return self._processor.read_file_streaming(self.path)
68
+
69
+ @property
70
+ def size_bytes(self) -> int | None:
71
+ """File size in bytes, or None if it cannot be determined."""
72
+ try:
73
+ return os.path.getsize(self.path)
74
+ except OSError:
75
+ return None
76
+
77
+ def __repr__(self) -> str:
78
+ return f"FileSource({self.path!r})"
logreducer/sql.py ADDED
@@ -0,0 +1,209 @@
1
+ """SQL input source for logreducer (optional ``sql`` extra).
2
+
3
+ Streams log lines out of any SQL database SQLAlchemy can reach - PostgreSQL,
4
+ MySQL, SQLite, and the rest - using a server-side cursor (the ``yield_per``
5
+ execution option, which turns on ``stream_results``). Rows are pulled in
6
+ batches, so memory stays flat regardless of how many rows the query returns;
7
+ the reducer never holds the whole result set.
8
+
9
+ This is a thin convenience over the abstraction, not a data-access layer. An
10
+ application that already has a SQLAlchemy ``Engine`` passes it straight in and
11
+ keeps ownership of it; the package only borrows a connection per pass. A URL
12
+ string is also accepted for CLI use, in which case the engine is created and
13
+ owned here (call ``close()`` or use the source as a context manager to dispose
14
+ it).
15
+
16
+ Row -> line convention: the query selects the log line as its **first column**.
17
+ Anything else (levels, timestamps) should be folded into that column in SQL,
18
+ e.g. ``SELECT ts || ' ' || msg AS line FROM events``.
19
+
20
+ SQLAlchemy is the deliberate choice here: it is the only mature *cross-database*
21
+ Python layer that streams via server-side cursors. Single-database drivers
22
+ (asyncpg, psycopg) or columnar loaders (ConnectorX, Polars) either lock to one
23
+ backend or materialise the entire result - the memory hazard this avoids.
24
+
25
+ Install: ``pip install 'logreducer[sql]'`` plus your database's DBAPI driver
26
+ (``psycopg`` for PostgreSQL, ``PyMySQL`` for MySQL; SQLite needs none).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from collections.abc import Iterator
32
+ from typing import TYPE_CHECKING, Any
33
+
34
+ if TYPE_CHECKING:
35
+ from sqlalchemy.engine import Engine
36
+
37
+ _INSTALL_HINT = (
38
+ "logreducer's SQL source needs SQLAlchemy. Install the extra:\n"
39
+ " pip install 'logreducer[sql]'\n"
40
+ "plus your database's DBAPI driver (psycopg for PostgreSQL, PyMySQL for "
41
+ "MySQL; SQLite needs none)."
42
+ )
43
+
44
+
45
+ class SQLSource:
46
+ """A re-iterable stream of log lines from a SQL query.
47
+
48
+ Each iteration opens a fresh connection and re-runs the query with a
49
+ server-side cursor, so the reducer's multi-pass modes work (every pass is
50
+ an idempotent re-read). The first column of each row is the log line;
51
+ NULLs and blank lines are skipped, matching FileSource.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ connectable: Engine | str,
57
+ query: str,
58
+ *,
59
+ params: dict[str, Any] | None = None,
60
+ yield_per: int = 1000,
61
+ sample: float | None = None,
62
+ sample_seed: int | None = None,
63
+ ) -> None:
64
+ """Build a SQL source.
65
+
66
+ Args:
67
+ connectable: A SQLAlchemy ``Engine`` (borrowed, not disposed) or a
68
+ database URL string (an engine is created and owned here).
69
+ query: A SQL SELECT whose first column is the log line.
70
+ params: Optional bound parameters for the query.
71
+ yield_per: Server-side cursor batch size (rows fetched per round
72
+ trip). Larger trades memory for fewer round trips.
73
+ sample: Optional fraction in (0, 1] - return only that fraction of
74
+ rows via a per-dialect random predicate. Pass ``sample_seed``
75
+ for a deterministic, re-iterable sample (required for the
76
+ reducer's multi-pass modes; PostgreSQL/MySQL only). Without a
77
+ seed the sample is best-effort and not reproducible across passes.
78
+ sample_seed: Seed for a reproducible sample. Raises
79
+ ``SamplingNotSupported`` on engines with no seedable RNG (SQLite).
80
+ """
81
+ try:
82
+ from sqlalchemy import create_engine
83
+ from sqlalchemy.engine import Engine as _Engine
84
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
85
+ raise ImportError(_INSTALL_HINT) from exc
86
+
87
+ if isinstance(connectable, str):
88
+ self._engine = create_engine(connectable)
89
+ self._owns_engine = True
90
+ elif isinstance(connectable, _Engine):
91
+ self._engine = connectable
92
+ self._owns_engine = False
93
+ else:
94
+ raise TypeError(
95
+ f"connectable must be a SQLAlchemy Engine or a URL string, got {type(connectable).__name__}"
96
+ )
97
+
98
+ self.query = query
99
+ self.params = params
100
+ self.yield_per = yield_per
101
+ self.sample = sample
102
+ self.sample_seed = sample_seed
103
+
104
+ # Precompute the sampled SQL now so an unsupported request (e.g. a seed
105
+ # on SQLite) fails at construction, not deep inside iteration.
106
+ self._sample_setup: str | None = None
107
+ self._sample_sql: str | None = None
108
+ if sample is not None:
109
+ from .sampling import build_sample_sql
110
+
111
+ self._sample_setup, self._sample_sql = build_sample_sql(
112
+ self._engine.dialect.name, query, sample, sample_seed
113
+ )
114
+
115
+ @classmethod
116
+ def from_table(
117
+ cls,
118
+ connectable: Engine | str,
119
+ table: str,
120
+ column: str,
121
+ *,
122
+ sample: float,
123
+ where: str | None = None,
124
+ sample_seed: int | None = None,
125
+ method: str = "system",
126
+ yield_per: int = 1000,
127
+ params: dict[str, Any] | None = None,
128
+ ) -> SQLSource:
129
+ """Sample a fraction of a table with the engine's NATIVE sampler.
130
+
131
+ Unlike ``sample=`` on the constructor (a random predicate over an
132
+ arbitrary query, which full-scans), this builds ``SELECT column FROM
133
+ table TABLESAMPLE ...`` so PostgreSQL samples at the page level -
134
+ genuinely cheap on a large table - with ``REPEATABLE(seed)`` for
135
+ determinism. MySQL/SQLite have no TABLESAMPLE and fall back to a
136
+ full-scan predicate. ``method`` is ``system`` (page, fast) or
137
+ ``bernoulli`` (row, uniform) on PostgreSQL.
138
+ """
139
+ try:
140
+ from sqlalchemy import create_engine
141
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
142
+ raise ImportError(_INSTALL_HINT) from exc
143
+ from .sampling import build_table_sample_query
144
+
145
+ owns = isinstance(connectable, str)
146
+ engine = create_engine(connectable) if owns else connectable
147
+ preparer = engine.dialect.identifier_preparer
148
+ query = build_table_sample_query(
149
+ engine.dialect.name,
150
+ preparer.quote(table),
151
+ preparer.quote(column),
152
+ fraction=sample,
153
+ seed=sample_seed,
154
+ method=method,
155
+ where=where,
156
+ )
157
+ source = cls(engine, query, yield_per=yield_per, params=params)
158
+ source._owns_engine = owns
159
+ return source
160
+
161
+ def __iter__(self) -> Iterator[str]:
162
+ from sqlalchemy import text
163
+
164
+ from .sources import rows_to_lines
165
+
166
+ stmt = text(self._sample_sql if self._sample_sql is not None else self.query)
167
+ with self._engine.connect() as conn:
168
+ # A deterministic sample (PostgreSQL) needs its per-connection seed
169
+ # set before the query, on the same connection.
170
+ if self._sample_setup is not None:
171
+ conn.exec_driver_sql(self._sample_setup)
172
+ # yield_per implies stream_results: a server-side cursor fetching
173
+ # `yield_per` rows per round trip, so memory does not grow with the
174
+ # result size.
175
+ result = conn.execution_options(yield_per=self.yield_per).execute(stmt, self.params or {})
176
+ yield from rows_to_lines(result)
177
+
178
+ def sample_batch(self, n: int) -> list[str]:
179
+ """Return one fresh random batch of up to ``n`` log lines.
180
+
181
+ Each call runs ``ORDER BY <rand> LIMIT n`` over the query, so successive
182
+ calls draw different rows (with-replacement) - the primitive the
183
+ ``reduce_to_target`` loop pulls batches from. Not re-iterable; a one-off
184
+ list, not a streaming pass. Deliberately samples the FULL query
185
+ population, ignoring any constructor ``sample=`` fraction - the target
186
+ loop wants fresh draws from everything, not a sample of a sample.
187
+ """
188
+ from sqlalchemy import text
189
+
190
+ from .sampling import build_sample_batch_sql
191
+ from .sources import rows_to_lines
192
+
193
+ sql = build_sample_batch_sql(self._engine.dialect.name, self.query, n)
194
+ with self._engine.connect() as conn:
195
+ return list(rows_to_lines(conn.execute(text(sql), self.params or {})))
196
+
197
+ def close(self) -> None:
198
+ """Dispose the engine, but only if this source created it."""
199
+ if self._owns_engine:
200
+ self._engine.dispose()
201
+
202
+ def __enter__(self) -> SQLSource:
203
+ return self
204
+
205
+ def __exit__(self, *exc: object) -> None:
206
+ self.close()
207
+
208
+ def __repr__(self) -> str:
209
+ return f"SQLSource({self._engine.url!r}, query={self.query!r})"
logreducer/target.py ADDED
@@ -0,0 +1,164 @@
1
+ """Reduce-to-target orchestrator.
2
+
3
+ Where ``LogReducer.reduce`` makes a single pass over a whole source, this pulls
4
+ fresh random batches and reduces them one at a time, accumulating distinct
5
+ representative lines until it has the number the caller asked for - or the
6
+ source runs dry, a fetch cap is hit, or the representatives stop growing.
7
+
8
+ Peak memory is bounded to roughly one batch plus the accumulator: batches are
9
+ sized to a byte budget (from the observed average row size), and a psutil
10
+ watchdog shrinks the batch and, as a hard backstop, stops the loop under
11
+ pressure. This is the memory-safe path for "give me X lines from a huge table"
12
+ without a full scan.
13
+
14
+ Batches come from ``source.sample_batch(n)`` where the source supports it (SQL,
15
+ ClickHouse); any other re-iterable falls back to a client-side reservoir sample
16
+ over the source per round.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import random
22
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
23
+
24
+ from .memory import MemoryMonitor
25
+ from .sampling import estimate_batch_rows, reservoir_sample
26
+
27
+ if TYPE_CHECKING:
28
+ from .core import LogReducer
29
+ from .sources import Source
30
+
31
+ _DEFAULT_BATCH_ROWS = 10_000
32
+ _MIN_BATCH_ROWS = 1_000
33
+
34
+
35
+ @runtime_checkable
36
+ class _Sampleable(Protocol):
37
+ """A source that can hand back a fresh random batch of ``n`` lines."""
38
+
39
+ def sample_batch(self, n: int) -> list[str]: ...
40
+
41
+
42
+ def _pull_batch(source: Source, n: int, rng: random.Random) -> list[str]:
43
+ """One fresh random batch of up to ``n`` lines.
44
+
45
+ Uses the source's own ``sample_batch`` (SQL/ClickHouse do it in the engine);
46
+ otherwise reservoir-samples the whole re-iterable client-side - correct, but
47
+ it re-reads the source each round, so it is best for in-memory or file
48
+ sources rather than remote ones.
49
+ """
50
+ if isinstance(source, _Sampleable):
51
+ return source.sample_batch(n)
52
+ return reservoir_sample(iter(source), n, rng)
53
+
54
+
55
+ def reduce_to_target(
56
+ source: Source,
57
+ reducer: LogReducer,
58
+ *,
59
+ target_rows: int,
60
+ max_fetches: int = 50,
61
+ max_batch_memory_gb: float | None = None,
62
+ batch_rows: int | None = None,
63
+ plateau_rounds: int = 2,
64
+ seed: int | None = None,
65
+ ) -> dict[str, Any]:
66
+ """Accumulate reduced representative lines until ``target_rows`` are found.
67
+
68
+ Args:
69
+ source: The data source. If it has ``sample_batch(n)`` (SQL, ClickHouse)
70
+ each round draws a fresh random batch from the engine; otherwise the
71
+ whole source is reservoir-sampled per round.
72
+ reducer: A configured ``LogReducer``. Reused across batches - its state
73
+ is reset per ``reduce()`` call, so each batch is reduced independently.
74
+ target_rows: How many distinct representative lines to collect.
75
+ max_fetches: Hard cap on batch pulls (backstop against a source that
76
+ never yields enough distinct representatives).
77
+ max_batch_memory_gb: Byte budget a batch must fit; batch size is derived
78
+ from the observed average row size. Also arms the memory watchdog.
79
+ Defaults to the reducer's own ``max_memory_gb``.
80
+ batch_rows: Fixed batch size; disables adaptive sizing when set.
81
+ plateau_rounds: Stop after this many consecutive batches that add no new
82
+ representatives (a low-cardinality source may never reach the target).
83
+ seed: Seed for the client-side reservoir fallback (ignored when the
84
+ source samples in the engine).
85
+
86
+ Returns:
87
+ ``{"lines": [...], "stats": {...}}`` - up to ``target_rows`` lines plus
88
+ counts and the stop reason (``target`` / ``exhausted`` / ``max_fetches``
89
+ / ``plateau`` / ``memory``).
90
+ """
91
+ if target_rows <= 0:
92
+ raise ValueError(f"target_rows must be positive, got {target_rows!r}")
93
+ if max_fetches <= 0:
94
+ raise ValueError(f"max_fetches must be positive, got {max_fetches!r}")
95
+ if batch_rows is not None and batch_rows <= 0:
96
+ raise ValueError(f"batch_rows must be positive, got {batch_rows!r}")
97
+ if plateau_rounds <= 0:
98
+ raise ValueError(f"plateau_rounds must be positive, got {plateau_rounds!r}")
99
+
100
+ # As documented: the byte budget defaults to the reducer's own memory cap,
101
+ # so adaptive batch sizing is on by default (pass batch_rows to fix it).
102
+ budget_gb = max_batch_memory_gb if max_batch_memory_gb is not None else reducer.config.max_memory_gb
103
+ monitor = MemoryMonitor(budget_gb)
104
+ budget_bytes = int(budget_gb * 1024**3)
105
+ rng = random.Random(seed)
106
+
107
+ seen: dict[str, None] = {}
108
+ batch = batch_rows if batch_rows is not None else _DEFAULT_BATCH_ROWS
109
+ fetches = 0
110
+ plateau = 0
111
+ reason = "max_fetches"
112
+
113
+ while len(seen) < target_rows and fetches < max_fetches:
114
+ lines = _pull_batch(source, batch, rng)
115
+ fetches += 1
116
+ if not lines:
117
+ reason = "exhausted"
118
+ break
119
+
120
+ # Size the next batch from the bytes we just observed (adaptive mode).
121
+ if budget_bytes and batch_rows is None:
122
+ batch = estimate_batch_rows(lines, budget_bytes)
123
+
124
+ added = 0
125
+ for line in reducer.reduce(lines):
126
+ if line not in seen:
127
+ seen[line] = None
128
+ added += 1
129
+ if len(seen) >= target_rows:
130
+ break
131
+
132
+ if len(seen) >= target_rows:
133
+ reason = "target"
134
+ break
135
+
136
+ if added == 0:
137
+ plateau += 1
138
+ if plateau >= plateau_rounds:
139
+ reason = "plateau"
140
+ break
141
+ else:
142
+ plateau = 0
143
+
144
+ # Watchdog: soft-shrink the next batch under pressure; hard-stop if the
145
+ # limit is genuinely exceeded after a GC.
146
+ _, safe = monitor.check_memory()
147
+ if not safe:
148
+ batch = max(_MIN_BATCH_ROWS, batch // 2)
149
+ if monitor.is_limit_exceeded():
150
+ reason = "memory"
151
+ break
152
+
153
+ result = list(seen)[:target_rows]
154
+ return {
155
+ "lines": result,
156
+ "stats": {
157
+ "target_rows": target_rows,
158
+ "collected": len(result),
159
+ "fetches": fetches,
160
+ "final_batch_rows": batch,
161
+ "peak_memory_gb": monitor.get_peak_usage_gb(),
162
+ "stop_reason": reason,
163
+ },
164
+ }
logreducer/temporal.py ADDED
@@ -0,0 +1,163 @@
1
+ """
2
+ Temporal processing and time-aware pattern extraction
3
+ """
4
+
5
+ import re
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import Any
10
+
11
+ from drain3 import TemplateMiner
12
+
13
+
14
+ @dataclass
15
+ class LogEntry:
16
+ """Parsed log entry with timestamp"""
17
+
18
+ raw_line: str
19
+ timestamp: datetime | None = None
20
+ level: str | None = None
21
+ line_number: int = 0
22
+
23
+
24
+ class TimestampParser:
25
+ """Parse timestamps from various formats"""
26
+
27
+ PATTERNS = [
28
+ (r"\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}", "%Y-%m-%d %H:%M:%S"),
29
+ (r"\d{1,2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2}", "%d/%b/%Y:%H:%M:%S"),
30
+ (r"\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}", "%b %d %H:%M:%S"),
31
+ ]
32
+
33
+ COMPILED_PATTERNS = [(re.compile(p), f) for p, f in PATTERNS]
34
+ LEVEL_PATTERN = re.compile(r"\b(DEBUG|INFO|WARN|ERROR|CRITICAL)\b", re.I)
35
+
36
+ def parse_line(self, line: str, line_number: int = 0) -> LogEntry:
37
+ """Parse log line"""
38
+ entry = LogEntry(raw_line=line.strip(), line_number=line_number)
39
+
40
+ # Extract timestamp
41
+ for pattern, date_format in self.COMPILED_PATTERNS:
42
+ match = pattern.search(line)
43
+ if match:
44
+ try:
45
+ timestamp_str = match.group()
46
+ if "%Y" not in date_format:
47
+ timestamp_str = f"{datetime.now().year} {timestamp_str}"
48
+ date_format = f"%Y {date_format}"
49
+ entry.timestamp = datetime.strptime(timestamp_str.replace("T", " "), date_format)
50
+ break
51
+ except (ValueError, TypeError):
52
+ # Timestamp parsing failed, try next pattern
53
+ pass
54
+
55
+ # Extract level
56
+ level_match = self.LEVEL_PATTERN.search(line)
57
+ if level_match:
58
+ entry.level = level_match.group().upper()
59
+
60
+ return entry
61
+
62
+
63
+ class TemporalProcessor:
64
+ """Time-aware log processing"""
65
+
66
+ def __init__(self, window_minutes: int = 60):
67
+ self.window_minutes = window_minutes
68
+ self.parser = TimestampParser()
69
+ self.miners_by_window: dict[int, TemplateMiner] = {}
70
+
71
+ def process_temporal(self, lines: list[str]) -> dict:
72
+ """Process with temporal awareness"""
73
+ entries = [self.parser.parse_line(line, i) for i, line in enumerate(lines)]
74
+
75
+ timed = [e for e in entries if e.timestamp]
76
+ timeless = [e for e in entries if not e.timestamp]
77
+
78
+ results: dict[str, Any] = {
79
+ "temporal_patterns": [],
80
+ "timeless_patterns": [],
81
+ "time_distribution": {},
82
+ }
83
+
84
+ if timed:
85
+ results["temporal_patterns"] = self._extract_temporal_patterns(timed)
86
+ results["time_distribution"] = self._analyze_distribution(timed)
87
+
88
+ if timeless:
89
+ results["timeless_patterns"] = self._extract_timeless_patterns(timeless)
90
+
91
+ return results
92
+
93
+ def _extract_temporal_patterns(self, entries: list[LogEntry]) -> list[dict]:
94
+ """Extract patterns with time context"""
95
+ patterns = []
96
+ windows = defaultdict(list)
97
+
98
+ # Group by time window
99
+ for entry in entries:
100
+ if entry.timestamp:
101
+ window_key = int(entry.timestamp.timestamp() // (self.window_minutes * 60))
102
+ windows[window_key].append(entry)
103
+
104
+ # Process each window
105
+ for window_key, window_entries in windows.items():
106
+ if window_key not in self.miners_by_window:
107
+ from drain3.template_miner_config import TemplateMinerConfig
108
+
109
+ config = TemplateMinerConfig()
110
+ config.snapshot_interval_minutes = 0 # Disable snapshots
111
+ config.snapshot_compress_state = False
112
+ self.miners_by_window[window_key] = TemplateMiner(config=config)
113
+
114
+ miner = self.miners_by_window[window_key]
115
+
116
+ for entry in window_entries:
117
+ result = miner.add_log_message(entry.raw_line)
118
+ cluster_id = result["cluster_id"]
119
+
120
+ patterns.append(
121
+ {
122
+ "template": miner.drain.id_to_cluster[cluster_id].get_template(),
123
+ "window": window_key,
124
+ "count": 1,
125
+ "example": entry.raw_line,
126
+ }
127
+ )
128
+
129
+ return patterns
130
+
131
+ def _extract_timeless_patterns(self, entries: list[LogEntry]) -> list[dict]:
132
+ """Extract patterns without timestamps"""
133
+ from drain3.template_miner_config import TemplateMinerConfig
134
+
135
+ config = TemplateMinerConfig()
136
+ config.snapshot_interval_minutes = 0 # Disable snapshots
137
+ config.snapshot_compress_state = False
138
+ miner = TemplateMiner(config=config)
139
+ patterns = []
140
+
141
+ for entry in entries:
142
+ result = miner.add_log_message(entry.raw_line)
143
+ cluster_id = result["cluster_id"]
144
+ patterns.append(
145
+ {
146
+ "template": miner.drain.id_to_cluster[cluster_id].get_template(),
147
+ "example": entry.raw_line,
148
+ }
149
+ )
150
+
151
+ return patterns
152
+
153
+ def _analyze_distribution(self, entries: list[LogEntry]) -> dict:
154
+ """Analyze time distribution"""
155
+ dist: dict[str, Any] = {"hourly": defaultdict(int), "levels": defaultdict(int)}
156
+
157
+ for entry in entries:
158
+ if entry.timestamp:
159
+ dist["hourly"][entry.timestamp.hour] += 1
160
+ if entry.level:
161
+ dist["levels"][entry.level] += 1
162
+
163
+ return dist