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.
- plain_observer-0.0.0/.gitignore +19 -0
- plain_observer-0.0.0/LICENSE +28 -0
- plain_observer-0.0.0/PKG-INFO +16 -0
- plain_observer-0.0.0/README.md +1 -0
- plain_observer-0.0.0/plain/observer/CHANGELOG.md +1 -0
- plain_observer-0.0.0/plain/observer/README.md +3 -0
- plain_observer-0.0.0/plain/observer/__init__.py +0 -0
- plain_observer-0.0.0/plain/observer/admin.py +102 -0
- plain_observer-0.0.0/plain/observer/cli.py +23 -0
- plain_observer-0.0.0/plain/observer/config.py +36 -0
- plain_observer-0.0.0/plain/observer/core.py +63 -0
- plain_observer-0.0.0/plain/observer/default_settings.py +9 -0
- plain_observer-0.0.0/plain/observer/migrations/0001_initial.py +96 -0
- plain_observer-0.0.0/plain/observer/migrations/__init__.py +0 -0
- plain_observer-0.0.0/plain/observer/models.py +355 -0
- plain_observer-0.0.0/plain/observer/otel.py +335 -0
- plain_observer-0.0.0/plain/observer/templates/admin/observer/trace_detail.html +10 -0
- plain_observer-0.0.0/plain/observer/templates/observer/_trace_detail.html +364 -0
- plain_observer-0.0.0/plain/observer/templates/observer/traces.html +288 -0
- plain_observer-0.0.0/plain/observer/templates/toolbar/observer.html +42 -0
- plain_observer-0.0.0/plain/observer/templates/toolbar/observer_button.html +45 -0
- plain_observer-0.0.0/plain/observer/urls.py +10 -0
- plain_observer-0.0.0/plain/observer/views.py +105 -0
- plain_observer-0.0.0/pyproject.toml +25 -0
|
@@ -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
|
|
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,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
|