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