Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 33 additions & 101 deletions src/common/core/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing_extensions import TypedDict

from common.core.constants import LOGGING_DEFAULT_ROOT_LOG_LEVEL
from common.core.sentry import sentry_processor

logger = logging.getLogger(__name__)

Expand All @@ -29,27 +30,6 @@ class JsonRecord(TypedDict, extra_items=Any, total=False): # type: ignore[call-
exc_info: str


class JsonFormatter(logging.Formatter):
"""Custom formatter for json logs."""

def get_json_record(self, record: logging.LogRecord) -> JsonRecord:
formatted_message = record.getMessage()
json_record: JsonRecord = {
"levelname": record.levelname,
"message": formatted_message,
"timestamp": self.formatTime(record, self.datefmt),
"logger_name": record.name,
"pid": record.process,
"thread_name": record.threadName,
}
if record.exc_info:
json_record["exc_info"] = self.formatException(record.exc_info)
return json_record

def format(self, record: logging.LogRecord) -> str:
return json.dumps(self.get_json_record(record))


def setup_logging(
log_level: str = "INFO",
log_format: str = "generic",
Expand Down Expand Up @@ -120,7 +100,10 @@ def map_event_to_json_record(
method_name: str,
event_dict: EventDict,
) -> EventDict:
"""Map structlog fields to match :class:`JsonFormatter` output schema."""
"""Map structlog fields to match :class:`JsonRecord` output schema."""
# Remove foreign record args injected by pass_foreign_args so they
# don't leak into the rendered JSON output.
event_dict.pop("positional_args", None)
record: JsonRecord = {
"message": event_dict.pop("event", ""),
"levelname": event_dict.pop("level", "").upper(),
Expand All @@ -136,91 +119,40 @@ def map_event_to_json_record(
return event_dict


class _SentryFriendlyProcessorFormatter(structlog.stdlib.ProcessorFormatter):
"""Preserves ``record.msg`` and ``record.args`` across formatting.

Sentry's ``LoggingIntegration`` reads these fields *after* handlers run;
structlog's ``ProcessorFormatter`` replaces them with rendered output, breaking event
grouping. We snapshot before and restore after so Sentry sees the originals
and avoids failed event deduplication.
"""

def __init__(
self,
log_format: str = "generic",
extra_foreign_processors: list[Processor] | None = None,
**kwargs: Any,
) -> None:
if log_format == "json":
renderer_processors: list[Processor] = [
map_event_to_json_record,
structlog.processors.JSONRenderer(),
]
else:
colors = sys.stdout.isatty() and structlog.dev._has_colors
renderer_processors = [
structlog.dev.ConsoleRenderer(colors=colors),
]

foreign_pre_chain: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.stdlib.ExtraAdder(),
self.drop_internal_keys,
*(extra_foreign_processors or []),
]

super().__init__(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
*renderer_processors,
],
foreign_pre_chain=foreign_pre_chain,
**kwargs,
)

def format(self, record: logging.LogRecord) -> str:
# Snapshot the original fields before ProcessorFormatter
# replaces them with rendered output.
original_msg = record.msg
original_args = record.args

# Stash original args on the record so foreign_pre_chain
# processors (e.g. the Gunicorn access log extractor) can
# access them — ProcessorFormatter clears record.args to ()
# before running the chain.
record._original_args = original_args

formatted = super().format(record)

# Restore so Sentry (and any other post-handler hook) sees
# the original message template and substitution args.
record.msg = original_msg
record.args = original_args
del record._original_args # type: ignore[attr-defined]

return formatted

@staticmethod
def drop_internal_keys(
_: WrappedLogger, __: str, event_dict: EventDict
) -> EventDict:
"""Remove internal attributes that leak via ``ExtraAdder``."""
event_dict.pop("_original_args", None)
return event_dict


def setup_structlog(
log_format: str,
extra_foreign_processors: list[Processor] | None = None,
) -> None:
"""Configure structlog to route through stdlib logging."""
from common.core.sentry import sentry_processor

formatter = _SentryFriendlyProcessorFormatter(
log_format=log_format, extra_foreign_processors=extra_foreign_processors
if log_format == "json":
renderer_processors: list[Processor] = [
map_event_to_json_record,
structlog.processors.JSONRenderer(),
]
else:
colors = sys.stdout.isatty() and structlog.dev._has_colors
renderer_processors = [
structlog.dev.ConsoleRenderer(colors=colors),
]

foreign_pre_chain: list[Processor] = [
structlog.contextvars.merge_contextvars,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.format_exc_info,
structlog.stdlib.ExtraAdder(),
*(extra_foreign_processors or []),
]

formatter = structlog.stdlib.ProcessorFormatter(
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
*renderer_processors,
],
foreign_pre_chain=foreign_pre_chain,
pass_foreign_args=True,
)

# Replace the formatter on existing root handlers with ProcessorFormatter.
Expand Down
8 changes: 4 additions & 4 deletions src/common/gunicorn/processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ def processor(
record = event_dict.get("_record")
if record is None or record.name != "gunicorn.access":
return event_dict
# ProcessorFormatter clears record.args before running
# foreign_pre_chain; the originals are stashed on the record
# by _SentryFriendlyProcessorFormatter.format().
args = getattr(record, "_original_args", record.args)
# Gunicorn passes request data as a dict in record.args.
# By the time foreign_pre_chain runs, ProcessorFormatter has
# cleared args on its copy; positional_args has the originals.
args = event_dict.get("positional_args", record.args)
if not isinstance(args, dict):
return event_dict

Expand Down
Loading
Loading