plain.observer 0.10.1__tar.gz → 0.11.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of plain.observer might be problematic. Click here for more details.

Files changed (36) hide show
  1. {plain_observer-0.10.1 → plain_observer-0.11.1}/PKG-INFO +1 -1
  2. plain_observer-0.11.1/plain/observer/AGENTS.md +6 -0
  3. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/CHANGELOG.md +25 -0
  4. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/cli.py +2 -2
  5. plain_observer-0.11.1/plain/observer/core.py +172 -0
  6. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/otel.py +18 -40
  7. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/observer/traces.html +31 -30
  8. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/toolbar.py +1 -1
  9. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/views.py +3 -3
  10. {plain_observer-0.10.1 → plain_observer-0.11.1}/pyproject.toml +1 -1
  11. plain_observer-0.10.1/plain/observer/core.py +0 -74
  12. {plain_observer-0.10.1 → plain_observer-0.11.1}/.gitignore +0 -0
  13. {plain_observer-0.10.1 → plain_observer-0.11.1}/LICENSE +0 -0
  14. {plain_observer-0.10.1 → plain_observer-0.11.1}/README.md +0 -0
  15. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/README.md +0 -0
  16. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/__init__.py +0 -0
  17. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/admin.py +0 -0
  18. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/config.py +0 -0
  19. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/default_settings.py +0 -0
  20. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/logging.py +0 -0
  21. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/migrations/0001_initial.py +0 -0
  22. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py +0 -0
  23. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/migrations/0003_span_plainobserv_span_id_e7ade3_idx.py +0 -0
  24. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/migrations/0004_trace_app_name_trace_app_version.py +0 -0
  25. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/migrations/0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more.py +0 -0
  26. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/migrations/0006_remove_log_logger.py +0 -0
  27. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/migrations/__init__.py +0 -0
  28. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/models.py +0 -0
  29. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/observer/partials/log.html +0 -0
  30. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/observer/partials/span.html +0 -0
  31. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/observer/trace.html +0 -0
  32. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/observer/trace_detail.html +0 -0
  33. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/observer/trace_share.html +0 -0
  34. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/toolbar/observer.html +0 -0
  35. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/templates/toolbar/observer_button.html +0 -0
  36. {plain_observer-0.10.1 → plain_observer-0.11.1}/plain/observer/urls.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.observer
3
- Version: 0.10.1
3
+ Version: 0.11.1
4
4
  Summary: On-page telemetry and observability tools for Plain.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -0,0 +1,6 @@
1
+ # Plain Observer AGENTS.md
2
+
3
+ - Send a request and record traces: `plain agent request /some/path --user 1 --header "Observer: persist"`
4
+ - Find traces by request ID: `plain observer traces --request-id abc-123-def`
5
+ - Diagnose trace: `plain observer diagnose [TRACE_ID]`
6
+ - Output raw trace data: `plain observer trace [TRACE_ID] --json`
@@ -1,5 +1,30 @@
1
1
  # plain-observer changelog
2
2
 
3
+ ## [0.11.1](https://github.com/dropseed/plain/releases/plain-observer@0.11.1) (2025-10-10)
4
+
5
+ ### What's changed
6
+
7
+ - Trace list items now update the URL when clicked, allowing direct linking to specific traces ([9f29b68](https://github.com/dropseed/plain/commit/9f29b68a87))
8
+ - Improved trace sidebar layout by moving the timestamp to the bottom right and creating better visual hierarchy ([9f29b68](https://github.com/dropseed/plain/commit/9f29b68a87))
9
+ - Updated diagnose command prompt text to be less personal in tone ([c82d67b](https://github.com/dropseed/plain/commit/c82d67bfcf))
10
+
11
+ ### Upgrade instructions
12
+
13
+ - No changes required
14
+
15
+ ## [0.11.0](https://github.com/dropseed/plain/releases/plain-observer@0.11.0) (2025-10-08)
16
+
17
+ ### What's changed
18
+
19
+ - Observer can now be enabled in DEBUG mode using an `Observer` HTTP header (e.g., `Observer: persist` or `Observer: summary`), which takes precedence over cookies ([cba149a](https://github.com/dropseed/plain/commit/cba149a40e))
20
+ - Added validation for observer mode values that raises helpful errors in DEBUG mode when invalid values are provided ([cba149a](https://github.com/dropseed/plain/commit/cba149a40e))
21
+ - Refactored `Observer` class to accept cookies and headers as constructor parameters, with new `from_request()` and `from_otel_context()` factory methods for improved testability ([cba149a](https://github.com/dropseed/plain/commit/cba149a40e))
22
+ - Added AGENTS.md file with helpful commands for AI agents working with Plain Observer ([cba149a](https://github.com/dropseed/plain/commit/cba149a40e))
23
+
24
+ ### Upgrade instructions
25
+
26
+ - No changes required
27
+
3
28
  ## [0.10.1](https://github.com/dropseed/plain/releases/plain-observer@0.10.1) (2025-10-08)
4
29
 
5
30
  ### What's changed
@@ -564,9 +564,9 @@ def diagnose(
564
564
  raise click.ClickException(f"Trace with ID '{trace_id}' not found")
565
565
 
566
566
  prompt_lines: list[str] = [
567
- "I have an OpenTelemetry trace data JSON from a Plain application. Analyze it for performance issues or improvements.",
567
+ "Below is OpenTelemetry trace data JSON from a Plain application. Analyze it for performance issues or improvements.",
568
568
  "",
569
- "Focus on easy and obvious wins first and foremost. You have access to the codebase, so make sure you look at it before suggesting anything! If there is nothing obvious, that's ok -- tell me that and ask whether there are specific things we should look deeper into.",
569
+ "Focus on easy and obvious wins first and foremost. You have access to the codebase, so make sure you look at it before suggesting anything! If there is nothing obvious, that's ok -- say that and ask whether there are specific things we should look deeper into.",
570
570
  "",
571
571
  "If potential code changes are found, briefly explain them and ask whether we should implement them.",
572
572
  "",
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import MutableMapping
5
+ from enum import Enum
6
+ from typing import TYPE_CHECKING, Any, cast
7
+
8
+ from opentelemetry import baggage
9
+
10
+ from plain.http import Response
11
+ from plain.http.cookie import unsign_cookie_value
12
+ from plain.runtime import settings
13
+
14
+ if TYPE_CHECKING:
15
+ from plain.http import Request
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class 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
+
63
+ class 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=cookies, headers=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()
@@ -8,7 +8,7 @@ from collections.abc import MutableMapping, Sequence
8
8
  from typing import TYPE_CHECKING, Any, cast
9
9
 
10
10
  import opentelemetry.context as context_api
11
- from opentelemetry import baggage, trace
11
+ from opentelemetry import trace
12
12
  from opentelemetry.context import Context
13
13
  from opentelemetry.sdk.trace import ReadableSpan, SpanProcessor, sampling
14
14
  from opentelemetry.semconv.attributes import url_attributes
@@ -20,7 +20,6 @@ from opentelemetry.trace import (
20
20
  format_trace_id,
21
21
  )
22
22
 
23
- from plain.http.cookie import unsign_cookie_value
24
23
  from plain.logs import app_logger
25
24
  from plain.models.otel import suppress_db_tracing
26
25
  from plain.runtime import settings
@@ -105,28 +104,20 @@ class ObserverSampler(sampling.Sampler):
105
104
  attributes=attributes,
106
105
  )
107
106
 
108
- # If no processor decision, check cookies directly for root spans
107
+ # If no processor decision, check headers and cookies directly for root spans
109
108
  decision: sampling.Decision | None = None
110
109
  if parent_context:
111
- # Check cookies for sampling decision
112
- cookies = cast(
113
- MutableMapping[str, str] | None,
114
- baggage.get_baggage("http.request.cookies", parent_context),
115
- )
116
- if cookies and (observer_cookie := cookies.get(Observer.COOKIE_NAME)):
117
- unsigned_value = unsign_cookie_value(
118
- Observer.COOKIE_NAME, observer_cookie, default=None
119
- )
120
-
121
- if unsigned_value in (
122
- ObserverMode.PERSIST.value,
123
- ObserverMode.SUMMARY.value,
124
- ):
125
- # Always use RECORD_AND_SAMPLE so ParentBased works correctly
126
- # The processor will check the mode to decide whether to export
127
- decision = sampling.Decision.RECORD_AND_SAMPLE
128
- else:
129
- decision = sampling.Decision.DROP
110
+ # Check Observer header (DEBUG only) and cookies
111
+ mode = Observer.from_otel_context(parent_context).mode()
112
+
113
+ # Set decision based on mode
114
+ if mode in (ObserverMode.PERSIST.value, ObserverMode.SUMMARY.value):
115
+ # Always use RECORD_AND_SAMPLE so ParentBased works correctly
116
+ # The processor will check the mode to decide whether to export
117
+ decision = sampling.Decision.RECORD_AND_SAMPLE
118
+ elif mode == ObserverMode.DISABLED.value:
119
+ # Explicitly disabled - never sample even with remote parent
120
+ decision = sampling.Decision.DROP
130
121
 
131
122
  # If there are links, assume it is to another trace/span that we are keeping
132
123
  if links:
@@ -441,25 +432,12 @@ class ObserverSpanProcessor(SpanProcessor):
441
432
  if not (context := parent_context or context_api.get_current()):
442
433
  return None
443
434
 
444
- cookies = cast(
445
- MutableMapping[str, str] | None,
446
- baggage.get_baggage("http.request.cookies", context),
447
- )
448
- if not cookies:
449
- return None
450
-
451
- observer_cookie = cookies.get(Observer.COOKIE_NAME)
452
- if not observer_cookie:
453
- return None
435
+ # Check Observer header (DEBUG only) and cookies
436
+ mode = Observer.from_otel_context(context).mode()
454
437
 
455
- try:
456
- mode = unsign_cookie_value(
457
- Observer.COOKIE_NAME, observer_cookie, default=None
458
- )
459
- if mode in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value):
460
- return mode
461
- except Exception as e:
462
- logger.warning("Failed to unsign observer cookie: %s", e)
438
+ # Only return valid recording modes (summary/persist), not disabled
439
+ if mode in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value):
440
+ return mode
463
441
 
464
442
  return None
465
443
 
@@ -88,6 +88,7 @@
88
88
  hx-get="{{ trace_item.get_absolute_url() }}"
89
89
  hx-target="#trace"
90
90
  hx-swap="innerHTML"
91
+ hx-push-url="true"
91
92
  class="block w-full text-left p-3 transition-colors hover:bg-white/5 focus:outline-none focus:bg-white/5"
92
93
  data-trace-id="{{ trace_item.trace_id }}">
93
94
  <div class="flex items-center justify-between gap-2">
@@ -98,9 +99,6 @@
98
99
  <div class="text-xs font-medium text-white/30 font-mono truncate">{{ trace_item.trace_id }}</div>
99
100
  {% endif %}
100
101
  </div>
101
- <div class="flex items-center gap-1.5 flex-shrink-0">
102
- <span class="text-white/40 text-xs" title="{{ trace_item.start_time|localtime }}">{{ trace_item.start_time|timesince }} ago</span>
103
- </div>
104
102
  </div>
105
103
  <div class="mt-1 space-y-1">
106
104
  {% if trace_item.summary %}<div class="text-xs text-white/60">{{ trace_item.summary }}</div>{% endif %}
@@ -114,35 +112,38 @@
114
112
  <div class="text-xs text-white/30 font-mono truncate">{{ trace_item.trace_id }}</div>
115
113
  </div>
116
114
  {% endif %}
117
- {% if trace_item.user_id or trace_item.session_id or trace_item.app_version %}
118
- <div class="flex items-center gap-3 text-xs text-white/50">
119
- {% if trace_item.user_id %}
120
- <span class="flex items-center gap-1 flex-shrink-0">
121
- <svg class="w-3 h-3 text-white/70" fill="currentColor" viewBox="0 0 16 16">
122
- <path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
123
- </svg>
124
- {{ trace_item.user_id }}
125
- </span>
126
- {% endif %}
127
- {% if trace_item.session_id %}
128
- <span class="flex items-center gap-1 min-w-0 flex-shrink">
129
- <svg class="w-3 h-3 text-white/70 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
130
- <path d="M13.5 3a.5.5 0 0 1 .5.5V11H2V3.5a.5.5 0 0 1 .5-.5zm-11-1A1.5 1.5 0 0 0 1 3.5V12h14V3.5A1.5 1.5 0 0 0 13.5 2zM0 12.5h16a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 12.5"/>
131
- </svg>
132
- <span class="truncate" title="{{ trace_item.session_id }}">{{ trace_item.session_id }}</span>
133
- </span>
134
- {% endif %}
135
- {% if trace_item.app_version %}
136
- <span class="flex items-center gap-1 flex-shrink-0">
137
- <svg class="w-3 h-3 text-white/70" fill="currentColor" viewBox="0 0 16 16">
138
- <path d="M6 4.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-1 0a.5.5 0 1 0-1 0 .5.5 0 0 0 1 0z"/>
139
- <path d="M2 1h4.586a1 1 0 0 1 .707.293l7 7a1 1 0 0 1 0 1.414l-4.586 4.586a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 1 6.586V2a1 1 0 0 1 1-1zm0 5.586 7 7L13.586 9l-7-7H2v4.586z"/>
140
- </svg>
141
- {{ trace_item.app_version }}
142
- </span>
115
+ <div class="flex items-center justify-between gap-3 text-xs text-white/50">
116
+ {% if trace_item.user_id or trace_item.session_id or trace_item.app_version %}
117
+ <div class="flex items-center gap-3">
118
+ {% if trace_item.user_id %}
119
+ <span class="flex items-center gap-1 flex-shrink-0">
120
+ <svg class="w-3 h-3 text-white/70" fill="currentColor" viewBox="0 0 16 16">
121
+ <path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6"/>
122
+ </svg>
123
+ {{ trace_item.user_id }}
124
+ </span>
125
+ {% endif %}
126
+ {% if trace_item.session_id %}
127
+ <span class="flex items-center gap-1 min-w-0 flex-shrink">
128
+ <svg class="w-3 h-3 text-white/70 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
129
+ <path d="M13.5 3a.5.5 0 0 1 .5.5V11H2V3.5a.5.5 0 0 1 .5-.5zm-11-1A1.5 1.5 0 0 0 1 3.5V12h14V3.5A1.5 1.5 0 0 0 13.5 2zM0 12.5h16a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 12.5"/>
130
+ </svg>
131
+ <span class="truncate" title="{{ trace_item.session_id }}">{{ trace_item.session_id }}</span>
132
+ </span>
133
+ {% endif %}
134
+ {% if trace_item.app_version %}
135
+ <span class="flex items-center gap-1 flex-shrink-0">
136
+ <svg class="w-3 h-3 text-white/70" fill="currentColor" viewBox="0 0 16 16">
137
+ <path d="M6 4.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-1 0a.5.5 0 1 0-1 0 .5.5 0 0 0 1 0z"/>
138
+ <path d="M2 1h4.586a1 1 0 0 1 .707.293l7 7a1 1 0 0 1 0 1.414l-4.586 4.586a1 1 0 0 1-1.414 0l-7-7A1 1 0 0 1 1 6.586V2a1 1 0 0 1 1-1zm0 5.586 7 7L13.586 9l-7-7H2v4.586z"/>
139
+ </svg>
140
+ {{ trace_item.app_version }}
141
+ </span>
142
+ {% endif %}
143
+ </div>
143
144
  {% endif %}
145
+ <span class="flex-shrink-0 text-white/40" title="{{ trace_item.start_time|localtime }}">{{ trace_item.start_time|timesince }} ago</span>
144
146
  </div>
145
- {% endif %}
146
147
  </div>
147
148
  </a>
148
149
  </li>
@@ -17,7 +17,7 @@ class ObserverToolbarItem(ToolbarItem):
17
17
  @cached_property
18
18
  def observer(self) -> Observer:
19
19
  """Get the Observer instance for this request."""
20
- return Observer(self.request)
20
+ return Observer.from_request(self.request)
21
21
 
22
22
  def get_template_context(self) -> dict[str, Any]:
23
23
  context = super().get_template_context()
@@ -37,13 +37,13 @@ class ObserverTracesView(AuthViewMixin, HTMXViewMixin, ListView):
37
37
 
38
38
  def get_template_context(self) -> dict[str, Any]:
39
39
  context = super().get_template_context()
40
- context["observer"] = Observer(self.request)
40
+ context["observer"] = Observer.from_request(self.request)
41
41
  return context
42
42
 
43
43
  def htmx_put_mode(self) -> Response:
44
44
  """Set observer mode via HTMX PUT."""
45
45
  mode = self.request.data.get("mode")
46
- observer = Observer(self.request)
46
+ observer = Observer.from_request(self.request)
47
47
 
48
48
  response = Response(status_code=204)
49
49
  response.headers["HX-Refresh"] = "true"
@@ -70,7 +70,7 @@ class ObserverTracesView(AuthViewMixin, HTMXViewMixin, ListView):
70
70
  """Handle POST requests to set observer mode."""
71
71
  action = self.request.data.get("observe_action")
72
72
  if action == "summary":
73
- observer = Observer(self.request)
73
+ observer = Observer.from_request(self.request)
74
74
  response = Response(status_code=204)
75
75
  observer.enable_summary_mode(response)
76
76
  return response
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.observer"
3
- version = "0.10.1"
3
+ version = "0.11.1"
4
4
  description = "On-page telemetry and observability tools for Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  license = "BSD-3-Clause"
@@ -1,74 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from enum import Enum
4
-
5
- from plain.http import Request, Response
6
-
7
-
8
- class ObserverMode(Enum):
9
- """Observer operation modes."""
10
-
11
- SUMMARY = "summary" # Real-time monitoring only, no DB export
12
- PERSIST = "persist" # Real-time monitoring + DB export
13
- DISABLED = "disabled" # Observer explicitly disabled
14
-
15
-
16
- class Observer:
17
- """Central class for managing observer state and operations."""
18
-
19
- COOKIE_NAME = "observer"
20
- SUMMARY_COOKIE_DURATION = 60 * 60 * 24 * 7 # 1 week in seconds
21
- PERSIST_COOKIE_DURATION = 60 * 60 * 24 # 1 day in seconds
22
-
23
- def __init__(self, request: Request) -> None:
24
- self.request = request
25
-
26
- def mode(self) -> str | None:
27
- """Get the current observer mode from signed cookie."""
28
- return self.request.get_signed_cookie(self.COOKIE_NAME, default=None)
29
-
30
- def is_enabled(self) -> bool:
31
- """Check if observer is enabled (either summary or persist mode)."""
32
- return self.mode() in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value)
33
-
34
- def is_persisting(self) -> bool:
35
- """Check if full persisting (with DB export) is enabled."""
36
- return self.mode() == ObserverMode.PERSIST.value
37
-
38
- def is_summarizing(self) -> bool:
39
- """Check if summary mode is enabled."""
40
- return self.mode() == ObserverMode.SUMMARY.value
41
-
42
- def is_disabled(self) -> bool:
43
- """Check if observer is explicitly disabled."""
44
- return self.mode() == ObserverMode.DISABLED.value
45
-
46
- def enable_summary_mode(self, response: Response) -> None:
47
- """Enable summary mode (real-time monitoring, no DB export)."""
48
- response.set_signed_cookie(
49
- self.COOKIE_NAME,
50
- ObserverMode.SUMMARY.value,
51
- max_age=self.SUMMARY_COOKIE_DURATION,
52
- )
53
-
54
- def enable_persist_mode(self, response: Response) -> None:
55
- """Enable full persist mode (real-time monitoring + DB export)."""
56
- response.set_signed_cookie(
57
- self.COOKIE_NAME,
58
- ObserverMode.PERSIST.value,
59
- max_age=self.PERSIST_COOKIE_DURATION,
60
- )
61
-
62
- def disable(self, response: Response) -> None:
63
- """Disable observer by setting cookie to disabled."""
64
- response.set_signed_cookie(
65
- self.COOKIE_NAME,
66
- ObserverMode.DISABLED.value,
67
- max_age=self.PERSIST_COOKIE_DURATION,
68
- )
69
-
70
- def get_current_trace_summary(self) -> str | None:
71
- """Get performance summary string for the currently active trace."""
72
- from .otel import get_current_trace_summary
73
-
74
- return get_current_trace_summary()
File without changes