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.
@@ -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