plain.observer 0.0.0__py3-none-any.whl

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.

@@ -0,0 +1 @@
1
+ # plain-observer changelog
@@ -0,0 +1,3 @@
1
+ # plain.observer
2
+
3
+ **Monitor.**
File without changes
@@ -0,0 +1,102 @@
1
+ from functools import cached_property
2
+
3
+ from plain.admin.toolbar import ToolbarPanel, register_toolbar_panel
4
+ from plain.admin.views import (
5
+ AdminModelDetailView,
6
+ AdminModelListView,
7
+ AdminViewset,
8
+ register_viewset,
9
+ )
10
+
11
+ from .core import Observer
12
+ from .models import Span, Trace
13
+
14
+
15
+ @register_viewset
16
+ class TraceViewset(AdminViewset):
17
+ class ListView(AdminModelListView):
18
+ nav_section = "Observer"
19
+ model = Trace
20
+ fields = [
21
+ "trace_id",
22
+ "request_id",
23
+ "session_id",
24
+ "user_id",
25
+ "start_time",
26
+ ]
27
+ allow_global_search = False
28
+ # Actually want a button to delete ALL! not possible yet
29
+ # actions = ["Delete"]
30
+
31
+ # def perform_action(self, action: str, target_pks: list):
32
+ # if action == "Delete":
33
+ # Trace.objects.filter(id__in=target_pks).delete()
34
+
35
+ class DetailView(AdminModelDetailView):
36
+ model = Trace
37
+ template_name = "admin/observer/trace_detail.html"
38
+
39
+ def get_template_context(self):
40
+ context = super().get_template_context()
41
+ trace_id = self.url_kwargs["pk"]
42
+ context["trace"] = Trace.objects.get(pk=trace_id)
43
+ context["show_delete_button"] = False
44
+ return context
45
+
46
+
47
+ @register_viewset
48
+ class SpanViewset(AdminViewset):
49
+ class ListView(AdminModelListView):
50
+ nav_section = "Observer"
51
+ model = Span
52
+ fields = [
53
+ "name",
54
+ "kind",
55
+ "status",
56
+ "span_id",
57
+ "parent_id",
58
+ "start_time",
59
+ ]
60
+ queryset_order = ["-pk"]
61
+ allow_global_search = False
62
+ displays = ["Parents only"]
63
+ search_fields = ["name", "span_id", "parent_id"]
64
+
65
+ def get_objects(self):
66
+ return (
67
+ super()
68
+ .get_objects()
69
+ .only(
70
+ "name",
71
+ "kind",
72
+ "span_id",
73
+ "parent_id",
74
+ "start_time",
75
+ )
76
+ )
77
+
78
+ def get_initial_queryset(self):
79
+ queryset = super().get_initial_queryset()
80
+ if self.display == "Parents only":
81
+ queryset = queryset.filter(parent_id="")
82
+ return queryset
83
+
84
+ class DetailView(AdminModelDetailView):
85
+ model = Span
86
+
87
+
88
+ @register_toolbar_panel
89
+ class ObserverToolbarPanel(ToolbarPanel):
90
+ name = "Observer"
91
+ template_name = "toolbar/observer.html"
92
+ button_template_name = "toolbar/observer_button.html"
93
+
94
+ @cached_property
95
+ def observer(self):
96
+ """Get the Observer instance for this request."""
97
+ return Observer(self.request)
98
+
99
+ def get_template_context(self):
100
+ context = super().get_template_context()
101
+ context["observer"] = self.observer
102
+ return context
plain/observer/cli.py ADDED
@@ -0,0 +1,23 @@
1
+ import click
2
+
3
+ from plain.cli import register_cli
4
+ from plain.observer.models import Trace
5
+
6
+
7
+ @register_cli("observer")
8
+ @click.group("observer")
9
+ def observer_cli():
10
+ pass
11
+
12
+
13
+ @observer_cli.command()
14
+ @click.option("--force", is_flag=True, help="Skip confirmation prompt.")
15
+ def clear(force: bool):
16
+ """Clear all observer data."""
17
+ if not force:
18
+ click.confirm(
19
+ "Are you sure you want to clear all observer data? This cannot be undone.",
20
+ abort=True,
21
+ )
22
+
23
+ print("Deleted", Trace.objects.all().delete())
@@ -0,0 +1,36 @@
1
+ from opentelemetry import trace
2
+ from opentelemetry.sdk.trace import TracerProvider
3
+
4
+ from plain.packages import PackageConfig, register_config
5
+
6
+ from .otel import ObserverSampler, ObserverSpanProcessor
7
+
8
+
9
+ @register_config
10
+ class Config(PackageConfig):
11
+ package_label = "plainobserver"
12
+
13
+ def ready(self):
14
+ if self.has_existing_trace_provider():
15
+ return
16
+
17
+ self.setup_observer()
18
+
19
+ @staticmethod
20
+ def has_existing_trace_provider() -> bool:
21
+ """Check if there is an existing trace provider."""
22
+ current_provider = trace.get_tracer_provider()
23
+ return current_provider and not isinstance(
24
+ current_provider, trace.ProxyTracerProvider
25
+ )
26
+
27
+ @staticmethod
28
+ def setup_observer() -> None:
29
+ sampler = ObserverSampler()
30
+ provider = TracerProvider(sampler=sampler)
31
+
32
+ # Add our combined processor that handles both memory storage and export
33
+ observer_processor = ObserverSpanProcessor()
34
+ provider.add_span_processor(observer_processor)
35
+
36
+ trace.set_tracer_provider(provider)
plain/observer/core.py ADDED
@@ -0,0 +1,63 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ObserverMode(Enum):
5
+ """Observer operation modes."""
6
+
7
+ SUMMARY = "summary" # Real-time monitoring only, no DB export
8
+ PERSIST = "persist" # Real-time monitoring + DB export
9
+ DISABLED = "disabled" # Observer explicitly disabled
10
+
11
+
12
+ class Observer:
13
+ """Central class for managing observer state and operations."""
14
+
15
+ COOKIE_NAME = "observer"
16
+ COOKIE_DURATION = 60 * 60 * 24 # 1 day in seconds
17
+
18
+ def __init__(self, request):
19
+ self.request = request
20
+
21
+ def mode(self):
22
+ """Get the current observer mode from signed cookie."""
23
+ return self.request.get_signed_cookie(self.COOKIE_NAME, default=None)
24
+
25
+ def is_enabled(self):
26
+ """Check if observer is enabled (either summary or persist mode)."""
27
+ return self.mode() in (ObserverMode.SUMMARY.value, ObserverMode.PERSIST.value)
28
+
29
+ def is_persisting(self):
30
+ """Check if full persisting (with DB export) is enabled."""
31
+ return self.mode() == ObserverMode.PERSIST.value
32
+
33
+ def is_summarizing(self):
34
+ """Check if summary mode is enabled."""
35
+ return self.mode() == ObserverMode.SUMMARY.value
36
+
37
+ def is_disabled(self):
38
+ """Check if observer is explicitly disabled."""
39
+ return self.mode() == ObserverMode.DISABLED.value
40
+
41
+ def enable_summary_mode(self, response):
42
+ """Enable summary mode (real-time monitoring, no DB export)."""
43
+ response.set_signed_cookie(
44
+ self.COOKIE_NAME, ObserverMode.SUMMARY.value, max_age=self.COOKIE_DURATION
45
+ )
46
+
47
+ def enable_persist_mode(self, response):
48
+ """Enable full persist mode (real-time monitoring + DB export)."""
49
+ response.set_signed_cookie(
50
+ self.COOKIE_NAME, ObserverMode.PERSIST.value, max_age=self.COOKIE_DURATION
51
+ )
52
+
53
+ def disable(self, response):
54
+ """Disable observer by setting cookie to disabled."""
55
+ response.set_signed_cookie(
56
+ self.COOKIE_NAME, ObserverMode.DISABLED.value, max_age=self.COOKIE_DURATION
57
+ )
58
+
59
+ def get_current_trace_summary(self):
60
+ """Get performance summary string for the currently active trace."""
61
+ from .otel import get_current_trace_summary
62
+
63
+ return get_current_trace_summary()
@@ -0,0 +1,9 @@
1
+ OBSERVER_IGNORE_URL_PATTERNS: list[str] = [
2
+ "/admin/.*",
3
+ "/assets/.*",
4
+ "/observer/.*",
5
+ "/pageviews/.*",
6
+ "/favicon.ico",
7
+ "/.well-known/.*",
8
+ ]
9
+ OBSERVER_TRACE_LIMIT: int = 100
@@ -0,0 +1,96 @@
1
+ # Generated by Plain 0.52.2 on 2025-07-17 02:27
2
+
3
+ import plain.models.deletion
4
+ from plain import models
5
+ from plain.models import migrations
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ initial = True
10
+
11
+ dependencies = []
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name="Trace",
16
+ fields=[
17
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
18
+ ("trace_id", models.CharField(max_length=255)),
19
+ ("start_time", models.DateTimeField()),
20
+ ("end_time", models.DateTimeField()),
21
+ ("root_span_name", models.TextField(default="", required=False)),
22
+ (
23
+ "request_id",
24
+ models.CharField(default="", max_length=255, required=False),
25
+ ),
26
+ (
27
+ "session_id",
28
+ models.CharField(default="", max_length=255, required=False),
29
+ ),
30
+ (
31
+ "user_id",
32
+ models.CharField(default="", max_length=255, required=False),
33
+ ),
34
+ ],
35
+ options={
36
+ "ordering": ["-start_time"],
37
+ },
38
+ ),
39
+ migrations.CreateModel(
40
+ name="Span",
41
+ fields=[
42
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
43
+ ("span_id", models.CharField(max_length=255)),
44
+ ("name", models.CharField(max_length=255)),
45
+ ("kind", models.CharField(max_length=50)),
46
+ (
47
+ "parent_id",
48
+ models.CharField(default="", max_length=255, required=False),
49
+ ),
50
+ ("start_time", models.DateTimeField()),
51
+ ("end_time", models.DateTimeField()),
52
+ ("status", models.CharField(default="", max_length=50, required=False)),
53
+ ("span_data", models.JSONField(default=dict, required=False)),
54
+ ],
55
+ options={
56
+ "ordering": ["-start_time"],
57
+ },
58
+ ),
59
+ migrations.AddConstraint(
60
+ model_name="trace",
61
+ constraint=models.UniqueConstraint(
62
+ fields=("trace_id",), name="observer_unique_trace_id"
63
+ ),
64
+ ),
65
+ migrations.AddField(
66
+ model_name="span",
67
+ name="trace",
68
+ field=models.ForeignKey(
69
+ on_delete=plain.models.deletion.CASCADE,
70
+ related_name="spans",
71
+ to="plainobserver.trace",
72
+ ),
73
+ ),
74
+ migrations.AddIndex(
75
+ model_name="span",
76
+ index=models.Index(
77
+ fields=["trace", "span_id"], name="plainobserv_trace_i_89a97c_idx"
78
+ ),
79
+ ),
80
+ migrations.AddIndex(
81
+ model_name="span",
82
+ index=models.Index(fields=["trace"], name="plainobserv_trace_i_84958a_idx"),
83
+ ),
84
+ migrations.AddIndex(
85
+ model_name="span",
86
+ index=models.Index(
87
+ fields=["start_time"], name="plainobserv_start_t_cb47a3_idx"
88
+ ),
89
+ ),
90
+ migrations.AddConstraint(
91
+ model_name="span",
92
+ constraint=models.UniqueConstraint(
93
+ fields=("trace", "span_id"), name="observer_unique_span_id"
94
+ ),
95
+ ),
96
+ ]
File without changes