1from __future__ import annotations
  2
  3import logging
  4from collections.abc import MutableMapping
  5from enum import Enum
  6from typing import TYPE_CHECKING, Any, cast
  7
  8from opentelemetry import baggage
  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
 17logger = logging.getLogger(__name__)
 18
 19
 20class ObserverMode(Enum):
 21    """Observer operation modes."""
 22
 23    SUMMARY = "summary"  # Real-time monitoring only, no DB export
 24    PERSIST = "persist"  # Real-time monitoring + DB export
 25    DISABLED = "disabled"  # Observer explicitly disabled
 26
 27    @classmethod
 28    def validate(cls, mode: str | None, source: str = "value") -> str | None:
 29        """Validate observer mode value.
 30
 31        In DEBUG mode, raises ValueError for invalid values.
 32        In production, logs debug message and returns None for invalid values.
 33
 34        Args:
 35            mode: The mode value to validate
 36            source: Description of where the mode came from (for error messages)
 37
 38        Returns the mode if valid, None otherwise.
 39        """
 40        if mode is None:
 41            return None
 42
 43        valid_modes = (cls.SUMMARY.value, cls.PERSIST.value, cls.DISABLED.value)
 44
 45        if mode not in valid_modes:
 46            if settings.DEBUG:
 47                raise ValueError(
 48                    f"Invalid Observer {source}: '{mode}'. "
 49                    f"Valid values are: {', '.join(valid_modes)}"
 50                )
 51            else:
 52                logger.debug(
 53                    "Invalid observer mode %s: '%s'. Expected one of: %s",
 54                    source,
 55                    mode,
 56                    valid_modes,
 57                )
 58                return None
 59
 60        return mode
 61
 62
 63class Observer:
 64    """Central class for managing observer state and operations."""
 65
 66    COOKIE_NAME = "observer"
 67    DEBUG_HEADER_NAME = "Observer"
 68    SUMMARY_COOKIE_DURATION = 60 * 60 * 24 * 7  # 1 week in seconds
 69    PERSIST_COOKIE_DURATION = 60 * 60 * 24  # 1 day in seconds
 70
 71    def __init__(self, *, cookies: dict[str, str], headers: dict[str, str]) -> None:
 72        self.cookies = cookies
 73        self.headers = headers
 74
 75    @classmethod
 76    def from_request(cls, request: Request) -> Observer:
 77        """Create an Observer instance from a request object."""
 78        return cls(cookies=request.cookies, headers=request.headers)
 79
 80    @classmethod
 81    def from_otel_context(cls, context: Any) -> Observer:
 82        """Create an Observer instance from an OpenTelemetry context.
 83
 84        This method extracts cookies and headers from the OTEL baggage.
 85        """
 86        cookies = cast(
 87            MutableMapping[str, str] | None,
 88            baggage.get_baggage("http.request.cookies", context),
 89        )
 90        if not cookies:
 91            cookies = {}
 92
 93        headers = cast(
 94            MutableMapping[str, str] | None,
 95            baggage.get_baggage("http.request.headers", context),
 96        )
 97        if not headers:
 98            headers = {}
 99
100        return cls(cookies=dict(cookies), headers=dict(headers))
101
102    def mode(self) -> str | None:
103        """Get the current observer mode from header (DEBUG only) or cookie.
104
105        In DEBUG mode, the Observer header takes precedence over the cookie.
106        Returns 'summary', 'persist', 'disabled', or None.
107
108        Raises ValueError if invalid value is found (DEBUG only).
109        """
110        # Check Observer header in DEBUG mode (takes precedence)
111        if settings.DEBUG:
112            if observer_header := self.headers.get(self.DEBUG_HEADER_NAME):
113                observer_mode = observer_header.lower()
114                return ObserverMode.validate(observer_mode, source="header value")
115
116        # Check cookie
117        observer_cookie = self.cookies.get(self.COOKIE_NAME)
118        if not observer_cookie:
119            return None
120
121        try:
122            mode = unsign_cookie_value(self.COOKIE_NAME, observer_cookie, default=None)
123            return ObserverMode.validate(mode, source="cookie value")
124        except Exception as e:
125            logger.debug("Failed to unsign observer cookie: %s", e)
126            return None
127
128    def is_enabled(self) -> bool:
129        """Check if observer is enabled (either summary or persist mode)."""
130        return self.mode() in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value)
131
132    def is_persisting(self) -> bool:
133        """Check if full persisting (with DB export) is enabled."""
134        return self.mode() == ObserverMode.PERSIST.value
135
136    def is_summarizing(self) -> bool:
137        """Check if summary mode is enabled."""
138        return self.mode() == ObserverMode.SUMMARY.value
139
140    def is_disabled(self) -> bool:
141        """Check if observer is explicitly disabled."""
142        return self.mode() == ObserverMode.DISABLED.value
143
144    def enable_summary_mode(self, response: Response) -> None:
145        """Enable summary mode (real-time monitoring, no DB export)."""
146        response.set_signed_cookie(
147            self.COOKIE_NAME,
148            ObserverMode.SUMMARY.value,
149            max_age=self.SUMMARY_COOKIE_DURATION,
150        )
151
152    def enable_persist_mode(self, response: Response) -> None:
153        """Enable full persist mode (real-time monitoring + DB export)."""
154        response.set_signed_cookie(
155            self.COOKIE_NAME,
156            ObserverMode.PERSIST.value,
157            max_age=self.PERSIST_COOKIE_DURATION,
158        )
159
160    def disable(self, response: Response) -> None:
161        """Disable observer by setting cookie to disabled."""
162        response.set_signed_cookie(
163            self.COOKIE_NAME,
164            ObserverMode.DISABLED.value,
165            max_age=self.PERSIST_COOKIE_DURATION,
166        )
167
168    def get_current_trace_summary(self) -> str | None:
169        """Get performance summary string for the currently active trace."""
170        from .otel import get_current_trace_summary
171
172        return get_current_trace_summary()