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()