tigrcorn-observability 0.3.16__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.
- tigrcorn_observability/__init__.py +1 -0
- tigrcorn_observability/events.py +35 -0
- tigrcorn_observability/logging.py +341 -0
- tigrcorn_observability/metrics.py +365 -0
- tigrcorn_observability/py.typed +1 -0
- tigrcorn_observability/tracing.py +180 -0
- tigrcorn_observability-0.3.16.dist-info/METADATA +290 -0
- tigrcorn_observability-0.3.16.dist-info/RECORD +11 -0
- tigrcorn_observability-0.3.16.dist-info/WHEEL +5 -0
- tigrcorn_observability-0.3.16.dist-info/licenses/LICENSE +163 -0
- tigrcorn_observability-0.3.16.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Logging, metrics, tracing helpers."""
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class Event:
|
|
8
|
+
name: str
|
|
9
|
+
attrs: dict[str, object]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
DOS_WARNING_EVENT = "tigrcorn.dos.warning"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def dos_warning(
|
|
16
|
+
*,
|
|
17
|
+
surface: str,
|
|
18
|
+
reason: str,
|
|
19
|
+
action: str,
|
|
20
|
+
resource: str | None = None,
|
|
21
|
+
limit: int | float | None = None,
|
|
22
|
+
observed: int | float | None = None,
|
|
23
|
+
) -> Event:
|
|
24
|
+
attrs: dict[str, object] = {
|
|
25
|
+
"surface": surface,
|
|
26
|
+
"reason": reason,
|
|
27
|
+
"action": action,
|
|
28
|
+
}
|
|
29
|
+
if resource is not None:
|
|
30
|
+
attrs["resource"] = resource
|
|
31
|
+
if limit is not None:
|
|
32
|
+
attrs["limit"] = limit
|
|
33
|
+
if observed is not None:
|
|
34
|
+
attrs["observed"] = observed
|
|
35
|
+
return Event(DOS_WARNING_EVENT, attrs)
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import logging
|
|
6
|
+
import logging.config
|
|
7
|
+
import socket
|
|
8
|
+
from logging import Logger
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
from tigrcorn_config.files import ConfigFileError, load_config_source
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LoggingConfigError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_ALLOWED_PROFILE_KEYS = {
|
|
20
|
+
'level',
|
|
21
|
+
'structured',
|
|
22
|
+
'access_log',
|
|
23
|
+
'access_log_file',
|
|
24
|
+
'access_log_format',
|
|
25
|
+
'error_log_file',
|
|
26
|
+
'format',
|
|
27
|
+
'stream',
|
|
28
|
+
'syslog_app_name',
|
|
29
|
+
'syslog_enterprise_id',
|
|
30
|
+
'syslog_msgid',
|
|
31
|
+
'syslog_procid',
|
|
32
|
+
'use_colors',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class ResolvedLoggingConfig:
|
|
38
|
+
level: str = 'info'
|
|
39
|
+
structured: bool = False
|
|
40
|
+
access_log: bool = True
|
|
41
|
+
access_log_file: str | None = None
|
|
42
|
+
access_log_format: str | None = None
|
|
43
|
+
error_log_file: str | None = None
|
|
44
|
+
format: str = 'default'
|
|
45
|
+
stream: bool = True
|
|
46
|
+
syslog_app_name: str = 'tigrcorn'
|
|
47
|
+
syslog_enterprise_id: int = 32473
|
|
48
|
+
syslog_msgid: str = '-'
|
|
49
|
+
syslog_procid: str = '-'
|
|
50
|
+
use_colors: bool | None = None
|
|
51
|
+
log_config: str | None = None
|
|
52
|
+
dict_config: dict[str, Any] | None = None
|
|
53
|
+
explicit_fields: tuple[str, ...] = ()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class JSONFormatter(logging.Formatter):
|
|
57
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
58
|
+
payload: dict[str, Any] = {
|
|
59
|
+
'timestamp': self.formatTime(record, self.datefmt),
|
|
60
|
+
'level': record.levelname,
|
|
61
|
+
'logger': record.name,
|
|
62
|
+
'message': record.getMessage(),
|
|
63
|
+
}
|
|
64
|
+
for key in ('event', 'peer', 'method', 'path', 'proto', 'status', 'result', 'trace_id', 'span_id'):
|
|
65
|
+
value = getattr(record, key, None)
|
|
66
|
+
if value is not None:
|
|
67
|
+
payload[key] = value
|
|
68
|
+
return json.dumps(payload, sort_keys=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ColorFormatter(logging.Formatter):
|
|
72
|
+
_COLORS = {
|
|
73
|
+
logging.DEBUG: '\x1b[36m',
|
|
74
|
+
logging.INFO: '\x1b[32m',
|
|
75
|
+
logging.WARNING: '\x1b[33m',
|
|
76
|
+
logging.ERROR: '\x1b[31m',
|
|
77
|
+
logging.CRITICAL: '\x1b[35m',
|
|
78
|
+
}
|
|
79
|
+
_RESET = '\x1b[0m'
|
|
80
|
+
|
|
81
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
82
|
+
message = super().format(record)
|
|
83
|
+
color = self._COLORS.get(record.levelno)
|
|
84
|
+
if not color:
|
|
85
|
+
return message
|
|
86
|
+
return f'{color}{message}{self._RESET}'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class RFC5424Formatter(logging.Formatter):
|
|
90
|
+
_SEVERITY = {
|
|
91
|
+
logging.DEBUG: 7,
|
|
92
|
+
logging.INFO: 6,
|
|
93
|
+
logging.WARNING: 4,
|
|
94
|
+
logging.ERROR: 3,
|
|
95
|
+
logging.CRITICAL: 2,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
app_name: str = 'tigrcorn',
|
|
102
|
+
procid: str = '-',
|
|
103
|
+
msgid: str = '-',
|
|
104
|
+
enterprise_id: int = 32473,
|
|
105
|
+
) -> None:
|
|
106
|
+
super().__init__()
|
|
107
|
+
self.app_name = app_name or '-'
|
|
108
|
+
self.procid = procid or '-'
|
|
109
|
+
self.msgid = msgid or '-'
|
|
110
|
+
self.enterprise_id = enterprise_id
|
|
111
|
+
self.hostname = socket.gethostname() or '-'
|
|
112
|
+
|
|
113
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
114
|
+
timestamp = self.formatTime(record, '%Y-%m-%dT%H:%M:%S%z')
|
|
115
|
+
if timestamp.endswith('+0000'):
|
|
116
|
+
timestamp = timestamp[:-5] + 'Z'
|
|
117
|
+
priority = 8 + self._SEVERITY.get(record.levelno, 6)
|
|
118
|
+
structured_data = self._structured_data(record)
|
|
119
|
+
return (
|
|
120
|
+
f'<{priority}>1 {timestamp} {self._nil_safe(self.hostname)} '
|
|
121
|
+
f'{self._nil_safe(self.app_name)} {self._nil_safe(self.procid)} '
|
|
122
|
+
f'{self._nil_safe(self.msgid)} {structured_data} {record.getMessage()}'
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _structured_data(self, record: logging.LogRecord) -> str:
|
|
126
|
+
pairs: list[str] = []
|
|
127
|
+
for key in ('event', 'peer', 'method', 'path', 'proto', 'status', 'result', 'trace_id', 'span_id'):
|
|
128
|
+
value = getattr(record, key, None)
|
|
129
|
+
if value is not None:
|
|
130
|
+
pairs.append(f'{key}="{self._escape_param(str(value))}"')
|
|
131
|
+
if not pairs:
|
|
132
|
+
return '-'
|
|
133
|
+
return f'[tigrcorn@{self.enterprise_id} {" ".join(pairs)}]'
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _escape_param(value: str) -> str:
|
|
137
|
+
return value.replace('\\', '\\\\').replace('"', '\\"').replace(']', '\\]')
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _nil_safe(value: str) -> str:
|
|
141
|
+
cleaned = str(value).strip()
|
|
142
|
+
return cleaned if cleaned else '-'
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class CloseAfterEmitFileHandler(logging.Handler):
|
|
146
|
+
terminator = '\n'
|
|
147
|
+
|
|
148
|
+
def __init__(self, path: str) -> None:
|
|
149
|
+
super().__init__()
|
|
150
|
+
self.baseFilename = str(Path(path))
|
|
151
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
154
|
+
try:
|
|
155
|
+
message = self.format(record)
|
|
156
|
+
with open(self.baseFilename, 'a', encoding='utf-8') as stream:
|
|
157
|
+
stream.write(message + self.terminator)
|
|
158
|
+
except Exception:
|
|
159
|
+
self.handleError(record)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AccessLogger:
|
|
163
|
+
def __init__(self, logger: Logger, *, enabled: bool = True, fmt: str | None = None) -> None:
|
|
164
|
+
self.logger = logger
|
|
165
|
+
self.enabled = enabled
|
|
166
|
+
self.fmt = fmt or '{peer} "{method} {path} {proto}" {status}'
|
|
167
|
+
|
|
168
|
+
def _peer(self, client: tuple[str, int] | None) -> str:
|
|
169
|
+
return f"{client[0]}:{client[1]}" if client else '-'
|
|
170
|
+
|
|
171
|
+
def log_http(self, client: tuple[str, int] | None, method: str, path: str, status: int, proto: str) -> None:
|
|
172
|
+
if not self.enabled:
|
|
173
|
+
return
|
|
174
|
+
peer = self._peer(client)
|
|
175
|
+
message = self.fmt.format(peer=peer, method=method, path=path, status=status, proto=proto)
|
|
176
|
+
self.logger.info(message, extra={'event': 'access.http', 'peer': peer, 'method': method, 'path': path, 'status': status, 'proto': proto})
|
|
177
|
+
|
|
178
|
+
def log_ws(self, client: tuple[str, int] | None, path: str, result: str) -> None:
|
|
179
|
+
if not self.enabled:
|
|
180
|
+
return
|
|
181
|
+
peer = self._peer(client)
|
|
182
|
+
message = f'{peer} "WEBSOCKET {path}" {result}'
|
|
183
|
+
self.logger.info(message, extra={'event': 'access.websocket', 'peer': peer, 'path': path, 'result': result})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _coerce_level(level: str) -> int:
|
|
187
|
+
return getattr(logging, str(level).upper(), logging.INFO)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _file_handler(path: str, formatter: logging.Formatter) -> logging.Handler:
|
|
191
|
+
handler = CloseAfterEmitFileHandler(path)
|
|
192
|
+
handler.setFormatter(formatter)
|
|
193
|
+
return handler
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _coerce_profile_bool(name: str, value: Any) -> bool:
|
|
197
|
+
if isinstance(value, bool):
|
|
198
|
+
return value
|
|
199
|
+
raise LoggingConfigError(f'logging profile {name!r} must be a boolean')
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def load_logging_profile(path: str | Path) -> dict[str, Any]:
|
|
203
|
+
try:
|
|
204
|
+
payload = load_config_source(path)
|
|
205
|
+
except ConfigFileError as exc:
|
|
206
|
+
raise LoggingConfigError(str(exc)) from exc
|
|
207
|
+
if 'logging' in payload and isinstance(payload['logging'], Mapping):
|
|
208
|
+
payload = dict(payload['logging'])
|
|
209
|
+
if not isinstance(payload, Mapping):
|
|
210
|
+
raise LoggingConfigError('log_config must resolve to a mapping or a top-level logging mapping')
|
|
211
|
+
if 'version' in payload:
|
|
212
|
+
return {'dict_config': dict(payload)}
|
|
213
|
+
unknown = sorted(set(payload) - _ALLOWED_PROFILE_KEYS)
|
|
214
|
+
if unknown:
|
|
215
|
+
raise LoggingConfigError(f'log_config contains unsupported keys: {unknown}')
|
|
216
|
+
result: dict[str, Any] = {}
|
|
217
|
+
for key, value in payload.items():
|
|
218
|
+
if key in {'structured', 'access_log', 'stream', 'use_colors'}:
|
|
219
|
+
result[key] = _coerce_profile_bool(key, value)
|
|
220
|
+
elif key in {
|
|
221
|
+
'level',
|
|
222
|
+
'access_log_file',
|
|
223
|
+
'access_log_format',
|
|
224
|
+
'error_log_file',
|
|
225
|
+
'format',
|
|
226
|
+
'syslog_app_name',
|
|
227
|
+
'syslog_procid',
|
|
228
|
+
'syslog_msgid',
|
|
229
|
+
}:
|
|
230
|
+
if value is not None and not isinstance(value, str):
|
|
231
|
+
raise LoggingConfigError(f'logging profile {key!r} must be a string or null')
|
|
232
|
+
result[key] = value
|
|
233
|
+
elif key == 'syslog_enterprise_id':
|
|
234
|
+
if not isinstance(value, int) or value <= 0:
|
|
235
|
+
raise LoggingConfigError("logging profile 'syslog_enterprise_id' must be a positive integer")
|
|
236
|
+
result[key] = value
|
|
237
|
+
if result.get('format') not in {None, 'default', 'json', 'rfc5424'}:
|
|
238
|
+
raise LoggingConfigError("logging profile 'format' must be one of default, json, or rfc5424")
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def resolve_logging_config(level: str = 'info', *, config: Any | None = None) -> ResolvedLoggingConfig:
|
|
243
|
+
resolved = ResolvedLoggingConfig(level=level)
|
|
244
|
+
if config is None:
|
|
245
|
+
return resolved
|
|
246
|
+
|
|
247
|
+
explicit_fields = tuple(sorted(set(getattr(config, 'explicit_fields', []) or ())))
|
|
248
|
+
log_config_path = getattr(config, 'log_config', None)
|
|
249
|
+
if log_config_path:
|
|
250
|
+
file_profile = load_logging_profile(log_config_path)
|
|
251
|
+
for key, value in file_profile.items():
|
|
252
|
+
if hasattr(resolved, key):
|
|
253
|
+
setattr(resolved, key, value)
|
|
254
|
+
resolved.log_config = str(log_config_path)
|
|
255
|
+
|
|
256
|
+
source_fields = (
|
|
257
|
+
'level',
|
|
258
|
+
'structured',
|
|
259
|
+
'access_log',
|
|
260
|
+
'access_log_file',
|
|
261
|
+
'access_log_format',
|
|
262
|
+
'error_log_file',
|
|
263
|
+
'use_colors',
|
|
264
|
+
)
|
|
265
|
+
if not log_config_path:
|
|
266
|
+
for field_name in source_fields:
|
|
267
|
+
value = getattr(config, field_name, getattr(resolved, field_name))
|
|
268
|
+
setattr(resolved, field_name, value)
|
|
269
|
+
else:
|
|
270
|
+
for field_name in explicit_fields:
|
|
271
|
+
if field_name in source_fields:
|
|
272
|
+
setattr(resolved, field_name, getattr(config, field_name, getattr(resolved, field_name)))
|
|
273
|
+
|
|
274
|
+
resolved.explicit_fields = explicit_fields
|
|
275
|
+
return resolved
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def validate_logging_contract(config: Any | None) -> None:
|
|
279
|
+
if config is None:
|
|
280
|
+
return
|
|
281
|
+
if getattr(config, 'log_config', None):
|
|
282
|
+
resolve_logging_config(getattr(config, 'level', 'info'), config=config)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _stream_formatter(*, resolved: ResolvedLoggingConfig, use_colors: bool) -> logging.Formatter:
|
|
286
|
+
if resolved.format == 'rfc5424':
|
|
287
|
+
return RFC5424Formatter(
|
|
288
|
+
app_name=resolved.syslog_app_name,
|
|
289
|
+
procid=resolved.syslog_procid,
|
|
290
|
+
msgid=resolved.syslog_msgid,
|
|
291
|
+
enterprise_id=resolved.syslog_enterprise_id,
|
|
292
|
+
)
|
|
293
|
+
structured = resolved.structured or resolved.format == 'json'
|
|
294
|
+
if structured:
|
|
295
|
+
return JSONFormatter()
|
|
296
|
+
if use_colors:
|
|
297
|
+
return ColorFormatter('%(asctime)s %(levelname)s %(name)s %(message)s')
|
|
298
|
+
return logging.Formatter('%(asctime)s %(levelname)s %(name)s %(message)s')
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def configure_logging(level: str = 'info', *, config: Any | None = None) -> logging.Logger:
|
|
302
|
+
resolved = resolve_logging_config(level, config=config)
|
|
303
|
+
if resolved.dict_config is not None:
|
|
304
|
+
logging.config.dictConfig(resolved.dict_config)
|
|
305
|
+
logger = logging.getLogger('tigrcorn')
|
|
306
|
+
if 'level' in resolved.explicit_fields:
|
|
307
|
+
logger.setLevel(_coerce_level(resolved.level))
|
|
308
|
+
return logger
|
|
309
|
+
|
|
310
|
+
logger = logging.getLogger('tigrcorn')
|
|
311
|
+
for handler in list(logger.handlers):
|
|
312
|
+
logger.removeHandler(handler)
|
|
313
|
+
try:
|
|
314
|
+
handler.close()
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
logger.setLevel(_coerce_level(resolved.level))
|
|
319
|
+
logger.propagate = False
|
|
320
|
+
|
|
321
|
+
if resolved.stream:
|
|
322
|
+
stream_handler = logging.StreamHandler()
|
|
323
|
+
enable_colors = resolved.use_colors
|
|
324
|
+
if enable_colors is None:
|
|
325
|
+
stream = getattr(stream_handler, 'stream', None)
|
|
326
|
+
enable_colors = bool(getattr(stream, 'isatty', lambda: False)())
|
|
327
|
+
stream_handler.setFormatter(_stream_formatter(resolved=resolved, use_colors=bool(enable_colors)))
|
|
328
|
+
logger.addHandler(stream_handler)
|
|
329
|
+
|
|
330
|
+
file_formatter: logging.Formatter = _stream_formatter(resolved=resolved, use_colors=False)
|
|
331
|
+
if resolved.access_log_file:
|
|
332
|
+
logger.addHandler(_file_handler(resolved.access_log_file, file_formatter))
|
|
333
|
+
if resolved.error_log_file and resolved.error_log_file != resolved.access_log_file:
|
|
334
|
+
logger.addHandler(_file_handler(resolved.error_log_file, file_formatter))
|
|
335
|
+
|
|
336
|
+
if not logger.handlers:
|
|
337
|
+
stream_handler = logging.StreamHandler()
|
|
338
|
+
stream_handler.setFormatter(_stream_formatter(resolved=resolved, use_colors=False))
|
|
339
|
+
logger.addHandler(stream_handler)
|
|
340
|
+
|
|
341
|
+
return logger
|