hannah-logging 0.1.0__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.
- hannah_logging-0.1.0/PKG-INFO +86 -0
- hannah_logging-0.1.0/README.md +75 -0
- hannah_logging-0.1.0/hannah_logging/__init__.py +133 -0
- hannah_logging-0.1.0/hannah_logging/buffer.py +84 -0
- hannah_logging-0.1.0/hannah_logging/handler.py +75 -0
- hannah_logging-0.1.0/hannah_logging/secrets.py +69 -0
- hannah_logging-0.1.0/hannah_logging/shipper.py +245 -0
- hannah_logging-0.1.0/hannah_logging.egg-info/PKG-INFO +86 -0
- hannah_logging-0.1.0/hannah_logging.egg-info/SOURCES.txt +16 -0
- hannah_logging-0.1.0/hannah_logging.egg-info/dependency_links.txt +1 -0
- hannah_logging-0.1.0/hannah_logging.egg-info/requires.txt +5 -0
- hannah_logging-0.1.0/hannah_logging.egg-info/top_level.txt +1 -0
- hannah_logging-0.1.0/pyproject.toml +22 -0
- hannah_logging-0.1.0/setup.cfg +4 -0
- hannah_logging-0.1.0/tests/test_buffer.py +42 -0
- hannah_logging-0.1.0/tests/test_handler.py +61 -0
- hannah_logging-0.1.0/tests/test_integration.py +177 -0
- hannah_logging-0.1.0/tests/test_secrets.py +59 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hannah-logging
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ships the logs of a Hannah component to the Hannah log collector
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: grpcio>=1.84.0
|
|
8
|
+
Requires-Dist: hannah-proto>=4.5.0
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
11
|
+
|
|
12
|
+
# hannah-logging (Python)
|
|
13
|
+
|
|
14
|
+
Ships the logs of a Hannah component to the Hannah log collector, so a user can download
|
|
15
|
+
one archive of all components' logs from the WebUI.
|
|
16
|
+
|
|
17
|
+
The library adds one `logging.Handler`. Your existing handlers (stdout, journald, syslog)
|
|
18
|
+
keep working unchanged.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install hannah-logging
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The library relies on the `hannah-proto` version the component already uses, because
|
|
27
|
+
every call to Hannah Core has to carry the matching protocol version.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import logging
|
|
33
|
+
import hannah_logging
|
|
34
|
+
|
|
35
|
+
logging.basicConfig(level=logging.INFO)
|
|
36
|
+
|
|
37
|
+
# As early as possible: everything logged from here on is buffered.
|
|
38
|
+
shipping = hannah_logging.install(
|
|
39
|
+
"telegram",
|
|
40
|
+
version=__version__,
|
|
41
|
+
secrets=[config.bot_token], # masked wherever they appear
|
|
42
|
+
logger_categories={"hannah.stt": hannah_logging.TRANSCRIPT},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Once the config is loaded:
|
|
46
|
+
shipping.connect(
|
|
47
|
+
hannah_address="localhost:50051", # discovery via Hannah Core
|
|
48
|
+
collector_address=None, # optional static fallback
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Categories
|
|
53
|
+
|
|
54
|
+
Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
|
|
55
|
+
`METADATA` (room and device names, presence). Exports can leave out the last two, and
|
|
56
|
+
the WebUI does so by default.
|
|
57
|
+
|
|
58
|
+
- By logger name: `logger_categories={"hannah.stt": TRANSCRIPT}` also covers child
|
|
59
|
+
loggers such as `hannah.stt.whisper`.
|
|
60
|
+
- Per call: `log.info("heard: %s", text, extra={hannah_logging.CATEGORY_ATTR: hannah_logging.TRANSCRIPT})`
|
|
61
|
+
|
|
62
|
+
### Secrets
|
|
63
|
+
|
|
64
|
+
Before a line leaves the process, the library masks:
|
|
65
|
+
|
|
66
|
+
- values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
|
|
67
|
+
- `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
|
|
68
|
+
- Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
|
|
69
|
+
- every value passed as `secrets=[…]` or later through `shipping.add_secret(…)`
|
|
70
|
+
|
|
71
|
+
Extra regexes go in `secret_patterns=[…]`. The component's own handlers still see the
|
|
72
|
+
unmasked line.
|
|
73
|
+
|
|
74
|
+
## Behaviour
|
|
75
|
+
|
|
76
|
+
- **Buffer:** 4 MiB by default (`max_buffer_bytes`). When it is full, the oldest lines are
|
|
77
|
+
dropped and reported to the collector as a gap. While no collector is known, the
|
|
78
|
+
buffer simply keeps running as a ring.
|
|
79
|
+
- **Timestamps** are taken when a line is logged, not when it is sent.
|
|
80
|
+
- **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
|
|
81
|
+
log collector when it moves. If Core is unreachable, the last known collector is kept.
|
|
82
|
+
The static `collector_address` is used while none is announced.
|
|
83
|
+
- **Never blocks the component:** sending runs on its own threads. Connection problems
|
|
84
|
+
are logged once to the `hannah_logging` logger (not shipped) and retried with backoff.
|
|
85
|
+
- **Shutdown:** at exit, the library tries for up to 2 seconds to send what is still
|
|
86
|
+
buffered (`shipping.close(timeout=…)`).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# hannah-logging (Python)
|
|
2
|
+
|
|
3
|
+
Ships the logs of a Hannah component to the Hannah log collector, so a user can download
|
|
4
|
+
one archive of all components' logs from the WebUI.
|
|
5
|
+
|
|
6
|
+
The library adds one `logging.Handler`. Your existing handlers (stdout, journald, syslog)
|
|
7
|
+
keep working unchanged.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install hannah-logging
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The library relies on the `hannah-proto` version the component already uses, because
|
|
16
|
+
every call to Hannah Core has to carry the matching protocol version.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import logging
|
|
22
|
+
import hannah_logging
|
|
23
|
+
|
|
24
|
+
logging.basicConfig(level=logging.INFO)
|
|
25
|
+
|
|
26
|
+
# As early as possible: everything logged from here on is buffered.
|
|
27
|
+
shipping = hannah_logging.install(
|
|
28
|
+
"telegram",
|
|
29
|
+
version=__version__,
|
|
30
|
+
secrets=[config.bot_token], # masked wherever they appear
|
|
31
|
+
logger_categories={"hannah.stt": hannah_logging.TRANSCRIPT},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# Once the config is loaded:
|
|
35
|
+
shipping.connect(
|
|
36
|
+
hannah_address="localhost:50051", # discovery via Hannah Core
|
|
37
|
+
collector_address=None, # optional static fallback
|
|
38
|
+
)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Categories
|
|
42
|
+
|
|
43
|
+
Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
|
|
44
|
+
`METADATA` (room and device names, presence). Exports can leave out the last two, and
|
|
45
|
+
the WebUI does so by default.
|
|
46
|
+
|
|
47
|
+
- By logger name: `logger_categories={"hannah.stt": TRANSCRIPT}` also covers child
|
|
48
|
+
loggers such as `hannah.stt.whisper`.
|
|
49
|
+
- Per call: `log.info("heard: %s", text, extra={hannah_logging.CATEGORY_ATTR: hannah_logging.TRANSCRIPT})`
|
|
50
|
+
|
|
51
|
+
### Secrets
|
|
52
|
+
|
|
53
|
+
Before a line leaves the process, the library masks:
|
|
54
|
+
|
|
55
|
+
- values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
|
|
56
|
+
- `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
|
|
57
|
+
- Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
|
|
58
|
+
- every value passed as `secrets=[…]` or later through `shipping.add_secret(…)`
|
|
59
|
+
|
|
60
|
+
Extra regexes go in `secret_patterns=[…]`. The component's own handlers still see the
|
|
61
|
+
unmasked line.
|
|
62
|
+
|
|
63
|
+
## Behaviour
|
|
64
|
+
|
|
65
|
+
- **Buffer:** 4 MiB by default (`max_buffer_bytes`). When it is full, the oldest lines are
|
|
66
|
+
dropped and reported to the collector as a gap. While no collector is known, the
|
|
67
|
+
buffer simply keeps running as a ring.
|
|
68
|
+
- **Timestamps** are taken when a line is logged, not when it is sent.
|
|
69
|
+
- **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
|
|
70
|
+
log collector when it moves. If Core is unreachable, the last known collector is kept.
|
|
71
|
+
The static `collector_address` is used while none is announced.
|
|
72
|
+
- **Never blocks the component:** sending runs on its own threads. Connection problems
|
|
73
|
+
are logged once to the `hannah_logging` logger (not shipped) and retried with backoff.
|
|
74
|
+
- **Shutdown:** at exit, the library tries for up to 2 seconds to send what is still
|
|
75
|
+
buffered (`shipping.close(timeout=…)`).
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Ships the logs of a Hannah component to the Hannah log collector.
|
|
2
|
+
|
|
3
|
+
import hannah_logging
|
|
4
|
+
|
|
5
|
+
shipping = hannah_logging.install("core", version=__version__) # as early as possible
|
|
6
|
+
...
|
|
7
|
+
shipping.connect(hannah_address="localhost:50051") # once the config is loaded
|
|
8
|
+
|
|
9
|
+
Everything logged from `install` on is buffered in memory (bounded), and sent to the
|
|
10
|
+
collector as soon as one is known — announced by Hannah Core, or a static address.
|
|
11
|
+
The host's own handlers (stdout, journald, syslog) are not touched.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import atexit
|
|
16
|
+
import logging
|
|
17
|
+
import socket
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Iterable, Mapping, Optional
|
|
20
|
+
|
|
21
|
+
from hannah_proto import logging_pb2
|
|
22
|
+
|
|
23
|
+
from .buffer import LogBuffer
|
|
24
|
+
from .handler import CATEGORY_ATTR, GENERAL, METADATA, TRANSCRIPT, CollectorHandler
|
|
25
|
+
from .secrets import SecretFilter
|
|
26
|
+
from .shipper import Discovery, Shipper
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"install", "LogShipping", "CollectorHandler", "SecretFilter",
|
|
30
|
+
"GENERAL", "TRANSCRIPT", "METADATA", "CATEGORY_ATTR",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
DEFAULT_MAX_BUFFER_BYTES = 4 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LogShipping:
|
|
37
|
+
"""Handle returned by `install`. Owns the handler and the background threads."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
component: str,
|
|
42
|
+
version: str,
|
|
43
|
+
instance: str,
|
|
44
|
+
handler: CollectorHandler,
|
|
45
|
+
buffer: LogBuffer,
|
|
46
|
+
secret_filter: SecretFilter,
|
|
47
|
+
target_logger: logging.Logger,
|
|
48
|
+
):
|
|
49
|
+
self.handler = handler
|
|
50
|
+
self._hello = logging_pb2.ShipHello(component=component, instance=instance, version=version)
|
|
51
|
+
self._buffer = buffer
|
|
52
|
+
self._secret_filter = secret_filter
|
|
53
|
+
self._target_logger = target_logger
|
|
54
|
+
self._shipper: Optional[Shipper] = None
|
|
55
|
+
self._discovery: Optional[Discovery] = None
|
|
56
|
+
self._lock = threading.Lock()
|
|
57
|
+
|
|
58
|
+
def connect(self, hannah_address: Optional[str] = None, collector_address: Optional[str] = None) -> None:
|
|
59
|
+
"""Starts shipping. `hannah_address` enables discovery via Hannah Core;
|
|
60
|
+
`collector_address` is the fallback while none is announced. Only the
|
|
61
|
+
first call has an effect."""
|
|
62
|
+
with self._lock:
|
|
63
|
+
if self._shipper is not None:
|
|
64
|
+
return
|
|
65
|
+
self._shipper = Shipper(self._buffer, self._hello, collector_address or None)
|
|
66
|
+
self._shipper.start()
|
|
67
|
+
if hannah_address:
|
|
68
|
+
self._discovery = Discovery(hannah_address, self._shipper.set_discovered)
|
|
69
|
+
self._discovery.start()
|
|
70
|
+
|
|
71
|
+
def add_secret(self, secret: str) -> None:
|
|
72
|
+
"""Masks this literal value in everything logged from now on (e.g. a token
|
|
73
|
+
that only becomes known after `install`)."""
|
|
74
|
+
self._secret_filter.add_literal(secret)
|
|
75
|
+
|
|
76
|
+
def close(self, timeout: float = 2.0) -> None:
|
|
77
|
+
"""Detaches the handler and gives the shipper up to `timeout` seconds to
|
|
78
|
+
send what is still buffered."""
|
|
79
|
+
self._target_logger.removeHandler(self.handler)
|
|
80
|
+
with self._lock:
|
|
81
|
+
discovery, shipper = self._discovery, self._shipper
|
|
82
|
+
self._discovery = self._shipper = None
|
|
83
|
+
if discovery is not None:
|
|
84
|
+
discovery.stop()
|
|
85
|
+
if shipper is not None:
|
|
86
|
+
shipper.stop()
|
|
87
|
+
shipper.join(timeout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def install(
|
|
91
|
+
component: str,
|
|
92
|
+
*,
|
|
93
|
+
version: str = "",
|
|
94
|
+
instance: Optional[str] = None,
|
|
95
|
+
hannah_address: Optional[str] = None,
|
|
96
|
+
collector_address: Optional[str] = None,
|
|
97
|
+
secrets: Iterable[str] = (),
|
|
98
|
+
secret_patterns: Iterable[str] = (),
|
|
99
|
+
logger_categories: Optional[Mapping[str, int]] = None,
|
|
100
|
+
level: int = logging.NOTSET,
|
|
101
|
+
max_buffer_bytes: int = DEFAULT_MAX_BUFFER_BYTES,
|
|
102
|
+
logger: Optional[logging.Logger] = None,
|
|
103
|
+
) -> LogShipping:
|
|
104
|
+
"""Attaches a CollectorHandler to `logger` (default: the root logger) and starts
|
|
105
|
+
buffering. Pass `hannah_address`/`collector_address` here or later via
|
|
106
|
+
`LogShipping.connect`.
|
|
107
|
+
|
|
108
|
+
component e.g. "core", "telegram" — the name shown in the export
|
|
109
|
+
instance distinguishes several instances; defaults to the hostname
|
|
110
|
+
secrets literal values to mask (the component's own tokens/passwords)
|
|
111
|
+
secret_patterns extra regexes to mask, on top of the built-in ones
|
|
112
|
+
logger_categories logger name prefix -> category, e.g. {"hannah.stt": TRANSCRIPT};
|
|
113
|
+
a single call can override it with extra={CATEGORY_ATTR: ...}
|
|
114
|
+
"""
|
|
115
|
+
buffer = LogBuffer(max_buffer_bytes)
|
|
116
|
+
secret_filter = SecretFilter(secrets, secret_patterns)
|
|
117
|
+
handler = CollectorHandler(buffer, secret_filter, logger_categories, level)
|
|
118
|
+
target_logger = logger or logging.getLogger()
|
|
119
|
+
target_logger.addHandler(handler)
|
|
120
|
+
|
|
121
|
+
shipping = LogShipping(
|
|
122
|
+
component=component,
|
|
123
|
+
version=version,
|
|
124
|
+
instance=instance or socket.gethostname(),
|
|
125
|
+
handler=handler,
|
|
126
|
+
buffer=buffer,
|
|
127
|
+
secret_filter=secret_filter,
|
|
128
|
+
target_logger=target_logger,
|
|
129
|
+
)
|
|
130
|
+
atexit.register(shipping.close)
|
|
131
|
+
if hannah_address or collector_address:
|
|
132
|
+
shipping.connect(hannah_address, collector_address)
|
|
133
|
+
return shipping
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Bounded in-memory buffer between the logging handler and the shipper thread."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import collections
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Deque, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
# Rough per-entry overhead on top of message + logger name, so a flood of empty
|
|
10
|
+
# messages still counts against the limit.
|
|
11
|
+
_ENTRY_OVERHEAD = 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Entry:
|
|
16
|
+
timestamp_ms: int
|
|
17
|
+
level: int # hannah_proto LogLevel value
|
|
18
|
+
logger: str
|
|
19
|
+
message: str
|
|
20
|
+
category: int # hannah_proto LogCategory value
|
|
21
|
+
|
|
22
|
+
def size(self) -> int:
|
|
23
|
+
return len(self.message) + len(self.logger) + _ENTRY_OVERHEAD
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Gap:
|
|
28
|
+
dropped: int
|
|
29
|
+
from_ms: int
|
|
30
|
+
to_ms: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LogBuffer:
|
|
34
|
+
"""Ring buffer limited by size. When full, the oldest entries are dropped and
|
|
35
|
+
recorded as a gap, which is shipped before the remaining entries.
|
|
36
|
+
|
|
37
|
+
`put` never blocks the logging thread beyond a short lock."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, max_bytes: int):
|
|
40
|
+
self._max_bytes = max_bytes
|
|
41
|
+
self._entries: Deque[Entry] = collections.deque()
|
|
42
|
+
self._bytes = 0
|
|
43
|
+
self._gap: Optional[Gap] = None
|
|
44
|
+
self._cond = threading.Condition()
|
|
45
|
+
|
|
46
|
+
def put(self, entry: Entry) -> None:
|
|
47
|
+
with self._cond:
|
|
48
|
+
self._entries.append(entry)
|
|
49
|
+
self._bytes += entry.size()
|
|
50
|
+
while self._bytes > self._max_bytes and self._entries:
|
|
51
|
+
self._drop_oldest()
|
|
52
|
+
self._cond.notify()
|
|
53
|
+
|
|
54
|
+
def _drop_oldest(self) -> None:
|
|
55
|
+
old = self._entries.popleft()
|
|
56
|
+
self._bytes -= old.size()
|
|
57
|
+
if self._gap is None:
|
|
58
|
+
self._gap = Gap(dropped=1, from_ms=old.timestamp_ms, to_ms=old.timestamp_ms)
|
|
59
|
+
else:
|
|
60
|
+
self._gap.dropped += 1
|
|
61
|
+
self._gap.to_ms = max(self._gap.to_ms, old.timestamp_ms)
|
|
62
|
+
|
|
63
|
+
def take(self, max_items: int, timeout: Optional[float]) -> Tuple[Optional[Gap], List[Entry]]:
|
|
64
|
+
"""Waits up to `timeout` for data, then removes and returns the pending gap
|
|
65
|
+
(if any) and up to `max_items` entries, oldest first."""
|
|
66
|
+
with self._cond:
|
|
67
|
+
if not self._entries and self._gap is None:
|
|
68
|
+
self._cond.wait(timeout)
|
|
69
|
+
gap, self._gap = self._gap, None
|
|
70
|
+
items: List[Entry] = []
|
|
71
|
+
while self._entries and len(items) < max_items:
|
|
72
|
+
entry = self._entries.popleft()
|
|
73
|
+
self._bytes -= entry.size()
|
|
74
|
+
items.append(entry)
|
|
75
|
+
return gap, items
|
|
76
|
+
|
|
77
|
+
def wake(self) -> None:
|
|
78
|
+
"""Wakes a waiting `take`, e.g. to react to a new collector address or shutdown."""
|
|
79
|
+
with self._cond:
|
|
80
|
+
self._cond.notify_all()
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
with self._cond:
|
|
84
|
+
return len(self._entries)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""logging.Handler that feeds the buffer. Runs in addition to the host's own handlers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Mapping, Optional
|
|
6
|
+
|
|
7
|
+
from hannah_proto import logging_pb2
|
|
8
|
+
|
|
9
|
+
from .buffer import Entry, LogBuffer
|
|
10
|
+
from .secrets import SecretFilter
|
|
11
|
+
from .shipper import OWN_THREADS
|
|
12
|
+
|
|
13
|
+
GENERAL = logging_pb2.LOG_CATEGORY_GENERAL
|
|
14
|
+
TRANSCRIPT = logging_pb2.LOG_CATEGORY_TRANSCRIPT
|
|
15
|
+
METADATA = logging_pb2.LOG_CATEGORY_METADATA
|
|
16
|
+
|
|
17
|
+
# Name of the `extra=` key that tags a single log call, e.g.
|
|
18
|
+
# log.info("heard: %s", text, extra={CATEGORY_ATTR: TRANSCRIPT})
|
|
19
|
+
CATEGORY_ATTR = "log_category"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_log_level(levelno: int) -> int:
|
|
23
|
+
if levelno >= logging.CRITICAL:
|
|
24
|
+
return logging_pb2.LOG_LEVEL_CRITICAL
|
|
25
|
+
if levelno >= logging.ERROR:
|
|
26
|
+
return logging_pb2.LOG_LEVEL_ERROR
|
|
27
|
+
if levelno >= logging.WARNING:
|
|
28
|
+
return logging_pb2.LOG_LEVEL_WARNING
|
|
29
|
+
if levelno >= logging.INFO:
|
|
30
|
+
return logging_pb2.LOG_LEVEL_INFO
|
|
31
|
+
return logging_pb2.LOG_LEVEL_DEBUG
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CollectorHandler(logging.Handler):
|
|
35
|
+
"""Formats, filters and buffers each record. The timestamp is the record's
|
|
36
|
+
creation time, so entries buffered before the collector was reachable keep
|
|
37
|
+
their original time."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
buffer: LogBuffer,
|
|
42
|
+
secret_filter: SecretFilter,
|
|
43
|
+
logger_categories: Optional[Mapping[str, int]] = None,
|
|
44
|
+
level: int = logging.NOTSET,
|
|
45
|
+
):
|
|
46
|
+
super().__init__(level)
|
|
47
|
+
self._buffer = buffer
|
|
48
|
+
self._secret_filter = secret_filter
|
|
49
|
+
# Longest prefix first, so "hannah.stt.transcript" wins over "hannah.stt".
|
|
50
|
+
self._logger_categories = sorted((logger_categories or {}).items(), key=lambda kv: len(kv[0]), reverse=True)
|
|
51
|
+
self.setFormatter(logging.Formatter("%(message)s"))
|
|
52
|
+
|
|
53
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
54
|
+
if record.thread in OWN_THREADS:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
message = self._secret_filter(self.format(record))
|
|
58
|
+
self._buffer.put(Entry(
|
|
59
|
+
timestamp_ms=int(record.created * 1000),
|
|
60
|
+
level=to_log_level(record.levelno),
|
|
61
|
+
logger=record.name,
|
|
62
|
+
message=message,
|
|
63
|
+
category=self._category(record),
|
|
64
|
+
))
|
|
65
|
+
except Exception:
|
|
66
|
+
self.handleError(record)
|
|
67
|
+
|
|
68
|
+
def _category(self, record: logging.LogRecord) -> int:
|
|
69
|
+
explicit = getattr(record, CATEGORY_ATTR, None)
|
|
70
|
+
if explicit:
|
|
71
|
+
return int(explicit)
|
|
72
|
+
for prefix, category in self._logger_categories:
|
|
73
|
+
if record.name == prefix or record.name.startswith(prefix + "."):
|
|
74
|
+
return category
|
|
75
|
+
return GENERAL
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Masks secrets in a log message before it leaves the process."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Iterable, List, Pattern, Tuple
|
|
6
|
+
|
|
7
|
+
MASK = "***"
|
|
8
|
+
|
|
9
|
+
# Key names whose value is treated as secret in "key=value", "key: value" and JSON-ish "key": "value".
|
|
10
|
+
_SECRET_KEYS = (
|
|
11
|
+
r"password|passwd|pwd|passphrase|secret|client[_-]?secret|token|access[_-]?token|refresh[_-]?token"
|
|
12
|
+
r"|api[_-]?key|apikey|access[_-]?key|private[_-]?key|psk|auth|authorization|credentials?"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_BUILTIN_PATTERNS: List[Tuple[Pattern[str], str]] = [
|
|
16
|
+
# PEM blocks (private keys, certificates with keys).
|
|
17
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), MASK),
|
|
18
|
+
# "Authorization: Bearer abc", "Bearer abc"
|
|
19
|
+
(re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+"), r"\1 " + MASK),
|
|
20
|
+
# key=value / key: value / "key": "value" — the key has to *end* in a secret word,
|
|
21
|
+
# so "bot_token=…" is masked but "tokens=512" or "token_count=3" is not.
|
|
22
|
+
(
|
|
23
|
+
re.compile(
|
|
24
|
+
r"""(?ix)
|
|
25
|
+
(?P<key>["']?[\w.-]*(?:""" + _SECRET_KEYS + r""")["']?)
|
|
26
|
+
(?P<sep>\s*[:=]\s*)
|
|
27
|
+
(?P<value>"[^"]*"|'[^']*'|[^\s,;&)}\]]+)
|
|
28
|
+
"""
|
|
29
|
+
),
|
|
30
|
+
lambda m: m.group("key") + m.group("sep") + _quoted_mask(m.group("value")),
|
|
31
|
+
),
|
|
32
|
+
# Credentials in URLs: scheme://user:password@host
|
|
33
|
+
(re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://[^/\s:@]+):[^/\s@]+@"), r"\1:" + MASK + "@"),
|
|
34
|
+
# JWTs
|
|
35
|
+
(re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), MASK),
|
|
36
|
+
# Telegram bot tokens
|
|
37
|
+
(re.compile(r"\b\d{8,10}:[A-Za-z0-9_-]{35}\b"), MASK),
|
|
38
|
+
# Well-known token prefixes (GitLab, GitHub, npm, PyPI, Slack, OpenAI-style)
|
|
39
|
+
(re.compile(r"\b(?:glpat|gldt|glrt|ghp|gho|ghu|ghs|ghr|github_pat|npm|pypi|xox[abpr]|sk)[-_][A-Za-z0-9_-]{16,}"), MASK),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _quoted_mask(value: str) -> str:
|
|
44
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
45
|
+
return value[0] + MASK + value[0]
|
|
46
|
+
return MASK
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SecretFilter:
|
|
50
|
+
"""Masks well-known secret shapes plus any literal values the component knows are secret
|
|
51
|
+
(its own tokens and passwords from its config — the most reliable way to catch them)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, literals: Iterable[str] = (), patterns: Iterable[str] = ()):
|
|
54
|
+
# Longest first, so a secret that contains another one is masked as a whole.
|
|
55
|
+
self._literals = sorted({s for s in literals if s and len(s) >= 4}, key=len, reverse=True)
|
|
56
|
+
self._patterns = list(_BUILTIN_PATTERNS) + [(re.compile(p), MASK) for p in patterns]
|
|
57
|
+
|
|
58
|
+
def add_literal(self, secret: str) -> None:
|
|
59
|
+
if secret and len(secret) >= 4 and secret not in self._literals:
|
|
60
|
+
self._literals.append(secret)
|
|
61
|
+
self._literals.sort(key=len, reverse=True)
|
|
62
|
+
|
|
63
|
+
def __call__(self, message: str) -> str:
|
|
64
|
+
for literal in self._literals:
|
|
65
|
+
if literal in message:
|
|
66
|
+
message = message.replace(literal, MASK)
|
|
67
|
+
for pattern, replacement in self._patterns:
|
|
68
|
+
message = pattern.sub(replacement, message)
|
|
69
|
+
return message
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Background threads: collector discovery via Hannah Core, and shipping to the collector.
|
|
2
|
+
|
|
3
|
+
Both run on their own threads with a synchronous gRPC channel, so they never touch the
|
|
4
|
+
host's event loop and never block its logging calls. Errors are swallowed and reported
|
|
5
|
+
once per state change on the `hannah_logging` logger — records from these threads are
|
|
6
|
+
not shipped (see CollectorHandler), so they only reach the host's other handlers.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Callable, Dict, Iterator, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
import grpc
|
|
15
|
+
from hannah_proto import PROTO_VERSION, hannah_pb2, hannah_pb2_grpc, infrastructure_pb2
|
|
16
|
+
from hannah_proto import logging_pb2, logging_pb2_grpc
|
|
17
|
+
from hannah_proto.interceptor.compat_interceptor import client_compat_version_metadata
|
|
18
|
+
|
|
19
|
+
from .buffer import LogBuffer
|
|
20
|
+
|
|
21
|
+
_log = logging.getLogger("hannah_logging")
|
|
22
|
+
|
|
23
|
+
PROTO_VERSION_METADATA_KEY = "x-proto-version"
|
|
24
|
+
MAX_BACKOFF_S = 30.0
|
|
25
|
+
CONNECT_TIMEOUT_S = 5.0
|
|
26
|
+
BATCH_SIZE = 200
|
|
27
|
+
|
|
28
|
+
# Idents of this library's threads — their log records are never shipped, so a failure
|
|
29
|
+
# while shipping can't feed itself back into the buffer.
|
|
30
|
+
OWN_THREADS: Set[int] = set()
|
|
31
|
+
|
|
32
|
+
_HANNAH_SERVICE = hannah_pb2.DESCRIPTOR.services_by_name["HannahService"]
|
|
33
|
+
_LOG_SERVICE = logging_pb2.DESCRIPTOR.services_by_name["LogService"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _metadata(service, method: str) -> Tuple[Tuple[str, str], ...]:
|
|
37
|
+
return (
|
|
38
|
+
(PROTO_VERSION_METADATA_KEY, str(PROTO_VERSION)),
|
|
39
|
+
client_compat_version_metadata(service, method),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def format_address(host: str, port: int) -> str:
|
|
44
|
+
if ":" in host and not host.startswith("["):
|
|
45
|
+
host = f"[{host}]" # IPv6
|
|
46
|
+
return f"{host}:{port}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _Worker(threading.Thread):
|
|
50
|
+
def __init__(self, name: str):
|
|
51
|
+
super().__init__(name=name, daemon=True)
|
|
52
|
+
self._stop_event = threading.Event()
|
|
53
|
+
|
|
54
|
+
def run(self) -> None:
|
|
55
|
+
OWN_THREADS.add(threading.get_ident())
|
|
56
|
+
try:
|
|
57
|
+
self._run()
|
|
58
|
+
finally:
|
|
59
|
+
OWN_THREADS.discard(threading.get_ident())
|
|
60
|
+
|
|
61
|
+
def _run(self) -> None:
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def stopping(self) -> bool:
|
|
66
|
+
return self._stop_event.is_set()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Discovery(_Worker):
|
|
70
|
+
"""Subscribes to Hannah Core's infrastructure broadcast and reports the current
|
|
71
|
+
log collector address (or None) to `on_change`.
|
|
72
|
+
|
|
73
|
+
If Core becomes unreachable, the last known collector is kept — the collector
|
|
74
|
+
doesn't depend on Core, so logs keep flowing. The snapshot after a reconnect
|
|
75
|
+
replaces the known state completely."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, hannah_address: str, on_change: Callable[[Optional[str]], None]):
|
|
78
|
+
super().__init__("hannah-logging-discovery")
|
|
79
|
+
self._hannah_address = hannah_address
|
|
80
|
+
self._on_change = on_change
|
|
81
|
+
self._collectors: Dict[str, str] = {} # instance -> address
|
|
82
|
+
self._call = None
|
|
83
|
+
self._lock = threading.Lock()
|
|
84
|
+
|
|
85
|
+
def stop(self) -> None:
|
|
86
|
+
self._stop_event.set()
|
|
87
|
+
with self._lock:
|
|
88
|
+
if self._call is not None:
|
|
89
|
+
self._call.cancel()
|
|
90
|
+
|
|
91
|
+
def _run(self) -> None:
|
|
92
|
+
backoff = 1.0
|
|
93
|
+
reported_failure = False
|
|
94
|
+
while not self.stopping:
|
|
95
|
+
channel = grpc.insecure_channel(self._hannah_address)
|
|
96
|
+
try:
|
|
97
|
+
stub = hannah_pb2_grpc.HannahServiceStub(channel)
|
|
98
|
+
call = stub.SubscribeInfrastructure(
|
|
99
|
+
infrastructure_pb2.InfrastructureFilter(kinds=[infrastructure_pb2.SERVICE_KIND_LOG_COLLECTOR]),
|
|
100
|
+
metadata=_metadata(_HANNAH_SERVICE, "SubscribeInfrastructure"),
|
|
101
|
+
)
|
|
102
|
+
with self._lock:
|
|
103
|
+
self._call = call
|
|
104
|
+
if self.stopping:
|
|
105
|
+
call.cancel()
|
|
106
|
+
for message in call:
|
|
107
|
+
if reported_failure:
|
|
108
|
+
_log.info("Log collector discovery: connected to Hannah at %s", self._hannah_address)
|
|
109
|
+
reported_failure = False
|
|
110
|
+
backoff = 1.0
|
|
111
|
+
self._handle(message)
|
|
112
|
+
except grpc.RpcError as exc:
|
|
113
|
+
if not self.stopping and not reported_failure:
|
|
114
|
+
_log.warning("Log collector discovery: Hannah at %s unreachable (%s), retrying",
|
|
115
|
+
self._hannah_address, _rpc_error_text(exc))
|
|
116
|
+
reported_failure = True
|
|
117
|
+
except Exception as exc: # never let the thread die
|
|
118
|
+
if not self.stopping:
|
|
119
|
+
_log.warning("Log collector discovery failed: %r", exc)
|
|
120
|
+
reported_failure = True
|
|
121
|
+
finally:
|
|
122
|
+
with self._lock:
|
|
123
|
+
self._call = None
|
|
124
|
+
channel.close()
|
|
125
|
+
self._stop_event.wait(backoff)
|
|
126
|
+
backoff = min(backoff * 2, MAX_BACKOFF_S)
|
|
127
|
+
|
|
128
|
+
def _handle(self, message: "infrastructure_pb2.InfrastructureMessage") -> None:
|
|
129
|
+
which = message.WhichOneof("payload")
|
|
130
|
+
if which == "snapshot":
|
|
131
|
+
self._collectors = {
|
|
132
|
+
s.instance: format_address(s.host, s.port)
|
|
133
|
+
for s in message.snapshot.services
|
|
134
|
+
if s.kind == infrastructure_pb2.SERVICE_KIND_LOG_COLLECTOR
|
|
135
|
+
}
|
|
136
|
+
elif which == "available":
|
|
137
|
+
s = message.available.service
|
|
138
|
+
if s.kind != infrastructure_pb2.SERVICE_KIND_LOG_COLLECTOR:
|
|
139
|
+
return
|
|
140
|
+
self._collectors[s.instance] = format_address(s.host, s.port)
|
|
141
|
+
elif which == "unavailable":
|
|
142
|
+
if message.unavailable.kind != infrastructure_pb2.SERVICE_KIND_LOG_COLLECTOR:
|
|
143
|
+
return
|
|
144
|
+
self._collectors.pop(message.unavailable.instance, None)
|
|
145
|
+
else:
|
|
146
|
+
return
|
|
147
|
+
# Several collectors are unusual; pick one deterministically.
|
|
148
|
+
current = self._collectors[min(self._collectors)] if self._collectors else None
|
|
149
|
+
self._on_change(current)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Shipper(_Worker):
|
|
153
|
+
"""Streams the buffer to the collector over LogService.Ship.
|
|
154
|
+
|
|
155
|
+
Only opens the stream once the channel is ready, so nothing is taken out of the
|
|
156
|
+
buffer while the collector is unreachable. Ends the stream when the collector
|
|
157
|
+
address changes and reconnects to the new one."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, buffer: LogBuffer, hello: "logging_pb2.ShipHello", static_address: Optional[str]):
|
|
160
|
+
super().__init__("hannah-logging-shipper")
|
|
161
|
+
self._buffer = buffer
|
|
162
|
+
self._hello = hello
|
|
163
|
+
self._static_address = static_address
|
|
164
|
+
self._discovered: Optional[str] = None
|
|
165
|
+
self._lock = threading.Lock()
|
|
166
|
+
|
|
167
|
+
def set_discovered(self, address: Optional[str]) -> None:
|
|
168
|
+
with self._lock:
|
|
169
|
+
changed = address != self._discovered
|
|
170
|
+
self._discovered = address
|
|
171
|
+
if changed:
|
|
172
|
+
if address:
|
|
173
|
+
_log.info("Log collector discovered at %s", address)
|
|
174
|
+
self._buffer.wake()
|
|
175
|
+
|
|
176
|
+
def target(self) -> Optional[str]:
|
|
177
|
+
with self._lock:
|
|
178
|
+
return self._discovered or self._static_address
|
|
179
|
+
|
|
180
|
+
def stop(self) -> None:
|
|
181
|
+
self._stop_event.set()
|
|
182
|
+
self._buffer.wake()
|
|
183
|
+
|
|
184
|
+
def _run(self) -> None:
|
|
185
|
+
backoff = 1.0
|
|
186
|
+
reported_failure = False
|
|
187
|
+
while not self.stopping:
|
|
188
|
+
target = self.target()
|
|
189
|
+
if not target:
|
|
190
|
+
# No collector known: the buffer keeps running as a ring, nothing to do.
|
|
191
|
+
self._stop_event.wait(1.0)
|
|
192
|
+
continue
|
|
193
|
+
channel = grpc.insecure_channel(target)
|
|
194
|
+
try:
|
|
195
|
+
grpc.channel_ready_future(channel).result(timeout=CONNECT_TIMEOUT_S)
|
|
196
|
+
if reported_failure:
|
|
197
|
+
_log.info("Log collector at %s reachable again", target)
|
|
198
|
+
reported_failure = False
|
|
199
|
+
logging_pb2_grpc.LogServiceStub(channel).Ship(
|
|
200
|
+
self._messages(target),
|
|
201
|
+
metadata=_metadata(_LOG_SERVICE, "Ship"),
|
|
202
|
+
)
|
|
203
|
+
backoff = 1.0
|
|
204
|
+
continue
|
|
205
|
+
except grpc.FutureTimeoutError:
|
|
206
|
+
failure = "not reachable"
|
|
207
|
+
except grpc.RpcError as exc:
|
|
208
|
+
failure = _rpc_error_text(exc)
|
|
209
|
+
except Exception as exc: # never let the thread die
|
|
210
|
+
failure = repr(exc)
|
|
211
|
+
finally:
|
|
212
|
+
channel.close()
|
|
213
|
+
if not self.stopping and not reported_failure:
|
|
214
|
+
_log.warning("Log collector at %s: %s — buffering, retrying", target, failure)
|
|
215
|
+
reported_failure = True
|
|
216
|
+
self._stop_event.wait(backoff)
|
|
217
|
+
backoff = min(backoff * 2, MAX_BACKOFF_S)
|
|
218
|
+
|
|
219
|
+
def _messages(self, target: str) -> Iterator["logging_pb2.ShipMessage"]:
|
|
220
|
+
yield logging_pb2.ShipMessage(hello=self._hello)
|
|
221
|
+
while True:
|
|
222
|
+
gap, entries = self._buffer.take(BATCH_SIZE, timeout=1.0)
|
|
223
|
+
if gap is not None:
|
|
224
|
+
yield logging_pb2.ShipMessage(gap=logging_pb2.LogGap(
|
|
225
|
+
dropped=gap.dropped, from_ms=gap.from_ms, to_ms=gap.to_ms,
|
|
226
|
+
))
|
|
227
|
+
for e in entries:
|
|
228
|
+
yield logging_pb2.ShipMessage(entry=logging_pb2.LogEntry(
|
|
229
|
+
timestamp_ms=e.timestamp_ms, level=e.level, logger=e.logger,
|
|
230
|
+
message=e.message, category=e.category,
|
|
231
|
+
))
|
|
232
|
+
# Collector moved: what's left in the buffer goes to the new one.
|
|
233
|
+
if self.target() != target:
|
|
234
|
+
return
|
|
235
|
+
# Shutdown: close the stream only once the buffer is drained.
|
|
236
|
+
if self.stopping and not entries and gap is None:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _rpc_error_text(exc: grpc.RpcError) -> str:
|
|
241
|
+
code = getattr(exc, "code", None)
|
|
242
|
+
details = getattr(exc, "details", None)
|
|
243
|
+
if callable(code) and callable(details):
|
|
244
|
+
return f"{code().name}: {details()}"
|
|
245
|
+
return repr(exc)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hannah-logging
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ships the logs of a Hannah component to the Hannah log collector
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: grpcio>=1.84.0
|
|
8
|
+
Requires-Dist: hannah-proto>=4.5.0
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
11
|
+
|
|
12
|
+
# hannah-logging (Python)
|
|
13
|
+
|
|
14
|
+
Ships the logs of a Hannah component to the Hannah log collector, so a user can download
|
|
15
|
+
one archive of all components' logs from the WebUI.
|
|
16
|
+
|
|
17
|
+
The library adds one `logging.Handler`. Your existing handlers (stdout, journald, syslog)
|
|
18
|
+
keep working unchanged.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install hannah-logging
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The library relies on the `hannah-proto` version the component already uses, because
|
|
27
|
+
every call to Hannah Core has to carry the matching protocol version.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import logging
|
|
33
|
+
import hannah_logging
|
|
34
|
+
|
|
35
|
+
logging.basicConfig(level=logging.INFO)
|
|
36
|
+
|
|
37
|
+
# As early as possible: everything logged from here on is buffered.
|
|
38
|
+
shipping = hannah_logging.install(
|
|
39
|
+
"telegram",
|
|
40
|
+
version=__version__,
|
|
41
|
+
secrets=[config.bot_token], # masked wherever they appear
|
|
42
|
+
logger_categories={"hannah.stt": hannah_logging.TRANSCRIPT},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Once the config is loaded:
|
|
46
|
+
shipping.connect(
|
|
47
|
+
hannah_address="localhost:50051", # discovery via Hannah Core
|
|
48
|
+
collector_address=None, # optional static fallback
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Categories
|
|
53
|
+
|
|
54
|
+
Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
|
|
55
|
+
`METADATA` (room and device names, presence). Exports can leave out the last two, and
|
|
56
|
+
the WebUI does so by default.
|
|
57
|
+
|
|
58
|
+
- By logger name: `logger_categories={"hannah.stt": TRANSCRIPT}` also covers child
|
|
59
|
+
loggers such as `hannah.stt.whisper`.
|
|
60
|
+
- Per call: `log.info("heard: %s", text, extra={hannah_logging.CATEGORY_ATTR: hannah_logging.TRANSCRIPT})`
|
|
61
|
+
|
|
62
|
+
### Secrets
|
|
63
|
+
|
|
64
|
+
Before a line leaves the process, the library masks:
|
|
65
|
+
|
|
66
|
+
- values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
|
|
67
|
+
- `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
|
|
68
|
+
- Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
|
|
69
|
+
- every value passed as `secrets=[…]` or later through `shipping.add_secret(…)`
|
|
70
|
+
|
|
71
|
+
Extra regexes go in `secret_patterns=[…]`. The component's own handlers still see the
|
|
72
|
+
unmasked line.
|
|
73
|
+
|
|
74
|
+
## Behaviour
|
|
75
|
+
|
|
76
|
+
- **Buffer:** 4 MiB by default (`max_buffer_bytes`). When it is full, the oldest lines are
|
|
77
|
+
dropped and reported to the collector as a gap. While no collector is known, the
|
|
78
|
+
buffer simply keeps running as a ring.
|
|
79
|
+
- **Timestamps** are taken when a line is logged, not when it is sent.
|
|
80
|
+
- **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
|
|
81
|
+
log collector when it moves. If Core is unreachable, the last known collector is kept.
|
|
82
|
+
The static `collector_address` is used while none is announced.
|
|
83
|
+
- **Never blocks the component:** sending runs on its own threads. Connection problems
|
|
84
|
+
are logged once to the `hannah_logging` logger (not shipped) and retried with backoff.
|
|
85
|
+
- **Shutdown:** at exit, the library tries for up to 2 seconds to send what is still
|
|
86
|
+
buffered (`shipping.close(timeout=…)`).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
hannah_logging/__init__.py
|
|
4
|
+
hannah_logging/buffer.py
|
|
5
|
+
hannah_logging/handler.py
|
|
6
|
+
hannah_logging/secrets.py
|
|
7
|
+
hannah_logging/shipper.py
|
|
8
|
+
hannah_logging.egg-info/PKG-INFO
|
|
9
|
+
hannah_logging.egg-info/SOURCES.txt
|
|
10
|
+
hannah_logging.egg-info/dependency_links.txt
|
|
11
|
+
hannah_logging.egg-info/requires.txt
|
|
12
|
+
hannah_logging.egg-info/top_level.txt
|
|
13
|
+
tests/test_buffer.py
|
|
14
|
+
tests/test_handler.py
|
|
15
|
+
tests/test_integration.py
|
|
16
|
+
tests/test_secrets.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hannah_logging
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hannah-logging"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Ships the logs of a Hannah component to the Hannah log collector"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"grpcio>=1.84.0",
|
|
13
|
+
# First release with LogService (logging.proto) and SubscribeInfrastructure.
|
|
14
|
+
"hannah-proto>=4.5.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
test = ["pytest>=8"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["."]
|
|
22
|
+
include = ["hannah_logging*"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from hannah_logging.buffer import Entry, LogBuffer
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def entry(ts, message="x" * 36):
|
|
5
|
+
return Entry(timestamp_ms=ts, level=2, logger="", message=message, category=1)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_take_returns_in_order():
|
|
9
|
+
buf = LogBuffer(max_bytes=10_000)
|
|
10
|
+
for ts in range(5):
|
|
11
|
+
buf.put(entry(ts))
|
|
12
|
+
gap, items = buf.take(max_items=3, timeout=0)
|
|
13
|
+
assert gap is None
|
|
14
|
+
assert [e.timestamp_ms for e in items] == [0, 1, 2]
|
|
15
|
+
_, items = buf.take(max_items=10, timeout=0)
|
|
16
|
+
assert [e.timestamp_ms for e in items] == [3, 4]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_overflow_drops_oldest_and_records_gap():
|
|
20
|
+
size = entry(0).size()
|
|
21
|
+
buf = LogBuffer(max_bytes=size * 3)
|
|
22
|
+
for ts in range(10, 16):
|
|
23
|
+
buf.put(entry(ts))
|
|
24
|
+
gap, items = buf.take(max_items=10, timeout=0)
|
|
25
|
+
assert [e.timestamp_ms for e in items] == [13, 14, 15]
|
|
26
|
+
assert (gap.dropped, gap.from_ms, gap.to_ms) == (3, 10, 12)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_gap_is_reported_once():
|
|
30
|
+
size = entry(0).size()
|
|
31
|
+
buf = LogBuffer(max_bytes=size)
|
|
32
|
+
buf.put(entry(1))
|
|
33
|
+
buf.put(entry(2))
|
|
34
|
+
gap, _ = buf.take(max_items=10, timeout=0)
|
|
35
|
+
assert gap.dropped == 1
|
|
36
|
+
gap, items = buf.take(max_items=10, timeout=0)
|
|
37
|
+
assert gap is None and items == []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_take_times_out_when_empty():
|
|
41
|
+
buf = LogBuffer(max_bytes=100)
|
|
42
|
+
assert buf.take(max_items=1, timeout=0.01) == (None, [])
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from hannah_proto import logging_pb2
|
|
5
|
+
|
|
6
|
+
from hannah_logging import CATEGORY_ATTR, GENERAL, METADATA, TRANSCRIPT
|
|
7
|
+
from hannah_logging.buffer import LogBuffer
|
|
8
|
+
from hannah_logging.handler import CollectorHandler
|
|
9
|
+
from hannah_logging.secrets import SecretFilter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def setup():
|
|
14
|
+
buf = LogBuffer(max_bytes=1_000_000)
|
|
15
|
+
handler = CollectorHandler(buf, SecretFilter(literals=["topsecret"]), {"app.stt": TRANSCRIPT})
|
|
16
|
+
logger = logging.getLogger("app")
|
|
17
|
+
logger.setLevel(logging.DEBUG)
|
|
18
|
+
logger.propagate = False
|
|
19
|
+
logger.addHandler(handler)
|
|
20
|
+
yield buf
|
|
21
|
+
logger.removeHandler(handler)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def taken(buf):
|
|
25
|
+
return buf.take(max_items=100, timeout=0)[1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_record_fields(setup):
|
|
29
|
+
logging.getLogger("app.x").warning("hello %s", "world")
|
|
30
|
+
[e] = taken(setup)
|
|
31
|
+
assert e.message == "hello world"
|
|
32
|
+
assert e.logger == "app.x"
|
|
33
|
+
assert e.level == logging_pb2.LOG_LEVEL_WARNING
|
|
34
|
+
assert e.category == GENERAL
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_timestamp_is_record_creation_time(setup):
|
|
38
|
+
logger = logging.getLogger("app")
|
|
39
|
+
record = logger.makeRecord("app", logging.INFO, __file__, 1, "x", (), None)
|
|
40
|
+
record.created = 1_700_000_000.123
|
|
41
|
+
logger.handle(record)
|
|
42
|
+
[e] = taken(setup)
|
|
43
|
+
assert e.timestamp_ms == 1_700_000_000_123
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_categories(setup):
|
|
47
|
+
logging.getLogger("app.stt").info("heard something")
|
|
48
|
+
logging.getLogger("app.stt.sub").info("also transcript")
|
|
49
|
+
logging.getLogger("app.stt2").info("not a child")
|
|
50
|
+
logging.getLogger("app").info("room Küche", extra={CATEGORY_ATTR: METADATA})
|
|
51
|
+
assert [e.category for e in taken(setup)] == [TRANSCRIPT, TRANSCRIPT, GENERAL, METADATA]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_secrets_masked_including_traceback(setup):
|
|
55
|
+
try:
|
|
56
|
+
raise ValueError("failed with topsecret")
|
|
57
|
+
except ValueError:
|
|
58
|
+
logging.getLogger("app").exception("login topsecret")
|
|
59
|
+
[e] = taken(setup)
|
|
60
|
+
assert "topsecret" not in e.message
|
|
61
|
+
assert "ValueError" in e.message
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""End to end against in-process fakes of Hannah Core (SubscribeInfrastructure) and the
|
|
2
|
+
log collector (LogService.Ship)."""
|
|
3
|
+
import logging
|
|
4
|
+
import queue
|
|
5
|
+
import time
|
|
6
|
+
from concurrent import futures
|
|
7
|
+
|
|
8
|
+
import grpc
|
|
9
|
+
import pytest
|
|
10
|
+
from hannah_proto import hannah_pb2_grpc, infrastructure_pb2, logging_pb2, logging_pb2_grpc
|
|
11
|
+
|
|
12
|
+
import hannah_logging
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FakeCollector(logging_pb2_grpc.LogServiceServicer):
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.hellos = []
|
|
18
|
+
self.entries = []
|
|
19
|
+
self.gaps = []
|
|
20
|
+
self.metadata = []
|
|
21
|
+
|
|
22
|
+
def Ship(self, request_iterator, context):
|
|
23
|
+
self.metadata.append(dict(context.invocation_metadata()))
|
|
24
|
+
accepted = 0
|
|
25
|
+
for msg in request_iterator:
|
|
26
|
+
which = msg.WhichOneof("payload")
|
|
27
|
+
if which == "hello":
|
|
28
|
+
self.hellos.append(msg.hello)
|
|
29
|
+
elif which == "entry":
|
|
30
|
+
self.entries.append(msg.entry)
|
|
31
|
+
accepted += 1
|
|
32
|
+
elif which == "gap":
|
|
33
|
+
self.gaps.append(msg.gap)
|
|
34
|
+
return logging_pb2.ShipAck(accepted=accepted)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FakeCore(hannah_pb2_grpc.HannahServiceServicer):
|
|
38
|
+
def __init__(self, collector_port):
|
|
39
|
+
self.collector_port = collector_port
|
|
40
|
+
self.metadata = []
|
|
41
|
+
self.events = queue.Queue()
|
|
42
|
+
|
|
43
|
+
def SubscribeInfrastructure(self, request, context):
|
|
44
|
+
self.metadata.append(dict(context.invocation_metadata()))
|
|
45
|
+
yield infrastructure_pb2.InfrastructureMessage(snapshot=infrastructure_pb2.InfrastructureSnapshot(services=[
|
|
46
|
+
infrastructure_pb2.ServiceEndpoint(
|
|
47
|
+
kind=infrastructure_pb2.SERVICE_KIND_LOG_COLLECTOR,
|
|
48
|
+
instance="main", host="127.0.0.1", port=self.collector_port,
|
|
49
|
+
),
|
|
50
|
+
]))
|
|
51
|
+
while context.is_active():
|
|
52
|
+
try:
|
|
53
|
+
yield self.events.get(timeout=0.1)
|
|
54
|
+
except queue.Empty:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def start_server(register):
|
|
59
|
+
server = grpc.server(futures.ThreadPoolExecutor(max_workers=8))
|
|
60
|
+
register(server)
|
|
61
|
+
port = server.add_insecure_port("127.0.0.1:0")
|
|
62
|
+
server.start()
|
|
63
|
+
return server, port
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def wait_for(predicate, timeout=10.0):
|
|
67
|
+
deadline = time.monotonic() + timeout
|
|
68
|
+
while time.monotonic() < deadline:
|
|
69
|
+
if predicate():
|
|
70
|
+
return True
|
|
71
|
+
time.sleep(0.05)
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
def collector():
|
|
77
|
+
fake = FakeCollector()
|
|
78
|
+
server, port = start_server(lambda s: logging_pb2_grpc.add_LogServiceServicer_to_server(fake, s))
|
|
79
|
+
fake.port = port
|
|
80
|
+
yield fake
|
|
81
|
+
server.stop(None)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@pytest.fixture
|
|
85
|
+
def logger():
|
|
86
|
+
log = logging.getLogger("itest")
|
|
87
|
+
log.setLevel(logging.DEBUG)
|
|
88
|
+
log.propagate = False
|
|
89
|
+
yield log
|
|
90
|
+
for h in list(log.handlers):
|
|
91
|
+
log.removeHandler(h)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_buffered_entries_arrive_via_discovery_with_original_timestamp(collector, logger):
|
|
95
|
+
core = FakeCore(collector.port)
|
|
96
|
+
core_server, core_port = start_server(lambda s: hannah_pb2_grpc.add_HannahServiceServicer_to_server(core, s))
|
|
97
|
+
try:
|
|
98
|
+
shipping = hannah_logging.install("testcomp", version="1.2.3", instance="pc1",
|
|
99
|
+
secrets=["s3cr3t-value"], logger=logger)
|
|
100
|
+
logger.info("before connect, password is s3cr3t-value")
|
|
101
|
+
logged_at_ms = int(time.time() * 1000)
|
|
102
|
+
time.sleep(0.3) # the entry has to be older than the connect
|
|
103
|
+
|
|
104
|
+
shipping.connect(hannah_address=f"127.0.0.1:{core_port}")
|
|
105
|
+
assert wait_for(lambda: len(collector.entries) >= 1)
|
|
106
|
+
|
|
107
|
+
[entry] = collector.entries
|
|
108
|
+
assert abs(entry.timestamp_ms - logged_at_ms) < 100
|
|
109
|
+
assert "s3cr3t-value" not in entry.message
|
|
110
|
+
assert collector.hellos[0].component == "testcomp"
|
|
111
|
+
assert collector.hellos[0].instance == "pc1"
|
|
112
|
+
assert collector.hellos[0].version == "1.2.3"
|
|
113
|
+
|
|
114
|
+
logger.warning("after connect")
|
|
115
|
+
assert wait_for(lambda: len(collector.entries) == 2)
|
|
116
|
+
assert collector.entries[1].level == logging_pb2.LOG_LEVEL_WARNING
|
|
117
|
+
|
|
118
|
+
shipping.close()
|
|
119
|
+
assert core.metadata[0]["x-proto-version"]
|
|
120
|
+
assert collector.metadata[0]["x-proto-version"]
|
|
121
|
+
finally:
|
|
122
|
+
core_server.stop(None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_static_address_fallback_and_flush_on_close(collector, logger):
|
|
126
|
+
shipping = hannah_logging.install("testcomp", logger=logger,
|
|
127
|
+
collector_address=f"127.0.0.1:{collector.port}")
|
|
128
|
+
for i in range(50):
|
|
129
|
+
logger.info("line %d", i)
|
|
130
|
+
shipping.close(timeout=5)
|
|
131
|
+
assert [e.message for e in collector.entries] == [f"line {i}" for i in range(50)]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_gap_reported_after_overflow_before_connect(collector, logger):
|
|
135
|
+
shipping = hannah_logging.install("testcomp", logger=logger, max_buffer_bytes=1000)
|
|
136
|
+
for i in range(100):
|
|
137
|
+
logger.info("overflowing line %03d", i)
|
|
138
|
+
shipping.connect(collector_address=f"127.0.0.1:{collector.port}")
|
|
139
|
+
assert wait_for(lambda: collector.gaps and collector.entries)
|
|
140
|
+
shipping.close(timeout=5)
|
|
141
|
+
dropped = collector.gaps[0].dropped
|
|
142
|
+
assert dropped > 0
|
|
143
|
+
assert dropped + len(collector.entries) == 100
|
|
144
|
+
assert collector.entries[-1].message == "overflowing line 099"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_unreachable_collector_never_blocks_logging(logger):
|
|
148
|
+
shipping = hannah_logging.install("testcomp", logger=logger, collector_address="127.0.0.1:1")
|
|
149
|
+
start = time.monotonic()
|
|
150
|
+
for i in range(1000):
|
|
151
|
+
logger.info("line %d", i)
|
|
152
|
+
assert time.monotonic() - start < 1.0
|
|
153
|
+
shipping.close(timeout=0.5)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_own_thread_records_are_not_buffered(logger):
|
|
157
|
+
lib_logger = logging.getLogger("hannah_logging")
|
|
158
|
+
seen = []
|
|
159
|
+
|
|
160
|
+
class Recorder(logging.Handler):
|
|
161
|
+
def emit(self, record):
|
|
162
|
+
seen.append(record)
|
|
163
|
+
|
|
164
|
+
recorder = Recorder()
|
|
165
|
+
lib_logger.addHandler(recorder)
|
|
166
|
+
shipping = hannah_logging.install("testcomp", logger=logger)
|
|
167
|
+
# Records from the library's own threads must not loop back into the buffer,
|
|
168
|
+
# even if the host routes them through the collector handler.
|
|
169
|
+
lib_logger.addHandler(shipping.handler)
|
|
170
|
+
try:
|
|
171
|
+
shipping.connect(hannah_address="127.0.0.1:1") # unreachable -> discovery warns
|
|
172
|
+
assert wait_for(lambda: any(r.threadName == "hannah-logging-discovery" for r in seen))
|
|
173
|
+
assert len(shipping._buffer) == 0
|
|
174
|
+
finally:
|
|
175
|
+
lib_logger.removeHandler(shipping.handler)
|
|
176
|
+
lib_logger.removeHandler(recorder)
|
|
177
|
+
shipping.close(timeout=0.5)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from hannah_logging.secrets import MASK, SecretFilter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def f():
|
|
8
|
+
return SecretFilter()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.mark.parametrize("message, secret", [
|
|
12
|
+
("login password=hunter2 ok", "hunter2"),
|
|
13
|
+
("bot_token: 123abc456def", "123abc456def"),
|
|
14
|
+
('{"api_key": "abcd-efgh-ijkl"}', "abcd-efgh-ijkl"),
|
|
15
|
+
("headers Authorization: Bearer eyJhbGciOi.payload.sig", "eyJhbGciOi.payload.sig"),
|
|
16
|
+
("connect mqtt://hannah:s3cr3tpw@broker:1883", "s3cr3tpw"),
|
|
17
|
+
("jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl here", "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl"),
|
|
18
|
+
("telegram 123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsawE started", "AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsawE"),
|
|
19
|
+
("using glpat-abcdefghij0123456789 for api", "glpat-abcdefghij0123456789"),
|
|
20
|
+
("psk=0123456789abcdef", "0123456789abcdef"),
|
|
21
|
+
])
|
|
22
|
+
def test_builtin_patterns_mask_secret(f, message, secret):
|
|
23
|
+
out = f(message)
|
|
24
|
+
assert secret not in out
|
|
25
|
+
assert MASK in out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.parametrize("message", [
|
|
29
|
+
"llm answered, tokens=512",
|
|
30
|
+
"token_count=3 authorized=true",
|
|
31
|
+
"Licht Küche eingeschaltet",
|
|
32
|
+
])
|
|
33
|
+
def test_harmless_messages_untouched(f, message):
|
|
34
|
+
assert f(message) == message
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_private_key_block():
|
|
38
|
+
f = SecretFilter()
|
|
39
|
+
out = f("key:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEow\nabc\n-----END RSA PRIVATE KEY-----\nrest")
|
|
40
|
+
assert "MIIEow" not in out
|
|
41
|
+
assert out.endswith("rest")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_literal_secrets_and_add_literal():
|
|
45
|
+
f = SecretFilter(literals=["my-very-secret"])
|
|
46
|
+
assert f("value is my-very-secret!") == f"value is {MASK}!"
|
|
47
|
+
f.add_literal("added-later-123")
|
|
48
|
+
assert "added-later-123" not in f("x added-later-123 y")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_short_literals_ignored():
|
|
52
|
+
# A 1–3 character "secret" would mangle ordinary text.
|
|
53
|
+
f = SecretFilter(literals=["ab"])
|
|
54
|
+
assert f("abc") == "abc"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_custom_pattern():
|
|
58
|
+
f = SecretFilter(patterns=[r"SN-\d{6}"])
|
|
59
|
+
assert f("serial SN-123456") == f"serial {MASK}"
|