Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

109 lines
3.1 KiB
Python

"""Process-log event capture for user-visible operational logs."""
from __future__ import annotations
import asyncio
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from dataclasses import dataclass, field
import inspect
import logging
from typing import Any
from .context import LOG_CONTEXT_FIELDS
from .formatters import ContextFilter
@dataclass(frozen=True)
class ProcessLogEvent:
type: str = "process_log"
level: str = "INFO"
message: str = ""
logger: str = ""
timestamp: float = 0.0
context: dict[str, Any] = field(default_factory=dict)
@classmethod
def from_record(cls, record: logging.LogRecord) -> "ProcessLogEvent":
context = dict(getattr(record, "log_context", {}) or {})
for key in LOG_CONTEXT_FIELDS:
value = getattr(record, key, None)
if value is not None:
context[key] = value
return cls(
level=record.levelname,
message=record.getMessage(),
logger=record.name,
timestamp=record.created,
context=context,
)
def to_dict(self) -> dict[str, Any]:
return {
"type": self.type,
"level": self.level,
"message": self.message,
"logger": self.logger,
"timestamp": self.timestamp,
"context": self.context,
}
class ProcessLogHandler(logging.Handler):
"""Emit structured process-log events from matching records."""
def __init__(
self,
emit: Callable[[ProcessLogEvent], Any],
*,
task_id: str | None = None,
turn_id: str | None = None,
min_level: int = logging.INFO,
) -> None:
super().__init__(level=min_level)
self._emit = emit
self._task_id = task_id
self._turn_id = turn_id
self.addFilter(ContextFilter())
def emit(self, record: logging.LogRecord) -> None:
try:
event = ProcessLogEvent.from_record(record)
if self._task_id and event.context.get("task_id") != self._task_id:
return
if self._turn_id and event.context.get("turn_id") != self._turn_id:
return
result = self._emit(event)
if inspect.isawaitable(result):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
asyncio.ensure_future(result, loop=loop)
except Exception:
self.handleError(record)
@contextmanager
def capture_process_logs(
emit: Callable[[ProcessLogEvent], Any],
*,
task_id: str | None = None,
turn_id: str | None = None,
min_level: int = logging.INFO,
) -> Iterator[ProcessLogHandler]:
"""Capture matching stdlib log records and emit ``ProcessLogEvent`` objects."""
handler = ProcessLogHandler(
emit,
task_id=task_id,
turn_id=turn_id,
min_level=min_level,
)
root = logging.getLogger()
root.addHandler(handler)
try:
yield handler
finally:
root.removeHandler(handler)
handler.close()