plain.observer 0.0.0__py3-none-any.whl → 0.2.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 +29 -0
- plain/observer/admin.py +0 -8
- plain/observer/cli.py +558 -5
- plain/observer/config.py +28 -20
- 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 +63 -2
- 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.0.0.dist-info → plain_observer-0.2.0.dist-info}/METADATA +1 -1
- plain_observer-0.2.0.dist-info/RECORD +25 -0
- plain/observer/templates/admin/observer/trace_detail.html +0 -10
- plain_observer-0.0.0.dist-info/RECORD +0 -23
- {plain_observer-0.0.0.dist-info → plain_observer-0.2.0.dist-info}/WHEEL +0 -0
- {plain_observer-0.0.0.dist-info → plain_observer-0.2.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
|
@@ -18,7 +18,7 @@ from .core import Observer, ObserverMode
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def
|
|
21
|
+
def get_observer_span_processor():
|
|
22
22
|
"""Get the span collector instance from the tracer provider."""
|
|
23
23
|
if not (current_provider := trace.get_tracer_provider()):
|
|
24
24
|
return None
|
|
@@ -41,10 +41,13 @@ def get_current_trace_summary() -> str | None:
|
|
|
41
41
|
if not (current_span := trace.get_current_span()):
|
|
42
42
|
return None
|
|
43
43
|
|
|
44
|
-
if not (processor :=
|
|
44
|
+
if not (processor := get_observer_span_processor()):
|
|
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
|
|
|
@@ -126,6 +129,50 @@ class ObserverSampler(sampling.Sampler):
|
|
|
126
129
|
return "ObserverSampler"
|
|
127
130
|
|
|
128
131
|
|
|
132
|
+
class ObserverCombinedSampler(sampling.Sampler):
|
|
133
|
+
"""Combine another sampler with ``ObserverSampler``."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, primary: sampling.Sampler, secondary: sampling.Sampler):
|
|
136
|
+
self.primary = primary
|
|
137
|
+
self.secondary = secondary
|
|
138
|
+
|
|
139
|
+
def should_sample(
|
|
140
|
+
self,
|
|
141
|
+
parent_context,
|
|
142
|
+
trace_id,
|
|
143
|
+
name,
|
|
144
|
+
kind: SpanKind | None = None,
|
|
145
|
+
attributes=None,
|
|
146
|
+
links=None,
|
|
147
|
+
trace_state=None,
|
|
148
|
+
):
|
|
149
|
+
result = self.primary.should_sample(
|
|
150
|
+
parent_context,
|
|
151
|
+
trace_id,
|
|
152
|
+
name,
|
|
153
|
+
kind=kind,
|
|
154
|
+
attributes=attributes,
|
|
155
|
+
links=links,
|
|
156
|
+
trace_state=trace_state,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if result.decision is sampling.Decision.DROP:
|
|
160
|
+
return self.secondary.should_sample(
|
|
161
|
+
parent_context,
|
|
162
|
+
trace_id,
|
|
163
|
+
name,
|
|
164
|
+
kind=kind,
|
|
165
|
+
attributes=attributes,
|
|
166
|
+
links=links,
|
|
167
|
+
trace_state=trace_state,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def get_description(self) -> str:
|
|
173
|
+
return f"ObserverCombinedSampler({self.primary.get_description()}, {self.secondary.get_description()})"
|
|
174
|
+
|
|
175
|
+
|
|
129
176
|
class ObserverSpanProcessor(SpanProcessor):
|
|
130
177
|
"""Collects spans in real-time for current trace performance monitoring.
|
|
131
178
|
|
|
@@ -148,6 +195,9 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
148
195
|
}
|
|
149
196
|
)
|
|
150
197
|
self._traces_lock = threading.Lock()
|
|
198
|
+
self._ignore_url_paths = [
|
|
199
|
+
re.compile(p) for p in settings.OBSERVER_IGNORE_URL_PATTERNS
|
|
200
|
+
]
|
|
151
201
|
|
|
152
202
|
def on_start(self, span, parent_context=None):
|
|
153
203
|
"""Called when a span starts."""
|
|
@@ -219,6 +269,10 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
219
269
|
len(trace_info["span_models"]),
|
|
220
270
|
trace_id,
|
|
221
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
|
+
)
|
|
222
276
|
self._export_trace(trace_info["trace"], trace_info["span_models"])
|
|
223
277
|
|
|
224
278
|
# Clean up trace
|
|
@@ -295,6 +349,13 @@ class ObserverSpanProcessor(SpanProcessor):
|
|
|
295
349
|
)
|
|
296
350
|
|
|
297
351
|
def _get_recording_mode(self, span, parent_context) -> str | None:
|
|
352
|
+
# Again check the span attributes, in case we relied on another sampler
|
|
353
|
+
if span.attributes:
|
|
354
|
+
if url_path := span.attributes.get(url_attributes.URL_PATH, ""):
|
|
355
|
+
for pattern in self._ignore_url_paths:
|
|
356
|
+
if pattern.match(url_path):
|
|
357
|
+
return None
|
|
358
|
+
|
|
298
359
|
# If the span has links, then we are going to export if the linked span is also exported
|
|
299
360
|
for link in span.links:
|
|
300
361
|
if link.context.is_valid and link.context.span_id:
|