logreducer 3.5.2__tar.gz → 3.5.3__tar.gz

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.
Files changed (25) hide show
  1. {logreducer-3.5.2 → logreducer-3.5.3}/PKG-INFO +15 -13
  2. {logreducer-3.5.2 → logreducer-3.5.3}/README.md +8 -4
  3. {logreducer-3.5.2 → logreducer-3.5.3}/pyproject.toml +17 -19
  4. {logreducer-3.5.2 → logreducer-3.5.3}/pyproject.toml.orig +17 -19
  5. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/anomaly.py +3 -1
  6. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/config.py +3 -1
  7. logreducer-3.5.3/src/logreducer/logging_config.py +237 -0
  8. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/patterns.py +28 -34
  9. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sql.py +1 -1
  10. logreducer-3.5.3/src/logreducer/template_mining.py +453 -0
  11. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/temporal.py +9 -19
  12. logreducer-3.5.2/src/logreducer/logging_config.py +0 -194
  13. {logreducer-3.5.2 → logreducer-3.5.3}/LICENSE +0 -0
  14. {logreducer-3.5.2 → logreducer-3.5.3}/NOTICE +0 -0
  15. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/__init__.py +0 -0
  16. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/cli.py +0 -0
  17. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/clickhouse.py +0 -0
  18. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/core.py +0 -0
  19. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/kafka.py +0 -0
  20. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/memory.py +0 -0
  21. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/py.typed +0 -0
  22. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sampling.py +0 -0
  23. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sinks.py +0 -0
  24. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sources.py +0 -0
  25. {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/target.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logreducer
3
- Version: 3.5.2
3
+ Version: 3.5.3
4
4
  Summary: Reduce GB-scale logs to a representative sample - streaming Python library and CLI with pattern mining and anomaly detection
5
5
  Keywords: log,logging,reduction,analysis,pattern-extraction,memory-efficient,streaming,anomaly-detection
6
6
  Author: HyperI Team
@@ -17,17 +17,15 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
17
  Classifier: Programming Language :: Python :: 3
18
18
  Classifier: Programming Language :: Python :: 3 :: Only
19
19
  Classifier: Operating System :: OS Independent
20
- Requires-Dist: drain3>=0.9.0
21
- Requires-Dist: psutil>=7.2.0
22
- Requires-Dist: loguru>=0.7.3
23
- Requires-Dist: numpy>=2.0.0
24
- Requires-Dist: scikit-learn>=1.5.0
20
+ Requires-Dist: psutil>=7.2.2
21
+ Requires-Dist: numpy>=2.5.3
22
+ Requires-Dist: scikit-learn>=1.9.1
25
23
  Requires-Dist: typer>=0.27.3
26
- Requires-Dist: clickhouse-connect>=1.0 ; extra == 'clickhouse'
24
+ Requires-Dist: clickhouse-connect>=1.9.0 ; extra == 'clickhouse'
27
25
  Requires-Dist: xxhash>=4.0.1 ; extra == 'enhanced'
28
26
  Requires-Dist: datasketch>=2.0.0 ; extra == 'enhanced'
29
- Requires-Dist: confluent-kafka>=2.14.0 ; extra == 'kafka'
30
- Requires-Dist: sqlalchemy>=2.0.30 ; extra == 'sql'
27
+ Requires-Dist: confluent-kafka>=2.15.1 ; extra == 'kafka'
28
+ Requires-Dist: sqlalchemy>=2.1.3 ; extra == 'sql'
31
29
  Maintainer: HyperI Team
32
30
  Maintainer-email: HyperI Team <dev@hyperi.io>
33
31
  Requires-Python: >=3.14
@@ -337,8 +335,9 @@ reducer = logreducer.LogReducer(mode="hybrid", config=cfg)
337
335
  # ...or let logreducer read your env cascade directly: prefixed beats bare.
338
336
  cfg = logreducer.BigDialConfig.from_env("MYAPP", "LOGREDUCER") # MYAPP_MAX_MEMORY_GB wins
339
337
 
340
- # 2. Logging: your app owns the loguru sinks; logreducer registers nothing and
341
- # its records flow through YOUR handlers, formatted by YOUR standard.
338
+ # 2. Logging: your app owns the stdlib logging handlers; logreducer attaches
339
+ # nothing and its records flow through YOUR handlers, at YOUR level,
340
+ # formatted by YOUR standard.
342
341
  logreducer.setup_logging(enable=True, own_sinks=False)
343
342
  ```
344
343
 
@@ -368,7 +367,7 @@ Logging is off by default. Enable it and pick a format via env vars:
368
367
  LOG_LEVEL=DEBUG LOG_FORMAT=json logreducer app.log --log
369
368
  ```
370
369
 
371
- `LOG_FORMAT=json` emits one JSON object per line for log aggregators; the default is human-readable (coloured in a terminal, plain in CI/containers). Embedding? See `own_sinks=False` above.
370
+ Logging is the standard library `logging` module, under the `logreducer` logger. `LOG_FORMAT=json` emits one JSON object per line for log aggregators (keys `timestamp`, `level`, `target`, `function`, `line_number`, `message`, `fields`); the default is human-readable (coloured in a terminal, plain in CI/containers). Embedding? See `own_sinks=False` above.
372
371
 
373
372
  ## Development
374
373
 
@@ -386,6 +385,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the toolchain, test layout (including
386
385
 
387
386
  Copyright 2026 HYPERI PTY LIMITED.
388
387
 
388
+ Credits: template mining (`src/logreducer/template_mining.py`) is derived from [Drain3](https://github.com/logpai/Drain3), Copyright (c) 2020-2022 International Business Machines and the Drain3 project contributors, MIT licence. Drain3 builds on the Drain parser in LogPAI's [logparser](https://github.com/logpai/logparser). The module header carries Drain3's licence text in full.
389
+
389
390
  ## Context
390
391
 
391
392
  Depth: [docs/architecture.md](docs/architecture.md).
@@ -406,6 +407,7 @@ it is given and drops the rest.
406
407
  | `src/logreducer/sources.py`, `sinks.py` | the two structural protocols that ARE the integration surface, plus `FileSource` and `FileSink` |
407
408
  | `src/logreducer/sql.py`, `clickhouse.py`, `kafka.py` | the optional adapters, one extra each |
408
409
  | `src/logreducer/patterns.py`, `anomaly.py`, `temporal.py` | Drain3 mining and fuzzy dedup, Isolation Forest, time-window grouping |
410
+ | `src/logreducer/template_mining.py` | the Drain template miner, derived from Drain3 (credited under License) |
409
411
  | `src/logreducer/memory.py` | `MemoryMonitor`, `BoundedDeduplicator`, the file read strategies |
410
412
  | `src/logreducer/sampling.py`, `target.py` | dialect-aware SQL sampling, and the `reduce_to_target` batch loop |
411
413
  | `src/logreducer/config.py` | `BigDialConfig`, the level presets, the `from_env` cascade |
@@ -451,7 +453,7 @@ One declared edge in `dfe-infra/suite.yaml`, and it runs the surprising way.
451
453
 
452
454
  `dfe-stack suite --consumer logreducer` returns an empty edge list: no repo in
453
455
  that graph is something this library depends on. The runtime dependencies
454
- (drain3, psutil, loguru, numpy, scikit-learn, typer) are packages, declared in
456
+ (psutil, numpy, scikit-learn, typer) are packages, declared in
455
457
  `pyproject.toml`, which is where they belong.
456
458
 
457
459
  The reason dfe-engine gives for not declaring the dependency -- pin it "once it
@@ -293,8 +293,9 @@ reducer = logreducer.LogReducer(mode="hybrid", config=cfg)
293
293
  # ...or let logreducer read your env cascade directly: prefixed beats bare.
294
294
  cfg = logreducer.BigDialConfig.from_env("MYAPP", "LOGREDUCER") # MYAPP_MAX_MEMORY_GB wins
295
295
 
296
- # 2. Logging: your app owns the loguru sinks; logreducer registers nothing and
297
- # its records flow through YOUR handlers, formatted by YOUR standard.
296
+ # 2. Logging: your app owns the stdlib logging handlers; logreducer attaches
297
+ # nothing and its records flow through YOUR handlers, at YOUR level,
298
+ # formatted by YOUR standard.
298
299
  logreducer.setup_logging(enable=True, own_sinks=False)
299
300
  ```
300
301
 
@@ -324,7 +325,7 @@ Logging is off by default. Enable it and pick a format via env vars:
324
325
  LOG_LEVEL=DEBUG LOG_FORMAT=json logreducer app.log --log
325
326
  ```
326
327
 
327
- `LOG_FORMAT=json` emits one JSON object per line for log aggregators; the default is human-readable (coloured in a terminal, plain in CI/containers). Embedding? See `own_sinks=False` above.
328
+ Logging is the standard library `logging` module, under the `logreducer` logger. `LOG_FORMAT=json` emits one JSON object per line for log aggregators (keys `timestamp`, `level`, `target`, `function`, `line_number`, `message`, `fields`); the default is human-readable (coloured in a terminal, plain in CI/containers). Embedding? See `own_sinks=False` above.
328
329
 
329
330
  ## Development
330
331
 
@@ -342,6 +343,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for the toolchain, test layout (including
342
343
 
343
344
  Copyright 2026 HYPERI PTY LIMITED.
344
345
 
346
+ Credits: template mining (`src/logreducer/template_mining.py`) is derived from [Drain3](https://github.com/logpai/Drain3), Copyright (c) 2020-2022 International Business Machines and the Drain3 project contributors, MIT licence. Drain3 builds on the Drain parser in LogPAI's [logparser](https://github.com/logpai/logparser). The module header carries Drain3's licence text in full.
347
+
345
348
  ## Context
346
349
 
347
350
  Depth: [docs/architecture.md](docs/architecture.md).
@@ -362,6 +365,7 @@ it is given and drops the rest.
362
365
  | `src/logreducer/sources.py`, `sinks.py` | the two structural protocols that ARE the integration surface, plus `FileSource` and `FileSink` |
363
366
  | `src/logreducer/sql.py`, `clickhouse.py`, `kafka.py` | the optional adapters, one extra each |
364
367
  | `src/logreducer/patterns.py`, `anomaly.py`, `temporal.py` | Drain3 mining and fuzzy dedup, Isolation Forest, time-window grouping |
368
+ | `src/logreducer/template_mining.py` | the Drain template miner, derived from Drain3 (credited under License) |
365
369
  | `src/logreducer/memory.py` | `MemoryMonitor`, `BoundedDeduplicator`, the file read strategies |
366
370
  | `src/logreducer/sampling.py`, `target.py` | dialect-aware SQL sampling, and the `reduce_to_target` batch loop |
367
371
  | `src/logreducer/config.py` | `BigDialConfig`, the level presets, the `from_env` cascade |
@@ -407,7 +411,7 @@ One declared edge in `dfe-infra/suite.yaml`, and it runs the surprising way.
407
411
 
408
412
  `dfe-stack suite --consumer logreducer` returns an empty edge list: no repo in
409
413
  that graph is something this library depends on. The runtime dependencies
410
- (drain3, psutil, loguru, numpy, scikit-learn, typer) are packages, declared in
414
+ (psutil, numpy, scikit-learn, typer) are packages, declared in
411
415
  `pyproject.toml`, which is where they belong.
412
416
 
413
417
  The reason dfe-engine gives for not declaring the dependency -- pin it "once it
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "logreducer"
7
- version = "3.5.2"
7
+ version = "3.5.3"
8
8
  description = "Reduce GB-scale logs to a representative sample - streaming Python library and CLI with pattern mining and anomaly detection"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -35,11 +35,9 @@ keywords = [
35
35
  "anomaly-detection",
36
36
  ]
37
37
  dependencies = [
38
- "drain3>=0.9.0",
39
- "psutil>=7.2.0",
40
- "loguru>=0.7.3",
41
- "numpy>=2.0.0",
42
- "scikit-learn>=1.5.0",
38
+ "psutil>=7.2.2",
39
+ "numpy>=2.5.3",
40
+ "scikit-learn>=1.9.1",
43
41
  "typer>=0.27.3",
44
42
  ]
45
43
 
@@ -56,9 +54,9 @@ enhanced = [
56
54
  "xxhash>=4.0.1",
57
55
  "datasketch>=2.0.0",
58
56
  ]
59
- sql = ["sqlalchemy>=2.0.30"]
60
- clickhouse = ["clickhouse-connect>=1.0"]
61
- kafka = ["confluent-kafka>=2.14.0"]
57
+ sql = ["sqlalchemy>=2.1.3"]
58
+ clickhouse = ["clickhouse-connect>=1.9.0"]
59
+ kafka = ["confluent-kafka>=2.15.1"]
62
60
 
63
61
  [project.urls]
64
62
  Homepage = "https://github.com/hyperi-io/logreducer"
@@ -280,17 +278,17 @@ no_tips = true
280
278
 
281
279
  [tool.hatch.build.targets.sdist]
282
280
  exclude = [
283
- "/hyperi-ai",
284
- "/.claude",
285
- "/.gemini",
286
281
  "/CURSOR.md",
287
- "/CLAUDE.md",
288
- "/.cursor",
289
- "/ci",
290
282
  "/GEMINI.md",
283
+ "/.cursor",
284
+ "/.windsurf",
285
+ "/.gemini",
286
+ "/CLAUDE.md",
291
287
  "/.github/copilot-instructions.md",
288
+ "/.claude",
289
+ "/ci",
290
+ "/hyperi-ai",
292
291
  "/STATE.md",
293
- "/.windsurf",
294
292
  ]
295
293
 
296
294
  [dependency-groups]
@@ -304,9 +302,9 @@ dev = [
304
302
  "pip-audit>=2.6.0",
305
303
  "vulture>=2.16",
306
304
  "vermin>=1.6.0",
307
- "sqlalchemy>=2.0.30",
308
- "clickhouse-connect>=1.0",
309
- "confluent-kafka>=2.14.0",
305
+ "sqlalchemy>=2.1.3",
306
+ "clickhouse-connect>=1.9.0",
307
+ "confluent-kafka>=2.15.1",
310
308
  "testcontainers[clickhouse,kafka,postgres,mysql]>=4.15.0",
311
309
  "psycopg[binary]>=3.2",
312
310
  "pymysql>=1.1.0",
@@ -6,7 +6,7 @@ build-backend = "uv_build"
6
6
 
7
7
  [project]
8
8
  name = "logreducer"
9
- version = "3.5.2"
9
+ version = "3.5.3"
10
10
  authors = [
11
11
  { name = "HyperI Team", email = "dev@hyperi.io" },
12
12
  ]
@@ -43,11 +43,9 @@ keywords = [
43
43
  "anomaly-detection",
44
44
  ]
45
45
  dependencies = [
46
- "drain3>=0.9.0",
47
- "psutil>=7.2.0",
48
- "loguru>=0.7.3",
49
- "numpy>=2.0.0",
50
- "scikit-learn>=1.5.0",
46
+ "psutil>=7.2.2",
47
+ "numpy>=2.5.3",
48
+ "scikit-learn>=1.9.1",
51
49
  "typer>=0.27.3",
52
50
  ]
53
51
 
@@ -57,13 +55,13 @@ enhanced = [
57
55
  "datasketch>=2.0.0",
58
56
  ]
59
57
  sql = [
60
- "sqlalchemy>=2.0.30",
58
+ "sqlalchemy>=2.1.3",
61
59
  ]
62
60
  clickhouse = [
63
- "clickhouse-connect>=1.0",
61
+ "clickhouse-connect>=1.9.0",
64
62
  ]
65
63
  kafka = [
66
- "confluent-kafka>=2.14.0",
64
+ "confluent-kafka>=2.15.1",
67
65
  ]
68
66
 
69
67
  [project.urls]
@@ -312,17 +310,17 @@ no_tips = true
312
310
 
313
311
  [tool.hatch.build.targets.sdist]
314
312
  exclude = [
315
- "/hyperi-ai",
316
- "/.claude",
317
- "/.gemini",
318
313
  "/CURSOR.md",
319
- "/CLAUDE.md",
320
- "/.cursor",
321
- "/ci",
322
314
  "/GEMINI.md",
315
+ "/.cursor",
316
+ "/.windsurf",
317
+ "/.gemini",
318
+ "/CLAUDE.md",
323
319
  "/.github/copilot-instructions.md",
320
+ "/.claude",
321
+ "/ci",
322
+ "/hyperi-ai",
324
323
  "/STATE.md",
325
- "/.windsurf",
326
324
  ]
327
325
 
328
326
  [dependency-groups]
@@ -336,9 +334,9 @@ dev = [
336
334
  "pip-audit>=2.6.0",
337
335
  "vulture>=2.16",
338
336
  "vermin>=1.6.0",
339
- "sqlalchemy>=2.0.30",
340
- "clickhouse-connect>=1.0",
341
- "confluent-kafka>=2.14.0",
337
+ "sqlalchemy>=2.1.3",
338
+ "clickhouse-connect>=1.9.0",
339
+ "confluent-kafka>=2.15.1",
342
340
  "testcontainers[clickhouse,kafka,postgres,mysql]>=4.15.0",
343
341
  "psycopg[binary]>=3.2",
344
342
  "pymysql>=1.1.0",
@@ -1,6 +1,8 @@
1
1
  """Anomaly detection utilities."""
2
2
 
3
- from loguru import logger
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
4
6
 
5
7
  # Try optional imports
6
8
  try:
@@ -1,5 +1,6 @@
1
1
  """Configuration and tuning parameters for LogReducer."""
2
2
 
3
+ import logging
3
4
  import os
4
5
  import types
5
6
  import typing
@@ -7,7 +8,8 @@ from dataclasses import dataclass, fields
7
8
  from enum import Enum
8
9
 
9
10
  import psutil
10
- from loguru import logger
11
+
12
+ logger = logging.getLogger(__name__)
11
13
 
12
14
 
13
15
  class ProcessingLevel(Enum):
@@ -0,0 +1,237 @@
1
+ """Logging configuration for LogReducer, on the standard library ``logging``.
2
+
3
+ Output follows one strict standard so logreducer's logs sit uniformly beside a
4
+ host application's logs in a shared deployment: RFC 3339 timestamps, a
5
+ ``time | LEVEL | name:function:line - message`` layout, ASCII-only in files,
6
+ solarized colours in an interactive terminal, a plain prefix in CI, and one
7
+ JSON object per line when ``LOG_FORMAT=json``.
8
+
9
+ logreducer is a library, not a service, so it only ever attaches handlers to its
10
+ OWN ``logreducer`` logger and never touches the root logger or a host
11
+ application's handlers - embedding logreducer inside another application must
12
+ not disturb that application's logging.
13
+
14
+ ENV overrides:
15
+ LOG_LEVEL=DEBUG
16
+ LOG_FORMAT=json|text
17
+ LOG_OUTPUT=stdout|stderr
18
+ """
19
+
20
+ import json
21
+ import logging
22
+ import logging.handlers
23
+ import os
24
+ import sys
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+
28
+ PACKAGE_LOGGER_NAME = "logreducer"
29
+
30
+ # Solarized palette (https://ethanschoonover.com/solarized/) for the console
31
+ # sink - readable on both light and dark terminal backgrounds.
32
+ SOLARIZED = {
33
+ "base01": "#586e75",
34
+ "green": "#859900",
35
+ "cyan": "#2aa198",
36
+ "blue": "#268bd2",
37
+ "yellow": "#b58900",
38
+ "orange": "#cb4b16",
39
+ "red": "#dc322f",
40
+ }
41
+
42
+ _LEVEL_COLOURS = {
43
+ logging.DEBUG: SOLARIZED["base01"],
44
+ logging.INFO: SOLARIZED["blue"],
45
+ logging.WARNING: SOLARIZED["yellow"],
46
+ logging.ERROR: SOLARIZED["orange"],
47
+ logging.CRITICAL: SOLARIZED["red"],
48
+ }
49
+
50
+ # Above CRITICAL, so a disabled logreducer emits nothing, not even through a host's handlers.
51
+ _DISABLED_LEVEL = logging.CRITICAL + 1
52
+
53
+ # Rotate the file sink at 10 MB and keep seven rotated files.
54
+ _FILE_MAX_BYTES = 10 * 1024 * 1024
55
+ _FILE_BACKUPS = 7
56
+
57
+ # Every attribute a bare LogRecord carries; anything else came from the caller's ``extra=``.
58
+ _STANDARD_RECORD_ATTRS = frozenset(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
59
+
60
+ # Handlers this module has attached, so re-configuring only tears down our own
61
+ # sinks - never the host application's.
62
+ _HANDLERS: list[logging.Handler] = []
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ def _is_ci() -> bool:
68
+ """True in a CI environment (colours off, plain prefix format)."""
69
+ return (
70
+ os.getenv("CI") == "true"
71
+ or os.getenv("GITHUB_ACTIONS") == "true"
72
+ or os.getenv("GITLAB_CI") == "true"
73
+ or os.getenv("JENKINS_URL") is not None
74
+ )
75
+
76
+
77
+ def _is_interactive() -> bool:
78
+ """True only for an interactive UTF-8 terminal (never a pipe/container)."""
79
+ if _is_ci() or not sys.stderr.isatty():
80
+ return False
81
+ term = os.getenv("TERM", "")
82
+ if not term or term == "dumb":
83
+ return False
84
+ locale = (os.getenv("LC_ALL") or os.getenv("LANG") or "").upper()
85
+ return "UTF-8" in locale or "UTF8" in locale
86
+
87
+
88
+ def _paint(text: str, hex_colour: str) -> str:
89
+ """Wrap ``text`` in a 24-bit ANSI foreground colour."""
90
+ red, green, blue = (int(hex_colour[i : i + 2], 16) for i in (1, 3, 5))
91
+ return f"\x1b[38;2;{red};{green};{blue}m{text}\x1b[0m"
92
+
93
+
94
+ def _local_rfc3339(created: float) -> str:
95
+ """RFC 3339 local time with milliseconds and the UTC offset."""
96
+ return datetime.fromtimestamp(created).astimezone().isoformat(timespec="milliseconds")
97
+
98
+
99
+ class _LineFormatter(logging.Formatter):
100
+ """Render ``[time] LEVEL name:function:line - message`` on one line (plus any traceback)."""
101
+
102
+ def __init__(self, *, with_time: bool, colour: bool, ascii_only: bool) -> None:
103
+ super().__init__()
104
+ self._with_time = with_time
105
+ self._colour = colour
106
+ self._ascii_only = ascii_only
107
+
108
+ def format(self, record: logging.LogRecord) -> str:
109
+ """Render one record."""
110
+ level = f"{record.levelname:<8}"
111
+ where = f"{record.name}:{record.funcName}:{record.lineno}"
112
+ message = record.getMessage()
113
+ if record.exc_info:
114
+ message = f"{message}\n{self.formatException(record.exc_info)}"
115
+
116
+ if self._colour:
117
+ level_colour = _LEVEL_COLOURS.get(record.levelno, SOLARIZED["blue"])
118
+ line = (
119
+ f"{_paint(_local_rfc3339(record.created), SOLARIZED['green'])} | "
120
+ f"{_paint(level, level_colour)} | {_paint(where, SOLARIZED['cyan'])} - {_paint(message, level_colour)}"
121
+ )
122
+ elif self._with_time:
123
+ line = f"{_local_rfc3339(record.created)} [{level}] {where} - {message}"
124
+ else:
125
+ line = f"[{level}] {where} - {message}"
126
+
127
+ if self._ascii_only:
128
+ return line.encode("ascii", "backslashreplace").decode("ascii")
129
+ return line
130
+
131
+
132
+ class _JsonFormatter(logging.Formatter):
133
+ """Render one flat JSON object per record, keyed like scalo's JSON log lines."""
134
+
135
+ def format(self, record: logging.LogRecord) -> str:
136
+ """Render one record."""
137
+ timestamp = datetime.fromtimestamp(record.created, UTC).isoformat(timespec="microseconds")
138
+ entry: dict[str, object] = {
139
+ "timestamp": timestamp.replace("+00:00", "Z"),
140
+ "level": record.levelname,
141
+ "target": record.name,
142
+ "function": record.funcName,
143
+ "line_number": record.lineno,
144
+ "message": record.getMessage(),
145
+ "fields": {key: value for key, value in record.__dict__.items() if key not in _STANDARD_RECORD_ATTRS},
146
+ }
147
+ if record.exc_info:
148
+ entry["exception"] = self.formatException(record.exc_info)
149
+ return json.dumps(entry, default=str, ensure_ascii=True)
150
+
151
+
152
+ def _attach(package_logger: logging.Logger, handler: logging.Handler, formatter: logging.Formatter) -> None:
153
+ handler.setFormatter(formatter)
154
+ package_logger.addHandler(handler)
155
+ _HANDLERS.append(handler)
156
+
157
+
158
+ def setup_logging(
159
+ enable: bool = False,
160
+ log_file: str | None = None,
161
+ log_level: str = "INFO",
162
+ log_format: str = "text",
163
+ console: bool = False,
164
+ own_sinks: bool = True,
165
+ ) -> None:
166
+ """Configure logreducer's own logging sinks.
167
+
168
+ Records always propagate to the host application's handlers as well, so a
169
+ host that has configured the root logger sees logreducer's records there.
170
+
171
+ Args:
172
+ enable: Whether logreducer emits logs at all (default False).
173
+ log_file: Optional path for an ASCII file sink that rotates at 10 MB
174
+ and keeps seven rotated files.
175
+ log_level: Threshold level (overridden by ``LOG_LEVEL``).
176
+ log_format: ``text`` (human) or ``json`` (overridden by ``LOG_FORMAT``).
177
+ console: Also log to the console (stderr, or ``LOG_OUTPUT=stdout``).
178
+ own_sinks: When False, attach NO handlers and leave the threshold to the
179
+ host - logreducer's records flow to the HOST application's handlers
180
+ at the host's level. This is the embedding seam: a host app that owns
181
+ logging calls ``setup_logging(enable=True, own_sinks=False)`` and
182
+ logreducer's output lands in the host's sinks, formatted by the
183
+ host's standard.
184
+
185
+ Raises:
186
+ ValueError: If the resolved level is not a ``logging`` level name.
187
+ """
188
+ # Env beats caller, so a deployment can retune logging without code changes.
189
+ log_level = os.environ.get("LOG_LEVEL", log_level).upper()
190
+ fmt_selector = (os.environ.get("LOG_FORMAT") or log_format or "text").strip().lower()
191
+ serialize = fmt_selector == "json"
192
+
193
+ # Tear down ONLY our previously attached handlers.
194
+ package_logger = logging.getLogger(PACKAGE_LOGGER_NAME)
195
+ for handler in _HANDLERS:
196
+ package_logger.removeHandler(handler)
197
+ handler.close()
198
+ _HANDLERS.clear()
199
+
200
+ if not enable:
201
+ package_logger.setLevel(_DISABLED_LEVEL)
202
+ return
203
+
204
+ if not own_sinks:
205
+ package_logger.setLevel(logging.NOTSET)
206
+ return
207
+
208
+ package_logger.setLevel(log_level)
209
+ ci = _is_ci()
210
+
211
+ if console:
212
+ stream = sys.stdout if os.environ.get("LOG_OUTPUT") == "stdout" else sys.stderr
213
+ colour = not serialize and not ci and _is_interactive()
214
+ console_formatter = (
215
+ _JsonFormatter() if serialize else _LineFormatter(with_time=colour, colour=colour, ascii_only=False)
216
+ )
217
+ _attach(package_logger, logging.StreamHandler(stream), console_formatter)
218
+
219
+ if log_file:
220
+ try:
221
+ Path(log_file).parent.mkdir(parents=True, exist_ok=True)
222
+ file_handler = logging.handlers.RotatingFileHandler(
223
+ log_file, maxBytes=_FILE_MAX_BYTES, backupCount=_FILE_BACKUPS, encoding="utf-8"
224
+ )
225
+ except OSError as exc:
226
+ # A bad log path should never take the process down.
227
+ logger.warning("Could not create log file %s: %s", log_file, exc)
228
+ else:
229
+ file_formatter = (
230
+ _JsonFormatter() if serialize else _LineFormatter(with_time=True, colour=False, ascii_only=True)
231
+ )
232
+ _attach(package_logger, file_handler, file_formatter)
233
+
234
+
235
+ def get_logger(name: str = PACKAGE_LOGGER_NAME) -> logging.Logger:
236
+ """Return the logger for a logreducer module name (``logreducer.<module>``)."""
237
+ return logging.getLogger(name)
@@ -1,17 +1,17 @@
1
1
  """Pattern extraction (Drain3 template mining) and fuzzy deduplication."""
2
2
 
3
+ import logging
3
4
  from collections.abc import Iterable, Iterator
4
5
  from dataclasses import dataclass, field
5
6
  from typing import TYPE_CHECKING
6
7
 
7
- from drain3 import TemplateMiner
8
- from drain3.masking import MaskingInstruction
9
- from drain3.template_miner_config import TemplateMinerConfig
10
- from loguru import logger
8
+ from .template_mining import MaskingInstruction, TemplateMiner, TemplateMinerConfig
11
9
 
12
10
  if TYPE_CHECKING:
13
11
  from .config import BigDialConfig
14
12
 
13
+ logger = logging.getLogger(__name__)
14
+
15
15
  # datasketch powers fuzzy dedup and ships in the optional `enhanced` extra;
16
16
  # without it FuzzyDeduplicator degrades to a pass-through (with a warning).
17
17
  try:
@@ -34,10 +34,10 @@ class LogPattern:
34
34
  metadata: dict = field(default_factory=dict)
35
35
 
36
36
 
37
- def _typed_masking_instructions() -> list[MaskingInstruction]:
37
+ def _typed_masking_instructions() -> tuple[MaskingInstruction, ...]:
38
38
  """Build the curated masking set for typed template slots.
39
39
 
40
- Drain3 applies these to each line before clustering, so masked values
40
+ The miner applies these to each line before clustering, so masked values
41
41
  become literal typed tokens (``<IP>``, ``<NUM>``, ...) rather than
42
42
  collapsing to the bare ``<*>`` wildcard. Instructions run sequentially
43
43
  over already-masked text, so the specific shapes (IP, UUID, MAC, IPv6)
@@ -70,52 +70,47 @@ def _typed_masking_instructions() -> list[MaskingInstruction]:
70
70
  # Integer (drain3's documented NUM example).
71
71
  (r"[-+]?\d+", "NUM"),
72
72
  ]
73
- return [MaskingInstruction(f"{start}(?:{pattern}){end}", name) for pattern, name in shapes]
73
+ return tuple(MaskingInstruction(f"{start}(?:{pattern}){end}", name) for pattern, name in shapes)
74
74
 
75
75
 
76
76
  class PatternExtractor:
77
- """Extract patterns using Drain3's online template miner."""
77
+ """Extract patterns using the online template miner (see template_mining)."""
78
78
 
79
79
  def __init__(self, config: "BigDialConfig") -> None:
80
80
  self.config = config
81
81
  self.setup_drain3()
82
82
 
83
83
  def setup_drain3(self) -> None:
84
- """Configure Drain3."""
85
- drain_config = TemplateMinerConfig()
86
- drain_config.profiling_enabled = False
87
- drain_config.drain_sim_th = self.config.drain_similarity
88
- drain_config.drain_depth = 4
89
- drain_config.snapshot_interval_minutes = 0 # Disable snapshots
90
- drain_config.snapshot_compress_state = False
91
- # Bound the template store when configured: Drain3 LRU-evicts beyond
92
- # drain_max_clusters, keeping memory flat on high-cardinality logs.
93
- drain_config.drain_max_clusters = self.config.max_clusters
84
+ """Configure the template miner."""
94
85
  # Opt-in typed masking: pre-cluster masking of well-known value shapes
95
86
  # so templates carry typed slots (<IP>, <NUM>, ...) instead of <*>.
96
- if self.config.typed_masking:
97
- drain_config.masking_instructions = _typed_masking_instructions()
98
- self.miner = TemplateMiner(config=drain_config)
87
+ masking = _typed_masking_instructions() if self.config.typed_masking else ()
88
+ drain_config = TemplateMinerConfig(
89
+ drain_sim_th=self.config.drain_similarity,
90
+ drain_depth=4,
91
+ # Bound the template store when configured: the miner LRU-evicts beyond
92
+ # drain_max_clusters, keeping memory flat on high-cardinality logs.
93
+ drain_max_clusters=self.config.max_clusters,
94
+ masking_instructions=masking,
95
+ )
96
+ self.miner = TemplateMiner(drain_config)
99
97
 
100
98
  def extract_patterns(self, lines: Iterable[str]) -> list[LogPattern]:
101
99
  """Extract patterns from lines (accepts a stream; only clusters are held).
102
100
 
103
- Feeds each line into Drain3's online miner, so the caller can pass a
101
+ Feeds each line into the online miner, so the caller can pass a
104
102
  generator - no need to materialise the whole line set. Memory is bounded
105
103
  by the number of clusters (see ``max_clusters``), not the line count.
106
104
  """
107
- pattern_map = {}
105
+ pattern_map: dict[int, LogPattern] = {}
108
106
 
109
107
  for line in lines:
110
- result = self.miner.add_log_message(line)
111
- cluster_id = result["cluster_id"]
112
-
113
- if cluster_id not in pattern_map:
114
- cluster = self.miner.drain.id_to_cluster[cluster_id]
108
+ cluster = self.miner.add_log_message(line)
109
+ pattern = pattern_map.get(cluster.cluster_id)
110
+ if pattern is None:
115
111
  pattern = LogPattern(template=cluster.get_template(), count=0)
116
- pattern_map[cluster_id] = pattern
112
+ pattern_map[cluster.cluster_id] = pattern
117
113
 
118
- pattern = pattern_map[cluster_id]
119
114
  pattern.count += 1
120
115
 
121
116
  if len(pattern.examples) < self.config.examples_per_pattern:
@@ -123,11 +118,10 @@ class PatternExtractor:
123
118
 
124
119
  # Later lines widen a cluster's slots to <*>, so the stored template is
125
120
  # re-read once the feed ends; an LRU-evicted cluster keeps its snapshot.
126
- clusters = self.miner.drain.id_to_cluster
127
121
  for cluster_id, pattern in pattern_map.items():
128
- cluster = clusters.get(cluster_id)
129
- if cluster is not None:
130
- pattern.template = cluster.get_template()
122
+ live_cluster = self.miner.get_cluster(cluster_id)
123
+ if live_cluster is not None:
124
+ pattern.template = live_cluster.get_template()
131
125
 
132
126
  patterns = list(pattern_map.values())
133
127
 
@@ -143,7 +143,7 @@ class SQLSource:
143
143
  from .sampling import build_table_sample_query
144
144
 
145
145
  owns = isinstance(connectable, str)
146
- engine = create_engine(connectable) if owns else connectable
146
+ engine = create_engine(connectable) if isinstance(connectable, str) else connectable
147
147
  preparer = engine.dialect.identifier_preparer
148
148
  query = build_table_sample_query(
149
149
  engine.dialect.name,