plain.observer 0.0.0__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.

@@ -0,0 +1,19 @@
1
+ .venv
2
+ .env
3
+ *.egg-info
4
+ *.py[co]
5
+ __pycache__
6
+ *.DS_Store
7
+
8
+ # Test apps
9
+ plain*/tests/.plain
10
+
11
+ # Ottobot
12
+ .aider*
13
+
14
+ /llms-full.txt
15
+
16
+ # Plain temp dirs
17
+ .plain
18
+
19
+ .vscode
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Dropseed, LLC
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.observer
3
+ Version: 0.0.0
4
+ Summary: On-page telemetry and observability tools for Plain.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ License-Expression: BSD-3-Clause
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: opentelemetry-sdk>=1.34.1
10
+ Requires-Dist: plain-admin<1.0.0
11
+ Requires-Dist: plain<1.0.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # plain.observer
15
+
16
+ **Monitor.**
@@ -0,0 +1 @@
1
+ plain/observer/README.md
@@ -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
@@ -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)
@@ -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
+ ]