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.
@@ -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,5 @@
1
+ grpcio>=1.84.0
2
+ hannah-proto>=4.5.0
3
+
4
+ [test]
5
+ pytest>=8
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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}"