v0.141.1
  1from __future__ import annotations
  2
  3import logging
  4from collections.abc import Mapping, MutableMapping
  5from enum import Enum
  6from typing import TYPE_CHECKING, Any, cast
  7
  8from opentelemetry import context as otel_context
  9
 10from plain.http import Response
 11from plain.http.cookie import unsign_cookie_value
 12from plain.runtime import settings
 13
 14if TYPE_CHECKING:
 15    from plain.http import Request
 16
 17__all__ = ["Observer", "ObserverMode", "PERSISTING_MODES", "RECORDING_MODES"]
 18
 19logger = logging.getLogger(__name__)
 20
 21
 22class ObserverMode(Enum):
 23    """Observer operation modes."""
 24
 25    SUMMARY = "summary"  # Real-time monitoring only, no DB export
 26    PERSIST = "persist"  # Real-time monitoring + DB export
 27    PERSIST_ONCE = "persist_once"  # Persist one trace then revert to summary
 28    DISABLED = "disabled"  # Observer explicitly disabled
 29
 30    @classmethod
 31    def validate(cls, mode: str | None, source: str = "value") -> str | None:
 32        """Validate observer mode value.
 33
 34        In DEBUG mode, raises ValueError for invalid values.
 35        In production, logs debug message and returns None for invalid values.
 36
 37        Args:
 38            mode: The mode value to validate
 39            source: Description of where the mode came from (for error messages)
 40
 41        Returns the mode if valid, None otherwise.
 42        """
 43        if mode is None:
 44            return None
 45
 46        valid_modes = (
 47            cls.SUMMARY.value,
 48            cls.PERSIST.value,
 49            cls.PERSIST_ONCE.value,
 50            cls.DISABLED.value,
 51        )
 52
 53        if mode not in valid_modes:
 54            if settings.DEBUG:
 55                raise ValueError(
 56                    f"Invalid Observer {source}: '{mode}'. "
 57                    f"Valid values are: {', '.join(valid_modes)}"
 58                )
 59            else:
 60                logger.debug(
 61                    "Invalid observer mode %s: '%s'. Expected one of: %s",
 62                    source,
 63                    mode,
 64                    valid_modes,
 65                )
 66                return None
 67
 68        return mode
 69
 70
 71# Modes that trigger DB export and log capture
 72PERSISTING_MODES = (ObserverMode.PERSIST.value, ObserverMode.PERSIST_ONCE.value)
 73# All modes that record traces (summary + persisting)
 74RECORDING_MODES = (*PERSISTING_MODES, ObserverMode.SUMMARY.value)
 75
 76
 77class Observer:
 78    """Central class for managing observer state and operations."""
 79
 80    COOKIE_NAME = "observer"
 81    DEBUG_HEADER_NAME = "Observer"
 82    SUMMARY_COOKIE_DURATION = 60 * 60 * 24 * 7  # 1 week in seconds
 83    PERSIST_COOKIE_DURATION = 60 * 60 * 24  # 1 day in seconds
 84
 85    def __init__(
 86        self, *, cookies: Mapping[str, str], headers: Mapping[str, str]
 87    ) -> None:
 88        self.cookies = cookies
 89        self.headers = headers
 90
 91    @classmethod
 92    def from_request(cls, request: Request) -> Observer:
 93        """Create an Observer instance from a request object."""
 94        return cls(cookies=request.cookies, headers=request.headers)
 95
 96    @classmethod
 97    def from_otel_context(cls, context: Any) -> Observer:
 98        """Create an Observer instance from an OpenTelemetry context.
 99
100        Reads cookies and headers from process-local context values
101        (set by BaseHandler._start_request_span via context.set_value).
102        """
103        cookies = cast(
104            MutableMapping[str, str] | None,
105            otel_context.get_value("plain.request.cookies", context),
106        )
107        if not cookies:
108            cookies = {}
109
110        headers = cast(
111            MutableMapping[str, str] | None,
112            otel_context.get_value("plain.request.headers", context),
113        )
114        if not headers:
115            headers = {}
116
117        return cls(cookies=dict(cookies), headers=dict(headers))
118
119    def mode(self) -> str | None:
120        """Get the current observer mode from header (DEBUG only) or cookie.
121
122        In DEBUG mode, the Observer header takes precedence over the cookie.
123        Returns 'summary', 'persist', 'disabled', or None.
124
125        Raises ValueError if invalid value is found (DEBUG only).
126        """
127        # Check Observer header in DEBUG mode (takes precedence)
128        if settings.DEBUG:
129            if observer_header := self.headers.get(self.DEBUG_HEADER_NAME):
130                observer_mode = observer_header.lower()
131                return ObserverMode.validate(observer_mode, source="header value")
132
133        # Check cookie
134        observer_cookie = self.cookies.get(self.COOKIE_NAME)
135        if not observer_cookie:
136            return None
137
138        try:
139            mode = unsign_cookie_value(self.COOKIE_NAME, observer_cookie, default=None)
140            return ObserverMode.validate(mode, source="cookie value")
141        except Exception as e:
142            logger.debug("Failed to unsign observer cookie: %s", e)
143            return None
144
145    def is_enabled(self) -> bool:
146        """Check if observer is enabled (either summary or persist mode)."""
147        return self.mode() in RECORDING_MODES
148
149    def is_persisting(self) -> bool:
150        """Check if full persisting (with DB export) is enabled."""
151        return self.mode() in PERSISTING_MODES
152
153    def is_recording_session(self) -> bool:
154        """Check if in continuous recording mode (not persist-once)."""
155        return self.mode() == ObserverMode.PERSIST.value
156
157    def is_persist_once(self) -> bool:
158        """Check if persist-once mode is enabled (single trace recording)."""
159        return self.mode() == ObserverMode.PERSIST_ONCE.value
160
161    def is_summarizing(self) -> bool:
162        """Check if summary mode is enabled."""
163        return self.mode() == ObserverMode.SUMMARY.value
164
165    def is_disabled(self) -> bool:
166        """Check if observer is explicitly disabled."""
167        return self.mode() == ObserverMode.DISABLED.value
168
169    def enable_summary_mode(self, response: Response) -> None:
170        """Enable summary mode (real-time monitoring, no DB export)."""
171        response.set_signed_cookie(
172            self.COOKIE_NAME,
173            ObserverMode.SUMMARY.value,
174            max_age=self.SUMMARY_COOKIE_DURATION,
175        )
176
177    def enable_persist_mode(self, response: Response) -> None:
178        """Enable full persist mode (real-time monitoring + DB export)."""
179        response.set_signed_cookie(
180            self.COOKIE_NAME,
181            ObserverMode.PERSIST.value,
182            max_age=self.PERSIST_COOKIE_DURATION,
183        )
184
185    def enable_persist_once_mode(self, response: Response) -> None:
186        """Enable persist-once mode (persist one trace then revert to summary)."""
187        response.set_signed_cookie(
188            self.COOKIE_NAME,
189            ObserverMode.PERSIST_ONCE.value,
190            max_age=self.PERSIST_COOKIE_DURATION,
191        )
192
193    def disable(self, response: Response) -> None:
194        """Disable observer by setting cookie to disabled."""
195        response.set_signed_cookie(
196            self.COOKIE_NAME,
197            ObserverMode.DISABLED.value,
198            max_age=self.PERSIST_COOKIE_DURATION,
199        )
200
201    def get_current_trace_stats(self) -> dict[str, Any] | None:
202        """Get structured performance stats for the currently active trace."""
203        from .otel import get_current_trace_stats
204
205        return get_current_trace_stats()