Skip to content

Commit 6f30760

Browse files
committed
Add one-click "Record this page" to observer toolbar
Adds a PERSIST_ONCE mode that records a single trace then reverts to summary. A small record dot button sits next to the observer pill in the toolbar — subtle by default, red on hover. Clicking it sets the persist_once cookie, reloads the page to capture the trace, then auto-reverts to summary and opens the Observer panel.
1 parent 704780b commit 6f30760

5 files changed

Lines changed: 103 additions & 18 deletions

File tree

plain-observer/plain/observer/core.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
if TYPE_CHECKING:
1515
from plain.http import Request
1616

17-
__all__ = ["Observer", "ObserverMode"]
17+
__all__ = ["Observer", "ObserverMode", "PERSISTING_MODES", "RECORDING_MODES"]
1818

1919
logger = logging.getLogger(__name__)
2020

@@ -24,6 +24,7 @@ class ObserverMode(Enum):
2424

2525
SUMMARY = "summary" # Real-time monitoring only, no DB export
2626
PERSIST = "persist" # Real-time monitoring + DB export
27+
PERSIST_ONCE = "persist_once" # Persist one trace then revert to summary
2728
DISABLED = "disabled" # Observer explicitly disabled
2829

2930
@classmethod
@@ -42,7 +43,12 @@ def validate(cls, mode: str | None, source: str = "value") -> str | None:
4243
if mode is None:
4344
return None
4445

45-
valid_modes = (cls.SUMMARY.value, cls.PERSIST.value, cls.DISABLED.value)
46+
valid_modes = (
47+
cls.SUMMARY.value,
48+
cls.PERSIST.value,
49+
cls.PERSIST_ONCE.value,
50+
cls.DISABLED.value,
51+
)
4652

4753
if mode not in valid_modes:
4854
if settings.DEBUG:
@@ -62,6 +68,12 @@ def validate(cls, mode: str | None, source: str = "value") -> str | None:
6268
return mode
6369

6470

71+
# Modes that trigger DB export and log capture
72+
PERSISTING_MODES = (ObserverMode.PERSIST.value, ObserverMode.PERSIST_ONCE.value)
73+
# All modes that record traces (summary + persisting)
74+
RECORDING_MODES = (*PERSISTING_MODES, ObserverMode.SUMMARY.value)
75+
76+
6577
class Observer:
6678
"""Central class for managing observer state and operations."""
6779

@@ -131,12 +143,20 @@ def mode(self) -> str | None:
131143

132144
def is_enabled(self) -> bool:
133145
"""Check if observer is enabled (either summary or persist mode)."""
134-
return self.mode() in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value)
146+
return self.mode() in RECORDING_MODES
135147

136148
def is_persisting(self) -> bool:
137149
"""Check if full persisting (with DB export) is enabled."""
150+
return self.mode() in PERSISTING_MODES
151+
152+
def is_recording_session(self) -> bool:
153+
"""Check if in continuous recording mode (not persist-once)."""
138154
return self.mode() == ObserverMode.PERSIST.value
139155

156+
def is_persist_once(self) -> bool:
157+
"""Check if persist-once mode is enabled (single trace recording)."""
158+
return self.mode() == ObserverMode.PERSIST_ONCE.value
159+
140160
def is_summarizing(self) -> bool:
141161
"""Check if summary mode is enabled."""
142162
return self.mode() == ObserverMode.SUMMARY.value
@@ -161,6 +181,14 @@ def enable_persist_mode(self, response: Response) -> None:
161181
max_age=self.PERSIST_COOKIE_DURATION,
162182
)
163183

184+
def enable_persist_once_mode(self, response: Response) -> None:
185+
"""Enable persist-once mode (persist one trace then revert to summary)."""
186+
response.set_signed_cookie(
187+
self.COOKIE_NAME,
188+
ObserverMode.PERSIST_ONCE.value,
189+
max_age=self.PERSIST_COOKIE_DURATION,
190+
)
191+
164192
def disable(self, response: Response) -> None:
165193
"""Disable observer by setting cookie to disabled."""
166194
response.set_signed_cookie(

plain-observer/plain/observer/logging.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from opentelemetry import trace
99
from opentelemetry.trace import format_span_id, format_trace_id
1010

11-
from .core import ObserverMode
11+
from .core import PERSISTING_MODES
1212
from .otel import get_observer_span_processor
1313

1414

@@ -50,8 +50,7 @@ def emit(self, record: logging.LogRecord) -> None:
5050
return
5151

5252
trace_info = processor._traces[trace_id]
53-
# Only capture logs in PERSIST mode
54-
if trace_info["mode"] != ObserverMode.PERSIST.value:
53+
if trace_info["mode"] not in PERSISTING_MODES:
5554
return
5655

5756
# Store the formatted message with span context

plain-observer/plain/observer/otel.py

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from plain.postgres.otel import suppress_db_tracing
2626
from plain.runtime import settings
2727

28-
from .core import Observer, ObserverMode
28+
from .core import PERSISTING_MODES, RECORDING_MODES, Observer, ObserverMode
2929

3030
if TYPE_CHECKING:
3131
from plain.observer.models import Span as ObserverSpanModel
@@ -108,9 +108,7 @@ def should_sample(
108108
mode = Observer.from_otel_context(parent_context).mode()
109109

110110
# Set decision based on mode
111-
if mode in (ObserverMode.PERSIST.value, ObserverMode.SUMMARY.value):
112-
# Always use RECORD_AND_SAMPLE so ParentBased works correctly
113-
# The processor will check the mode to decide whether to export
111+
if mode in RECORDING_MODES:
114112
decision = sampling.Decision.RECORD_AND_SAMPLE
115113
elif mode == ObserverMode.DISABLED.value:
116114
# Explicitly disabled - never sample even with remote parent
@@ -233,8 +231,7 @@ def on_start(self, span: Any, parent_context: Context | None = None) -> None:
233231

234232
span_id = f"0x{format_span_id(span.get_span_context().span_id)}"
235233

236-
# Enable DEBUG logging only for PERSIST mode (when logs are captured)
237-
if trace_info["mode"] == ObserverMode.PERSIST.value:
234+
if trace_info["mode"] in PERSISTING_MODES:
238235
app_logger.debug_mode.start()
239236

240237
# Store span (we know mode is truthy if we get here)
@@ -256,8 +253,7 @@ def on_end(self, span: ReadableSpan) -> None:
256253

257254
trace_info = self._traces[trace_id]
258255

259-
# Disable DEBUG logging only for PERSIST mode spans
260-
if trace_info["mode"] == ObserverMode.PERSIST.value:
256+
if trace_info["mode"] in PERSISTING_MODES:
261257
app_logger.debug_mode.end()
262258

263259
# Move span from active to completed
@@ -276,8 +272,7 @@ def on_end(self, span: ReadableSpan) -> None:
276272
for s in all_spans
277273
]
278274

279-
# Export if in persist mode
280-
if trace_info["mode"] == ObserverMode.PERSIST.value:
275+
if trace_info["mode"] in PERSISTING_MODES:
281276
# Get and remove logs for this trace
282277
from .logging import observer_log_handler
283278

@@ -432,7 +427,7 @@ def _get_recording_mode(
432427
mode = Observer.from_otel_context(context).mode()
433428

434429
# Only return valid recording modes (summary/persist), not disabled
435-
if mode in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value):
430+
if mode in RECORDING_MODES:
436431
return mode
437432

438433
return None

plain-observer/plain/observer/templates/toolbar/observer_button.html

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
1+
{# Observer pill + record dot, visually semi-attached #}
2+
<div class="inline-flex items-center gap-px">
3+
4+
{# Record dot — separate clickable circle, tight against the pill #}
5+
{% if observer.is_summarizing() or observer.is_persist_once() %}
6+
<button
7+
type="button"
8+
class="inline-flex items-center justify-center cursor-pointer rounded-full px-1.5 py-px bg-white/8 text-white/30 hover:text-red-400 hover:bg-white/12 transition-colors self-stretch"
9+
data-observer-record-page
10+
title="Record this page"
11+
>
12+
<svg class="size-1.5" viewBox="0 0 16 16" fill="currentColor">
13+
<circle cx="8" cy="8" r="8"/>
14+
</svg>
15+
</button>
16+
{% endif %}
17+
118
{# Request pill — observer state + request stats, clickable to open Observer panel #}
219
<button
320
type="button"
421
class="inline-flex items-center cursor-pointer text-xs rounded-full px-1 py-px bg-white/8 text-white/80 hover:bg-white/12 overflow-hidden divide-x divide-white/10 [&>span]:px-1.5"
522
data-toolbar-tab="Observer"
623
>
7-
{% if observer.is_persisting() %}
24+
{% if observer.is_recording_session() %}
825
<span class="inline-flex items-center">
926
<span class="relative inline-flex size-1.5 mr-1.5 flex-shrink-0">
1027
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
@@ -76,6 +93,8 @@
7693
</span>
7794
{% endif %}
7895

96+
</div>
97+
7998
<script nonce="{{ request.csp_nonce }}">
8099
(function() {
81100
function formatBytes(bytes) {
@@ -97,5 +116,44 @@
97116
el.classList.remove("hidden");
98117
}
99118
});
119+
120+
const recordBtn = document.querySelector("[data-observer-record-page]");
121+
122+
{% if observer.is_persist_once() %}
123+
// Revert persist_once cookie back to summary, then re-enable record button
124+
const revertForm = new FormData();
125+
revertForm.append("observe_action", "summary");
126+
if (recordBtn) recordBtn.disabled = true;
127+
fetch("{{ url('observer:traces') }}", {
128+
method: "POST",
129+
body: revertForm,
130+
credentials: "same-origin"
131+
}).then(function() {
132+
if (recordBtn) recordBtn.disabled = false;
133+
});
134+
135+
window.addEventListener("load", function() {
136+
if (window.plainToolbar) {
137+
window.plainToolbar.showTab("Observer");
138+
}
139+
});
140+
{% endif %}
141+
142+
if (recordBtn) {
143+
recordBtn.addEventListener("click", function(e) {
144+
e.stopPropagation();
145+
recordBtn.style.color = "#f87171";
146+
recordBtn.disabled = true;
147+
const form = new FormData();
148+
form.append("observe_action", "persist_once");
149+
fetch("{{ url('observer:traces') }}", {
150+
method: "POST",
151+
body: form,
152+
credentials: "same-origin"
153+
}).then(function() {
154+
window.location.reload();
155+
});
156+
});
157+
}
100158
})();
101159
</script>

plain-observer/plain/observer/views.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ def post(self) -> Response:
7474
response = Response(status_code=204)
7575
observer.enable_summary_mode(response)
7676
return response
77+
elif action == "persist_once":
78+
observer = Observer.from_request(self.request)
79+
response = Response(status_code=204)
80+
observer.enable_persist_once_mode(response)
81+
return response
7782
return Response("Invalid action", status_code=400)
7883

7984

0 commit comments

Comments
 (0)