hannah-grpc-lib 0.4.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_grpc_lib-0.4.0/PKG-INFO +108 -0
- hannah_grpc_lib-0.4.0/README.md +97 -0
- hannah_grpc_lib-0.4.0/hannah_grpc/__init__.py +7 -0
- hannah_grpc_lib-0.4.0/hannah_grpc/logging/__init__.py +155 -0
- hannah_grpc_lib-0.4.0/hannah_grpc/logging/buffer.py +84 -0
- hannah_grpc_lib-0.4.0/hannah_grpc/logging/handler.py +75 -0
- hannah_grpc_lib-0.4.0/hannah_grpc/logging/instance.py +33 -0
- hannah_grpc_lib-0.4.0/hannah_grpc/logging/secrets.py +69 -0
- hannah_grpc_lib-0.4.0/hannah_grpc/logging/shipper.py +247 -0
- hannah_grpc_lib-0.4.0/hannah_grpc_lib.egg-info/PKG-INFO +108 -0
- hannah_grpc_lib-0.4.0/hannah_grpc_lib.egg-info/SOURCES.txt +20 -0
- hannah_grpc_lib-0.4.0/hannah_grpc_lib.egg-info/dependency_links.txt +1 -0
- hannah_grpc_lib-0.4.0/hannah_grpc_lib.egg-info/requires.txt +5 -0
- hannah_grpc_lib-0.4.0/hannah_grpc_lib.egg-info/top_level.txt +1 -0
- hannah_grpc_lib-0.4.0/pyproject.toml +22 -0
- hannah_grpc_lib-0.4.0/setup.cfg +4 -0
- hannah_grpc_lib-0.4.0/tests/test_buffer.py +42 -0
- hannah_grpc_lib-0.4.0/tests/test_discovery.py +22 -0
- hannah_grpc_lib-0.4.0/tests/test_handler.py +61 -0
- hannah_grpc_lib-0.4.0/tests/test_instance.py +58 -0
- hannah_grpc_lib-0.4.0/tests/test_integration.py +217 -0
- hannah_grpc_lib-0.4.0/tests/test_secrets.py +59 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hannah-grpc-lib
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Shared gRPC client code for Hannah components (log shipping 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-grpc-lib (Python)
|
|
13
|
+
|
|
14
|
+
Shared gRPC client code for Hannah components. Until 0.3.1 this package was called
|
|
15
|
+
`hannah-logging` (import `hannah_logging`); that package gets no further updates.
|
|
16
|
+
|
|
17
|
+
## Logging (`hannah_grpc.logging`)
|
|
18
|
+
|
|
19
|
+
Ships the logs of a Hannah component to the Hannah log collector, so a user can download
|
|
20
|
+
one archive of all components' logs from the WebUI.
|
|
21
|
+
|
|
22
|
+
The library adds one `logging.Handler`. Your existing handlers (stdout, journald, syslog)
|
|
23
|
+
keep working unchanged.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install hannah-grpc-lib
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The library relies on the `hannah-proto` version the component already uses, because
|
|
32
|
+
every call to Hannah Core has to carry the matching protocol version.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import logging
|
|
38
|
+
import hannah_grpc.logging as hannah_logging # alias it: `from hannah_grpc import logging` would shadow the stdlib
|
|
39
|
+
|
|
40
|
+
logging.basicConfig(level=logging.INFO)
|
|
41
|
+
|
|
42
|
+
# As early as possible: everything logged from here on is buffered.
|
|
43
|
+
shipping = hannah_logging.install(
|
|
44
|
+
"telegram",
|
|
45
|
+
version=__version__,
|
|
46
|
+
secrets=[config.bot_token], # masked wherever they appear
|
|
47
|
+
logger_categories={"hannah.stt": hannah_logging.TRANSCRIPT},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Once the config is loaded:
|
|
51
|
+
shipping.connect(
|
|
52
|
+
hannah_address="localhost:50051", # discovery via Hannah Core
|
|
53
|
+
collector_address=None, # optional static fallback
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Setting the collector address directly
|
|
58
|
+
|
|
59
|
+
A component that knows the collector address itself (Hannah Core, which announces it)
|
|
60
|
+
passes it in instead of subscribing to discovery:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
shipping.set_collector_address("192.168.1.10:50061") # None withdraws it
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
It starts shipping if `connect` hasn't been called yet, takes precedence over the static
|
|
67
|
+
`collector_address`, and a new address moves the stream.
|
|
68
|
+
|
|
69
|
+
### Categories
|
|
70
|
+
|
|
71
|
+
Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
|
|
72
|
+
`METADATA` (room and device names, presence). Exports can leave out the last two, and
|
|
73
|
+
the WebUI does so by default.
|
|
74
|
+
|
|
75
|
+
- By logger name: `logger_categories={"hannah.stt": TRANSCRIPT}` also covers child
|
|
76
|
+
loggers such as `hannah.stt.whisper`.
|
|
77
|
+
- Per call: `log.info("heard: %s", text, extra={hannah_logging.CATEGORY_ATTR: hannah_logging.TRANSCRIPT})`
|
|
78
|
+
|
|
79
|
+
### Secrets
|
|
80
|
+
|
|
81
|
+
Before a line leaves the process, the library masks:
|
|
82
|
+
|
|
83
|
+
- values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
|
|
84
|
+
- `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
|
|
85
|
+
- Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
|
|
86
|
+
- every value passed as `secrets=[…]` or later through `shipping.add_secret(…)`
|
|
87
|
+
|
|
88
|
+
Extra regexes go in `secret_patterns=[…]`. The component's own handlers still see the
|
|
89
|
+
unmasked line.
|
|
90
|
+
|
|
91
|
+
## Behaviour
|
|
92
|
+
|
|
93
|
+
- **Buffer:** 4 MiB by default (`max_buffer_bytes`). When it is full, the oldest lines are
|
|
94
|
+
dropped and reported to the collector as a gap. While no collector is known, the
|
|
95
|
+
buffer simply keeps running as a ring.
|
|
96
|
+
- **Timestamps** are taken when a line is logged, not when it is sent.
|
|
97
|
+
- **Instance name:** tells several instances of a component apart in the export. Without
|
|
98
|
+
`instance`, the library uses the `HANNAH_LOG_INSTANCE` environment variable, else the
|
|
99
|
+
hostname. In a container whose hostname is just its ID (a new one with every recreate),
|
|
100
|
+
it uses `container` instead. Running the same component in several containers? Set
|
|
101
|
+
`HANNAH_LOG_INSTANCE`, or `hostname:` in Compose.
|
|
102
|
+
- **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
|
|
103
|
+
log collector when it moves. If Core is unreachable, the last known collector is kept.
|
|
104
|
+
The static `collector_address` is used while none is announced.
|
|
105
|
+
- **Never blocks the component:** sending runs on its own threads. Connection problems
|
|
106
|
+
are logged once to the `hannah_grpc.logging` logger (not shipped) and retried with backoff.
|
|
107
|
+
- **Shutdown:** at exit, the library tries for up to 2 seconds to send what is still
|
|
108
|
+
buffered (`shipping.close(timeout=…)`).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# hannah-grpc-lib (Python)
|
|
2
|
+
|
|
3
|
+
Shared gRPC client code for Hannah components. Until 0.3.1 this package was called
|
|
4
|
+
`hannah-logging` (import `hannah_logging`); that package gets no further updates.
|
|
5
|
+
|
|
6
|
+
## Logging (`hannah_grpc.logging`)
|
|
7
|
+
|
|
8
|
+
Ships the logs of a Hannah component to the Hannah log collector, so a user can download
|
|
9
|
+
one archive of all components' logs from the WebUI.
|
|
10
|
+
|
|
11
|
+
The library adds one `logging.Handler`. Your existing handlers (stdout, journald, syslog)
|
|
12
|
+
keep working unchanged.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install hannah-grpc-lib
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The library relies on the `hannah-proto` version the component already uses, because
|
|
21
|
+
every call to Hannah Core has to carry the matching protocol version.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
import logging
|
|
27
|
+
import hannah_grpc.logging as hannah_logging # alias it: `from hannah_grpc import logging` would shadow the stdlib
|
|
28
|
+
|
|
29
|
+
logging.basicConfig(level=logging.INFO)
|
|
30
|
+
|
|
31
|
+
# As early as possible: everything logged from here on is buffered.
|
|
32
|
+
shipping = hannah_logging.install(
|
|
33
|
+
"telegram",
|
|
34
|
+
version=__version__,
|
|
35
|
+
secrets=[config.bot_token], # masked wherever they appear
|
|
36
|
+
logger_categories={"hannah.stt": hannah_logging.TRANSCRIPT},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Once the config is loaded:
|
|
40
|
+
shipping.connect(
|
|
41
|
+
hannah_address="localhost:50051", # discovery via Hannah Core
|
|
42
|
+
collector_address=None, # optional static fallback
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Setting the collector address directly
|
|
47
|
+
|
|
48
|
+
A component that knows the collector address itself (Hannah Core, which announces it)
|
|
49
|
+
passes it in instead of subscribing to discovery:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
shipping.set_collector_address("192.168.1.10:50061") # None withdraws it
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
It starts shipping if `connect` hasn't been called yet, takes precedence over the static
|
|
56
|
+
`collector_address`, and a new address moves the stream.
|
|
57
|
+
|
|
58
|
+
### Categories
|
|
59
|
+
|
|
60
|
+
Each line is tagged `GENERAL`, `TRANSCRIPT` (speech transcripts, user utterances) or
|
|
61
|
+
`METADATA` (room and device names, presence). Exports can leave out the last two, and
|
|
62
|
+
the WebUI does so by default.
|
|
63
|
+
|
|
64
|
+
- By logger name: `logger_categories={"hannah.stt": TRANSCRIPT}` also covers child
|
|
65
|
+
loggers such as `hannah.stt.whisper`.
|
|
66
|
+
- Per call: `log.info("heard: %s", text, extra={hannah_logging.CATEGORY_ATTR: hannah_logging.TRANSCRIPT})`
|
|
67
|
+
|
|
68
|
+
### Secrets
|
|
69
|
+
|
|
70
|
+
Before a line leaves the process, the library masks:
|
|
71
|
+
|
|
72
|
+
- values of keys such as `password`, `token`, `api_key`, `secret`, `psk`
|
|
73
|
+
- `Bearer` tokens, JWTs, credentials in URLs (`mqtt://user:pw@host`), PEM private keys
|
|
74
|
+
- Telegram bot tokens and well-known token prefixes (`glpat-`, `ghp_`, …)
|
|
75
|
+
- every value passed as `secrets=[…]` or later through `shipping.add_secret(…)`
|
|
76
|
+
|
|
77
|
+
Extra regexes go in `secret_patterns=[…]`. The component's own handlers still see the
|
|
78
|
+
unmasked line.
|
|
79
|
+
|
|
80
|
+
## Behaviour
|
|
81
|
+
|
|
82
|
+
- **Buffer:** 4 MiB by default (`max_buffer_bytes`). When it is full, the oldest lines are
|
|
83
|
+
dropped and reported to the collector as a gap. While no collector is known, the
|
|
84
|
+
buffer simply keeps running as a ring.
|
|
85
|
+
- **Timestamps** are taken when a line is logged, not when it is sent.
|
|
86
|
+
- **Instance name:** tells several instances of a component apart in the export. Without
|
|
87
|
+
`instance`, the library uses the `HANNAH_LOG_INSTANCE` environment variable, else the
|
|
88
|
+
hostname. In a container whose hostname is just its ID (a new one with every recreate),
|
|
89
|
+
it uses `container` instead. Running the same component in several containers? Set
|
|
90
|
+
`HANNAH_LOG_INSTANCE`, or `hostname:` in Compose.
|
|
91
|
+
- **Discovery:** subscribes to Hannah Core's infrastructure announcements and follows the
|
|
92
|
+
log collector when it moves. If Core is unreachable, the last known collector is kept.
|
|
93
|
+
The static `collector_address` is used while none is announced.
|
|
94
|
+
- **Never blocks the component:** sending runs on its own threads. Connection problems
|
|
95
|
+
are logged once to the `hannah_grpc.logging` logger (not shipped) and retried with backoff.
|
|
96
|
+
- **Shutdown:** at exit, the library tries for up to 2 seconds to send what is still
|
|
97
|
+
buffered (`shipping.close(timeout=…)`).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Shared gRPC client code for Hannah components.
|
|
2
|
+
|
|
3
|
+
import hannah_grpc.logging as hannah_logging # ships the component's logs
|
|
4
|
+
|
|
5
|
+
Import the subpackages under an alias: `from hannah_grpc import logging` would shadow
|
|
6
|
+
the standard library's `logging` in your module.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Ships the logs of a Hannah component to the Hannah log collector.
|
|
2
|
+
|
|
3
|
+
import hannah_grpc.logging as 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 threading
|
|
18
|
+
from typing import Iterable, Mapping, Optional
|
|
19
|
+
|
|
20
|
+
from hannah_proto import logging_pb2
|
|
21
|
+
|
|
22
|
+
from .buffer import LogBuffer
|
|
23
|
+
from .handler import CATEGORY_ATTR, GENERAL, METADATA, TRANSCRIPT, CollectorHandler
|
|
24
|
+
from .instance import resolve_instance
|
|
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._closed = False
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
|
|
59
|
+
def connect(self, hannah_address: Optional[str] = None, collector_address: Optional[str] = None) -> None:
|
|
60
|
+
"""Starts shipping. `hannah_address` enables discovery via Hannah Core;
|
|
61
|
+
`collector_address` is the fallback while none is announced. Only the
|
|
62
|
+
first call has an effect."""
|
|
63
|
+
with self._lock:
|
|
64
|
+
if self._shipper is not None or self._closed:
|
|
65
|
+
return
|
|
66
|
+
self._shipper = Shipper(self._buffer, self._hello, collector_address or None)
|
|
67
|
+
self._shipper.start()
|
|
68
|
+
if hannah_address:
|
|
69
|
+
self._discovery = Discovery(hannah_address, self._shipper.set_discovered)
|
|
70
|
+
self._discovery.start()
|
|
71
|
+
|
|
72
|
+
def set_collector_address(self, address: Optional[str]) -> None:
|
|
73
|
+
"""Sets the collector address directly, for a component that knows it without
|
|
74
|
+
discovery (Hannah Core itself). Treated like an address announced by Core: it
|
|
75
|
+
takes precedence over the static fallback, and a change moves the stream.
|
|
76
|
+
None withdraws it. Starts shipping if `connect` hasn't been called yet."""
|
|
77
|
+
self.connect()
|
|
78
|
+
with self._lock:
|
|
79
|
+
shipper = self._shipper
|
|
80
|
+
if shipper is not None:
|
|
81
|
+
shipper.set_discovered(address or None)
|
|
82
|
+
|
|
83
|
+
def add_secret(self, secret: str) -> None:
|
|
84
|
+
"""Masks this literal value in everything logged from now on (e.g. a token
|
|
85
|
+
that only becomes known after `install`)."""
|
|
86
|
+
self._secret_filter.add_literal(secret)
|
|
87
|
+
|
|
88
|
+
def close(self, timeout: float = 2.0) -> None:
|
|
89
|
+
"""Detaches the handler and gives the shipper up to `timeout` seconds to
|
|
90
|
+
send what is still buffered."""
|
|
91
|
+
self._target_logger.removeHandler(self.handler)
|
|
92
|
+
with self._lock:
|
|
93
|
+
discovery, shipper = self._discovery, self._shipper
|
|
94
|
+
self._discovery = self._shipper = None
|
|
95
|
+
self._closed = True
|
|
96
|
+
if discovery is not None:
|
|
97
|
+
discovery.stop()
|
|
98
|
+
if shipper is not None:
|
|
99
|
+
shipper.stop()
|
|
100
|
+
shipper.join(timeout)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def collector_address(self) -> Optional[str]:
|
|
104
|
+
"""The collector address currently in use — announced by Hannah Core, else the
|
|
105
|
+
static fallback. None while none is known or before `connect`."""
|
|
106
|
+
with self._lock:
|
|
107
|
+
shipper = self._shipper
|
|
108
|
+
return shipper.target() if shipper is not None else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def install(
|
|
112
|
+
component: str,
|
|
113
|
+
*,
|
|
114
|
+
version: str = "",
|
|
115
|
+
instance: Optional[str] = None,
|
|
116
|
+
hannah_address: Optional[str] = None,
|
|
117
|
+
collector_address: Optional[str] = None,
|
|
118
|
+
secrets: Iterable[str] = (),
|
|
119
|
+
secret_patterns: Iterable[str] = (),
|
|
120
|
+
logger_categories: Optional[Mapping[str, int]] = None,
|
|
121
|
+
level: int = logging.NOTSET,
|
|
122
|
+
max_buffer_bytes: int = DEFAULT_MAX_BUFFER_BYTES,
|
|
123
|
+
logger: Optional[logging.Logger] = None,
|
|
124
|
+
) -> LogShipping:
|
|
125
|
+
"""Attaches a CollectorHandler to `logger` (default: the root logger) and starts
|
|
126
|
+
buffering. Pass `hannah_address`/`collector_address` here or later via
|
|
127
|
+
`LogShipping.connect`.
|
|
128
|
+
|
|
129
|
+
component e.g. "core", "telegram" — the name shown in the export
|
|
130
|
+
instance distinguishes several instances; defaults to $HANNAH_LOG_INSTANCE,
|
|
131
|
+
else the hostname ("container" if that is just a container ID)
|
|
132
|
+
secrets literal values to mask (the component's own tokens/passwords)
|
|
133
|
+
secret_patterns extra regexes to mask, on top of the built-in ones
|
|
134
|
+
logger_categories logger name prefix -> category, e.g. {"hannah.stt": TRANSCRIPT};
|
|
135
|
+
a single call can override it with extra={CATEGORY_ATTR: ...}
|
|
136
|
+
"""
|
|
137
|
+
buffer = LogBuffer(max_buffer_bytes)
|
|
138
|
+
secret_filter = SecretFilter(secrets, secret_patterns)
|
|
139
|
+
handler = CollectorHandler(buffer, secret_filter, logger_categories, level)
|
|
140
|
+
target_logger = logger or logging.getLogger()
|
|
141
|
+
target_logger.addHandler(handler)
|
|
142
|
+
|
|
143
|
+
shipping = LogShipping(
|
|
144
|
+
component=component,
|
|
145
|
+
version=version,
|
|
146
|
+
instance=resolve_instance(instance),
|
|
147
|
+
handler=handler,
|
|
148
|
+
buffer=buffer,
|
|
149
|
+
secret_filter=secret_filter,
|
|
150
|
+
target_logger=target_logger,
|
|
151
|
+
)
|
|
152
|
+
atexit.register(shipping.close)
|
|
153
|
+
if hannah_address or collector_address:
|
|
154
|
+
shipping.connect(hannah_address, collector_address)
|
|
155
|
+
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,33 @@
|
|
|
1
|
+
"""Resolves the instance name a component reports to the collector."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
INSTANCE_ENV = "HANNAH_LOG_INSTANCE"
|
|
10
|
+
CONTAINER_INSTANCE = "container"
|
|
11
|
+
|
|
12
|
+
# Docker and Podman drop one of these files into every container.
|
|
13
|
+
_CONTAINER_MARKERS = ("/.dockerenv", "/run/.containerenv")
|
|
14
|
+
# Without an explicit hostname, the container gets its short (12) or full (64) ID.
|
|
15
|
+
_CONTAINER_ID = re.compile(r"[0-9a-f]{12}|[0-9a-f]{64}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_instance(explicit: Optional[str] = None) -> str:
|
|
19
|
+
"""The explicit value, else $HANNAH_LOG_INSTANCE, else "container" when the hostname
|
|
20
|
+
is just a container ID (it changes with every recreate), else the hostname."""
|
|
21
|
+
if explicit:
|
|
22
|
+
return explicit
|
|
23
|
+
from_env = os.environ.get(INSTANCE_ENV, "").strip()
|
|
24
|
+
if from_env:
|
|
25
|
+
return from_env
|
|
26
|
+
host = socket.gethostname()
|
|
27
|
+
if _CONTAINER_ID.fullmatch(host) and _in_container():
|
|
28
|
+
return CONTAINER_INSTANCE
|
|
29
|
+
return host
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _in_container() -> bool:
|
|
33
|
+
return any(os.path.exists(p) for p in _CONTAINER_MARKERS)
|
|
@@ -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
|