tiphys.logging

Structured logging configuration for Tiphys.

Uses structlog for structured JSON logging with request-scoped context propagation.

Features:

  • Request-scoped logging context (correlation IDs, channel, peer info)
  • Context propagation through async operations
  • Structured JSON output for production
  • Colored console output for development
  1"""
  2Structured logging configuration for Tiphys.
  3
  4Uses structlog for structured JSON logging with request-scoped context propagation.
  5
  6Features:
  7- Request-scoped logging context (correlation IDs, channel, peer info)
  8- Context propagation through async operations
  9- Structured JSON output for production
 10- Colored console output for development
 11"""
 12
 13from __future__ import annotations
 14
 15import logging
 16import sys
 17from collections.abc import MutableMapping
 18from contextvars import ContextVar
 19from dataclasses import dataclass, field
 20from typing import Any
 21from uuid import uuid4
 22
 23import structlog
 24from structlog.types import Processor
 25
 26from tiphys.config import LogLevel
 27
 28# Context variable for request-scoped logging
 29_log_context: ContextVar[dict[str, Any] | None] = ContextVar("log_context", default=None)
 30
 31
 32@dataclass
 33class LogContext:
 34    """
 35    Request-scoped logging context.
 36
 37    Provides structured context that propagates through the request lifecycle.
 38    All fields are automatically included in log output.
 39
 40    Example:
 41        from tiphys.logging import LogContext, set_log_context
 42
 43        # At request start
 44        ctx = LogContext(
 45            channel_name="webchat",
 46            peer_id="user123",
 47            session_key="webchat:user123",
 48        )
 49        set_log_context(ctx)
 50
 51        # All subsequent logs will include the context
 52        logger.info("Processing message")  # Includes correlation_id, channel, etc.
 53    """
 54
 55    correlation_id: str = field(default_factory=lambda: str(uuid4()))
 56    channel_name: str | None = None
 57    peer_id: str | None = None
 58    session_key: str | None = None
 59    agent_id: str | None = None
 60    message_id: str | None = None
 61    extra: dict[str, Any] = field(default_factory=dict)
 62
 63    def as_dict(self) -> dict[str, Any]:
 64        """Convert to dict for structlog binding."""
 65        result: dict[str, Any] = {"correlation_id": self.correlation_id}
 66        if self.channel_name:
 67            result["channel"] = self.channel_name
 68        if self.peer_id:
 69            result["peer_id"] = self.peer_id
 70        if self.session_key:
 71            result["session_key"] = self.session_key
 72        if self.agent_id:
 73            result["agent_id"] = self.agent_id
 74        if self.message_id:
 75            result["message_id"] = self.message_id
 76        result.update(self.extra)
 77        return result
 78
 79    def with_extra(self, **kwargs: Any) -> LogContext:
 80        """Return a new context with additional fields."""
 81        new_extra = {**self.extra, **kwargs}
 82        return LogContext(
 83            correlation_id=self.correlation_id,
 84            channel_name=self.channel_name,
 85            peer_id=self.peer_id,
 86            session_key=self.session_key,
 87            agent_id=self.agent_id,
 88            message_id=self.message_id,
 89            extra=new_extra,
 90        )
 91
 92
 93def set_log_context(ctx: LogContext) -> None:
 94    """
 95    Set the current logging context.
 96
 97    Args:
 98        ctx: The LogContext to set as current.
 99    """
100    _log_context.set(ctx.as_dict())
101
102
103def get_log_context() -> dict[str, Any]:
104    """
105    Get the current logging context.
106
107    Returns:
108        Dict of context values, or empty dict if not set.
109    """
110    return _log_context.get() or {}
111
112
113def add_log_context(**kwargs: Any) -> None:
114    """
115    Add fields to the current logging context.
116
117    Args:
118        **kwargs: Fields to add to the context.
119    """
120    ctx = (_log_context.get() or {}).copy()
121    ctx.update(kwargs)
122    _log_context.set(ctx)
123
124
125def clear_log_context() -> None:
126    """Clear the current logging context."""
127    _log_context.set({})
128
129
130def inject_context(
131    logger: Any,
132    method_name: str,
133    event_dict: MutableMapping[str, Any],
134) -> MutableMapping[str, Any]:
135    """
136    Structlog processor that injects request-scoped context.
137
138    Context values are injected but can be overridden by explicit log arguments.
139
140    Args:
141        logger: The bound logger.
142        method_name: The logging method name.
143        event_dict: The event dictionary.
144
145    Returns:
146        Event dict with context values merged.
147    """
148    ctx = get_log_context()
149    # Context values are defaults - explicit arguments override them
150    return {**ctx, **event_dict}
151
152
153def setup_logging(
154    level: LogLevel = LogLevel.INFO,
155    json_output: bool = False,
156) -> None:
157    """
158    Configure structured logging for Tiphys.
159
160    Args:
161        level: Minimum log level to output.
162        json_output: If True, output logs as JSON. Otherwise, use colored console output.
163    """
164    # Convert LogLevel enum to logging level
165    log_level = getattr(logging, level.value)
166
167    # Shared processors for all configurations
168    shared_processors: list[Processor] = [
169        structlog.contextvars.merge_contextvars,
170        inject_context,  # Inject request-scoped context (correlation_id, channel, etc.)
171        structlog.processors.add_log_level,
172        structlog.processors.TimeStamper(fmt="iso"),
173        structlog.stdlib.PositionalArgumentsFormatter(),
174        structlog.processors.StackInfoRenderer(),
175        structlog.processors.UnicodeDecoder(),
176    ]
177
178    if json_output:
179        # JSON output for production
180        processors: list[Processor] = [
181            *shared_processors,
182            structlog.processors.format_exc_info,
183            structlog.processors.JSONRenderer(),
184        ]
185    else:
186        # Colored console output for development
187        processors = [
188            *shared_processors,
189            structlog.dev.ConsoleRenderer(colors=True),
190        ]
191
192    # Configure structlog
193    structlog.configure(
194        processors=processors,
195        wrapper_class=structlog.make_filtering_bound_logger(log_level),
196        context_class=dict,
197        logger_factory=structlog.PrintLoggerFactory(),
198        cache_logger_on_first_use=True,
199    )
200
201    # Also configure stdlib logging for third-party libraries
202    logging.basicConfig(
203        format="%(message)s",
204        stream=sys.stdout,
205        level=log_level,
206    )
207
208    # Reduce noise from third-party libraries
209    logging.getLogger("uvicorn").setLevel(logging.WARNING)
210    logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
211    logging.getLogger("httpx").setLevel(logging.WARNING)
212    logging.getLogger("httpcore").setLevel(logging.WARNING)
213
214
215def get_logger(name: str) -> structlog.typing.FilteringBoundLogger:
216    """
217    Get a logger for a specific module.
218
219    Args:
220        name: Module name (typically __name__).
221
222    Returns:
223        A bound logger instance.
224    """
225    return structlog.get_logger(name)  # type: ignore[no-any-return]
@dataclass
class LogContext:
33@dataclass
34class LogContext:
35    """
36    Request-scoped logging context.
37
38    Provides structured context that propagates through the request lifecycle.
39    All fields are automatically included in log output.
40
41    Example:
42        from tiphys.logging import LogContext, set_log_context
43
44        # At request start
45        ctx = LogContext(
46            channel_name="webchat",
47            peer_id="user123",
48            session_key="webchat:user123",
49        )
50        set_log_context(ctx)
51
52        # All subsequent logs will include the context
53        logger.info("Processing message")  # Includes correlation_id, channel, etc.
54    """
55
56    correlation_id: str = field(default_factory=lambda: str(uuid4()))
57    channel_name: str | None = None
58    peer_id: str | None = None
59    session_key: str | None = None
60    agent_id: str | None = None
61    message_id: str | None = None
62    extra: dict[str, Any] = field(default_factory=dict)
63
64    def as_dict(self) -> dict[str, Any]:
65        """Convert to dict for structlog binding."""
66        result: dict[str, Any] = {"correlation_id": self.correlation_id}
67        if self.channel_name:
68            result["channel"] = self.channel_name
69        if self.peer_id:
70            result["peer_id"] = self.peer_id
71        if self.session_key:
72            result["session_key"] = self.session_key
73        if self.agent_id:
74            result["agent_id"] = self.agent_id
75        if self.message_id:
76            result["message_id"] = self.message_id
77        result.update(self.extra)
78        return result
79
80    def with_extra(self, **kwargs: Any) -> LogContext:
81        """Return a new context with additional fields."""
82        new_extra = {**self.extra, **kwargs}
83        return LogContext(
84            correlation_id=self.correlation_id,
85            channel_name=self.channel_name,
86            peer_id=self.peer_id,
87            session_key=self.session_key,
88            agent_id=self.agent_id,
89            message_id=self.message_id,
90            extra=new_extra,
91        )

Request-scoped logging context.

Provides structured context that propagates through the request lifecycle. All fields are automatically included in log output.

Example: from tiphys.logging import LogContext, set_log_context

# At request start
ctx = LogContext(
    channel_name="webchat",
    peer_id="user123",
    session_key="webchat:user123",
)
set_log_context(ctx)

# All subsequent logs will include the context
logger.info("Processing message")  # Includes correlation_id, channel, etc.
LogContext( correlation_id: str = <factory>, channel_name: str | None = None, peer_id: str | None = None, session_key: str | None = None, agent_id: str | None = None, message_id: str | None = None, extra: dict[str, typing.Any] = <factory>)
correlation_id: str
channel_name: str | None = None
peer_id: str | None = None
session_key: str | None = None
agent_id: str | None = None
message_id: str | None = None
extra: dict[str, typing.Any]
def as_dict(self) -> dict[str, typing.Any]:
64    def as_dict(self) -> dict[str, Any]:
65        """Convert to dict for structlog binding."""
66        result: dict[str, Any] = {"correlation_id": self.correlation_id}
67        if self.channel_name:
68            result["channel"] = self.channel_name
69        if self.peer_id:
70            result["peer_id"] = self.peer_id
71        if self.session_key:
72            result["session_key"] = self.session_key
73        if self.agent_id:
74            result["agent_id"] = self.agent_id
75        if self.message_id:
76            result["message_id"] = self.message_id
77        result.update(self.extra)
78        return result

Convert to dict for structlog binding.

def with_extra(self, **kwargs: Any) -> LogContext:
80    def with_extra(self, **kwargs: Any) -> LogContext:
81        """Return a new context with additional fields."""
82        new_extra = {**self.extra, **kwargs}
83        return LogContext(
84            correlation_id=self.correlation_id,
85            channel_name=self.channel_name,
86            peer_id=self.peer_id,
87            session_key=self.session_key,
88            agent_id=self.agent_id,
89            message_id=self.message_id,
90            extra=new_extra,
91        )

Return a new context with additional fields.

def set_log_context(ctx: LogContext) -> None:
 94def set_log_context(ctx: LogContext) -> None:
 95    """
 96    Set the current logging context.
 97
 98    Args:
 99        ctx: The LogContext to set as current.
100    """
101    _log_context.set(ctx.as_dict())

Set the current logging context.

Args: ctx: The LogContext to set as current.

def get_log_context() -> dict[str, typing.Any]:
104def get_log_context() -> dict[str, Any]:
105    """
106    Get the current logging context.
107
108    Returns:
109        Dict of context values, or empty dict if not set.
110    """
111    return _log_context.get() or {}

Get the current logging context.

Returns: Dict of context values, or empty dict if not set.

def add_log_context(**kwargs: Any) -> None:
114def add_log_context(**kwargs: Any) -> None:
115    """
116    Add fields to the current logging context.
117
118    Args:
119        **kwargs: Fields to add to the context.
120    """
121    ctx = (_log_context.get() or {}).copy()
122    ctx.update(kwargs)
123    _log_context.set(ctx)

Add fields to the current logging context.

Args: **kwargs: Fields to add to the context.

def clear_log_context() -> None:
126def clear_log_context() -> None:
127    """Clear the current logging context."""
128    _log_context.set({})

Clear the current logging context.

def inject_context( logger: Any, method_name: str, event_dict: MutableMapping[str, typing.Any]) -> MutableMapping[str, typing.Any]:
131def inject_context(
132    logger: Any,
133    method_name: str,
134    event_dict: MutableMapping[str, Any],
135) -> MutableMapping[str, Any]:
136    """
137    Structlog processor that injects request-scoped context.
138
139    Context values are injected but can be overridden by explicit log arguments.
140
141    Args:
142        logger: The bound logger.
143        method_name: The logging method name.
144        event_dict: The event dictionary.
145
146    Returns:
147        Event dict with context values merged.
148    """
149    ctx = get_log_context()
150    # Context values are defaults - explicit arguments override them
151    return {**ctx, **event_dict}

Structlog processor that injects request-scoped context.

Context values are injected but can be overridden by explicit log arguments.

Args: logger: The bound logger. method_name: The logging method name. event_dict: The event dictionary.

Returns: Event dict with context values merged.

def setup_logging( level: tiphys.config.LogLevel = <LogLevel.INFO: 'INFO'>, json_output: bool = False) -> None:
154def setup_logging(
155    level: LogLevel = LogLevel.INFO,
156    json_output: bool = False,
157) -> None:
158    """
159    Configure structured logging for Tiphys.
160
161    Args:
162        level: Minimum log level to output.
163        json_output: If True, output logs as JSON. Otherwise, use colored console output.
164    """
165    # Convert LogLevel enum to logging level
166    log_level = getattr(logging, level.value)
167
168    # Shared processors for all configurations
169    shared_processors: list[Processor] = [
170        structlog.contextvars.merge_contextvars,
171        inject_context,  # Inject request-scoped context (correlation_id, channel, etc.)
172        structlog.processors.add_log_level,
173        structlog.processors.TimeStamper(fmt="iso"),
174        structlog.stdlib.PositionalArgumentsFormatter(),
175        structlog.processors.StackInfoRenderer(),
176        structlog.processors.UnicodeDecoder(),
177    ]
178
179    if json_output:
180        # JSON output for production
181        processors: list[Processor] = [
182            *shared_processors,
183            structlog.processors.format_exc_info,
184            structlog.processors.JSONRenderer(),
185        ]
186    else:
187        # Colored console output for development
188        processors = [
189            *shared_processors,
190            structlog.dev.ConsoleRenderer(colors=True),
191        ]
192
193    # Configure structlog
194    structlog.configure(
195        processors=processors,
196        wrapper_class=structlog.make_filtering_bound_logger(log_level),
197        context_class=dict,
198        logger_factory=structlog.PrintLoggerFactory(),
199        cache_logger_on_first_use=True,
200    )
201
202    # Also configure stdlib logging for third-party libraries
203    logging.basicConfig(
204        format="%(message)s",
205        stream=sys.stdout,
206        level=log_level,
207    )
208
209    # Reduce noise from third-party libraries
210    logging.getLogger("uvicorn").setLevel(logging.WARNING)
211    logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
212    logging.getLogger("httpx").setLevel(logging.WARNING)
213    logging.getLogger("httpcore").setLevel(logging.WARNING)

Configure structured logging for Tiphys.

Args: level: Minimum log level to output. json_output: If True, output logs as JSON. Otherwise, use colored console output.

def get_logger(name: str) -> structlog.typing.FilteringBoundLogger:
216def get_logger(name: str) -> structlog.typing.FilteringBoundLogger:
217    """
218    Get a logger for a specific module.
219
220    Args:
221        name: Module name (typically __name__).
222
223    Returns:
224        A bound logger instance.
225    """
226    return structlog.get_logger(name)  # type: ignore[no-any-return]

Get a logger for a specific module.

Args: name: Module name (typically __name__).

Returns: A bound logger instance.