|
4 | 4 |
|
5 | 5 | import structlog |
6 | 6 |
|
| 7 | +from riptide_collector import __version__ |
7 | 8 |
|
8 | | -class _HealthCheckAccessFilter(logging.Filter): |
9 | | - _silent_paths = frozenset({"/health", "/ready"}) |
| 9 | +# Field names Splunk treats as built-in input metadata. Using them as JSON |
| 10 | +# keys causes Splunk to silently overwrite our values with the forwarder's |
| 11 | +# (`source`, `host`, `index`, `sourcetype`, `time`/`_time`/`_raw`), or to |
| 12 | +# double-extract (`event`). Keep them off the wire. |
| 13 | +_SPLUNK_RESERVED = frozenset( |
| 14 | + {"source", "sourcetype", "host", "index", "time", "_time", "_raw", "event"} |
| 15 | +) |
10 | 16 |
|
11 | | - def filter(self, record: logging.LogRecord) -> bool: |
12 | | - args = record.args |
13 | | - if isinstance(args, tuple) and len(args) >= 3: |
14 | | - path = args[2] |
15 | | - if isinstance(path, str) and path.split("?", 1)[0] in self._silent_paths: |
16 | | - return False |
17 | | - return True |
| 17 | +_SERVICE_NAME = "riptide-collector" |
18 | 18 |
|
19 | 19 |
|
20 | | -def configure_logging(level: str = "INFO") -> None: |
| 20 | +def _make_service_metadata_processor(env: str): |
| 21 | + def _add(_logger: Any, _name: str, event_dict: dict[str, Any]) -> dict[str, Any]: |
| 22 | + del _logger, _name |
| 23 | + event_dict.setdefault("service", _SERVICE_NAME) |
| 24 | + event_dict.setdefault("version", __version__) |
| 25 | + event_dict.setdefault("env", env) |
| 26 | + return event_dict |
| 27 | + |
| 28 | + return _add |
| 29 | + |
| 30 | + |
| 31 | +def _rename_level(_logger: Any, _name: str, event_dict: dict[str, Any]) -> dict[str, Any]: |
| 32 | + del _logger, _name |
| 33 | + if "level" in event_dict and "log_level" not in event_dict: |
| 34 | + event_dict["log_level"] = event_dict.pop("level") |
| 35 | + return event_dict |
| 36 | + |
| 37 | + |
| 38 | +def _strip_reserved(_logger: Any, _name: str, event_dict: dict[str, Any]) -> dict[str, Any]: |
| 39 | + del _logger, _name |
| 40 | + # `msg` is set by EventRenamer, `log_level` by _rename_level — both |
| 41 | + # already safe. This pass catches accidental kwargs (e.g. `source="jenkins"`) |
| 42 | + # and namespaces them under `splunk_<name>` so the value survives without |
| 43 | + # colliding with Splunk's built-in field of the same name. |
| 44 | + for key in list(event_dict.keys()): |
| 45 | + if key in _SPLUNK_RESERVED: |
| 46 | + event_dict[f"splunk_{key}"] = event_dict.pop(key) |
| 47 | + return event_dict |
| 48 | + |
| 49 | + |
| 50 | +def configure_logging(level: str = "INFO", env: str = "dev") -> None: |
21 | 51 | log_level = getattr(logging, level.upper(), logging.INFO) |
22 | | - logging.basicConfig( |
23 | | - format="%(message)s", |
24 | | - stream=sys.stdout, |
25 | | - level=log_level, |
26 | | - ) |
27 | | - logging.getLogger("uvicorn.access").addFilter(_HealthCheckAccessFilter()) |
28 | 52 |
|
29 | | - processors: list[Any] = [ |
| 53 | + shared_processors: list[Any] = [ |
30 | 54 | structlog.contextvars.merge_contextvars, |
31 | 55 | structlog.processors.add_log_level, |
32 | 56 | structlog.processors.TimeStamper(fmt="iso", utc=True), |
33 | 57 | structlog.processors.StackInfoRenderer(), |
34 | 58 | structlog.processors.format_exc_info, |
35 | | - structlog.processors.JSONRenderer(), |
| 59 | + _make_service_metadata_processor(env), |
| 60 | + structlog.processors.EventRenamer("msg"), |
| 61 | + _rename_level, |
| 62 | + _strip_reserved, |
36 | 63 | ] |
37 | 64 |
|
| 65 | + # Bridge: route stdlib logs (uvicorn, sqlalchemy, alembic) through the |
| 66 | + # same JSON pipeline so Splunk sees one schema only. |
| 67 | + formatter = structlog.stdlib.ProcessorFormatter( |
| 68 | + foreign_pre_chain=shared_processors, |
| 69 | + processors=[ |
| 70 | + structlog.stdlib.ProcessorFormatter.remove_processors_meta, |
| 71 | + structlog.processors.JSONRenderer(), |
| 72 | + ], |
| 73 | + ) |
| 74 | + handler = logging.StreamHandler(sys.stdout) |
| 75 | + handler.setFormatter(formatter) |
| 76 | + root = logging.getLogger() |
| 77 | + root.handlers = [handler] |
| 78 | + root.setLevel(log_level) |
| 79 | + |
| 80 | + # uvicorn's access log becomes redundant once our middleware emits |
| 81 | + # http_request; keep only warnings/errors from it. |
| 82 | + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) |
| 83 | + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) |
| 84 | + |
38 | 85 | structlog.configure( |
39 | | - processors=processors, |
| 86 | + processors=[ |
| 87 | + *shared_processors, |
| 88 | + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, |
| 89 | + ], |
40 | 90 | wrapper_class=structlog.make_filtering_bound_logger(log_level), |
41 | | - logger_factory=structlog.PrintLoggerFactory(), |
| 91 | + logger_factory=structlog.stdlib.LoggerFactory(), |
42 | 92 | cache_logger_on_first_use=True, |
43 | 93 | ) |
44 | 94 |
|
|
0 commit comments