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/__init__.py +40 -0
- logreducer/anomaly.py +75 -0
- logreducer/cli.py +359 -0
- logreducer/clickhouse.py +171 -0
- logreducer/config.py +166 -0
- logreducer/core.py +493 -0
- logreducer/kafka.py +300 -0
- logreducer/logging_config.py +193 -0
- logreducer/memory.py +247 -0
- logreducer/patterns.py +154 -0
- logreducer/py.typed +1 -0
- logreducer/sampling.py +211 -0
- logreducer/sinks.py +81 -0
- logreducer/sources.py +78 -0
- logreducer/sql.py +209 -0
- logreducer/target.py +164 -0
- logreducer/temporal.py +163 -0
- logreducer-3.4.0.dist-info/METADATA +387 -0
- logreducer-3.4.0.dist-info/RECORD +23 -0
- logreducer-3.4.0.dist-info/WHEEL +4 -0
- logreducer-3.4.0.dist-info/entry_points.txt +3 -0
- logreducer-3.4.0.dist-info/licenses/LICENSE +201 -0
- logreducer-3.4.0.dist-info/licenses/NOTICE +53 -0
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
|