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.

@@ -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=None):
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 get_annotated_spans(self):
150
- """Return spans with annotations and nesting information."""
151
- spans = list(self.spans.all().order_by("start_time"))
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 get_span_processor():
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 := get_span_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: