plain.observer 0.1.0__py3-none-any.whl → 0.3.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.
- plain/observer/CHANGELOG.md +26 -0
- plain/observer/admin.py +3 -11
- plain/observer/cli.py +558 -5
- plain/observer/migrations/0001_initial.py +2 -2
- plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py +58 -0
- plain/observer/models.py +56 -27
- plain/observer/otel.py +7 -0
- plain/observer/templates/observer/{_trace_detail.html → trace.html} +155 -98
- plain/observer/templates/observer/trace_detail.html +24 -0
- plain/observer/templates/observer/trace_share.html +19 -0
- plain/observer/templates/observer/traces.html +161 -135
- plain/observer/templates/toolbar/observer_button.html +27 -43
- plain/observer/urls.py +3 -1
- plain/observer/views.py +90 -56
- {plain_observer-0.1.0.dist-info → plain_observer-0.3.0.dist-info}/METADATA +1 -1
- plain_observer-0.3.0.dist-info/RECORD +25 -0
- plain/observer/templates/admin/observer/trace_detail.html +0 -10
- plain_observer-0.1.0.dist-info/RECORD +0 -23
- {plain_observer-0.1.0.dist-info → plain_observer-0.3.0.dist-info}/WHEEL +0 -0
- {plain_observer-0.1.0.dist-info → plain_observer-0.3.0.dist-info}/licenses/LICENSE +0 -0
plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Generated by Plain 0.54.1 on 2025-07-21 16:01
|
|
2
|
+
|
|
3
|
+
from plain import models
|
|
4
|
+
from plain.models import migrations
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
dependencies = [
|
|
9
|
+
("plainobserver", "0001_initial"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name="trace",
|
|
15
|
+
name="share_created_at",
|
|
16
|
+
field=models.DateTimeField(allow_null=True, required=False),
|
|
17
|
+
),
|
|
18
|
+
migrations.AddField(
|
|
19
|
+
model_name="trace",
|
|
20
|
+
name="share_id",
|
|
21
|
+
field=models.CharField(default="", max_length=32, required=False),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name="trace",
|
|
25
|
+
name="summary",
|
|
26
|
+
field=models.CharField(default="", max_length=255, required=False),
|
|
27
|
+
),
|
|
28
|
+
migrations.AddIndex(
|
|
29
|
+
model_name="trace",
|
|
30
|
+
index=models.Index(
|
|
31
|
+
fields=["trace_id"], name="plainobserv_trace_i_075b48_idx"
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
migrations.AddIndex(
|
|
35
|
+
model_name="trace",
|
|
36
|
+
index=models.Index(
|
|
37
|
+
fields=["start_time"], name="plainobserv_start_t_636c80_idx"
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
migrations.AddIndex(
|
|
41
|
+
model_name="trace",
|
|
42
|
+
index=models.Index(
|
|
43
|
+
fields=["request_id"], name="plainobserv_request_d1d5b2_idx"
|
|
44
|
+
),
|
|
45
|
+
),
|
|
46
|
+
migrations.AddIndex(
|
|
47
|
+
model_name="trace",
|
|
48
|
+
index=models.Index(
|
|
49
|
+
fields=["share_id"], name="plainobserv_share_i_754f3c_idx"
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
migrations.AddIndex(
|
|
53
|
+
model_name="trace",
|
|
54
|
+
index=models.Index(
|
|
55
|
+
fields=["session_id"], name="plainobserv_session_350f42_idx"
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
]
|
plain/observer/models.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import secrets
|
|
2
3
|
from datetime import UTC, datetime
|
|
3
4
|
from functools import cached_property
|
|
4
5
|
|
|
@@ -15,6 +16,8 @@ from opentelemetry.semconv.attributes import db_attributes
|
|
|
15
16
|
from opentelemetry.trace import format_trace_id
|
|
16
17
|
|
|
17
18
|
from plain import models
|
|
19
|
+
from plain.urls import reverse
|
|
20
|
+
from plain.utils import timezone
|
|
18
21
|
|
|
19
22
|
|
|
20
23
|
@models.register_model
|
|
@@ -24,12 +27,17 @@ class Trace(models.Model):
|
|
|
24
27
|
end_time = models.DateTimeField()
|
|
25
28
|
|
|
26
29
|
root_span_name = models.TextField(default="", required=False)
|
|
30
|
+
summary = models.CharField(max_length=255, default="", required=False)
|
|
27
31
|
|
|
28
32
|
# Plain fields
|
|
29
33
|
request_id = models.CharField(max_length=255, default="", required=False)
|
|
30
34
|
session_id = models.CharField(max_length=255, default="", required=False)
|
|
31
35
|
user_id = models.CharField(max_length=255, default="", required=False)
|
|
32
36
|
|
|
37
|
+
# Shareable URL fields
|
|
38
|
+
share_id = models.CharField(max_length=32, default="", required=False)
|
|
39
|
+
share_created_at = models.DateTimeField(allow_null=True, required=False)
|
|
40
|
+
|
|
33
41
|
class Meta:
|
|
34
42
|
ordering = ["-start_time"]
|
|
35
43
|
constraints = [
|
|
@@ -38,25 +46,43 @@ class Trace(models.Model):
|
|
|
38
46
|
name="observer_unique_trace_id",
|
|
39
47
|
)
|
|
40
48
|
]
|
|
49
|
+
indexes = [
|
|
50
|
+
models.Index(fields=["trace_id"]),
|
|
51
|
+
models.Index(fields=["start_time"]),
|
|
52
|
+
models.Index(fields=["request_id"]),
|
|
53
|
+
models.Index(fields=["share_id"]),
|
|
54
|
+
models.Index(fields=["session_id"]),
|
|
55
|
+
]
|
|
41
56
|
|
|
42
57
|
def __str__(self):
|
|
43
58
|
return self.trace_id
|
|
44
59
|
|
|
60
|
+
def get_absolute_url(self):
|
|
61
|
+
"""Return the canonical URL for this trace."""
|
|
62
|
+
return reverse("observer:trace_detail", trace_id=self.trace_id)
|
|
63
|
+
|
|
64
|
+
def generate_share_id(self):
|
|
65
|
+
"""Generate a unique share ID for this trace."""
|
|
66
|
+
self.share_id = secrets.token_urlsafe(24)
|
|
67
|
+
self.share_created_at = timezone.now()
|
|
68
|
+
self.save(update_fields=["share_id", "share_created_at"])
|
|
69
|
+
return self.share_id
|
|
70
|
+
|
|
71
|
+
def remove_share_id(self):
|
|
72
|
+
"""Remove the share ID from this trace."""
|
|
73
|
+
self.share_id = ""
|
|
74
|
+
self.share_created_at = None
|
|
75
|
+
self.save(update_fields=["share_id", "share_created_at"])
|
|
76
|
+
|
|
45
77
|
def duration_ms(self):
|
|
46
78
|
return (self.end_time - self.start_time).total_seconds() * 1000
|
|
47
79
|
|
|
48
|
-
def get_trace_summary(self, spans
|
|
80
|
+
def get_trace_summary(self, spans):
|
|
49
81
|
"""Get a concise summary string for toolbar display.
|
|
50
82
|
|
|
51
83
|
Args:
|
|
52
84
|
spans: Optional list of span objects. If not provided, will query from database.
|
|
53
85
|
"""
|
|
54
|
-
# Get spans from database if not provided
|
|
55
|
-
if spans is None:
|
|
56
|
-
spans = list(self.spans.all())
|
|
57
|
-
|
|
58
|
-
if not spans:
|
|
59
|
-
return ""
|
|
60
86
|
|
|
61
87
|
# Count database queries and track duplicates
|
|
62
88
|
query_counts = {}
|
|
@@ -132,9 +158,6 @@ class Trace(models.Model):
|
|
|
132
158
|
else None
|
|
133
159
|
)
|
|
134
160
|
|
|
135
|
-
# Create trace instance
|
|
136
|
-
# Note: end_time might be None if there are active spans
|
|
137
|
-
# This is OK since this trace is only used for summaries, not persistence
|
|
138
161
|
return cls(
|
|
139
162
|
trace_id=trace_id,
|
|
140
163
|
start_time=start_time,
|
|
@@ -146,9 +169,27 @@ class Trace(models.Model):
|
|
|
146
169
|
root_span_name=root_span.name if root_span else "",
|
|
147
170
|
)
|
|
148
171
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
172
|
+
def as_dict(self):
|
|
173
|
+
spans = [span.span_data for span in self.spans.all().order_by("start_time")]
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"trace_id": self.trace_id,
|
|
177
|
+
"start_time": self.start_time.isoformat(),
|
|
178
|
+
"end_time": self.end_time.isoformat(),
|
|
179
|
+
"duration_ms": self.duration_ms(),
|
|
180
|
+
"summary": self.summary,
|
|
181
|
+
"root_span_name": self.root_span_name,
|
|
182
|
+
"request_id": self.request_id,
|
|
183
|
+
"user_id": self.user_id,
|
|
184
|
+
"session_id": self.session_id,
|
|
185
|
+
"spans": spans,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SpanQuerySet(models.QuerySet):
|
|
190
|
+
def annotate_spans(self):
|
|
191
|
+
"""Annotate spans with nesting levels and duplicate query warnings."""
|
|
192
|
+
spans = list(self.order_by("start_time"))
|
|
152
193
|
|
|
153
194
|
# Build span dictionary for parent lookups
|
|
154
195
|
span_dict = {span.span_id: span for span in spans}
|
|
@@ -191,20 +232,6 @@ class Trace(models.Model):
|
|
|
191
232
|
|
|
192
233
|
return spans
|
|
193
234
|
|
|
194
|
-
def as_dict(self):
|
|
195
|
-
spans = [span.span_data for span in self.spans.all().order_by("start_time")]
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
"trace_id": self.trace_id,
|
|
199
|
-
"start_time": self.start_time.isoformat(),
|
|
200
|
-
"end_time": self.end_time.isoformat(),
|
|
201
|
-
"duration_ms": self.duration_ms(),
|
|
202
|
-
"request_id": self.request_id,
|
|
203
|
-
"user_id": self.user_id,
|
|
204
|
-
"session_id": self.session_id,
|
|
205
|
-
"spans": spans,
|
|
206
|
-
}
|
|
207
|
-
|
|
208
235
|
|
|
209
236
|
@models.register_model
|
|
210
237
|
class Span(models.Model):
|
|
@@ -220,6 +247,8 @@ class Span(models.Model):
|
|
|
220
247
|
status = models.CharField(max_length=50, default="", required=False)
|
|
221
248
|
span_data = models.JSONField(default=dict, required=False)
|
|
222
249
|
|
|
250
|
+
objects = SpanQuerySet.as_manager()
|
|
251
|
+
|
|
223
252
|
class Meta:
|
|
224
253
|
ordering = ["-start_time"]
|
|
225
254
|
constraints = [
|
plain/observer/otel.py
CHANGED
|
@@ -45,6 +45,9 @@ def get_current_trace_summary() -> str | None:
|
|
|
45
45
|
return None
|
|
46
46
|
|
|
47
47
|
trace_id = f"0x{format_trace_id(current_span.get_span_context().trace_id)}"
|
|
48
|
+
|
|
49
|
+
# Technically we're still in the trace... so the duration and stuff could shift slightly
|
|
50
|
+
# (though we should be at the end of the template, hopefully)
|
|
48
51
|
return processor.get_trace_summary(trace_id)
|
|
49
52
|
|
|
50
53
|
|
|
@@ -266,6 +269,10 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
266
269
|
len(trace_info["span_models"]),
|
|
267
270
|
trace_id,
|
|
268
271
|
)
|
|
272
|
+
# The trace is done now, so we can get a more accurate summary
|
|
273
|
+
trace_info["trace"].summary = trace_info["trace"].get_trace_summary(
|
|
274
|
+
trace_info["span_models"]
|
|
275
|
+
)
|
|
269
276
|
self._export_trace(trace_info["trace"], trace_info["span_models"])
|
|
270
277
|
|
|
271
278
|
# Clean up trace
|