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/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""LogReducer - reduce large volumes of log lines to a representative sample.
|
|
2
|
+
|
|
3
|
+
A streaming reduction engine (dedup -> pattern mining -> anomaly/temporal
|
|
4
|
+
analysis) with an IO-agnostic core: any re-iterable stream of str lines is a
|
|
5
|
+
valid Source. Ships as both a library and a `logreducer` CLI.
|
|
6
|
+
|
|
7
|
+
Copyright 2026 HYPERI PTY LIMITED.
|
|
8
|
+
Licensed under the Apache License, Version 2.0 (see LICENSE).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
__version__ = version("logreducer")
|
|
15
|
+
except PackageNotFoundError: # running from a source tree that is not installed
|
|
16
|
+
__version__ = "0.0.0+unknown"
|
|
17
|
+
|
|
18
|
+
from .config import BigDialConfig, OutputFormat, ProcessingLevel, ProcessingMode
|
|
19
|
+
from .core import LogReducer
|
|
20
|
+
from .logging_config import setup_logging
|
|
21
|
+
from .sampling import SamplingNotSupported
|
|
22
|
+
from .sinks import FileSink, Sink
|
|
23
|
+
from .sources import FileSource, Source
|
|
24
|
+
from .target import reduce_to_target
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BigDialConfig",
|
|
28
|
+
"FileSink",
|
|
29
|
+
"FileSource",
|
|
30
|
+
"LogReducer",
|
|
31
|
+
"OutputFormat",
|
|
32
|
+
"ProcessingLevel",
|
|
33
|
+
"ProcessingMode",
|
|
34
|
+
"SamplingNotSupported",
|
|
35
|
+
"Sink",
|
|
36
|
+
"Source",
|
|
37
|
+
"__version__",
|
|
38
|
+
"reduce_to_target",
|
|
39
|
+
"setup_logging",
|
|
40
|
+
]
|
logreducer/anomaly.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Anomaly detection utilities
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
# Try optional imports
|
|
8
|
+
try:
|
|
9
|
+
from sklearn.ensemble import IsolationForest
|
|
10
|
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
11
|
+
|
|
12
|
+
SKLEARN_AVAILABLE = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
SKLEARN_AVAILABLE = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AnomalyDetector:
|
|
18
|
+
"""Detect anomalous log lines"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, contamination: float = 0.1):
|
|
21
|
+
self.contamination = contamination
|
|
22
|
+
self.enabled = SKLEARN_AVAILABLE
|
|
23
|
+
|
|
24
|
+
if not self.enabled:
|
|
25
|
+
logger.warning("scikit-learn not available, anomaly detection disabled")
|
|
26
|
+
|
|
27
|
+
def detect_anomalies(self, lines: list[str]) -> tuple[list[str], list[str]]:
|
|
28
|
+
"""
|
|
29
|
+
Separate anomalous and normal lines
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
(anomalous_lines, normal_lines)
|
|
33
|
+
"""
|
|
34
|
+
if not self.enabled or len(lines) < 10:
|
|
35
|
+
return [], lines
|
|
36
|
+
|
|
37
|
+
# Vectorize
|
|
38
|
+
vectorizer = TfidfVectorizer(
|
|
39
|
+
max_features=min(1000, len(lines) // 10),
|
|
40
|
+
ngram_range=(1, 3),
|
|
41
|
+
max_df=0.9,
|
|
42
|
+
min_df=2 if len(lines) > 100 else 1,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
# Keep the TF-IDF matrix SPARSE - IsolationForest accepts sparse
|
|
47
|
+
# input directly. Densifying (.toarray()) would allocate
|
|
48
|
+
# n_lines x n_features x 8 bytes, which dwarfs the sparse matrix and
|
|
49
|
+
# can blow the memory budget on a large unique-line set.
|
|
50
|
+
X = vectorizer.fit_transform(lines)
|
|
51
|
+
except ValueError:
|
|
52
|
+
# Empty vocabulary (every term filtered by min_df/max_df) - nothing
|
|
53
|
+
# to score, so treat all lines as normal rather than failing.
|
|
54
|
+
return [], lines
|
|
55
|
+
|
|
56
|
+
iso_forest = IsolationForest(contamination=self.contamination, random_state=42, n_estimators=100)
|
|
57
|
+
labels = iso_forest.fit_predict(X)
|
|
58
|
+
scores = iso_forest.score_samples(X)
|
|
59
|
+
|
|
60
|
+
# One pass: split normal vs anomalous, collecting scores for the latter.
|
|
61
|
+
normal: list[str] = []
|
|
62
|
+
anomaly_with_scores: list[tuple[str, float]] = []
|
|
63
|
+
for line, label, score in zip(lines, labels, scores, strict=False):
|
|
64
|
+
if label == -1:
|
|
65
|
+
anomaly_with_scores.append((line, score))
|
|
66
|
+
else:
|
|
67
|
+
normal.append(line)
|
|
68
|
+
|
|
69
|
+
# Most anomalous first (IsolationForest scores: lower = more anomalous).
|
|
70
|
+
anomaly_with_scores.sort(key=lambda pair: pair[1])
|
|
71
|
+
anomalous = [line for line, _ in anomaly_with_scores]
|
|
72
|
+
|
|
73
|
+
logger.info(f"Found {len(anomalous)} anomalies out of {len(lines)} lines")
|
|
74
|
+
|
|
75
|
+
return anomalous, normal
|
logreducer/cli.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LogReducer command-line interface.
|
|
3
|
+
|
|
4
|
+
A single command that reduces a log source to a representative sample. The
|
|
5
|
+
source is one of:
|
|
6
|
+
|
|
7
|
+
* a file - ``logreducer app.log``
|
|
8
|
+
* a SQL or ClickHouse query - ``logreducer --dsn postgresql://... --query '...'``
|
|
9
|
+
* a Kafka topic - ``logreducer --dsn kafka://broker:9092 --topic logs --group g``
|
|
10
|
+
|
|
11
|
+
The DB and Kafka sources need the matching optional extra installed
|
|
12
|
+
(``logreducer[sql]`` / ``[clickhouse]`` / ``[kafka]``); a plain file needs
|
|
13
|
+
nothing. Built on typer.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .core import LogReducer
|
|
24
|
+
from .logging_config import setup_logging
|
|
25
|
+
from .sources import Source
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _err(message: str) -> None:
|
|
29
|
+
"""Write an error line to stderr."""
|
|
30
|
+
print(message, file=sys.stderr)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_callback(value: bool) -> None:
|
|
34
|
+
"""Eager --version handler: print version and exit."""
|
|
35
|
+
if value:
|
|
36
|
+
print(f"logreducer version {__version__}")
|
|
37
|
+
raise typer.Exit(0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_dsn_source(
|
|
41
|
+
dsn: str,
|
|
42
|
+
query: str | None,
|
|
43
|
+
topic: str | None,
|
|
44
|
+
group: str | None,
|
|
45
|
+
*,
|
|
46
|
+
sample: float | None = None,
|
|
47
|
+
sample_seed: int | None = None,
|
|
48
|
+
) -> Source:
|
|
49
|
+
"""Dispatch a ``--dsn`` to the matching source adapter by URL scheme.
|
|
50
|
+
|
|
51
|
+
``clickhouse://`` -> ClickHouseSource, ``kafka://`` -> KafkaSource, and any
|
|
52
|
+
other scheme (postgresql, mysql, sqlite, ...) -> SQLSource via SQLAlchemy.
|
|
53
|
+
Adapter imports are lazy so a plain-file run never needs the extras.
|
|
54
|
+
``sample`` (and ``sample_seed`` for SQL) apply to the DB sources; Kafka has
|
|
55
|
+
no sampling and rejects it.
|
|
56
|
+
"""
|
|
57
|
+
scheme = dsn.split("://", 1)[0].lower() if "://" in dsn else ""
|
|
58
|
+
|
|
59
|
+
if scheme == "clickhouse":
|
|
60
|
+
if not query:
|
|
61
|
+
raise typer.BadParameter("--query is required for a clickhouse:// source")
|
|
62
|
+
from .clickhouse import ClickHouseSource
|
|
63
|
+
|
|
64
|
+
return ClickHouseSource(dsn, query, sample=sample)
|
|
65
|
+
|
|
66
|
+
if scheme == "kafka":
|
|
67
|
+
if not (topic and group):
|
|
68
|
+
raise typer.BadParameter("--topic and --group are required for a kafka:// source")
|
|
69
|
+
if sample is not None:
|
|
70
|
+
raise typer.BadParameter("--sample is not supported for a kafka:// source")
|
|
71
|
+
from .kafka import KafkaSource
|
|
72
|
+
|
|
73
|
+
brokers = dsn.split("://", 1)[1]
|
|
74
|
+
return KafkaSource(brokers, group, topic)
|
|
75
|
+
|
|
76
|
+
# Everything else is standard SQL reached through SQLAlchemy.
|
|
77
|
+
if not query:
|
|
78
|
+
raise typer.BadParameter("--query is required for a SQL --dsn source")
|
|
79
|
+
from .sql import SQLSource
|
|
80
|
+
|
|
81
|
+
return SQLSource(dsn, query, sample=sample, sample_seed=sample_seed)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _reduce(
|
|
85
|
+
input_file: str | None = typer.Argument(None, help="Log file to reduce (omit when using --dsn)"),
|
|
86
|
+
output: str | None = typer.Option(None, "--output", "-o", help="Output file (default: stdout)"),
|
|
87
|
+
output_format: str = typer.Option("line", "--format", help="Output format: line, json, jsonl"),
|
|
88
|
+
pretty_json: bool = typer.Option(False, "--pretty-json", help="Pretty-print JSON output"),
|
|
89
|
+
level: str = typer.Option("standard", "--level", "-l", help="Processing level: standard, enhanced, maximum"),
|
|
90
|
+
mode: str = typer.Option("pattern", "--mode", "-m", help="Processing mode: pattern, anomaly, temporal, hybrid"),
|
|
91
|
+
dsn: str | None = typer.Option(None, "--dsn", help="Source DSN: postgresql://, clickhouse://, kafka://, ..."),
|
|
92
|
+
query: str | None = typer.Option(None, "--query", help="SQL SELECT (first column is the log line) for --dsn"),
|
|
93
|
+
topic: str | None = typer.Option(None, "--topic", help="Kafka topic (with a kafka:// --dsn)"),
|
|
94
|
+
group: str | None = typer.Option(None, "--group", help="Kafka consumer group (with a kafka:// --dsn)"),
|
|
95
|
+
max_memory: float | None = typer.Option(None, "--max-memory", help="Maximum memory usage in GB"),
|
|
96
|
+
max_patterns: int | None = typer.Option(None, "--max-patterns", help="Maximum number of patterns to extract"),
|
|
97
|
+
log: bool = typer.Option(False, "--log", help="Enable processing logs"),
|
|
98
|
+
log_file: str | None = typer.Option(None, "--log-file", help="Log file path"),
|
|
99
|
+
log_level: str = typer.Option("INFO", "--log-level", help="Logging level: DEBUG, INFO, WARNING, ERROR"),
|
|
100
|
+
estimate: bool = typer.Option(False, "--estimate", help="Estimate processing requirements and exit (file only)"),
|
|
101
|
+
metadata: bool = typer.Option(False, "--metadata", help="Include detailed metadata in output"),
|
|
102
|
+
stats: bool = typer.Option(False, "--stats", help="Print processing statistics to stderr"),
|
|
103
|
+
sample: float | None = typer.Option(
|
|
104
|
+
None, "--sample", help="Sample this fraction (0-1) of source rows (SQL/ClickHouse)"
|
|
105
|
+
),
|
|
106
|
+
sample_seed: int | None = typer.Option(
|
|
107
|
+
None, "--sample-seed", help="Seed for a reproducible --sample (SQL: PostgreSQL/MySQL)"
|
|
108
|
+
),
|
|
109
|
+
target_rows: int | None = typer.Option(
|
|
110
|
+
None, "--target-rows", help="Collect this many reduced lines via repeated sampled batches"
|
|
111
|
+
),
|
|
112
|
+
max_fetches: int = typer.Option(50, "--max-fetches", help="Max sampled batches to pull for --target-rows"),
|
|
113
|
+
max_batch_memory: float | None = typer.Option(
|
|
114
|
+
None, "--max-batch-memory", help="Per-batch memory budget in GB for --target-rows"
|
|
115
|
+
),
|
|
116
|
+
version: bool = typer.Option(
|
|
117
|
+
False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit"
|
|
118
|
+
),
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Reduce a log source to a representative sample."""
|
|
121
|
+
# Build reducer config overrides, dropping unset values.
|
|
122
|
+
overrides: dict[str, Any] = {
|
|
123
|
+
"max_memory_gb": max_memory,
|
|
124
|
+
"max_patterns": max_patterns,
|
|
125
|
+
"enable_logging": log or None,
|
|
126
|
+
"log_file": log_file,
|
|
127
|
+
"log_level": log_level,
|
|
128
|
+
"output_format": output_format,
|
|
129
|
+
"pretty_json": pretty_json or None,
|
|
130
|
+
}
|
|
131
|
+
kwargs = {k: v for k, v in overrides.items() if v is not None}
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
reducer = LogReducer(level=level, mode=mode, **kwargs)
|
|
135
|
+
except ValueError as exc:
|
|
136
|
+
_err(f"Error: {exc}")
|
|
137
|
+
raise typer.Exit(1) from exc
|
|
138
|
+
|
|
139
|
+
# The reducer configures only a file sink; when --log is set the CLI also
|
|
140
|
+
# wants progress on the console.
|
|
141
|
+
if log:
|
|
142
|
+
setup_logging(enable=True, console=True, log_file=log_file, log_level=log_level)
|
|
143
|
+
|
|
144
|
+
# --estimate is a file-only, pre-flight sizing step.
|
|
145
|
+
if estimate:
|
|
146
|
+
if not input_file:
|
|
147
|
+
_err("Error: --estimate requires an input file")
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
_run_estimate(reducer, input_file)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# --target-rows switches to the reduce-to-target orchestrator: pull sampled
|
|
153
|
+
# batches and accumulate reduced lines until the target is met.
|
|
154
|
+
if target_rows is not None:
|
|
155
|
+
_run_target(
|
|
156
|
+
reducer,
|
|
157
|
+
dsn=dsn,
|
|
158
|
+
query=query,
|
|
159
|
+
topic=topic,
|
|
160
|
+
group=group,
|
|
161
|
+
input_file=input_file,
|
|
162
|
+
output=output,
|
|
163
|
+
output_format=output_format,
|
|
164
|
+
pretty_json=pretty_json,
|
|
165
|
+
stats=stats,
|
|
166
|
+
sample=sample,
|
|
167
|
+
sample_seed=sample_seed,
|
|
168
|
+
target_rows=target_rows,
|
|
169
|
+
max_fetches=max_fetches,
|
|
170
|
+
max_batch_memory=max_batch_memory,
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
if dsn:
|
|
176
|
+
source = _build_dsn_source(dsn, query, topic, group, sample=sample, sample_seed=sample_seed)
|
|
177
|
+
try:
|
|
178
|
+
result = reducer.reduce(source, output_file=output, return_metadata=metadata)
|
|
179
|
+
finally:
|
|
180
|
+
close = getattr(source, "close", None)
|
|
181
|
+
if callable(close):
|
|
182
|
+
close()
|
|
183
|
+
elif input_file:
|
|
184
|
+
if sample is not None or sample_seed is not None:
|
|
185
|
+
# Same treatment as the kafka:// rejection: fail loudly rather
|
|
186
|
+
# than silently ignore a flag that only applies to DB sources.
|
|
187
|
+
raise typer.BadParameter("--sample/--sample-seed apply to SQL/ClickHouse sources, not files")
|
|
188
|
+
result = reducer.process_file(input_file, output, return_metadata=metadata)
|
|
189
|
+
else:
|
|
190
|
+
_err("Error: provide a log file, or --dsn with --query (SQL/ClickHouse) or --topic/--group (Kafka)")
|
|
191
|
+
raise typer.Exit(1)
|
|
192
|
+
except (typer.Exit, typer.BadParameter):
|
|
193
|
+
raise # our control-flow exit / typer usage errors - let typer format them
|
|
194
|
+
except KeyboardInterrupt:
|
|
195
|
+
_err("Processing interrupted by user")
|
|
196
|
+
raise typer.Exit(1) from None
|
|
197
|
+
except Exception as exc:
|
|
198
|
+
# File-not-found, a missing optional extra (ImportError), or an adapter
|
|
199
|
+
# failure (bad DSN, connection refused, bad SQL, Kafka error) - report a
|
|
200
|
+
# clean one-line error instead of dumping a Python traceback.
|
|
201
|
+
_err(f"Error: {exc}")
|
|
202
|
+
raise typer.Exit(1) from exc
|
|
203
|
+
|
|
204
|
+
_emit_result(result, output, output_format, pretty_json, metadata)
|
|
205
|
+
|
|
206
|
+
if stats:
|
|
207
|
+
_print_stats(reducer.stats)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _run_estimate(reducer: LogReducer, input_file: str) -> None:
|
|
211
|
+
"""Print a pre-flight processing estimate for a file.
|
|
212
|
+
|
|
213
|
+
The estimate is the command's product, so it goes to stdout via ``print``
|
|
214
|
+
(not the library logger, which is disabled unless --log is passed).
|
|
215
|
+
"""
|
|
216
|
+
try:
|
|
217
|
+
est = reducer.estimate_processing(input_file)
|
|
218
|
+
except OSError as exc:
|
|
219
|
+
_err(f"Error estimating processing: {exc}")
|
|
220
|
+
raise typer.Exit(1) from exc
|
|
221
|
+
|
|
222
|
+
print("Processing Estimation")
|
|
223
|
+
print("=" * 50)
|
|
224
|
+
print(f"File size: {est['file_size_gb']:.2f} GB")
|
|
225
|
+
print(f"Estimated memory: {est['memory_required_gb']:.2f} GB")
|
|
226
|
+
print(f"Processing strategy: {est['strategy']}")
|
|
227
|
+
print(f"Estimated time: {est['estimated_time_seconds']:.0f} seconds")
|
|
228
|
+
print(f"Will sample data: {'Yes' if est['will_sample'] else 'No'}")
|
|
229
|
+
print(f"Expected output lines: ~{est['estimated_output_lines']:,}")
|
|
230
|
+
if est["memory_required_gb"] > 8.0:
|
|
231
|
+
_err("Warning: large memory requirements detected; consider --max-memory")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _run_target(
|
|
235
|
+
reducer: LogReducer,
|
|
236
|
+
*,
|
|
237
|
+
dsn: str | None,
|
|
238
|
+
query: str | None,
|
|
239
|
+
topic: str | None,
|
|
240
|
+
group: str | None,
|
|
241
|
+
input_file: str | None,
|
|
242
|
+
output: str | None,
|
|
243
|
+
output_format: str,
|
|
244
|
+
pretty_json: bool,
|
|
245
|
+
stats: bool,
|
|
246
|
+
sample: float | None,
|
|
247
|
+
sample_seed: int | None,
|
|
248
|
+
target_rows: int,
|
|
249
|
+
max_fetches: int,
|
|
250
|
+
max_batch_memory: float | None,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Collect target_rows reduced lines by pulling repeated sampled batches."""
|
|
253
|
+
from .target import reduce_to_target
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
if dsn:
|
|
257
|
+
source: Source = _build_dsn_source(dsn, query, topic, group, sample=sample, sample_seed=sample_seed)
|
|
258
|
+
elif input_file:
|
|
259
|
+
from .sources import FileSource
|
|
260
|
+
|
|
261
|
+
source = FileSource(input_file, max_memory_gb=reducer.config.max_memory_gb)
|
|
262
|
+
else:
|
|
263
|
+
_err("Error: --target-rows needs a log file, or --dsn with --query (SQL/ClickHouse)")
|
|
264
|
+
raise typer.Exit(1)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
outcome = reduce_to_target(
|
|
268
|
+
source,
|
|
269
|
+
reducer,
|
|
270
|
+
target_rows=target_rows,
|
|
271
|
+
max_fetches=max_fetches,
|
|
272
|
+
max_batch_memory_gb=max_batch_memory,
|
|
273
|
+
seed=sample_seed,
|
|
274
|
+
)
|
|
275
|
+
finally:
|
|
276
|
+
close = getattr(source, "close", None)
|
|
277
|
+
if callable(close):
|
|
278
|
+
close()
|
|
279
|
+
except (typer.Exit, typer.BadParameter):
|
|
280
|
+
raise
|
|
281
|
+
except KeyboardInterrupt:
|
|
282
|
+
_err("Processing interrupted by user")
|
|
283
|
+
raise typer.Exit(1) from None
|
|
284
|
+
except Exception as exc:
|
|
285
|
+
_err(f"Error: {exc}")
|
|
286
|
+
raise typer.Exit(1) from exc
|
|
287
|
+
|
|
288
|
+
_emit_lines(outcome["lines"], output, output_format, pretty_json)
|
|
289
|
+
if stats:
|
|
290
|
+
s = outcome["stats"]
|
|
291
|
+
print(
|
|
292
|
+
f"\nCollected {s['collected']}/{s['target_rows']} lines in {s['fetches']} fetch(es) "
|
|
293
|
+
f"(stopped: {s['stop_reason']})",
|
|
294
|
+
file=sys.stderr,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _emit_lines(lines: list[str], output: str | None, output_format: str, pretty_json: bool) -> None:
|
|
299
|
+
"""Write reduced lines to a file (via FileSink) or print them to stdout."""
|
|
300
|
+
if output:
|
|
301
|
+
from .sinks import FileSink
|
|
302
|
+
|
|
303
|
+
FileSink(output, output_format=output_format).write(lines)
|
|
304
|
+
return
|
|
305
|
+
if output_format == "json":
|
|
306
|
+
print(json.dumps({"lines": lines}, indent=2 if pretty_json else None))
|
|
307
|
+
elif output_format == "jsonl":
|
|
308
|
+
for line in lines:
|
|
309
|
+
print(json.dumps({"line": line}))
|
|
310
|
+
else:
|
|
311
|
+
for line in lines:
|
|
312
|
+
print(line)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _emit_result(
|
|
316
|
+
result: list[str] | dict,
|
|
317
|
+
output: str | None,
|
|
318
|
+
output_format: str,
|
|
319
|
+
pretty_json: bool,
|
|
320
|
+
metadata: bool,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Print the reduced result to stdout when not writing to a file."""
|
|
323
|
+
if output:
|
|
324
|
+
return # Already written by the reducer.
|
|
325
|
+
|
|
326
|
+
if metadata and isinstance(result, dict):
|
|
327
|
+
if output_format == "json":
|
|
328
|
+
print(json.dumps(result, indent=2 if pretty_json else None))
|
|
329
|
+
else:
|
|
330
|
+
for line in result["lines"]:
|
|
331
|
+
print(line)
|
|
332
|
+
elif isinstance(result, list):
|
|
333
|
+
# Honour --format on stdout the same way the --target-rows path does.
|
|
334
|
+
_emit_lines(result, None, output_format, pretty_json)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _print_stats(stats: dict) -> None:
|
|
338
|
+
"""Print a processing summary to stderr (size/rate omitted for non-files)."""
|
|
339
|
+
print("\nProcessing completed:", file=sys.stderr)
|
|
340
|
+
input_size_mb = stats.get("input_size_mb")
|
|
341
|
+
if input_size_mb is not None:
|
|
342
|
+
print(f" Input: {stats['input_lines']:,} lines ({input_size_mb:.1f} MB)", file=sys.stderr)
|
|
343
|
+
else:
|
|
344
|
+
print(f" Input: {stats['input_lines']:,} lines", file=sys.stderr)
|
|
345
|
+
print(f" Output: {stats['output_lines']:,} lines", file=sys.stderr)
|
|
346
|
+
print(f" Reduction: {stats['reduction_percent']:.1f}%", file=sys.stderr)
|
|
347
|
+
print(f" Time: {stats['processing_time_seconds']:.2f}s", file=sys.stderr)
|
|
348
|
+
rate = stats.get("processing_rate_mb_per_sec")
|
|
349
|
+
if rate is not None:
|
|
350
|
+
print(f" Rate: {rate:.1f} MB/sec", file=sys.stderr)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def main() -> None:
|
|
354
|
+
"""Console-script entry point."""
|
|
355
|
+
typer.run(_reduce)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
main()
|
logreducer/clickhouse.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""ClickHouse input source for logreducer (optional ``clickhouse`` extra).
|
|
2
|
+
|
|
3
|
+
Streams log lines out of ClickHouse using the official ``clickhouse-connect``
|
|
4
|
+
driver's native block streaming (``query_row_block_stream``). ClickHouse returns
|
|
5
|
+
results in blocks; this iterates block by block and row by row, so memory stays
|
|
6
|
+
flat no matter how large the result is.
|
|
7
|
+
|
|
8
|
+
ClickHouse gets its own adapter rather than going through SQLAlchemy on purpose:
|
|
9
|
+
ClickHouse's SQLAlchemy dialect buffers the whole result client-side, which
|
|
10
|
+
defeats constant-memory streaming. The native driver's block interface is the
|
|
11
|
+
only path that streams. Everything else mirrors SQLSource - re-iterable (each
|
|
12
|
+
pass re-runs the query), first column is the log line, NULL/blank skipped.
|
|
13
|
+
|
|
14
|
+
Row -> line convention: the query selects the log line as its **first column**,
|
|
15
|
+
e.g. ``SELECT message FROM logs WHERE ...``.
|
|
16
|
+
|
|
17
|
+
Install: ``pip install 'logreducer[clickhouse]'``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from clickhouse_connect.driver.client import Client
|
|
28
|
+
|
|
29
|
+
_INSTALL_HINT = "logreducer's ClickHouse source needs clickhouse-connect. Install the extra:\n pip install 'logreducer[clickhouse]'"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ClickHouseSource:
|
|
33
|
+
"""A re-iterable stream of log lines from a ClickHouse query.
|
|
34
|
+
|
|
35
|
+
Each iteration opens a block stream and re-runs the query, so the reducer's
|
|
36
|
+
multi-pass modes work. The first column of each row is the log line; NULLs
|
|
37
|
+
and blank lines are skipped, matching FileSource.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
client_or_dsn: Client | str,
|
|
43
|
+
query: str,
|
|
44
|
+
*,
|
|
45
|
+
parameters: dict[str, Any] | None = None,
|
|
46
|
+
settings: dict[str, Any] | None = None,
|
|
47
|
+
sample: float | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Build a ClickHouse source.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
client_or_dsn: A ``clickhouse-connect`` Client (borrowed, not
|
|
53
|
+
closed) or a ``clickhouse://user:pass@host:port/db`` DSN string
|
|
54
|
+
(a client is created and owned here).
|
|
55
|
+
query: A SQL SELECT whose first column is the log line.
|
|
56
|
+
parameters: Optional query parameters (server-side binding).
|
|
57
|
+
settings: Optional ClickHouse settings for the query.
|
|
58
|
+
sample: Optional fraction in (0, 1] - appends ClickHouse's native
|
|
59
|
+
``SAMPLE`` clause. ClickHouse sampling is deterministic (no seed
|
|
60
|
+
needed), so it is safe for the reducer's multi-pass modes. It
|
|
61
|
+
REQUIRES the queried table to declare ``SAMPLE BY`` in its
|
|
62
|
+
ORDER BY, and the query to be a plain ``SELECT ... FROM table``
|
|
63
|
+
(SAMPLE goes after the table, before any WHERE) - otherwise put
|
|
64
|
+
the SAMPLE clause in your own query instead.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
import clickhouse_connect
|
|
68
|
+
from clickhouse_connect.driver.client import Client as _Client
|
|
69
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
70
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
71
|
+
|
|
72
|
+
if isinstance(client_or_dsn, str):
|
|
73
|
+
self._client = clickhouse_connect.get_client(dsn=client_or_dsn)
|
|
74
|
+
self._owns_client = True
|
|
75
|
+
elif isinstance(client_or_dsn, _Client):
|
|
76
|
+
self._client = client_or_dsn
|
|
77
|
+
self._owns_client = False
|
|
78
|
+
else:
|
|
79
|
+
raise TypeError(
|
|
80
|
+
f"client_or_dsn must be a clickhouse-connect Client or a DSN string, got {type(client_or_dsn).__name__}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if sample is not None:
|
|
84
|
+
if not (0.0 < sample <= 1.0):
|
|
85
|
+
raise ValueError(f"sample fraction must be in (0, 1], got {sample!r}")
|
|
86
|
+
# SAMPLE must sit after the table, BEFORE any WHERE/GROUP/ORDER -
|
|
87
|
+
# appending it to a query with those clauses builds invalid SQL, so
|
|
88
|
+
# fail fast and point at the alternatives.
|
|
89
|
+
if re.search(r"\b(WHERE|GROUP\s+BY|ORDER\s+BY|LIMIT)\b", query, re.IGNORECASE):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"sample= only supports a plain 'SELECT ... FROM table' query "
|
|
92
|
+
"(ClickHouse SAMPLE goes before WHERE/GROUP/ORDER); use "
|
|
93
|
+
"from_table(..., where=...) or put SAMPLE in your own query"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self.query = query
|
|
97
|
+
self.parameters = parameters
|
|
98
|
+
self.settings = settings
|
|
99
|
+
self.sample = sample
|
|
100
|
+
self._query = f"{query} SAMPLE {float(sample)!r}" if sample is not None else query
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_table(
|
|
104
|
+
cls,
|
|
105
|
+
client_or_dsn: Client | str,
|
|
106
|
+
table: str,
|
|
107
|
+
column: str,
|
|
108
|
+
*,
|
|
109
|
+
sample: float,
|
|
110
|
+
where: str | None = None,
|
|
111
|
+
parameters: dict[str, Any] | None = None,
|
|
112
|
+
settings: dict[str, Any] | None = None,
|
|
113
|
+
) -> ClickHouseSource:
|
|
114
|
+
"""Sample a fraction of a table with ClickHouse's native ``SAMPLE`` clause.
|
|
115
|
+
|
|
116
|
+
Builds ``SELECT column FROM table SAMPLE k [WHERE ...]`` with the clause
|
|
117
|
+
in the correct position (after the table, before WHERE). Deterministic by
|
|
118
|
+
construction. Requires the table to declare a ``SAMPLE BY`` key in its
|
|
119
|
+
ORDER BY; otherwise ClickHouse rejects the query.
|
|
120
|
+
"""
|
|
121
|
+
if not (0.0 < sample <= 1.0):
|
|
122
|
+
raise ValueError(f"sample fraction must be in (0, 1], got {sample!r}")
|
|
123
|
+
# Backtick-quote identifiers, doubling any embedded backtick so a name
|
|
124
|
+
# can never break out of its quoting.
|
|
125
|
+
tbl = "`" + table.replace("`", "``") + "`"
|
|
126
|
+
col = "`" + column.replace("`", "``") + "`"
|
|
127
|
+
where_sql = f" WHERE {where}" if where else ""
|
|
128
|
+
query = f"SELECT {col} FROM {tbl} SAMPLE {float(sample)!r}{where_sql}"
|
|
129
|
+
return cls(client_or_dsn, query, parameters=parameters, settings=settings)
|
|
130
|
+
|
|
131
|
+
def __iter__(self) -> Iterator[str]:
|
|
132
|
+
from .sources import rows_to_lines
|
|
133
|
+
|
|
134
|
+
with self._client.query_row_block_stream(
|
|
135
|
+
self._query, parameters=self.parameters, settings=self.settings
|
|
136
|
+
) as stream:
|
|
137
|
+
for block in stream:
|
|
138
|
+
yield from rows_to_lines(block)
|
|
139
|
+
|
|
140
|
+
def sample_batch(self, n: int) -> list[str]:
|
|
141
|
+
"""Return one fresh random batch of up to ``n`` log lines.
|
|
142
|
+
|
|
143
|
+
Runs ``... ORDER BY rand() LIMIT n`` over the query, so successive calls
|
|
144
|
+
draw different rows (with-replacement) - the primitive the
|
|
145
|
+
``reduce_to_target`` loop pulls from. A one-off list, not a stream.
|
|
146
|
+
Deliberately samples the FULL query population, ignoring any constructor
|
|
147
|
+
``sample=`` fraction - the target loop wants fresh draws from everything.
|
|
148
|
+
"""
|
|
149
|
+
from .sampling import build_sample_batch_sql
|
|
150
|
+
from .sources import rows_to_lines
|
|
151
|
+
|
|
152
|
+
sql = build_sample_batch_sql("clickhouse", self.query, n)
|
|
153
|
+
out: list[str] = []
|
|
154
|
+
with self._client.query_row_block_stream(sql, parameters=self.parameters, settings=self.settings) as stream:
|
|
155
|
+
for block in stream:
|
|
156
|
+
out.extend(rows_to_lines(block))
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
"""Close the client, but only if this source created it."""
|
|
161
|
+
if self._owns_client:
|
|
162
|
+
self._client.close()
|
|
163
|
+
|
|
164
|
+
def __enter__(self) -> ClickHouseSource:
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def __exit__(self, *exc: object) -> None:
|
|
168
|
+
self.close()
|
|
169
|
+
|
|
170
|
+
def __repr__(self) -> str:
|
|
171
|
+
return f"ClickHouseSource(query={self.query!r})"
|