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.
- {logreducer-3.5.2 → logreducer-3.5.3}/PKG-INFO +15 -13
- {logreducer-3.5.2 → logreducer-3.5.3}/README.md +8 -4
- {logreducer-3.5.2 → logreducer-3.5.3}/pyproject.toml +17 -19
- {logreducer-3.5.2 → logreducer-3.5.3}/pyproject.toml.orig +17 -19
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/anomaly.py +3 -1
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/config.py +3 -1
- logreducer-3.5.3/src/logreducer/logging_config.py +237 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/patterns.py +28 -34
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sql.py +1 -1
- logreducer-3.5.3/src/logreducer/template_mining.py +453 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/temporal.py +9 -19
- logreducer-3.5.2/src/logreducer/logging_config.py +0 -194
- {logreducer-3.5.2 → logreducer-3.5.3}/LICENSE +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/NOTICE +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/__init__.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/cli.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/clickhouse.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/core.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/kafka.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/memory.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/py.typed +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sampling.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sinks.py +0 -0
- {logreducer-3.5.2 → logreducer-3.5.3}/src/logreducer/sources.py +0 -0
- {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.
|
|
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:
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist:
|
|
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.
|
|
30
|
-
Requires-Dist: sqlalchemy>=2.
|
|
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
|
|
341
|
-
# its records flow through YOUR handlers,
|
|
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
|
-
(
|
|
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
|
|
297
|
-
# its records flow through YOUR handlers,
|
|
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
|
-
(
|
|
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.
|
|
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
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
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.
|
|
60
|
-
clickhouse = ["clickhouse-connect>=1.0"]
|
|
61
|
-
kafka = ["confluent-kafka>=2.
|
|
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.
|
|
308
|
-
"clickhouse-connect>=1.0",
|
|
309
|
-
"confluent-kafka>=2.
|
|
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.
|
|
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
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
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.
|
|
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.
|
|
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.
|
|
340
|
-
"clickhouse-connect>=1.0",
|
|
341
|
-
"confluent-kafka>=2.
|
|
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,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
|
-
|
|
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
|
|
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() ->
|
|
37
|
+
def _typed_masking_instructions() -> tuple[MaskingInstruction, ...]:
|
|
38
38
|
"""Build the curated masking set for typed template slots.
|
|
39
39
|
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
129
|
-
if
|
|
130
|
-
pattern.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
|
|
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,
|