hannah-logging 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,9 @@
1
+ hannah_logging/__init__.py,sha256=H1OfA7kZ1V0aIOxExh19fo3g_PBmFnm5n2R2_I6JQbU,5120
2
+ hannah_logging/buffer.py,sha256=E0DoGrMhFk4PufNxJFWBNMhMS8IlGV4kSZyY8U9qknA,2804
3
+ hannah_logging/handler.py,sha256=4zz9QQ0ve7Z4r1AvtotkvSxfkO_TGR84C8LY9lLO9q8,2701
4
+ hannah_logging/secrets.py,sha256=cv_qs9_HuaMILNYbb65bSJVrUT7ME-csbYUilnns4vU,3127
5
+ hannah_logging/shipper.py,sha256=zPqvgbxOSC6xUFU2Rx33B-ingfKgfELvvokaXG5-gpQ,9862
6
+ hannah_logging-0.1.0.dist-info/METADATA,sha256=61WgUuZen4BwOlAFa_T7RjeIHhfwgW3Ai-WZjDSpkE8,3228
7
+ hannah_logging-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ hannah_logging-0.1.0.dist-info/top_level.txt,sha256=oRIbyox0Mgj5WBzQb2M3_7lJejxggFV082s9pIqnrZs,15
9
+ hannah_logging-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ hannah_logging